diff --git a/AddSubtitlesToVideo.py b/AddSubtitlesToVideo.py index d461e9f..c32109b 100644 --- a/AddSubtitlesToVideo.py +++ b/AddSubtitlesToVideo.py @@ -4,21 +4,125 @@ 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): + 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.2 + else: + fallback_duration = 1.5 + 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 + + # 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",), - "subtitles": ("STRING", ), + "images": ("IMAGE",), "language": (["类中文字符", "English_word"], {"default": "类中文字符"}), "max_words_per_line": ( "INT", {"default": 12, "min": 1, "max": 30}), "font_path": ( - "STRING", {"default": "simhei.ttf"}), + "STRING", {"default": font_path}), "font_size": ("INT", {"default": 30, "min": 1, "max": 200}), "font_color": ("STRING", {"default": "black"}), "subtitle_background_color": ( @@ -30,6 +134,10 @@ class AddSubtitlesToTensor: "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}), } } @@ -42,7 +150,6 @@ class AddSubtitlesToTensor: def add_subtitles(self, images, - subtitles, language, font_path, font_size, @@ -54,13 +161,20 @@ class AddSubtitlesToTensor: max_words_per_line, subtitle_background_color, fps, + subtitle_text=None, + 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." + if subtitle_text: + subtitles = convert_subtitle_to_list(subtitle_text) + else: + subtitles = ast.literal_eval(json_text) + try: - subtitles = ast.literal_eval(subtitles) if not isinstance(subtitles, list): raise ValueError("Subtitles must be a list of dictionaries.") for sub in subtitles: diff --git a/ChironGoRoundTC-600SB.ttf b/ChironGoRoundTC-600SB.ttf new file mode 100644 index 0000000..6ab3536 Binary files /dev/null and b/ChironGoRoundTC-600SB.ttf differ diff --git a/__init__.py b/__init__.py index 2298971..f944bc7 100644 --- a/__init__.py +++ b/__init__.py @@ -1,13 +1,12 @@ from .audiotoolsnode import * -from .string_editor_node import StringEditorPersistentTempFileNode from .minimal_pause_node import MinimalPauseNode from .AddSubtitlesToVideo import AddSubtitlesToTensor from .MWAudioRecorderAT import AudioRecorderAT +from .stringnodes import StringDisplayNode, StringPauseGateNode NODE_CLASS_MAPPINGS = { "SaveAudioMW": SaveAudioMW, "LoadAudioMW": LoadAudioMW, - "StringEditorPersistentTempFileNode": StringEditorPersistentTempFileNode, "MinimalPauseNode": MinimalPauseNode, "AudioConcatenate": AudioConcatenate, "AudioAddWatermark": AudioAddWatermark, @@ -18,12 +17,13 @@ NODE_CLASS_MAPPINGS = { "AudioRecorderAT": AudioRecorderAT, "AddSubtitlesToVideo": AddSubtitlesToTensor, "MultiLinePromptAT": MultiLinePromptAT, + "StringDisplayNode": StringDisplayNode, + "StringPauseGateNode": StringPauseGateNode, } NODE_DISPLAY_NAME_MAPPINGS = { "SaveAudioMW": "Save Audio @MW", "LoadAudioMW": "Load Audio @MW", - "StringEditorPersistentTempFileNode": "Show or Edit String", "MinimalPauseNode": "Pause Node", "AudioConcatenate": "Audio Concatenate", "AudioAddWatermark": "Audio Watermark Embedding", @@ -34,10 +34,10 @@ NODE_DISPLAY_NAME_MAPPINGS = { "AudioRecorderAT": "MW Audio Recorder", "AddSubtitlesToVideo": "Add Subtitles To Video", "MultiLinePromptAT": "Multi-Line Prompt", + "StringDisplayNode": "📺 String Viewer", + "StringPauseGateNode": "⏸ String Pause Gate", } __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] -EXTENSION_NAME = "StringEditor" - WEB_DIRECTORY = "./web" diff --git a/images/2025-03-25_14-00-28.png b/images/2025-03-25_14-00-28.png index 67bf781..c427e27 100644 Binary files a/images/2025-03-25_14-00-28.png and b/images/2025-03-25_14-00-28.png differ diff --git a/images/2025-04-25_15-48-54.png b/images/2025-04-25_15-48-54.png index 6253781..f90b5e7 100644 Binary files a/images/2025-04-25_15-48-54.png and b/images/2025-04-25_15-48-54.png differ diff --git a/images/20250426115357.png b/images/20250426115357.png index 4ccd00b..2e11aea 100644 Binary files a/images/20250426115357.png and b/images/20250426115357.png differ diff --git a/pyproject.toml b/pyproject.toml index 669bc05..6a676db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "audiotools" description = "A ComfyUI node containing multiple audio processing tools." -version = "1.0.8" +version = "1.1.0" license = {file = "LICENSE"} dependencies = ["sox", "librosa"] diff --git a/string_editor_node.py b/string_editor_node.py deleted file mode 100644 index 7a05022..0000000 --- a/string_editor_node.py +++ /dev/null @@ -1,176 +0,0 @@ -import uuid -from pathlib import Path -from aiohttp import web -import folder_paths -from server import PromptServer -from comfy_execution.graph import ExecutionBlocker -import json -import time - -TEMP_SUBFOLDER = "string-editor-persistent-state" - -def get_persistent_temp_file_path(node_id: str) -> Path: - temp_dir = Path(folder_paths.get_temp_directory()) - state_dir = temp_dir / TEMP_SUBFOLDER - state_dir.mkdir(parents=True, exist_ok=True) - return state_dir / f"state_{node_id}.json" - -if hasattr(PromptServer, 'instance') and PromptServer.instance is not None: - routes = PromptServer.instance.routes - @routes.post('/string_editor/signal_continue') - async def signal_continue_handler(request: web.Request) -> web.Response: - try: - if request.content_type != 'application/json': - return web.json_response({"status": "error", "message": "Invalid content type"}, status=400) - data = await request.json() - node_id = data.get('node_id') - edited_string = data.get('edited_string') - if not node_id: - return web.json_response({"status": "error", "message": "node_id missing"}, status=400) - if edited_string is None: - edited_string = "" - temp_file = get_persistent_temp_file_path(node_id) - try: - state_data = {"edited_string": edited_string, "last_modified": time.time()} - with open(temp_file, 'w', encoding='utf-8') as f: - json.dump(state_data, f) - return web.json_response({"status": "ok", "message": "State saved, ready for re-queue."}) - except IOError as e: - return web.json_response({"status": "error", "message": f"Failed to write state file: {e}"}, status=500) - except json.JSONDecodeError: - return web.json_response({"status": "error", "message": "Invalid JSON format"}, status=400) - except Exception: - return web.json_response({"status": "error", "message": "Internal server error"}, status=500) - -class StringEditorPersistentTempFileNode: - @classmethod - def IS_CHANGED(cls, *args, **kwargs): - return str(uuid.uuid4()) - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "input_string": ("STRING", {"forceInput": True}), - "split_tag": ("STRING", {"default": "", "tooltip": "Split a string into two segments using a tag."}), - "pause": ("BOOLEAN", {"default": False}), - }, - "hidden": { - "prompt": "PROMPT", - "extra_pnginfo": "EXTRA_PNGINFO", - "unique_id": "UNIQUE_ID", - }, - } - - RETURN_TYPES = ("STRING", "STRING", "STRING") - RETURN_NAMES = ("edited_string", "split_1", "split_2") - FUNCTION = "edit_or_check_and_output" - CATEGORY = "🎤MW/MW-Audio-Tools" - OUTPUT_NODE = True - - def edit_or_check_and_output(self, - input_string: str, - split_tag: str, - pause: bool, - unique_id: str, - prompt=None, - extra_pnginfo=None, - **kwargs - ): - - node_id = unique_id - temp_file = get_persistent_temp_file_path(node_id) - # --- REMOVE class variable self.current_input_string - it's not needed --- - # if self.current_input_string is None: - # self.current_input_string = input_string - # -------------------------------------------------------------------- - - # Initialize string_to_display with the CURRENT input - current_input_string = str(input_string) - string_to_display = current_input_string # <<< Start with current input - - should_block = True - output_string = None # This will hold the value read from file if successful - - try: - if temp_file.exists(): - with open(temp_file, 'r', encoding='utf-8') as f: - state_data = json.load(f) - # Read the value from the file - value_from_file = state_data.get("edited_string", current_input_string) # Fallback needed? - # *** THIS IS THE KEY CHANGE *** - string_to_display = value_from_file # <<< Update string_to_display with file content - output_string = value_from_file # <<< Store the value intended for output - # ***************************** - try: - temp_file.unlink() - should_block = False - except OSError as e: - # If delete fails, we must block to avoid losing the edit - print(f"[ERROR] Node {node_id}: Failed to delete state file {temp_file}: {e}. Forcing block.") # Added print/log - should_block = True - output_string = None # Cannot guarantee output if delete failed - string_to_display = value_from_file # Still display what we read before delete failed - else: - # File doesn't exist (first run or after successful continue) - string_to_display = current_input_string # Use current input - state_data = {"edited_string": string_to_display, "last_modified": time.time()} - try: - # Create the file to prepare for blocking - with open(temp_file, 'w', encoding='utf-8') as f: - json.dump(state_data, f) - except IOError as e: - print(f"[ERROR] Node {node_id}: Failed to create initial state file {temp_file}: {e}") # Added print/log - pass # Proceed to block anyway - should_block = True - except (IOError, json.JSONDecodeError) as e: - print(f"[ERROR] Node {node_id}: Error processing state file {temp_file}: {e}. Blocking.") # Added print/log - string_to_display = current_input_string # Fallback display - should_block = True - output_string = None - try: - temp_file.unlink() # Clean up potentially corrupt file - except OSError: - pass - - # Determine if UI should be enabled - ui_should_be_enabled = pause and should_block - - # Always send the UI update with the determined string_to_display - if hasattr(PromptServer, 'instance') and PromptServer.instance is not None: - PromptServer.instance.send_sync("string_editor_persistent_tempfile_display", { - "node_id": node_id, - "string_to_edit": string_to_display, # <<< Send the correct value - "enable_ui": ui_should_be_enabled - }) - # Apply delay only if pause=True and we are *not* blocking (i.e., file processed) - if pause and not should_block: - time.sleep(0.02) - - # Final return logic - parts = ["", ""] - if split_tag.strip() != "" and split_tag in input_string: - parts = input_string.split(split_tag, 1) - if len(parts) > 1: - print("There are multiple tags in the string, only split by the first one.") - - split_1 = parts[0].strip() - split_2 = parts[1].strip() - - if not pause: - PromptServer.instance.send_sync("string_editor_persistent_tempfile_display", { - "node_id": node_id, - "string_to_edit": input_string, # <<< Send the correct value - "enable_ui": ui_should_be_enabled - }) - time.sleep(0.02) - # If pause is off, return the current input string directly - return {"result": (input_string, split_1, split_2)} - else: - # If pause is on, return Blocker or the value read from the file - if should_block: - return {"result": (ExecutionBlocker(None), ExecutionBlocker(None), ExecutionBlocker(None))} - else: - # Ensure we have a valid output string (should be from file) - final_output = output_string if output_string is not None else current_input_string # Fallback just in case - return {"result": (final_output, split_1, split_2)} \ No newline at end of file diff --git a/stringnodes.py b/stringnodes.py new file mode 100644 index 0000000..4ba4e00 --- /dev/null +++ b/stringnodes.py @@ -0,0 +1,157 @@ +# filename: ComfyUI/custom_nodes/string_display_node.py + +import uuid +from pathlib import Path +from aiohttp import web + +import folder_paths +from server import PromptServer +from comfy_execution.graph import ExecutionBlocker +import hashlib + +class StringDisplayNode: + @classmethod + def IS_CHANGED(cls, input_string, **kwargs): + # Re-run only when the input string actually changes + m = hashlib.sha256() + m.update(input_string.encode('utf-8')) # Encode string for hashing + return m.digest().hex() + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"forceInput": True, "multiline": True}), # Allow multiline input + }, + "hidden": { + # Keep unique_id to link the node instance to the UI widget + "unique_id": "UNIQUE_ID", + }, + } + + # Node is not an output node + OUTPUT_NODE = True + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text",) + FUNCTION = "display_and_output" + CATEGORY = "🎤MW/MW-Audio-Tools" # Category + + def display_and_output(self, + text: str, + unique_id: str): + # Ensure text is a string + string_to_display = str(text) + + # Send the string to the frontend to update the UI widget + if hasattr(PromptServer, 'instance') and PromptServer.instance is not None: + PromptServer.instance.send_sync("string_display_update", { + "node_id": unique_id, + "string_to_display": string_to_display, + }) + + # Immediately return the input string as the output + return {"result": (string_to_display,)} + + +TEMP_SUBFOLDER = "string-pause-signals" + +# --- Helper to get signal file path --- +def get_signal_file_path(node_id: str) -> Path: + """Gets the path for this node's signal file.""" + temp_dir = Path(folder_paths.get_temp_directory()) + signal_dir = temp_dir / TEMP_SUBFOLDER + signal_dir.mkdir(parents=True, exist_ok=True) + # File existence signals "continue" + return signal_dir / f"continue_{node_id}.signal" + +# --- HTTP Route --- +if hasattr(PromptServer, 'instance') and PromptServer.instance is not None: + routes = PromptServer.instance.routes + @routes.post('/string_pause/signal_continue') + async def signal_continue_handler(request: web.Request) -> web.Response: + """Creates the signal file to allow the node to continue.""" + try: + # No JSON needed, just check for node_id in query or form data? Let's use JSON. + if request.content_type != 'application/json': + return web.json_response({"status": "error", "message": "Invalid content type"}, status=400) + data = await request.json() + node_id = data.get('node_id') + + if not node_id: + return web.json_response({"status": "error", "message": "node_id missing"}, status=400) + + signal_file = get_signal_file_path(node_id) + + try: + # Create an empty file to signal continuation + signal_file.touch(exist_ok=True) # Create if doesn't exist, update timestamp if does + + return web.json_response({"status": "ok", "message": "Continue signal sent."}) + except IOError as e: + + return web.json_response({"status": "error", "message": f"Failed to create signal file: {e}"}, status=500) + except Exception as e: + + return web.json_response({"status": "error", "message": "Internal server error"}, status=500) + + +class StringPauseGateNode: + """A node that simply pauses workflow execution until continued via UI.""" + + # Force execution so the file check always runs + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + return str(uuid.uuid4()) + + @classmethod + def INPUT_TYPES(cls): + # Takes any input type just to allow connection, but doesn't use it + return { + "required": { + "text": ("STRING", {"forceInput": True}), + }, + "optional": { + "paste_edit_text": ("STRING", {"multiline": True, "default": "",}), + "merge_text": ("BOOLEAN", {"default": False}), + "enable_pause_gate": ("BOOLEAN", {"default": True}), + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO", + "unique_id": "UNIQUE_ID", + }, + } + + # Returns the same type it received + RETURN_TYPES = ("STRING", "STRING",) + RETURN_NAMES = ("text", "edited_text",) + FUNCTION = "pause_or_continue" + CATEGORY = "🎤MW/MW-Audio-Tools" # Simple category + OUTPUT_NODE = False + + def pause_or_continue(self, text, paste_edit_text, merge_text, enable_pause_gate, unique_id: str, prompt=None, extra_pnginfo=None, **kwargs): + node_id = unique_id + + signal_file = get_signal_file_path(node_id) + + if merge_text: + text = paste_edit_text = text + paste_edit_text + + if not enable_pause_gate: + return {"result": (text, paste_edit_text)} + else: + if signal_file.exists(): + # Signal file exists - User clicked Continue + try: + signal_file.unlink() # Delete the signal file to reset for next time + except OSError as e: + print(f"Failed to delete signal file: {e}") + # Return the text data + return {"result": (text, paste_edit_text)} + else: + # Send message to UI to enable the button + if hasattr(PromptServer, 'instance') and PromptServer.instance is not None: + PromptServer.instance.send_sync("string_pause_enable_button", { "node_id": node_id }) + # Return ExecutionBlocker for the text output + return {"result": (ExecutionBlocker(None),)} diff --git a/web/minimal_pause_node.js b/web/minimal_pause_node.js index 98cc704..f0993b0 100644 --- a/web/minimal_pause_node.js +++ b/web/minimal_pause_node.js @@ -34,12 +34,14 @@ app.registerExtension({ // No serialize needed usually for simple buttons }); - // // --- computeSize (Minimal Height) --- - // widget.computeSize = function(width) { - // // Enough height for the button and padding - // const minHeight = 10; - // return [width, minHeight]; - // } + // --- computeSize (Minimal Height) --- + widget.computeSize = function(width) { + // Enough height for the button and padding + // Assuming your button + padding needs about 30-40px height + const minHeight = 35; // Adjust this value based on actual button size + padding + // computeSize should return [width, height] + return [width, minHeight]; + } // --- Button Click Handler --- button.addEventListener("click", async () => { diff --git a/web/string_editor_node.js b/web/string_editor_node.js deleted file mode 100644 index 96e0fea..0000000 --- a/web/string_editor_node.js +++ /dev/null @@ -1,137 +0,0 @@ -// filename: ComfyUI/web/extensions/string_editor_persistent_tempfile_node.js -import { app } from "../../scripts/app.js"; -import { api } from "../../scripts/api.js"; - -app.registerExtension({ - name: "Comfy.StringEditorPersistentTempFileNode", - - async nodeCreated(node) { - if (node.constructor.comfyClass !== "StringEditorPersistentTempFileNode") { - return; - } - console.log("StringEditorPersistentTempFileNode: nodeCreated", node.id); - - const container = document.createElement("div"); - container.style.display = "flex"; - container.style.flexDirection = "column"; - // Removed justify-content, let natural flow happen - // Removed height: 100% - container.style.padding = "2px"; - container.style.margin = "0"; - container.style.gap = "2px"; // Smaller gap - // --- Control Overflow --- - container.style.overflow = "hidden"; // Prevent container itself from overflowing node bounds - // ----------------------- - - // const label = document.createElement("label"); - // label.textContent = "Edit Text & Continue (Persistent):"; - // label.style.fontSize = "0.8em"; - // label.style.color = "var(--descrip-text)"; - // label.style.marginBottom = "3px"; - // label.style.flexShrink = "0"; - // container.appendChild(label); // Label directly in container - - const textarea = document.createElement("textarea"); - textarea.rows = 50; // Keep large default rows - textarea.placeholder = "Workflow paused. Edit text and click Continue."; - // --- Control Textarea Size/Overflow --- - // Let textarea be naturally sized by rows initially, but limit max height - textarea.style.minHeight = "160px"; // Reasonable minimum - textarea.style.maxHeight = "600px"; // Set a sensible maximum height (adjust as needed) - textarea.style.overflowY = "auto"; // Add scrollbar if content exceeds max height - textarea.style.resize = "vertical"; - textarea.style.padding = "1px"; - textarea.style.border = "1px solid var(--border-color)"; - textarea.style.backgroundColor = "var(--comfy-input-bg)"; - textarea.style.color = "var(--input-text)"; - textarea.style.width = "calc(100% - 1px)"; // Ensure it fits horizontally within padding - textarea.disabled = true; - node.stringEditorTextarea = textarea; - container.appendChild(textarea); // Textarea directly in container - - const button = document.createElement("button"); - button.textContent = "Continue Workflow"; - button.style.padding = "3px 10px"; - button.style.marginTop = "5px"; - button.style.cursor = "pointer"; - button.style.border = "1px solid var(--border-color)"; - button.style.backgroundColor = "var(--comfy-input-bg)"; - button.style.color = "var(--input-text)"; - button.style.flexShrink = "0"; // Prevent shrinking - button.style.borderRadius = "15px"; - button.disabled = true; - node.stringEditorButton = button; - container.appendChild(button); // Button directly in container - - const widget = node.addDOMWidget(`string_editor_persistent_widget_${node.id}`, "dom", container, {}); - - // --- computeSize (Simpler - let content mostly decide) --- - widget.computeSize = function(width) { - // Estimate minimum based on non-textarea elements + small textarea amount - const minHeight = 250; // Minimum needed for label, button, small textarea - // Get the container's *current* scrollHeight as a proxy for desired height - let contentHeight = container.scrollHeight; - const maxHeight = 800; // Absolute max node height (adjust) - - // Use scrollHeight, but cap it and ensure minimum - let targetHeight = Math.max(minHeight, contentHeight + 10); // Add padding - targetHeight = Math.min(maxHeight, targetHeight); // Apply max height - - console.log(`[computeSize] Request W: ${width}, scrollH: ${container.scrollHeight}, Target H: ${targetHeight}`); - return [width, Math.ceil(targetHeight)]; - } - - // --- Resize Observer (Still useful for manual drag) --- - let resizeTimeout; - new ResizeObserver(() => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - try { - // When textarea manually resizes, tell the node its overall size might need recalculating - node.computeSize(); // Trigger node's own size update based on widget - node.setDirtyCanvas(true, true); - } catch(e) { console.error("ResizeObserver error:", e); } - }, 150); - }).observe(textarea); - - - // --- Button Click Handler (Keep TempFile logic) --- - button.addEventListener("click", async () => { - // ...(Same logic: call /signal_continue, then app.queuePrompt() on success)... - const editedString = textarea.value; const nodeId = node.id; - console.log(`StringEditorPersistent: 'Continue' clicked for node ${nodeId}.`); - button.disabled = true; textarea.disabled = true; - try { - const response = await api.fetchApi("/string_editor/signal_continue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: nodeId, edited_string: editedString }), }); - if (!response.ok) { const errorData = await response.json(); const errorMsg = `Error saving state: ${errorData.message || response.statusText} (Status: ${response.status})`; console.error(`StringEditorPersistent: ${errorMsg} for node ${nodeId}`); alert(errorMsg); button.textContent = "Continue Workflow"; button.disabled = false; textarea.disabled = false; } - else { console.log(`StringEditorPersistent: State saved. Re-queueing prompt.`); app.queuePrompt(); } - } catch (error) { console.error(`StringEditorPersistent: Network error during continue for node ${nodeId}:`, error); alert(`Network error: ${error.message}`); button.textContent = "Continue Workflow"; button.disabled = false; textarea.disabled = false; } - }); - - // Initial size calculation - node.computeSize(); - node.setDirtyCanvas(true, true); - - }, // end nodeCreated - - async setup(app) { - // ...(setup listener remains the same - listens for string_editor_persistent_tempfile_display)... - console.log("StringEditorPersistentTempFileNode: Setup."); - api.addEventListener("string_editor_persistent_tempfile_display", (event) => { // Listen for NEW event name - const { node_id, string_to_edit } = event.detail; - console.log(`StringEditorPersistent: Received 'display' event for node ${node_id}`); - const node = app.graph.getNodeById(node_id); - if (node && node.stringEditorTextarea && node.stringEditorButton) { - console.log(`StringEditorPersistent: Updating UI for node ${node_id}`); - node.stringEditorTextarea.value = string_to_edit; - node.stringEditorTextarea.disabled = false; - node.stringEditorButton.textContent = "Continue Workflow"; - node.stringEditorButton.disabled = false; - node.computeSize(); // Recalculate size after content update - node.setDirtyCanvas(true, true); - } else { console.warn(`StringEditorPersistent: Could not find node/widgets for ID ${node_id}`); } - }); - } // end setup -}); - -console.log("StringEditorPersistentTempFileNode: Extension registered."); \ No newline at end of file diff --git a/web/string_pause_gate_node.js b/web/string_pause_gate_node.js new file mode 100644 index 0000000..6f8679f --- /dev/null +++ b/web/string_pause_gate_node.js @@ -0,0 +1,108 @@ +// filename: ComfyUI/web/extensions/minimal_pause_node.js +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "Comfy.StringPauseGateNode", + + async nodeCreated(node) { + if (node.constructor.comfyClass !== "StringPauseGateNode") { + return; + } + console.log("StringPauseGateNode: nodeCreated", node.id); + + // --- Create the simple Button Widget --- + const container = document.createElement("div"); + container.style.padding = "1px"; + container.style.width = "100%"; + // 将 'bottom' 改为 'center' 来实现水平居中 + container.style.textAlign = "center"; + + const button = document.createElement("button"); + button.textContent = "Continue"; + button.style.padding = "3px 8px"; + button.style.cursor = "pointer"; + button.style.border = "1px solid var(--border-color)"; + button.style.backgroundColor = "#228B22"; // 绿色 + button.style.color = "var(--input-text)"; + button.style.borderRadius = "10px"; // Slightly rounded + button.disabled = true; // Start disabled + node.continueButton = button; // Store reference + + container.appendChild(button); + + const widget = node.addDOMWidget(`minimal_pause_widget_${node.id}`, "dom", container, { + // No serialize needed usually for simple buttons + }); + + // --- computeSize (Minimal Height) --- + widget.computeSize = function(width) { + // Enough height for the button and padding + // Assuming your button + padding needs about 30-40px height + const minHeight = 35; // Adjust this value based on actual button size + padding + // computeSize should return [width, height] + return [width, minHeight]; + } + + // --- Button Click Handler --- + button.addEventListener("click", async () => { + const nodeId = node.id; + console.log(`StringPauseGateNode: 'Continue' clicked for node ${nodeId}.`); + button.textContent = "Continue"; // Optional feedback + button.disabled = true; // Disable while processing + + try { + // Call the backend endpoint to create the signal file + const response = await api.fetchApi("/string_pause/signal_continue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: nodeId }), // Only need node_id + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMsg = `Error signaling continue: ${errorData.message || response.statusText} (Status: ${response.status})`; + console.error(`StringPauseGateNode: ${errorMsg} for node ${nodeId}`); + alert(errorMsg); + button.textContent = "Continue"; // Restore button text on error + button.disabled = false; // Re-enable + } else { + // Backend confirmed signal - Re-queue the prompt + console.log(`StringPauseGateNode: Signal sent for node ${nodeId}. Re-queueing prompt.`); + app.queuePrompt(); // Trigger next run + // Keep button disabled as workflow restarts + } + } catch (error) { + console.error(`StringPauseGateNode: Network error during continue for node ${nodeId}:`, error); + alert(`Network error during continue: ${error.message}`); + button.textContent = "Continue"; // Restore on network error + button.disabled = false; // Re-enable + } + }); + + // Initial size + node.size = widget.computeSize(node.size[0]); + node.setDirtyCanvas(true, true); + }, // end nodeCreated + + async setup(app) { + console.log("StringPauseGateNode: Setup."); + // Listen for the signal from Python to enable the button + api.addEventListener("string_pause_enable_button", (event) => { + const { node_id } = event.detail; + console.log(`StringPauseGateNode: Received 'enable_button' event for node ${node_id}`); + const node = app.graph.getNodeById(node_id); + if (node && node.continueButton) { + console.log(`StringPauseGateNode: Enabling button for node ${node_id}`); + node.continueButton.textContent = "Continue"; + node.continueButton.disabled = false; // Enable the button + // Optional: Maybe make the node flash or something? + node.setDirtyCanvas(true, false); // Minor redraw might be needed + } else { + console.warn(`StringPauseGateNode: Could not find node or button for ID ${node_id} during 'enable_button' event.`); + } + }); + } // end setup +}); + +console.log("StringPauseGateNode: Extension registered."); \ No newline at end of file diff --git a/web/string_viewer_node.js b/web/string_viewer_node.js new file mode 100644 index 0000000..192004b --- /dev/null +++ b/web/string_viewer_node.js @@ -0,0 +1,118 @@ +// filename: ComfyUI/web/extensions/string_viewer_node.js + +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +// Register the extension +app.registerExtension({ + name: "Comfy.StringViewerNode", // Unique extension name (可以保留,但与实际 comfyClass 不同) + + async nodeCreated(node) { + // Check if this is our node type - **CORRECTED comfyClass CHECK** + // Based on latest logs, the comfyClass is "StringDisplayNode" + if (node.constructor.comfyClass !== "StringDisplayNode") { // <<< CHANGED BACK TO "StringDisplayNode" + console.log(`StringDisplayNode: Not a StringDisplayNode, skipping DOM widget creation for node ${node.id} with comfyClass ${node.constructor.comfyClass}`); + return; + } + // ** THIS LOG SHOULD NOW APPEAR ** + console.log(`StringDisplayNode: nodeCreated executed for node ${node.id} (${node.constructor.comfyClass})`); + + // --- Create Container Div --- + const container = document.createElement("div"); + container.style.display = "flex"; + container.style.flexDirection = "column"; + container.style.padding = "4px"; + container.style.margin = "0"; + container.style.gap = "0px"; + container.style.overflow = "hidden"; + + container.style.width = "100%"; + container.style.height = "100%"; + container.style.boxSizing = "border-box"; + + // ** DEBUG BORDER ** + container.style.border = "1px dashed #388E3C"; // Visualize container area + console.log("StringDisplayNode: Created container div"); + + // --- Create Textarea --- + const textarea = document.createElement("textarea"); + textarea.placeholder = "Waiting for string input..."; + textarea.value = ""; + textarea.readOnly = true; + + textarea.style.flexGrow = 1; + textarea.style.width = "100%"; + textarea.style.height = "100%"; + + textarea.style.boxSizing = "border-box"; + textarea.style.resize = "none"; + + textarea.style.padding = "4px"; + textarea.style.border = "1px solid var(--border-color)"; + textarea.style.backgroundColor = "var(--comfy-input-bg)"; + textarea.style.color = "var(--input-text)"; + textarea.style.whiteSpace = "pre-wrap"; + textarea.style.wordBreak = "break-word"; + + // ** DEBUG BORDER ** + // textarea.style.border = "1px dashed blue"; // Visualize textarea area + + console.log("StringDisplayNode: Created textarea"); + + // --- Append Textarea to Container --- + container.appendChild(textarea); + console.log("StringDisplayNode: Appended textarea to container"); + + // --- Add Container as DOM Widget --- + const widget = node.addDOMWidget("string_viewer_widget", "dom", container, { + computeSize: function(width) { + const containerVerticalPadding = 8; + const textareaVerticalSpace = 8 + 2; + const estimatedMinTextareaContentHeight = 3 * 14; + const estimatedMinTextareaHeight = estimatedMinTextareaContentHeight + textareaVerticalSpace; + const defaultWidgetHeight = containerVerticalPadding + estimatedMinTextareaHeight; + const minNodeHeight = 150; + + const targetHeight = Math.max(minNodeHeight, defaultWidgetHeight); + + console.log(`[computeSize] W: ${width}, TargetH: ${targetHeight}`); + + return [width, targetHeight]; + } + }); + + console.log("StringDisplayNode: Added DOM widget"); + + node.stringDisplayTextarea = textarea; // Store reference + + node.computeSize(); + node.setDirtyCanvas(true, true); + + console.log("StringDisplayNode: Computed size and set dirty"); + + }, + + async setup(app) { + console.log("StringDisplayNode: Setup."); + + api.addEventListener("string_display_update", (event) => { + const { node_id, string_to_display } = event.detail; + // console.log(`StringDisplayNode: Received 'display' event for node ${node_id}`); + + const node = app.graph.getNodeById(node_id); + + if (node && node.stringDisplayTextarea) { + // console.log(`StringDisplayNode: Updating UI for node ${node_id}`); + node.stringDisplayTextarea.value = string_to_display; + node.stringDisplayTextarea.placeholder = ""; + + node.computeSize(); + node.setDirtyCanvas(true, true); + } else { + console.warn(`StringDisplayNode: Could not find node/widgets (textarea property missing?) for ID ${node_id}. Event received before node created or node deleted?`); + } + }); + } +}); + +console.log("StringDisplayNode: Extension registered."); \ No newline at end of file diff --git a/workflow-examples/添加字幕.json b/workflow-examples/添加字幕.json new file mode 100644 index 0000000..0ea03b5 --- /dev/null +++ b/workflow-examples/添加字幕.json @@ -0,0 +1 @@ +{"id":"2516ad48-2e2f-4681-bffe-b9ff435d8ae2","revision":0,"last_node_id":17,"last_link_id":24,"nodes":[{"id":2,"type":"VHS_LoadVideo","pos":[182.47775268554688,-253.60462951660156],"size":[253.279296875,557.279296875],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"meta_batch","name":"meta_batch","shape":7,"type":"VHS_BatchManager","link":null},{"localized_name":"vae","name":"vae","shape":7,"type":"VAE","link":null},{"localized_name":"video","name":"video","type":"COMBO","widget":{"name":"video"},"link":null},{"localized_name":"force_rate","name":"force_rate","type":"FLOAT","widget":{"name":"force_rate"},"link":null},{"localized_name":"custom_width","name":"custom_width","type":"INT","widget":{"name":"custom_width"},"link":null},{"localized_name":"custom_height","name":"custom_height","type":"INT","widget":{"name":"custom_height"},"link":null},{"localized_name":"frame_load_cap","name":"frame_load_cap","type":"INT","widget":{"name":"frame_load_cap"},"link":null},{"localized_name":"skip_first_frames","name":"skip_first_frames","type":"INT","widget":{"name":"skip_first_frames"},"link":null},{"localized_name":"select_every_nth","name":"select_every_nth","type":"INT","widget":{"name":"select_every_nth"},"link":null},{"localized_name":"format","name":"format","shape":7,"type":"COMBO","widget":{"name":"format"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[5]},{"localized_name":"frame_count","name":"frame_count","type":"INT","links":null},{"localized_name":"audio","name":"audio","type":"AUDIO","links":null},{"localized_name":"video_info","name":"video_info","type":"VHS_VIDEOINFO","links":[11]}],"properties":{"Node name for S&R":"VHS_LoadVideo"},"widgets_values":{"video":"WanVideo2_1_00002.mp4","force_rate":0,"custom_width":0,"custom_height":0,"frame_load_cap":0,"skip_first_frames":0,"select_every_nth":1,"format":"AnimateDiff","choose video to upload":"image","videopreview":{"hidden":false,"paused":false,"params":{"filename":"WanVideo2_1_00002.mp4","type":"input","format":"video/mp4","force_rate":0,"custom_width":0,"custom_height":0,"frame_load_cap":0,"skip_first_frames":0,"select_every_nth":1}}}},{"id":8,"type":"AddSubtitlesToVideo","pos":[471.262451171875,-247.06256103515625],"size":[304.091796875,338],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":5},{"localized_name":"json_text","name":"json_text","shape":7,"type":"STRING","link":null},{"localized_name":"subtitle_text","name":"subtitle_text","shape":7,"type":"STRING","link":17},{"localized_name":"language","name":"language","type":"COMBO","widget":{"name":"language"},"link":null},{"localized_name":"max_words_per_line","name":"max_words_per_line","type":"INT","widget":{"name":"max_words_per_line"},"link":null},{"localized_name":"font_path","name":"font_path","type":"STRING","widget":{"name":"font_path"},"link":null},{"localized_name":"font_size","name":"font_size","type":"INT","widget":{"name":"font_size"},"link":null},{"localized_name":"font_color","name":"font_color","type":"STRING","widget":{"name":"font_color"},"link":null},{"localized_name":"subtitle_background_color","name":"subtitle_background_color","type":"STRING","widget":{"name":"subtitle_background_color"},"link":null},{"localized_name":"y_offset","name":"y_offset","type":"INT","widget":{"name":"y_offset"},"link":null},{"localized_name":"x_offset","name":"x_offset","type":"INT","widget":{"name":"x_offset"},"link":null},{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":14},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":15},{"localized_name":"fps","name":"fps","type":"FLOAT","widget":{"name":"fps"},"link":12}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[6,19]}],"properties":{"Node name for S&R":"AddSubtitlesToVideo"},"widgets_values":["类中文字符",12,"D:\\AIGC\\APP\\ComfyUI_Data\\custom_nodes\\ComfyUI_AudioTools\\ChironGoRoundTC-600SB.ttf",30,"black","#B8860B",0,0,1024,1024,20]},{"id":10,"type":"MultiLineLyricsDR","pos":[-101.29224395751953,73.20828247070312],"size":[240.18695068359375,191.58883666992188],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"lyrics","name":"lyrics","type":"STRING","widget":{"name":"lyrics"},"link":null}],"outputs":[{"localized_name":"lyrics","name":"lyrics","type":"STRING","links":[17]}],"properties":{"Node name for S&R":"MultiLineLyricsDR"},"widgets_values":["[00:0.23]让我掉下眼泪的\n[00:1.80]不止昨夜的酒\n[00:26.06]让我依依不舍的\n[00:29.91]不止你的温柔\n[00:33.79]余路还要走多久\n[00:37.92]你攥着我的手\n[00:41.81]让我感到为难的\n[00:45.78]是挣扎的自由\n[00:51.81]分别总是在九月\n[00:55.66]回忆是思念的愁\n[00:59.77]深秋嫩绿的垂柳\n"]},{"id":15,"type":"MultiLineLyricsDR","pos":[-107.53343963623047,319.6718444824219],"size":[251.40185546875,245.79452514648438],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"lyrics","name":"lyrics","type":"STRING","widget":{"name":"lyrics"},"link":null}],"outputs":[{"localized_name":"lyrics","name":"lyrics","type":"STRING","links":[18]}],"properties":{"Node name for S&R":"MultiLineLyricsDR"},"widgets_values":["\n[00:0.23]What brings tears to my eyes\n[00:1.80]Is not only the wine from last night\n[00:26.06]What makes me reluctant to leave\n[00:29.91]Is not just your tenderness\n[00:33.79]How much longer must I walk\n[00:37.92]You hold my hand tightly\n[00:41.81]What makes me hesitate"]},{"id":14,"type":"MultiLineLyricsDR","pos":[-105.34197235107422,-254.1727752685547],"size":[258.8785705566406,257.00933837890625],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"lyrics","name":"lyrics","type":"STRING","widget":{"name":"lyrics"},"link":null}],"outputs":[{"localized_name":"lyrics","name":"lyrics","type":"STRING","links":[]}],"properties":{"Node name for S&R":"MultiLineLyricsDR"},"widgets_values":["[{'timestamp': [0.0, 0.8], 'text': '我一定是,'}, {'timestamp': [0.98, 3.72], 'text': '我面临一个我信念体系里的冲突!'}, {'timestamp': [4.46, 6.28], 'text': '卡尔若有所思的说,'}, {'timestamp': [6.92, 9.08], 'text': '否则我不会有这种感觉,'}, {'timestamp': [9.84, 10.98], 'text': '但我不知道该怎么办,'}, {'timestamp': [11.54, 12.82], 'text': '我两样都想做。'}, {'timestamp': [15.28, 16.04], 'text': '立刻,'}, {'timestamp': [16.76, 18.26], 'text': '而且有点在意料之中,'}, {'timestamp': [18.8, 20.64], 'text': '塞斯加入了谈话。'}, {'timestamp': [22.24, 25.44], 'text': '你为什么会被迫面对自己的信念?'}, {'timestamp': [26.1, 27.74], 'text': '塞斯问卡尔香港,'}, {'timestamp': [27.74, 29.98], 'text': '卡尔两手一摘一副'}]"]},{"id":3,"type":"VHS_VideoCombine","pos":[801.1923828125,-220.90367126464844],"size":[220.5830078125,452.5830078125],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":6},{"localized_name":"audio","name":"audio","shape":7,"type":"AUDIO","link":null},{"localized_name":"meta_batch","name":"meta_batch","shape":7,"type":"VHS_BatchManager","link":null},{"localized_name":"vae","name":"vae","shape":7,"type":"VAE","link":null},{"localized_name":"frame_rate","name":"frame_rate","type":"FLOAT","widget":{"name":"frame_rate"},"link":13},{"localized_name":"loop_count","name":"loop_count","type":"INT","widget":{"name":"loop_count"},"link":null},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"format","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"localized_name":"pingpong","name":"pingpong","type":"BOOLEAN","widget":{"name":"pingpong"},"link":null},{"localized_name":"save_output","name":"save_output","type":"BOOLEAN","widget":{"name":"save_output"},"link":null}],"outputs":[{"localized_name":"Filenames","name":"Filenames","type":"VHS_FILENAMES","links":null}],"properties":{"Node name for S&R":"VHS_VideoCombine"},"widgets_values":{"frame_rate":8,"loop_count":0,"filename_prefix":"AnimateDiff","format":"image/gif","pingpong":false,"save_output":true,"videopreview":{"hidden":false,"paused":false,"params":{"filename":"AnimateDiff_00005.gif","subfolder":"","type":"output","format":"image/gif","frame_rate":16,"workflow":"AnimateDiff_00005.png","fullpath":"D:\\AIGC\\APP\\ComfyUI_Data\\output\\AnimateDiff_00005.gif"}}}},{"id":17,"type":"VHS_VideoCombine","pos":[1057.577392578125,-218.37672424316406],"size":[427.0748596191406,659.0748291015625],"flags":{},"order":8,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":20},{"localized_name":"audio","name":"audio","shape":7,"type":"AUDIO","link":null},{"localized_name":"meta_batch","name":"meta_batch","shape":7,"type":"VHS_BatchManager","link":null},{"localized_name":"vae","name":"vae","shape":7,"type":"VAE","link":null},{"localized_name":"frame_rate","name":"frame_rate","type":"FLOAT","widget":{"name":"frame_rate"},"link":24},{"localized_name":"loop_count","name":"loop_count","type":"INT","widget":{"name":"loop_count"},"link":null},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"format","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"localized_name":"pingpong","name":"pingpong","type":"BOOLEAN","widget":{"name":"pingpong"},"link":null},{"localized_name":"save_output","name":"save_output","type":"BOOLEAN","widget":{"name":"save_output"},"link":null}],"outputs":[{"localized_name":"Filenames","name":"Filenames","type":"VHS_FILENAMES","links":null}],"properties":{"Node name for S&R":"VHS_VideoCombine"},"widgets_values":{"frame_rate":8,"loop_count":0,"filename_prefix":"AnimateDiff","format":"image/gif","pingpong":false,"save_output":true,"videopreview":{"hidden":false,"paused":false,"params":{"filename":"AnimateDiff_00008.gif","subfolder":"","type":"output","format":"image/gif","frame_rate":16,"workflow":"AnimateDiff_00008.png","fullpath":"D:\\AIGC\\APP\\ComfyUI_Data\\output\\AnimateDiff_00008.gif"}}}},{"id":13,"type":"VHS_VideoInfoLoaded","pos":[184.81857299804688,384.197509765625],"size":[247.837890625,106],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"video_info","name":"video_info","type":"VHS_VIDEOINFO","link":11}],"outputs":[{"localized_name":"fps🟦","name":"fps🟦","type":"FLOAT","links":[12,13,21,24]},{"localized_name":"frame_count🟦","name":"frame_count🟦","type":"INT","links":null},{"localized_name":"duration🟦","name":"duration🟦","type":"FLOAT","links":null},{"localized_name":"width🟦","name":"width🟦","type":"INT","links":[14,22]},{"localized_name":"height🟦","name":"height🟦","type":"INT","links":[15,23]}],"properties":{"Node name for S&R":"VHS_VideoInfoLoaded"},"widgets_values":{}},{"id":16,"type":"AddSubtitlesToVideo","pos":[475.9112548828125,166.4729766845703],"size":[304.091796875,338],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":19},{"localized_name":"json_text","name":"json_text","shape":7,"type":"STRING","link":null},{"localized_name":"subtitle_text","name":"subtitle_text","shape":7,"type":"STRING","link":18},{"localized_name":"language","name":"language","type":"COMBO","widget":{"name":"language"},"link":null},{"localized_name":"max_words_per_line","name":"max_words_per_line","type":"INT","widget":{"name":"max_words_per_line"},"link":null},{"localized_name":"font_path","name":"font_path","type":"STRING","widget":{"name":"font_path"},"link":null},{"localized_name":"font_size","name":"font_size","type":"INT","widget":{"name":"font_size"},"link":null},{"localized_name":"font_color","name":"font_color","type":"STRING","widget":{"name":"font_color"},"link":null},{"localized_name":"subtitle_background_color","name":"subtitle_background_color","type":"STRING","widget":{"name":"subtitle_background_color"},"link":null},{"localized_name":"y_offset","name":"y_offset","type":"INT","widget":{"name":"y_offset"},"link":null},{"localized_name":"x_offset","name":"x_offset","type":"INT","widget":{"name":"x_offset"},"link":null},{"localized_name":"width","name":"width","type":"INT","widget":{"name":"width"},"link":22},{"localized_name":"height","name":"height","type":"INT","widget":{"name":"height"},"link":23},{"localized_name":"fps","name":"fps","type":"FLOAT","widget":{"name":"fps"},"link":21}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[20]}],"properties":{"Node name for S&R":"AddSubtitlesToVideo"},"widgets_values":["English_word",12,"D:\\AIGC\\APP\\ComfyUI_Data\\custom_nodes\\ComfyUI_AudioTools\\ChironGoRoundTC-600SB.ttf",30,"black","#B8860B",-100,0,1024,1024,20]}],"links":[[5,2,0,8,0,"IMAGE"],[6,8,0,3,0,"IMAGE"],[11,2,3,13,0,"VHS_VIDEOINFO"],[12,13,0,8,13,"FLOAT"],[13,13,0,3,4,"FLOAT"],[14,13,3,8,11,"INT"],[15,13,4,8,12,"INT"],[17,10,0,8,2,"STRING"],[18,15,0,16,2,"STRING"],[19,8,0,16,0,"IMAGE"],[20,16,0,17,0,"IMAGE"],[21,13,0,16,13,"FLOAT"],[22,13,3,16,11,"INT"],[23,13,4,16,12,"INT"],[24,13,0,17,4,"FLOAT"]],"groups":[],"config":{},"extra":{"frontendVersion":"1.18.9","VHS_latentpreview":false,"VHS_latentpreviewrate":0,"VHS_MetadataImage":true,"VHS_KeepIntermediate":true},"version":0.4} \ No newline at end of file