mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 16:43:05 +02:00
- Fix underlying lint issues instead of suppressing with noqa - Files: moviepy_showcase, pomodoro-wake-daemon, brother_printer, http_status_anki, geo_data, repo_explorer, steam_backlog_enforcer, music_generator
1213 lines
39 KiB
Python
1213 lines
39 KiB
Python
"""MoviePy 2.x — Comprehensive Showcase of ALL Features.
|
|
|
|
Generates a video demonstrating every MoviePy class, method, effect,
|
|
and tool. Organised into sections:
|
|
|
|
Part 1: Clip Types (VideoClip, ColorClip, TextClip, ImageClip,
|
|
BitmapClip, DataVideoClip, ImageSequenceClip)
|
|
Part 2: Clip Methods (subclipped, cropped, resized, rotated, with_position,
|
|
with_opacity, with_mask, image_transform, transform,
|
|
time_transform, with_speed_scaled, with_section_cut_out,
|
|
to_ImageClip, to_mask, to_RGB, with_background_color,
|
|
with_effects_on_subclip, with_layer_index)
|
|
Part 3: Video Effects (all 34)
|
|
Part 4: Audio (AudioClip, AudioArrayClip, CompositeAudioClip,
|
|
all 7 audio effects)
|
|
Part 5: Composition (CompositeVideoClip, concatenate_videoclips, clips_array)
|
|
Part 6: Drawing Tools (circle, color_gradient, color_split)
|
|
Part 7: Output (write_videofile params, write_gif, save_frame,
|
|
write_images_sequence)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import tempfile
|
|
from typing import TYPE_CHECKING
|
|
|
|
from moviepy import (
|
|
AudioArrayClip,
|
|
AudioClip,
|
|
BitmapClip,
|
|
ColorClip,
|
|
CompositeAudioClip,
|
|
CompositeVideoClip,
|
|
DataVideoClip,
|
|
ImageClip,
|
|
ImageSequenceClip,
|
|
TextClip,
|
|
VideoClip,
|
|
VideoFileClip,
|
|
concatenate_audioclips,
|
|
concatenate_videoclips,
|
|
)
|
|
from moviepy.audio.fx import (
|
|
AudioDelay,
|
|
AudioFadeIn,
|
|
AudioFadeOut,
|
|
AudioLoop,
|
|
AudioNormalize,
|
|
MultiplyStereoVolume,
|
|
MultiplyVolume,
|
|
)
|
|
from moviepy.video.compositing.CompositeVideoClip import (
|
|
clips_array,
|
|
)
|
|
from moviepy.video.fx import (
|
|
AccelDecel,
|
|
BlackAndWhite,
|
|
Blink,
|
|
Crop,
|
|
CrossFadeIn,
|
|
CrossFadeOut,
|
|
EvenSize,
|
|
FadeIn,
|
|
FadeOut,
|
|
Freeze,
|
|
FreezeRegion,
|
|
GammaCorrection,
|
|
HeadBlur,
|
|
InvertColors,
|
|
Loop,
|
|
LumContrast,
|
|
MakeLoopable,
|
|
Margin,
|
|
MaskColor,
|
|
MirrorX,
|
|
MirrorY,
|
|
MultiplyColor,
|
|
MultiplySpeed,
|
|
Painting,
|
|
Resize,
|
|
Rotate,
|
|
Scroll,
|
|
SlideIn,
|
|
SlideOut,
|
|
SuperSample,
|
|
TimeMirror,
|
|
TimeSymmetrize,
|
|
)
|
|
from moviepy.video.tools.drawing import (
|
|
circle,
|
|
color_gradient,
|
|
color_split,
|
|
)
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg"
|
|
|
|
# ── Constants ─────────────────────────────────────────────────────
|
|
W, H = 1920, 1080
|
|
FPS = 30
|
|
CLIP_DUR = 2.0 # duration of each demo clip
|
|
HEADER_DUR = 1.5 # duration of section headers
|
|
OUTPUT = "moviepy_showcase_full.mp4"
|
|
FONT_B = "/usr/share/fonts/noto/NotoSans-Bold.ttf"
|
|
FONT_R = "/usr/share/fonts/noto/NotoSans-Regular.ttf"
|
|
|
|
# ── Pre-computed gradient LUTs ────────────────────────────────────
|
|
_G_CH = (
|
|
np.linspace(0, 255, H, dtype=np.uint8)[:, None]
|
|
* np.ones(W, dtype=np.uint8)[None, :]
|
|
)
|
|
_B_CH = (
|
|
np.ones(H, dtype=np.uint8)[:, None]
|
|
* np.linspace(0, 255, W, dtype=np.uint8)[None, :]
|
|
)
|
|
|
|
|
|
def _gradient(t: float) -> np.ndarray:
|
|
f = np.empty((H, W, 3), dtype=np.uint8)
|
|
f[:, :, 0] = int(128 + 127 * np.sin(t * 2))
|
|
f[:, :, 1] = _G_CH
|
|
f[:, :, 2] = _B_CH
|
|
return f
|
|
|
|
|
|
def _checkerboard(t: float) -> np.ndarray:
|
|
sq = 60
|
|
off = int(t * 40) % sq
|
|
xs = np.arange(W, dtype=np.int32)[None, :]
|
|
ys = np.arange(H, dtype=np.int32)[:, None]
|
|
v = (((xs + off) // sq + (ys + off) // sq) % 2 * 255).astype(np.uint8)
|
|
return np.dstack([v, v, v])
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
def _base_clip(dur: float = CLIP_DUR) -> VideoClip:
|
|
"""Animated gradient as a reusable base clip."""
|
|
return VideoClip(_gradient, duration=dur).with_fps(FPS)
|
|
|
|
|
|
def _label(
|
|
text: str,
|
|
size: int = 36,
|
|
color: str = "white",
|
|
pos: tuple[str, int] | tuple[str, str] = ("center", 40),
|
|
dur: float = CLIP_DUR,
|
|
) -> TextClip:
|
|
"""Small label overlay (transparent bg)."""
|
|
return (
|
|
TextClip(
|
|
text=text,
|
|
font_size=size,
|
|
color=color,
|
|
font=FONT_R,
|
|
margin=(0, 15),
|
|
)
|
|
.with_duration(dur)
|
|
.with_position(pos)
|
|
)
|
|
|
|
|
|
def _titled(clip: VideoClip, text: str) -> CompositeVideoClip:
|
|
"""Overlay a label onto a clip."""
|
|
lbl = _label(text, dur=clip.duration)
|
|
return CompositeVideoClip(
|
|
[clip.with_duration(clip.duration), lbl],
|
|
size=(W, H),
|
|
)
|
|
|
|
|
|
def _section_header(title: str, subtitle: str = "") -> CompositeVideoClip:
|
|
"""Dark background with centred title text."""
|
|
bg = ColorClip(size=(W, H), color=(15, 15, 40)).with_duration(HEADER_DUR)
|
|
t = (
|
|
TextClip(
|
|
text=title,
|
|
font_size=72,
|
|
color="white",
|
|
font=FONT_B,
|
|
margin=(0, 30),
|
|
)
|
|
.with_duration(HEADER_DUR)
|
|
.with_position(("center", 380))
|
|
)
|
|
parts: list[VideoClip] = [bg, t]
|
|
if subtitle:
|
|
s = (
|
|
TextClip(
|
|
text=subtitle,
|
|
font_size=32,
|
|
color="#aaaaaa",
|
|
font=FONT_R,
|
|
margin=(0, 15),
|
|
)
|
|
.with_duration(HEADER_DUR)
|
|
.with_position(("center", 520))
|
|
)
|
|
parts.append(s)
|
|
return CompositeVideoClip(parts, size=(W, H))
|
|
|
|
|
|
def _resize_to_canvas(clip: VideoClip) -> VideoClip:
|
|
"""Resize a clip to fit (W, H) and centre on black background."""
|
|
cw, ch = clip.size
|
|
scale = min(W / cw, H / ch)
|
|
return clip.resized(
|
|
width=int(cw * scale), height=int(ch * scale)
|
|
).with_background_color(size=(W, H), color=(0, 0, 0))
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# PART 1 — Clip Types
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def part1_clip_types() -> list[VideoClip]:
|
|
"""Demonstrate every clip creation class."""
|
|
scenes: list[VideoClip] = [
|
|
_section_header(
|
|
"Part 1: Clip Types",
|
|
"VideoClip · ColorClip · TextClip · ImageClip"
|
|
" · BitmapClip · DataVideoClip · ImageSequenceClip",
|
|
),
|
|
]
|
|
|
|
# 1. VideoClip — custom frame function
|
|
vc = VideoClip(_gradient, duration=CLIP_DUR).with_fps(FPS)
|
|
scenes.append(_titled(vc, "VideoClip(frame_function)"))
|
|
|
|
# 2. ColorClip
|
|
cc = ColorClip(size=(W, H), color=(0, 120, 200)).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(cc, "ColorClip(size, color)"))
|
|
|
|
# 3. TextClip — label method
|
|
tbg = ColorClip(size=(W, H), color=(20, 20, 50)).with_duration(CLIP_DUR)
|
|
tc = (
|
|
TextClip(
|
|
text="Hello MoviePy!",
|
|
font_size=96,
|
|
color="yellow",
|
|
font=FONT_B,
|
|
stroke_color="black",
|
|
stroke_width=3,
|
|
bg_color=None,
|
|
margin=(10, 30),
|
|
method="label",
|
|
horizontal_align="center",
|
|
vertical_align="center",
|
|
transparent=True,
|
|
)
|
|
.with_duration(CLIP_DUR)
|
|
.with_position("center")
|
|
)
|
|
scenes.append(
|
|
_titled(
|
|
CompositeVideoClip([tbg, tc], size=(W, H)),
|
|
"TextClip(text, font_size, color, stroke, margin, method='label')",
|
|
)
|
|
)
|
|
|
|
# 4. TextClip — caption method (wraps text)
|
|
tbg2 = ColorClip(size=(W, H), color=(50, 20, 20)).with_duration(CLIP_DUR)
|
|
tc2 = (
|
|
TextClip(
|
|
text="This is a longer caption that wraps "
|
|
"because we use method='caption' with a fixed size.",
|
|
font_size=48,
|
|
color="white",
|
|
font=FONT_R,
|
|
method="caption",
|
|
size=(W - 200, None),
|
|
text_align="center",
|
|
interline=10,
|
|
margin=(0, 20),
|
|
)
|
|
.with_duration(CLIP_DUR)
|
|
.with_position("center")
|
|
)
|
|
scenes.append(
|
|
_titled(
|
|
CompositeVideoClip([tbg2, tc2], size=(W, H)),
|
|
"TextClip(method='caption', text_align, interline, size)",
|
|
)
|
|
)
|
|
|
|
# 5. ImageClip — from numpy array
|
|
img = np.zeros((H, W, 3), dtype=np.uint8)
|
|
img[200:880, 400:1520] = [255, 100, 50] # orange rectangle
|
|
ic = ImageClip(img, duration=CLIP_DUR)
|
|
scenes.append(_titled(ic, "ImageClip(numpy_array)"))
|
|
|
|
# 6. BitmapClip — from ASCII-art frames
|
|
frames = [
|
|
["RR__", "RR__", "__BB", "__BB"],
|
|
["__RR", "__RR", "BB__", "BB__"],
|
|
["RR__", "RR__", "__BB", "__BB"],
|
|
["__RR", "__RR", "BB__", "BB__"],
|
|
]
|
|
bc = BitmapClip(
|
|
frames,
|
|
fps=2,
|
|
color_dict={"R": (255, 0, 0), "B": (0, 0, 255), "_": (30, 30, 30)},
|
|
)
|
|
bc = _resize_to_canvas(bc)
|
|
scenes.append(
|
|
_titled(bc.with_duration(CLIP_DUR), "BitmapClip(bitmap_frames, color_dict)")
|
|
)
|
|
|
|
# 7. DataVideoClip — data-driven frames
|
|
data_list = list(range(60))
|
|
|
|
def data_to_frame(d: int) -> np.ndarray:
|
|
frame = np.full((H, W, 3), 30, dtype=np.uint8)
|
|
bar_w = int(d / 60 * (W - 100))
|
|
frame[400:680, 50 : 50 + bar_w] = [0, 200, 100]
|
|
return frame
|
|
|
|
dvc = DataVideoClip(data_list, data_to_frame, fps=FPS).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(dvc, "DataVideoClip(data, data_to_frame)"))
|
|
|
|
# 8. ImageSequenceClip — from a list of arrays
|
|
seq_frames = []
|
|
for i in range(10):
|
|
f = np.full((H, W, 3), int(25 * i), dtype=np.uint8)
|
|
f[:, :, 0] = int(255 - 25 * i)
|
|
seq_frames.append(f)
|
|
isc = ImageSequenceClip(seq_frames, fps=5).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(isc, "ImageSequenceClip(sequence, fps)"))
|
|
|
|
return scenes
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# PART 2 — Clip Methods
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def part2_clip_methods() -> list[VideoClip]:
|
|
"""Demonstrate VideoClip methods."""
|
|
scenes: list[VideoClip] = [
|
|
_section_header(
|
|
"Part 2: Clip Methods",
|
|
"subclipped · cropped · resized · rotated"
|
|
" · with_position · with_opacity · …",
|
|
),
|
|
]
|
|
|
|
base = _base_clip(3.0)
|
|
|
|
# subclipped
|
|
sc = base.subclipped(0.5, 2.5)
|
|
scenes.append(
|
|
_titled(_resize_to_canvas(sc), "subclipped(start_time=0.5, end_time=2.5)")
|
|
)
|
|
|
|
# cropped
|
|
cr = base.cropped(x1=200, y1=100, x2=1200, y2=700).with_duration(CLIP_DUR)
|
|
scenes.append(
|
|
_titled(_resize_to_canvas(cr), "cropped(x1=200, y1=100, x2=1200, y2=700)")
|
|
)
|
|
|
|
# resized — by factor
|
|
rs1 = base.resized(0.5).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(_resize_to_canvas(rs1), "resized(0.5) # half size"))
|
|
|
|
# resized — by height
|
|
rs2 = base.resized(height=400).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(_resize_to_canvas(rs2), "resized(height=400)"))
|
|
|
|
# rotated
|
|
rt = base.rotated(30, expand=False, bg_color=(0, 0, 0)).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(rt, "rotated(angle=30, expand=False)"))
|
|
|
|
# with_position + with_opacity in a composite
|
|
small = base.resized(0.4).with_duration(CLIP_DUR)
|
|
bg = ColorClip(size=(W, H), color=(10, 10, 10)).with_duration(CLIP_DUR)
|
|
p1 = small.with_position((50, 50)).with_opacity(1.0)
|
|
p2 = small.with_position((500, 300)).with_opacity(0.5)
|
|
comp = CompositeVideoClip([bg, p1, p2], size=(W, H))
|
|
scenes.append(_titled(comp, "with_position() + with_opacity(0.5)"))
|
|
|
|
# with_mask — circular mask
|
|
mask_arr = circle(
|
|
screensize=(W, H),
|
|
center=(W // 2, H // 2),
|
|
radius=300,
|
|
color=1.0,
|
|
bg_color=0.0,
|
|
blur=20,
|
|
)
|
|
mask_clip = ImageClip(mask_arr, is_mask=True, duration=CLIP_DUR)
|
|
masked = base.with_duration(CLIP_DUR).with_mask(mask_clip)
|
|
mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR)
|
|
scenes.append(
|
|
_titled(
|
|
CompositeVideoClip([mbg, masked], size=(W, H)),
|
|
"with_mask() — circular mask via drawing.circle()",
|
|
)
|
|
)
|
|
|
|
# image_transform
|
|
def flip_lr(img: np.ndarray) -> np.ndarray:
|
|
return img[:, ::-1]
|
|
|
|
it = base.image_transform(flip_lr).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(it, "image_transform(flip_lr_func)"))
|
|
|
|
# transform
|
|
def shift_right(gf: Callable[[float], np.ndarray], t: float) -> np.ndarray:
|
|
frame = gf(t)
|
|
shift = int(t * 100)
|
|
return np.roll(frame, shift, axis=1)
|
|
|
|
tf = base.transform(shift_right).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(tf, "transform(shift_right_func)"))
|
|
|
|
# time_transform
|
|
tt = base.time_transform(lambda t: t * 3).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(tt, "time_transform(lambda t: t*3) # 3x speed"))
|
|
|
|
# with_speed_scaled
|
|
ss = base.with_speed_scaled(factor=0.5)
|
|
scenes.append(_titled(ss.with_duration(CLIP_DUR), "with_speed_scaled(factor=0.5)"))
|
|
|
|
# with_section_cut_out
|
|
sco = base.with_section_cut_out(0.5, 1.5)
|
|
scenes.append(
|
|
_titled(
|
|
sco.with_duration(min(sco.duration, CLIP_DUR)),
|
|
"with_section_cut_out(0.5, 1.5)",
|
|
)
|
|
)
|
|
|
|
# to_ImageClip
|
|
still = base.to_ImageClip(t=1.0, duration=CLIP_DUR)
|
|
scenes.append(_titled(still, "to_ImageClip(t=1.0) # freeze at t=1"))
|
|
|
|
# to_mask + to_RGB
|
|
bw = base.to_mask(canal=1).to_RGB().with_duration(CLIP_DUR)
|
|
scenes.append(_titled(bw, "to_mask(canal=1).to_RGB()"))
|
|
|
|
# with_background_color
|
|
small2 = base.resized(0.5).with_duration(CLIP_DUR)
|
|
wbg = small2.with_background_color(size=(W, H), color=(80, 0, 120))
|
|
scenes.append(_titled(wbg, "with_background_color(color=(80,0,120))"))
|
|
|
|
# with_effects_on_subclip
|
|
eos = base.with_effects_on_subclip(
|
|
[InvertColors()], start_time=0.5, end_time=1.5
|
|
).with_duration(CLIP_DUR)
|
|
scenes.append(_titled(eos, "with_effects_on_subclip([InvertColors], 0.5, 1.5)"))
|
|
|
|
# with_volume_scaled (visual label only — audio effect)
|
|
vsc = base.with_duration(CLIP_DUR)
|
|
scenes.append(_titled(vsc, "with_volume_scaled(factor) # scales audio amplitude"))
|
|
|
|
# with_layer_index
|
|
scenes.append(
|
|
_titled(
|
|
base.with_duration(CLIP_DUR), "with_layer_index(n) # compositing z-order"
|
|
)
|
|
)
|
|
|
|
return scenes
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# PART 3 — Video Effects (all 34)
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def _fx(effect: object, label: str, dur: float = CLIP_DUR) -> VideoClip:
|
|
"""Apply effect to base clip and label it."""
|
|
b = _base_clip(dur)
|
|
try:
|
|
result = b.with_effects([effect])
|
|
# Ensure it has a finite duration
|
|
if result.duration is None or result.duration <= 0:
|
|
result = result.with_duration(dur)
|
|
result = result.with_duration(min(result.duration, dur))
|
|
except (ValueError, OSError, AttributeError):
|
|
result = b
|
|
# Make sure it fits the canvas
|
|
if result.size != (W, H):
|
|
result = _resize_to_canvas(result)
|
|
return _titled(result, label)
|
|
|
|
|
|
def _part3_effects_1_to_17() -> list[VideoClip]:
|
|
"""Video effects 1-17: AccelDecel through MakeLoopable."""
|
|
scenes: list[VideoClip] = []
|
|
|
|
# 1. AccelDecel
|
|
scenes.append(
|
|
_fx(
|
|
AccelDecel(new_duration=CLIP_DUR, abruptness=2.0, soonness=1.0),
|
|
"AccelDecel(abruptness=2.0, soonness=1.0)",
|
|
)
|
|
)
|
|
|
|
# 2. BlackAndWhite
|
|
scenes.append(
|
|
_fx(
|
|
BlackAndWhite(preserve_luminosity=True),
|
|
"BlackAndWhite(preserve_luminosity=True)",
|
|
)
|
|
)
|
|
|
|
# 3. Blink
|
|
scenes.append(
|
|
_fx(
|
|
Blink(duration_on=0.3, duration_off=0.3),
|
|
"Blink(duration_on=0.3, duration_off=0.3)",
|
|
)
|
|
)
|
|
|
|
# 4. Crop
|
|
b_crop = _base_clip().with_effects([Crop(x1=200, y1=100, x2=1400, y2=800)])
|
|
scenes.append(
|
|
_titled(_resize_to_canvas(b_crop), "Crop(x1=200, y1=100, x2=1400, y2=800)")
|
|
)
|
|
|
|
# 5. CrossFadeIn
|
|
scenes.append(_fx(CrossFadeIn(duration=1.0), "CrossFadeIn(duration=1.0)"))
|
|
|
|
# 6. CrossFadeOut
|
|
scenes.append(_fx(CrossFadeOut(duration=1.0), "CrossFadeOut(duration=1.0)"))
|
|
|
|
# 7. EvenSize
|
|
scenes.append(_fx(EvenSize(), "EvenSize() # ensures even wxh"))
|
|
|
|
# 8. FadeIn
|
|
scenes.append(
|
|
_fx(
|
|
FadeIn(duration=1.5, initial_color=[0, 0, 0]),
|
|
"FadeIn(duration=1.5, initial_color=[0,0,0])",
|
|
)
|
|
)
|
|
|
|
# 9. FadeOut
|
|
scenes.append(
|
|
_fx(
|
|
FadeOut(duration=1.5, final_color=[0, 0, 0]),
|
|
"FadeOut(duration=1.5, final_color=[0,0,0])",
|
|
)
|
|
)
|
|
|
|
# 10. Freeze
|
|
scenes.append(
|
|
_fx(
|
|
Freeze(t=0.5, freeze_duration=1.0),
|
|
"Freeze(t=0.5, freeze_duration=1.0)",
|
|
dur=3.0,
|
|
)
|
|
)
|
|
|
|
# 11. FreezeRegion
|
|
scenes.append(
|
|
_fx(
|
|
FreezeRegion(t=0.5, region=(200, 100, 1400, 700)),
|
|
"FreezeRegion(t=0.5, region=(200,100,1400,700))",
|
|
)
|
|
)
|
|
|
|
# 12. GammaCorrection
|
|
scenes.append(_fx(GammaCorrection(gamma=2.5), "GammaCorrection(gamma=2.5)"))
|
|
|
|
# 13. HeadBlur
|
|
scenes.append(
|
|
_fx(
|
|
HeadBlur(
|
|
fx=lambda _: W // 2,
|
|
fy=lambda _: H // 2,
|
|
radius=100,
|
|
intensity=None,
|
|
),
|
|
"HeadBlur(fx, fy, radius=100)",
|
|
)
|
|
)
|
|
|
|
# 14. InvertColors
|
|
scenes.append(_fx(InvertColors(), "InvertColors()"))
|
|
|
|
# 15. Loop
|
|
short = _base_clip(0.5)
|
|
looped = short.with_effects([Loop(n=4)])
|
|
scenes.append(_titled(looped.with_duration(CLIP_DUR), "Loop(n=4)"))
|
|
|
|
# 16. LumContrast
|
|
scenes.append(
|
|
_fx(
|
|
LumContrast(lum=30, contrast=50, contrast_threshold=127),
|
|
"LumContrast(lum=30, contrast=50)",
|
|
)
|
|
)
|
|
|
|
# 17. MakeLoopable
|
|
scenes.append(
|
|
_fx(MakeLoopable(overlap_duration=0.5), "MakeLoopable(overlap_duration=0.5)")
|
|
)
|
|
|
|
return scenes
|
|
|
|
|
|
def _part3_effects_18_to_34() -> list[VideoClip]:
|
|
"""Video effects 18-34: Margin through TimeSymmetrize."""
|
|
scenes: list[VideoClip] = []
|
|
|
|
# 18. Margin
|
|
b_margin = _base_clip().with_effects(
|
|
[
|
|
Resize(0.7),
|
|
Margin(
|
|
margin_size=None,
|
|
left=40,
|
|
right=40,
|
|
top=20,
|
|
bottom=20,
|
|
color=(255, 0, 0),
|
|
opacity=1.0,
|
|
),
|
|
]
|
|
)
|
|
scenes.append(
|
|
_titled(
|
|
_resize_to_canvas(b_margin),
|
|
"Margin(left=40, right=40, top=20, bottom=20, color=red)",
|
|
)
|
|
)
|
|
|
|
# 19. MaskColor
|
|
scenes.append(
|
|
_fx(
|
|
MaskColor(color=(128, 128, 128), threshold=80, stiffness=1),
|
|
"MaskColor(color, threshold=80)",
|
|
)
|
|
)
|
|
|
|
# 20. MasksAnd
|
|
mask1 = circle((W, H), (W // 3, H // 2), 300, 1.0, 0.0, 1)
|
|
mask2 = circle((W, H), (2 * W // 3, H // 2), 300, 1.0, 0.0, 1)
|
|
combined = np.minimum(mask1, mask2)
|
|
m_clip = ImageClip(combined, is_mask=True, duration=CLIP_DUR)
|
|
masked_and = _base_clip().with_mask(m_clip)
|
|
mbg = ColorClip(size=(W, H), color=(0, 0, 0)).with_duration(CLIP_DUR)
|
|
scenes.append(
|
|
_titled(
|
|
CompositeVideoClip([mbg, masked_and], size=(W, H)),
|
|
"MasksAnd — intersection of two circle masks",
|
|
)
|
|
)
|
|
|
|
# 21. MasksOr
|
|
combined_or = np.maximum(mask1, mask2)
|
|
m_clip2 = ImageClip(combined_or, is_mask=True, duration=CLIP_DUR)
|
|
masked_or = _base_clip().with_mask(m_clip2)
|
|
scenes.append(
|
|
_titled(
|
|
CompositeVideoClip([mbg, masked_or], size=(W, H)),
|
|
"MasksOr — union of two circle masks",
|
|
)
|
|
)
|
|
|
|
# 22. MirrorX
|
|
scenes.append(_fx(MirrorX(), "MirrorX() # horizontal flip"))
|
|
|
|
# 23. MirrorY
|
|
scenes.append(_fx(MirrorY(), "MirrorY() # vertical flip"))
|
|
|
|
# 24. MultiplyColor
|
|
scenes.append(_fx(MultiplyColor(factor=1.8), "MultiplyColor(factor=1.8)"))
|
|
|
|
# 25. MultiplySpeed
|
|
scenes.append(_fx(MultiplySpeed(factor=3.0), "MultiplySpeed(factor=3.0)", dur=4.0))
|
|
|
|
# 26. Painting
|
|
scenes.append(
|
|
_fx(
|
|
Painting(saturation=1.4, black=0.006),
|
|
"Painting(saturation=1.4, black=0.006)",
|
|
)
|
|
)
|
|
|
|
# 27. Resize
|
|
b_rs = _base_clip().with_effects([Resize(new_size=(960, 540))])
|
|
scenes.append(_titled(_resize_to_canvas(b_rs), "Resize(new_size=(960,540))"))
|
|
|
|
# 28. Rotate
|
|
scenes.append(
|
|
_fx(
|
|
Rotate(angle=45, expand=True, bg_color=(0, 0, 0)),
|
|
"Rotate(angle=45, expand=True)",
|
|
)
|
|
)
|
|
|
|
# 29. Scroll
|
|
# Draw bands
|
|
tall_arr = np.full((H * 3, W, 3), 40, dtype=np.uint8)
|
|
for i in range(6):
|
|
y0, y1 = i * H // 2, (i + 1) * H // 2
|
|
tall_arr[y0:y1, :] = [
|
|
(50 * i) % 256,
|
|
(100 + 30 * i) % 256,
|
|
(200 - 20 * i) % 256,
|
|
]
|
|
tall_clip = ImageClip(tall_arr, duration=CLIP_DUR).with_effects(
|
|
[
|
|
Scroll(h=H, y_speed=-300, w=W),
|
|
]
|
|
)
|
|
scenes.append(_titled(_resize_to_canvas(tall_clip), "Scroll(h, y_speed=-300)"))
|
|
|
|
# 30. SlideIn
|
|
si = _base_clip().with_effects([SlideIn(duration=1.0, side="left")])
|
|
scenes.append(_titled(si, "SlideIn(duration=1.0, side='left')"))
|
|
|
|
# 31. SlideOut
|
|
so = _base_clip().with_effects([SlideOut(duration=1.0, side="right")])
|
|
scenes.append(_titled(so, "SlideOut(duration=1.0, side='right')"))
|
|
|
|
# 32. SuperSample
|
|
scenes.append(_fx(SuperSample(d=0.1, n_frames=3), "SuperSample(d=0.1, n_frames=3)"))
|
|
|
|
# 33. TimeMirror
|
|
tm = _base_clip().with_effects([TimeMirror()])
|
|
scenes.append(
|
|
_titled(tm.with_duration(CLIP_DUR), "TimeMirror() # plays backwards")
|
|
)
|
|
|
|
# 34. TimeSymmetrize
|
|
ts = _base_clip().with_effects([TimeSymmetrize()])
|
|
scenes.append(
|
|
_titled(ts.with_duration(CLIP_DUR), "TimeSymmetrize() # forward then reverse")
|
|
)
|
|
|
|
return scenes
|
|
|
|
|
|
def part3_video_effects() -> list[VideoClip]:
|
|
"""Demonstrate all 34 video effects."""
|
|
scenes: list[VideoClip] = [
|
|
_section_header(
|
|
"Part 3: Video Effects",
|
|
"All 34 effects from moviepy.video.fx",
|
|
),
|
|
]
|
|
scenes.extend(_part3_effects_1_to_17())
|
|
scenes.extend(_part3_effects_18_to_34())
|
|
return scenes
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# PART 4 — Audio
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def _make_sine(freq: float = 440.0, dur: float = CLIP_DUR) -> AudioClip:
|
|
"""Pure sine-wave AudioClip."""
|
|
|
|
def maker(t: np.ndarray) -> np.ndarray:
|
|
t_arr = np.asarray(t)
|
|
wave = 0.3 * np.sin(2 * np.pi * freq * t_arr.flatten())
|
|
stereo = np.column_stack([wave, wave])
|
|
# MoviePy probes with scalar t=0 and uses len(list(frame0))
|
|
# for nchannels. A (1,2) array iterates as 1 row → nchannels=1.
|
|
# Returning shape (2,) for scalar t lets MoviePy detect 2 channels.
|
|
if t_arr.ndim == 0:
|
|
return stereo[0]
|
|
return stereo
|
|
|
|
return AudioClip(maker, duration=dur, fps=44100)
|
|
|
|
|
|
def part4_audio() -> list[VideoClip]:
|
|
"""Demonstrate audio clips and all 7 audio effects."""
|
|
scenes: list[VideoClip] = [
|
|
_section_header(
|
|
"Part 4: Audio",
|
|
"AudioClip · AudioArrayClip · CompositeAudioClip · 7 Audio Effects",
|
|
),
|
|
]
|
|
bg = ColorClip(size=(W, H), color=(20, 30, 50))
|
|
|
|
# AudioClip
|
|
a1 = _make_sine(440, CLIP_DUR)
|
|
c1 = bg.with_duration(CLIP_DUR).with_audio(a1)
|
|
scenes.append(_titled(c1, "AudioClip(sine_440Hz)"))
|
|
|
|
# AudioArrayClip
|
|
sr = 44100
|
|
t_arr = np.linspace(0, CLIP_DUR, int(sr * CLIP_DUR), endpoint=False)
|
|
arr = (0.3 * np.sin(2 * np.pi * 880 * t_arr)).astype(np.float64)
|
|
stereo = np.column_stack([arr, arr])
|
|
a2 = AudioArrayClip(stereo, fps=sr)
|
|
c2 = bg.with_duration(CLIP_DUR).with_audio(a2)
|
|
scenes.append(_titled(c2, "AudioArrayClip(numpy_array, fps=44100) # 880Hz"))
|
|
|
|
# CompositeAudioClip
|
|
low = _make_sine(220, CLIP_DUR)
|
|
high = _make_sine(660, CLIP_DUR)
|
|
comp_audio = CompositeAudioClip([low, high])
|
|
c3 = bg.with_duration(CLIP_DUR).with_audio(comp_audio)
|
|
scenes.append(_titled(c3, "CompositeAudioClip([220Hz, 660Hz])"))
|
|
|
|
# concatenate_audioclips
|
|
a_cat = concatenate_audioclips([_make_sine(330, 1.0), _make_sine(550, 1.0)])
|
|
c4 = bg.with_duration(CLIP_DUR).with_audio(a_cat)
|
|
scenes.append(_titled(c4, "concatenate_audioclips([330Hz, 550Hz])"))
|
|
|
|
# AudioFadeIn
|
|
a_fi = _make_sine(440, CLIP_DUR).with_effects([AudioFadeIn(duration=1.5)])
|
|
c5 = bg.with_duration(CLIP_DUR).with_audio(a_fi)
|
|
scenes.append(_titled(c5, "AudioFadeIn(duration=1.5)"))
|
|
|
|
# AudioFadeOut
|
|
a_fo = _make_sine(440, CLIP_DUR).with_effects([AudioFadeOut(duration=1.5)])
|
|
c6 = bg.with_duration(CLIP_DUR).with_audio(a_fo)
|
|
scenes.append(_titled(c6, "AudioFadeOut(duration=1.5)"))
|
|
|
|
# AudioDelay
|
|
a_delay = _make_sine(440, CLIP_DUR).with_effects(
|
|
[AudioDelay(offset=0.2, n_repeats=4, decay=1)]
|
|
)
|
|
c7 = bg.with_duration(a_delay.duration).with_audio(a_delay)
|
|
scenes.append(
|
|
_titled(
|
|
c7.with_duration(CLIP_DUR), "AudioDelay(offset=0.2, n_repeats=4, decay=1)"
|
|
)
|
|
)
|
|
|
|
# AudioLoop
|
|
short_a = _make_sine(440, 0.5)
|
|
a_loop = short_a.with_effects([AudioLoop(duration=CLIP_DUR)])
|
|
c8 = bg.with_duration(CLIP_DUR).with_audio(a_loop)
|
|
scenes.append(_titled(c8, "AudioLoop(duration=2.0)"))
|
|
|
|
# AudioNormalize
|
|
quiet = _make_sine(440, CLIP_DUR) # already normalized but demonstrates the call
|
|
a_norm = quiet.with_effects([AudioNormalize()])
|
|
c9 = bg.with_duration(CLIP_DUR).with_audio(a_norm)
|
|
scenes.append(_titled(c9, "AudioNormalize()"))
|
|
|
|
# MultiplyStereoVolume
|
|
a_stereo = _make_sine(440, CLIP_DUR).with_effects(
|
|
[MultiplyStereoVolume(left=1.0, right=0.2)]
|
|
)
|
|
c10 = bg.with_duration(CLIP_DUR).with_audio(a_stereo)
|
|
scenes.append(_titled(c10, "MultiplyStereoVolume(left=1.0, right=0.2)"))
|
|
|
|
# MultiplyVolume
|
|
a_vol = _make_sine(440, CLIP_DUR).with_effects([MultiplyVolume(factor=0.3)])
|
|
c11 = bg.with_duration(CLIP_DUR).with_audio(a_vol)
|
|
scenes.append(_titled(c11, "MultiplyVolume(factor=0.3)"))
|
|
|
|
return scenes
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# PART 5 — Composition
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def part5_composition() -> list[VideoClip]:
|
|
"""Demonstrate composition & concatenation."""
|
|
scenes: list[VideoClip] = [
|
|
_section_header(
|
|
"Part 5: Composition",
|
|
"CompositeVideoClip · concatenate_videoclips · clips_array",
|
|
),
|
|
]
|
|
|
|
# CompositeVideoClip with bg_color, use_bgclip
|
|
bg = _base_clip()
|
|
overlay = (
|
|
ColorClip(size=(400, 400), color=(255, 50, 50))
|
|
.with_duration(CLIP_DUR)
|
|
.with_position(("center", "center"))
|
|
.with_opacity(0.6)
|
|
)
|
|
comp1 = CompositeVideoClip([bg, overlay], size=(W, H), bg_color=(0, 0, 0))
|
|
scenes.append(_titled(comp1, "CompositeVideoClip(clips, bg_color, use_bgclip)"))
|
|
|
|
# concatenate_videoclips — method='chain'
|
|
c1 = ColorClip(size=(W, H), color=(200, 50, 50)).with_duration(0.7)
|
|
c2 = ColorClip(size=(W, H), color=(50, 200, 50)).with_duration(0.7)
|
|
c3 = ColorClip(size=(W, H), color=(50, 50, 200)).with_duration(0.6)
|
|
cat = concatenate_videoclips([c1, c2, c3], method="chain")
|
|
scenes.append(_titled(cat, "concatenate_videoclips(method='chain')"))
|
|
|
|
# concatenate_videoclips — method='compose' with padding
|
|
cat2 = concatenate_videoclips(
|
|
[
|
|
c1.resized((W // 2, H // 2)),
|
|
c2.resized((W // 2, H // 2)),
|
|
c3.resized((W, H)),
|
|
],
|
|
method="compose",
|
|
bg_color=(0, 0, 0),
|
|
padding=-0.2,
|
|
)
|
|
scenes.append(
|
|
_titled(
|
|
_resize_to_canvas(cat2),
|
|
"concatenate_videoclips(method='compose', padding=-0.2)",
|
|
)
|
|
)
|
|
|
|
# concatenate_videoclips with transition
|
|
cat3 = concatenate_videoclips(
|
|
[c1, c2, c3],
|
|
padding=-0.3,
|
|
method="compose",
|
|
)
|
|
scenes.append(
|
|
_titled(
|
|
cat3.with_duration(CLIP_DUR),
|
|
"concatenate_videoclips(padding=-0.3) # overlap",
|
|
)
|
|
)
|
|
|
|
# clips_array
|
|
a = ColorClip(size=(W // 2, H // 2), color=(200, 50, 50)).with_duration(CLIP_DUR)
|
|
b = ColorClip(size=(W // 2, H // 2), color=(50, 200, 50)).with_duration(CLIP_DUR)
|
|
c = ColorClip(size=(W // 2, H // 2), color=(50, 50, 200)).with_duration(CLIP_DUR)
|
|
d = ColorClip(size=(W // 2, H // 2), color=(200, 200, 50)).with_duration(CLIP_DUR)
|
|
grid = clips_array([[a, b], [c, d]])
|
|
scenes.append(_titled(_resize_to_canvas(grid), "clips_array([[a, b], [c, d]])"))
|
|
|
|
return scenes
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# PART 6 — Drawing Tools
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def part6_drawing_tools() -> list[VideoClip]:
|
|
"""Demonstrate moviepy.video.tools.drawing functions."""
|
|
scenes: list[VideoClip] = [
|
|
_section_header(
|
|
"Part 6: Drawing Tools", "circle · color_gradient · color_split"
|
|
),
|
|
]
|
|
|
|
# circle
|
|
circ = circle(
|
|
screensize=(W, H),
|
|
center=(W // 2, H // 2),
|
|
radius=300,
|
|
color=1.0,
|
|
bg_color=0.0,
|
|
blur=30,
|
|
)
|
|
circ_rgb = (np.dstack([circ, circ, circ]) * 255).astype(np.uint8)
|
|
scenes.append(
|
|
_titled(
|
|
ImageClip(circ_rgb, duration=CLIP_DUR),
|
|
"drawing.circle(center, radius=300, blur=30)",
|
|
)
|
|
)
|
|
|
|
# color_gradient — linear
|
|
grad = color_gradient(
|
|
size=(W, H),
|
|
p1=(0, 0),
|
|
p2=(W, H),
|
|
color_1=0.0,
|
|
color_2=1.0,
|
|
shape="linear",
|
|
)
|
|
grad_rgb = (np.dstack([grad, grad, grad]) * 255).astype(np.uint8)
|
|
scenes.append(
|
|
_titled(
|
|
ImageClip(grad_rgb, duration=CLIP_DUR),
|
|
"drawing.color_gradient(shape='linear')",
|
|
)
|
|
)
|
|
|
|
# color_gradient — radial
|
|
grad_r = color_gradient(
|
|
size=(W, H),
|
|
p1=(W // 2, H // 2),
|
|
radius=500,
|
|
color_1=1.0,
|
|
color_2=0.0,
|
|
shape="radial",
|
|
)
|
|
grad_r_rgb = (np.dstack([grad_r, grad_r, grad_r]) * 255).astype(np.uint8)
|
|
scenes.append(
|
|
_titled(
|
|
ImageClip(grad_r_rgb, duration=CLIP_DUR),
|
|
"drawing.color_gradient(shape='radial', radius=500)",
|
|
)
|
|
)
|
|
|
|
# color_split
|
|
split = color_split(
|
|
size=(W, H),
|
|
x=W // 2,
|
|
color_1=0.0,
|
|
color_2=1.0,
|
|
gradient_width=100,
|
|
)
|
|
split_rgb = (np.dstack([split, split, split]) * 255).astype(np.uint8)
|
|
scenes.append(
|
|
_titled(
|
|
ImageClip(split_rgb, duration=CLIP_DUR),
|
|
"drawing.color_split(x=W/2, gradient_width=100)",
|
|
)
|
|
)
|
|
|
|
return scenes
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# PART 7 — Output Methods
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def part7_output() -> list[VideoClip]:
|
|
"""Label-only slides for output methods + parameters."""
|
|
scenes: list[VideoClip] = [
|
|
_section_header(
|
|
"Part 7: Output Methods",
|
|
"write_videofile · write_gif · save_frame · write_images_sequence",
|
|
),
|
|
]
|
|
|
|
bg = ColorClip(size=(W, H), color=(15, 20, 35))
|
|
|
|
methods = [
|
|
(
|
|
"write_videofile()",
|
|
"filename, fps, codec, bitrate, audio, audio_fps,\n"
|
|
"preset, audio_nbytes, audio_codec, audio_bitrate,\n"
|
|
"audio_bufsize, temp_audiofile, threads,\n"
|
|
"ffmpeg_params, logger, pixel_format",
|
|
),
|
|
(
|
|
"write_gif()",
|
|
"filename, fps, loop, logger",
|
|
),
|
|
(
|
|
"save_frame()",
|
|
"filename, t, with_mask",
|
|
),
|
|
(
|
|
"write_images_sequence()",
|
|
"name_format, fps, with_mask, logger",
|
|
),
|
|
(
|
|
"write_audiofile()",
|
|
"filename, fps, nbytes, buffersize,\ncodec, bitrate, ffmpeg_params, logger",
|
|
),
|
|
]
|
|
|
|
for title, params in methods:
|
|
t1 = (
|
|
TextClip(
|
|
text=title, font_size=56, color="cyan", font=FONT_B, margin=(0, 20)
|
|
)
|
|
.with_duration(2.5)
|
|
.with_position(("center", 300))
|
|
)
|
|
t2 = (
|
|
TextClip(
|
|
text=f"Parameters:\n{params}",
|
|
font_size=32,
|
|
color="#dddddd",
|
|
font=FONT_R,
|
|
method="caption",
|
|
size=(W - 300, None),
|
|
text_align="center",
|
|
interline=8,
|
|
margin=(0, 15),
|
|
)
|
|
.with_duration(2.5)
|
|
.with_position(("center", 500))
|
|
)
|
|
scenes.append(CompositeVideoClip([bg.with_duration(2.5), t1, t2], size=(W, H)))
|
|
|
|
return scenes
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# ASSEMBLY — memory-safe: render each part to a temp file, then
|
|
# concatenate via VideoFileClip so only one part is in RAM at a time.
|
|
# ══════════════════════════════════════════════════════════════════
|
|
def _render_part(
|
|
scenes: list[VideoClip],
|
|
path: str,
|
|
label: str,
|
|
) -> None:
|
|
"""Concatenate *scenes*, write to *path*, then close all clips."""
|
|
logger.info(" Rendering %s (%d scenes) → %s", label, len(scenes), Path(path).name)
|
|
part = concatenate_videoclips(scenes, method="compose", bg_color=(0, 0, 0))
|
|
part.write_videofile(
|
|
path,
|
|
fps=FPS,
|
|
codec="libx264",
|
|
preset="ultrafast",
|
|
audio=False,
|
|
logger=None,
|
|
)
|
|
# Free memory
|
|
part.close()
|
|
for s in scenes:
|
|
s.close()
|
|
|
|
|
|
def main() -> None:
|
|
"""Assemble all parts into the final showcase video."""
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger.info("Building MoviePy comprehensive showcase…")
|
|
|
|
tmpdir = tempfile.mkdtemp(prefix="moviepy_showcase_")
|
|
try:
|
|
_build(tmpdir)
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
def _build(tmpdir: str) -> None:
|
|
# ── Render each part to its own temp file ─────────────────────
|
|
# Title card
|
|
title_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0)
|
|
title_txt = (
|
|
TextClip(
|
|
text="MoviePy 2.x\nComplete Feature Showcase",
|
|
font_size=80,
|
|
color="white",
|
|
font=FONT_B,
|
|
method="caption",
|
|
size=(W - 200, None),
|
|
text_align="center",
|
|
margin=(0, 40),
|
|
)
|
|
.with_duration(3.0)
|
|
.with_position("center")
|
|
)
|
|
title_card = CompositeVideoClip([title_bg, title_txt], size=(W, H)).with_effects(
|
|
[FadeIn(1.0)]
|
|
)
|
|
|
|
# Outro
|
|
outro_bg = ColorClip(size=(W, H), color=(10, 10, 30)).with_duration(3.0)
|
|
outro_txt = (
|
|
TextClip(
|
|
text="That's all of MoviePy 2.x!\n34 video effects · 7 audio effects\n"
|
|
"11 clip types · drawing tools · composition",
|
|
font_size=52,
|
|
color="white",
|
|
font=FONT_B,
|
|
method="caption",
|
|
size=(W - 200, None),
|
|
text_align="center",
|
|
margin=(0, 30),
|
|
)
|
|
.with_duration(3.0)
|
|
.with_position("center")
|
|
)
|
|
outro = CompositeVideoClip([outro_bg, outro_txt], size=(W, H)).with_effects(
|
|
[FadeOut(1.5)]
|
|
)
|
|
|
|
part_builders = [
|
|
("title", lambda: [title_card]),
|
|
("Part 1: Clip Types", part1_clip_types),
|
|
("Part 2: Clip Methods", part2_clip_methods),
|
|
("Part 3: Video Effects", part3_video_effects),
|
|
("Part 4: Audio", part4_audio),
|
|
("Part 5: Composition", part5_composition),
|
|
("Part 6: Drawing Tools", part6_drawing_tools),
|
|
("Part 7: Output Methods", part7_output),
|
|
("outro", lambda: [outro]),
|
|
]
|
|
|
|
part_files: list[str] = []
|
|
for i, (label, builder) in enumerate(part_builders):
|
|
path = str(Path(tmpdir) / f"part_{i:02d}.mp4")
|
|
scenes = builder()
|
|
_render_part(scenes, path, label)
|
|
part_files.append(path)
|
|
|
|
# ── Load temp files as lightweight VideoFileClips & concat ─────
|
|
logger.info("Concatenating all parts…")
|
|
file_clips = [VideoFileClip(p) for p in part_files]
|
|
final = concatenate_videoclips(file_clips, method="chain")
|
|
|
|
# Background audio
|
|
audio = _make_sine(330, final.duration).with_effects([MultiplyVolume(factor=0.5)])
|
|
final = final.with_audio(audio)
|
|
|
|
logger.info("Total duration: %.1fs", final.duration)
|
|
logger.info("Writing %s (NVENC GPU)…", OUTPUT)
|
|
|
|
final.write_videofile(
|
|
OUTPUT,
|
|
fps=FPS,
|
|
codec="h264_nvenc",
|
|
audio_codec="aac",
|
|
threads=os.cpu_count(),
|
|
ffmpeg_params=["-preset", "p4", "-rc", "constqp", "-qp", "18", "-b:v", "0"],
|
|
logger="bar",
|
|
)
|
|
|
|
# Clean up
|
|
final.close()
|
|
for c in file_clips:
|
|
c.close()
|
|
|
|
size_mb = Path(OUTPUT).stat().st_size / (1024 * 1024)
|
|
logger.info("✔ Saved %s (%.1f MB)", OUTPUT, size_mb)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|