Files

1471 lines
57 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 视频抽帧工具节点扩展
* 提供视频手动抽帧、预览和帧索引生成功能
*/
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
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 = '<span class="pi pi-spin pi-spinner"></span>';
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 = `
<div class="info-row"><span id="header-time">00:00.00/00:00.00</span></div>
<div class="info-row"><span id="header-frame">0/${state.totalFrames}</span>&nbsp;${state.fps}fps</div>
`;
// 插入到关闭按钮之前
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 = `<span>${item}</span>`;
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<Object|null>} 视频信息对象 {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;
}