/** * 视频抽帧工具节点扩展 * 提供视频手动抽帧、预览和帧索引生成功能 */ import { app } from "../../../../scripts/app.js"; import { api } from "../../../../scripts/api.js"; import { createSettingsDialog, createInputGroup, createTooltip, createConfirmPopup } from "../modules/uiComponents.js"; import { APIService } from "../services/api.js"; // 引入专用样式文件(保留用于视频播放器特定的布局样式) const link = document.createElement("link"); link.rel = "stylesheet"; link.type = "text/css"; link.href = new URL("../css/captionFrame.css", import.meta.url).href; document.head.appendChild(link); // 弹窗状态跟踪(防止重复打开) let isDialogOpen = false; app.registerExtension({ name: "ComfyUI.PromptAssistant.CaptionFrame", /** * 节点定义注册前的钩子 * @param {Object} nodeType 节点类型定义 * @param {Object} nodeData 节点数据 */ async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name === "VideoCaptionNode") { const onNodeCreated = nodeType.prototype.onNodeCreated; // --- 重写节点创建逻辑 --- nodeType.prototype.onNodeCreated = function () { if (onNodeCreated) { onNodeCreated.apply(this, arguments); } const node = this; // 添加"选取反推帧"按钮 // 初始状态下按钮可能隐藏,取决于"抽帧策略" const btnWidget = this.addWidget("button", "🎬选取反推帧", null, () => { showFrameExtractionModal(node); }); // 保存原始 computeSize 函数,用于动态隐藏/显示时的尺寸计算 const origComputeSize = btnWidget.computeSize?.bind(btnWidget); // 确保按钮始终显示 btnWidget.type = "button"; btnWidget.computeSize = origComputeSize || (() => [0, 26]); btnWidget.hidden = false; // 仅重绘画布,不改变节点大小 app.graph.setDirtyCanvas(true, false); }; } } }); /** * 显示视频抽帧工具弹窗 * @param {Object} node 当前节点实例 */ async function showFrameExtractionModal(node) { // 防止重复打开 if (isDialogOpen) { return; } isDialogOpen = true; // 1. 获取连接的视频信息 const videoInfo = await findConnectedVideo(node); if (!videoInfo) { isDialogOpen = false; alert("未检测到有效的视频输入连接。请确保节点已连接到 Load Video 节点。"); return; } if (videoInfo.error) { isDialogOpen = false; alert(videoInfo.error); return; } // 2. 获取视频元数据(FPS、时长、总帧数) let initialFps = 30; let originalDuration = 0; let originalTotalFrames = 0; try { const response = await api.fetchApi(APIService.getDynamicApiBase() + '/video/info', { method: "POST", body: JSON.stringify({ filename: videoInfo.filename, type: videoInfo.type }) }); const data = await response.json(); if (data.success) { initialFps = data.fps || 30; originalDuration = data.duration || 0; originalTotalFrames = data.total_frames || 0; // 如果 total_frames 为 0(某些视频格式无法直接读取帧数),使用 fps * duration 计算 if (originalTotalFrames === 0 && originalDuration > 0 && initialFps > 0) { originalTotalFrames = Math.floor(initialFps * originalDuration); } } } catch (e) { console.warn("[PromptAssistant] 获取视频信息失败, 将使用默认值:", e); } // 3. 计算实际使用的FPS(考虑force_rate参数) // force_rate > 0 时使用强制FPS,否则使用原始FPS const actualFps = (videoInfo.forceRate && videoInfo.forceRate > 0) ? videoInfo.forceRate : initialFps; // 4. 计算抽帧后的实际总帧数和持续时间 // 如果使用了 force_rate,实际帧数 = 原始总帧数 * (force_rate / original_fps) let actualTotalFrames = originalTotalFrames; let actualDuration = originalDuration; // 有效性检查:确保帧数是有效的正整数 if (!Number.isFinite(originalTotalFrames) || originalTotalFrames <= 0 || originalTotalFrames > 1e9) { // 如果帧数无效,尝试使用 duration * fps 计算 if (originalDuration > 0 && initialFps > 0) { originalTotalFrames = Math.floor(originalDuration * initialFps); actualTotalFrames = originalTotalFrames; } else { // 最后的兜底值 originalTotalFrames = 100; actualTotalFrames = 100; } console.warn('[PromptAssistant-CaptionFrame] 帧数无效,使用计算值:', originalTotalFrames); } if (videoInfo.forceRate && videoInfo.forceRate > 0 && initialFps > 0) { actualTotalFrames = Math.floor(originalTotalFrames * (videoInfo.forceRate / initialFps)); actualDuration = actualTotalFrames / actualFps; } // 确保至少有1帧 if (actualTotalFrames <= 0) { actualTotalFrames = 1; } /* console.log('[PromptAssistant-CaptionFrame] 视频元数据:', { originalFps: initialFps, originalDuration, originalTotalFrames, forceRate: videoInfo.forceRate, actualFps, actualTotalFrames, actualDuration }); */ // 状态容器,用于在 renderContent 和 onSave 之间共享数据 const state = { fps: actualFps, originalFps: initialFps, // 保存原始FPS用于计算 forceRate: videoInfo.forceRate || 0, // 保存 force_rate 用于帧提取 totalFrames: actualTotalFrames, // 实际总帧数 duration: actualDuration, // 实际持续时间 selectedFrames: new Set(), rangeStart: null, // 帧索引驱动相关状态 currentFrameIndex: 0, // 当前帧索引 isLoading: false, // 帧加载中标志 frameCache: new Map(), // 帧缓存 (frameIndex -> base64) filename: videoInfo.filename, // 视频文件名 widgets: { // 使用后端定义的英文 widget 名称 manualIndex: node.widgets.find(w => w.name === "manual_indices"), strategy: node.widgets.find(w => w.name === "sampling_mode") } }; // 初始化已选帧状态 if (state.widgets.manualIndex?.value) { state.widgets.manualIndex.value.split(',').forEach(p => { p = p.trim(); if (!p) return; if (p.includes('-')) state.selectedFrames.add(p); else { const n = parseInt(p); if (!isNaN(n)) state.selectedFrames.add(n); } }); } // 3. 创建通用设置弹窗 createSettingsDialog({ title: '🎬 视频手动抽帧工具', saveButtonText: '确认应用', cancelButtonText: '取消', saveButtonIcon: 'pi-check', disableBackdropAndCloseOnClickOutside: true, // 不需要模态遮罩层,允许交互 dialogClassName: 'caption-frame-dialog', // 自定义类名 // 取消回调(跳过二次确认,直接关闭) onCancel: () => { }, // 渲染弹窗内容 renderContent: (contentContainer, header) => { // 注意:具体的 flex 布局和高度控制现在主要由 CSS 处理 (.caption-frame-dialog .p-dialog-content) // 构建界面 renderVideoInterface(contentContainer, state, videoInfo, header); }, // 保存回调 onSave: () => { const sorted = Array.from(state.selectedFrames).sort((a, b) => { const getStart = v => typeof v === 'string' ? parseInt(v.split('-')[0]) : v; return getStart(a) - getStart(b); }); if (state.widgets.manualIndex) { const newValue = sorted.join(","); state.widgets.manualIndex.value = newValue; // 触发 widget callback 确保值同步到节点 if (state.widgets.manualIndex.callback) { state.widgets.manualIndex.callback(newValue, app.graph, node, state.widgets.manualIndex); } // [Debug] 输出保存的值 // console.log('[PromptAssistant-CaptionFrame] 保存帧索引:', newValue); // 自动切换策略为手动 if (state.widgets.strategy) { state.widgets.strategy.value = "Manual (Indices)"; // 同样触发策略 widget 的 callback if (state.widgets.strategy.callback) { state.widgets.strategy.callback("Manual (Indices)", app.graph, node, state.widgets.strategy); } } // 标记节点需要重新执行 node.setDirtyCanvas(true, true); app.graph.setDirtyCanvas(true, true); } }, // 关闭回调:清理资源 onClose: () => { isDialogOpen = false; if (state.frameCache) { state.frameCache.clear(); } } }); } /** * 渲染视频操作界面 * @param {HTMLElement} container 容器元素 * @param {Object} state 状态对象 * @param {Object} videoInfo 视频信息对象 */ /** * 渲染视频操作界面 * @param {HTMLElement} container 容器元素 * @param {Object} state 状态对象 * @param {Object} videoInfo 视频信息对象 * @param {HTMLElement} [headerElement] 弹窗头部元素(可选) */ function renderVideoInterface(container, state, videoInfo, headerElement) { // --- 显示区域(混合模式:img 用于精确定位,video 用于流畅播放)--- const frameContainer = document.createElement("div"); frameContainer.className = "video-container frame-display-container"; // 创建帧图片元素(用于精确定位模式) const frameImg = document.createElement("img"); frameImg.id = "caption-frame-display"; frameImg.className = "frame-display-img"; frameImg.alt = "视频帧预览"; // 创建视频元素(用于流畅播放模式,默认隐藏) const videoElement = document.createElement("video"); videoElement.className = "frame-video-player"; // 优化缓冲设置 videoElement.preload = "auto"; // 尽可能多地预加载视频 // 设置视频源 if (videoInfo.fromLoadNode && state.fps !== state.originalFps) { const params = { filename: videoInfo.filename, type: videoInfo.type || "input", force_rate: state.fps, skip_first_frames: 0, select_every_nth: 1, frame_load_cap: 0, timestamp: Date.now() }; videoElement.src = api.apiURL('/vhs/viewvideo?' + new URLSearchParams(params)); } else { videoElement.src = videoInfo.url; } // 创建加载指示器 const loadingIndicator = document.createElement("div"); loadingIndicator.className = "frame-loading-indicator"; loadingIndicator.innerHTML = ''; loadingIndicator.style.display = "none"; // 缓冲状态事件监听 - 拖动到未缓冲位置时显示加载指示器 videoElement.addEventListener("waiting", () => { loadingIndicator.style.display = "flex"; }); videoElement.addEventListener("canplay", () => { loadingIndicator.style.display = "none"; }); videoElement.addEventListener("canplaythrough", () => { loadingIndicator.style.display = "none"; }); frameContainer.appendChild(frameImg); frameContainer.appendChild(videoElement); frameContainer.appendChild(loadingIndicator); // 初始状态:显示视频,隐藏图片(video 预览模式) frameImg.style.display = "none"; videoElement.style.display = "block"; // 切换到图片模式 const switchToImageMode = async (frameIndex) => { videoElement.style.display = "none"; frameImg.style.display = "block"; await loadFrame(frameIndex); }; // 切换到视频播放模式 const switchToVideoMode = (startFromFrame) => { // 同步视频进度到当前帧位置 const targetTime = startFromFrame / state.fps; videoElement.currentTime = targetTime; frameImg.style.display = "none"; videoElement.style.display = "block"; videoElement.play(); }; // --- 帧加载函数 --- const loadFrame = async (frameIndex) => { if (state.isLoading) return; // 边界检查 frameIndex = Math.max(0, Math.min(state.totalFrames - 1, frameIndex)); state.currentFrameIndex = frameIndex; // 检查缓存 if (state.frameCache.has(frameIndex)) { frameImg.src = `data:image/jpeg;base64,${state.frameCache.get(frameIndex)}`; updateDisplay(); return; } // 显示加载指示器 state.isLoading = true; loadingIndicator.style.display = "flex"; try { const response = await api.fetchApi(APIService.getDynamicApiBase() + '/video/frame', { method: "POST", body: JSON.stringify({ filename: state.filename, frame_index: frameIndex, force_rate: state.forceRate }) }); const data = await response.json(); if (data.success && data.data) { // 缓存帧数据 state.frameCache.set(frameIndex, data.data); frameImg.src = `data:image/jpeg;base64,${data.data}`; // 预加载相邻帧(提升体验) preloadAdjacentFrames(frameIndex); } else { console.error("[PromptAssistant-CaptionFrame] 帧加载失败:", data.error); } } catch (e) { console.error("[PromptAssistant-CaptionFrame] 帧加载异常:", e); } finally { state.isLoading = false; loadingIndicator.style.display = "none"; updateDisplay(); } }; // --- 预加载相邻帧(提升体验)--- const preloadAdjacentFrames = async (centerIndex) => { const preloadRange = 2; // 预加载前后2帧 for (let offset = 1; offset <= preloadRange; offset++) { const indices = [centerIndex - offset, centerIndex + offset]; for (const idx of indices) { if (idx >= 0 && idx < state.totalFrames && !state.frameCache.has(idx)) { // 异步预加载,不阻塞 api.fetchApi(APIService.getDynamicApiBase() + '/video/frame', { method: "POST", body: JSON.stringify({ filename: state.filename, frame_index: idx, force_rate: state.forceRate }) }).then(res => res.json()).then(data => { if (data.success && data.data) { state.frameCache.set(idx, data.data); } }).catch(() => { }); // 静默忽略预加载失败 } } } }; // --- 信息移动到标题栏 --- let headerTimeSpan = null; let headerFrameSpan = null; if (headerElement) { // 创建标题栏信息容器(样式在 captionFrame.css 中定义) const infoContainer = document.createElement("div"); infoContainer.className = "video-header-info"; // 构建两行信息HTML infoContainer.innerHTML = `
00:00.00/00:00.00
0/${state.totalFrames} ${state.fps}fps
`; // 插入到关闭按钮之前 const icons = headerElement.querySelector('.p-dialog-header-icons'); if (icons) { headerElement.insertBefore(infoContainer, icons); } else { headerElement.appendChild(infoContainer); } headerTimeSpan = infoContainer.querySelector("#header-time"); headerFrameSpan = infoContainer.querySelector("#header-frame"); } container.appendChild(frameContainer); // --- 统一时间轴组件(合并进度条滑块和标记轨道)--- const timelineContainer = document.createElement("div"); timelineContainer.className = "unified-timeline-container"; // 标记轨道层(底层,用于显示帧标记) const markerTrack = document.createElement("div"); markerTrack.className = "frame-marker-track"; timelineContainer.appendChild(markerTrack); // 自定义滑块层(顶层) const sliderThumb = document.createElement("div"); sliderThumb.className = "timeline-slider-thumb"; timelineContainer.appendChild(sliderThumb); container.appendChild(timelineContainer); // 拖动时切换到 video 预览模式(流畅),松开后精确加载帧 let isDraggingSlider = false; // 统一的帧索引到百分比转换函数(确保滑块和标记使用相同的计算方式) const frameToPercent = (frameIndex) => { const maxFrame = state.totalFrames - 1; if (maxFrame <= 0) return 0; return (frameIndex / maxFrame) * 100; }; // 更新滑块位置的辅助函数 const updateSliderPosition = (frameIndex) => { sliderThumb.style.left = `${frameToPercent(frameIndex)}%`; }; // 根据鼠标位置计算帧索引 const getFrameFromMousePosition = (clientX) => { const rect = timelineContainer.getBoundingClientRect(); const offsetX = clientX - rect.left; const percent = Math.max(0, Math.min(1, offsetX / rect.width)); return Math.round(percent * (state.totalFrames - 1)); }; // 滑块拖动事件 sliderThumb.addEventListener("mousedown", (e) => { e.preventDefault(); isDraggingSlider = true; sliderThumb.classList.add("dragging"); // 切换到 video 预览模式 frameImg.style.display = "none"; videoElement.style.display = "block"; loadingIndicator.style.display = "none"; const onMouseMove = (moveEvent) => { if (!isDraggingSlider) return; const targetFrame = getFrameFromMousePosition(moveEvent.clientX); state.currentFrameIndex = targetFrame; videoElement.currentTime = targetFrame / state.fps; updateSliderPosition(targetFrame); updateDisplay(); }; const onMouseUp = () => { isDraggingSlider = false; sliderThumb.classList.remove("dragging"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); }; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); }); // 时间轴点击跳转 timelineContainer.addEventListener("click", (e) => { // 如果点击的是滑块本身或标记元素,不处理 if (e.target === sliderThumb || e.target.closest('.frame-marker, .frame-marker-range, .frame-marker-temp')) { return; } const targetFrame = getFrameFromMousePosition(e.clientX); state.currentFrameIndex = targetFrame; videoElement.currentTime = targetFrame / state.fps; updateSliderPosition(targetFrame); updateDisplay(); }); // --- 控制区域 --- const controlsContainer = document.createElement("div"); controlsContainer.className = "controls-container"; container.appendChild(controlsContainer); // 辅助函数:创建按钮 // 返回 { btn: HTMLElement, iconSpan: HTMLElement } const createBtn = (text, iconClass, onClick, type = 'secondary', iconPos = 'left') => { const btn = document.createElement("button"); // 复用 PrimeVue 按钮样式 btn.className = `p-button p-component p-button-${type} p-button-sm`; // 仅图标按钮处理 const isIconOnly = !text && iconClass; if (isIconOnly) { btn.classList.add("p-button-icon-only"); } let iconSpan = null; if (iconClass) { iconSpan = document.createElement("span"); // 仅图标时不需要 left/right 定位类 iconSpan.className = isIconOnly ? `p-button-icon pi ${iconClass}` : `p-button-icon-${iconPos} pi ${iconClass}`; btn.appendChild(iconSpan); } // 有文字时才添加 label if (text) { const labelSpan = document.createElement("span"); labelSpan.className = "p-button-label"; labelSpan.textContent = text; if (iconPos === 'right' && iconSpan) { // 图标在右时,需要先移除再重新排列 btn.removeChild(iconSpan); btn.appendChild(labelSpan); btn.appendChild(iconSpan); } else { btn.appendChild(labelSpan); } } btn.onclick = onClick; btn.style.marginRight = "5px"; return { btn, iconSpan }; }; // 2. 播放控制按钮组 (左侧) const playbackControls = document.createElement("div"); playbackControls.className = "playback-controls"; playbackControls.style.marginTop = "0"; // 长按连续跳帧辅助函数 // @param {HTMLElement} btn 按钮元素 // @param {Function} action 跳帧操作函数 const setupLongPressFrame = (btn, action) => { let pressTimer = null; // 长按延迟定时器 let intervalTimer = null; // 连续触发定时器 const startPress = () => { // 立即执行一次跳帧 action(); // 设置延迟后开始连续跳帧 pressTimer = setTimeout(() => { intervalTimer = setInterval(() => { action(); }, 50); // 每200ms跳一帧 }, 500); // 500ms后开始连续跳帧 }; const endPress = () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null; } }; btn.addEventListener('mousedown', startPress); btn.addEventListener('mouseup', endPress); btn.addEventListener('mouseleave', endPress); }; // 上一帧按钮(使用 video.currentTime 快速预览) const prevFrameBtn = createBtn("上一帧", "pi-caret-left", null).btn; setupLongPressFrame(prevFrameBtn, () => { stopPlayback(); // 停止播放 if (state.currentFrameIndex > 0) { state.currentFrameIndex--; videoElement.currentTime = state.currentFrameIndex / state.fps; updateDisplay(); } }); playbackControls.appendChild(prevFrameBtn); // --- 播放/暂停功能(使用 video 元素流畅播放)--- let isPlaying = false; let animationFrameId = null; // 用于取消动画帧 const playBtnObj = createBtn("播放", "pi-play", () => { if (isPlaying) { stopPlayback(); } else { startPlayback(); } }); playbackControls.appendChild(playBtnObj.btn); // 使用 requestAnimationFrame 实现流畅的滑块更新 const updatePlaybackProgress = () => { if (!isPlaying) return; // 使用 video.currentTime 计算连续的进度(而非离散帧索引) const currentTime = videoElement.currentTime; const duration = state.duration; // 直接使用时间比例计算滑块位置,实现平滑移动 if (duration > 0) { const percent = Math.min(100, (currentTime / duration) * 100); sliderThumb.style.left = `${percent}%`; } // 同时更新帧索引(用于显示) const currentFrame = Math.floor(currentTime * state.fps); state.currentFrameIndex = Math.max(0, Math.min(state.totalFrames - 1, currentFrame)); // 更新头部信息显示 const formatTime = (s) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); const ms = Math.floor((s % 1) * 100); return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(ms).padStart(2, '0')}`; }; if (headerTimeSpan) headerTimeSpan.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; if (headerFrameSpan) headerFrameSpan.textContent = `${state.currentFrameIndex} / ${state.totalFrames}`; // 继续下一帧动画 animationFrameId = requestAnimationFrame(updatePlaybackProgress); }; const startPlayback = () => { if (isPlaying) return; isPlaying = true; // 切换到视频播放模式 switchToVideoMode(state.currentFrameIndex); // 启动流畅动画更新 animationFrameId = requestAnimationFrame(updatePlaybackProgress); // 更新按钮图标 if (playBtnObj.iconSpan) { playBtnObj.iconSpan.classList.remove("pi-play"); playBtnObj.iconSpan.classList.add("pi-pause"); } }; const stopPlayback = () => { if (!isPlaying) return; isPlaying = false; // 取消动画帧 if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } // 暂停视频(不自动抓取精确帧,继续显示 video) videoElement.pause(); // 同步帧索引并更新显示 const currentFrameFromVideo = Math.floor(videoElement.currentTime * state.fps); state.currentFrameIndex = Math.max(0, Math.min(state.totalFrames - 1, currentFrameFromVideo)); updateDisplay(); // 更新按钮图标 if (playBtnObj.iconSpan) { playBtnObj.iconSpan.classList.remove("pi-pause"); playBtnObj.iconSpan.classList.add("pi-play"); } }; // 视频播放结束时自动停止(保持 video 显示,不切换到 img) videoElement.addEventListener("ended", () => { isPlaying = false; if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } state.currentFrameIndex = state.totalFrames - 1; updateDisplay(); if (playBtnObj.iconSpan) { playBtnObj.iconSpan.classList.remove("pi-pause"); playBtnObj.iconSpan.classList.add("pi-play"); } }); // 下一帧按钮(使用 video.currentTime 快速预览) const nextFrameBtn = createBtn("下一帧", "pi-caret-right", null).btn; setupLongPressFrame(nextFrameBtn, () => { stopPlayback(); // 停止播放 if (state.currentFrameIndex < state.totalFrames - 1) { state.currentFrameIndex++; videoElement.currentTime = state.currentFrameIndex / state.fps; updateDisplay(); } }); playbackControls.appendChild(nextFrameBtn); // 静音切换按钮 const muteBtnObj = createBtn("", "pi-volume-up", () => { videoElement.muted = !videoElement.muted; // 切换图标 if (muteBtnObj.iconSpan) { if (videoElement.muted) { muteBtnObj.iconSpan.classList.remove("pi-volume-up"); muteBtnObj.iconSpan.classList.add("pi-volume-off"); } else { muteBtnObj.iconSpan.classList.remove("pi-volume-off"); muteBtnObj.iconSpan.classList.add("pi-volume-up"); } } }); playbackControls.appendChild(muteBtnObj.btn); // 3. 标记控制按钮组 (右侧) const markerControls = document.createElement("div"); markerControls.className = "playback-controls"; markerControls.style.marginTop = "0"; markerControls.style.display = "flex"; markerControls.style.gap = "8px"; markerControls.appendChild(createBtn("标记当前帧", "pi-thumbtack", () => { state.selectedFrames.add(state.currentFrameIndex); renderTags(); }, "primary").btn); markerControls.appendChild(createBtn("范围", "pi-step-backward-alt", () => { state.rangeStart = state.currentFrameIndex; // 在轨道上显示临时闪烁标记 renderRangeStartMarker(); }, "success").btn); markerControls.appendChild(createBtn("范围", "pi-step-forward-alt", (e) => { if (state.rangeStart === null) { // 使用气泡对话框提示用户 const button = e.target.closest('button'); createConfirmPopup({ target: button, message: '请先设置起点', icon: 'pi-info-circle', singleButton: true, confirmLabel: '确定', position: 'top', onConfirm: () => { } }); return; } const rangeEnd = state.currentFrameIndex; if (rangeEnd < state.rangeStart) { // 使用气泡对话框提示用户 const button = e.target.closest('button'); createConfirmPopup({ target: button, message: '终点必须大于起点', icon: 'pi-exclamation-triangle', singleButton: true, confirmLabel: '确定', position: 'top', onConfirm: () => { } }); return; } state.selectedFrames.add(`${state.rangeStart}-${rangeEnd}`); state.rangeStart = null; // 移除临时标记并渲染范围标记 removeRangeStartMarker(); renderTags(); }, "success", "right").btn); controlsContainer.appendChild(playbackControls); controlsContainer.appendChild(markerControls); // --- 已选帧列表区域 --- const listContainer = document.createElement("div"); listContainer.className = "frame-list-container"; listContainer.style.marginTop = "0"; const listHeader = document.createElement("div"); listHeader.className = "frame-list-header"; // 标签列表容器(左侧) const tagsList = document.createElement("div"); tagsList.className = "frame-tags"; listHeader.appendChild(tagsList); // 清空按钮(右侧) const clearBtnObj = createBtn("", "pi-eraser", () => { if (confirm("确定清空所有已选帧吗?")) { state.selectedFrames.clear(); renderTags(); } }, "danger"); clearBtnObj.btn.style.padding = "6px 8px"; listHeader.appendChild(clearBtnObj.btn); listContainer.appendChild(listHeader); container.appendChild(listContainer); // 添加 tooltip(需要在元素添加到 DOM 后) createTooltip({ target: clearBtnObj.btn, content: "清空已选帧", position: "top" }); // --- 事件绑定与逻辑 --- // --- 事件绑定与逻辑 --- // 获取引用以便更新 (优先使用 header 中的引用) // 更新显示函数(基于帧索引) const updateDisplay = () => { const currentFrame = state.currentFrameIndex; const totalFrames = state.totalFrames; // 根据帧索引计算当前时间 const t = currentFrame / state.fps; const d = state.duration; // 格式化时间 MM:SS.ms const formatTime = (s) => { const m = Math.floor(s / 60); const sec = Math.floor(s % 60); const ms = Math.floor((s % 1) * 100); return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(ms).padStart(2, '0')}`; }; if (headerTimeSpan) headerTimeSpan.textContent = `${formatTime(t)} / ${formatTime(d)}`; if (headerFrameSpan) headerFrameSpan.textContent = `${currentFrame} / ${totalFrames}`; // 同步自定义滑块位置 updateSliderPosition(currentFrame); }; // --- 渲染范围起点临时标记 --- const renderRangeStartMarker = () => { // 先移除已有的临时标记 removeRangeStartMarker(); const totalFrames = state.totalFrames; if (totalFrames <= 0 || state.rangeStart === null) return; // 使用统一的帧到百分比转换函数 const leftPercent = frameToPercent(state.rangeStart); const tempMarker = document.createElement("div"); tempMarker.className = "frame-marker-temp"; tempMarker.style.left = `${leftPercent}%`; tempMarker.dataset.frame = state.rangeStart; markerTrack.appendChild(tempMarker); // 添加 tooltip createTooltip({ target: tempMarker, content: `范围起点: ${state.rangeStart}`, position: 'top' }); }; // --- 移除范围起点临时标记 --- const removeRangeStartMarker = () => { const tempMarker = markerTrack.querySelector(".frame-marker-temp"); if (tempMarker) { tempMarker.remove(); } }; // --- 清理残留的 tooltip --- const clearTooltips = () => { document.querySelectorAll('.pa-tooltip').forEach(t => t.remove()); }; // --- 渲染帧标记轨道 --- const renderMarkers = () => { // 清理可能残留的 tooltip(拖动时元素被删除但 tooltip 未销毁) clearTooltips(); markerTrack.innerHTML = ""; const totalFrames = state.totalFrames; if (totalFrames <= 0) return; state.selectedFrames.forEach(item => { if (typeof item === 'string' && item.includes('-')) { // 范围标记(使用统一的帧到百分比转换) const [start, end] = item.split('-').map(Number); const leftPercent = frameToPercent(start); const rightPercent = frameToPercent(end); const widthPercent = rightPercent - leftPercent; const rangeEl = document.createElement("div"); rangeEl.className = "frame-marker-range"; rangeEl.style.left = `${leftPercent}%`; rangeEl.style.width = `${Math.max(widthPercent, 0.5)}%`; rangeEl.dataset.range = item; rangeEl.dataset.originalItem = item; // 创建左边缘手柄 const leftHandle = document.createElement("div"); leftHandle.className = "range-handle range-handle-left"; rangeEl.appendChild(leftHandle); // 创建右边缘手柄 const rightHandle = document.createElement("div"); rightHandle.className = "range-handle range-handle-right"; rangeEl.appendChild(rightHandle); // 左边缘拖动 leftHandle.addEventListener('mousedown', (e) => { e.stopPropagation(); e.preventDefault(); clearTooltips(); // 拖动时立即清理 tooltip let isDragging = false; let dragLabel = null; const originalStart = start; const originalEnd = end; const onMouseMove = (moveEvent) => { if (!isDragging) { isDragging = true; // 创建跟随标签 dragLabel = document.createElement("div"); dragLabel.className = "drag-label"; markerTrack.appendChild(dragLabel); } const trackRect = markerTrack.getBoundingClientRect(); const offsetX = moveEvent.clientX - trackRect.left; const newPercent = Math.max(0, Math.min(1, offsetX / trackRect.width)); const newStart = Math.round(newPercent * (totalFrames - 1)); // 限制不能超过结束帧 const clampedStart = Math.max(0, Math.min(originalEnd - 1, newStart)); // 更新位置和宽度(使用统一的百分比转换) const newLeftPercent = frameToPercent(clampedStart); const newRightPercent = frameToPercent(originalEnd); rangeEl.style.left = `${newLeftPercent}%`; rangeEl.style.width = `${newRightPercent - newLeftPercent}%`; rangeEl.dataset.range = `${clampedStart}-${originalEnd}`; // 更新跟随标签 if (dragLabel) { dragLabel.textContent = `帧 ${clampedStart}-${originalEnd}`; // 标签位置定位在左边缘 dragLabel.style.left = `${newLeftPercent}%`; } // 同步 video 预览到新的起始帧 state.currentFrameIndex = clampedStart; videoElement.currentTime = clampedStart / state.fps; updateDisplay(); }; const onMouseUp = () => { // 移除跟随标签 if (dragLabel) { dragLabel.remove(); dragLabel = null; } if (isDragging) { const newRange = rangeEl.dataset.range; state.selectedFrames.delete(item); state.selectedFrames.add(newRange); renderTags(); } document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); // 右边缘拖动 rightHandle.addEventListener('mousedown', (e) => { e.stopPropagation(); e.preventDefault(); clearTooltips(); // 拖动时立即清理 tooltip let isDragging = false; let dragLabel = null; const originalStart = start; const originalEnd = end; const onMouseMove = (moveEvent) => { if (!isDragging) { isDragging = true; // 创建跟随标签 dragLabel = document.createElement("div"); dragLabel.className = "drag-label"; markerTrack.appendChild(dragLabel); } const trackRect = markerTrack.getBoundingClientRect(); const offsetX = moveEvent.clientX - trackRect.left; const newPercent = Math.max(0, Math.min(1, offsetX / trackRect.width)); const newEnd = Math.round(newPercent * (totalFrames - 1)); // 限制不能小于起始帧 const clampedEnd = Math.max(originalStart + 1, Math.min(totalFrames - 1, newEnd)); // 更新宽度(使用统一的百分比转换) const leftPercent = frameToPercent(originalStart); const rightPercent = frameToPercent(clampedEnd); rangeEl.style.width = `${rightPercent - leftPercent}%`; rangeEl.dataset.range = `${originalStart}-${clampedEnd}`; // 更新跟随标签 if (dragLabel) { dragLabel.textContent = `帧 ${originalStart}-${clampedEnd}`; // 标签位置定位在右边缘 dragLabel.style.left = `${rightPercent}%`; } // 同步 video 预览到新的结束帧 state.currentFrameIndex = clampedEnd; videoElement.currentTime = clampedEnd / state.fps; updateDisplay(); }; const onMouseUp = () => { // 移除跟随标签 if (dragLabel) { dragLabel.remove(); dragLabel = null; } if (isDragging) { const newRange = rangeEl.dataset.range; state.selectedFrames.delete(item); state.selectedFrames.add(newRange); renderTags(); } document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); // 中间区域拖动(整体平移) rangeEl.addEventListener('mousedown', (e) => { // 检查是否点击在手柄上,如果是则不处理 if (e.target.classList.contains('range-handle')) { return; } e.stopPropagation(); e.preventDefault(); clearTooltips(); // 拖动时立即清理 tooltip let isDragging = false; let dragLabel = null; const startX = e.clientX; const originalStart = start; const originalEnd = end; const rangeWidth = originalEnd - originalStart; const onMouseMove = (moveEvent) => { if (!isDragging) { isDragging = true; // 创建跟随标签 dragLabel = document.createElement("div"); dragLabel.className = "drag-label"; markerTrack.appendChild(dragLabel); } const deltaX = moveEvent.clientX - startX; const trackRect = markerTrack.getBoundingClientRect(); const deltaPercent = deltaX / trackRect.width; const deltaFrames = Math.round(deltaPercent * (totalFrames - 1)); let newStart = originalStart + deltaFrames; let newEnd = originalEnd + deltaFrames; // 限制范围不能超出边界 if (newStart < 0) { newStart = 0; newEnd = rangeWidth; } else if (newEnd >= totalFrames) { newEnd = totalFrames - 1; newStart = newEnd - rangeWidth; } // 更新位置(使用统一的百分比转换) const newLeftPercent = frameToPercent(newStart); rangeEl.style.left = `${newLeftPercent}%`; rangeEl.dataset.range = `${newStart}-${newEnd}`; // 更新跟随标签 if (dragLabel) { dragLabel.textContent = `帧 ${newStart}-${newEnd}`; // 标签位置定位在范围中心 const centerFrame = (newStart + newEnd) / 2; dragLabel.style.left = `${frameToPercent(centerFrame)}%`; } // 同步 video 预览到新的起始帧 state.currentFrameIndex = newStart; videoElement.currentTime = newStart / state.fps; updateDisplay(); }; const onMouseUp = () => { // 移除跟随标签 if (dragLabel) { dragLabel.remove(); dragLabel = null; } if (isDragging) { const newRange = rangeEl.dataset.range; state.selectedFrames.delete(item); state.selectedFrames.add(newRange); renderTags(); } document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); // 双击范围帧跳转到起始帧 rangeEl.addEventListener('dblclick', (e) => { e.stopPropagation(); const range = rangeEl.dataset.range; const startFrame = parseInt(range.split('-')[0]); state.currentFrameIndex = startFrame; videoElement.currentTime = startFrame / state.fps; updateDisplay(); }); markerTrack.appendChild(rangeEl); // 使用 createTooltip 显示帧范围 createTooltip({ target: rangeEl, content: `帧 ${item}`, position: 'top' }); } else { // 单帧标记(使用统一的帧到百分比转换) const frame = typeof item === 'number' ? item : parseInt(item); const leftPercent = frameToPercent(frame); const markerEl = document.createElement("div"); markerEl.className = "frame-marker"; markerEl.style.left = `${leftPercent}%`; markerEl.dataset.frame = frame; markerEl.dataset.originalItem = item; // 添加拖动功能 markerEl.addEventListener('mousedown', (e) => { e.stopPropagation(); e.preventDefault(); clearTooltips(); // 拖动时立即清理 tooltip const originalFrame = parseInt(markerEl.dataset.frame); const originalItem = markerEl.dataset.originalItem; let isDragging = false; let dragLabel = null; const onMouseMove = (moveEvent) => { if (!isDragging) { isDragging = true; // 创建跟随标签 dragLabel = document.createElement("div"); dragLabel.className = "drag-label"; markerTrack.appendChild(dragLabel); } const trackRect = markerTrack.getBoundingClientRect(); const offsetX = moveEvent.clientX - trackRect.left; const newPercent = Math.max(0, Math.min(1, offsetX / trackRect.width)); const newFrame = Math.round(newPercent * (totalFrames - 1)); // 限制范围 const clampedFrame = Math.max(0, Math.min(totalFrames - 1, newFrame)); // 实时更新位置(使用统一的百分比转换) markerEl.style.left = `${frameToPercent(clampedFrame)}%`; markerEl.dataset.frame = clampedFrame; // 更新跟随标签 if (dragLabel) { dragLabel.textContent = `帧 ${clampedFrame}`; dragLabel.style.left = `${frameToPercent(clampedFrame)}%`; } // 同步 video 预览 state.currentFrameIndex = clampedFrame; videoElement.currentTime = clampedFrame / state.fps; updateDisplay(); }; const onMouseUp = () => { // 移除跟随标签 if (dragLabel) { dragLabel.remove(); dragLabel = null; } if (isDragging) { const newFrame = parseInt(markerEl.dataset.frame); // 更新 selectedFrames if (typeof originalItem === 'number') { state.selectedFrames.delete(originalItem); } else { state.selectedFrames.delete(parseInt(originalItem)); } state.selectedFrames.add(newFrame); // 重新渲染 renderTags(); } document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); // 双击跳转到对应帧 markerEl.addEventListener('dblclick', (e) => { e.stopPropagation(); const targetFrame = parseInt(markerEl.dataset.frame); state.currentFrameIndex = targetFrame; videoElement.currentTime = targetFrame / state.fps; updateDisplay(); }); markerTrack.appendChild(markerEl); // 使用 createTooltip 显示帧号 createTooltip({ target: markerEl, content: `帧 ${frame}`, position: 'top' }); } }); }; // --- 渲染底部帧标签 --- const renderTags = () => { tagsList.innerHTML = ""; const sorted = Array.from(state.selectedFrames).sort((a, b) => { const getStart = v => typeof v === 'string' ? parseInt(v.split('-')[0]) : v; return getStart(a) - getStart(b); }); sorted.forEach(item => { const tag = document.createElement("div"); // 根据类型添加对应的样式类 const isRange = typeof item === 'string' && item.includes('-'); tag.className = `frame-tag ${isRange ? 'frame-tag-range' : 'frame-tag-single'}`; tag.innerHTML = `${item}`; const removeIcon = document.createElement("span"); removeIcon.className = "remove-frame"; removeIcon.innerHTML = "×"; removeIcon.onclick = () => { state.selectedFrames.delete(item); renderTags(); }; tag.appendChild(removeIcon); tagsList.appendChild(tag); }); // 同步更新标记轨道 renderMarkers(); }; // --- 帧滑块点击支持(在标记轨道上点击跳转到指定帧)--- markerTrack.addEventListener('click', (e) => { if (e.target === markerTrack) { const rect = markerTrack.getBoundingClientRect(); const clickPercent = (e.clientX - rect.left) / rect.width; const targetFrame = Math.round(clickPercent * state.totalFrames); loadFrame(Math.max(0, Math.min(state.totalFrames - 1, targetFrame))); } }); // --- 初始化:加载第一帧 --- loadFrame(0); renderTags(); } /** * 递归查找连接的视频源 * @param {Object} node 起始节点 * @returns {Promise} 视频信息对象 {url, filename, type} */ async function findConnectedVideo(node) { if (!node.inputs) return null; // 辅助函数:从节点提取视频文件信息 const extractVideoFile = (node) => { let filename = null; let subfolder = ""; let type = "input"; let forceRate = 0; // 检测是否为VideoHelperSuite的Load Video节点 const isLoadVideoNode = node.type?.includes("VHS_LoadVideo"); // 策略1: 从 serialize.widgets_values 获取 if (node.serialize) { const serialized = node.serialize(); if (serialized?.widgets_values?.length > 0) { filename = serialized.widgets_values[0]; } } // 策略2: 从 widgets 获取 if (!filename && node.widgets?.length > 0) { for (const w of node.widgets) { if (w.value && typeof w.value === 'string' && w.value.length > 0) { filename = w.value; break; } } } // 策略3: 从 properties 获取 if (!filename && node.properties) { filename = node.properties.video || node.properties.filename || node.properties.upload; } // 提取force_rate参数(来自VideoHelperSuite的Load Video节点) if (node.widgets) { const forceRateWidget = node.widgets.find(w => w.name === "force_rate"); if (forceRateWidget && forceRateWidget.value != null) { forceRate = parseFloat(forceRateWidget.value); } } if (filename) { return { filename, subfolder, type, forceRate, fromLoadNode: isLoadVideoNode }; } return null; }; // 辅助函数:递归遍历图 const findVideoSource = (currentNode, visited = new Set()) => { if (!currentNode || visited.has(currentNode.id)) return null; visited.add(currentNode.id); // 检查当前节点是否有视频文件 const videoFile = extractVideoFile(currentNode); if (videoFile) return videoFile; // 递归查找上游节点 if (currentNode.inputs) { for (const input of currentNode.inputs) { if (input.link) { const link = app.graph.links[input.link]; if (link) { const sourceNode = app.graph.getNodeById(link.origin_id); const result = findVideoSource(sourceNode, visited); if (result) return result; } } } } return null; }; // 1. 优先查找 Video 类型输入 const videoInput = node.inputs.find(i => i.name === "视频" || i.type === "VIDEO"); if (videoInput?.link) { const link = app.graph.links[videoInput.link]; if (link) { const originNode = app.graph.getNodeById(link.origin_id); const videoFile = findVideoSource(originNode); if (videoFile) { const params = new URLSearchParams(videoFile); return { url: api.apiURL("/view?" + params.toString()), filename: videoFile.filename, type: videoFile.type, forceRate: videoFile.forceRate || 0, fromLoadNode: videoFile.fromLoadNode || false }; } } } // 2. 查找 Image 类型输入(图像序列) const imageInput = node.inputs.find(i => i.name === "图像序列" || i.type === "IMAGE"); if (imageInput?.link) { const link = app.graph.links[imageInput.link]; if (link) { const originNode = app.graph.getNodeById(link.origin_id); const videoFile = findVideoSource(originNode); if (videoFile) { const params = new URLSearchParams(videoFile); return { url: api.apiURL("/view?" + params.toString()), filename: videoFile.filename, type: videoFile.type, forceRate: videoFile.forceRate || 0, fromLoadNode: videoFile.fromLoadNode || false }; } else { return { type: "image_sequence", error: "检测到图像序列输入,但无法追溯到原始视频文件。请确保图像序列来自视频加载节点(如 VHS Load Video)。" }; } } } return null; }