Files
stuttlepress-ComfyUI-Wan-VA…/web/visual_frame_selector.js
T

1166 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// ── Constants ──────────────────────────────────────────────────────
const NODE_CLASS = "VisualFrameSelector";
const API_PREFIX = "/visual_frame_selector/v2";
const SVG = {
play: `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M3 2 L3 14 L13 8 Z"/></svg>`,
pause: `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="3" height="12"/><rect x="10" y="2" width="3" height="12"/></svg>`,
first: `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="2" y="2" width="2" height="12"/><path d="M6 8 L14 2 L14 14 Z"/></svg>`,
prev: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"><path stroke-width="2" d="M10 4 L4 8 L10 12"/></svg>`,
next: `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor"><path stroke-width="2" d="M6 4 L12 8 L6 12"/></svg>`,
last: `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2 L10 8 L2 14 Z"/><rect x="12" y="2" width="2" height="12"/></svg>`,
jumpIn: `<svg width="16" height="16" viewBox="0 0 16 16" fill="#55f"><path d="M8 2 L4 2 L4 14 L8 14 L8 12 L6 12 L6 4 L8 4 Z"/><line x1="10" y1="8" x2="16" y2="8" stroke="#55f" stroke-width="1"/><polygon points="11,5 9,8 11,11"/></svg>`,
setIn: `<svg width="16" height="16" viewBox="0 0 16 16" fill="#55f"><path d="M8 2 L4 2 L4 14 L8 14 L8 12 L6 12 L6 4 L8 4 Z"/></svg>`,
playRange: `<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2 L2 14" stroke="#55f" stroke-width="2"/><path d="M14 2 L14 14" stroke="red" stroke-width="2"/><path d="M5 4 L11 8 L5 12 Z"/></svg>`,
setOut: `<svg width="16" height="16" viewBox="0 0 16 16" fill="red"><path d="M8 2 L12 2 L12 14 L8 14 L8 12 L10 12 L10 4 L8 4 Z"/></svg>`,
jumpOut: `<svg width="16" height="16" viewBox="0 0 16 16" fill="red"><path d="M8 2 L12 2 L12 14 L8 14 L8 12 L10 12 L10 4 L8 4 Z"/><line x1="0" y1="8" x2="6" y2="8" stroke="red" stroke-width="1"/><polygon points="5,5 7,8 5,11"/></svg>`,
};
// ── State Management ───────────────────────────────────────────────
function createState() {
return {
video: {
filename: "",
fps: 0,
totalFrames: 0,
width: 0,
height: 0,
hasAudio: false,
loaded: false,
},
selection: {
currentFrame: 0,
startFrame: 0,
endFrame: 0,
},
ui: {
isPlaying: false,
isDragging: false,
draggingMarker: null,
},
};
}
function updateState(ctx, changes, source) {
const { node, state, dom, widgets } = ctx;
const prevStart = state.selection.startFrame;
const prevEnd = state.selection.endFrame;
const prevCurrent = state.selection.currentFrame;
if (changes.video) Object.assign(state.video, changes.video);
if (changes.selection) Object.assign(state.selection, changes.selection);
if (changes.ui) Object.assign(state.ui, changes.ui);
// Enforce constraints
if (state.video.totalFrames > 0) {
const maxFrame = state.video.totalFrames - 1;
state.selection.currentFrame = clamp(state.selection.currentFrame, 0, maxFrame);
state.selection.startFrame = clamp(state.selection.startFrame, 0, maxFrame);
state.selection.endFrame = clamp(state.selection.endFrame, 0, maxFrame);
if (state.selection.endFrame <= state.selection.startFrame) {
state.selection.endFrame = Math.min(state.selection.startFrame + 1, maxFrame);
}
}
// Sync widgets (guard against re-entry from widget source)
if (source !== "widget") {
syncWidgetValue(widgets.currentFrame, state.selection.currentFrame);
syncWidgetValue(widgets.startFrame, state.selection.startFrame);
syncWidgetValue(widgets.endFrame, state.selection.endFrame);
}
// Update video seek
const video = dom.video;
if (video && source !== "video-timeupdate" && state.selection.currentFrame !== prevCurrent && video.duration) {
// Map frame to time through video.duration rather than frame/fps.
// The browser's duration (from container metadata) can differ slightly
// from totalFrames/fps, so using duration directly avoids drift.
const total = state.video.totalFrames;
const time = total > 1
? (state.selection.currentFrame / (total - 1)) * video.duration
: 0;
if (Math.abs(video.currentTime - time) > 0.01) {
video.currentTime = time;
}
}
// Update scrubber markers
if (state.video.totalFrames > 0) {
const total = state.video.totalFrames;
if (state.selection.startFrame !== prevStart || changes.video) {
setMarkerPosition(dom.inMarker, dom.inLine, state.selection.startFrame, total);
}
if (state.selection.endFrame !== prevEnd || changes.video) {
setMarkerPosition(dom.outMarker, dom.outLine, state.selection.endFrame, total);
}
}
// Update progress bar
if (video && video.duration && dom.progress) {
dom.progress.style.width = (video.currentTime / video.duration) * 100 + "%";
}
updateStatusLine(ctx);
if (node.graph) {
node.graph.setDirtyCanvas(true, true);
}
}
// ── Helpers ─────────────────────────────────────────────────────────
function clamp(val, min, max) {
return Math.max(min, Math.min(max, val));
}
let _syncing = false;
function syncWidgetValue(widget, value) {
if (_syncing) return;
if (widget && widget.value !== value) {
_syncing = true;
widget.value = value;
// Setting .value alone may not trigger reactivity in all renderers.
// Fire the callback to ensure the UI updates.
if (widget.callback) {
widget.callback(value);
}
_syncing = false;
}
}
function setMarkerPosition(marker, line, frame, totalFrames) {
if (!totalFrames || totalFrames <= 1) return;
const percent = (frame / (totalFrames - 1)) * 100;
marker.style.left = percent + "%";
line.style.left = percent + "%";
}
function updateStatusLine(ctx) {
const { state, dom } = ctx;
const wasVisible = dom.statusRow.style.display === "flex";
if (!state.video.loaded) {
dom.statusRow.style.display = "none";
dom.infoRow.style.display = "none";
if (wasVisible && ctx._remeasureControls) ctx._remeasureControls();
return;
}
dom.statusRow.style.display = "flex";
dom.infoRow.style.display = "flex";
if (!wasVisible && ctx._remeasureControls) ctx._remeasureControls();
const count = state.selection.endFrame - state.selection.startFrame + 1;
const total = state.video.totalFrames;
// First bar: Total and Selected frames
dom.statusText.textContent = `Total frames: ${total} | Selected frames: ${count}`;
// Second bar: Resolution and FPS
const width = state.video.width;
const height = state.video.height;
if (ctx.widgets.divisibleBy16?.value && width > 0 && height > 0) {
const sw = Math.ceil(width / 16) * 16;
const sh = Math.ceil(height / 16) * 16;
dom.resolutionText.textContent = (sw !== width || sh !== height)
? `Resolution: ${width}×${height} → ${sw}×${sh}`
: `Resolution: ${width}×${height} (×16 ✓)`;
} else {
dom.resolutionText.textContent = `Resolution: ${width}×${height}`;
}
const fps = state.video.fps > 0 ? state.video.fps.toFixed(1) : "N/A";
dom.fpsText.textContent = `FPS: ${fps}`;
}
// ── DOM Construction ───────────────────────────────────────────────
function buildScrubber() {
const scrubber = document.createElement("div");
scrubber.style.cssText = "position:relative;height:30px;background:#222;cursor:pointer;margin-top:8px;";
const progress = document.createElement("div");
progress.style.cssText = "height:100%;width:0%;background:#445566;pointer-events:none;";
scrubber.appendChild(progress);
const inMarker = document.createElement("div");
inMarker.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" style="display:block;"><path d="M6 2 L10 10 L2 10 Z" fill="#55f"/></svg>`;
inMarker.style.cssText = "position:absolute;left:0%;bottom:0;transform:translateX(-50%) translateY(80%);pointer-events:auto;cursor:ew-resize;z-index:2;";
const inLine = document.createElement("div");
inLine.style.cssText = "position:absolute;left:0%;top:0;width:2px;height:100%;background:#55f;pointer-events:none;opacity:0.5;transform:translateX(-1px);";
const outMarker = document.createElement("div");
outMarker.innerHTML = `<svg width="12" height="12" viewBox="0 0 12 12" style="display:block;"><path d="M6 10 L2 2 L10 2 Z" fill="red"/></svg>`;
outMarker.style.cssText = "position:absolute;left:100%;top:0;transform:translateX(-50%) translateY(-80%);pointer-events:auto;cursor:ew-resize;z-index:2;";
const outLine = document.createElement("div");
outLine.style.cssText = "position:absolute;left:100%;top:0;width:2px;height:100%;background:red;pointer-events:none;opacity:0.5;transform:translateX(-1px);";
scrubber.append(inLine, outLine, inMarker, outMarker);
return { scrubber, progress, inMarker, inLine, outMarker, outLine };
}
function buildButton(svgContent, title) {
const btn = document.createElement("button");
btn.innerHTML = svgContent;
btn.title = title;
btn.style.cssText = `
display:flex;align-items:center;justify-content:center;flex-shrink:0;
width:28px;height:24px;padding:0;
background-color:#2A2A2A;border:1px solid #444;border-radius:4px;
color:#ddd;cursor:pointer;outline:none;
`;
btn.onmouseenter = () => { btn.style.backgroundColor = "#444"; btn.style.borderColor = "#666"; };
btn.onmouseleave = () => { btn.style.backgroundColor = "#2A2A2A"; btn.style.borderColor = "#444"; };
return btn;
}
function buildControlBar() {
const controlBar = document.createElement("div");
controlBar.style.cssText = "margin-top:8px;display:flex;flex-direction:column;gap:4px;align-items:center;justify-content:center;";
const row1 = document.createElement("div");
row1.style.cssText = "display:flex;gap:4px;align-items:center;justify-content:center;";
const firstBtn = buildButton(SVG.first, "First frame");
const prevBtn = buildButton(SVG.prev, "Previous frame");
const playPauseBtn = buildButton(SVG.play, "Play/Pause");
const nextBtn = buildButton(SVG.next, "Next frame");
const lastBtn = buildButton(SVG.last, "Last frame");
row1.append(firstBtn, prevBtn, playPauseBtn, nextBtn, lastBtn);
const row2 = document.createElement("div");
row2.style.cssText = "display:flex;gap:4px;align-items:center;justify-content:center;";
const jumpInBtn = buildButton(SVG.jumpIn, "Jump to start_frame");
const setInBtn = buildButton(SVG.setIn, "Set start_frame");
const playRangeBtn = buildButton(SVG.playRange, "Play from start_frame to end_frame");
const setOutBtn = buildButton(SVG.setOut, "Set end_frame");
const jumpOutBtn = buildButton(SVG.jumpOut, "Jump to end_frame");
row2.append(jumpInBtn, setInBtn, playRangeBtn, setOutBtn, jumpOutBtn);
const statusRow = document.createElement("div");
statusRow.style.cssText = "display:none;gap:8px;align-items:center;justify-content:center;margin-top:4px;";
const statusText = document.createElement("span");
statusText.style.cssText = "color:#aaa;font-size:12px;white-space:nowrap;";
statusText.textContent = "";
statusRow.appendChild(statusText);
// Second info bar
const infoRow = document.createElement("div");
infoRow.style.cssText = "display:none;gap:8px;align-items:center;justify-content:center;margin-top:2px;";
const resolutionText = document.createElement("span");
resolutionText.style.cssText = "color:#888;font-size:11px;white-space:nowrap;";
resolutionText.textContent = "";
infoRow.appendChild(resolutionText);
const fpsText = document.createElement("span");
fpsText.style.cssText = "color:#888;font-size:11px;white-space:nowrap;";
fpsText.textContent = "";
infoRow.appendChild(fpsText);
controlBar.append(row1, row2, statusRow, infoRow);
return {
element: controlBar,
buttons: { firstBtn, prevBtn, playPauseBtn, nextBtn, lastBtn, jumpInBtn, setInBtn, playRangeBtn, setOutBtn, jumpOutBtn },
statusRow,
statusText,
infoRow,
resolutionText,
fpsText,
};
}
// ── Native Video Element Discovery ─────────────────────────────────
/**
* Find the native video element created by ComfyUI's video-preview widget.
* Once found, we configure it (disable controls/loop) and wire our events.
*
* Uses a MutationObserver on the widget container so that controls are
* suppressed the instant the <video> element enters the DOM — before the
* browser renders it. This eliminates the flash of native controls on
* workflow restore, where ComfyUI recreates the <video> synchronously.
*
* Falls back to polling for the widget container itself if it isn't
* present yet (e.g. video-preview widget added after onNodeCreated).
*
* Uses the standard widget API which works in both legacy mode and
* compatibility mode.
*/
function findNativeVideoElement(node, abortController, callback) {
let settled = false;
const onFound = (video, widget) => {
if (settled) return;
settled = true;
// Suppress native controls immediately — before any paint — so they
// never flash on workflow restore. hookVideoElement will also set
// these, but doing it here closes the race window.
video.controls = false;
video.muted = true;
callback({ video, widget });
};
// Attach a MutationObserver to a widget container element so we catch
// the <video> element the moment it's inserted, before paint.
const observeContainer = (widget) => {
const container = widget.element;
// Already present?
const existing = container.querySelector("video");
if (existing) {
onFound(existing, widget);
return;
}
const mo = new MutationObserver(() => {
const video = container.querySelector("video");
if (video) {
mo.disconnect();
onFound(video, widget);
}
});
mo.observe(container, { childList: true, subtree: true });
abortController.signal.addEventListener("abort", () => mo.disconnect());
};
// Find the video-preview widget. It may already exist or may be added
// to node.widgets after onNodeCreated returns, so we poll for it briefly.
const tryAttach = () => {
for (const w of (node.widgets || [])) {
if (w.name === "video-preview" && w.element) {
observeContainer(w);
return true;
}
}
return false;
};
if (tryAttach()) return;
let attempts = 0;
const interval = setInterval(() => {
attempts++;
if (tryAttach() || settled) {
clearInterval(interval);
} else if (attempts > 100) { // 5 seconds
clearInterval(interval);
console.warn("[VisualFrameSelector] Could not find video-preview widget after 5s");
}
}, 50);
abortController.signal.addEventListener("abort", () => clearInterval(interval));
}
// ── Event Wiring ───────────────────────────────────────────────────
function wireVideoEvents(ctx) {
const video = ctx.dom.video;
if (!video) return;
// Tag so we don't double-wire
if (video._vfsWired) return;
video._vfsWired = true;
// Video timeupdate -> state
// Derive frame from position within video.duration (consistent with seek).
video.addEventListener("timeupdate", () => {
if (!ctx.state.video.fps || ctx.state.video.fps <= 0) return;
const total = ctx.state.video.totalFrames;
const maxFrame = Math.max(0, total - 1);
let frame;
if (video.duration > 0 && total > 1) {
frame = Math.round((video.currentTime / video.duration) * (total - 1));
} else {
frame = Math.round(video.currentTime * ctx.state.video.fps);
}
frame = clamp(frame, 0, maxFrame);
if (frame !== ctx.state.selection.currentFrame) {
updateState(ctx, { selection: { currentFrame: frame } }, "video-timeupdate");
}
if (video.duration && ctx.dom.progress) {
ctx.dom.progress.style.width = (video.currentTime / video.duration) * 100 + "%";
}
});
video.addEventListener("play", () => {
ctx.state.ui.isPlaying = true;
ctx.dom.buttons.playPauseBtn.innerHTML = SVG.pause;
});
video.addEventListener("pause", () => {
ctx.state.ui.isPlaying = false;
ctx.dom.buttons.playPauseBtn.innerHTML = SVG.play;
});
}
/**
* Take over a video element: disable native controls, store reference, wire events.
* Called on initial discovery and after each video file change (ComfyUI may
* replace the <video> element when loading a new source).
*/
function hookVideoElement(ctx, video) {
if (!video || video === ctx.dom.video) return false;
video.controls = false;
video.loop = false;
video.muted = true;
ctx.dom.video = video;
wireVideoEvents(ctx);
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.
*/
function rehookVideo(ctx) {
const node = ctx.node;
let attempts = 0;
const check = () => {
let video = null;
for (const w of (node.widgets || [])) {
if (w.name === "video-preview" && w.element) {
video = w.element.querySelector("video");
if (video) break;
}
}
if (video && video !== ctx.dom.video) {
hookVideoElement(ctx, video);
return true;
}
return false;
};
// Try immediately
if (check()) return;
// Poll briefly — new element may appear after a tick
const interval = setInterval(() => {
attempts++;
if (check() || attempts > 20) { // 1 second max
clearInterval(interval);
}
}, 50);
ctx.abortController.signal.addEventListener("abort", () => clearInterval(interval));
}
function wireControlEvents(ctx) {
const { state, dom } = ctx;
const { scrubber, progress, inMarker, outMarker, buttons } = dom;
const abort = ctx.abortController;
// Scrubber click-to-seek
scrubber.addEventListener("mousedown", (e) => {
if (e.target === scrubber || e.target === progress) {
state.ui.isDragging = true;
seekFromMouseEvent(ctx, e);
}
});
// Marker dragging
inMarker.addEventListener("mousedown", (e) => {
e.stopPropagation();
state.ui.draggingMarker = "in";
});
outMarker.addEventListener("mousedown", (e) => {
e.stopPropagation();
state.ui.draggingMarker = "out";
});
document.addEventListener("mousemove", (e) => {
if (state.ui.draggingMarker) {
handleMarkerDrag(ctx, e);
} else if (state.ui.isDragging) {
seekFromMouseEvent(ctx, e);
}
}, { signal: abort.signal });
document.addEventListener("mouseup", () => {
state.ui.isDragging = false;
state.ui.draggingMarker = null;
}, { signal: abort.signal });
// Transport controls
const seekToFrame = (frame) => {
updateState(ctx, { selection: { currentFrame: frame } }, "transport");
};
buttons.firstBtn.onclick = () => seekToFrame(0);
buttons.prevBtn.onclick = () => { dom.video?.pause(); seekToFrame(state.selection.currentFrame - 1); };
buttons.playPauseBtn.onclick = () => {
const v = dom.video;
if (v) { if (v.paused) v.play(); else v.pause(); }
};
buttons.nextBtn.onclick = () => { dom.video?.pause(); seekToFrame(state.selection.currentFrame + 1); };
buttons.lastBtn.onclick = () => seekToFrame(state.video.totalFrames - 1);
// Marker controls
buttons.jumpInBtn.onclick = () => seekToFrame(state.selection.startFrame);
buttons.setInBtn.onclick = () => {
updateState(ctx, { selection: { startFrame: state.selection.currentFrame } }, "transport");
};
buttons.playRangeBtn.onclick = () => {
const v = dom.video;
if (!v || !v.duration) return;
seekToFrame(state.selection.startFrame);
v.play();
const checkEnd = () => {
if (v.paused) { v.removeEventListener("timeupdate", checkEnd); return; }
const total = state.video.totalFrames;
if (total <= 1) return;
// Map endFrame to time through duration, then add one frame's worth
const endTime = (state.selection.endFrame / (total - 1)) * v.duration;
const oneFrameTime = v.duration / (total - 1);
if (v.currentTime >= endTime + oneFrameTime * 0.5) {
v.pause();
v.currentTime = endTime;
v.removeEventListener("timeupdate", checkEnd);
}
};
v.addEventListener("timeupdate", checkEnd);
};
buttons.setOutBtn.onclick = () => {
updateState(ctx, { selection: { endFrame: state.selection.currentFrame } }, "transport");
};
buttons.jumpOutBtn.onclick = () => seekToFrame(state.selection.endFrame);
// Widget callbacks
wireWidgetCallbacks(ctx);
}
function seekFromMouseEvent(ctx, e) {
if (!ctx.state.video.totalFrames) return;
const rect = ctx.dom.scrubber.getBoundingClientRect();
const percent = clamp((e.clientX - rect.left) / rect.width, 0, 1);
const frame = Math.round(percent * (ctx.state.video.totalFrames - 1));
updateState(ctx, { selection: { currentFrame: frame } }, "programmatic");
}
function handleMarkerDrag(ctx, e) {
if (!ctx.state.video.totalFrames) return;
const rect = ctx.dom.scrubber.getBoundingClientRect();
const percent = clamp((e.clientX - rect.left) / rect.width, 0, 1);
const frame = Math.round(percent * (ctx.state.video.totalFrames - 1));
if (ctx.state.ui.draggingMarker === "in") {
updateState(ctx, { selection: { startFrame: frame } }, "marker-drag");
} else if (ctx.state.ui.draggingMarker === "out") {
updateState(ctx, { selection: { endFrame: frame } }, "marker-drag");
}
}
function wireWidgetCallbacks(ctx) {
const { widgets } = ctx;
if (widgets.currentFrame) {
widgets.currentFrame.callback = (value) => {
updateState(ctx, { selection: { currentFrame: value } }, "widget");
};
}
if (widgets.startFrame) {
widgets.startFrame.callback = (value) => {
updateState(ctx, { selection: { startFrame: value } }, "widget");
};
}
if (widgets.endFrame) {
widgets.endFrame.callback = (value) => {
updateState(ctx, { selection: { endFrame: value } }, "widget");
};
}
}
// ── Video Loading ──────────────────────────────────────────────────
async function loadVideo(ctx, filename) {
const { state, dom, widgets } = ctx;
if (!filename) {
updateState(ctx, {
video: { filename: "", fps: 0, totalFrames: 0, width: 0, height: 0, hasAudio: false, loaded: false },
selection: { currentFrame: 0, startFrame: 0, endFrame: 0 },
}, "api");
return;
}
// Detect if this is a different video (new load) vs same video (workflow restore)
const isNewVideo = filename !== state.video.filename;
try {
const resp = await fetch(`${API_PREFIX}/query_video?filename=${encodeURIComponent(filename)}`);
if (!resp.ok) return;
const meta = await resp.json();
if (meta.error) return; // PyAV-level failure, silently skip
const defaultEnd = Math.min(meta.total_frames - 1, Math.round(meta.fps * 2));
const maxFrame = meta.total_frames - 1;
if (widgets.currentFrame) widgets.currentFrame.options.max = maxFrame;
if (widgets.startFrame) widgets.startFrame.options.max = maxFrame;
if (widgets.endFrame) widgets.endFrame.options.max = maxFrame;
if (isNewVideo) {
// 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 {
// 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");
// output_all_frames doesn't depend on the video element being
// present — restore it immediately so it isn't lost if dom.video
// is still null when syncWhenReady fires below. Use
// syncWidgetValue so the widget callback fires and updates the
// display in both the canvas and Vue renderers.
const savedOAF = ctx._savedFrameValues;
if (savedOAF?.output_all_frames !== undefined) {
syncWidgetValue(widgets.outputAllFrames, savedOAF.output_all_frames);
}
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;
// Belt-and-suspenders: re-apply output_all_frames in case
// something between the early restore above and loadedmetadata
// clobbered the value.
if (saved?.output_all_frames !== undefined) {
syncWidgetValue(widgets.outputAllFrames, saved.output_all_frames);
}
updateState(ctx, {
selection: {
currentFrame: cf,
startFrame: sf,
endFrame: ef === 0 ? defaultEnd : ef,
},
}, "programmatic");
};
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);
}
}
// ── Resize Logic ───────────────────────────────────────────────────
function setupResize(ctx) {
const { node, dom } = ctx;
const controlsWidget = dom.controlsWidget;
const controlsContainer = dom.controlsContainer;
// Cache the measured controls height to avoid feedback loops.
// computeSize is called during layout — if it reads offsetHeight and
// returns a new value, ComfyUI re-layouts, changing offsetHeight again.
// We measure once initially and re-measure only on explicit events.
let cachedControlsHeight = 130; // sensible default before first measurement
const measureControls = () => {
const h = controlsContainer.offsetHeight;
if (h > 0) cachedControlsHeight = h + 30; // generous padding for widget spacing
};
// Measure after first render
requestAnimationFrame(() => {
requestAnimationFrame(measureControls);
});
// Re-measure when status line appears/disappears (video load/clear)
ctx._remeasureControls = () => {
requestAnimationFrame(measureControls);
};
// Minimum width: 5 buttons * 28px + 4 gaps * 4px + padding = ~180px
const MIN_WIDTH = 315;
const MIN_HEIGHT = 150;
controlsWidget.computeSize = function () {
return [MIN_WIDTH, cachedControlsHeight];
};
// Apply minimum size immediately
node.setSize([Math.max(node.size[0], MIN_WIDTH), Math.max(node.size[1], MIN_HEIGHT)]);
// Capture computeSize now so onResize can call it to compute the true
// minimum height at any given width. The video preview's computeSize
// returns [w, w * aspectRatio], so the required height grows with width.
// Using a hard-coded MIN_HEIGHT caused overflow: when the node was made
// wider (above MIN_WIDTH), the video needed more vertical space, but the
// height wasn't forced up because it was still above the low MIN_HEIGHT.
const origComputeSize = node.computeSize.bind(node);
requestAnimationFrame(() => {
node.minWidth = MIN_WIDTH;
node.lastResizePos = [...node.pos];
const origOnResize = node.onResize;
node.onResize = function (size) {
const xChanged = this.pos[0] !== this.lastResizePos[0];
const yChanged = this.pos[1] !== this.lastResizePos[1];
if (size[0] < MIN_WIDTH) {
size[0] = MIN_WIDTH;
if (xChanged) this.pos[0] = this.lastResizePos[0];
}
// origComputeSize() always computes at the node's *minimum* width
// (whatever LiteGraph determines), not the actual node width.
// At wider widths the video widget needs more height (aspect ratio),
// so we correct by adding the delta: (actualWidth - computedWidth) * AR.
const computedMinSize = origComputeSize();
const videoEl = ctx.dom.video;
const aspectRatio = (videoEl?.videoHeight && videoEl?.videoWidth)
? videoEl.videoHeight / videoEl.videoWidth
: 9 / 16;
const correctedMinH = computedMinSize[1] + Math.round((size[0] - computedMinSize[0]) * aspectRatio);
const minH = Math.max(MIN_HEIGHT, correctedMinH);
if (size[1] < minH) {
size[1] = minH;
if (yChanged) this.pos[1] = this.lastResizePos[1];
}
this.lastResizePos = [...this.pos];
if (origOnResize) return origOnResize.call(this, size);
return size;
};
});
}
// ── Node Extension ─────────────────────────────────────────────────
app.registerExtension({
name: "VisualFrameSelector.v2",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== NODE_CLASS) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const node = this;
// ── Remove input connectors ──
const inputsToRemove = ["current_frame", "start_frame", "end_frame"];
if (node.inputs) {
for (let i = node.inputs.length - 1; i >= 0; i--) {
if (inputsToRemove.includes(node.inputs[i].name)) {
node.removeInput(i);
}
}
}
// ── Create state and abort controller ──
const state = createState();
const abortController = new AbortController();
// ── Build controls DOM ──
const controlsContainer = document.createElement("div");
controlsContainer.style.cssText = "width:100%;padding:0 10px 10px 10px;box-sizing:border-box;";
const { scrubber, progress, inMarker, inLine, outMarker, outLine } = buildScrubber();
const controlBar = buildControlBar();
controlsContainer.appendChild(scrubber);
controlsContainer.appendChild(controlBar.element);
// ── Add controls as DOM widget ──
const controlsWidget = node.addDOMWidget("vfs_controls", "customvideo", controlsContainer, {
serialize: false,
hideOnZoom: false,
});
// ── Find ComfyUI widgets ──
const widgets = {
video: node.widgets.find(w => w.name === "video"),
currentFrame: node.widgets.find(w => w.name === "current_frame"),
startFrame: node.widgets.find(w => w.name === "start_frame"),
endFrame: node.widgets.find(w => w.name === "end_frame"),
outputAllFrames: node.widgets.find(w => w.name === "output_all_frames"),
divisibleBy16: node.widgets.find(w => w.name === "divisible_by_16"),
};
// ── Widget ordering ──
// Desired order:
// 1. video combo (file selector)
// 2. video-preview (native preview — may be added after onNodeCreated)
// 3. vfs_controls (our scrubber + buttons)
// 4. current_frame, start_frame, end_frame
// 5. any other widgets we don't recognize
//
// 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() {
const combo = [];
const preview = [];
const controls = [];
const frameW = [];
const other = [];
for (const w of node.widgets) {
if (w.name === "video" || w.name === "upload") {
combo.push(w);
} else if (w.name === "video-preview") {
preview.push(w);
} else if (w === controlsWidget) {
controls.push(w);
} else if (frameWidgetNames.has(w.name)) {
frameW.push(w);
} else {
other.push(w);
}
}
node.widgets = [...combo, ...preview, ...controls, ...frameW, ...other];
}
reorderWidgets();
// ── Build context (video element added later when found) ──
const ctx = {
node,
state,
dom: {
video: null, // Set when native video element is found
scrubber,
progress,
inMarker,
inLine,
outMarker,
outLine,
controlsContainer,
controlsWidget,
controlBar,
statusRow: controlBar.statusRow,
statusText: controlBar.statusText,
infoRow: controlBar.infoRow,
resolutionText: controlBar.resolutionText,
fpsText: controlBar.fpsText,
buttons: controlBar.buttons,
},
widgets,
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,
output_all_frames: widgets.outputAllFrames?.value ?? false,
divisible_by_16: widgets.divisibleBy16?.value ?? false,
};
};
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;
// Use syncWidgetValue so the widget callback fires and
// the display updates in both the canvas and Vue renderers.
syncWidgetValue(widgets.outputAllFrames, v.output_all_frames ?? false);
syncWidgetValue(widgets.divisibleBy16, v.divisible_by_16 ?? false);
}
};
// ── Wire control events (these work even before video is found) ──
wireControlEvents(ctx);
setupResize(ctx);
// ── Find native video element and wire to it ──
const vfsCallback = (result) => {
const { video, widget } = result;
// All post-discovery setup, extracted so it can be called from
// either the synchronous path (video already connected) or the
// deferred path (video connected after DOM re-attachment below).
const proceed = () => {
// Take over the video element
hookVideoElement(ctx, video);
// Persistent watcher: ComfyUI may replace the <video> element
// after initialization (e.g. when the video source finishes loading).
// rehookVideo polls for 1 second but may miss replacements that
// arrive later. This observer has no expiry — it watches for the
// lifetime of the node and re-hooks immediately if a new element appears.
const replacementObserver = new MutationObserver(() => {
const current = widget.element.querySelector("video");
if (current && current !== ctx.dom.video) {
hookVideoElement(ctx, current);
}
});
replacementObserver.observe(widget.element, { childList: true, subtree: true });
abortController.signal.addEventListener("abort", () => replacementObserver.disconnect());
// Override video-preview's computeSize to scale with the current
// node width rather than the video's intrinsic dimensions.
// Without this, ComfyUI's drag resize uses computeSize() as a floor,
// locking the node at whatever size it was saved at.
widget.computeSize = function (width) {
const w = width || node.size[0];
const aspectRatio = (video.videoHeight && video.videoWidth)
? video.videoHeight / video.videoWidth
: 9 / 16;
return [w, Math.round(w * aspectRatio)];
};
// Re-run widget ordering now that video-preview exists
reorderWidgets();
// If video already has metadata loaded, restore state
if (video.duration && ctx.state.video.loaded) {
updateState(ctx, {
selection: {
currentFrame: ctx.state.selection.currentFrame,
startFrame: ctx.state.selection.startFrame,
endFrame: ctx.state.selection.endFrame,
}
}, "programmatic");
}
};
if (video.isConnected) {
proceed();
return;
}
// The video element exists but isn't connected to the document yet.
// Two distinct causes observed in the wild:
// (a) Tab switch / simple re-attach: widget.element is temporarily
// detached; it re-connects shortly and video.isConnected becomes true.
// (b) Subgraph exit: ComfyUI tears down and recreates widget DOM entirely.
// widget.element is replaced with a fresh element; the originally-
// discovered video is orphaned and will never connect. Detected by
// widget.element.isConnected becoming true on the NEW element.
// 2s fallback is the Nodes 2.0 / Vue renderer signal — in that renderer the
// video is never in the real document.
console.debug(
"[VisualFrameSelector] Video not yet connected to document (widget.element.isConnected=%s); waiting for DOM attachment...",
widget.element.isConnected
);
let settled = false;
const cleanup = () => {
connectedWatcher.disconnect();
clearInterval(pollId);
clearTimeout(settleTimeout);
};
const settle = (connected) => {
if (settled) return;
settled = true;
cleanup();
if (connected) {
console.debug("[VisualFrameSelector] Video connected after DOM attachment; proceeding.");
proceed();
} else {
console.warn("[VisualFrameSelector] Video element is detached from DOM — renderer not supported. Controls disabled.");
ctx.dom.controlsContainer.innerHTML = "";
const msg = document.createElement("div");
msg.style.cssText = "padding:12px;color:#f80;font-size:13px;text-align:center;line-height:1.4;";
msg.textContent = "Visual Frame Selector controls are not supported with the current renderer. Please disable Nodes 2.0 in ComfyUI settings, or use the native video controls above.";
ctx.dom.controlsContainer.appendChild(msg);
node.setSize(node.computeSize());
}
};
// Case (b): widget.element was replaced. Cancel and re-run discovery
// so proceed() wires to the fresh element via vfsCallback.
const checkReplacement = () => {
if (!widget.element?.isConnected) return false;
const freshVideo = widget.element.querySelector("video");
if (!freshVideo?.isConnected) return false;
if (settled) return false;
settled = true;
cleanup();
console.debug("[VisualFrameSelector] widget.element replaced with connected element; restarting discovery.");
findNativeVideoElement(node, abortController, vfsCallback);
return true;
};
// Observe the full document to catch widget containers attached anywhere.
const connectedWatcher = new MutationObserver(() => {
if (video.isConnected) { settle(true); return; }
if (checkReplacement()) return;
console.debug("[VisualFrameSelector] connectedWatcher fired but video still not connected (widget.element.isConnected=%s)", widget.element.isConnected);
});
connectedWatcher.observe(document, { childList: true, subtree: true });
// Polling fallback — catches re-attachment or replacement that the
// MutationObserver misses (shadow DOM, in-progress mutation batch).
const pollId = setInterval(() => {
if (video.isConnected) { settle(true); return; }
checkReplacement();
}, 50);
const settleTimeout = setTimeout(() => {
console.debug("[VisualFrameSelector] 2s timeout: video.isConnected=%s, widget.element.isConnected=%s", video.isConnected, widget.element.isConnected);
settle(false);
}, 2000);
abortController.signal.addEventListener("abort", () => settle(false));
};
findNativeVideoElement(node, abortController, vfsCallback);
// ── Video file selector callback ──
if (widgets.video) {
let initialLoadDone = false;
const origVideoCallback = widgets.video.callback;
widgets.video.callback = async function (value) {
// Call ComfyUI's native callback first — this triggers the
// video-preview widget to load the new source via /api/view.
if (origVideoCallback) {
origVideoCallback.apply(this, arguments);
}
// Re-find the video element — ComfyUI may have replaced it
rehookVideo(ctx);
// If this is the first call (during configure() positional
// restore, before the double-RAF fires), pre-set the filename
// so loadVideo treats this as a workflow restore rather than
// a brand-new video selection. Without this, isNewVideo=true
// and frame/output_all_frames values are reset to defaults
// instead of being read from vfs_frame_values.
// User-initiated changes (initialLoadDone already true) are
// unaffected and still take the new-video path.
if (!initialLoadDone) {
state.video.filename = value;
}
initialLoadDone = true;
await loadVideo(ctx, value);
};
// 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;
// Drive ComfyUI's native video-preview widget to set
// the <video> src. In newer ComfyUI frontends the
// callback is not called during configure/restore, so
// we invoke it explicitly here.
if (origVideoCallback) {
origVideoCallback.call(widgets.video, widgets.video.value);
}
rehookVideo(ctx);
loadVideo(ctx, widgets.video.value);
}
});
});
}
// ── Cleanup on node removal ──
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
abortController.abort();
if (origOnRemoved) origOnRemoved.call(this);
};
node._vfsCtx = ctx;
return result;
};
},
});