Integrated baseline. Still rough around the edges.
This commit is contained in:
+13
-107
@@ -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
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user