diff --git a/funclip/subtitle_renderer.py b/funclip/subtitle_renderer.py new file mode 100644 index 0000000..74205f3 --- /dev/null +++ b/funclip/subtitle_renderer.py @@ -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) diff --git a/funclip/videoclipper.py b/funclip/videoclipper.py index fd6007f..8655940 100644 --- a/funclip/videoclipper.py +++ b/funclip/videoclipper.py @@ -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 @@ -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] @@ -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") diff --git a/requirements.txt b/requirements.txt index ee6b781..25d9fc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_subtitle_renderer.py b/tests/test_subtitle_renderer.py new file mode 100644 index 0000000..57d684d --- /dev/null +++ b/tests/test_subtitle_renderer.py @@ -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