/**
* 视频抽帧工具节点扩展
* 提供视频手动抽帧、预览和帧索引生成功能
*/
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 = `
${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