Skip to content

Commit 6a887ef

Browse files
authored
Merge pull request #210 from modelscope/codex/fix-subtitle-color-135
fix: preserve selected subtitle colors
2 parents 3691a4d + c0d390d commit 6a887ef

4 files changed

Lines changed: 141 additions & 3 deletions

File tree

funclip/subtitle_renderer.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Pillow-backed subtitle text rendering for MoviePy 1.x."""
2+
3+
from pathlib import Path
4+
from numbers import Real
5+
6+
import numpy as np
7+
from moviepy.editor import ImageClip
8+
from PIL import Image, ImageColor, ImageDraw, ImageFont
9+
10+
11+
DEFAULT_FONT_PATH = (
12+
Path(__file__).resolve().parents[1] / "font" / "STHeitiMedium.ttc"
13+
)
14+
15+
16+
def make_text_clip(
17+
text,
18+
font_path=DEFAULT_FONT_PATH,
19+
font_size=32,
20+
color="white",
21+
):
22+
"""Render transparent subtitle text without ImageMagick."""
23+
if isinstance(font_size, bool) or not isinstance(font_size, Real):
24+
raise TypeError("font_size must be a number")
25+
if font_size <= 0 or not float(font_size).is_integer():
26+
raise ValueError("font_size must be a positive integer")
27+
font_size = int(font_size)
28+
29+
font_path = Path(font_path)
30+
if not font_path.is_file():
31+
raise FileNotFoundError(f"subtitle font not found: {font_path}")
32+
33+
try:
34+
fill = ImageColor.getrgb(str(color))[:3]
35+
except ValueError as error:
36+
raise ValueError(f"unsupported subtitle color: {color!r}") from error
37+
38+
text = str(text)
39+
font = ImageFont.truetype(str(font_path), font_size)
40+
spacing = max(1, font_size // 5)
41+
probe = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
42+
draw = ImageDraw.Draw(probe)
43+
left, top, right, bottom = draw.multiline_textbbox(
44+
(0, 0), text, font=font, spacing=spacing
45+
)
46+
padding = max(2, font_size // 12)
47+
width = max(1, right - left + 2 * padding)
48+
height = max(1, bottom - top + 2 * padding)
49+
50+
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
51+
draw = ImageDraw.Draw(image)
52+
draw.multiline_text(
53+
(padding - left, padding - top),
54+
text,
55+
font=font,
56+
fill=(*fill, 255),
57+
spacing=spacing,
58+
)
59+
return ImageClip(np.asarray(image), transparent=True)

funclip/videoclipper.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@
1414
import soundfile as sf
1515
from moviepy.editor import *
1616
import moviepy.editor as mpy
17-
from moviepy.video.tools.subtitles import SubtitlesClip, TextClip
17+
from moviepy.video.tools.subtitles import SubtitlesClip
1818
from moviepy.editor import VideoFileClip, concatenate_videoclips
1919
from moviepy.video.compositing.CompositeVideoClip import CompositeVideoClip
20+
try:
21+
from .subtitle_renderer import make_text_clip
22+
except ImportError:
23+
from subtitle_renderer import make_text_clip
2024
from utils.subtitle_utils import generate_srt, generate_srt_clip, str2list
2125
from utils.argparse_tools import ArgumentParser, get_commandline_args
2226
from utils.trans_utils import pre_proc, proc, write_state, load_state, proc_spk, convert_pcm_to_float
@@ -346,7 +350,9 @@ def video_clip(self,
346350
start_end_info = "from {} to {}".format(start, end)
347351
clip_srt += srt_clip
348352
if add_sub:
349-
generator = lambda txt: TextClip(txt, font='./font/STHeitiMedium.ttc', fontsize=font_size, color=font_color)
353+
generator = lambda txt: make_text_clip(
354+
txt, font_size=font_size, color=font_color
355+
)
350356
subtitles = SubtitlesClip(subs, generator)
351357
video_clip = CompositeVideoClip([video_clip, subtitles.set_pos(('center','bottom'))])
352358
concate_clip = [video_clip]
@@ -365,7 +371,9 @@ def video_clip(self,
365371
start_end_info += ", from {} to {}".format(str(start)[:5], str(end)[:5])
366372
clip_srt += srt_clip
367373
if add_sub:
368-
generator = lambda txt: TextClip(txt, font='./font/STHeitiMedium.ttc', fontsize=font_size, color=font_color)
374+
generator = lambda txt: make_text_clip(
375+
txt, font_size=font_size, color=font_color
376+
)
369377
subtitles = SubtitlesClip(chi_subs, generator)
370378
_video_clip = CompositeVideoClip([_video_clip, subtitles.set_pos(('center','bottom'))])
371379
# _video_clip.write_videofile("debug.mp4", audio_codec="aac")

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ funasr>=1.4.9
55
transformers>=4.32.0,<5.0
66
huggingface_hub>=0.19.3,<1.0
77
moviepy==1.0.3
8+
pillow
89
numpy==1.26.4
910
gradio>=4.31.3,<5.0
1011
starlette<1.0

tests/test_subtitle_renderer.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from pathlib import Path
2+
3+
import numpy as np
4+
from moviepy.editor import ColorClip, CompositeVideoClip
5+
from moviepy.video.tools.subtitles import SubtitlesClip
6+
7+
from funclip.subtitle_renderer import make_text_clip
8+
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
FONT_PATH = ROOT / "font" / "STHeitiMedium.ttc"
12+
13+
14+
def _foreground_rgb(color):
15+
clip = make_text_clip("字幕 Test", FONT_PATH, 48, color)
16+
frame = clip.get_frame(0)
17+
mask = clip.mask.get_frame(0)
18+
19+
assert mask.min() == 0
20+
assert mask.max() > 0.9
21+
return frame[mask > 0.5]
22+
23+
24+
def test_subtitle_renderer_preserves_selected_colors():
25+
red = _foreground_rgb("red").mean(axis=0)
26+
green = _foreground_rgb("green").mean(axis=0)
27+
black = _foreground_rgb("black").mean(axis=0)
28+
white = _foreground_rgb("white").mean(axis=0)
29+
30+
assert red[0] > 200 and red[1] < 40 and red[2] < 40
31+
assert green[1] > 100 and green[0] < 40 and green[2] < 40
32+
assert black.max() < 10
33+
assert white.min() > 240
34+
35+
36+
def test_subtitle_renderer_rejects_invalid_font_size():
37+
assert make_text_clip("subtitle", FONT_PATH, 48.0, "white").size[0] > 0
38+
39+
for value in (0, -1, 48.5, "48", True):
40+
try:
41+
make_text_clip("subtitle", FONT_PATH, value, "white")
42+
except (TypeError, ValueError):
43+
continue
44+
raise AssertionError(f"font size {value!r} should be rejected")
45+
46+
47+
def test_selected_color_reaches_composited_video_frame():
48+
background = ColorClip((320, 120), color=(20, 20, 20), duration=1)
49+
subtitles = SubtitlesClip(
50+
[((0, 1), "字幕")],
51+
lambda text: make_text_clip(text, FONT_PATH, 48, "red"),
52+
).set_pos(("center", "bottom"))
53+
frame = CompositeVideoClip([background, subtitles]).get_frame(0.5)
54+
55+
red_pixels = (
56+
(frame[:, :, 0] > 180)
57+
& (frame[:, :, 1] < 60)
58+
& (frame[:, :, 2] < 60)
59+
)
60+
assert red_pixels.sum() > 100
61+
62+
63+
def test_pillow_is_an_explicit_runtime_dependency():
64+
requirements = {
65+
line.strip().lower()
66+
for line in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
67+
if line.strip() and not line.lstrip().startswith("#")
68+
}
69+
70+
assert "pillow" in requirements

0 commit comments

Comments
 (0)