From 6ca03b227e5c7fdc2e721bfd902c314836d8f422 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 15 Feb 2026 13:43:20 -0300 Subject: [PATCH] Add AudioWaveformSlicer and AudioSliceSelector nodes Interactive audio waveform node with cut lines, playback, and slice selector for filtering individual audio segments. --- __init__.py | 38 ++- audio_slice_selector.py | 26 ++ audio_waveform_slicer.py | 126 ++++++++ js/audio_waveform_slicer.js | 616 ++++++++++++++++++++++++++++++++++++ 4 files changed, 802 insertions(+), 4 deletions(-) create mode 100644 audio_slice_selector.py create mode 100644 audio_waveform_slicer.py create mode 100644 js/audio_waveform_slicer.js diff --git a/__init__.py b/__init__.py index e81793d..bd90fcf 100644 --- a/__init__.py +++ b/__init__.py @@ -31,7 +31,18 @@ from .comparison_swipe import ComparisonSwipeNode from .folder_video_concatenator import FolderVideoConcatenator from .interactive_crop import InteractiveCropNode from .caption_image_loader import CaptionImageLoader -from . import server_routes # Register Custom API Routes +from .video_audio_combiner import ( + VideoAudioCombiner, + VideoAudioCombinerSimple, + HAS_NEW_VIDEO_API, +) + +if HAS_NEW_VIDEO_API: + from .video_audio_combiner import VideoAudioCombinerV3 +from .acestep_loader import AceStepLoKrLoader +from .audio_waveform_slicer import AudioWaveformSlicer +from .audio_slice_selector import AudioSliceSelector +from . import server_routes # Register Custom API Routes NODE_CLASS_MAPPINGS = { "CustomCrop": CustomCropNode, @@ -57,9 +68,18 @@ NODE_CLASS_MAPPINGS = { "ComparisonSwipe": ComparisonSwipeNode, "FolderVideoConcatenator": FolderVideoConcatenator, "InteractiveCrop": InteractiveCropNode, - "CaptionImageLoader": CaptionImageLoader + "CaptionImageLoader": CaptionImageLoader, + "VideoAudioCombiner": VideoAudioCombiner, + "VideoAudioCombinerSimple": VideoAudioCombinerSimple, + "AceStepLoKrLoader": AceStepLoKrLoader, + "AudioWaveformSlicer": AudioWaveformSlicer, + "AudioSliceSelector": AudioSliceSelector, } +# Add V3 nodes if the new API is available +if HAS_NEW_VIDEO_API: + NODE_CLASS_MAPPINGS["VideoAudioCombinerV3"] = VideoAudioCombinerV3 + NODE_DISPLAY_NAME_MAPPINGS = { "CustomCrop": "Custom Crop", "SmartResize": "Smart Resize with Border Fill", @@ -83,8 +103,18 @@ NODE_DISPLAY_NAME_MAPPINGS = { "ComparisonSwipe": "Comparison Swipe Video", "FolderVideoConcatenator": "Folder Video Concatenator (OpenCV)", "InteractiveCrop": "Interactive Crop", - "CaptionImageLoader": "Caption Image Loader" + "CaptionImageLoader": "Caption Image Loader", + "VideoAudioCombiner": "Video + Audio Combiner", + "VideoAudioCombinerSimple": "Video + Audio Combiner (Simple)", + "AceStepLoKrLoader": "AceStep LoKr Loader", + "AudioWaveformSlicer": "Audio Waveform Slicer", + "AudioSliceSelector": "Audio Slice Selector", } + +# Add V3 display names if available +if HAS_NEW_VIDEO_API: + NODE_DISPLAY_NAME_MAPPINGS["VideoAudioCombinerV3"] = "Video + Audio Combiner (V3)" + WEB_DIRECTORY = "./js" -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/audio_slice_selector.py b/audio_slice_selector.py new file mode 100644 index 0000000..dd14781 --- /dev/null +++ b/audio_slice_selector.py @@ -0,0 +1,26 @@ +class AudioSliceSelector: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio_list": ("AUDIO",), + "index": ("INT", {"default": 0, "min": 0, "max": 999}), + }, + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio",) + INPUT_IS_LIST = True + FUNCTION = "select" + CATEGORY = "audio" + + def select(self, audio_list, index): + # When INPUT_IS_LIST is True, all inputs arrive as lists + idx = index[0] if isinstance(index, list) else index + + if not audio_list: + raise ValueError("No audio slices received.") + + # Clamp index + idx = max(0, min(idx, len(audio_list) - 1)) + return (audio_list[idx],) diff --git a/audio_waveform_slicer.py b/audio_waveform_slicer.py new file mode 100644 index 0000000..4a34b7f --- /dev/null +++ b/audio_waveform_slicer.py @@ -0,0 +1,126 @@ +import torch +import av +import os +import json +import hashlib +import folder_paths + + +def f32_pcm(wav: torch.Tensor) -> torch.Tensor: + if wav.dtype.is_floating_point: + return wav + elif wav.dtype == torch.int16: + return wav.float() / (2 ** 15) + elif wav.dtype == torch.int32: + return wav.float() / (2 ** 31) + raise ValueError(f"Unsupported wav dtype: {wav.dtype}") + + +def load_audio(filepath: str) -> tuple: + with av.open(filepath) as af: + if not af.streams.audio: + raise ValueError("No audio stream found in the file.") + + stream = af.streams.audio[0] + sr = stream.codec_context.sample_rate + n_channels = stream.channels + + frames = [] + for frame in af.decode(streams=stream.index): + buf = torch.from_numpy(frame.to_ndarray()) + if buf.shape[0] != n_channels: + buf = buf.view(-1, n_channels).t() + frames.append(buf) + + if not frames: + raise ValueError("No audio frames decoded.") + + wav = torch.cat(frames, dim=1) + wav = f32_pcm(wav) + return wav, sr + + +class AudioWaveformSlicer: + @classmethod + def INPUT_TYPES(cls): + input_dir = folder_paths.get_input_directory() + files = folder_paths.filter_files_content_types( + os.listdir(input_dir), ["audio", "video"] + ) + return { + "required": { + "audio": (sorted(files), {"audio_upload": True}), + "cut_positions": ("STRING", {"default": "[]", "multiline": False}), + }, + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio_slices",) + OUTPUT_IS_LIST = (True,) + FUNCTION = "slice_audio" + CATEGORY = "audio" + + def slice_audio(self, audio, cut_positions): + audio_path = folder_paths.get_annotated_filepath(audio) + waveform, sample_rate = load_audio(audio_path) + # waveform shape: [C, T] + + total_samples = waveform.shape[1] + + # Parse cut positions (in seconds), sort them + try: + cuts = json.loads(cut_positions) + except (json.JSONDecodeError, TypeError): + cuts = [] + + if not isinstance(cuts, list): + cuts = [] + + # Convert seconds to sample indices, filter valid ones + cut_samples = [] + for c in cuts: + try: + s = int(float(c) * sample_rate) + if 0 < s < total_samples: + cut_samples.append(s) + except (ValueError, TypeError): + continue + + cut_samples = sorted(set(cut_samples)) + + # Build slice boundaries + boundaries = [0] + cut_samples + [total_samples] + + slices = [] + for i in range(len(boundaries) - 1): + start = boundaries[i] + end = boundaries[i + 1] + if end > start: + slice_waveform = waveform[:, start:end] + slices.append({ + "waveform": slice_waveform.unsqueeze(0), # [1, C, T] + "sample_rate": sample_rate, + }) + + if not slices: + slices.append({ + "waveform": waveform.unsqueeze(0), + "sample_rate": sample_rate, + }) + + return (slices,) + + @classmethod + def IS_CHANGED(cls, audio, cut_positions): + audio_path = folder_paths.get_annotated_filepath(audio) + m = hashlib.sha256() + with open(audio_path, "rb") as f: + m.update(f.read()) + m.update(cut_positions.encode()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(cls, audio, **kwargs): + if not folder_paths.exists_annotated_filepath(audio): + return "Invalid audio file: {}".format(audio) + return True diff --git a/js/audio_waveform_slicer.js b/js/audio_waveform_slicer.js new file mode 100644 index 0000000..860e147 --- /dev/null +++ b/js/audio_waveform_slicer.js @@ -0,0 +1,616 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const DEBUG = true; +function log(...args) { + if (DEBUG) console.log("[AudioWaveformSlicer]", ...args); +} + +const WAVEFORM_TOP_PADDING = 80; +const WAVEFORM_BOTTOM_PADDING = 50; +const WAVEFORM_H_PADDING = 10; +const CUT_LINE_HIT_RADIUS = 6; +const PLAYHEAD_COLOR = "#FFFFFF"; +const CUT_LINE_COLOR = "#FF3333"; +const SEGMENT_COLORS = [ + "rgba(60, 130, 80, 0.12)", + "rgba(60, 80, 160, 0.12)", + "rgba(160, 120, 50, 0.12)", + "rgba(130, 60, 130, 0.12)", +]; + +function formatTime(seconds) { + if (!seconds || !isFinite(seconds)) return "0:00.0"; + const m = Math.floor(seconds / 60); + const s = (seconds % 60).toFixed(1); + return `${m}:${s.padStart(4, "0")}`; +} + +app.registerExtension({ + name: "AnotherUtils.AudioWaveformSlicer", + async beforeRegisterNodeDef(nodeType, nodeData, appInstance) { + if (nodeData.name !== "AudioWaveformSlicer") return; + + log("Registering AudioWaveformSlicer extension"); + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + log("onNodeCreated called"); + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + + try { + this._aws = { + peaks: null, + duration: 0, + audioBuffer: null, + audioContext: null, + sourceNode: null, + isPlaying: false, + playStartTime: 0, + playOffset: 0, + playheadPos: 0, + cutLines: [], + draggingCut: -1, + animFrameId: null, + waveformRect: null, + lastAudioFile: null, + }; + + // Find widgets + this._awsWidgets = {}; + if (this.widgets) { + for (const w of this.widgets) { + if (w.name === "audio" || w.name === "cut_positions") { + this._awsWidgets[w.name] = w; + } + } + } + + // Hide cut_positions widget + const cpWidget = this._awsWidgets.cut_positions; + if (cpWidget) { + cpWidget.type = "hidden"; + cpWidget.computeSize = function () { return [0, -4]; }; + } + + // Set audio widget callback + const audioWidget = this._awsWidgets.audio; + if (audioWidget) { + const self = this; + audioWidget.callback = function () { + self._loadAudioFile(); + }; + } + + // Set minimum size + if (this.size) { + this.size[0] = Math.max(this.size[0] || 400, 400); + this.size[1] = Math.max(this.size[1] || 300, 300); + } + + // Restore cut_positions from widget + this._restoreCutPositions(); + + // Load initial audio if set + if (audioWidget && audioWidget.value) { + this._loadAudioFile(); + } + + log("onNodeCreated completed successfully"); + } catch (e) { + console.error("[AudioWaveformSlicer] Error in onNodeCreated:", e); + } + + return r; + }; + + // --- Load audio file --- + nodeType.prototype._loadAudioFile = async function () { + var filename = null; + try { + filename = this._awsWidgets && this._awsWidgets.audio ? this._awsWidgets.audio.value : null; + } catch (e) { + return; + } + if (!filename) return; + if (!this._aws) return; + if (filename === this._aws.lastAudioFile) return; + + this._stopPlayback(); + this._aws.lastAudioFile = filename; + this._aws.peaks = null; + this._aws.audioBuffer = null; + this._aws.duration = 0; + this._aws.playheadPos = 0; + + try { + var url = api.apiURL("/view?filename=" + encodeURIComponent(filename) + "&type=input"); + log("Loading audio from:", url); + var response = await fetch(url); + var arrayBuffer = await response.arrayBuffer(); + + if (!this._aws.audioContext) { + this._aws.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + + var audioBuffer = await this._aws.audioContext.decodeAudioData(arrayBuffer); + this._aws.audioBuffer = audioBuffer; + this._aws.duration = audioBuffer.duration; + log("Audio loaded, duration:", audioBuffer.duration); + + this._calculatePeaks(); + this.setDirtyCanvas(true, true); + } catch (e) { + console.error("[AudioWaveformSlicer] Error loading audio:", e); + } + }; + + // --- Calculate peaks --- + nodeType.prototype._calculatePeaks = function () { + if (!this._aws || !this._aws.audioBuffer) return; + var buf = this._aws.audioBuffer; + + var rect = this._getWaveformRect(); + var numBars = Math.max(1, Math.floor(rect.w)); + var channelData = buf.getChannelData(0); + var samplesPerBar = Math.max(1, Math.floor(channelData.length / numBars)); + + var peaks = new Float32Array(numBars); + for (var i = 0; i < numBars; i++) { + var max = 0; + var start = i * samplesPerBar; + var end = Math.min(start + samplesPerBar, channelData.length); + for (var j = start; j < end; j++) { + var abs = Math.abs(channelData[j]); + if (abs > max) max = abs; + } + peaks[i] = max; + } + + this._aws.peaks = peaks; + }; + + // --- Get waveform drawing rect --- + nodeType.prototype._getWaveformRect = function () { + var widgetCount = (this.widgets && this.widgets.length) ? this.widgets.length : 2; + var topY = Math.max(WAVEFORM_TOP_PADDING, widgetCount * 28 + 10); + var nodeW = (this.size && this.size[0]) ? this.size[0] : 400; + var nodeH = (this.size && this.size[1]) ? this.size[1] : 300; + var w = nodeW - WAVEFORM_H_PADDING * 2; + var h = nodeH - topY - WAVEFORM_BOTTOM_PADDING; + return { + x: WAVEFORM_H_PADDING, + y: topY, + w: Math.max(w, 10), + h: Math.max(h, 20), + }; + }; + + // --- Sync cut_positions widget --- + nodeType.prototype._syncCutPositions = function () { + if (!this._aws || !this._awsWidgets) return; + var cpWidget = this._awsWidgets.cut_positions; + if (cpWidget) { + var sorted = this._aws.cutLines.slice().sort(function (a, b) { return a - b; }); + this._aws.cutLines = sorted; + cpWidget.value = JSON.stringify(sorted); + } + }; + + // --- Restore cut positions from widget --- + nodeType.prototype._restoreCutPositions = function () { + if (!this._aws) return; + try { + var val = (this._awsWidgets && this._awsWidgets.cut_positions) ? this._awsWidgets.cut_positions.value : "[]"; + val = val || "[]"; + var arr = JSON.parse(val); + if (Array.isArray(arr)) { + this._aws.cutLines = arr.map(Number).filter(function (n) { return !isNaN(n); }); + } + } catch (e) { + this._aws.cutLines = []; + } + }; + + // --- Playback --- + nodeType.prototype._togglePlayback = function () { + if (!this._aws) return; + if (this._aws.isPlaying) { + this._stopPlayback(); + } else { + this._startPlayback(); + } + }; + + nodeType.prototype._startPlayback = function () { + if (!this._aws) return; + var buf = this._aws.audioBuffer; + var ctx = this._aws.audioContext; + if (!buf || !ctx) return; + + if (ctx.state === "suspended") ctx.resume(); + + var source = ctx.createBufferSource(); + source.buffer = buf; + source.connect(ctx.destination); + + var offset = this._aws.playOffset; + source.start(0, offset); + + this._aws.sourceNode = source; + this._aws.playStartTime = ctx.currentTime - offset; + this._aws.isPlaying = true; + + var self = this; + source.onended = function () { + if (self._aws && self._aws.isPlaying) { + self._aws.isPlaying = false; + self._aws.playOffset = 0; + self._aws.playheadPos = 0; + if (self._aws.animFrameId) { + cancelAnimationFrame(self._aws.animFrameId); + self._aws.animFrameId = null; + } + self.setDirtyCanvas(true, true); + } + }; + + this._animatePlayhead(); + }; + + nodeType.prototype._stopPlayback = function () { + if (!this._aws) return; + if (this._aws.sourceNode) { + try { + this._aws.sourceNode.onended = null; + this._aws.sourceNode.stop(); + } catch (e) { /* ignore */ } + this._aws.sourceNode = null; + } + if (this._aws.isPlaying && this._aws.audioContext) { + this._aws.playOffset = this._aws.audioContext.currentTime - this._aws.playStartTime; + } + this._aws.isPlaying = false; + if (this._aws.animFrameId) { + cancelAnimationFrame(this._aws.animFrameId); + this._aws.animFrameId = null; + } + this.setDirtyCanvas(true, true); + }; + + nodeType.prototype._animatePlayhead = function () { + if (!this._aws || !this._aws.isPlaying) return; + + var ctx = this._aws.audioContext; + if (!ctx) return; + var elapsed = ctx.currentTime - this._aws.playStartTime; + var duration = this._aws.duration || 1; + this._aws.playheadPos = Math.min(elapsed / duration, 1); + + if (this._aws.playheadPos >= 1) { + this._aws.isPlaying = false; + this._aws.playOffset = 0; + this._aws.playheadPos = 0; + this._aws.sourceNode = null; + this.setDirtyCanvas(true, true); + return; + } + + this.setDirtyCanvas(true, true); + var self = this; + this._aws.animFrameId = requestAnimationFrame(function () { + self._animatePlayhead(); + }); + }; + + // --- Time <-> X coordinate conversion --- + nodeType.prototype._timeToX = function (seconds) { + var rect = this._getWaveformRect(); + var duration = (this._aws && this._aws.duration) ? this._aws.duration : 1; + return rect.x + (seconds / duration) * rect.w; + }; + + nodeType.prototype._xToTime = function (x) { + var rect = this._getWaveformRect(); + var duration = (this._aws && this._aws.duration) ? this._aws.duration : 1; + var ratio = (x - rect.x) / rect.w; + return Math.max(0, Math.min(ratio * duration, duration)); + }; + + // --- Draw --- + var origOnDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function (ctx) { + if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments); + + if (!this._aws) return; + + try { + var rect = this._getWaveformRect(); + this._aws.waveformRect = rect; + + // Background + ctx.fillStyle = "rgba(0, 0, 0, 0.3)"; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + + if (!this._aws.peaks || this._aws.peaks.length === 0) { + ctx.fillStyle = "#888"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Select an audio file", rect.x + rect.w / 2, rect.y + rect.h / 2); + ctx.textAlign = "left"; + // Still draw transport bar + this._drawTransportBar(ctx, rect); + return; + } + + // Recalculate peaks if width changed + if (this._aws.peaks.length !== Math.floor(rect.w)) { + this._calculatePeaks(); + } + + var peaks = this._aws.peaks; + if (!peaks) return; + + // Draw segment backgrounds + var cuts = this._aws.cutLines.slice().sort(function (a, b) { return a - b; }); + var segments = [0].concat(cuts, [this._aws.duration]); + for (var i = 0; i < segments.length - 1; i++) { + var sx = this._timeToX(segments[i]); + var ex = this._timeToX(segments[i + 1]); + ctx.fillStyle = SEGMENT_COLORS[i % SEGMENT_COLORS.length]; + ctx.fillRect(sx, rect.y, ex - sx, rect.h); + } + + // Draw waveform bars + var centerY = rect.y + rect.h / 2; + var maxBarH = rect.h / 2 - 2; + for (var i = 0; i < peaks.length; i++) { + var amp = peaks[i]; + var barH = amp * maxBarH; + var x = rect.x + i; + + var r = Math.floor(60 + amp * 195); + var g = Math.floor(180 - amp * 100); + var b = 60; + ctx.fillStyle = "rgb(" + r + "," + g + "," + b + ")"; + ctx.fillRect(x, centerY - barH, 1, barH * 2); + } + + // Draw cut lines + for (var i = 0; i < this._aws.cutLines.length; i++) { + var t = this._aws.cutLines[i]; + var cx = this._timeToX(t); + + ctx.strokeStyle = CUT_LINE_COLOR; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx, rect.y); + ctx.lineTo(cx, rect.y + rect.h); + ctx.stroke(); + + // Handle triangle + ctx.fillStyle = CUT_LINE_COLOR; + ctx.beginPath(); + ctx.moveTo(cx - 5, rect.y); + ctx.lineTo(cx + 5, rect.y); + ctx.lineTo(cx, rect.y + 10); + ctx.closePath(); + ctx.fill(); + + // Timestamp label + ctx.fillStyle = "#FFF"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(formatTime(t), cx, rect.y - 3); + } + + // Draw playhead + if (this._aws.duration > 0) { + var phX = rect.x + this._aws.playheadPos * rect.w; + ctx.strokeStyle = PLAYHEAD_COLOR; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(phX, rect.y); + ctx.lineTo(phX, rect.y + rect.h); + ctx.stroke(); + } + + // Transport bar + this._drawTransportBar(ctx, rect); + + } catch (e) { + console.error("[AudioWaveformSlicer] Draw error:", e); + } + + // Reset text alignment + ctx.textAlign = "left"; + }; + + // --- Draw transport bar --- + nodeType.prototype._drawTransportBar = function (ctx, rect) { + if (!this._aws) return; + var barY = rect.y + rect.h + 5; + + // Play/pause button + ctx.fillStyle = "#CCC"; + ctx.font = "16px sans-serif"; + ctx.textAlign = "left"; + var btnText = this._aws.isPlaying ? "\u23F8" : "\u25B6"; + ctx.fillText(btnText, rect.x + 5, barY + 16); + + // Time display + var currentTime = this._aws.playheadPos * (this._aws.duration || 0); + ctx.fillStyle = "#AAA"; + ctx.font = "12px sans-serif"; + ctx.fillText( + formatTime(currentTime) + " / " + formatTime(this._aws.duration), + rect.x + 28, + barY + 14 + ); + + // Slice count + var numCuts = this._aws.cutLines ? this._aws.cutLines.length : 0; + ctx.textAlign = "right"; + ctx.fillText( + numCuts + " cuts \u2192 " + (numCuts + 1) + " slices", + rect.x + rect.w, + barY + 14 + ); + ctx.textAlign = "left"; + }; + + // --- Mouse events --- + var origOnMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function (event, pos, graphCanvas) { + if (!this._aws || !this._aws.waveformRect) { + return origOnMouseDown ? origOnMouseDown.apply(this, arguments) : false; + } + + var rect = this._aws.waveformRect; + var x = pos[0]; + var y = pos[1]; + + // Check play button click + var barY = rect.y + rect.h + 5; + if (x >= rect.x && x <= rect.x + 25 && y >= barY && y <= barY + 22) { + this._togglePlayback(); + return true; + } + + // Check if inside waveform area + if (x < rect.x || x > rect.x + rect.w || y < rect.y || y > rect.y + rect.h) { + return origOnMouseDown ? origOnMouseDown.apply(this, arguments) : false; + } + + // Check if near a cut line (for dragging) + if (this._aws.cutLines) { + for (var i = 0; i < this._aws.cutLines.length; i++) { + var cx = this._timeToX(this._aws.cutLines[i]); + if (Math.abs(x - cx) <= CUT_LINE_HIT_RADIUS) { + this._aws.draggingCut = i; + return true; + } + } + } + + // Single click: seek playhead + if (this._aws.duration > 0) { + var time = this._xToTime(x); + this._aws.playheadPos = time / this._aws.duration; + this._aws.playOffset = time; + + if (this._aws.isPlaying) { + this._stopPlayback(); + this._startPlayback(); + } + + this.setDirtyCanvas(true, true); + } + return true; + }; + + var origOnMouseMove = nodeType.prototype.onMouseMove; + nodeType.prototype.onMouseMove = function (event, pos, graphCanvas) { + if (this._aws && this._aws.draggingCut >= 0) { + var x = pos[0]; + var time = this._xToTime(x); + this._aws.cutLines[this._aws.draggingCut] = time; + this._syncCutPositions(); + this.setDirtyCanvas(true, true); + return true; + } + return origOnMouseMove ? origOnMouseMove.apply(this, arguments) : false; + }; + + var origOnMouseUp = nodeType.prototype.onMouseUp; + nodeType.prototype.onMouseUp = function (event, pos, graphCanvas) { + if (this._aws && this._aws.draggingCut >= 0) { + this._aws.draggingCut = -1; + this._syncCutPositions(); + this.setDirtyCanvas(true, true); + return true; + } + return origOnMouseUp ? origOnMouseUp.apply(this, arguments) : false; + }; + + var origOnDblClick = nodeType.prototype.onDblClick; + nodeType.prototype.onDblClick = function (event, pos, graphCanvas) { + if (!this._aws || !this._aws.waveformRect || !this._aws.peaks) { + return origOnDblClick ? origOnDblClick.apply(this, arguments) : undefined; + } + + var rect = this._aws.waveformRect; + var x = pos[0]; + var y = pos[1]; + + // Check if inside waveform + if (x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h) { + // Check if near existing cut line -> remove it + for (var i = 0; i < this._aws.cutLines.length; i++) { + var cx = this._timeToX(this._aws.cutLines[i]); + if (Math.abs(x - cx) <= CUT_LINE_HIT_RADIUS) { + this._aws.cutLines.splice(i, 1); + this._syncCutPositions(); + this.setDirtyCanvas(true, true); + return true; + } + } + + // Add new cut line + var time = this._xToTime(x); + this._aws.cutLines.push(time); + this._syncCutPositions(); + this.setDirtyCanvas(true, true); + return true; + } + + return origOnDblClick ? origOnDblClick.apply(this, arguments) : undefined; + }; + + // --- Configure (workflow load) --- + var origOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + if (origOnConfigure) origOnConfigure.apply(this, arguments); + + try { + // Re-find widgets after configure + this._awsWidgets = {}; + if (this.widgets) { + for (var i = 0; i < this.widgets.length; i++) { + var w = this.widgets[i]; + if (w.name === "audio" || w.name === "cut_positions") { + this._awsWidgets[w.name] = w; + } + } + } + + // Hide cut_positions + var cpWidget = this._awsWidgets.cut_positions; + if (cpWidget) { + cpWidget.type = "hidden"; + cpWidget.computeSize = function () { return [0, -4]; }; + } + + if (this._aws) { + this._restoreCutPositions(); + } + + if (this._awsWidgets.audio && this._awsWidgets.audio.value) { + this._loadAudioFile(); + } + } catch (e) { + console.error("[AudioWaveformSlicer] Error in onConfigure:", e); + } + }; + + // --- Compute size --- + var origComputeSize = nodeType.prototype.computeSize; + nodeType.prototype.computeSize = function () { + var size = origComputeSize ? origComputeSize.apply(this, arguments) : [400, 300]; + if (!size) size = [400, 300]; + return [Math.max(size[0], 400), Math.max(size[1], 300)]; + }; + + log("Extension registered successfully"); + }, +});