From ba65aa1c18d13c708f9bdfbb279e80d85a2b3293 Mon Sep 17 00:00:00 2001 From: billwuhao Date: Fri, 16 May 2025 23:25:00 +0800 Subject: [PATCH] fix subtitles --- AddSubtitlesToVideo.py | 11 +- audiotoolsnode.py | 281 +++++++++++++++++++++-------- pyproject.toml | 2 +- web/hierarchicalFileAudioWidget.js | 82 +++++++++ 4 files changed, 291 insertions(+), 85 deletions(-) create mode 100644 web/hierarchicalFileAudioWidget.js diff --git a/AddSubtitlesToVideo.py b/AddSubtitlesToVideo.py index c32109b..5acbe6d 100644 --- a/AddSubtitlesToVideo.py +++ b/AddSubtitlesToVideo.py @@ -53,7 +53,7 @@ def parse_timestamp(timestamp_str): return 0.0 -def convert_subtitle_to_list(subtitle_string): +def convert_subtitle_to_list(subtitle_string, fallback_duration): lines = subtitle_string.strip().split('\n') parsed_entries = [] @@ -94,12 +94,14 @@ def convert_subtitle_to_list(subtitle_string): next_start_time = parsed_entries[i+1]['start_time'] end_time = next_start_time - 0.2 else: - fallback_duration = 1.5 + 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 > 5: + end_time = round(start_time + 5, 2) # Append to result list result_list.append({'timestamp': [start_time, round(end_time,2)], 'text': text}) @@ -169,8 +171,9 @@ class AddSubtitlesToTensor: 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) + fallback_duration = len(images) / fps - 0.2 + if subtitle_text: + subtitles = convert_subtitle_to_list(subtitle_text, fallback_duration) else: subtitles = ast.literal_eval(json_text) diff --git a/audiotoolsnode.py b/audiotoolsnode.py index 6c95348..ecb03ad 100644 --- a/audiotoolsnode.py +++ b/audiotoolsnode.py @@ -101,6 +101,122 @@ def get_all_files( return file_dict if return_type == "dict" else file_paths +from comfy.cli_args import args +import av +# from ComfyUI +def save_audio(self, audio, filename_prefix="ComfyUI", format="flac", prompt=None, extra_pnginfo=None, quality="128k"): + + filename_prefix += self.prefix_append + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir) + results = [] + + # Prepare metadata dictionary + metadata = {} + if not args.disable_metadata: + if prompt is not None: + metadata["prompt"] = json.dumps(prompt) + if extra_pnginfo is not None: + for x in extra_pnginfo: + metadata[x] = json.dumps(extra_pnginfo[x]) + + # Opus supported sample rates + OPUS_RATES = [8000, 12000, 16000, 24000, 48000] + + for (batch_number, waveform) in enumerate(audio["waveform"].cpu()): + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.{format}" + output_path = os.path.join(full_output_folder, file) + + # Use original sample rate initially + sample_rate = audio["sample_rate"] + + # Handle Opus sample rate requirements + if format == "opus": + if sample_rate > 48000: + sample_rate = 48000 + elif sample_rate not in OPUS_RATES: + # Find the next highest supported rate + for rate in sorted(OPUS_RATES): + if rate > sample_rate: + sample_rate = rate + break + if sample_rate not in OPUS_RATES: # Fallback if still not supported + sample_rate = 48000 + + # Resample if necessary + if sample_rate != audio["sample_rate"]: + waveform = torchaudio.functional.resample(waveform, audio["sample_rate"], sample_rate) + + # Create in-memory WAV buffer + wav_buffer = io.BytesIO() + torchaudio.save(wav_buffer, waveform, sample_rate, format="WAV") + wav_buffer.seek(0) # Rewind for reading + + # Use PyAV to convert and add metadata + input_container = av.open(wav_buffer) + + # Create output with specified format + output_buffer = io.BytesIO() + output_container = av.open(output_buffer, mode='w', format=format) + + # Set metadata on the container + for key, value in metadata.items(): + output_container.metadata[key] = value + + # Set up the output stream with appropriate properties + input_container.streams.audio[0] + if format == "opus": + out_stream = output_container.add_stream("libopus", rate=sample_rate) + if quality == "64k": + out_stream.bit_rate = 64000 + elif quality == "96k": + out_stream.bit_rate = 96000 + elif quality == "128k": + out_stream.bit_rate = 128000 + elif quality == "192k": + out_stream.bit_rate = 192000 + elif quality == "320k": + out_stream.bit_rate = 320000 + elif format == "mp3": + out_stream = output_container.add_stream("libmp3lame", rate=sample_rate) + if quality == "V0": + #TODO i would really love to support V3 and V5 but there doesn't seem to be a way to set the qscale level, the property below is a bool + out_stream.codec_context.qscale = 1 + elif quality == "128k": + out_stream.bit_rate = 128000 + elif quality == "320k": + out_stream.bit_rate = 320000 + else: #format == "flac": + out_stream = output_container.add_stream("flac", rate=sample_rate) + + + # Copy frames from input to output + for frame in input_container.decode(audio=0): + frame.pts = None # Let PyAV handle timestamps + output_container.mux(out_stream.encode(frame)) + + # Flush encoder + output_container.mux(out_stream.encode(None)) + + # Close containers + output_container.close() + input_container.close() + + # Write the output to file + output_buffer.seek(0) + with open(output_path, 'wb') as f: + f.write(output_buffer.getbuffer()) + + results.append({ + "filename": file, + "subfolder": subfolder, + "type": self.type + }) + counter += 1 + + return { "ui": { "audio": results } } + + class SaveAudioMW: def __init__(self): self.output_dir = folder_paths.get_output_directory() @@ -110,8 +226,9 @@ class SaveAudioMW: @classmethod def INPUT_TYPES(s): return {"required": { "audio": ("AUDIO", ), - "format": (["WAV", "MP3", "FLAC"],), + "format": (["MP3", "FLAC"],), "filename_prefix": ("STRING", {"default": "audio/ComfyUI"}), + "mp3_quality": (["V0", "128k", "320k"],{"default": "V0"}), }, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, } @@ -123,98 +240,102 @@ class SaveAudioMW: CATEGORY = "🎤MW/MW-Audio-Tools" - def save_audio(self, audio, format, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): - filename_prefix += self.prefix_append - full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir) + def save_audio(self, audio, format, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None, mp3_quality="128k"): - results = [] - metadata = {} - if prompt is not None: - metadata["prompt"] = json.dumps(prompt) - if extra_pnginfo is not None: - for x in extra_pnginfo: - metadata[x] = json.dumps(extra_pnginfo[x]) + return save_audio(self, audio, filename_prefix, format.lower(), prompt, extra_pnginfo, quality=mp3_quality) - for (batch_number, waveform) in enumerate(audio["waveform"].cpu()): - filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}_.{format.lower()}" - buff = io.BytesIO() - torchaudio.save(buff, waveform, audio["sample_rate"], format=format) +# # --- Function to build a file tree structure --- +# def build_file_tree(base_path, extensions, current_path=""): +# """ +# Builds a recursive tree structure of files and directories. +# Each node is a dictionary: +# { "name": "...", "type": "folder" or "file", "path": "relative_path", "children": [...] } +# """ +# tree = [] +# full_current_path = os.path.join(base_path, current_path) + +# if not os.path.isdir(full_current_path): +# return [] - if format == "FLAC": - buff = self.insert_or_replace_vorbis_comment(buff, metadata) - elif metadata: - print(("SaveAudioMW Warning: Metadata saving is currently only supported for FLAC format. " - f"Skipping metadata for {format}")) +# # Sort entries to have folders first, then files, alphabetically +# entries = sorted(os.listdir(full_current_path)) + +# # Separate folders and files for processing +# dirs = [d for d in entries if os.path.isdir(os.path.join(full_current_path, d))] +# files = [f for f in entries if os.path.isfile(os.path.join(full_current_path, f))] - with open(os.path.join(full_output_folder, file), 'wb') as f: - f.write(buff.getbuffer()) +# for name in dirs: +# node_path = os.path.join(current_path, name) +# children = build_file_tree(base_path, extensions, node_path) +# # Only add folder if it has children or you want to show empty folders +# if children: # or True, if you want to show empty folders +# tree.append({ +# "name": name, +# "type": "folder", +# "path": os.path.normpath(node_path), # Store normalized relative path +# "children": children +# }) - results.append({ - "filename": file, - "subfolder": subfolder, - "type": self.type - }) - counter += 1 +# for name in files: +# if any(name.lower().endswith(ext.lower()) for ext in extensions): +# node_path = os.path.join(current_path, name) +# tree.append({ +# "name": name, +# "type": "file", +# "path": os.path.normpath(node_path) # Store normalized relative path +# }) +# return tree - return { "ui": { "audio": results },} - # From ComfyUI - def create_vorbis_comment_block(self, comment_dict, last_block): - vendor_string = b'ComfyUI' - vendor_length = len(vendor_string) +# class LoadAudioMW: +# audios_dir_absolute = get_path() +# _audio_extensions = [".wav", ".mp3", ".flac", ".mp4", ".WAV", ".MP3", ".FLAC", ".MP4"] +# _file_tree_data = build_file_tree(audios_dir_absolute, _audio_extensions) +# @classmethod +# def INPUT_TYPES(s): +# return { +# "required": { +# # The first element of the tuple is the data for our custom widget. +# # The second element is a config dict. "widget": "HIERARCHICAL_FILE_AUDIO" is key. +# "selected_audio_path": (s._file_tree_data, {"widget": "HIERARCHICAL_FILE_AUDIO"}), +# "start_time_sec": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10000.0, "step": 0.01}), +# "duration_sec": ("FLOAT", {"default": 5.0, "min": 0.01, "max": 10000.0, "step": 0.01}), +# } +# } - comments = [] - for key, value in comment_dict.items(): - comment = f"{key}={value}".encode('utf-8') - comments.append(struct.pack('I', len(comment_data))[1:] + comment_data +# # selected_audio_path is the RELATIVE path from the JS widget +# # We need to join it with the absolute base audio directory +# if not LoadAudioMW.audios_dir_absolute: +# raise FileNotFoundError("Audio base directory is not configured.") + +# full_audio_path = os.path.join(LoadAudioMW.audios_dir_absolute, selected_audio_path) - return comment_block - - # From ComfyUI - def insert_or_replace_vorbis_comment(self, flac_io, comment_dict): - if len(comment_dict) == 0: - return flac_io - - flac_io.seek(4) - - blocks = [] - last_block = False - - while not last_block: - header = flac_io.read(4) - last_block = (header[0] & 0x80) != 0 - block_type = header[0] & 0x7F - block_length = struct.unpack('>I', b'\x00' + header[1:])[0] - block_data = flac_io.read(block_length) - - if block_type == 4 or block_type == 1: - pass - else: - header = bytes([(header[0] & (~0x80))]) + header[1:] - blocks.append(header + block_data) - - blocks.append(self.create_vorbis_comment_block(comment_dict, last_block=True)) - - new_flac_io = io.BytesIO() - new_flac_io.write(b'fLaC') - for block in blocks: - new_flac_io.write(block) - - new_flac_io.write(flac_io.read()) - return new_flac_io +# # --- Rest of your audio loading logic from the original example --- +# # import torchaudio # Make sure torchaudio is imported +# waveform, sample_rate = torchaudio.load(full_audio_path) +# waveform = waveform.unsqueeze(0) + +# start_sample = round(start_time_sec * sample_rate) +# num_samples_requested = round(duration_sec * sample_rate) +# total_samples = waveform.shape[-1] +# start_sample = max(0, min(start_sample, total_samples)) +# actual_end_sample = min(start_sample + num_samples_requested, total_samples) +# if start_sample >= actual_end_sample: +# raise ValueError(f"SliceAudio: Calculated slice is empty or invalid.") + +# sliced_waveform = waveform[..., start_sample:actual_end_sample] +# output_audio = {"waveform": sliced_waveform, "sample_rate": sample_rate} +# return (output_audio,) class LoadAudioMW: audios_dir = get_path() diff --git a/pyproject.toml b/pyproject.toml index 7a63832..9b7d6a1 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.1.1" +version = "1.1.2" license = {file = "LICENSE"} dependencies = ["sox", "librosa"] diff --git a/web/hierarchicalFileAudioWidget.js b/web/hierarchicalFileAudioWidget.js new file mode 100644 index 0000000..4e68c39 --- /dev/null +++ b/web/hierarchicalFileAudioWidget.js @@ -0,0 +1,82 @@ +// // alert("Hierarchical Audio Widget JS Loaded!"); +// console.log("Hierarchical Audio Widget JS file is being processed."); + +// // File: js/hierarchicalFileAudioWidget.js +// import { app } from "../../scripts/app.js"; +// import { api } from "../../scripts/api.js"; + +// // Helper function to create tree elements +// function createTreeElement(item, onFileSelect, currentPath) { +// const element = document.createElement("li"); +// element.style.cursor = "pointer"; +// element.style.marginLeft = "10px"; // Basic indentation + +// const nameSpan = document.createElement("span"); +// nameSpan.textContent = (item.type === "folder" ? "📁 " : "🎵 ") + item.name; +// element.appendChild(nameSpan); + +// if (item.type === "folder") { +// nameSpan.style.fontWeight = "bold"; +// const childrenContainer = document.createElement("ul"); +// childrenContainer.style.display = "none"; // Collapsed by default +// childrenContainer.style.listStyleType = "none"; +// childrenContainer.style.paddingLeft = "15px"; + +// if (item.children && item.children.length > 0) { +// item.children.forEach(child => { +// childrenContainer.appendChild(createTreeElement(child, onFileSelect, item.path)); +// }); +// } else { +// const emptyMsg = document.createElement("li"); +// emptyMsg.textContent = "(empty)"; +// emptyMsg.style.fontStyle = "italic"; +// emptyMsg.style.color = "#888"; +// childrenContainer.appendChild(emptyMsg); +// } +// element.appendChild(childrenContainer); + +// element.addEventListener("click", (e) => { +// e.stopPropagation(); // Prevent event from bubbling up to parent folders +// const isVisible = childrenContainer.style.display !== "none"; +// childrenContainer.style.display = isVisible ? "none" : "block"; +// nameSpan.textContent = (isVisible ? "📁 " : "📂 ") + item.name; // Change icon +// }); +// } else { // File +// element.addEventListener("click", (e) => { +// e.stopPropagation(); +// onFileSelect(item.path); // Pass the relative path of the file +// }); +// } +// return element; +// } + +// if (app && typeof app.registerExtension === 'function') { +// console.log("[MW_AUDIO_WIDGET] app.registerExtension IS a function. Attempting to register."); +// app.registerExtension({ +// name: "MW.HierarchicalFileAudioWidget", // Unique name for your extension +// // 添加 setup 函数 +// setup(appInstance) { +// console.log("[MW_AUDIO_WIDGET] EXTENSION SETUP FUNCTION CALLED!"); +// }, +// async getCustomWidgets(appInstance) { +// console.log("[MW_AUDIO_WIDGET] getCustomWidgets CALLED. Preparing to return object. (USING NORMAL FUNCTION)"); + +// const widgetsToRegister = { +// "HIERARCHICAL_FILE_AUDIO": function(node, inputName, inputData, appRef) { // 改为普通函数 +// // 使用 'this' 关键字时要小心,在这个上下文中它可能不是你期望的 +// console.log(`[MW_AUDIO_WIDGET] NORMAL FUNCTION FACTORY for HIERARCHICAL_FILE_AUDIO CALLED! Input: ${inputName}`); +// // ... (其余的工厂函数逻辑保持不变,使用空的或简化的版本进行测试) +// let el = document.createElement("span"); +// el.textContent = "Normal Function Factory Called"; +// return { type: "STRING", name: inputName, value: "", element: el, computeSize: (w)=>[w,20] }; +// } +// }; + +// console.log("[MW_AUDIO_WIDGET] Object being returned by getCustomWidgets:", widgetsToRegister); +// return widgetsToRegister; +// } +// }); +// console.log("[MW_AUDIO_WIDGET] Extension registration attempt FINISHED."); +// } else { +// console.error("[MW_AUDIO_WIDGET] ERROR: app or app.registerExtension is not available!"); +// } \ No newline at end of file