Skip to content

Instantly share code, notes, and snippets.

@shimpe
Last active May 17, 2024 02:41
Show Gist options
  • Save shimpe/f74a3a19960553390808d31445e59f56 to your computer and use it in GitHub Desktop.
Save shimpe/f74a3a19960553390808d31445e59f56 to your computer and use it in GitHub Desktop.
fancy captions with moviepy
import PySide6.QtSvg
import PySide6.QtGui
import PySide6.QtCore
import moviepy.video.VideoClip
import moviepy.editor
import numpy as np
from moviepy.editor import *
from vectortween.PointAnimation import PointAnimation
from vectortween.SequentialAnimation import SequentialAnimation
# set up some properties of the clip we are going to generate
fps = 25
duration = 10
W = 1000
H = 500
# some helper code to convert SVG to png
def toNumpy(incomingImage):
''' Converts a QImage into numpy format '''
incomingImage = incomingImage.convertToFormat(PySide6.QtGui.QImage.Format.Format_RGB32)
width = incomingImage.width()
height = incomingImage.height()
ptr = incomingImage.bits()
arr = np.array(ptr).reshape(height, width, 4) # Copies the data
return arr[:, :, :3] # remove alpha channel
# need to create dummy application to access font database
dummyapp = PySide6.QtGui.QGuiApplication()
# dynamically build a function that generates frames based on animation parameters and an svg text template
def make_frame_with_animation_and_text(xyanimation, anim_fraction, total_duration, text):
"""
xyanimation: a PointAnimation to vary the x, y position of the text. (0, 0) is centered in the middle of the screen.
anim_fraction: how long should the animation take to complete relative to the total_duration of the clip (e.g. 0.9 is 90%)
total_duration: total duration of the clip in seconds
text: svg text template (see example code below)
"""
def make_frame(t):
df = total_duration * fps
tf = t * fps
x, y = xyanimation.make_frame(tf, 0, 0, anim_fraction * df, df, None)
svg = f"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="{W}" height="{H}" viewBox="{-W / 2} {-H / 2} {W} {H}">
{text.format(x=x, y=y)}
</svg>
"""
r = PySide6.QtSvg.QSvgRenderer(bytes(svg, "utf-8"))
i = PySide6.QtGui.QImage(PySide6.QtCore.QSize(W, H), PySide6.QtGui.QImage.Format_RGB32)
i.fill(PySide6.QtGui.QColor(0, 0, 0))
p = PySide6.QtGui.QPainter(i)
r.render(p)
p.end()
return toNumpy(i.rgbSwapped())
return make_frame
# define some fancy styles we can use while defining texts
my_styles = {
"normal": """fill="black" stroke="white" stroke-width="2px" font-size="55" font-family="sans-serif" text-anchor="middle" """,
"h1": """fill="blue" stroke="gold" stroke-width="4px" font-size="80" font-family="serif" text-anchor="middle" """,
"h2": """fill="gold" stroke="maroon" stroke-width="2px" font-size="90" font-family="serif" font-style="italic" text-anchor="middle" """,
"h3": """fill="green" stroke="white" stroke-width="6px" font-size="200" font-family="serif" text-anchor="middle" """,
"small": """fill="red" stroke="white" stroke-width="5px" font-size="30" font-weight="bold" font-family="serif" text-anchor="middle" """
}
# define some fancy animations we can use to move our text around the screen
anim = {
"top_to_bottom": PointAnimation((-W, -H / 2), (0, H / 3), tween=['easeOutBounce'], ytween=['easeOutBounce']),
"topright_to_left": PointAnimation((W + 10, -H), (0, 0), tween=['easeOutQuad'], ytween=['easeOutCirc']),
"multi": SequentialAnimation([
PointAnimation((-W / 5, -H / 3), (W / 3, H / 2), tween=['easeOutBounce'], ytween=['easeOutQuad']),
PointAnimation((W / 3, H / 2), (-W / 5, H / 5), tween=['easeOutQuad'], ytween=['easeOutBounce']),
PointAnimation((-W / 5, H / 5), (W / 5, -H / 3), tween=['easeOutQuad'], ytween=['easeOutQuad']),
PointAnimation((W / 5, -H / 3), (-W / 5, -H / 3), tween=['easeOutQuad'], ytween=['easeOutBounce']),
], timeweight=[1, 0.5, 0.5, 1], repeats=3)
}
# define some texts
# each line of text must be defined separately, and can be styled and animated separately
# text svg template must take include x="{{x}}" y="{{y}}" properties for an animation to have effect
my_text = """<text {normal} x="{{x}}" y="{{y}}">Outline text <tspan {h1}>with</tspan> <tspan {h2}>highlights</tspan>!</text>""".format(
**my_styles)
# render clip with text line
txt_clip = moviepy.video.VideoClip.VideoClip(
make_frame=make_frame_with_animation_and_text(anim['top_to_bottom'], 0.25, duration, my_text), \
duration=duration / 2)
# make black (= background) transparent so we can overlay text lines later on
txt_clip = moviepy.editor.vfx.mask_color(txt_clip, color=[0, 0, 0])
my_2nd_text = """<text {normal} x="{{x}}" y="{{y}}">Follow-up <tspan {h3}>to</tspan> <tspan {h1}>first part?!</tspan></text>""".format(
**my_styles)
_2nd_txt_clip = moviepy.video.VideoClip.VideoClip(
make_frame=make_frame_with_animation_and_text(anim['topright_to_left'], 0.1, duration, my_2nd_text), \
duration=duration / 2)
_2nd_txt_clip = moviepy.editor.vfx.mask_color(_2nd_txt_clip,
color=[0, 0, 0]) # make black transparent so we can overlay clips
my_3rd_text = """<text {small} x="{{x}}" y="{{y}}">MOVIEPY ROCKS!</text>""".format(**my_styles)
_3rd_txt_clip = moviepy.video.VideoClip.VideoClip(
make_frame=make_frame_with_animation_and_text(anim['multi'], 1.0, duration, my_3rd_text), \
duration=duration
)
_3rd_txt_clip = moviepy.editor.vfx.mask_color(_3rd_txt_clip,
color=[0, 0, 0]) # make black transparent so we can overlay clips
# Overlay all text clips for some fancy animation
video = CompositeVideoClip([txt_clip, _2nd_txt_clip, _3rd_txt_clip])
# Write video file with the result
video.write_gif("test_with_svg.gif", fps=fps)
@shimpe
Copy link
Author

shimpe commented Dec 16, 2022

see: Example of what this produces.

@shimpe
Copy link
Author

shimpe commented Dec 16, 2022

if you extend the script with font size animation, you can do stuff like: this example

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment