272 lines
11 KiB
Python
272 lines
11 KiB
Python
import torch
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import ast
|
|
|
|
|
|
def parse_timestamp(timestamp_str):
|
|
# Remove leading/trailing whitespace and brackets
|
|
timestamp_str = timestamp_str.strip()
|
|
if not (timestamp_str.startswith('[') and timestamp_str.endswith(']')):
|
|
# Handle cases where brackets might be missing, though the format suggests they are present
|
|
# If strict format is required, could raise an error
|
|
print(f"Warning: Timestamp string missing brackets: {timestamp_str}")
|
|
inner_str = timestamp_str
|
|
else:
|
|
inner_str = timestamp_str[1:-1] # Remove '[' and ']'
|
|
|
|
try:
|
|
# Split into minutes and seconds.milliseconds
|
|
parts = inner_str.split(':')
|
|
if len(parts) != 2:
|
|
print(f"Warning: Timestamp format incorrect (expecting MM:SS.mmm): {timestamp_str}")
|
|
# Attempt to parse as seconds if possible
|
|
try:
|
|
return float(inner_str)
|
|
except ValueError:
|
|
print(f"Error parsing timestamp: {timestamp_str}. Returning 0.0.")
|
|
return 0.0
|
|
|
|
minutes_str = parts[0]
|
|
sec_milli_str = parts[1]
|
|
|
|
# Split seconds.milliseconds into seconds and milliseconds
|
|
sec_parts = sec_milli_str.split('.')
|
|
seconds_str = sec_parts[0]
|
|
milliseconds_str = sec_parts[1] if len(sec_parts) > 1 else '0' # Handle missing .mmm
|
|
|
|
# Convert to numbers
|
|
minutes = int(minutes_str)
|
|
seconds = int(seconds_str)
|
|
# Pad milliseconds with zeros if necessary (e.g., '5' -> '500')
|
|
milliseconds_str = milliseconds_str.ljust(3, '0')
|
|
milliseconds = int(milliseconds_str[:3]) # Take only the first 3 digits
|
|
|
|
total_seconds = float(minutes * 60 + seconds + milliseconds / 1000.0)
|
|
return total_seconds
|
|
|
|
except ValueError:
|
|
print(f"Error parsing timestamp: {timestamp_str}. Returning 0.0.")
|
|
return 0.0
|
|
except Exception as e:
|
|
print(f"An unexpected error occurred while parsing timestamp {timestamp_str}: {e}. Returning 0.0.")
|
|
return 0.0
|
|
|
|
|
|
def convert_subtitle_to_list(subtitle_string, fallback_duration, max_duration_of_segment):
|
|
lines = subtitle_string.strip().split('\n')
|
|
|
|
parsed_entries = []
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line: # Skip empty lines
|
|
continue
|
|
|
|
# Find the end of the timestamp part (the closing bracket)
|
|
end_bracket_index = line.find(']')
|
|
|
|
if end_bracket_index == -1 or not line.startswith('['):
|
|
# Line doesn't look like a subtitle line with timestamp
|
|
print(f"Warning: Skipping line that doesn't match [MM:SS.mmm]Text format: {line}")
|
|
continue
|
|
|
|
# Extract timestamp string and text
|
|
timestamp_str = line[:end_bracket_index + 1] # Includes the brackets
|
|
text = line[end_bracket_index + 1:]
|
|
|
|
# Parse the timestamp string into seconds (float)
|
|
start_time = parse_timestamp(timestamp_str)
|
|
|
|
parsed_entries.append({'start_time': start_time, 'text': text})
|
|
|
|
result_list = []
|
|
num_entries = len(parsed_entries)
|
|
|
|
for i in range(num_entries):
|
|
start_time = parsed_entries[i]['start_time']
|
|
text = parsed_entries[i]['text']
|
|
|
|
end_time = None
|
|
|
|
# Rule: End time is the start time of the *next* entry minus 0.2 seconds
|
|
if i < num_entries - 1:
|
|
next_start_time = parsed_entries[i+1]['start_time']
|
|
end_time = next_start_time - 0.05
|
|
else:
|
|
fallback_duration = fallback_duration
|
|
end_time = start_time + fallback_duration
|
|
|
|
minimal_duration = 0.01 # A very small duration to avoid end_time <= start_time
|
|
if end_time <= start_time:
|
|
end_time = start_time + minimal_duration
|
|
if end_time - start_time > max_duration_of_segment:
|
|
end_time = round(start_time + max_duration_of_segment, 2)
|
|
|
|
# Append to result list
|
|
result_list.append({'timestamp': [start_time, round(end_time,2)], 'text': text})
|
|
|
|
return result_list
|
|
|
|
|
|
class AddSubtitlesToTensor:
|
|
"""
|
|
ComfyUI 节点,用于将字幕添加到视频帧的张量中。
|
|
"""
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
import os
|
|
font_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ChironGoRoundTC-600SB.ttf")
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE",),
|
|
"language": (["类中文字符", "English_word"], {"default": "类中文字符"}),
|
|
"max_words_per_line": (
|
|
"INT", {"default": 12, "min": 1, "max": 30}),
|
|
"font_path": (
|
|
"STRING", {"default": font_path}),
|
|
"font_size": ("INT", {"default": 30, "min": 1, "max": 200}),
|
|
"font_color": ("STRING", {"default": "black", "tooltip": "HTML color or Color name"}),
|
|
"subtitle_background_color": (
|
|
"STRING", {"default": "#B8860B", "tooltip": "HTML color or Color name. None: Do not add background color"}),
|
|
"y_offset": (
|
|
"INT", {"default": 0, "min": -1000, "max": 1000, "step": 5}),
|
|
"x_offset": (
|
|
"INT", {"default": 0, "min": -1000, "max": 1000, "step": 5}),
|
|
"width": ("INT", {"default": 1024}),
|
|
"height": ("INT", {"default": 1024}),
|
|
"fps": ("FLOAT", {"default": 20, "min": 1, "max": 240}),
|
|
},
|
|
"optional": {
|
|
"json_text": ("STRING", {"forceInput": True}),
|
|
"subtitle_text": ("STRING", {"forceInput": True}),
|
|
"max_duration_of_segment": ("FLOAT", {"default": 6, "min": 0.1, "max": 30}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("image",)
|
|
|
|
FUNCTION = "add_subtitles"
|
|
|
|
CATEGORY = "🎤MW/MW-Audio-Tools"
|
|
|
|
def add_subtitles(self,
|
|
images,
|
|
language,
|
|
font_path,
|
|
font_size,
|
|
font_color,
|
|
y_offset,
|
|
x_offset,
|
|
width,
|
|
height,
|
|
max_words_per_line,
|
|
subtitle_background_color,
|
|
fps,
|
|
subtitle_text=None,
|
|
max_duration_of_segment=6,
|
|
json_text=None,
|
|
):
|
|
|
|
if subtitle_background_color == "None":
|
|
subtitle_background_color = None
|
|
|
|
assert subtitle_text or json_text, "Either subtitle_text or json_text must be provided."
|
|
fallback_duration = len(images) / fps - 0.2
|
|
if subtitle_text:
|
|
subtitles = convert_subtitle_to_list(subtitle_text, fallback_duration, max_duration_of_segment)
|
|
else:
|
|
subtitles = ast.literal_eval(json_text)
|
|
|
|
try:
|
|
if not isinstance(subtitles, list):
|
|
raise ValueError("Subtitles must be a list of dictionaries.")
|
|
for sub in subtitles:
|
|
if not isinstance(sub, dict):
|
|
raise ValueError("Each subtitle must be a dictionary.")
|
|
except Exception as e:
|
|
print(f"Error parsing subtitles: {e}")
|
|
|
|
if not isinstance(images, torch.Tensor):
|
|
print("Images must be a torch tensor")
|
|
return (images,)
|
|
|
|
print(f"images.shape:{images.shape}")
|
|
if len(images.shape) == 3:
|
|
images = images.unsqueeze(0)
|
|
images = torch.clamp(images, 0.0, 1.0)
|
|
font = ImageFont.truetype(font_path, font_size)
|
|
|
|
# x_offset = - x_offset
|
|
y_offset = - y_offset
|
|
|
|
num_frames = images.shape[0]
|
|
for i in range(num_frames):
|
|
frame_time = i / fps
|
|
|
|
relevant_subtitles = [
|
|
sub
|
|
for sub in subtitles
|
|
if sub["timestamp"][0] <= frame_time <= sub["timestamp"][1]
|
|
]
|
|
|
|
if relevant_subtitles:
|
|
frame = Image.fromarray((images[i].numpy() * 255).astype(np.uint8))
|
|
draw = ImageDraw.Draw(frame)
|
|
|
|
# 重置 y_offset
|
|
current_y_offset = y_offset
|
|
|
|
for sub in relevant_subtitles:
|
|
text = sub["text"]
|
|
wrapped_text = self.wrap_text(text, language, max_words_per_line)
|
|
|
|
bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
|
|
# 计算居中后的坐标
|
|
center_x = width / 2 + x_offset
|
|
center_y = height / 2 + current_y_offset
|
|
|
|
# 计算左上角坐标
|
|
x = center_x - text_width / 2
|
|
y = center_y - text_height / 2
|
|
|
|
if subtitle_background_color:
|
|
bbox = draw.multiline_textbbox((x, y), wrapped_text, font=font)
|
|
bg_x0 = bbox[0]
|
|
bg_y0 = bbox[1]
|
|
bg_x1 = bbox[2]
|
|
bg_y1 = bbox[3]
|
|
draw.rectangle((bg_x0, bg_y0, bg_x1, bg_y1), fill=subtitle_background_color)
|
|
|
|
draw.multiline_text((x, y), wrapped_text, font=font, fill=font_color)
|
|
|
|
# 更新 y 偏移量,以便下一条字幕显示在下方 (如果需要显示多条字幕)
|
|
current_y_offset += text_height + 10
|
|
|
|
images[i] = torch.from_numpy(np.array(frame).astype(np.float32) / 255)
|
|
|
|
return (images,)
|
|
def wrap_text(self, text, language, max_words_per_line):
|
|
words = text.split()
|
|
wrapped_lines = []
|
|
current_line = []
|
|
if language == "类中文字符":
|
|
for i in range(0, len(text), max_words_per_line):
|
|
wrapped_lines.append(text[i:i + max_words_per_line].strip())
|
|
else:
|
|
for word in words:
|
|
if len(current_line) < max_words_per_line:
|
|
current_line.append(word)
|
|
else:
|
|
wrapped_lines.append(" ".join(current_line))
|
|
current_line = [word]
|
|
|
|
if current_line:
|
|
wrapped_lines.append(" ".join(current_line))
|
|
|
|
return "\n".join(wrapped_lines)
|