Integrated baseline. Still rough around the edges.

This commit is contained in:
stuttlepress
2026-02-27 16:16:53 -06:00
parent a57cae4477
commit cc8cfff649
7 changed files with 204 additions and 230 deletions
+13 -107
View File
@@ -1,37 +1,17 @@
import os
import torch
import numpy as np
import os
import av
import folder_paths
# Video extensions supported for the file selector
VIDEO_EXTENSIONS = ['mp4', 'webm', 'mkv', 'mov', 'avi', 'gif']
class VisualFrameSelector:
DESCRIPTION = """
Interactive video frame selection.
- Drag markers to set start/end frames
- Click scrubber to seek
- Use transport buttons for playback
"""
Interactive video frame selection with visual preview.
Load a video and use the visual scrubber to select a range of frames.
The selected frames are output as an IMAGE tensor.
Frame range is inclusive at both ends, 0-based indexing.
start_frame=0, end_frame=4 outputs 5 frames (0, 1, 2, 3, 4).
The UI enforces end_frame >= start_frame + 1 (minimum 2-frame selection).
end_frame=0 means "last frame of the video".
"""
DESCRIPTION = """# Visual Frame Selector
Interactive video frame selection with visual preview.
**Controls:**
- Drag markers to set start/end frames
- Click scrubber to seek
- Use transport buttons for playback
- Right-click video for volume control
"""
@classmethod
def INPUT_TYPES(cls):
@@ -47,10 +27,10 @@ Interactive video frame selection with visual preview.
}
}
RETURN_TYPES = ("IMAGE", "INT", "FLOAT", "INT", "INT", "INT", "AUDIO")
RETURN_NAMES = ("images", "selected_frames", "fps", "total_frames", "start_frame", "end_frame", "audio")
RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "INT")
RETURN_NAMES = ("images", "selected_frames", "total_frames", "start_frame", "end_frame")
FUNCTION = "load_frames"
CATEGORY = "image/video"
CATEGORY = "video/utility"
@classmethod
def IS_CHANGED(cls, video, start_frame, end_frame, **kwargs):
@@ -128,20 +108,16 @@ Interactive video frame selection with visual preview.
frames_tensor = torch.stack(frames)
audio = self._extract_audio(video_path, actual_start, actual_end, fps)
return (
frames_tensor,
len(frames),
fps,
total_frames,
actual_start,
actual_end,
audio,
)
except (ValueError, FileNotFoundError):
raise # Let these propagate cleanly to ComfyUI
raise
except Exception as e:
raise ValueError(f"Error processing video: {str(e)}")
@@ -150,81 +126,11 @@ Interactive video frame selection with visual preview.
if container is not None:
container.close()
def _extract_audio(self, video_path, start_frame, end_frame, fps):
"""Extract audio for the selected frame range using PyAV.
Returns audio dict compatible with ComfyUI AUDIO type, or None if
no audio track exists.
"""
if fps <= 0:
return None
try:
container = av.open(video_path)
if len(container.streams.audio) == 0:
container.close()
return None
audio_stream = container.streams.audio[0]
sample_rate = audio_stream.rate
channels = audio_stream.channels
start_time = start_frame / fps
duration = (end_frame - start_frame + 1) / fps
end_time = start_time + duration
# Seek to start time
if start_time > 0:
target_pts = int(start_time / audio_stream.time_base)
container.seek(target_pts, stream=audio_stream)
# Decode audio samples
audio_frames = []
for frame in container.decode(audio=0):
frame_time = float(frame.pts * audio_stream.time_base) if frame.pts is not None else 0.0
if frame_time > end_time:
break
# Convert to float32 numpy array
arr = frame.to_ndarray()
audio_frames.append(arr)
container.close()
if len(audio_frames) == 0:
return None
# Concatenate all decoded audio
audio_np = np.concatenate(audio_frames, axis=-1)
# Ensure shape is [channels, samples]
if audio_np.ndim == 1:
audio_np = audio_np.reshape(1, -1)
# Trim to exact time range
start_sample = int(start_time * sample_rate)
end_sample = int(end_time * sample_rate)
total_samples = audio_np.shape[-1]
# After seeking, the first decoded frame may start before our target
# Just use what we have, trim to duration
max_samples = end_sample - start_sample
if audio_np.shape[-1] > max_samples:
audio_np = audio_np[:, :max_samples]
# ComfyUI AUDIO format: {"waveform": tensor [batch, channels, samples], "sample_rate": int}
audio_tensor = torch.from_numpy(audio_np.astype(np.float32))
audio_tensor = audio_tensor.unsqueeze(0) # Add batch dim
return {"waveform": audio_tensor, "sample_rate": sample_rate}
except Exception:
return None
NODE_CLASS_MAPPINGS = {
"VisualFrameSelector": VisualFrameSelector,
"VisualFrameSelector": VisualFrameSelector
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VisualFrameSelector": "🪐 Visual Frame Selector",
}
"VisualFrameSelector": "🪐 Visual Frame Selector"
}
+41 -35
View File
@@ -1,6 +1,7 @@
import os
import re
import cv2
import av
import numpy as np
import torch
@@ -25,15 +26,16 @@ class LoadVideosFromFolderSimple:
},
}
RETURN_TYPES = ["IMAGE"]
RETURN_NAMES = ["images"]
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "load_videos"
CATEGORY = "video/utility"
DESCRIPTION = """
Load all videos from a folder, concatenated into a single image batch.
Optionally connect a VideoHelperSuite Meta Batch Manager to process large collections in smaller RAM-safe chunks. See VHS Meta Batch Manager documentation for more information.
- Formats: webm, mp4, mkv, gif, mov
- All videos must have identical resolution
Load all videos from a folder, concatenated into
a single image batch. Optionally connect a
VideoHelperSuite Meta Batch Manager to process
large collections in smaller RAM-safe chunks. See
VHS Meta Batch Manager documentation for more information.
"""
def load_videos(self, folder_path, debug, meta_batch=None, unique_id=None):
@@ -150,20 +152,19 @@ class LoadVideosFromFolderSimple:
def _load_video_frames(self, video_path):
"""Load all frames from a video file. Returns [N, H, W, C] float32 tensor."""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise RuntimeError(f"Failed to open video: {video_path}")
container = av.open(video_path)
if len(container.streams.video) == 0:
container.close()
raise RuntimeError(f"No video stream found in: {video_path}")
frames = []
while True:
ret, frame = cap.read()
if not ret:
break
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frame_tensor = torch.from_numpy(frame_rgb).float() / 255.0
for frame in container.decode(video=0):
rgb = frame.to_ndarray(format="rgb24")
frame_tensor = torch.from_numpy(rgb.astype(np.float32) / 255.0)
frames.append(frame_tensor)
cap.release()
container.close()
if not frames:
raise RuntimeError(f"No frames extracted from {video_path}")
@@ -190,10 +191,18 @@ class LoadVideosFromFolderSimple:
"""Fast frame count estimate using container metadata. No decoding."""
total = 0
for video_path in video_files:
cap = cv2.VideoCapture(video_path)
if cap.isOpened():
total += int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
try:
container = av.open(video_path)
if len(container.streams.video) > 0:
count = container.streams.video[0].frames
if count > 0:
total += count
else:
# Container doesn't report frame count; decode to count
total += sum(1 for _ in container.decode(video=0))
container.close()
except Exception:
pass
return total
def _frame_generator(self, video_files, debug):
@@ -205,31 +214,28 @@ class LoadVideosFromFolderSimple:
if debug:
print(f"[Batched] Opening [{idx+1}/{len(video_files)}]: {os.path.basename(video_path)}")
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise RuntimeError(f"Failed to open video: {video_path}")
container = av.open(video_path)
if len(container.streams.video) == 0:
container.close()
raise RuntimeError(f"No video stream found in: {video_path}")
frame_count = 0
while True:
ret, frame = cap.read()
if not ret:
break
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frame_tensor = torch.from_numpy(frame_rgb).float() / 255.0
for frame in container.decode(video=0):
rgb = frame.to_ndarray(format="rgb24")
frame_tensor = torch.from_numpy(rgb.astype(np.float32) / 255.0)
frame_count += 1
yield frame_tensor, video_path
cap.release()
container.close()
if debug:
print(f"[Batched] Finished {os.path.basename(video_path)}: {frame_count} frames")
# ComfyUI node registration
NODE_CLASS_MAPPINGS = {
"LoadVideosFromFolderSimple": LoadVideosFromFolderSimple,
"LoadVideosFromFolderSimple": LoadVideosFromFolderSimple
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoadVideosFromFolderSimple": "🪐 Load Videos From Folder (Simple)",
"LoadVideosFromFolderSimple": "🪐 Load Videos From Folder (Simple)"
}
+4 -2
View File
@@ -30,7 +30,9 @@ class WanVACEBatchContext:
RETURN_NAMES = ("work_dir", "workfile_prefix", "video_1_filename", "video_2_filename", "is_first", "is_last")
FUNCTION = "setup_context"
CATEGORY = "video/VACE"
DESCRIPTION = "Establishes iteration context for batch video processing."
DESCRIPTION = """
Establishes iteration context for batch video join processing.
"""
INPUT_IS_LIST = True
def setup_context(self, **kwargs):
@@ -89,5 +91,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WanVACEBatchContext": "🪐 Wan VACE Batch Context"
"WanVACEBatchContext": "🪐 VACE Batch Context"
}
+6 -4
View File
@@ -8,8 +8,8 @@ class WanVACEExtend:
"video": ("IMAGE",),
"extend_from_idx": ("INT", {
"default": -1,
"min": -999999,
"max": 999999,
"min": -1000000,
"max": 1000000,
"step": 1,
"tooltip": "Frame index to extend from. Negative values count from the end of the video. e.g., -1 is last frame"
}),
@@ -34,7 +34,10 @@ class WanVACEExtend:
RETURN_NAMES = ("control_video", "control_mask", "width", "height", "length", "start_images")
FUNCTION = "vace_extend"
CATEGORY = "video/VACE"
DESCRIPTION = "Generates VACE control video and mask for extending a video from an arbitrary position using context frames."
DESCRIPTION = """
Generates VACE control video and mask for extending a video from an
arbitrary position using context frames.
"""
def vace_extend(self, video, extend_from_idx, context_frames, new_frames):
height = int(video.shape[1])
@@ -98,7 +101,6 @@ class WanVACEExtend:
return (control_video, mask, width, height, length, start_images)
NODE_CLASS_MAPPINGS = {
"WanVACEExtend": WanVACEExtend
}
+6 -11
View File
@@ -1,12 +1,6 @@
import torch
class WanVACEPrepBase:
"""
Shared implementation for WanVACEPrep and WanVACEPrepBatch.
Not registered as a ComfyUI node.
"""
def _validate_dimensions(self, video_1, video_2):
"""Validate that both videos have matching, 16-divisible dimensions."""
height = int(video_1.shape[1])
@@ -95,7 +89,10 @@ class WanVACEPrep(WanVACEPrepBase):
RETURN_NAMES = ("control_video", "control_mask", "width", "height", "length", "start_images", "end_images")
FUNCTION = "vace_prep"
CATEGORY = "video/VACE"
DESCRIPTION = "Generates VACE control video and mask for smooth transitions between two videos using context frames and frame replacement."
DESCRIPTION = """
Generates VACE control video and mask for smooth transitions between
two videos using context frames and frame replacement.
"""
def vace_prep(self, video_1, video_2, context_frames, replace_frames, new_frames):
width, height = self._validate_dimensions(video_1, video_2)
@@ -241,14 +238,12 @@ class WanVACEPrepBatch(WanVACEPrepBase):
return (control_video, mask, width, height, length, start_images, end_images)
# ComfyUI node registration
NODE_CLASS_MAPPINGS = {
"WanVACEPrep": WanVACEPrep,
"WanVACEPrepBatch": WanVACEPrepBatch,
"WanVACEPrepBatch": WanVACEPrepBatch
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WanVACEPrep": "🪐 VACE Join",
"WanVACEPrepBatch": "🪐 VACE Join (Batch)",
"WanVACEPrepBatch": "🪐 VACE Join (Batch)"
}
+12 -15
View File
@@ -23,7 +23,7 @@ class VACESmooth(WanVACEPrepBase):
end_frame : INT — last frame of the selected range, exclusive (from VFS)
context_frames : frames of context to pull from each side of the split
replace_frames : frames to regenerate at each transition edge
add_frames : new frames to insert between the two sides
new_frames : new frames to insert between the two sides
(equivalent to new_frames in WanVACEPrep)
Output signature mirrors WanVACEPrep exactly so the downstream
@@ -40,13 +40,13 @@ class VACESmooth(WanVACEPrepBase):
"start_frame": ("INT", {
"default": 0,
"min": 0,
"max": 99999,
"max": 1000000,
"tooltip": "First frame of the VFS selection (0-based index)."
}),
"end_frame": ("INT", {
"default": 0,
"min": 0,
"max": 99999,
"max": 1000000,
"tooltip": "Last frame of the VFS selection, exclusive (0-based index)."
}),
"context_frames": ("INT", {
@@ -63,7 +63,7 @@ class VACESmooth(WanVACEPrepBase):
"step": 4,
"tooltip": "Number of frames to regenerate at each transition edge (multiple of 4)."
}),
"add_frames": ("INT", {
"new_frames": ("INT", {
"default": 0,
"min": 0,
"max": 240,
@@ -77,17 +77,16 @@ class VACESmooth(WanVACEPrepBase):
RETURN_NAMES = ("control_video", "control_mask", "width", "height", "length", "start_images", "end_images")
FUNCTION = "vace_prep_inline"
CATEGORY = "video/VACE"
DESCRIPTION = (
"Inline VACE prep: splits a single video at the VFS selection boundary "
"and generates the control video and mask for smooth in-clip transitions. "
"Output signature matches Wan VACE Prep for drop-in compatibility."
)
DESCRIPTION = """
Generates VACE control video and mask for smooth transitions between
two specified frames, replacing and/or adding frames in the transition.
"""
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
def vace_prep_inline(self, images, start_frame, end_frame, context_frames, replace_frames, add_frames):
def vace_prep_inline(self, images, start_frame, end_frame, context_frames, replace_frames, new_frames):
total_frames = images.shape[0]
# ── 1. Validate selection bounds ────────────────────────────────
@@ -129,7 +128,7 @@ class VACESmooth(WanVACEPrepBase):
v1_context, v2_context = self._extract_context(video_1, video_2, context_frames, replace_frames)
control_video, mask, _ = self._build_control_video_and_mask(
video_1, v1_context, v2_context,
context_frames, replace_frames, add_frames,
context_frames, replace_frames, new_frames,
height, width
)
@@ -174,12 +173,10 @@ class VACESmooth(WanVACEPrepBase):
f"The split requires at least context_frames + replace_frames frames on each side."
)
# ComfyUI node registration
NODE_CLASS_MAPPINGS = {
"VACESmooth": VACESmooth,
"VACESmooth": VACESmooth
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VACESmooth": "🪐 VACE Smooth",
"VACESmooth": "🪐 VACE Smooth"
}
+122 -56
View File
@@ -155,10 +155,9 @@ function updateStatusLine(ctx) {
if (!wasVisible && ctx._remeasureControls) ctx._remeasureControls();
const count = state.selection.endFrame - state.selection.startFrame + 1;
const fps = state.video.fps;
const total = state.video.totalFrames;
dom.statusText.textContent = `${fps} FPS | ${total} total | Selected: ${count} frames`;
dom.statusText.textContent = `Total frames: ${total} | Selected frames: ${count}`;
}
// ── DOM Construction ───────────────────────────────────────────────
@@ -387,11 +386,6 @@ function hookVideoElement(ctx, video) {
return true;
}
/**
* Re-find and re-hook the native video element after a video file change.
* ComfyUI may create a new <video> element when loading a new source.
* Polls briefly since the new element may appear asynchronously.
*/
/**
* Re-find and re-hook the native video element after a video file change.
* ComfyUI may create a new <video> element when loading a new source.
@@ -587,51 +581,90 @@ async function loadVideo(ctx, filename) {
if (widgets.startFrame) widgets.startFrame.options.max = maxFrame;
if (widgets.endFrame) widgets.endFrame.options.max = maxFrame;
let startFrame, endFrame, currentFrame;
if (isNewVideo) {
currentFrame = 0;
startFrame = 0;
endFrame = defaultEnd;
// New video selected by user — reset to defaults
updateState(ctx, {
video: {
filename,
fps: meta.fps,
totalFrames: meta.total_frames,
width: meta.width,
height: meta.height,
hasAudio: meta.has_audio,
loaded: true,
},
selection: { currentFrame: 0, startFrame: 0, endFrame: defaultEnd },
}, "api");
// Sync seek position once the video element is ready
const syncWhenReady = () => {
const v = dom.video;
if (!v || ctx.state.video.filename !== filename) return;
if (v.readyState >= 1) {
updateState(ctx, {
selection: { currentFrame: 0, startFrame: 0, endFrame: defaultEnd },
}, "programmatic");
} else {
v.addEventListener("loadedmetadata", () => {
if (ctx.state.video.filename === filename) {
updateState(ctx, {
selection: { currentFrame: 0, startFrame: 0, endFrame: defaultEnd },
}, "programmatic");
}
}, { once: true });
}
};
setTimeout(syncWhenReady, 200);
} else {
startFrame = widgets.startFrame ? clamp(widgets.startFrame.value, 0, maxFrame) : 0;
endFrame = widgets.endFrame ? clamp(widgets.endFrame.value, 0, maxFrame) : defaultEnd;
currentFrame = widgets.currentFrame ? clamp(widgets.currentFrame.value, 0, maxFrame) : 0;
if (endFrame === 0) endFrame = defaultEnd;
}
// Workflow restore — set video metadata now, defer reading
// widget values until the video element has loaded. By the time
// loadedmetadata fires, ComfyUI will have finished restoring
// all widget values from the serialized workflow.
updateState(ctx, {
video: {
filename,
fps: meta.fps,
totalFrames: meta.total_frames,
width: meta.width,
height: meta.height,
hasAudio: meta.has_audio,
loaded: true,
},
}, "api");
updateState(ctx, {
video: {
filename,
fps: meta.fps,
totalFrames: meta.total_frames,
width: meta.width,
height: meta.height,
hasAudio: meta.has_audio,
loaded: true,
},
selection: { currentFrame, startFrame, endFrame },
}, "api");
// Sync seek position once the video element is ready.
const syncWhenReady = () => {
const v = dom.video;
if (!v || ctx.state.video.filename !== filename) return;
if (v.readyState >= 1) {
const restoreFromWidgets = () => {
// Use values saved by onConfigure (by name) rather than
// reading widgets, which may have been clobbered by
// ComfyUI's positional restore.
const saved = ctx._savedFrameValues;
const sf = saved ? clamp(saved.start_frame, 0, maxFrame) : 0;
const ef = saved ? clamp(saved.end_frame, 0, maxFrame) : defaultEnd;
const cf = saved ? clamp(saved.current_frame, 0, maxFrame) : 0;
updateState(ctx, {
selection: { currentFrame, startFrame, endFrame },
selection: {
currentFrame: cf,
startFrame: sf,
endFrame: ef === 0 ? defaultEnd : ef,
},
}, "programmatic");
} else {
v.addEventListener("loadedmetadata", () => {
if (ctx.state.video.filename === filename) {
updateState(ctx, {
selection: { currentFrame, startFrame, endFrame },
}, "programmatic");
}
}, { once: true });
}
};
// Small delay to let rehookVideo find the new element first
setTimeout(syncWhenReady, 200);
};
const syncWhenReady = () => {
const v = dom.video;
if (!v || ctx.state.video.filename !== filename) return;
if (v.readyState >= 1) {
restoreFromWidgets();
} else {
v.addEventListener("loadedmetadata", () => {
if (ctx.state.video.filename === filename) {
restoreFromWidgets();
}
}, { once: true });
}
};
setTimeout(syncWhenReady, 200);
}
} catch (err) {
console.error("[VisualFrameSelector] Error loading video:", err);
@@ -764,6 +797,11 @@ app.registerExtension({
// The video-preview widget may be appended to
// node.widgets AFTER onNodeCreated returns, so we re-run ordering
// whenever we detect the native video element.
//
// Because the widget count differs between save (with video-preview)
// and restore (without it yet), ComfyUI's positional value
// restoration assigns values to the wrong widgets. We fix this
// with onConfigure below, which restores frame values by name.
const frameWidgetNames = new Set(["current_frame", "start_frame", "end_frame"]);
function reorderWidgets() {
@@ -815,6 +853,37 @@ app.registerExtension({
abortController,
};
// ── Serialize/restore frame values by name ──
// ComfyUI serializes widget values by positional index, but the
// video-preview widget only exists after onNodeCreated returns.
// This means the widget count (and therefore positions) differ
// between save and restore, causing values to land on the wrong
// widgets. We work around this by saving frame values by name
// in onSerialize and restoring them in onConfigure.
const origOnSerialize = node.onSerialize;
node.onSerialize = function (o) {
if (origOnSerialize) origOnSerialize.call(this, o);
o.vfs_frame_values = {
current_frame: widgets.currentFrame?.value ?? 0,
start_frame: widgets.startFrame?.value ?? 0,
end_frame: widgets.endFrame?.value ?? 0,
};
};
const origOnConfigure = node.onConfigure;
node.onConfigure = function (info) {
if (origOnConfigure) origOnConfigure.call(this, info);
if (info.vfs_frame_values) {
const v = info.vfs_frame_values;
ctx._savedFrameValues = v;
// Write to widgets immediately to override ComfyUI's
// positional restore, which may have put wrong values here.
if (widgets.currentFrame) widgets.currentFrame.value = v.current_frame ?? 0;
if (widgets.startFrame) widgets.startFrame.value = v.start_frame ?? 0;
if (widgets.endFrame) widgets.endFrame.value = v.end_frame ?? 0;
}
};
// ── Wire control events (these work even before video is found) ──
wireControlEvents(ctx);
setupResize(ctx);
@@ -886,11 +955,16 @@ app.registerExtension({
await loadVideo(ctx, value);
};
// Deferred initial load for workflow restore
// Deferred initial load for workflow restore.
// Set state.video.filename so loadVideo treats this as a
// restore (isNewVideo=false) and defers reading widget values
// until loadedmetadata, by which time ComfyUI will have
// finished restoring all widget values.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!initialLoadDone && widgets.video.value) {
initialLoadDone = true;
state.video.filename = widgets.video.value;
rehookVideo(ctx);
loadVideo(ctx, widgets.video.value);
}
@@ -910,11 +984,3 @@ app.registerExtension({
};
},
});
NODE_CLASS_MAPPINGS = {
"VisualFrameSelector": VisualFrameSelector,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VisualFrameSelector": "🪐 Visual Frame Selector",
}