1790 lines
57 KiB
JavaScript
1790 lines
57 KiB
JavaScript
/**
|
||
* VideoFrameExtractor – ComfyUI custom widget (v3)
|
||
*
|
||
* Changes vs v2.1:
|
||
* • Playhead removed entirely — loop region is the only scrub mechanism.
|
||
* • Hoverable loop zone: handles glow on hover; body brightens with a
|
||
* dashed highlight border so the user knows what is grabbable.
|
||
* • Transport bar with large Play / Pause / Stop buttons drawn in the
|
||
* widget itself, positioned between the preview and the timeline.
|
||
* • rAF loop drives videoEl.currentTime through the loop window.
|
||
*/
|
||
|
||
let app;
|
||
try {
|
||
({ app } = await import("../../scripts/app.js"));
|
||
} catch {
|
||
({ app } = await import("/scripts/app.js"));
|
||
}
|
||
|
||
// ─── Layout ──────────────────────────────────────────────────────────────────
|
||
const TRANSPORT_H = 44;
|
||
const RULER_H = 18;
|
||
const STRIP_H_LANDSCAPE = 48;
|
||
const STRIP_H_MAX = 140;
|
||
const ACTIVITY_H = 28;
|
||
const LOOP_H = 24;
|
||
const INFO_H = 40;
|
||
const PAD = 10;
|
||
const HANDLE_W = 10;
|
||
const COLLAPSE_H = 20; // preview collapse toggle height
|
||
const RESIZE_H = 14; // preview resize handle — tall enough to be obvious
|
||
const PREVIEW_H_DEFAULT = 160;
|
||
const PREVIEW_H_MIN = 80;
|
||
const PREVIEW_H_MAX = 400;
|
||
// WIDGET_H is dynamic — computed in widget.computeSize()
|
||
const THUMB_COUNT = 32;
|
||
|
||
// ─── Colours ─────────────────────────────────────────────────────────────────
|
||
const C = {
|
||
surface: "#131318",
|
||
panel: "#0f0f14",
|
||
border: "#222228",
|
||
ruler: "#1a1a22",
|
||
rulerText: "#555568",
|
||
rulerMajor: "#44445a",
|
||
strip: "#0d0d12",
|
||
stripBorder: "#1e1e28",
|
||
activity: "#22d3ee",
|
||
activityFill: "rgba(34,211,238,0.18)",
|
||
|
||
// Loop region
|
||
loopFill: "rgba(99,102,241,0.22)",
|
||
loopFillHov: "rgba(99,102,241,0.42)", // brighter when hovered
|
||
loopBorder: "#6366f1",
|
||
loopBorderHov: "#a5b4fc",
|
||
loopHandle: "#818cf8",
|
||
loopHandleHov: "#e0e7ff",
|
||
loopGlow: "rgba(99,102,241,0.55)",
|
||
|
||
// Transport
|
||
btnBg: "#1e1e2a",
|
||
btnBgHov: "#2d2d42",
|
||
btnBgActive: "#3730a3",
|
||
btnBorder: "#3f3f5a",
|
||
btnBorderHov: "#818cf8",
|
||
playIcon: "#a5f3fc",
|
||
pauseIcon: "#fde68a",
|
||
stopIcon: "#fca5a5",
|
||
|
||
// Playback position line (replaces playhead — simpler, no dragging)
|
||
posLine: "rgba(99,102,241,0.7)",
|
||
stepBg: "#0f2744",
|
||
stepBgHov: "#1a3a5c",
|
||
stepBorder: "#1e4976",
|
||
stepBorderHov: "#3b82f6",
|
||
stepIcon: "#93c5fd",
|
||
|
||
text: "#e2e2ea",
|
||
textDim: "#55556a",
|
||
};
|
||
|
||
// ─── Util ─────────────────────────────────────────────────────────────────────
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
|
||
function roundRect(ctx, x, y, w, h, r = 6) {
|
||
if (w <= 0 || h <= 0) return;
|
||
r = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
// ─── Widget factory ───────────────────────────────────────────────────────────
|
||
function createVideoTimelineWidget(node, videoWidgetName) {
|
||
// Preview size / collapse state
|
||
let previewH = 160;
|
||
let collapsed = false; // preview section
|
||
let resizeDrag = false;
|
||
let resizeDragStartY = 0;
|
||
let resizeDragStartH = 0;
|
||
// Video state
|
||
let videoEl = null;
|
||
let videoReady = false;
|
||
let videoLoadProgress = 0;
|
||
let videoError = null; // string message or null
|
||
let videoStalled = false;
|
||
let loadStartTime = 0;
|
||
let loadTimeoutId = null;
|
||
let loadStage = "idle"; // idle | fetching-info | loading-metadata | buffering | ready | error
|
||
let lastStageEvent = "";
|
||
const LOAD_TIMEOUT_MS = 30000;
|
||
let totalFrames = 0;
|
||
let fps = 30;
|
||
let vidW = 0,
|
||
vidH = 0;
|
||
let bytesPerFrameF32 = 0;
|
||
let memoryLimitBytes = 8 * 1024 * 1024 * 1024;
|
||
|
||
// Loop region (frame units)
|
||
let loopIn = 0;
|
||
let loopOut = 0;
|
||
|
||
// View window (frame units)
|
||
let viewStart = 0,
|
||
viewEnd = 0;
|
||
const MIN_VIEW = 10;
|
||
|
||
// Filmstrip / activity
|
||
const thumbCache = new Map();
|
||
let thumbsQueued = false;
|
||
let activityData = [];
|
||
|
||
// Interaction
|
||
let drag = null;
|
||
let layout = {};
|
||
|
||
// Dynamic filmstrip height: tall for portrait so thumbs aren't squished
|
||
function getStripH() {
|
||
if (!vidW || !vidH) return STRIP_H_LANDSCAPE;
|
||
const aspect = vidW / vidH;
|
||
if (aspect >= 1) return STRIP_H_LANDSCAPE;
|
||
return Math.min(STRIP_H_MAX, Math.round(STRIP_H_LANDSCAPE / aspect));
|
||
}
|
||
|
||
// Transport playback state
|
||
// "stopped" | "playing" | "paused"
|
||
let playState = "stopped";
|
||
let rafId = null;
|
||
let currentFrame = 0; // tracks where we are during playback (for pos line)
|
||
|
||
// ─── Cursor ──────────────────────────────────────────────────────────────
|
||
let _canvasEl = null,
|
||
_lastCursor = "";
|
||
function getLGCanvas() {
|
||
if (_canvasEl?.isConnected) return _canvasEl;
|
||
_canvasEl =
|
||
document.querySelector("canvas.litegraph") ??
|
||
document.querySelector("canvas");
|
||
return _canvasEl;
|
||
}
|
||
function setCursor(c) {
|
||
if (c === _lastCursor) return;
|
||
_lastCursor = c;
|
||
const el = getLGCanvas();
|
||
if (el) el.style.cursor = c;
|
||
}
|
||
function cursorFor(type, dragging) {
|
||
if (type === "loopIn" || type === "loopOut") return "ew-resize";
|
||
if (type === "loopBody") return dragging ? "grabbing" : "grab";
|
||
if (
|
||
type === "btnPlayPause" ||
|
||
type === "btnPrev" ||
|
||
type === "btnNext" ||
|
||
type === "btnJumpBack" ||
|
||
type === "btnJumpFwd"
|
||
)
|
||
return "pointer";
|
||
if (type === "resize") return "ns-resize";
|
||
if (type === "collapse") return "pointer";
|
||
return "default";
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||
const getWidget = (name) => node.widgets?.find((w) => w.name === name);
|
||
|
||
function syncWidgets() {
|
||
const sf = getWidget("start_frame");
|
||
const ef = getWidget("end_frame");
|
||
const nf = getWidget("num_frames");
|
||
if (sf) sf.value = loopIn;
|
||
if (ef) ef.value = loopOut;
|
||
if (nf) nf.value = loopOut - loopIn + 1;
|
||
}
|
||
|
||
function effectiveViewEnd() {
|
||
return viewEnd > viewStart ? viewEnd : totalFrames;
|
||
}
|
||
|
||
function frameToX(frame, tX, tW) {
|
||
const vS = viewStart,
|
||
vE = effectiveViewEnd();
|
||
if (vE <= vS) return tX;
|
||
return tX + ((frame - vS) / (vE - vS)) * tW;
|
||
}
|
||
|
||
function xToFrame(x, tX, tW) {
|
||
const t = clamp((x - tX) / tW, 0, 1);
|
||
return Math.round(viewStart + t * (effectiveViewEnd() - viewStart));
|
||
}
|
||
|
||
// ─── Transport / playback ────────────────────────────────────────────────
|
||
|
||
function play() {
|
||
if (!videoEl || !totalFrames) return;
|
||
if (playState === "stopped" || playState === "paused") {
|
||
// Start from loop start if stopped; resume from current position if paused
|
||
if (playState === "stopped") {
|
||
currentFrame = loopIn;
|
||
videoEl.currentTime = loopIn / fps;
|
||
}
|
||
}
|
||
playState = "playing";
|
||
videoEl.muted = false;
|
||
videoEl.play().catch(() => {}); // tell the browser element to actually play
|
||
scheduleFrame();
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
|
||
function pause() {
|
||
if (playState !== "playing") return;
|
||
playState = "paused";
|
||
cancelAnimationFrame(rafId);
|
||
rafId = null;
|
||
if (videoEl) videoEl.pause(); // stop the browser video element itself
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
|
||
function stop() {
|
||
// Internal reset — called when video changes or loop moves.
|
||
// Not exposed as a button any more.
|
||
cancelAnimationFrame(rafId);
|
||
rafId = null;
|
||
playState = "paused";
|
||
currentFrame = loopIn;
|
||
if (videoEl) {
|
||
videoEl.pause();
|
||
videoEl.currentTime = loopIn / fps;
|
||
}
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
|
||
// Convenience predicate used by mouse handler (avoids closure-over-playState issues)
|
||
function isPlaying() {
|
||
return playState === "playing";
|
||
}
|
||
|
||
// Shift the entire loop window by one frame (keeps span constant).
|
||
// Pauses playback, seeks preview to new loopIn, syncs widgets.
|
||
function stepFrame(delta) {
|
||
if (playState === "playing") pause();
|
||
const span = loopOut - loopIn;
|
||
// Clamp so neither end goes out of bounds
|
||
const newIn = clamp(loopIn + delta, 0, Math.max(0, totalFrames - 1 - span));
|
||
const newOut = newIn + span;
|
||
loopIn = newIn;
|
||
loopOut = newOut;
|
||
currentFrame = loopIn;
|
||
if (videoEl && fps > 0) videoEl.currentTime = loopIn / fps;
|
||
syncWidgets();
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
|
||
function scheduleFrame() {
|
||
rafId = requestAnimationFrame(() => {
|
||
// Bail out if play was cancelled, video was swapped, or it isn't ready
|
||
if (playState !== "playing" || !videoEl || !videoReady) return;
|
||
currentFrame = Math.round((videoEl.currentTime || 0) * fps);
|
||
if (currentFrame >= loopOut) {
|
||
currentFrame = loopIn;
|
||
videoEl.currentTime = loopIn / fps;
|
||
}
|
||
node.setDirtyCanvas(true, false);
|
||
scheduleFrame();
|
||
});
|
||
// Only nudge play if the current src matches what we intend to play
|
||
if (videoEl && videoReady && videoEl.paused) videoEl.play().catch(() => {});
|
||
}
|
||
|
||
// ─── Fetch ───────────────────────────────────────────────────────────────
|
||
|
||
async function fetchVideoInfo(videoName) {
|
||
if (!videoName) return;
|
||
// Halt any playback/looping on the previous video immediately — before
|
||
// we await anything — so the rAF loop can't race with the new src load.
|
||
cancelAnimationFrame(rafId);
|
||
rafId = null;
|
||
playState = "paused";
|
||
if (videoEl) {
|
||
try { videoEl.pause(); } catch (_) {}
|
||
// Fully detach the old source so it stops buffering/decoding.
|
||
try {
|
||
videoEl.removeAttribute("src");
|
||
videoEl.load();
|
||
} catch (_) {}
|
||
videoEl.dataset.src = "";
|
||
}
|
||
|
||
// Clear UI state so the old frame vanishes before metadata arrives
|
||
videoReady = false;
|
||
videoLoadProgress = 0;
|
||
videoError = null;
|
||
videoStalled = false;
|
||
loadStage = "fetching-info";
|
||
lastStageEvent = `Requesting info for ${videoName}`;
|
||
loadStartTime = performance.now();
|
||
clearTimeout(loadTimeoutId);
|
||
node.setDirtyCanvas(true, false);
|
||
try {
|
||
const r = await fetch(
|
||
`/video_frame_extractor/info?video=${encodeURIComponent(videoName)}`,
|
||
);
|
||
const info = await r.json();
|
||
if (info.error) return;
|
||
totalFrames = info.total_frames || 1;
|
||
fps = info.fps || 30;
|
||
vidW = info.width || 0;
|
||
vidH = info.height || 0;
|
||
bytesPerFrameF32 = info.bytes_per_frame_f32 || vidW * vidH * 3 * 4;
|
||
memoryLimitBytes = info.memory_limit_bytes || 8 * 1024 * 1024 * 1024;
|
||
loopIn = 0;
|
||
loopOut = totalFrames - 1;
|
||
currentFrame = 0;
|
||
viewStart = 0;
|
||
viewEnd = totalFrames;
|
||
activityData = [];
|
||
thumbsQueued = false;
|
||
thumbCache.clear();
|
||
videoLoadProgress = 0;
|
||
stop();
|
||
syncWidgets();
|
||
loadVideoEl(videoName);
|
||
queueThumbs(videoName);
|
||
node.setDirtyCanvas(true, true);
|
||
} catch (e) {
|
||
videoError = `Info fetch failed: ${e.message || e}`;
|
||
loadStage = "error";
|
||
lastStageEvent = "Failed to fetch video info";
|
||
node.setDirtyCanvas(true, false);
|
||
console.warn("[VideoFrameExtractor] fetchVideoInfo:", e);
|
||
}
|
||
}
|
||
|
||
function loadVideoEl(videoName) {
|
||
const url = `/view?filename=${encodeURIComponent(videoName)}&type=input`;
|
||
if (!videoEl) {
|
||
videoEl = document.createElement("video");
|
||
videoEl.muted = true;
|
||
videoEl.preload = "auto";
|
||
videoEl.playsInline = true;
|
||
videoEl.crossOrigin = "anonymous";
|
||
const errMsg = (code) => {
|
||
switch (code) {
|
||
case 1: return "Load aborted";
|
||
case 2: return "Network error";
|
||
case 3: return "Video decode error";
|
||
case 4: return "Format not supported";
|
||
default: return "Unknown video error";
|
||
}
|
||
};
|
||
videoEl.addEventListener("loadstart", () => {
|
||
loadStage = "loading-metadata";
|
||
lastStageEvent = "Request sent, waiting for response";
|
||
node.setDirtyCanvas(true, false);
|
||
});
|
||
videoEl.addEventListener("loadedmetadata", () => {
|
||
loadStage = "buffering";
|
||
lastStageEvent = `Metadata parsed (${videoEl.videoWidth}×${videoEl.videoHeight})`;
|
||
node.setDirtyCanvas(true, false);
|
||
});
|
||
videoEl.addEventListener("progress", () => {
|
||
if (videoEl.buffered.length > 0 && videoEl.duration > 0) {
|
||
videoLoadProgress = videoEl.buffered.end(videoEl.buffered.length - 1) / videoEl.duration;
|
||
}
|
||
if (loadStage !== "ready") loadStage = "buffering";
|
||
lastStageEvent = "Downloading video data";
|
||
videoStalled = false;
|
||
node.setDirtyCanvas(true, false);
|
||
});
|
||
videoEl.addEventListener("canplay", () => {
|
||
videoReady = true;
|
||
videoLoadProgress = 1;
|
||
videoStalled = false;
|
||
videoError = null;
|
||
loadStage = "ready";
|
||
lastStageEvent = `Ready in ${((performance.now() - loadStartTime) / 1000).toFixed(1)}s`;
|
||
clearTimeout(loadTimeoutId);
|
||
node.setDirtyCanvas(true, false);
|
||
});
|
||
videoEl.addEventListener("loadeddata", () => {
|
||
videoReady = true;
|
||
videoStalled = false;
|
||
if (loadStage !== "ready") {
|
||
loadStage = "ready";
|
||
lastStageEvent = "First frame decoded";
|
||
}
|
||
node.setDirtyCanvas(true, false);
|
||
});
|
||
videoEl.addEventListener("error", () => {
|
||
const code = videoEl.error?.code;
|
||
videoError = errMsg(code);
|
||
videoReady = false;
|
||
loadStage = "error";
|
||
lastStageEvent = `Error code ${code ?? "?"}`;
|
||
clearTimeout(loadTimeoutId);
|
||
console.warn("[VideoFrameExtractor] video load error", videoEl.error);
|
||
node.setDirtyCanvas(true, false);
|
||
});
|
||
videoEl.addEventListener("stalled", () => {
|
||
if (!videoReady) {
|
||
videoStalled = true;
|
||
lastStageEvent = "Network stalled";
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
});
|
||
videoEl.addEventListener("suspend", () => {
|
||
if (!videoReady && videoLoadProgress < 0.99) {
|
||
videoStalled = true;
|
||
lastStageEvent = "Browser suspended download";
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
});
|
||
videoEl.addEventListener("waiting", () => {
|
||
if (playState === "playing") {
|
||
videoStalled = true;
|
||
lastStageEvent = "Buffer underrun during playback";
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
});
|
||
videoEl.addEventListener("playing", () => {
|
||
videoStalled = false;
|
||
lastStageEvent = "Playing";
|
||
node.setDirtyCanvas(true, false);
|
||
});
|
||
}
|
||
if (videoEl.dataset.src !== url) {
|
||
// Immediately mark not ready so the old frame is cleared
|
||
videoReady = false;
|
||
videoLoadProgress = 0;
|
||
videoError = null;
|
||
videoStalled = false;
|
||
loadStartTime = performance.now();
|
||
|
||
// Release old video memory
|
||
videoEl.pause();
|
||
videoEl.removeAttribute("src");
|
||
videoEl.load();
|
||
|
||
videoEl.src = url;
|
||
videoEl.dataset.src = url;
|
||
videoEl.load();
|
||
|
||
// Watchdog — if loading never completes, flag it as a timeout
|
||
clearTimeout(loadTimeoutId);
|
||
loadTimeoutId = setTimeout(() => {
|
||
if (!videoReady) {
|
||
videoError = "Load timed out — video may be corrupt or unreadable";
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
}, LOAD_TIMEOUT_MS);
|
||
|
||
node.setDirtyCanvas(true, false);
|
||
} else if (videoEl.readyState >= 2) {
|
||
// Same URL, already loaded — just mark ready again
|
||
videoReady = true;
|
||
videoLoadProgress = 1;
|
||
node.setDirtyCanvas(true, false);
|
||
}
|
||
}
|
||
|
||
async function queueThumbs(videoName) {
|
||
if (thumbsQueued || !totalFrames) return;
|
||
thumbsQueued = true;
|
||
const indices = Array.from({ length: THUMB_COUNT }, (_, i) =>
|
||
Math.round((i * (totalFrames - 1)) / (THUMB_COUNT - 1)),
|
||
);
|
||
const BATCH = 4;
|
||
for (let b = 0; b < indices.length; b += BATCH) {
|
||
await Promise.all(
|
||
indices.slice(b, b + BATCH).map(async (fi, bi) => {
|
||
try {
|
||
const url = `/video_frame_extractor/thumbnail?video=${encodeURIComponent(videoName)}&frame=${fi}`;
|
||
const img = new Image();
|
||
await new Promise((res, rej) => {
|
||
img.onload = res;
|
||
img.onerror = rej;
|
||
img.src = url;
|
||
});
|
||
thumbCache.set(fi, img);
|
||
activityData[b + bi] = computeActivity(img);
|
||
node.setDirtyCanvas(true, false);
|
||
} catch (_) {
|
||
activityData[b + bi] = 0;
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
function computeActivity(img) {
|
||
try {
|
||
const oc = document.createElement("canvas");
|
||
oc.width = 16;
|
||
oc.height = 9;
|
||
const ox = oc.getContext("2d");
|
||
ox.drawImage(img, 0, 0, 16, 9);
|
||
const px = ox.getImageData(0, 0, 16, 9).data;
|
||
let sum = 0;
|
||
for (let i = 0; i < px.length; i += 4)
|
||
sum += (px[i] * 0.299 + px[i + 1] * 0.587 + px[i + 2] * 0.114) / 255;
|
||
return sum / (16 * 9);
|
||
} catch (_) {
|
||
return 0.5;
|
||
}
|
||
}
|
||
|
||
// ─── Draw helpers ─────────────────────────────────────────────────────────
|
||
|
||
function drawPreview(ctx, x, y, w, h) {
|
||
ctx.fillStyle = C.panel;
|
||
roundRect(ctx, x, y, w, h, 5);
|
||
ctx.fill();
|
||
ctx.strokeStyle = C.border;
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
|
||
if (videoEl && videoReady) {
|
||
ctx.save();
|
||
roundRect(ctx, x, y, w, h, 5);
|
||
ctx.clip();
|
||
const vAR = (videoEl.videoWidth || 16) / (videoEl.videoHeight || 9);
|
||
const pAR = w / h;
|
||
let dw, dh, dx, dy;
|
||
if (vAR > pAR) {
|
||
dw = w;
|
||
dh = w / vAR;
|
||
dx = x;
|
||
dy = y + (h - dh) / 2;
|
||
} else {
|
||
dh = h;
|
||
dw = h * vAR;
|
||
dy = y;
|
||
dx = x + (w - dw) / 2;
|
||
}
|
||
ctx.drawImage(videoEl, dx, dy, dw, dh);
|
||
ctx.restore();
|
||
} else if (videoError) {
|
||
const cx = x + w / 2;
|
||
const cy = y + h / 2;
|
||
ctx.fillStyle = "#fca5a5";
|
||
ctx.font = "bold 13px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText("⚠ Video error", cx, cy - 22);
|
||
ctx.fillStyle = "#fecaca";
|
||
ctx.font = "11px 'Courier New', monospace";
|
||
ctx.fillText(videoError, cx, cy - 4);
|
||
if (lastStageEvent) {
|
||
ctx.fillStyle = "#9ca3af";
|
||
ctx.font = "10px 'Courier New', monospace";
|
||
ctx.fillText(lastStageEvent, cx, cy + 14);
|
||
}
|
||
} else if (totalFrames > 0 || loadStage === "fetching-info") {
|
||
// Video is loading — show detailed status
|
||
const pct = Math.round(videoLoadProgress * 100);
|
||
const elapsed = loadStartTime > 0 ? (performance.now() - loadStartTime) / 1000 : 0;
|
||
|
||
// Headline
|
||
let headline;
|
||
if (videoStalled) headline = pct > 0 ? `Stalled at ${pct}%` : "Stalled";
|
||
else if (loadStage === "fetching-info") headline = "Fetching video info…";
|
||
else if (loadStage === "loading-metadata") headline = "Loading metadata…";
|
||
else if (loadStage === "buffering") headline = pct > 0 ? `Buffering ${pct}%` : "Buffering…";
|
||
else headline = "Loading video…";
|
||
|
||
const cx = x + w / 2;
|
||
const cy = y + h / 2;
|
||
|
||
ctx.fillStyle = videoStalled ? "#fbbf24" : "#c7d2fe";
|
||
ctx.font = "bold 13px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(headline, cx, cy - 22);
|
||
|
||
// Progress bar
|
||
const pbW = Math.min(w - 40, 320);
|
||
const pbX = x + (w - pbW) / 2;
|
||
const pbY = cy - 8;
|
||
ctx.fillStyle = "#1a1a26";
|
||
roundRect(ctx, pbX, pbY, pbW, 8, 4);
|
||
ctx.fill();
|
||
if (videoLoadProgress > 0) {
|
||
ctx.fillStyle = videoStalled ? "#f59e0b" : "#6366f1";
|
||
roundRect(ctx, pbX, pbY, Math.max(8, pbW * videoLoadProgress), 8, 4);
|
||
ctx.fill();
|
||
} else if (loadStage === "fetching-info" || loadStage === "loading-metadata") {
|
||
// Indeterminate shimmer — a sliding block
|
||
const t = (performance.now() / 1000) % 1.5;
|
||
const bw = pbW * 0.25;
|
||
const bx = pbX + (pbW - bw) * (t / 1.5);
|
||
ctx.fillStyle = "#4f46e5";
|
||
roundRect(ctx, bx, pbY, bw, 8, 4);
|
||
ctx.fill();
|
||
}
|
||
ctx.strokeStyle = "#2a2a3e";
|
||
ctx.lineWidth = 0.5;
|
||
roundRect(ctx, pbX, pbY, pbW, 8, 4);
|
||
ctx.stroke();
|
||
|
||
// Detail line
|
||
ctx.fillStyle = "#8b8ba0";
|
||
ctx.font = "10px 'Courier New', monospace";
|
||
const detail = lastStageEvent || "";
|
||
ctx.fillText(detail, cx, cy + 14);
|
||
|
||
// Stats line: elapsed + buffered seconds + readyState
|
||
const parts = [];
|
||
if (elapsed > 0) parts.push(`${elapsed.toFixed(1)}s elapsed`);
|
||
if (videoEl && videoEl.buffered?.length > 0 && videoEl.duration > 0) {
|
||
const bufEnd = videoEl.buffered.end(videoEl.buffered.length - 1);
|
||
parts.push(`${bufEnd.toFixed(1)}s / ${videoEl.duration.toFixed(1)}s buffered`);
|
||
}
|
||
if (videoEl) {
|
||
const rsLabels = ["nothing", "metadata", "current", "future", "enough"];
|
||
parts.push(`readyState=${rsLabels[videoEl.readyState] ?? videoEl.readyState}`);
|
||
}
|
||
if (parts.length) {
|
||
ctx.fillStyle = "#55556a";
|
||
ctx.font = "9px 'Courier New', monospace";
|
||
ctx.fillText(parts.join(" • "), cx, cy + 28);
|
||
}
|
||
} else {
|
||
ctx.fillStyle = C.textDim;
|
||
ctx.font = "12px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText("⬆ Upload a video above", x + w / 2, y + h / 2 + 4);
|
||
}
|
||
|
||
// Timecode overlay
|
||
if (totalFrames > 0) {
|
||
const f = currentFrame;
|
||
const secs = f / fps;
|
||
const mm = String(Math.floor(secs / 60)).padStart(2, "0");
|
||
const ss = String(Math.floor(secs % 60)).padStart(2, "0");
|
||
const ff = String(f % Math.max(1, Math.round(fps))).padStart(2, "0");
|
||
ctx.fillStyle = "rgba(0,0,0,0.72)";
|
||
ctx.fillRect(x + 4, y + h - 22, 160, 18);
|
||
ctx.fillStyle = "#a5b4fc";
|
||
ctx.font = "12px 'Courier New', monospace";
|
||
ctx.textAlign = "left";
|
||
ctx.fillText(`${mm}:${ss}:${ff} fr ${f}`, x + 8, y + h - 8);
|
||
if (vidW && vidH) {
|
||
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
||
ctx.fillRect(x + w - 80, y + 4, 76, 16);
|
||
ctx.fillStyle = C.textDim;
|
||
ctx.font = "10px 'Courier New', monospace";
|
||
ctx.textAlign = "right";
|
||
ctx.fillText(`${vidW}×${vidH}`, x + w - 6, y + 14);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Transport bar ────────────────────────────────────────────────────────
|
||
// Layout (left → right):
|
||
// [⏮ JUMP BACK] [◀ PREV] [▶ PLAY / ⏸ PAUSE] [NEXT ▶] [JUMP FWD ⏭]
|
||
//
|
||
// JUMP buttons shift the loop region by its own span + 1, so each press
|
||
// moves to the "next set" of frames. Disabled when no room remains.
|
||
function drawTransport(ctx, x, y, w, h) {
|
||
ctx.fillStyle = "#0e0e16";
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.strokeStyle = C.border;
|
||
ctx.lineWidth = 0.5;
|
||
ctx.strokeRect(x, y, w, h);
|
||
|
||
if (!totalFrames) return {};
|
||
|
||
const BTN_H = 32;
|
||
const STEP_W = 44;
|
||
const JUMP_W = 52;
|
||
const TOGGLE_W = 100;
|
||
const GAP = 8;
|
||
const totalW = JUMP_W + GAP + STEP_W + GAP + TOGGLE_W + GAP + STEP_W + GAP + JUMP_W;
|
||
const bx0 = x + Math.round((w - totalW) / 2);
|
||
const by = y + Math.round((h - BTN_H) / 2);
|
||
|
||
const span = loopOut - loopIn;
|
||
const jumpDelta = span + 1;
|
||
const canJumpBack = loopIn - jumpDelta >= 0;
|
||
const canJumpFwd = loopOut + jumpDelta <= totalFrames - 1;
|
||
|
||
const isPlaying = playState === "playing";
|
||
let bx = bx0;
|
||
const buttons = [
|
||
{
|
||
id: "btnJumpBack",
|
||
label: "⏮",
|
||
sublabel: "JUMP",
|
||
col: C.stepIcon,
|
||
isStep: true,
|
||
bx,
|
||
bw: JUMP_W,
|
||
disabled: !canJumpBack,
|
||
},
|
||
];
|
||
bx += JUMP_W + GAP;
|
||
buttons.push({
|
||
id: "btnPrev",
|
||
label: "◀",
|
||
sublabel: "PREV",
|
||
col: C.stepIcon,
|
||
isStep: true,
|
||
bx,
|
||
bw: STEP_W,
|
||
});
|
||
bx += STEP_W + GAP;
|
||
buttons.push({
|
||
id: "btnPlayPause",
|
||
label: isPlaying ? "⏸ PAUSE" : "▶ PLAY",
|
||
col: isPlaying ? C.pauseIcon : C.playIcon,
|
||
bx,
|
||
bw: TOGGLE_W,
|
||
isActive: isPlaying,
|
||
});
|
||
bx += TOGGLE_W + GAP;
|
||
buttons.push({
|
||
id: "btnNext",
|
||
label: "▶",
|
||
sublabel: "NEXT",
|
||
col: C.stepIcon,
|
||
isStep: true,
|
||
bx,
|
||
bw: STEP_W,
|
||
});
|
||
bx += STEP_W + GAP;
|
||
buttons.push({
|
||
id: "btnJumpFwd",
|
||
label: "⏭",
|
||
sublabel: "JUMP",
|
||
col: C.stepIcon,
|
||
isStep: true,
|
||
bx,
|
||
bw: JUMP_W,
|
||
disabled: !canJumpFwd,
|
||
});
|
||
|
||
const hitRects = {};
|
||
|
||
for (const btn of buttons) {
|
||
const isActive = !!btn.isActive;
|
||
const disabled = !!btn.disabled;
|
||
const isHovered = !disabled && widget._hover === btn.id;
|
||
|
||
ctx.save();
|
||
if (disabled) ctx.globalAlpha = 0.35;
|
||
|
||
const bgCol = btn.isStep
|
||
? isHovered
|
||
? C.stepBgHov
|
||
: C.stepBg
|
||
: isActive
|
||
? C.btnBgActive
|
||
: isHovered
|
||
? C.btnBgHov
|
||
: C.btnBg;
|
||
const bdCol = btn.isStep
|
||
? isHovered
|
||
? C.stepBorderHov
|
||
: C.stepBorder
|
||
: isActive
|
||
? "#818cf8"
|
||
: isHovered
|
||
? C.btnBorderHov
|
||
: C.btnBorder;
|
||
|
||
ctx.fillStyle = bgCol;
|
||
roundRect(ctx, btn.bx, by, btn.bw, BTN_H, 6);
|
||
ctx.fill();
|
||
ctx.strokeStyle = bdCol;
|
||
ctx.lineWidth = isActive ? 1.5 : 1;
|
||
ctx.stroke();
|
||
|
||
if (isActive && !btn.isStep) {
|
||
ctx.strokeStyle = "rgba(99,102,241,0.35)";
|
||
ctx.lineWidth = 3;
|
||
roundRect(ctx, btn.bx - 1, by - 1, btn.bw + 2, BTN_H + 2, 7);
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.textAlign = "center";
|
||
if (btn.isStep) {
|
||
ctx.fillStyle = isHovered ? "#bfdbfe" : btn.col;
|
||
ctx.font = "14px 'Courier New', monospace";
|
||
ctx.fillText(btn.label, btn.bx + btn.bw / 2, by + BTN_H / 2 - 1);
|
||
ctx.fillStyle = isHovered ? "#93c5fd" : "#3b6a96";
|
||
ctx.font = "8px 'Courier New', monospace";
|
||
ctx.fillText(btn.sublabel, btn.bx + btn.bw / 2, by + BTN_H / 2 + 10);
|
||
} else {
|
||
ctx.fillStyle = isActive ? "#ffffff" : isHovered ? "#e2e2ea" : btn.col;
|
||
ctx.font = "11px 'Courier New', monospace";
|
||
ctx.fillText(btn.label, btn.bx + btn.bw / 2, by + BTN_H / 2 + 4);
|
||
}
|
||
|
||
ctx.restore();
|
||
|
||
// Store hit rect only if enabled (disabled buttons don't respond)
|
||
hitRects[btn.id] = {
|
||
x: btn.bx,
|
||
y: by,
|
||
w: btn.bw,
|
||
h: BTN_H,
|
||
disabled,
|
||
};
|
||
}
|
||
|
||
// Progress bar — slim strip at bottom of transport row
|
||
if (totalFrames > 0) {
|
||
const pbY = y + h - 5;
|
||
const pbW = w - 20;
|
||
const pbX = x + 10;
|
||
const span = loopOut - loopIn;
|
||
const pct = span > 0 ? clamp(currentFrame - loopIn, 0, span) / span : 0;
|
||
ctx.fillStyle = "#1a1a26";
|
||
roundRect(ctx, pbX, pbY, pbW, 3, 2);
|
||
ctx.fill();
|
||
if (pct > 0) {
|
||
ctx.fillStyle = "#6366f1";
|
||
roundRect(ctx, pbX, pbY, Math.round(pbW * pct), 3, 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
return hitRects;
|
||
}
|
||
|
||
function drawRuler(ctx, x, y, w, h) {
|
||
ctx.fillStyle = C.ruler;
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.strokeStyle = C.border;
|
||
ctx.lineWidth = 0.5;
|
||
ctx.strokeRect(x, y, w, h);
|
||
if (!totalFrames) return;
|
||
|
||
const vS = viewStart,
|
||
vE = effectiveViewEnd();
|
||
const vis = vE - vS;
|
||
const raw = 40 * (vis / w);
|
||
const nice = [
|
||
1,
|
||
2,
|
||
5,
|
||
10,
|
||
15,
|
||
30,
|
||
60,
|
||
120,
|
||
300,
|
||
600,
|
||
Math.round(fps),
|
||
Math.round(fps * 5),
|
||
Math.round(fps * 10),
|
||
Math.round(fps * 30),
|
||
];
|
||
const tickInterval = nice.find((n) => n >= raw) || nice[nice.length - 1];
|
||
const firstTick = Math.ceil(vS / tickInterval) * tickInterval;
|
||
|
||
ctx.font = "9px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
for (let f = firstTick; f <= vE; f += tickInterval) {
|
||
const tx = frameToX(f, x, w);
|
||
const isSec = fps > 0 && f % Math.round(fps) === 0;
|
||
ctx.strokeStyle = isSec ? C.rulerMajor : "#2a2a38";
|
||
ctx.lineWidth = isSec ? 1 : 0.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tx, y);
|
||
ctx.lineTo(tx, y + (isSec ? h : h * 0.5));
|
||
ctx.stroke();
|
||
if (isSec || tickInterval >= 30) {
|
||
const secs = f / fps;
|
||
ctx.fillStyle = C.rulerText;
|
||
ctx.fillText(
|
||
`${String(Math.floor(secs / 60)).padStart(2, "0")}:${String(Math.floor(secs % 60)).padStart(2, "0")}`,
|
||
tx,
|
||
y + h - 2,
|
||
);
|
||
}
|
||
}
|
||
// Hover tint — signals the ruler is clickable
|
||
if (widget._hover === "ruler") {
|
||
ctx.fillStyle = "rgba(165,180,252,0.05)";
|
||
ctx.fillRect(x, y, w, h);
|
||
}
|
||
}
|
||
|
||
function drawFilmstrip(ctx, x, y, w, h) {
|
||
ctx.fillStyle = C.strip;
|
||
ctx.fillRect(x, y, w, h);
|
||
|
||
if (!totalFrames || thumbCache.size === 0) {
|
||
ctx.fillStyle = C.textDim;
|
||
ctx.font = "10px monospace";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText("Loading filmstrip…", x + w / 2, y + h / 2 + 3);
|
||
return;
|
||
}
|
||
|
||
const vS = viewStart,
|
||
vE = effectiveViewEnd();
|
||
const aspect = vidW && vidH ? vidW / vidH : 16 / 9;
|
||
const thumbW = h * aspect;
|
||
const step = Math.max(
|
||
1,
|
||
Math.round((vE - vS) / Math.max(1, Math.ceil(w / thumbW))),
|
||
);
|
||
const first = Math.floor(vS / step) * step;
|
||
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(x, y, w, h);
|
||
ctx.clip();
|
||
|
||
for (let f = first; f <= vE + step; f += step) {
|
||
const cellX = frameToX(f, x, w);
|
||
const cellW = Math.max(1, frameToX(f + step, x, w) - cellX);
|
||
const img = findNearestThumb(f);
|
||
if (img) {
|
||
try {
|
||
ctx.drawImage(img, cellX, y, cellW, h);
|
||
} catch (_) {}
|
||
} else {
|
||
ctx.fillStyle = "#090910";
|
||
ctx.fillRect(cellX, y, cellW, h);
|
||
}
|
||
ctx.strokeStyle = C.stripBorder;
|
||
ctx.lineWidth = 0.5;
|
||
ctx.strokeRect(cellX, y, cellW, h);
|
||
}
|
||
|
||
// Darken outside loop region
|
||
const lx = frameToX(loopIn, x, w);
|
||
const rx = frameToX(loopOut, x, w);
|
||
ctx.fillStyle = "rgba(0,0,0,0.6)";
|
||
ctx.fillRect(x, y, lx - x, h);
|
||
ctx.fillRect(rx, y, x + w - rx, h);
|
||
|
||
ctx.restore();
|
||
|
||
// Frame labels
|
||
const lblEvery = Math.max(1, Math.round((vE - vS) / 8));
|
||
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
||
ctx.font = "9px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
for (let f = Math.ceil(vS / lblEvery) * lblEvery; f <= vE; f += lblEvery)
|
||
ctx.fillText(String(f), frameToX(f, x, w), y + h - 3);
|
||
|
||
// Hover tint over filmstrip
|
||
if (widget._hover === "filmstrip") {
|
||
ctx.fillStyle = "rgba(165,180,252,0.05)";
|
||
ctx.fillRect(x, y, w, h);
|
||
}
|
||
}
|
||
|
||
function findNearestThumb(target) {
|
||
if (thumbCache.has(target)) return thumbCache.get(target);
|
||
let best = null,
|
||
bestDist = Infinity;
|
||
for (const [f, img] of thumbCache) {
|
||
const d = Math.abs(f - target);
|
||
if (d < bestDist) {
|
||
bestDist = d;
|
||
best = img;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function drawActivity(ctx, x, y, w, h) {
|
||
ctx.fillStyle = C.panel;
|
||
ctx.fillRect(x, y, w, h);
|
||
if (activityData.length < 2) return;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(x, y, w, h);
|
||
ctx.clip();
|
||
ctx.beginPath();
|
||
for (let i = 0; i < activityData.length; i++) {
|
||
const frame = Math.round(
|
||
(i * (totalFrames - 1)) / (activityData.length - 1),
|
||
);
|
||
const ax = frameToX(frame, x, w);
|
||
const ay = y + h - activityData[i] * h * 0.88;
|
||
i === 0 ? ctx.moveTo(ax, ay) : ctx.lineTo(ax, ay);
|
||
}
|
||
ctx.lineTo(x + w, y + h);
|
||
ctx.lineTo(x, y + h);
|
||
ctx.closePath();
|
||
ctx.fillStyle = C.activityFill;
|
||
ctx.fill();
|
||
ctx.strokeStyle = C.activity;
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
ctx.fillStyle = C.textDim;
|
||
ctx.font = "9px monospace";
|
||
ctx.textAlign = "left";
|
||
ctx.fillText("BRIGHTNESS", x + 3, y + h - 3);
|
||
}
|
||
|
||
// ── Loop bar — the star of the show ──────────────────────────────────────
|
||
function drawLoopBar(ctx, x, y, w, h) {
|
||
ctx.fillStyle = "#0c0c12";
|
||
ctx.fillRect(x, y, w, h);
|
||
|
||
if (!totalFrames) return;
|
||
|
||
const lx = frameToX(loopIn, x, w);
|
||
const rx = frameToX(loopOut, x, w);
|
||
const regionW = rx - lx;
|
||
|
||
const hovIn = widget._hover === "loopIn" || drag?.type === "loopIn";
|
||
const hovOut = widget._hover === "loopOut" || drag?.type === "loopOut";
|
||
const hovBody = widget._hover === "loopBody" || drag?.type === "loopBody";
|
||
const anyHov = hovIn || hovOut || hovBody;
|
||
|
||
// ── Body fill ───────────────────────────────────────────────────────
|
||
ctx.fillStyle = anyHov ? C.loopFillHov : C.loopFill;
|
||
ctx.fillRect(lx, y, regionW, h);
|
||
|
||
// ── Body border ─────────────────────────────────────────────────────
|
||
if (anyHov) {
|
||
// Bright solid border when hovered
|
||
ctx.strokeStyle = C.loopBorderHov;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeRect(lx, y, regionW, h);
|
||
|
||
// Dashed highlight on top edge to signal "this whole area is draggable"
|
||
if (hovBody) {
|
||
ctx.setLineDash([4, 3]);
|
||
ctx.strokeStyle = "rgba(165,180,252,0.7)";
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(lx + HANDLE_W + 2, y + 1);
|
||
ctx.lineTo(rx - HANDLE_W - 2, y + 1);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
} else {
|
||
ctx.strokeStyle = C.loopBorder;
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(lx, y, regionW, h);
|
||
}
|
||
|
||
// ── "Drag me" hint label inside body ────────────────────────────────
|
||
if (regionW > 80 && !anyHov) {
|
||
ctx.fillStyle = "rgba(99,102,241,0.4)";
|
||
ctx.font = "9px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText("↔ drag", lx + regionW / 2, y + h * 0.68);
|
||
}
|
||
|
||
// ── Frame count label ────────────────────────────────────────────────
|
||
if (regionW > 50) {
|
||
ctx.fillStyle = anyHov
|
||
? "rgba(224,231,255,0.95)"
|
||
: "rgba(139,139,255,0.75)";
|
||
ctx.font = "9px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(`${loopOut - loopIn + 1} fr`, lx + regionW / 2, y + h * 0.4);
|
||
}
|
||
|
||
// ── Playback position line inside loop ───────────────────────────────
|
||
if (playState !== "stopped" && totalFrames > 0) {
|
||
const posX = frameToX(currentFrame, x, w);
|
||
if (posX >= lx && posX <= rx) {
|
||
ctx.strokeStyle = C.posLine;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(posX, y);
|
||
ctx.lineTo(posX, y + h);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// ── Handles ─────────────────────────────────────────────────────────
|
||
drawLoopHandle(ctx, lx, y, h, "in", hovIn);
|
||
drawLoopHandle(ctx, rx, y, h, "out", hovOut);
|
||
}
|
||
|
||
function drawLoopHandle(ctx, cx, y, h, side, hovered) {
|
||
const col = hovered ? C.loopHandleHov : C.loopHandle;
|
||
|
||
// Glow effect behind handle when hovered
|
||
if (hovered) {
|
||
ctx.fillStyle = C.loopGlow;
|
||
const gw = HANDLE_W + 8;
|
||
if (side === "in") ctx.fillRect(cx - gw, y - 2, gw + 2, h + 4);
|
||
else ctx.fillRect(cx - 2, y - 2, gw + 2, h + 4);
|
||
}
|
||
|
||
// Handle body
|
||
ctx.fillStyle = col;
|
||
ctx.strokeStyle = hovered ? "#e0e7ff" : "#4f46e5";
|
||
ctx.lineWidth = hovered ? 1.5 : 1;
|
||
|
||
if (side === "in") {
|
||
// Left bracket tab
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - HANDLE_W, y);
|
||
ctx.lineTo(cx, y);
|
||
ctx.lineTo(cx, y + h);
|
||
ctx.lineTo(cx - HANDLE_W, y + h);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
// Arrow chevron
|
||
ctx.fillStyle = hovered ? "#1e1b4b" : "#312e81";
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - HANDLE_W + 3, y + h * 0.28);
|
||
ctx.lineTo(cx - 3, y + h * 0.5);
|
||
ctx.lineTo(cx - HANDLE_W + 3, y + h * 0.72);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
} else {
|
||
// Right bracket tab
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, y);
|
||
ctx.lineTo(cx + HANDLE_W, y);
|
||
ctx.lineTo(cx + HANDLE_W, y + h);
|
||
ctx.lineTo(cx, y + h);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.fillStyle = hovered ? "#1e1b4b" : "#312e81";
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx + HANDLE_W - 3, y + h * 0.28);
|
||
ctx.lineTo(cx + 3, y + h * 0.5);
|
||
ctx.lineTo(cx + HANDLE_W - 3, y + h * 0.72);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
function drawCollapseToggle(ctx, x, y, w, h) {
|
||
const isHov = widget._hover === "collapse";
|
||
ctx.fillStyle = isHov ? "#1e1e2e" : "#16161e";
|
||
ctx.fillRect(x, y, w, h);
|
||
ctx.strokeStyle = C.border;
|
||
ctx.lineWidth = 0.5;
|
||
ctx.strokeRect(x, y, w, h);
|
||
// Chevron
|
||
const cx = x + 12,
|
||
cy = y + h / 2;
|
||
ctx.strokeStyle = isHov ? "#a5b4fc" : "#44445a";
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
if (collapsed) {
|
||
ctx.moveTo(cx - 3, cy - 3);
|
||
ctx.lineTo(cx + 3, cy);
|
||
ctx.lineTo(cx - 3, cy + 3);
|
||
} else {
|
||
ctx.moveTo(cx - 4, cy - 2);
|
||
ctx.lineTo(cx, cy + 3);
|
||
ctx.lineTo(cx + 4, cy - 2);
|
||
}
|
||
ctx.stroke();
|
||
ctx.fillStyle = isHov ? "#a5b4fc" : C.textDim;
|
||
ctx.font = "10px 'Courier New', monospace";
|
||
ctx.textAlign = "left";
|
||
ctx.fillText(
|
||
collapsed
|
||
? "VIDEO PREVIEW (click to expand)"
|
||
: "VIDEO PREVIEW (click to collapse)",
|
||
x + 24,
|
||
y + h / 2 + 4,
|
||
);
|
||
if (collapsed && totalFrames > 0) {
|
||
ctx.textAlign = "right";
|
||
ctx.fillText(
|
||
`${totalFrames} fr • ${fps.toFixed(0)} fps`,
|
||
x + w - 4,
|
||
y + h / 2 + 4,
|
||
);
|
||
}
|
||
}
|
||
|
||
function drawResizeHandle(ctx, x, y, w, h) {
|
||
const isHov = widget._hover === "resize" || resizeDrag;
|
||
|
||
// Background — lightens on hover to signal interactivity
|
||
ctx.fillStyle = isHov ? "#2e2e46" : "#1a1a28";
|
||
ctx.fillRect(x, y, w, h);
|
||
|
||
// Top + bottom border
|
||
ctx.strokeStyle = isHov ? "#818cf8" : "#2a2a3e";
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, y);
|
||
ctx.lineTo(x + w, y);
|
||
ctx.moveTo(x, y + h);
|
||
ctx.lineTo(x + w, y + h);
|
||
ctx.stroke();
|
||
|
||
// Label — centred vertically
|
||
ctx.fillStyle = isHov ? "#c7d2fe" : "#55556a";
|
||
ctx.font = "9px 'Courier New', monospace";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText("↕ DRAG TO RESIZE", x + w / 2, y + h / 2 + 4);
|
||
}
|
||
|
||
function formatBytes(n) {
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let i = 0;
|
||
while (Math.abs(n) >= 1024 && i < units.length - 1) {
|
||
n /= 1024;
|
||
i++;
|
||
}
|
||
return `${n.toFixed(1)} ${units[i]}`;
|
||
}
|
||
|
||
function estimatePeakMemory(nFrames, customNFrames) {
|
||
if (!bytesPerFrameF32) return 0;
|
||
const bppU8 = bytesPerFrameF32 / 4;
|
||
const firstCall = nFrames * bppU8 + nFrames * bytesPerFrameF32;
|
||
const residentAfter = nFrames * bytesPerFrameF32 * 2;
|
||
const secondCall =
|
||
residentAfter + customNFrames * bppU8 + customNFrames * bytesPerFrameF32;
|
||
return Math.max(firstCall, secondCall);
|
||
}
|
||
|
||
function getTargetFps() {
|
||
const w = getWidget("target_fps");
|
||
return w?.value || 24.0;
|
||
}
|
||
|
||
function drawInfoBar(ctx, x, y, w) {
|
||
if (!totalFrames) return;
|
||
const dur = ((loopOut - loopIn) / fps).toFixed(2);
|
||
const nFrames = loopOut - loopIn + 1;
|
||
const stateLabel = playState === "playing" ? "▶ PLAYING" : "⏸ PAUSED";
|
||
|
||
// Row 1 — loop summary
|
||
ctx.font = "bold 13px 'Courier New', monospace";
|
||
ctx.textAlign = "left";
|
||
ctx.fillStyle = "#ffffff";
|
||
ctx.fillText(
|
||
`LOOP ${loopIn} → ${loopOut} (${nFrames} fr / ${dur}s) • ${stateLabel}`,
|
||
x + 2,
|
||
y + 14,
|
||
);
|
||
const vS = viewStart,
|
||
vE = effectiveViewEnd();
|
||
const zoomPct = Math.round((totalFrames / Math.max(1, vE - vS)) * 100);
|
||
ctx.textAlign = "right";
|
||
ctx.fillStyle = "#e2e2ea";
|
||
ctx.font = "12px 'Courier New', monospace";
|
||
ctx.fillText(`zoom ${zoomPct}% ⇧+scroll=pan`, x + w - 2, y + 14);
|
||
|
||
// Row 2 — memory estimate (color-coded)
|
||
if (bytesPerFrameF32 > 0) {
|
||
const tFps = getTargetFps();
|
||
const clipDur = nFrames / Math.max(fps, 0.001);
|
||
const customN = Math.max(1, Math.round(clipDur * tFps));
|
||
const peak = estimatePeakMemory(nFrames, customN);
|
||
const ratio = peak / memoryLimitBytes;
|
||
|
||
let color;
|
||
if (ratio >= 1) color = "#ef4444"; // red — will be blocked
|
||
else if (ratio >= 0.75) color = "#f59e0b"; // amber — risky
|
||
else color = "#86efac"; // green — safe
|
||
|
||
const icon = ratio >= 1 ? "⛔" : ratio >= 0.75 ? "⚠" : "✓";
|
||
const statusText =
|
||
ratio >= 1
|
||
? "WILL BE BLOCKED"
|
||
: ratio >= 0.75
|
||
? "high memory use"
|
||
: "ok";
|
||
|
||
ctx.textAlign = "left";
|
||
ctx.fillStyle = color;
|
||
ctx.font = "bold 12px 'Courier New', monospace";
|
||
ctx.fillText(
|
||
`${icon} MEM est. ${formatBytes(peak)} / ${formatBytes(memoryLimitBytes)} limit • ${statusText}`,
|
||
x + 2,
|
||
y + 30,
|
||
);
|
||
|
||
ctx.textAlign = "right";
|
||
ctx.fillStyle = "#9ca3af";
|
||
ctx.font = "11px 'Courier New', monospace";
|
||
ctx.fillText(
|
||
`${nFrames}+${customN} fr @ ${vidW}×${vidH}`,
|
||
x + w - 2,
|
||
y + 30,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── Widget object ────────────────────────────────────────────────────────
|
||
const widget = {
|
||
type: "VIDEOTIMELINE_V3",
|
||
name: "video_timeline_widget",
|
||
options: {},
|
||
value: null,
|
||
_hover: null,
|
||
_btnRects: {},
|
||
|
||
// Called externally (e.g. from spinner watches) to update the loop
|
||
// region without going through drag logic.
|
||
setLoop(newIn, newOut) {
|
||
if (!totalFrames) return;
|
||
loopIn = Math.max(0, Math.min(newIn, totalFrames - 1));
|
||
loopOut = Math.max(loopIn, Math.min(newOut, totalFrames - 1));
|
||
currentFrame = loopIn;
|
||
if (videoEl) {
|
||
videoEl.pause();
|
||
videoEl.currentTime = loopIn / fps;
|
||
}
|
||
cancelAnimationFrame(rafId);
|
||
rafId = null;
|
||
playState = "paused";
|
||
syncWidgets();
|
||
node.setDirtyCanvas(true, false);
|
||
},
|
||
|
||
draw(ctx, nodeObj, widgetWidth, y) {
|
||
ctx.save();
|
||
const outerX = PAD;
|
||
const outerW = widgetWidth - PAD * 2;
|
||
const totalH = widget.computeSize()[1] - 4;
|
||
|
||
ctx.fillStyle = C.surface;
|
||
ctx.strokeStyle = C.border;
|
||
ctx.lineWidth = 1;
|
||
roundRect(ctx, outerX, y, outerW, totalH);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
|
||
const iX = outerX + 6;
|
||
const iW = outerW - 12;
|
||
|
||
// Collapse toggle row — always visible
|
||
const collapseY = y + 4;
|
||
drawCollapseToggle(ctx, iX, collapseY, iW, COLLAPSE_H);
|
||
|
||
// Sections below — heights vary when preview is collapsed
|
||
const previewY = collapseY + COLLAPSE_H + 2;
|
||
const resizeBarY = previewY + (collapsed ? 0 : previewH);
|
||
const transportY = resizeBarY + (collapsed ? 0 : RESIZE_H + 2);
|
||
const rulerY = transportY + (collapsed ? 0 : TRANSPORT_H + 2);
|
||
const stripY = rulerY + RULER_H;
|
||
const sH = getStripH();
|
||
const actY = stripY + sH;
|
||
const loopBarY = actY + ACTIVITY_H;
|
||
const infoY = loopBarY + LOOP_H + 4;
|
||
|
||
layout = {
|
||
iX,
|
||
iW,
|
||
collapseY,
|
||
previewY,
|
||
resizeBarY,
|
||
transportY,
|
||
rulerY,
|
||
stripY,
|
||
actY,
|
||
loopBarY,
|
||
infoY,
|
||
};
|
||
|
||
if (!collapsed) {
|
||
drawPreview(ctx, iX, previewY, iW, previewH);
|
||
drawResizeHandle(ctx, iX, resizeBarY, iW, RESIZE_H);
|
||
widget._btnRects = drawTransport(ctx, iX, transportY, iW, TRANSPORT_H);
|
||
} else {
|
||
widget._btnRects = {};
|
||
}
|
||
drawRuler(ctx, iX, rulerY, iW, RULER_H);
|
||
drawFilmstrip(ctx, iX, stripY, iW, sH);
|
||
drawActivity(ctx, iX, actY, iW, ACTIVITY_H);
|
||
drawLoopBar(ctx, iX, loopBarY, iW, LOOP_H);
|
||
drawInfoBar(ctx, iX, infoY, iW);
|
||
|
||
ctx.restore();
|
||
|
||
if (playState === "playing" || videoReady || (videoEl && !videoReady && totalFrames > 0))
|
||
node.setDirtyCanvas(true, false);
|
||
},
|
||
|
||
computeSize() {
|
||
const ph = collapsed ? 0 : previewH + RESIZE_H + 2 + TRANSPORT_H + 2;
|
||
return [
|
||
0,
|
||
COLLAPSE_H +
|
||
10 +
|
||
ph +
|
||
RULER_H +
|
||
getStripH() +
|
||
ACTIVITY_H +
|
||
LOOP_H +
|
||
INFO_H +
|
||
28,
|
||
];
|
||
},
|
||
|
||
mouse(event, pos) {
|
||
if (!layout.iX) return false;
|
||
|
||
const mx = pos[0];
|
||
const my = pos[1];
|
||
|
||
// ── Wheel ──────────────────────────────────────────────────────
|
||
if (event.type === "wheel") {
|
||
handleWheel(event, mx);
|
||
node.setDirtyCanvas(true, false);
|
||
return true;
|
||
}
|
||
|
||
const {
|
||
iX,
|
||
iW,
|
||
collapseY,
|
||
resizeBarY,
|
||
transportY,
|
||
rulerY,
|
||
stripY,
|
||
actY,
|
||
loopBarY,
|
||
} = layout;
|
||
|
||
// ── Pre-compute zone membership ─────────────────────────────────
|
||
const inCollapse =
|
||
collapseY != null && my >= collapseY && my <= collapseY + COLLAPSE_H;
|
||
const inResize =
|
||
!collapsed &&
|
||
resizeBarY != null &&
|
||
my >= resizeBarY &&
|
||
my <= resizeBarY + RESIZE_H;
|
||
const inTransport =
|
||
!collapsed &&
|
||
transportY != null &&
|
||
my >= transportY &&
|
||
my <= transportY + TRANSPORT_H;
|
||
const inRuler =
|
||
rulerY != null && my >= rulerY && my < (stripY ?? rulerY + RULER_H);
|
||
const inStrip =
|
||
stripY != null && my >= stripY && my < (actY ?? stripY + getStripH());
|
||
const inAct = actY != null && my >= actY && my < actY + ACTIVITY_H;
|
||
const inLoopRow =
|
||
loopBarY != null && my >= loopBarY && my <= loopBarY + LOOP_H;
|
||
const inTimeline = inRuler || inStrip || inAct || inLoopRow;
|
||
|
||
// Single hit-test helper for transport buttons (skips disabled ones)
|
||
function hitBtn() {
|
||
for (const [id, r] of Object.entries(widget._btnRects)) {
|
||
if (r.disabled) continue;
|
||
if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h)
|
||
return id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Pointer down ────────────────────────────────────────────────
|
||
if (event.type === "pointerdown") {
|
||
if (inCollapse) {
|
||
collapsed = !collapsed;
|
||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||
node.setDirtyCanvas(true, true);
|
||
return true;
|
||
}
|
||
if (inResize) {
|
||
resizeDrag = true;
|
||
resizeDragStartY = my;
|
||
resizeDragStartH = previewH;
|
||
setCursor("ns-resize");
|
||
return true;
|
||
}
|
||
if (inTransport) {
|
||
const id = hitBtn();
|
||
if (id === "btnPlayPause") {
|
||
isPlaying() ? pause() : play();
|
||
return true;
|
||
}
|
||
if (id === "btnPrev") {
|
||
stepFrame(-1);
|
||
return true;
|
||
}
|
||
if (id === "btnNext") {
|
||
stepFrame(+1);
|
||
return true;
|
||
}
|
||
if (id === "btnJumpBack") {
|
||
stepFrame(-((loopOut - loopIn) + 1));
|
||
return true;
|
||
}
|
||
if (id === "btnJumpFwd") {
|
||
stepFrame(+((loopOut - loopIn) + 1));
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
if (inTimeline) {
|
||
drag = detectTarget(mx, my, iX, iW, loopBarY);
|
||
if (drag) {
|
||
setCursor(cursorFor(drag.type, true));
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── Pointer move ────────────────────────────────────────────────
|
||
if (event.type === "pointermove") {
|
||
// Active drag takes priority
|
||
if (resizeDrag) {
|
||
const delta = my - resizeDragStartY;
|
||
previewH = clamp(
|
||
resizeDragStartH + delta,
|
||
PREVIEW_H_MIN,
|
||
PREVIEW_H_MAX,
|
||
);
|
||
node.setSize([node.size[0], node.computeSize()[1]]);
|
||
node.setDirtyCanvas(true, true);
|
||
widget._hover = "resize";
|
||
setCursor("ns-resize");
|
||
return true;
|
||
}
|
||
if (drag) {
|
||
applyDrag(mx, iX, iW);
|
||
setCursor(cursorFor(drag.type, true));
|
||
widget._hover = drag.type;
|
||
node.setDirtyCanvas(true, false);
|
||
return true;
|
||
}
|
||
|
||
// Hover detection — exactly one zone wins
|
||
let hov = null;
|
||
let cur = "default";
|
||
if (inCollapse) {
|
||
hov = "collapse";
|
||
cur = "pointer";
|
||
} else if (inResize) {
|
||
hov = "resize";
|
||
cur = "ns-resize";
|
||
} else if (inTransport) {
|
||
hov = hitBtn();
|
||
cur = hov ? "pointer" : "default";
|
||
} else if (inLoopRow) {
|
||
const t = detectTarget(mx, my, iX, iW, loopBarY);
|
||
hov = t?.type ?? null;
|
||
cur = cursorFor(hov, false);
|
||
} else if (inRuler) {
|
||
hov = "ruler";
|
||
cur = "crosshair";
|
||
} else if (inStrip || inAct) {
|
||
hov = "filmstrip";
|
||
cur = "default";
|
||
}
|
||
|
||
widget._hover = hov;
|
||
setCursor(cur);
|
||
node.setDirtyCanvas(true, false);
|
||
return false;
|
||
}
|
||
|
||
// ── Pointer up ──────────────────────────────────────────────────
|
||
if (event.type === "pointerup") {
|
||
resizeDrag = false;
|
||
drag = null;
|
||
// Re-evaluate hover at rest position
|
||
let hov = null;
|
||
if (inCollapse) hov = "collapse";
|
||
else if (inResize) hov = "resize";
|
||
else if (inTransport) hov = hitBtn();
|
||
else if (inLoopRow)
|
||
hov = detectTarget(mx, my, iX, iW, loopBarY)?.type ?? null;
|
||
else if (inRuler) hov = "ruler";
|
||
else if (inStrip || inAct) hov = "filmstrip";
|
||
widget._hover = hov;
|
||
setCursor(hov ? cursorFor(hov, false) : "default");
|
||
return false;
|
||
}
|
||
|
||
return false;
|
||
},
|
||
};
|
||
|
||
// ─── Drag targeting ──────────────────────────────────────────────────────
|
||
function detectTarget(mx, my, tX, tW, loopBarY) {
|
||
if (!totalFrames) return null;
|
||
const lx = frameToX(loopIn, tX, tW);
|
||
const rx = frameToX(loopOut, tX, tW);
|
||
|
||
// Loop bar row only
|
||
const inLoopRow = my >= loopBarY && my <= loopBarY + LOOP_H;
|
||
if (inLoopRow) {
|
||
if (Math.abs(mx - lx) <= HANDLE_W + 4)
|
||
return {
|
||
type: "loopIn",
|
||
startX: mx,
|
||
startIn: loopIn,
|
||
startOut: loopOut,
|
||
};
|
||
if (Math.abs(mx - rx) <= HANDLE_W + 4)
|
||
return {
|
||
type: "loopOut",
|
||
startX: mx,
|
||
startIn: loopIn,
|
||
startOut: loopOut,
|
||
};
|
||
if (mx > lx + HANDLE_W && mx < rx - HANDLE_W)
|
||
return {
|
||
type: "loopBody",
|
||
startX: mx,
|
||
startIn: loopIn,
|
||
startOut: loopOut,
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function applyDrag(mx, tX, tW) {
|
||
if (!drag) return;
|
||
const frame = xToFrame(mx, tX, tW);
|
||
const dxFr = Math.round(
|
||
((mx - drag.startX) / tW) * (effectiveViewEnd() - viewStart),
|
||
);
|
||
switch (drag.type) {
|
||
case "loopIn":
|
||
loopIn = clamp(frame, 0, loopOut - 1);
|
||
break;
|
||
case "loopOut":
|
||
loopOut = clamp(frame, loopIn + 1, totalFrames - 1);
|
||
break;
|
||
case "loopBody": {
|
||
const span = drag.startOut - drag.startIn;
|
||
loopIn = clamp(drag.startIn + dxFr, 0, totalFrames - 1 - span);
|
||
loopOut = loopIn + span;
|
||
break;
|
||
}
|
||
}
|
||
// Always pause and seek preview to loop start when the region changes
|
||
cancelAnimationFrame(rafId);
|
||
rafId = null;
|
||
playState = "paused";
|
||
currentFrame = loopIn;
|
||
if (videoEl) {
|
||
videoEl.pause();
|
||
videoEl.currentTime = loopIn / fps;
|
||
}
|
||
syncWidgets();
|
||
}
|
||
|
||
function handleWheel(event, mx) {
|
||
if (!totalFrames) return;
|
||
const { iX, iW } = layout;
|
||
const vS = viewStart,
|
||
vE = effectiveViewEnd(),
|
||
vis = vE - vS;
|
||
if (event.shiftKey) {
|
||
const pan = Math.round(vis * 0.08 * Math.sign(event.deltaY));
|
||
viewStart = clamp(vS + pan, 0, totalFrames - MIN_VIEW);
|
||
viewEnd = clamp(vE + pan, viewStart + MIN_VIEW, totalFrames);
|
||
} else {
|
||
const cursorFrame = xToFrame(mx, iX, iW);
|
||
const factor = event.deltaY > 0 ? 1.15 : 0.87;
|
||
let ns = 0,
|
||
ne = 0;
|
||
let newSpan = clamp(Math.round(vis * factor), MIN_VIEW, totalFrames);
|
||
const ratio = clamp((cursorFrame - vS) / vis, 0, 1);
|
||
ns = Math.round(cursorFrame - ratio * newSpan);
|
||
ne = ns + newSpan;
|
||
if (ns < 0) {
|
||
ns = 0;
|
||
ne = newSpan;
|
||
}
|
||
if (ne > totalFrames) {
|
||
ne = totalFrames;
|
||
ns = totalFrames - newSpan;
|
||
}
|
||
viewStart = clamp(ns, 0, totalFrames);
|
||
viewEnd = clamp(ne, viewStart + MIN_VIEW, totalFrames);
|
||
}
|
||
event.preventDefault?.();
|
||
event.stopPropagation?.();
|
||
}
|
||
|
||
// ─── Poll for video widget changes ───────────────────────────────────────
|
||
let lastVideo = null;
|
||
setInterval(() => {
|
||
const vw = getWidget(videoWidgetName);
|
||
if (vw?.value && vw.value !== lastVideo) {
|
||
lastVideo = vw.value;
|
||
fetchVideoInfo(vw.value);
|
||
}
|
||
}, 600);
|
||
const vw0 = getWidget(videoWidgetName);
|
||
if (vw0?.value) fetchVideoInfo(vw0.value);
|
||
|
||
return widget;
|
||
}
|
||
|
||
// ─── Register ─────────────────────────────────────────────────────────────────
|
||
app.registerExtension({
|
||
name: "Comfy.VideoFrameExtractor.v3",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData.name !== "VideoFrameExtractor") return;
|
||
|
||
const onCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
onCreated?.apply(this, arguments);
|
||
const tl = createVideoTimelineWidget(this, "video");
|
||
if (this.widgets?.length) this.widgets.splice(1, 0, tl);
|
||
else {
|
||
this.widgets = this.widgets || [];
|
||
this.widgets.push(tl);
|
||
}
|
||
|
||
// Fix widget label and upload button text
|
||
const vw = this.widgets?.find((w) => w.name === "video");
|
||
if (vw) {
|
||
vw.label = "Video Filename";
|
||
}
|
||
|
||
// Rename the upload button (added by ComfyUI core when video_upload: True).
|
||
// It has no stable name across versions, so match by label/text.
|
||
for (const w of this.widgets || []) {
|
||
const labelMatch =
|
||
typeof w.label === "string" && /choose\s+file\s+to\s+upload/i.test(w.label);
|
||
const nameMatch = w.name === "upload" || w.name === "choose file to upload";
|
||
if (labelMatch || nameMatch) {
|
||
w.label = "Choose Video to Upload";
|
||
if (typeof w.name === "string" && /choose\s+file/i.test(w.name)) {
|
||
w.name = "Choose Video to Upload";
|
||
}
|
||
}
|
||
}
|
||
|
||
// num_frames: auto-computed from loop span — hide it completely so the
|
||
// user isn't confused by a read-only "AUTO" field. We still update its
|
||
// `value` via syncWidgets() so the backend receives the span.
|
||
const nf = this.widgets?.find((w) => w.name === "num_frames");
|
||
if (nf) {
|
||
nf.type = "hidden";
|
||
nf.hidden = true;
|
||
nf.computeSize = () => [0, -4]; // collapse layout row
|
||
nf.draw = () => {};
|
||
nf.mouse = () => false;
|
||
}
|
||
|
||
// Watch start_frame and end_frame spinners — sync loop region when changed
|
||
const tlWidget = this.widgets?.find(
|
||
(w) => w.name === "video_timeline_widget",
|
||
);
|
||
const sfWidget = this.widgets?.find((w) => w.name === "start_frame");
|
||
const efWidget = this.widgets?.find((w) => w.name === "end_frame");
|
||
if (tlWidget && sfWidget && efWidget) {
|
||
let lastSf = sfWidget.value;
|
||
let lastEf = efWidget.value;
|
||
setInterval(() => {
|
||
const sf = sfWidget.value;
|
||
const ef = efWidget.value;
|
||
if (sf !== lastSf || ef !== lastEf) {
|
||
lastSf = sf;
|
||
lastEf = ef;
|
||
// Clamp: start can't exceed end and vice-versa
|
||
const newIn = Math.min(sf, ef);
|
||
const newOut = Math.max(sf, ef);
|
||
tlWidget.setLoop(newIn, newOut);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
this.setSize([520, this.computeSize()[1]]);
|
||
};
|
||
},
|
||
});
|