feat(video-frame): 添加视频帧选择功能及历史记录组件

实现视频帧选择功能,包括:
1. 添加视频帧选择弹窗组件,支持拖动进度条或输入时间选择指定帧
2. 新增视频帧插入工具,可将选定帧插入画布中视频元素下方
3. 重构历史记录功能为通用组件,支持图片和视频历史统一管理
4. 优化视频尺寸获取逻辑,添加缓存机制提升性能
5. 强制全局UI使用浅色主题,确保视觉一致性
This commit is contained in:
liquid committed 2025-09-06 20:57:06 +08:00
1 parent a61e619748
commit 035c8dba6f
15 files changed
+1298 -295

No files matched your search

+10 -1
View File
@@ -27,7 +27,16 @@
--aitu-gradient-primary: linear-gradient(135deg, #F39C12 0%, #E67E22 30%, #5A4FCF 70%, #E91E63 100%);
--aitu-gradient-brush: linear-gradient(135deg, #5A4FCF 0%, #7B68EE 50%, #E91E63 100%);
}
/* 在全局样式中设置 */
:root {
color-scheme: light; /* 只支持浅色模式 */
--td-bg-color-page: #ffffff !important;
--td-bg-color-container: #f5f5f5 !important;
--td-text-color-primary: #333333 !important;
--td-text-color-secondary: #666666 !important;
--td-border-level-1-color: #e0e0e0 !important;
--td-border-level-2-color: #eeeeee !important;
}
body {
margin: 0;
padding: 0;
+4 -1
View File
@@ -20,4 +20,7 @@
## 视频
点击单个图片的popup-toolbar(只有图片才需要),添加一个AI生成视频按钮,点击AI生成视频按钮打开类似'/Users/ljq/code/self/drawn
ix/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx'的弹窗,左边表单的上传图片只支持一张图,右边是生成的
视频预览
视频预览
对于视频元素,点击的popup-toolbar出现选择视频帧的图标,点击图标打开视频帧选择弹窗.视频帧选择弹窗上方默认是该视频最后一帧的图片,图片下有进度条,可以拖动或输入选择指定位置的帧图片,点击确认,插入到画布中该视频的底下。
@@ -0,0 +1,240 @@
// Generation History Component - 统一的历史记录样式
.generation-history-container {
.history-icon-button {
position: relative;
width: 32px;
height: 32px;
border: none;
background: var(--color-bg-container, #fff);
color: var(--color-text-secondary, #666);
border-radius: 50%;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
background: var(--color-primary-light, #e6f7ff);
color: var(--color-primary, #0052d9);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
.history-popover {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
width: 320px;
max-height: 400px;
background: var(--color-bg-container, #fff);
border: 1px solid var(--color-border, #d9d9d9);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 1000;
.history-popover-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border, #d9d9d9);
background: var(--color-bg-secondary, #f5f5f5);
.history-title {
font-weight: 600;
color: var(--color-text-primary, #333);
}
.history-close-button {
background: none;
border: none;
font-size: 18px;
color: var(--color-text-secondary, #666);
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
&:hover {
background: var(--color-error-light, #fff2f0);
color: var(--color-error, #ff4d4f);
}
}
}
.history-list {
max-height: 300px;
overflow-y: auto;
.history-item {
display: flex;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid var(--color-border-light, #f0f0f0);
&:hover {
background: var(--color-bg-secondary, #f5f5f5);
}
&:last-child {
border-bottom: none;
}
.history-item-media {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
background: var(--color-bg-layout, #f5f5f5);
position: relative;
.history-item-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.history-video-thumbnail {
position: relative;
width: 100%;
height: 100%;
.video-play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.play-icon {
color: white;
font-size: 10px;
margin-left: 1px; // 视觉居中调整
}
}
}
.history-video-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-tertiary, #e8e8e8);
color: var(--color-text-secondary, #666);
.placeholder-icon {
font-size: 24px;
}
}
}
.history-item-info {
flex: 1;
min-width: 0; // 允许文字截断
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
.history-item-prompt {
color: var(--color-text-primary, #333);
font-size: 14px;
line-height: 1.4;
word-break: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.history-item-time {
color: var(--color-text-secondary, #666);
font-size: 12px;
}
}
}
}
.history-more-info {
padding: 8px 16px;
text-align: center;
color: var(--color-text-secondary, #666);
font-size: 12px;
background: var(--color-bg-secondary, #f5f5f5);
border-top: 1px solid var(--color-border-light, #f0f0f0);
}
}
}
// 自定义滚动条样式
.history-list {
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: var(--color-bg-secondary, #f5f5f5);
}
&::-webkit-scrollbar-thumb {
background: var(--color-border, #d9d9d9);
border-radius: 2px;
&:hover {
background: var(--color-primary, #0052d9);
}
}
}
// 响应式调整
@media (max-width: 768px) {
.generation-history-container {
.history-popover {
width: 280px;
right: -20px; // 在小屏幕上稍微调整位置
.history-list {
.history-item {
padding: 10px 12px;
gap: 10px;
.history-item-media {
width: 50px;
height: 50px;
}
.history-item-info {
.history-item-prompt {
font-size: 13px;
}
}
}
}
}
}
}
@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { HistoryIcon } from 'tdesign-icons-react';
import { useI18n } from '../../i18n';
import './generation-history.scss';
// 通用历史记录项接口
export interface BaseHistoryItem {
id: string;
prompt: string;
timestamp: number;
}
// 图片历史记录项接口
export interface ImageHistoryItem extends BaseHistoryItem {
type: 'image';
imageUrl: string;
width: number;
height: number;
}
// 视频历史记录项接口(适配图片格式)
export interface VideoHistoryItem extends BaseHistoryItem {
type: 'video';
imageUrl: string; // 视频缩略图URL,适配图片格式
width: number; // 视频宽度,适配图片格式
height: number; // 视频高度,适配图片格式
// 视频特有字段
previewUrl: string;
downloadUrl?: string;
}
// 联合类型
export type HistoryItem = ImageHistoryItem | VideoHistoryItem;
export interface GenerationHistoryProps {
historyItems: HistoryItem[];
onSelectFromHistory: (item: HistoryItem) => void;
position?: {
bottom?: string;
right?: string;
top?: string;
left?: string;
};
className?: string;
}
export const GenerationHistory: React.FC<GenerationHistoryProps> = ({
historyItems,
onSelectFromHistory,
position = { bottom: '8px', right: '8px' },
className = ''
}) => {
const { language } = useI18n();
const [showHistoryPopover, setShowHistoryPopover] = useState(false);
if (historyItems.length === 0) {
return null;
}
const renderHistoryItem = (item: HistoryItem) => {
return (
<div
key={item.id}
className="history-item"
onClick={() => {
onSelectFromHistory(item);
setShowHistoryPopover(false);
}}
>
<div className="history-item-media">
{item.type === 'image' ? (
<img
src={item.imageUrl}
alt="History item"
className="history-item-image"
loading="lazy"
/>
) : (
// 视频类型,使用统一的 imageUrl 字段
item.imageUrl ? (
<div className="history-video-thumbnail">
<img
src={item.imageUrl}
alt="Video thumbnail"
className="history-item-image"
loading="lazy"
/>
<div className="video-play-overlay">
<div className="play-icon">▶</div>
</div>
</div>
) : (
<div className="history-item-image history-video-placeholder">
<div className="placeholder-icon">🎬</div>
</div>
)
)}
</div>
<div className="history-item-info">
<div className="history-item-prompt" title={item.prompt}>
{item.prompt.length > 25
? `${item.prompt.slice(0, 25)}...`
: item.prompt}
</div>
<div className="history-item-time">
{new Date(item.timestamp).toLocaleDateString()}
</div>
</div>
</div>
);
};
return (
<div
className={`generation-history-container ${className}`}
style={{
position: 'absolute',
bottom: position.bottom,
right: position.right,
top: position.top,
left: position.left,
zIndex: 10
}}
>
<button
className="history-icon-button"
onClick={() => setShowHistoryPopover(!showHistoryPopover)}
onMouseEnter={() => setShowHistoryPopover(true)}
title={language === 'zh' ? '查看生成历史' : 'View generation history'}
>
<HistoryIcon />
</button>
{showHistoryPopover && (
<div
className="history-popover"
onMouseLeave={() => setShowHistoryPopover(false)}
>
<div className="history-popover-header">
<span className="history-title">
{language === 'zh' ? '生成历史' : 'Generation History'}
</span>
<button
className="history-close-button"
onClick={() => setShowHistoryPopover(false)}
>
×
</button>
</div>
<div className="history-list">
{historyItems.slice(0, 10).map(renderHistoryItem)}
</div>
{historyItems.length > 10 && (
<div className="history-more-info">
{language === 'zh'
? `还有 ${historyItems.length - 10} 个项目...`
: `${historyItems.length - 10} more items...`
}
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,143 @@
import { ImageHistoryItem, VideoHistoryItem, HistoryItem } from './generation-history';
// 图片历史记录缓存key
export const IMAGE_HISTORY_CACHE_KEY = 'ai_image_generation_history';
// 视频历史记录缓存key
export const VIDEO_HISTORY_CACHE_KEY = 'ai_video_generation_history';
/**
* 保存历史记录到localStorage
*/
export const saveToHistory = <T extends HistoryItem>(item: T, cacheKey: string): void => {
try {
const existing = loadHistory<T>(cacheKey);
// 添加新项目到开头,并限制最多保存50个
const updated = [item, ...existing.filter(h => h.id !== item.id)].slice(0, 50);
localStorage.setItem(cacheKey, JSON.stringify(updated));
} catch (error) {
console.warn('Failed to save history:', error);
}
};
/**
* 从localStorage加载历史记录
*/
export const loadHistory = <T extends HistoryItem>(cacheKey: string): T[] => {
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const data = JSON.parse(cached) as T[];
// 过滤掉超过7天的记录
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
return data.filter(item => item.timestamp > weekAgo);
}
} catch (error) {
console.warn('Failed to load history:', error);
}
return [];
};
/**
* 保存图片历史记录
*/
export const saveImageToHistory = (item: Omit<ImageHistoryItem, 'type'>): void => {
const imageItem: ImageHistoryItem = {
...item,
type: 'image'
};
saveToHistory(imageItem, IMAGE_HISTORY_CACHE_KEY);
};
/**
* 保存视频历史记录
*/
export const saveVideoToHistory = (item: Omit<VideoHistoryItem, 'type'>): void => {
const videoItem: VideoHistoryItem = {
...item,
type: 'video'
};
saveToHistory(videoItem, VIDEO_HISTORY_CACHE_KEY);
};
/**
* 加载图片历史记录(包含旧数据迁移)
*/
export const loadImageHistory = (): ImageHistoryItem[] => {
try {
const cached = localStorage.getItem(IMAGE_HISTORY_CACHE_KEY);
if (cached) {
const data = JSON.parse(cached) as any[];
// 过滤掉超过7天的记录
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const validItems = data.filter(item => item.timestamp > weekAgo);
// 迁移旧数据格式到新格式
return validItems.map((item: any): ImageHistoryItem => {
return {
...item,
type: 'image' // 确保所有图片历史都有正确的 type 字段
} as ImageHistoryItem;
});
}
} catch (error) {
console.warn('Failed to load image history:', error);
}
return [];
};
/**
* 加载视频历史记录(包含旧数据迁移)
*/
export const loadVideoHistory = (): VideoHistoryItem[] => {
try {
const cached = localStorage.getItem(VIDEO_HISTORY_CACHE_KEY);
if (cached) {
const data = JSON.parse(cached) as any[];
// 过滤掉超过7天的记录
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const validItems = data.filter(item => item.timestamp > weekAgo);
// 迁移旧数据格式到新格式
return validItems.map((item: any): VideoHistoryItem => {
// 如果是旧格式(没有 imageUrl 字段)
if (!item.imageUrl && (item.thumbnail || item.previewUrl)) {
return {
...item,
type: 'video',
imageUrl: item.thumbnail || item.previewUrl, // 使用缩略图或预览URL
width: item.width || 400, // 默认尺寸
height: item.height || 225, // 默认尺寸
previewUrl: item.previewUrl || item.videoUrls?.previewUrl || '',
downloadUrl: item.downloadUrl || item.videoUrls?.downloadUrl
};
}
// 如果已经是新格式
return {
...item,
type: 'video'
} as VideoHistoryItem;
});
}
} catch (error) {
console.warn('Failed to load video history:', error);
}
return [];
};
/**
* 生成唯一ID
*/
export const generateHistoryId = (): string => {
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
/**
* 从历史记录中提取用户使用过的提示词(去重,最新的在前)
*/
export const extractUserPromptsFromHistory = (historyItems: HistoryItem[]): string[] => {
return historyItems
.map(item => item.prompt.trim())
.filter(prompt => prompt.length > 0)
.filter((prompt, index, arr) => arr.indexOf(prompt) === index); // 去重
};
@@ -0,0 +1,20 @@
export { GenerationHistory } from './generation-history';
export type {
BaseHistoryItem,
ImageHistoryItem,
VideoHistoryItem,
HistoryItem,
GenerationHistoryProps
} from './generation-history';
export {
saveToHistory,
loadHistory,
saveImageToHistory,
saveVideoToHistory,
loadImageHistory,
loadVideoHistory,
generateHistoryId,
extractUserPromptsFromHistory,
IMAGE_HISTORY_CACHE_KEY,
VIDEO_HISTORY_CACHE_KEY
} from './history-manager';
+13
View File
@@ -536,3 +536,16 @@ export const SettingsIcon = createIcon(
</g>
</svg>
);
export const VideoFrameIcon = createIcon(
<svg viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g id="video-frame" stroke="none" fill="currentColor">
<rect x="1" y="3" width="14" height="10" rx="1" fill="none" stroke="currentColor" strokeWidth="1.2"/>
<rect x="3" y="5" width="10" height="6" rx="0.5" fill="currentColor" opacity="0.6"/>
<circle cx="12" cy="6" r="0.8" fill="currentColor"/>
<path d="M2 14L5 11L7 13L11 9L14 12" stroke="currentColor" strokeWidth="1" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<rect x="1" y="14.5" width="14" height="1" rx="0.5" fill="currentColor"/>
<circle cx="4" cy="15" r="0.8" fill="white" stroke="currentColor" strokeWidth="0.5"/>
</g>
</svg>
);
@@ -40,11 +40,14 @@ import { isWhite, removeHexAlpha } from '../../../utils/color';
import { NO_COLOR } from '../../../constants/color';
import { Freehand } from '../../../plugins/freehand/type';
import { PopupLinkButton } from './link-button';
import { AIImageIcon, AIVideoIcon } from '../../icons';
import { AIImageIcon, AIVideoIcon, VideoFrameIcon } from '../../icons';
import { useDrawnix, DialogType } from '../../../hooks/use-drawnix';
import { useI18n } from '../../../i18n';
import { ToolButton } from '../../tool-button';
import { useGlobalMousePosition, getCurrentMousePosition } from '../../../hooks/use-global-mouse-position';
import { isVideoElement } from '../../../plugins/with-video';
import { VideoFrameSelector } from '../../video-frame-selector/video-frame-selector';
import { insertVideoFrame } from '../../../utils/video-frame';
export const PopupToolbar = () => {
const board = useBoard();
@@ -56,6 +59,10 @@ export const PopupToolbar = () => {
const [isInitialPositioning, setIsInitialPositioning] = useState(true);
const isInitialPositioningRef = useRef(isInitialPositioning);
// 视频帧选择弹窗状态
const [showVideoFrameSelector, setShowVideoFrameSelector] = useState(false);
const [selectedVideoElement, setSelectedVideoElement] = useState<PlaitElement | null>(null);
// 初始化全局鼠标位置跟踪
useGlobalMousePosition();
const open =
@@ -83,6 +90,7 @@ export const PopupToolbar = () => {
hasStrokeStyle?: boolean;
marks?: Omit<CustomText, 'text'>;
hasAIVideo?: boolean; // 是否显示AI视频生成按钮
hasVideoFrame?: boolean; // 是否显示视频帧选择按钮
} = {
fill: 'red',
};
@@ -107,6 +115,11 @@ export const PopupToolbar = () => {
PlaitDrawElement.isImage(element)
) &&
!PlaitBoard.hasBeenTextEditing(board);
// 检查是否只选中了一个视频元素
const hasVideoFrame =
selectedElements.length === 1 &&
isVideoElement(selectedElements[0]) &&
!PlaitBoard.hasBeenTextEditing(board);
state = {
...getElementState(board),
hasFill,
@@ -116,6 +129,7 @@ export const PopupToolbar = () => {
hasStrokeStyle,
hasText,
hasAIVideo,
hasVideoFrame,
};
}
useEffect(() => {
@@ -315,9 +329,50 @@ export const PopupToolbar = () => {
}}
/>
)}
{state.hasVideoFrame && (
<ToolButton
className="video-frame"
key={7}
type="icon"
icon={VideoFrameIcon}
visible={true}
title={language === 'zh' ? '视频帧选择' : 'Video Frame Selection'}
aria-label={language === 'zh' ? '视频帧选择' : 'Video Frame Selection'}
onPointerUp={() => {
// 找到选中的视频元素
const videoElement = selectedElements.find(element => isVideoElement(element));
if (videoElement) {
setSelectedVideoElement(videoElement);
setShowVideoFrameSelector(true);
}
}}
/>
)}
</Stack.Row>
</Island>
)}
{/* 视频帧选择弹窗 */}
{showVideoFrameSelector && selectedVideoElement && (
<VideoFrameSelector
visible={showVideoFrameSelector}
videoUrl={(selectedVideoElement as any).url || ''}
onClose={() => {
setShowVideoFrameSelector(false);
setSelectedVideoElement(null);
}}
onConfirm={async (frameImageDataUrl: string, timestamp: number) => {
try {
if (selectedVideoElement) {
await insertVideoFrame(board, selectedVideoElement, frameImageDataUrl, timestamp);
}
} catch (error) {
console.error('Failed to insert video frame:', error);
// 可以在这里添加错误提示
}
}}
/>
)}
</>
);
};
@@ -8,12 +8,18 @@ import { getSelectedElements, PlaitElement, getRectangleByElements, Point } from
import { defaultGeminiClient, promptForApiKey } from '../../utils/gemini-api';
import { insertImageFromUrl } from '../../data/image';
import { compressImageUrl } from '../../utils/selection-utils';
import { HistoryIcon } from 'tdesign-icons-react';
import {
GenerationHistory,
ImageHistoryItem,
VideoHistoryItem,
saveImageToHistory,
loadImageHistory,
generateHistoryId,
extractUserPromptsFromHistory
} from '../generation-history';
// 预览图缓存key
const PREVIEW_CACHE_KEY = 'ai_image_generation_preview_cache';
// 历史图片缓存key
const HISTORY_CACHE_KEY = 'ai_image_generation_history';
// 缓存数据接口
interface PreviewCache {
@@ -24,16 +30,6 @@ interface PreviewCache {
height: number | string;
}
// 历史图片接口
interface HistoryItem {
id: string;
prompt: string;
imageUrl: string;
timestamp: number;
width: number | string;
height: number | string;
}
// 保存预览缓存
const savePreviewCache = (data: PreviewCache) => {
try {
@@ -62,35 +58,6 @@ const loadPreviewCache = (): PreviewCache | null => {
return null;
};
// 保存历史记录
const saveToHistory = (item: HistoryItem) => {
try {
const existing = loadHistory();
// 添加新项目到开头,并限制最多保存50个
const updated = [item, ...existing.filter(h => h.id !== item.id)].slice(0, 50);
localStorage.setItem(HISTORY_CACHE_KEY, JSON.stringify(updated));
} catch (error) {
console.warn('Failed to save history:', error);
}
};
// 加载历史记录
const loadHistory = (): HistoryItem[] => {
try {
const cached = localStorage.getItem(HISTORY_CACHE_KEY);
if (cached) {
const data = JSON.parse(cached) as HistoryItem[];
// 过滤掉超过7天的记录
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
return data.filter(item => item.timestamp > weekAgo);
}
} catch (error) {
console.warn('Failed to load history:', error);
}
return [];
};
const getPromptExample = (language: 'zh' | 'en') => {
if (language === 'zh') {
@@ -135,8 +102,7 @@ const AIImageGeneration = ({ initialPrompt = '', initialImages = [], selectedEle
// 支持文件和URL两种类型的图片
const [uploadedImages, setUploadedImages] = useState<(File | { url: string; name: string })[]>(initialImages);
// 历史相关状态
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [showHistoryPopover, setShowHistoryPopover] = useState(false);
const [historyItems, setHistoryItems] = useState<ImageHistoryItem[]>([]);
const { appState, setAppState } = useDrawnix();
const { language } = useI18n();
@@ -204,7 +170,7 @@ const AIImageGeneration = ({ initialPrompt = '', initialImages = [], selectedEle
// 加载历史记录
useEffect(() => {
const history = loadHistory();
const history = loadImageHistory();
setHistoryItems(history);
}, []);
@@ -290,18 +256,19 @@ const AIImageGeneration = ({ initialPrompt = '', initialImages = [], selectedEle
savePreviewCache(cacheData);
// 保存到历史记录
const historyItem: HistoryItem = {
id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
const historyItem: Omit<ImageHistoryItem, 'type'> = {
id: generateHistoryId(),
prompt,
imageUrl,
timestamp: Date.now(),
width,
height
width: typeof width === 'string' ? parseInt(width) || 400 : width,
height: typeof height === 'string' ? parseInt(height) || 400 : height
};
saveToHistory(historyItem);
saveImageToHistory(historyItem);
// 更新历史列表状态
setHistoryItems(prev => [historyItem, ...prev.filter(h => h.id !== historyItem.id)].slice(0, 50));
const newHistoryItem: ImageHistoryItem = { ...historyItem, type: 'image' };
setHistoryItems(prev => [newHistoryItem, ...prev.filter(h => h.id !== historyItem.id)].slice(0, 50));
} catch (error) {
console.warn('Failed to preload image, setting anyway:', error);
// 即使预加载失败,也设置图片URL,让浏览器正常加载
@@ -318,30 +285,30 @@ const AIImageGeneration = ({ initialPrompt = '', initialImages = [], selectedEle
savePreviewCache(cacheData);
// 保存到历史记录
const historyItem: HistoryItem = {
id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
const historyItem: Omit<ImageHistoryItem, 'type'> = {
id: generateHistoryId(),
prompt,
imageUrl,
timestamp: Date.now(),
width,
height
width: typeof width === 'string' ? parseInt(width) || 400 : width,
height: typeof height === 'string' ? parseInt(height) || 400 : height
};
saveToHistory(historyItem);
saveImageToHistory(historyItem);
// 更新历史列表状态
setHistoryItems(prev => [historyItem, ...prev.filter(h => h.id !== historyItem.id)].slice(0, 50));
const newHistoryItem: ImageHistoryItem = { ...historyItem, type: 'image' };
setHistoryItems(prev => [newHistoryItem, ...prev.filter(h => h.id !== historyItem.id)].slice(0, 50));
} finally {
updateImageLoading(false);
}
};
// 从历史记录选择图片
const selectFromHistory = (historyItem: HistoryItem) => {
const selectFromHistory = (historyItem: ImageHistoryItem) => {
setPrompt(historyItem.prompt);
setWidth(historyItem.width);
setHeight(historyItem.height);
setGeneratedImage(historyItem.imageUrl);
setShowHistoryPopover(false);
// 更新预览缓存
const cacheData: PreviewCache = {
@@ -354,6 +321,14 @@ const AIImageGeneration = ({ initialPrompt = '', initialImages = [], selectedEle
savePreviewCache(cacheData);
};
// 通用历史选择处理器(兼容各种类型)
const handleSelectFromHistory = (item: ImageHistoryItem | VideoHistoryItem) => {
if (item.type === 'image') {
selectFromHistory(item as ImageHistoryItem);
}
// 图片生成组件不处理视频类型
};
// 获取合并的预设提示词(用户历史 + 默认预设)
const getMergedPresetPrompts = () => {
// 默认预设提示词
@@ -377,12 +352,8 @@ const AIImageGeneration = ({ initialPrompt = '', initialImages = [], selectedEle
'Abstract art with colorful geometric shapes'
];
// 从历史记录中提取用户使用过的提示词(去重,最新的在前)
const userPrompts = historyItems
.map(item => item.prompt.trim())
.filter(prompt => prompt.length > 0)
.filter((prompt, index, arr) => arr.indexOf(prompt) === index) // 去重
.slice(0, 8); // 最多取8个用户历史提示词
// 使用工具函数提取用户历史提示词
const userPrompts = extractUserPromptsFromHistory(historyItems).slice(0, 8);
// 合并:用户历史提示词在前,默认预设在后,总数不超过12个
const merged = [...userPrompts, ...defaultPrompts]
@@ -979,73 +950,14 @@ Description: ${prompt}`;
<div className="placeholder-text">
{language === 'zh' ? '图像将在这里显示' : 'Image will be displayed here'}
</div>
{/* 历史记录图标 - 右下角 */}
{historyItems.length > 0 && (
<div className="history-icon-container">
<button
className="history-icon-button"
onClick={() => setShowHistoryPopover(!showHistoryPopover)}
onMouseEnter={() => setShowHistoryPopover(true)}
title={language === 'zh' ? '查看生成历史' : 'View generation history'}
>
<HistoryIcon />
</button>
{showHistoryPopover && (
<div
className="history-popover"
onMouseLeave={() => setShowHistoryPopover(false)}
>
<div className="history-popover-header">
<span className="history-title">
{language === 'zh' ? '生成历史' : 'Generation History'}
</span>
<button
className="history-close-button"
onClick={() => setShowHistoryPopover(false)}
>
×
</button>
</div>
<div className="history-list">
{historyItems.slice(0, 10).map((item) => (
<div
key={item.id}
className="history-item"
onClick={() => selectFromHistory(item)}
>
<img
src={item.imageUrl}
alt="History item"
className="history-item-image"
loading="lazy"
/>
<div className="history-item-info">
<div className="history-item-prompt" title={item.prompt}>
{item.prompt.length > 30
? `${item.prompt.slice(0, 30)}...`
: item.prompt}
</div>
<div className="history-item-time">
{new Date(item.timestamp).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
{historyItems.length > 10 && (
<div className="history-more-info">
{language === 'zh'
? `还有 ${historyItems.length - 10} 张图片...`
: `${historyItems.length - 10} more images...`
}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* 统一历史记录组件 */}
<GenerationHistory
historyItems={historyItems}
onSelectFromHistory={handleSelectFromHistory}
/>
</div>
{/* 插入和清除按钮区域 */}
@@ -8,12 +8,18 @@ import { getSelectedElements, PlaitElement, getRectangleByElements, Point } from
import { defaultGeminiClient, videoGeminiClient, promptForApiKey } from '../../utils/gemini-api';
import { compressImageUrl, getInsertionPointForSelectedElements } from '../../utils/selection-utils';
import { insertVideoFromUrl } from '../../data/video';
import { HistoryIcon } from 'tdesign-icons-react';
import {
GenerationHistory,
VideoHistoryItem,
ImageHistoryItem,
saveVideoToHistory,
loadVideoHistory,
generateHistoryId,
extractUserPromptsFromHistory
} from '../generation-history';
// 预览视频缓存key
const PREVIEW_CACHE_KEY = 'ai_video_generation_preview_cache';
// 历史视频缓存key
const HISTORY_CACHE_KEY = 'ai_video_generation_history';
// 视频URL接口
interface VideoUrls {
@@ -29,16 +35,6 @@ interface PreviewCache {
sourceImage?: string;
}
// 历史视频接口
interface HistoryItem {
id: string;
prompt: string;
videoUrls: VideoUrls;
thumbnail?: string; // 视频缩略图(从第一帧提取)
timestamp: number;
sourceImage?: string; // 生成视频的源图片
}
// 保存预览缓存
const savePreviewCache = (data: PreviewCache) => {
try {
@@ -67,34 +63,6 @@ const loadPreviewCache = (): PreviewCache | null => {
return null;
};
// 保存历史记录
const saveToHistory = (item: HistoryItem) => {
try {
const existing = loadHistory();
// 添加新项目到开头,并限制最多保存20个
const updated = [item, ...existing.filter(h => h.id !== item.id)].slice(0, 20);
localStorage.setItem(HISTORY_CACHE_KEY, JSON.stringify(updated));
} catch (error) {
console.warn('Failed to save history:', error);
}
};
// 加载历史记录
const loadHistory = (): HistoryItem[] => {
try {
const cached = localStorage.getItem(HISTORY_CACHE_KEY);
if (cached) {
const data = JSON.parse(cached) as HistoryItem[];
// 过滤掉超过7天的记录
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
return data.filter(item => item.timestamp > weekAgo);
}
} catch (error) {
console.warn('Failed to load history:', error);
}
return [];
};
// 从视频生成缩略图(第一帧)
const generateVideoThumbnail = async (videoUrl: string): Promise<string | undefined> => {
return new Promise((resolve) => {
@@ -211,8 +179,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
// 只支持单张图片上传
const [uploadedImage, setUploadedImage] = useState<File | { url: string; name: string } | null>(initialImage || null);
// 历史相关状态
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [showHistoryPopover, setShowHistoryPopover] = useState(false);
const [historyItems, setHistoryItems] = useState<VideoHistoryItem[]>([]);
const { appState, setAppState } = useDrawnix();
const { language } = useI18n();
@@ -279,7 +246,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
// 加载历史记录
useEffect(() => {
const history = loadHistory();
const history = loadVideoHistory();
setHistoryItems(history);
}, []);
@@ -364,28 +331,37 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
const thumbnailPromise = generateVideoThumbnail(videoUrls.previewUrl);
// 保存到历史记录(先保存基本信息)
const historyItem: HistoryItem = {
id: `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
const historyItem: Omit<VideoHistoryItem, 'type'> = {
id: generateHistoryId(),
prompt,
videoUrls,
timestamp: Date.now(),
sourceImage: uploadedImage instanceof File ? URL.createObjectURL(uploadedImage) : uploadedImage?.url
imageUrl: '', // 先置空,等待缩略图生成
width: 400, // 默认尺寸
height: 225, // 默认尺寸
previewUrl: videoUrls.previewUrl,
downloadUrl: videoUrls.downloadUrl,
timestamp: Date.now()
};
// 等待缩略图生成完成,然后更新历史记录
try {
const thumbnail = await thumbnailPromise;
if (thumbnail) {
historyItem.thumbnail = thumbnail;
historyItem.imageUrl = thumbnail; // 使用缩略图作为 imageUrl
} else {
// 如果缩略图生成失败,使用预览URL
historyItem.imageUrl = videoUrls.previewUrl;
}
} catch (error) {
console.warn('Failed to generate video thumbnail:', error);
// 如果缩略图生成失败,使用预览URL
historyItem.imageUrl = videoUrls.previewUrl;
}
saveToHistory(historyItem);
saveVideoToHistory(historyItem);
// 更新历史列表状态
setHistoryItems(prev => [historyItem, ...prev.filter(h => h.id !== historyItem.id)].slice(0, 20));
const newHistoryItem: VideoHistoryItem = { ...historyItem, type: 'video' };
setHistoryItems(prev => [newHistoryItem, ...prev.filter(h => h.id !== historyItem.id)].slice(0, 50));
} catch (error) {
console.warn('Failed to set generated video:', error);
setGeneratedVideo(videoUrls);
@@ -395,26 +371,35 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
};
// 从历史记录选择视频
const selectFromHistory = (historyItem: HistoryItem) => {
const selectFromHistory = (historyItem: VideoHistoryItem) => {
setPrompt(historyItem.prompt);
setGeneratedVideo(historyItem.videoUrls);
if (historyItem.sourceImage) {
setUploadedImage({ url: historyItem.sourceImage, name: 'History Image' });
}
setShowHistoryPopover(false);
setGeneratedVideo({
previewUrl: historyItem.previewUrl,
downloadUrl: historyItem.downloadUrl || historyItem.previewUrl
});
// 选择历史记录时清除错误状态
setError(null);
// 更新预览缓存
const cacheData: PreviewCache = {
prompt: historyItem.prompt,
generatedVideo: historyItem.videoUrls,
timestamp: Date.now(),
sourceImage: historyItem.sourceImage
generatedVideo: {
previewUrl: historyItem.previewUrl,
downloadUrl: historyItem.downloadUrl || historyItem.previewUrl
},
timestamp: Date.now()
};
savePreviewCache(cacheData);
};
// 通用历史选择处理器(兼容各种类型)
const handleSelectFromHistory = (item: VideoHistoryItem | ImageHistoryItem) => {
if (item.type === 'video') {
selectFromHistory(item as VideoHistoryItem);
}
// 视频生成组件不处理图片类型
};
// 获取合并的预设提示词(用户历史 + 默认预设)
const getMergedPresetPrompts = () => {
// 默认预设提示词
@@ -438,12 +423,8 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
'Create subtle depth of field changes in the scene'
];
// 从历史记录中提取用户使用过的提示词(去重,最新的在前)
const userPrompts = historyItems
.map(item => item.prompt.trim())
.filter(prompt => prompt.length > 0)
.filter((prompt, index, arr) => arr.indexOf(prompt) === index) // 去重
.slice(0, 8); // 最多取8个用户历史提示词
// 使用工具函数提取用户历史提示词
const userPrompts = extractUserPromptsFromHistory(historyItems).slice(0, 8);
// 合并:用户历史提示词在前,默认预设在后,总数不超过12个
const merged = [...userPrompts, ...defaultPrompts]
@@ -849,99 +830,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
{/* 预览区域 */}
<div className="preview-section">
<div className="image-preview-container" style={{ position: 'relative' }}>
{/* 历史记录图标 - 固定在右下角 */}
{historyItems.length > 0 && (
<div
className="history-icon-container"
style={{
position: 'absolute',
bottom: '8px',
right: '8px',
zIndex: 10
}}
>
<button
className="history-icon-button"
onClick={() => setShowHistoryPopover(!showHistoryPopover)}
onMouseEnter={() => setShowHistoryPopover(true)}
title={language === 'zh' ? '查看生成历史' : 'View generation history'}
>
<HistoryIcon />
</button>
{showHistoryPopover && (
<div
className="history-popover"
onMouseLeave={() => setShowHistoryPopover(false)}
style={{
position: 'absolute',
bottom: '100%',
right: '0',
marginBottom: '8px'
}}
>
<div className="history-popover-header">
<span className="history-title">
{language === 'zh' ? '生成历史' : 'Generation History'}
</span>
<button
className="history-close-button"
onClick={() => setShowHistoryPopover(false)}
>
×
</button>
</div>
<div className="history-list">
{historyItems.slice(0, 10).map((item) => (
<div
key={item.id}
className="history-item"
onClick={() => selectFromHistory(item)}
>
<div className="history-item-media">
{item.thumbnail ? (
<div className="history-video-thumbnail">
<img
src={item.thumbnail}
alt="Video thumbnail"
className="history-item-image"
loading="lazy"
/>
<div className="video-play-overlay">
<div className="play-icon">▶</div>
</div>
</div>
) : (
<div className="history-item-image history-video-placeholder">
<div className="placeholder-icon">🎬</div>
</div>
)}
</div>
<div className="history-item-info">
<div className="history-item-prompt" title={item.prompt}>
{item.prompt.length > 25
? `${item.prompt.slice(0, 25)}...`
: item.prompt}
</div>
<div className="history-item-time">
{new Date(item.timestamp).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
{historyItems.length > 10 && (
<div className="history-more-info">
{language === 'zh'
? `还有 ${historyItems.length - 10} 个视频...`
: `${historyItems.length - 10} more videos...`
}
</div>
)}
</div>
)}
</div>
)}
<div className="image-preview-container" >
{isGenerating ? (
<div className="preview-loading">
@@ -984,6 +873,12 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
)}
</div>
{/* 统一历史记录组件 */}
<GenerationHistory
historyItems={historyItems}
onSelectFromHistory={handleSelectFromHistory}
position={{ bottom: '60px', right: '8px' }}
/>
{/* 插入、下载和清除按钮区域 */}
{generatedVideo && (
<div className="section-actions">
@@ -213,6 +213,10 @@ export const TTDDialog = ({ container }: { container: HTMLElement | null }) => {
mode="modal"
preventScrollThrough={true}
closeBtn={true}
style={{
backgroundColor: '#ffffff',
color: '#333333'
} as React.CSSProperties}
>
<AIImageGeneration
initialPrompt={aiImageData.initialPrompt}
@@ -254,6 +258,10 @@ export const TTDDialog = ({ container }: { container: HTMLElement | null }) => {
mode="modal"
preventScrollThrough={true}
closeBtn={true}
style={{
backgroundColor: '#ffffff',
color: '#333333'
} as React.CSSProperties}
>
<AIVideoGeneration
initialPrompt={aiVideoData.initialPrompt}
@@ -0,0 +1,227 @@
.video-frame-selector {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
// Force light theme - override dark mode styles
background-color: #ffffff !important;
color: #333333 !important;
// Force light theme for all child elements
* {
color: #333333 !important;
}
input, textarea, button {
background-color: #ffffff !important;
color: #333333 !important;
border-color: #d1d5db !important;
}
.frame-preview {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
border: 2px dashed #ddd;
border-radius: 8px;
background-color: #f9f9f9;
.loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #666;
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 14px;
}
}
.frame-image-container {
width: 100%;
display: flex;
justify-content: center;
.frame-image {
max-width: 100%;
max-height: 300px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
}
.time-controls {
display: flex;
flex-direction: column;
gap: 16px;
.time-display {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
font-family: 'Courier New', monospace;
font-size: 16px;
font-weight: 500;
.current-time {
color: #1890ff;
}
.separator {
color: #666;
}
.total-time {
color: #666;
}
}
.progress-container {
width: 100%;
.progress-slider {
width: 100%;
height: 6px;
background: #ddd;
border-radius: 3px;
outline: none;
-webkit-appearance: none;
appearance: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: #1890ff;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
&:hover {
background: #40a9ff;
}
}
&::-moz-range-thumb {
width: 18px;
height: 18px;
background: #1890ff;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
&:hover {
background: #40a9ff;
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&::-webkit-slider-thumb {
cursor: not-allowed;
}
&::-moz-range-thumb {
cursor: not-allowed;
}
}
}
}
.time-input-container {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.time-input-label {
font-size: 14px;
color: #666;
}
.time-input {
width: 100px;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
font-size: 14px;
&:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
&:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
}
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
.action-button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&.secondary {
background-color: #f5f5f5;
color: #666;
&:hover {
background-color: #e6e6e6;
}
}
&.primary {
background-color: #1890ff;
color: white;
&:hover:not(:disabled) {
background-color: #40a9ff;
}
&:disabled {
background-color: #d9d9d9;
cursor: not-allowed;
}
}
}
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@@ -0,0 +1,222 @@
import React, { useState, useRef, useEffect } from 'react';
import { Dialog } from 'tdesign-react';
import { useI18n } from '../../i18n';
import './video-frame-selector.scss';
export interface VideoFrameSelectorProps {
visible: boolean;
videoUrl: string;
onClose: () => void;
onConfirm: (frameImageDataUrl: string, timestamp: number) => void;
}
export const VideoFrameSelector: React.FC<VideoFrameSelectorProps> = ({
visible,
videoUrl,
onClose,
onConfirm
}) => {
const { language } = useI18n();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [frameImage, setFrameImage] = useState<string>('');
// 初始化时设置为最后一帧
useEffect(() => {
if (visible && videoUrl) {
setIsLoading(true);
setFrameImage('');
}
}, [visible, videoUrl]);
// 视频加载完成后设置到最后一帧
const handleVideoLoaded = () => {
const video = videoRef.current;
if (video) {
setDuration(video.duration);
// 设置到最后一帧(稍微提前一点避免加载问题)
const lastFrameTime = Math.max(0, video.duration - 0.1);
setCurrentTime(lastFrameTime);
video.currentTime = lastFrameTime;
}
};
// 视频时间更新时生成帧图片
const handleTimeUpdate = () => {
generateFrameImage();
setIsLoading(false);
};
// 生成当前帧的图片
const generateFrameImage = () => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置画布尺寸匹配视频
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 绘制当前帧
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 转换为 data URL
const imageDataUrl = canvas.toDataURL('image/png');
setFrameImage(imageDataUrl);
};
// 拖拽进度条
const handleSliderChange = (value: number) => {
const video = videoRef.current;
if (video && duration > 0) {
const newTime = (value / 100) * duration;
setCurrentTime(newTime);
video.currentTime = newTime;
}
};
// 输入时间
const handleTimeInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current;
if (!video || duration === 0) return;
const inputValue = parseFloat(event.target.value);
if (isNaN(inputValue)) return;
const newTime = Math.max(0, Math.min(duration, inputValue));
setCurrentTime(newTime);
video.currentTime = newTime;
};
// 确认选择
const handleConfirm = () => {
if (frameImage) {
onConfirm(frameImage, currentTime);
onClose();
}
};
// 格式化时间显示
const formatTime = (time: number): string => {
const minutes = Math.floor(time / 60);
const seconds = (time % 60).toFixed(1);
return `${minutes}:${seconds.padStart(4, '0')}`;
};
return (
<Dialog
visible={visible}
onClose={onClose}
header={language === 'zh' ? '选择视频帧' : 'Select Video Frame'}
width={600}
destroyOnClose
style={{
backgroundColor: '#ffffff',
color: '#333333'
} as React.CSSProperties}
>
<div className="video-frame-selector">
{/* 隐藏的视频元素用于帧提取 */}
<video
ref={videoRef}
src={videoUrl}
style={{ display: 'none' }}
muted
playsInline
onLoadedData={handleVideoLoaded}
onSeeked={handleTimeUpdate}
onTimeUpdate={handleTimeUpdate}
/>
{/* 隐藏的画布用于生成帧图片 */}
<canvas
ref={canvasRef}
style={{ display: 'none' }}
/>
{/* 帧预览区域 */}
<div className="frame-preview">
{isLoading ? (
<div className="loading-placeholder">
<div className="loading-spinner"></div>
<div className="loading-text">
{language === 'zh' ? '加载视频中...' : 'Loading video...'}
</div>
</div>
) : (
<div className="frame-image-container">
<img
src={frameImage}
alt="Video frame"
className="frame-image"
/>
</div>
)}
</div>
{/* 时间控制区域 */}
<div className="time-controls">
<div className="time-display">
<span className="current-time">{formatTime(currentTime)}</span>
<span className="separator">/</span>
<span className="total-time">{formatTime(duration)}</span>
</div>
{/* 进度条 */}
<div className="progress-container">
<input
type="range"
min={0}
max={100}
value={duration > 0 ? (currentTime / duration) * 100 : 0}
onChange={(e) => handleSliderChange(Number(e.target.value))}
className="progress-slider"
disabled={isLoading}
/>
</div>
{/* 精确时间输入 */}
<div className="time-input-container">
<label className="time-input-label">
{language === 'zh' ? '精确时间 (秒):' : 'Precise time (seconds):'}
</label>
<input
type="number"
min={0}
max={duration}
step={0.1}
value={currentTime.toFixed(1)}
onChange={handleTimeInputChange}
className="time-input"
disabled={isLoading}
/>
</div>
</div>
{/* 操作按钮 */}
<div className="actions">
<button
className="action-button secondary"
onClick={onClose}
>
{language === 'zh' ? '取消' : 'Cancel'}
</button>
<button
className="action-button primary"
onClick={handleConfirm}
disabled={isLoading || !frameImage}
>
{language === 'zh' ? '确认插入' : 'Confirm Insert'}
</button>
</div>
</div>
</Dialog>
);
};
+30 -2
View File
@@ -20,8 +20,16 @@ export interface VideoDimensions {
* @param videoUrl 视频URL
* @returns Promise<VideoDimensions> 视频的宽度和高度
*/
// 防止重复调用的缓存
const dimensionsCache = new Map<string, Promise<VideoDimensions>>();
export const getVideoDimensions = (videoUrl: string): Promise<VideoDimensions> => {
return new Promise((resolve, reject) => {
// 检查缓存
if (dimensionsCache.has(videoUrl)) {
return dimensionsCache.get(videoUrl)!;
}
const promise = new Promise<VideoDimensions>((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
@@ -29,8 +37,17 @@ export const getVideoDimensions = (videoUrl: string): Promise<VideoDimensions> =
// 设置超时时间,防止长时间等待
const timeout = setTimeout(() => {
console.warn('Video dimensions loading timeout for:', videoUrl);
video.src = '';
reject(new Error('Video dimensions loading timeout'));
// 从缓存中移除超时的URL
dimensionsCache.delete(videoUrl);
// 超时时返回默认尺寸而不是抛出错误
resolve({
width: 400,
height: 225
});
}, 10000); // 10秒超时
video.onloadedmetadata = () => {
@@ -51,6 +68,10 @@ export const getVideoDimensions = (videoUrl: string): Promise<VideoDimensions> =
} catch (error) {
clearTimeout(timeout);
video.src = '';
// 从缓存中移除失败的URL
dimensionsCache.delete(videoUrl);
reject(error);
}
};
@@ -60,6 +81,9 @@ export const getVideoDimensions = (videoUrl: string): Promise<VideoDimensions> =
console.warn('Failed to load video metadata for dimensions:', error);
video.src = '';
// 从缓存中移除失败的URL
dimensionsCache.delete(videoUrl);
// 如果视频加载失败,返回默认尺寸而不是抛出错误
resolve({
width: 400,
@@ -70,6 +94,10 @@ export const getVideoDimensions = (videoUrl: string): Promise<VideoDimensions> =
// 开始加载视频元数据
video.src = videoUrl;
});
// 将Promise添加到缓存
dimensionsCache.set(videoUrl, promise);
return promise;
};
/**
+64
View File
@@ -0,0 +1,64 @@
import { PlaitBoard, PlaitElement, getRectangleByElements, Point } from '@plait/core';
import { DrawTransforms } from '@plait/draw';
/**
* 将视频帧图片插入到指定视频元素的底下
* @param board PlaitBoard实例
* @param videoElement 视频元素
* @param frameImageDataUrl 帧图片的data URL
* @param timestamp 帧的时间戳(秒)
*/
export const insertVideoFrame = async (
board: PlaitBoard,
videoElement: PlaitElement,
frameImageDataUrl: string,
timestamp: number
): Promise<void> => {
if (!board || !videoElement) {
throw new Error('Board and video element are required');
}
try {
// 获取视频元素的位置和尺寸
const videoRect = getRectangleByElements(board, [videoElement], false);
// 计算插入位置:视频底下,左对齐,间距20px
const insertionPoint: Point = [
videoRect.x, // 左对齐
videoRect.y + videoRect.height + 20 // 底下20px间距
];
console.log('Inserting video frame at position:', insertionPoint, 'timestamp:', timestamp);
// 将data URL转换为对象,准备插入
const frameImageElement = {
url: frameImageDataUrl,
width: Math.min(videoRect.width, 400), // 限制最大宽度为400或视频宽度
height: Math.min(videoRect.width * 0.5625, 225), // 保持16:9比例,限制最大高度
// 添加自定义属性标识这是视频帧
isVideoFrame: true,
videoTimestamp: timestamp,
sourceVideoId: (videoElement as any).id || 'unknown',
};
// 插入图片元素
DrawTransforms.insertImage(board, frameImageElement, insertionPoint);
console.log('Video frame inserted successfully');
} catch (error) {
console.error('Failed to insert video frame:', error);
throw new Error(`Video frame insertion failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* 格式化时间戳为可读字符串
* @param timestamp 时间戳(秒)
* @returns 格式化的时间字符串 (mm:ss.s)
*/
export const formatTimestamp = (timestamp: number): string => {
const minutes = Math.floor(timestamp / 60);
const seconds = (timestamp % 60).toFixed(1);
return `${minutes}:${seconds.padStart(4, '0')}`;
};