refactor: 视频帧独立预览区,只显示视频卡片不逐帧展示

- 新增 #videoPreview 独立区域,金色 video-chip 卡片
- 视频帧存入 pendingVideoFrames,与 pendingImages 完全分离
- 发送时自动合并为 images 数组
This commit is contained in:
thzxx
2026-06-18 11:33:32 +08:00
parent d5a8592317
commit 389e42874a
3 changed files with 109 additions and 23 deletions
+54 -23
View File
@@ -26,9 +26,11 @@ let chatInputEl: HTMLTextAreaElement;
let btnSendEl: HTMLButtonElement; let btnSendEl: HTMLButtonElement;
let imagePreviewEl: HTMLElement; let imagePreviewEl: HTMLElement;
let filePreviewEl: HTMLElement; let filePreviewEl: HTMLElement;
let pendingImages: Array<{ name: string; base64: string; timestampSeconds?: number }> = []; let videoPreviewEl: HTMLElement;
let pendingImages: Array<{ name: string; base64: string }> = [];
let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = []; let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = [];
let pendingVideoInfo: { duration: number; width: number; height: number } | null = null; let pendingVideoFrames: Array<{ name: string; base64: string; timestampSeconds: number }> = [];
let pendingVideoMeta: { fileName: string; duration: number; width: number; height: number } | null = null;
const TEXT_EXTENSIONS = new Set([ const TEXT_EXTENSIONS = new Set([
'txt','md','markdown','rst','log','csv','tsv', 'txt','md','markdown','rst','log','csv','tsv',
@@ -70,6 +72,7 @@ export function initInputArea(): void {
btnSendEl = document.querySelector('#btnSend')!; btnSendEl = document.querySelector('#btnSend')!;
imagePreviewEl = document.querySelector('#imagePreview')!; imagePreviewEl = document.querySelector('#imagePreview')!;
filePreviewEl = document.querySelector('#filePreview')!; filePreviewEl = document.querySelector('#filePreview')!;
videoPreviewEl = document.querySelector('#videoPreview')!;
btnSendEl.addEventListener('click', () => { btnSendEl.addEventListener('click', () => {
if (state.get<boolean>(KEYS.IS_STREAMING)) { if (state.get<boolean>(KEYS.IS_STREAMING)) {
@@ -251,7 +254,7 @@ async function handleVideoPath(filePath: string): Promise<void> {
appendSystemMessage(`🎬 正在提取视频帧 (1fps): ${name}...`); appendSystemMessage(`🎬 正在提取视频帧 (1fps): ${name}...`);
try { try {
const result = await bridge.video.extractFrames(filePath, { maxFrames: 60, maxWidth: 512 }); const result = await bridge.video.extractFrames(filePath, { maxFrames: 600, maxWidth: 512 });
if (!result.success) { if (!result.success) {
appendSystemMessage(`❌ 视频帧提取失败: ${result.error}`); appendSystemMessage(`❌ 视频帧提取失败: ${result.error}`);
return; return;
@@ -260,25 +263,44 @@ async function handleVideoPath(filePath: string): Promise<void> {
const frames = result.frames!; const frames = result.frames!;
const info = result.videoInfo!; const info = result.videoInfo!;
// 保存视频元信息(用于消息构建时的时序标注) // 存入视频专用存储
pendingVideoInfo = { duration: info.duration, width: info.width, height: info.height }; pendingVideoFrames = frames;
pendingVideoMeta = { fileName: name, duration: info.duration, width: info.width, height: info.height };
// 帧加入 pendingImages,保留时间戳
for (const frame of frames) {
pendingImages.push({ name: frame.name, base64: frame.base64, timestampSeconds: frame.timestampSeconds });
}
logInfo(`视频帧提取完成: ${name}`, logInfo(`视频帧提取完成: ${name}`,
`${frames.length} 帧 (1fps, ${info.duration.toFixed(0)}s, ${info.width}x${info.height})`); `${frames.length} 帧 (1fps, ${info.duration.toFixed(0)}s, ${info.width}x${info.height})`);
appendSystemMessage(`${name}: 已提取 ${frames.length} 帧 (1fps 采样)`); appendSystemMessage(`${name}: 已提取 ${frames.length} 帧 (1fps 采样)`);
renderImagePreviews(); renderVideoPreview();
} catch (err) { } catch (err) {
logError('视频帧提取异常', (err as Error).message); logError('视频帧提取异常', (err as Error).message);
appendSystemMessage(`❌ 视频处理失败: ${(err as Error).message}`); appendSystemMessage(`❌ 视频处理失败: ${(err as Error).message}`);
} }
} }
function renderVideoPreview(): void {
if (!pendingVideoMeta || pendingVideoFrames.length === 0) {
videoPreviewEl.style.display = 'none';
videoPreviewEl.innerHTML = '';
return;
}
const m = pendingVideoMeta;
videoPreviewEl.style.display = '';
videoPreviewEl.innerHTML = `
<div class="video-chip">
<span class="video-icon">🎬</span>
<span class="video-name">${escapeHtml(m.fileName)}</span>
<span class="video-meta">${pendingVideoFrames.length}帧 · ${m.duration.toFixed(0)}s</span>
<button class="remove-video">×</button>
</div>
`;
videoPreviewEl.querySelector('.remove-video')!.addEventListener('click', () => {
pendingVideoFrames = [];
pendingVideoMeta = null;
renderVideoPreview();
});
}
function renderImagePreviews(): void { function renderImagePreviews(): void {
if (pendingImages.length === 0) { if (pendingImages.length === 0) {
imagePreviewEl.style.display = 'none'; imagePreviewEl.style.display = 'none';
@@ -897,22 +919,28 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
const now = Date.now(); const now = Date.now();
const msgsToAdd: ChatMessage[] = []; const msgsToAdd: ChatMessage[] = [];
const userFiles: ChatFile[] = pendingFiles.map(f => ({ name: f.name, language: f.language, size: f.size })); const userFiles: ChatFile[] = pendingFiles.map(f => ({ name: f.name, language: f.language, size: f.size }));
const images = pendingImages.map(img => img.base64);
if (pendingImages.length > 0) { // 合并图片和视频帧为统一的 images 数组
const hasVideoFrames = pendingVideoInfo !== null && pendingImages.some(img => img.timestampSeconds !== undefined); const images: string[] = [
...pendingImages.map(img => img.base64),
...pendingVideoFrames.map(f => f.base64)
];
const totalAttachments = pendingImages.length + pendingVideoFrames.length;
if (totalAttachments > 0) {
let imgDesc: string; let imgDesc: string;
if (hasVideoFrames) { if (pendingVideoMeta && pendingVideoFrames.length > 0) {
const vi = pendingVideoInfo!; // 有视频:构建帧序列描述
const frameList = pendingImages const frameList = pendingVideoFrames
.filter(img => img.timestampSeconds !== undefined) .map(f => ` ${String(f.timestampSeconds).padStart(4, ' ')}s — ${f.name}`)
.map(img => ` ${String(img.timestampSeconds!).padStart(4, ' ')}s — ${img.name}`)
.join('\n'); .join('\n');
imgDesc = `[视频帧序列 · ${pendingImages.length}帧 · 1fps · ${vi.duration.toFixed(0)}s]\n${frameList}\n\n上述帧按时间顺序排列,帧间存在时序关系。请分析视频内容。`; imgDesc = `[视频帧序列 · ${pendingVideoFrames.length}帧 · 1fps · ${pendingVideoMeta.duration.toFixed(0)}s]\n${frameList}\n\n上述帧按时间顺序排列,帧间存在时序关系。请分析视频内容。`;
} else if (pendingImages.length === 1) { } else if (pendingImages.length === 1) {
imgDesc = `[上传了图片: ${pendingImages[0].name}]`; imgDesc = `[上传了图片: ${pendingImages[0].name}]`;
} else { } else if (pendingImages.length > 0) {
imgDesc = `[上传了 ${pendingImages.length} 张图片]`; imgDesc = `[上传了 ${pendingImages.length} 张图片]`;
} else {
imgDesc = '';
} }
const msg: ChatMessage = { const msg: ChatMessage = {
role: 'user', role: 'user',
@@ -942,7 +970,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
state.update(KEYS.CURRENT_SESSION, (session: any) => ({ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session, ...session,
...(isFirstMsg && { ...(isFirstMsg && {
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : pendingVideoInfo ? '[视频消息]' : '[图片消息]'), 30), title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : pendingVideoMeta ? '[视频消息]' : '[图片消息]'), 30),
model model
}), }),
messages: [...session.messages, ...msgsToAdd], messages: [...session.messages, ...msgsToAdd],
@@ -958,11 +986,14 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
chatInputEl.value = ''; chatInputEl.value = '';
pendingImages = []; pendingImages = [];
pendingFiles = []; pendingFiles = [];
pendingVideoInfo = null; pendingVideoFrames = [];
pendingVideoMeta = null;
imagePreviewEl.style.display = 'none'; imagePreviewEl.style.display = 'none';
imagePreviewEl.innerHTML = ''; imagePreviewEl.innerHTML = '';
filePreviewEl.style.display = 'none'; filePreviewEl.style.display = 'none';
filePreviewEl.innerHTML = ''; filePreviewEl.innerHTML = '';
videoPreviewEl.style.display = 'none';
videoPreviewEl.innerHTML = '';
autoResizeTextarea(); autoResizeTextarea();
// 新一轮开始:清理上一轮的工作空间状态 // 新一轮开始:清理上一轮的工作空间状态
+1
View File
@@ -141,6 +141,7 @@
<div class="input-area" id="inputArea"> <div class="input-area" id="inputArea">
<div class="image-preview" id="imagePreview" style="display:none;"></div> <div class="image-preview" id="imagePreview" style="display:none;"></div>
<div class="file-preview" id="filePreview" style="display:none;"></div> <div class="file-preview" id="filePreview" style="display:none;"></div>
<div class="video-preview" id="videoPreview" style="display:none;"></div>
<div class="input-row"> <div class="input-row">
<button class="icon-btn attach-btn" id="btnAttachImg" title="上传图片"> <button class="icon-btn attach-btn" id="btnAttachImg" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+54
View File
@@ -1227,6 +1227,60 @@ html, body {
background: var(--critical-bg); background: var(--critical-bg);
} }
/* ── 视频预览 ── */
.video-preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0;
}
.video-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
background: rgba(212, 160, 60, 0.1);
border: 1px solid rgba(212, 160, 60, 0.2);
font-size: 12px;
line-height: 1;
animation: fadeSlideUp 200ms ease;
}
.video-chip .video-icon { font-size: 16px; flex-shrink: 0; }
.video-chip .video-name {
font-weight: 600;
color: var(--text-primary);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-chip .video-meta {
color: #D4A03C;
flex-shrink: 0;
}
.remove-video {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 14px;
padding: 0 2px;
border-radius: 3px;
transition: var(--transition);
}
.remove-video:hover {
color: var(--critical);
background: var(--critical-bg);
}
.input-row { .input-row {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;