refactor: 视频帧独立预览区,只显示视频卡片不逐帧展示
- 新增 #videoPreview 独立区域,金色 video-chip 卡片 - 视频帧存入 pendingVideoFrames,与 pendingImages 完全分离 - 发送时自动合并为 images 数组
This commit is contained in:
@@ -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();
|
||||||
|
|
||||||
// 新一轮开始:清理上一轮的工作空间状态
|
// 新一轮开始:清理上一轮的工作空间状态
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user