Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions funclip/subtitle_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Pillow-backed subtitle text rendering for MoviePy 1.x."""

from pathlib import Path
from numbers import Real

import numpy as np
from moviepy.editor import ImageClip
from PIL import Image, ImageColor, ImageDraw, ImageFont


DEFAULT_FONT_PATH = (
Path(__file__).resolve().parents[1] / "font" / "STHeitiMedium.ttc"
)


def make_text_clip(
text,
font_path=DEFAULT_FONT_PATH,
font_size=32,
color="white",
):
"""Render transparent subtitle text without ImageMagick."""
if isinstance(font_size, bool) or not isinstance(font_size, Real):
raise TypeError("font_size must be a number")
if font_size <= 0 or not float(font_size).is_integer():
raise ValueError("font_size must be a positive integer")
font_size = int(font_size)

font_path = Path(font_path)
if not font_path.is_file():
raise FileNotFoundError(f"subtitle font not found: {font_path}")

try:
fill = ImageColor.getrgb(str(color))[:3]
except ValueError as error:
raise ValueError(f"unsupported subtitle color: {color!r}") from error

text = str(text)
font = ImageFont.truetype(str(font_path), font_size)
spacing = max(1, font_size // 5)
probe = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
draw = ImageDraw.Draw(probe)
left, top, right, bottom = draw.multiline_textbbox(
(0, 0), text, font=font, spacing=spacing
)
padding = max(2, font_size // 12)
width = max(1, right - left + 2 * padding)
height = max(1, bottom - top + 2 * padding)

image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
draw.multiline_text(
(padding - left, padding - top),
text,
font=font,
fill=(*fill, 255),
spacing=spacing,
)
return ImageClip(np.asarray(image), transparent=True)
14 changes: 11 additions & 3 deletions funclip/videoclipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@
import soundfile as sf
from moviepy.editor import *
import moviepy.editor as mpy
from moviepy.video.tools.subtitles import SubtitlesClip, TextClip
from moviepy.video.tools.subtitles import SubtitlesClip
from moviepy.editor import VideoFileClip, concatenate_videoclips
from moviepy.video.compositing.CompositeVideoClip import CompositeVideoClip
try:
from .subtitle_renderer import make_text_clip
except ImportError:
from subtitle_renderer import make_text_clip
from utils.subtitle_utils import generate_srt, generate_srt_clip, str2list
from utils.argparse_tools import ArgumentParser, get_commandline_args
from utils.trans_utils import pre_proc, proc, write_state, load_state, proc_spk, convert_pcm_to_float
Expand Down Expand Up @@ -346,7 +350,9 @@ def video_clip(self,
start_end_info = "from {} to {}".format(start, end)
clip_srt += srt_clip
if add_sub:
generator = lambda txt: TextClip(txt, font='./font/STHeitiMedium.ttc', fontsize=font_size, color=font_color)
generator = lambda txt: make_text_clip(
txt, font_size=font_size, color=font_color
)
subtitles = SubtitlesClip(subs, generator)
video_clip = CompositeVideoClip([video_clip, subtitles.set_pos(('center','bottom'))])
concate_clip = [video_clip]
Expand All @@ -365,7 +371,9 @@ def video_clip(self,
start_end_info += ", from {} to {}".format(str(start)[:5], str(end)[:5])
clip_srt += srt_clip
if add_sub:
generator = lambda txt: TextClip(txt, font='./font/STHeitiMedium.ttc', fontsize=font_size, color=font_color)
generator = lambda txt: make_text_clip(
txt, font_size=font_size, color=font_color
)
subtitles = SubtitlesClip(chi_subs, generator)
_video_clip = CompositeVideoClip([_video_clip, subtitles.set_pos(('center','bottom'))])
# _video_clip.write_videofile("debug.mp4", audio_codec="aac")
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ funasr>=1.4.9
transformers>=4.32.0,<5.0
huggingface_hub>=0.19.3,<1.0
moviepy==1.0.3
pillow
numpy==1.26.4
gradio>=4.31.3,<5.0
starlette<1.0
Expand Down
70 changes: 70 additions & 0 deletions tests/test_subtitle_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from pathlib import Path

import numpy as np
from moviepy.editor import ColorClip, CompositeVideoClip
from moviepy.video.tools.subtitles import SubtitlesClip

from funclip.subtitle_renderer import make_text_clip


ROOT = Path(__file__).resolve().parents[1]
FONT_PATH = ROOT / "font" / "STHeitiMedium.ttc"


def _foreground_rgb(color):
clip = make_text_clip("字幕 Test", FONT_PATH, 48, color)
frame = clip.get_frame(0)
mask = clip.mask.get_frame(0)

assert mask.min() == 0
assert mask.max() > 0.9
return frame[mask > 0.5]


def test_subtitle_renderer_preserves_selected_colors():
red = _foreground_rgb("red").mean(axis=0)
green = _foreground_rgb("green").mean(axis=0)
black = _foreground_rgb("black").mean(axis=0)
white = _foreground_rgb("white").mean(axis=0)

assert red[0] > 200 and red[1] < 40 and red[2] < 40
assert green[1] > 100 and green[0] < 40 and green[2] < 40
assert black.max() < 10
assert white.min() > 240


def test_subtitle_renderer_rejects_invalid_font_size():
assert make_text_clip("subtitle", FONT_PATH, 48.0, "white").size[0] > 0

for value in (0, -1, 48.5, "48", True):
try:
make_text_clip("subtitle", FONT_PATH, value, "white")
except (TypeError, ValueError):
continue
raise AssertionError(f"font size {value!r} should be rejected")


def test_selected_color_reaches_composited_video_frame():
background = ColorClip((320, 120), color=(20, 20, 20), duration=1)
subtitles = SubtitlesClip(
[((0, 1), "字幕")],
lambda text: make_text_clip(text, FONT_PATH, 48, "red"),
).set_pos(("center", "bottom"))
frame = CompositeVideoClip([background, subtitles]).get_frame(0.5)

red_pixels = (
(frame[:, :, 0] > 180)
& (frame[:, :, 1] < 60)
& (frame[:, :, 2] < 60)
)
assert red_pixels.sum() > 100


def test_pillow_is_an_explicit_runtime_dependency():
requirements = {
line.strip().lower()
for line in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}

assert "pillow" in requirements