diff --git a/app/models/schema.py b/app/models/schema.py index 3696fa3..fb168b4 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -98,6 +98,22 @@ class VideoParams(BaseModel): text_fore_color: Optional[str] = "#FFFFFF" text_background_color: Union[bool, str] = True + # 艺术字体相关参数 + art_font_enabled: Optional[bool] = False + art_font_type: Optional[str] = "normal" # normal, shadow, outline, 3d, etc. + art_font_background: Optional[str] = "none" # none, red, blue, etc. + + # 标题贴纸相关参数 + title_sticker_enabled: Optional[bool] = False + title_sticker_text: Optional[str] = "" + title_sticker_font: Optional[str] = "STHeitiMedium.ttc" + title_sticker_font_size: Optional[int] = 80 + title_sticker_style: Optional[str] = "rainbow" # rainbow, neon, gradient, etc. + title_sticker_background: Optional[str] = "rounded_rect" # none, rounded_rect, rect, etc. + title_sticker_background_color: Optional[str] = "#000000" + title_sticker_border: Optional[bool] = True + title_sticker_border_color: Optional[str] = "#FFFFFF" + font_size: int = 60 stroke_color: Optional[str] = "#000000" stroke_width: float = 1.5 @@ -124,6 +140,11 @@ class SubtitleRequest(BaseModel): video_source: Optional[str] = "local" subtitle_enabled: Optional[str] = "true" + # 艺术字体相关参数 + art_font_enabled: Optional[bool] = False + art_font_type: Optional[str] = "normal" # normal, shadow, outline, 3d, etc. + art_font_background: Optional[str] = "none" # none, red, blue, etc. + class AudioRequest(BaseModel): video_script: str diff --git a/app/services/video.py b/app/services/video.py index 01cf376..8beca82 100644 --- a/app/services/video.py +++ b/app/services/video.py @@ -1,6 +1,7 @@ import glob import os import random +import uuid from typing import List from loguru import logger @@ -16,7 +17,7 @@ from moviepy import ( concatenate_videoclips, ) from moviepy.video.tools.subtitles import SubtitlesClip -from PIL import ImageFont +from PIL import Image, ImageDraw, ImageFont, ImageFilter from app.models import const from app.models.schema import ( @@ -182,6 +183,345 @@ def combine_videos( return combined_video_path +def create_title_sticker(text, font, font_size, style, background, background_color, border, border_color, size): + """ + 创建标题贴纸 + + :param text: 标题文本 + :param font: 字体路径 + :param font_size: 字体大小 + :param style: 标题样式(rainbow, neon, gradient等) + :param background: 背景类型(none, rounded_rect, rect等) + :param background_color: 背景颜色 + :param border: 是否有边框 + :param border_color: 边框颜色 + :param size: 视频尺寸 + :return: ImageClip对象 + """ + if not text: + return None + + video_width, video_height = size + + # 创建字体对象 + font_obj = ImageFont.truetype(font, font_size) + + # 计算文本尺寸 + left, top, right, bottom = font_obj.getbbox(text) + text_width = right - left + text_height = bottom - top + + # 设置贴纸尺寸(比文本略大) + padding_x = int(text_width * 0.3) + padding_y = int(text_height * 0.5) + sticker_width = text_width + padding_x * 2 + sticker_height = text_height + padding_y * 2 + + # 确保文本在背景中垂直居中 + text_y_position = (sticker_height - text_height) // 2 + + # 创建透明背景图像 + img = Image.new('RGBA', (sticker_width, sticker_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # 绘制背景 + if background != "none": + # 确保背景颜色完全不透明 + if background_color.startswith('#') and len(background_color) == 7: + bg_color = background_color + 'ff' # 添加不透明度 + else: + bg_color = background_color + + if background == "rounded_rect": + # 绘制圆角矩形 + radius = int(sticker_height * 0.3) # 圆角半径 + draw.rounded_rectangle( + [(0, 0), (sticker_width, sticker_height)], + radius=radius, + fill=bg_color + ) + elif background == "rect": + # 绘制矩形 + draw.rectangle( + [(0, 0), (sticker_width, sticker_height)], + fill=bg_color + ) + + # 根据样式绘制文本 + if style == "rainbow": + # 彩虹渐变文字 + rainbow_colors = ["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#4B0082", "#9400D3"] + # 创建渐变色文本 + gradient_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0)) + gradient_draw = ImageDraw.Draw(gradient_img) + + # 计算每个字符的颜色 + for i, char in enumerate(text): + color_idx = i % len(rainbow_colors) + char_width = font_obj.getbbox(char)[2] - font_obj.getbbox(char)[0] + gradient_draw.text((left + i * char_width, 0), char, font=font_obj, fill=rainbow_colors[color_idx]) + + # 添加白色描边 + if border: + for offset_x, offset_y in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + draw.text((padding_x + offset_x, text_y_position + offset_y), text, font=font_obj, fill=border_color) + + # 将渐变文本粘贴到主图像 + img.paste(gradient_img, (padding_x, text_y_position), gradient_img) + + elif style == "neon": + # 霓虹灯效果 + glow_color = "#FF4500" # 橙红色 + outer_glow_color = "#FFFF00" # 黄色外发光 + + # 添加外发光效果 + for offset in range(3, 0, -1): + alpha = 100 - offset * 30 + glow_alpha = max(0, alpha) + glow_color_with_alpha = glow_color[0:7] + format(glow_alpha, '02x') + for dx, dy in [(ox, oy) for ox in range(-offset, offset+1) for oy in range(-offset, offset+1)]: + draw.text((padding_x + dx, text_y_position + dy), text, font=font_obj, fill=glow_color_with_alpha) + + # 添加内发光 + draw.text((padding_x, text_y_position), text, font=font_obj, fill=outer_glow_color) + + # 添加主文本 + draw.text((padding_x, text_y_position), text, font=font_obj, fill=glow_color) + + # 应用模糊效果增强霓虹感 + img = img.filter(ImageFilter.GaussianBlur(1)) + + elif style == "gradient": + # 渐变效果 + start_color = (255, 0, 0) # 红色 + end_color = (0, 0, 255) # 蓝色 + + # 创建渐变色文本 + gradient_img = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0)) + gradient_draw = ImageDraw.Draw(gradient_img) + + # 绘制渐变背景 + for y in range(text_height): + r = int(start_color[0] + (end_color[0] - start_color[0]) * y / text_height) + g = int(start_color[1] + (end_color[1] - start_color[1]) * y / text_height) + b = int(start_color[2] + (end_color[2] - start_color[2]) * y / text_height) + gradient_draw.line([(0, y), (text_width, y)], fill=(r, g, b, 255)) + + # 创建文本蒙版 + mask = Image.new('L', (text_width, text_height), 0) + mask_draw = ImageDraw.Draw(mask) + mask_draw.text((0, 0), text, font=font_obj, fill=255) + + # 应用蒙版到渐变图像 + gradient_text = Image.new('RGBA', (text_width, text_height), (0, 0, 0, 0)) + gradient_text.paste(gradient_img, (0, 0), mask) + + # 添加描边 + if border: + for offset_x, offset_y in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + draw.text((padding_x + offset_x, text_y_position + offset_y), text, font=font_obj, fill=border_color) + + # 将渐变文本粘贴到主图像 + img.paste(gradient_text, (padding_x, text_y_position), gradient_text) + + else: # 默认样式 + # 添加描边 + if border: + for offset_x, offset_y in [(1, 0), (-1, 0), (0, 1), (0, -1)]: + draw.text((padding_x + offset_x, text_y_position + offset_y), text, font=font_obj, fill=border_color) + + # 绘制主文本 + draw.text((padding_x, text_y_position), text, font=font_obj, fill="#FFFFFF") + + # 保存为临时文件 + temp_img_path = os.path.join(utils.storage_dir("temp", create=True), f"title_sticker_{str(uuid.uuid4())}.png") + img.save(temp_img_path, format="PNG") + + # 创建图像剪辑 + clip = ImageClip(temp_img_path) + + # 删除临时文件 + try: + os.remove(temp_img_path) + except Exception as e: + logger.warning(f"Failed to remove temporary image file: {e}") + + return clip + + +def create_art_text_clip(text, font, font_size, color, art_font_type, art_font_background, size, text_align='center'): + """ + 创建艺术字体字幕 + + :param text: 文本内容 + :param font: 字体路径 + :param font_size: 字体大小 + :param color: 字体颜色 + :param art_font_type: 艺术字体类型(normal, shadow, outline, 3d, neon, metallic) + :param art_font_background: 背景颜色 + :param size: 字幕大小 + :param text_align: 文本对齐方式 + :return: TextClip对象 + """ + width, height = size[0], None + + # 创建一个透明背景的图像 + # 首先计算文本高度 + font_obj = ImageFont.truetype(font, font_size) + lines = text.split('\n') + total_height = 0 + for line in lines: + left, top, right, bottom = font_obj.getbbox(line) + line_height = bottom - top + total_height += line_height + 10 # 添加行间距 + + # 创建背景图像 + if art_font_background != "none" and art_font_background != "": + # 如果是预定义颜色 + if art_font_background in ["red", "blue", "green", "yellow", "purple", "orange"]: + bg_colors = { + "red": (255, 0, 0, 180), + "blue": (0, 0, 255, 180), + "green": (0, 128, 0, 180), + "yellow": (255, 255, 0, 180), + "purple": (128, 0, 128, 180), + "orange": (255, 165, 0, 180) + } + bg_color = bg_colors.get(art_font_background, (255, 0, 0, 180)) + else: + # 如果是自定义颜色(如#FF0000) + try: + # 将十六进制颜色代码转换为RGBA + hex_color = art_font_background.lstrip('#') + r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + bg_color = (r, g, b, 180) # 半透明 + except Exception: + bg_color = (255, 0, 0, 180) # 默认红色 + + # 创建背景图像,比文本区域稍大一些 + bg_img = Image.new('RGBA', (width, total_height + 40), (0, 0, 0, 0)) + draw = ImageDraw.Draw(bg_img) + # 绘制圆角矩形背景 + draw.rounded_rectangle([(10, 5), (width-10, total_height+35)], radius=20, fill=bg_color) + else: + bg_img = Image.new('RGBA', (width, total_height + 40), (0, 0, 0, 0)) + + # 创建文本图像 + txt_img = Image.new('RGBA', (width, total_height + 40), (0, 0, 0, 0)) + draw = ImageDraw.Draw(txt_img) + + # 解析颜色 + try: + if color.startswith('#'): + hex_color = color.lstrip('#') + r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + text_color = (r, g, b, 255) + else: + text_color = (255, 255, 255, 255) # 默认白色 + except Exception: + text_color = (255, 255, 255, 255) # 默认白色 + + # 根据艺术字体类型应用不同的效果 + y_offset = 20 # 初始垂直偏移 + for line in lines: + # 计算文本宽度以实现居中对齐 + left, top, right, bottom = font_obj.getbbox(line) + text_width = right - left + line_height = bottom - top + + if text_align == 'center': + x_position = (width - text_width) // 2 + elif text_align == 'right': + x_position = width - text_width - 20 + else: # left + x_position = 20 + + if art_font_type == "shadow": + # 阴影效果 + shadow_offset = max(2, font_size // 20) # 阴影偏移量 + draw.text((x_position + shadow_offset, y_offset + shadow_offset), line, font=font_obj, fill=(0, 0, 0, 160)) + draw.text((x_position, y_offset), line, font=font_obj, fill=text_color) + + elif art_font_type == "outline": + # 描边效果 + outline_size = max(2, font_size // 25) # 描边大小 + # 绘制描边(四个方向) + for dx, dy in [(-1,-1), (-1,1), (1,-1), (1,1), (-outline_size,0), (outline_size,0), (0,-outline_size), (0,outline_size)]: + draw.text((x_position + dx, y_offset + dy), line, font=font_obj, fill=(0, 0, 0, 200)) + # 绘制主文本 + draw.text((x_position, y_offset), line, font=font_obj, fill=text_color) + + elif art_font_type == "3d": + # 3D立体效果 + depth = max(3, font_size // 15) # 3D深度 + for i in range(depth, 0, -1): + alpha = 100 + (155 * i // depth) # 渐变透明度 + shadow_color = (0, 0, 0, alpha) + draw.text((x_position - i, y_offset + i), line, font=font_obj, fill=shadow_color) + # 绘制主文本 + draw.text((x_position, y_offset), line, font=font_obj, fill=text_color) + + elif art_font_type == "neon": + # 霓虹灯效果 + glow_iterations = 5 + glow_color = (0, 255, 255, 50) # 青色荧光 + for i in range(glow_iterations, 0, -1): + blur_radius = i * 2 + for dx, dy in [(j, k) for j in range(-1, 2) for k in range(-1, 2)]: + draw.text((x_position + dx * blur_radius, y_offset + dy * blur_radius), + line, font=font_obj, fill=(glow_color[0], glow_color[1], glow_color[2], glow_color[3] // i)) + # 绘制主文本 + draw.text((x_position, y_offset), line, font=font_obj, fill=text_color) + + elif art_font_type == "metallic": + # 金属效果 + # 金属渐变色 + metallic_base = (212, 175, 55, 255) # 金色基色 + metallic_highlight = (255, 223, 0, 255) # 金色高光 + + # 绘制金属效果的底色 + draw.text((x_position, y_offset), line, font=font_obj, fill=metallic_base) + + # 添加高光效果 + highlight_offset = max(1, font_size // 30) + draw.text((x_position - highlight_offset, y_offset - highlight_offset), + line, font=font_obj, fill=(255, 255, 255, 100)) + + # 添加阴影增强金属感 + shadow_offset = max(1, font_size // 25) + draw.text((x_position + shadow_offset, y_offset + shadow_offset), + line, font=font_obj, fill=(100, 100, 100, 100)) + + else: # normal + # 普通文本 + draw.text((x_position, y_offset), line, font=font_obj, fill=text_color) + + y_offset += line_height + 10 # 移动到下一行 + + # 合并背景和文本图像 + final_img = Image.alpha_composite(bg_img, txt_img) + + # 如果是霓虹灯效果,添加模糊 + if art_font_type == "neon": + final_img = final_img.filter(ImageFilter.GaussianBlur(1)) + + # 将PIL图像转换为TextClip + # 需要先保存为临时文件 + temp_img_path = os.path.join(utils.storage_dir("temp", create=True), f"art_text_{str(uuid.uuid4())}.png") + final_img.save(temp_img_path, format="PNG") + + # 创建图像剪辑 + clip = ImageClip(temp_img_path) + + # 删除临时文件 + try: + os.remove(temp_img_path) + except Exception as e: + logger.warning(f"Failed to remove temporary image file: {e}") + + return clip + + def wrap_text(text, max_width, font="Arial", fontsize=60): # Create ImageFont font = ImageFont.truetype(font, fontsize) @@ -279,18 +619,34 @@ def generate_video( wrapped_txt, txt_height = wrap_text( phrase, max_width=max_width, font=font_path, fontsize=params.font_size ) - _clip = TextClip( - text=wrapped_txt, - font=font_path, - font_size=params.font_size, - color=params.text_fore_color, - bg_color=params.text_background_color, - stroke_color=params.stroke_color, - stroke_width=params.stroke_width, - size=(video_width, None), - method='caption', - text_align='center' - ) + + # 判断是否启用艺术字体 + if hasattr(params, 'art_font_enabled') and params.art_font_enabled: + # 创建艺术字体 + _clip = create_art_text_clip( + text=wrapped_txt, + font=font_path, + font_size=params.font_size, + color=params.text_fore_color, + art_font_type=params.art_font_type, + art_font_background=params.art_font_background, + size=(video_width, None), + text_align='center' + ) + else: + # 使用普通字幕 + _clip = TextClip( + text=wrapped_txt, + font=font_path, + font_size=params.font_size, + color=params.text_fore_color, + bg_color=params.text_background_color, + stroke_color=params.stroke_color, + stroke_width=params.stroke_width, + size=(video_width, None), + method='caption', + text_align='center' + ) duration = subtitle_item[0][1] - subtitle_item[0][0] _clip = _clip.with_start(subtitle_item[0][0]) _clip = _clip.with_end(subtitle_item[0][1]) @@ -319,15 +675,61 @@ def generate_video( ) def make_textclip(text): - return TextClip( - text=text, - font=font_path, - font_size=params.font_size, - size=(video_width, None), - method='caption', - text_align='center' + # 判断是否启用艺术字体 + if hasattr(params, 'art_font_enabled') and params.art_font_enabled: + # 创建艺术字体 + return create_art_text_clip( + text=text, + font=font_path, + font_size=params.font_size, + color=params.text_fore_color, + art_font_type=params.art_font_type, + art_font_background=params.art_font_background, + size=(video_width, None), + text_align='center' + ) + else: + # 使用普通字幕 + return TextClip( + text=text, + font=font_path, + font_size=params.font_size, + size=(video_width, None), + method='caption', + text_align='center' + ) + + # 创建所有视频元素的列表 + video_elements = [video_clip] + + # 添加标题贴纸 + if hasattr(params, 'title_sticker_enabled') and params.title_sticker_enabled and params.title_sticker_text: + # 获取标题贴纸字体路径 + title_font_path = os.path.join(utils.font_dir(), params.title_sticker_font) + if os.name == "nt": + title_font_path = title_font_path.replace("\\", "/") + + # 创建标题贴纸 + title_sticker = create_title_sticker( + text=params.title_sticker_text, + font=title_font_path, + font_size=params.title_sticker_font_size, + style=params.title_sticker_style, + background=params.title_sticker_background, + background_color=params.title_sticker_background_color, + border=params.title_sticker_border, + border_color=params.title_sticker_border_color, + size=(video_width, video_height) ) + # 设置标题贴纸位置(顶部中间) + if title_sticker: + title_sticker = title_sticker.with_position(("center", video_height * 0.05)) + title_sticker = title_sticker.with_duration(video_clip.duration) + video_elements.append(title_sticker) + logger.info(f"Added title sticker: {params.title_sticker_text}") + + # 添加字幕 if subtitle_path and os.path.exists(subtitle_path): sub = SubtitlesClip( subtitles=subtitle_path, encoding="utf-8", make_textclip=make_textclip @@ -336,7 +738,10 @@ def generate_video( for item in sub.subtitles: clip = create_text_clip(subtitle_item=item) text_clips.append(clip) - video_clip = CompositeVideoClip([video_clip, *text_clips]) + video_elements.extend(text_clips) + + # 合成所有视频元素 + video_clip = CompositeVideoClip(video_elements) bgm_file = get_bgm_file(bgm_type=params.bgm_type, bgm_file=params.bgm_file) if bgm_file: diff --git a/webui/Main.py b/webui/Main.py index bfc29b7..d83b7fc 100644 --- a/webui/Main.py +++ b/webui/Main.py @@ -773,6 +773,199 @@ with right_panel: with stroke_cols[1]: params.stroke_width = st.slider(tr("Stroke Width"), 0.0, 10.0, 1.5) + # 艺术字体设置 + st.write(tr("Art Font Settings")) + params.art_font_enabled = st.checkbox(tr("Enable Art Font"), value=False) + + if params.art_font_enabled: + art_font_types = [ + (tr("Normal"), "normal"), + (tr("Shadow"), "shadow"), + (tr("Outline"), "outline"), + (tr("3D"), "3d"), + (tr("Neon"), "neon"), + (tr("Metallic"), "metallic"), + ] + selected_index = st.selectbox( + tr("Art Font Type"), + index=0, + options=range(len(art_font_types)), + format_func=lambda x: art_font_types[x][0], + ) + params.art_font_type = art_font_types[selected_index][1] + + art_font_backgrounds = [ + (tr("None"), "none"), + (tr("Red"), "red"), + (tr("Blue"), "blue"), + (tr("Green"), "green"), + (tr("Yellow"), "yellow"), + (tr("Purple"), "purple"), + (tr("Orange"), "orange"), + (tr("Custom"), "custom"), + ] + selected_index = st.selectbox( + tr("Art Font Background"), + index=0, + options=range(len(art_font_backgrounds)), + format_func=lambda x: art_font_backgrounds[x][0], + ) + params.art_font_background = art_font_backgrounds[selected_index][1] + + if params.art_font_background == "custom": + params.art_font_background = st.color_picker(tr("Custom Background Color"), "#FF0000") + + # 预览效果 + st.write(tr("Preview")) + preview_text = tr("Art Font Preview") + + # 根据选择的艺术字体类型生成不同的CSS样式 + font_style = "" + bg_style = "background-color: #333;" + + # 设置背景颜色 + if params.art_font_background != "none": + if params.art_font_background == "red": + bg_style = "background-color: rgba(255, 0, 0, 0.7);" + elif params.art_font_background == "blue": + bg_style = "background-color: rgba(0, 0, 255, 0.7);" + elif params.art_font_background == "green": + bg_style = "background-color: rgba(0, 128, 0, 0.7);" + elif params.art_font_background == "yellow": + bg_style = "background-color: rgba(255, 255, 0, 0.7);" + elif params.art_font_background == "purple": + bg_style = "background-color: rgba(128, 0, 128, 0.7);" + elif params.art_font_background == "orange": + bg_style = "background-color: rgba(255, 165, 0, 0.7);" + elif params.art_font_background.startswith("#"): + # 处理自定义颜色 + bg_style = f"background-color: {params.art_font_background}aa;" + + # 设置字体样式 + if params.art_font_type == "normal": + font_style = f"color: {params.text_fore_color}; font-weight: bold;" + elif params.art_font_type == "shadow": + font_style = f"color: {params.text_fore_color}; font-weight: bold; text-shadow: 3px 3px 5px #000;" + elif params.art_font_type == "outline": + font_style = f"color: {params.text_fore_color}; font-weight: bold; text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;" + elif params.art_font_type == "3d": + font_style = f"color: {params.text_fore_color}; font-weight: bold; text-shadow: 0px 1px 0px #999, 0px 2px 0px #888, 0px 3px 0px #777, 0px 4px 0px #666, 0px 5px 0px #555, 0px 6px 0px #444, 0px 7px 0px #333, 0px 8px 7px #001135;" + elif params.art_font_type == "neon": + font_style = f"color: {params.text_fore_color}; font-weight: bold; text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 15px #0073e6, 0 0 20px #0073e6, 0 0 25px #0073e6, 0 0 30px #0073e6, 0 0 35px #0073e6;" + elif params.art_font_type == "metallic": + font_style = f"color: {params.text_fore_color}; font-weight: bold; background: -webkit-linear-gradient(#eee, #333); -webkit-background-clip: text; -webkit-text-fill-color: transparent; text-shadow: 2px 2px 4px rgba(0,0,0,0.3);" + + # 生成HTML预览 + preview_html = f""" +