feat(video-frame): 添加视频帧选择功能及历史记录组件
实现视频帧选择功能,包括: 1. 添加视频帧选择弹窗组件,支持拖动进度条或输入时间选择指定帧 2. 新增视频帧插入工具,可将选定帧插入画布中视频元素下方 3. 重构历史记录功能为通用组件,支持图片和视频历史统一管理 4. 优化视频尺寸获取逻辑,添加缓存机制提升性能 5. 强制全局UI使用浅色主题,确保视觉一致性
This commit is contained in:
1 parent
a61e619748
commit
035c8dba6f
15 files changed
+1298
-295
No files matched your search
@@ -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
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')}`;
|
||||
};
|
||||
Reference in new issue
Block a user