/** * 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]]); }; }, });