diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 5c617c09..1a01f1c0 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -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; diff --git a/docs/需求.md b/docs/需求.md index 4c22ad93..de8c6112 100644 --- a/docs/需求.md +++ b/docs/需求.md @@ -20,4 +20,7 @@ ## 视频 点击单个图片的popup-toolbar(只有图片才需要),添加一个AI生成视频按钮,点击AI生成视频按钮打开类似'/Users/ljq/code/self/drawn ix/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx'的弹窗,左边表单的上传图片只支持一张图,右边是生成的 - 视频预览 \ No newline at end of file + 视频预览 + + +对于视频元素,点击的popup-toolbar出现选择视频帧的图标,点击图标打开视频帧选择弹窗.视频帧选择弹窗上方默认是该视频最后一帧的图片,图片下有进度条,可以拖动或输入选择指定位置的帧图片,点击确认,插入到画布中该视频的底下。 \ No newline at end of file diff --git a/packages/drawnix/src/components/generation-history/generation-history.scss b/packages/drawnix/src/components/generation-history/generation-history.scss new file mode 100644 index 00000000..d7711b3e --- /dev/null +++ b/packages/drawnix/src/components/generation-history/generation-history.scss @@ -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; + } + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/drawnix/src/components/generation-history/generation-history.tsx b/packages/drawnix/src/components/generation-history/generation-history.tsx new file mode 100644 index 00000000..758e10e3 --- /dev/null +++ b/packages/drawnix/src/components/generation-history/generation-history.tsx @@ -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 = ({ + 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 ( +
{ + onSelectFromHistory(item); + setShowHistoryPopover(false); + }} + > +
+ {item.type === 'image' ? ( + History item + ) : ( + // 视频类型,使用统一的 imageUrl 字段 + item.imageUrl ? ( +
+ Video thumbnail +
+
▶
+
+
+ ) : ( +
+
🎬
+
+ ) + )} +
+
+
+ {item.prompt.length > 25 + ? `${item.prompt.slice(0, 25)}...` + : item.prompt} +
+
+ {new Date(item.timestamp).toLocaleDateString()} +
+
+
+ ); + }; + + return ( +
+ + {showHistoryPopover && ( +
setShowHistoryPopover(false)} + > +
+ + {language === 'zh' ? '生成历史' : 'Generation History'} + + +
+
+ {historyItems.slice(0, 10).map(renderHistoryItem)} +
+ {historyItems.length > 10 && ( +
+ {language === 'zh' + ? `还有 ${historyItems.length - 10} 个项目...` + : `${historyItems.length - 10} more items...` + } +
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/packages/drawnix/src/components/generation-history/history-manager.ts b/packages/drawnix/src/components/generation-history/history-manager.ts new file mode 100644 index 00000000..1752e3ae --- /dev/null +++ b/packages/drawnix/src/components/generation-history/history-manager.ts @@ -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 = (item: T, cacheKey: string): void => { + try { + const existing = loadHistory(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 = (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): void => { + const imageItem: ImageHistoryItem = { + ...item, + type: 'image' + }; + saveToHistory(imageItem, IMAGE_HISTORY_CACHE_KEY); +}; + +/** + * 保存视频历史记录 + */ +export const saveVideoToHistory = (item: Omit): 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); // 去重 +}; \ No newline at end of file diff --git a/packages/drawnix/src/components/generation-history/index.ts b/packages/drawnix/src/components/generation-history/index.ts new file mode 100644 index 00000000..cfaf72d9 --- /dev/null +++ b/packages/drawnix/src/components/generation-history/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/drawnix/src/components/icons.tsx b/packages/drawnix/src/components/icons.tsx index e3ef83c3..d63204d4 100644 --- a/packages/drawnix/src/components/icons.tsx +++ b/packages/drawnix/src/components/icons.tsx @@ -536,3 +536,16 @@ export const SettingsIcon = createIcon( ); + +export const VideoFrameIcon = createIcon( + + + + + + + + + + +); diff --git a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx index 7a7d4dfb..381b07d7 100644 --- a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx +++ b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx @@ -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(null); + // 初始化全局鼠标位置跟踪 useGlobalMousePosition(); const open = @@ -83,6 +90,7 @@ export const PopupToolbar = () => { hasStrokeStyle?: boolean; marks?: Omit; 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 && ( + { + // 找到选中的视频元素 + const videoElement = selectedElements.find(element => isVideoElement(element)); + if (videoElement) { + setSelectedVideoElement(videoElement); + setShowVideoFrameSelector(true); + } + }} + /> + )} )} + + {/* 视频帧选择弹窗 */} + {showVideoFrameSelector && selectedVideoElement && ( + { + 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); + // 可以在这里添加错误提示 + } + }} + /> + )} ); }; diff --git a/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx b/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx index 63370d72..cd71212b 100644 --- a/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx +++ b/packages/drawnix/src/components/ttd-dialog/ai-image-generation.tsx @@ -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([]); - const [showHistoryPopover, setShowHistoryPopover] = useState(false); + const [historyItems, setHistoryItems] = useState([]); 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 = { + 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 = { + 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}`;
{language === 'zh' ? '图像将在这里显示' : 'Image will be displayed here'}
- {/* 历史记录图标 - 右下角 */} - {historyItems.length > 0 && ( -
- - {showHistoryPopover && ( -
setShowHistoryPopover(false)} - > -
- - {language === 'zh' ? '生成历史' : 'Generation History'} - - -
-
- {historyItems.slice(0, 10).map((item) => ( -
selectFromHistory(item)} - > - History item -
-
- {item.prompt.length > 30 - ? `${item.prompt.slice(0, 30)}...` - : item.prompt} -
-
- {new Date(item.timestamp).toLocaleDateString()} -
-
-
- ))} -
- {historyItems.length > 10 && ( -
- {language === 'zh' - ? `还有 ${historyItems.length - 10} 张图片...` - : `${historyItems.length - 10} more images...` - } -
- )} -
- )} -
- )} )} + {/* 统一历史记录组件 */} + + {/* 插入和清除按钮区域 */} diff --git a/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx b/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx index 4b6fcb0f..ce61c877 100644 --- a/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx +++ b/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx @@ -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 => { return new Promise((resolve) => { @@ -211,8 +179,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati // 只支持单张图片上传 const [uploadedImage, setUploadedImage] = useState(initialImage || null); // 历史相关状态 - const [historyItems, setHistoryItems] = useState([]); - const [showHistoryPopover, setShowHistoryPopover] = useState(false); + const [historyItems, setHistoryItems] = useState([]); 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 = { + 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 {/* 预览区域 */}
-
- {/* 历史记录图标 - 固定在右下角 */} - {historyItems.length > 0 && ( -
- - {showHistoryPopover && ( -
setShowHistoryPopover(false)} - style={{ - position: 'absolute', - bottom: '100%', - right: '0', - marginBottom: '8px' - }} - > -
- - {language === 'zh' ? '生成历史' : 'Generation History'} - - -
-
- {historyItems.slice(0, 10).map((item) => ( -
selectFromHistory(item)} - > -
- {item.thumbnail ? ( -
- Video thumbnail -
-
▶
-
-
- ) : ( -
-
🎬
-
- )} -
-
-
- {item.prompt.length > 25 - ? `${item.prompt.slice(0, 25)}...` - : item.prompt} -
-
- {new Date(item.timestamp).toLocaleDateString()} -
-
-
- ))} -
- {historyItems.length > 10 && ( -
- {language === 'zh' - ? `还有 ${historyItems.length - 10} 个视频...` - : `${historyItems.length - 10} more videos...` - } -
- )} -
- )} -
- )} +
{isGenerating ? (
@@ -984,6 +873,12 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati )}
+ {/* 统一历史记录组件 */} + {/* 插入、下载和清除按钮区域 */} {generatedVideo && (
diff --git a/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx b/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx index f0142cca..602d290a 100644 --- a/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx +++ b/packages/drawnix/src/components/ttd-dialog/ttd-dialog.tsx @@ -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} > { mode="modal" preventScrollThrough={true} closeBtn={true} + style={{ + backgroundColor: '#ffffff', + color: '#333333' + } as React.CSSProperties} > void; + onConfirm: (frameImageDataUrl: string, timestamp: number) => void; +} + +export const VideoFrameSelector: React.FC = ({ + visible, + videoUrl, + onClose, + onConfirm +}) => { + const { language } = useI18n(); + const videoRef = useRef(null); + const canvasRef = useRef(null); + + const [isLoading, setIsLoading] = useState(true); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + const [frameImage, setFrameImage] = useState(''); + + // 初始化时设置为最后一帧 + 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) => { + 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 ( + +
+ {/* 隐藏的视频元素用于帧提取 */} +
+
+ ); +}; \ No newline at end of file diff --git a/packages/drawnix/src/data/video.ts b/packages/drawnix/src/data/video.ts index 179e371d..62a90afb 100644 --- a/packages/drawnix/src/data/video.ts +++ b/packages/drawnix/src/data/video.ts @@ -20,8 +20,16 @@ export interface VideoDimensions { * @param videoUrl 视频URL * @returns Promise 视频的宽度和高度 */ +// 防止重复调用的缓存 +const dimensionsCache = new Map>(); + export const getVideoDimensions = (videoUrl: string): Promise => { - return new Promise((resolve, reject) => { + // 检查缓存 + if (dimensionsCache.has(videoUrl)) { + return dimensionsCache.get(videoUrl)!; + } + + const promise = new Promise((resolve, reject) => { const video = document.createElement('video'); video.crossOrigin = 'anonymous'; video.muted = true; @@ -29,8 +37,17 @@ export const getVideoDimensions = (videoUrl: string): Promise = // 设置超时时间,防止长时间等待 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 = } catch (error) { clearTimeout(timeout); video.src = ''; + + // 从缓存中移除失败的URL + dimensionsCache.delete(videoUrl); + reject(error); } }; @@ -60,6 +81,9 @@ export const getVideoDimensions = (videoUrl: string): Promise = 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 = // 开始加载视频元数据 video.src = videoUrl; }); + + // 将Promise添加到缓存 + dimensionsCache.set(videoUrl, promise); + return promise; }; /** diff --git a/packages/drawnix/src/utils/video-frame.ts b/packages/drawnix/src/utils/video-frame.ts new file mode 100644 index 00000000..831e03e7 --- /dev/null +++ b/packages/drawnix/src/utils/video-frame.ts @@ -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 => { + 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')}`; +}; \ No newline at end of file