├── components ├── ComparisonPreview.tsx ├── icons │ ├── SettingsIcon.tsx │ ├── MinusIcon.tsx │ ├── PlusIcon.tsx │ ├── XMarkIcon.tsx │ ├── CheckIcon.tsx │ ├── ChevronDownIcon.tsx │ ├── ChevronLeftIcon.tsx │ ├── ChevronRightIcon.tsx │ ├── UndoIcon.tsx │ ├── DownloadIcon.tsx │ ├── SearchIcon.tsx │ ├── VideoPlayIcon.tsx │ ├── XCircleIcon.tsx │ ├── SwitchHorizontalIcon.tsx │ ├── FitScreenIcon.tsx │ ├── UploadIcon.tsx │ ├── VideoIcon.tsx │ ├── LightBulbIcon.tsx │ ├── RedoIcon.tsx │ ├── ResetIcon.tsx │ ├── BookOpenIcon.tsx │ ├── EditIcon.tsx │ ├── CollectionIcon.tsx │ ├── TagIcon.tsx │ ├── SparklesIcon.tsx │ ├── TrashIcon.tsx │ ├── StarIcon.tsx │ ├── AspectRatioIcons.tsx │ ├── KeyIcon.tsx │ └── ChevronIcons.tsx ├── EmptyState.tsx ├── LoadingState.tsx ├── ImageGrid.tsx ├── PromptBuilder.tsx ├── ImageCard.tsx ├── ApiKeyModal.tsx ├── TagManager.tsx ├── Header.tsx ├── ComicPanelEditorModal.tsx ├── IllustratedWiki.tsx ├── ImagePreview.tsx ├── ImageUploader.tsx ├── TextToImage.tsx ├── ImageToVideo.tsx ├── InpaintingModal.tsx ├── ImportExportModal.tsx ├── ImageToImage.tsx ├── History.tsx └── ComicStrip.tsx ├── .env.local.example ├── metadata.json ├── index.tsx ├── package.json ├── vite.config.ts ├── tsconfig.json ├── LICENSE ├── .gitignore ├── utils ├── imageUtils.ts ├── videoUtils.ts └── promptUtils.ts ├── index.html ├── types.ts ├── README.md └── services └── historyService.ts /components/ComparisonPreview.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/icons/SettingsIcon.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # 重命名此文件为 .env.local 并设置您的实际 API 密钥 2 | VITE_GEMINI_API_KEY=your_actual_api_key_here -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Image Studio", 3 | "description": "从文字到杰作,从静态到动态。Image Studio 是您的终极 AI 视觉创作套件,集图解、绘画、编辑和视频生成于一体。释放想象,创造非凡。", 4 | "requestFramePermissions": [] 5 | } -------------------------------------------------------------------------------- /index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom/client'; 4 | import App from './App'; 5 | 6 | const rootElement = document.getElementById('root'); 7 | if (!rootElement) { 8 | throw new Error("Could not find root element to mount to"); 9 | } 10 | 11 | const root = ReactDOM.createRoot(rootElement); 12 | root.render( 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-studio", 3 | "private": true, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^19.1.1", 14 | "react-dom": "^19.1.1", 15 | "@google/genai": "^1.16.0" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^22.14.0", 19 | "typescript": "~5.8.2", 20 | "vite": "^6.2.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /components/icons/MinusIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const MinusIcon: React.FC = (props) => ( 6 | 14 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /components/icons/PlusIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const PlusIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/XMarkIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const XMarkIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const CheckIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/ChevronDownIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const ChevronDownIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); -------------------------------------------------------------------------------- /components/icons/ChevronLeftIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const ChevronLeftIcon: React.FC = (props) => ( 6 | 14 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /components/icons/ChevronRightIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const ChevronRightIcon: React.FC = (props) => ( 6 | 14 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /components/icons/UndoIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const UndoIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); -------------------------------------------------------------------------------- /components/icons/DownloadIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const DownloadIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const SearchIcon: React.FC = (props) => ( 6 | 14 | 19 | 20 | ); -------------------------------------------------------------------------------- /components/icons/VideoPlayIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const VideoPlayIcon: React.FC = (props) => ( 7 | 13 | 18 | 19 | ); -------------------------------------------------------------------------------- /components/icons/XCircleIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const XCircleIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/SwitchHorizontalIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const SwitchHorizontalIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig, loadEnv } from 'vite'; 3 | 4 | export default defineConfig(({ mode }) => { 5 | const env = loadEnv(mode, '.', ''); 6 | return { 7 | define: { 8 | 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), 9 | 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), 10 | 'import.meta.env.VITE_GEMINI_API_KEY': JSON.stringify(env.VITE_GEMINI_API_KEY) 11 | }, 12 | resolve: { 13 | alias: { 14 | '@': path.resolve(__dirname, '.'), 15 | } 16 | } 17 | }; 18 | }); -------------------------------------------------------------------------------- /components/icons/FitScreenIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const FitScreenIcon: React.FC = (props) => ( 6 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /components/icons/UploadIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const UploadIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface EmptyStateProps { 5 | icon?: string; 6 | title?: string; 7 | message?: string; 8 | } 9 | 10 | export const EmptyState: React.FC = ({ 11 | icon = "🤔", 12 | title = "还没有生成任何卡片", 13 | message = "在上方输入您想了解的概念,点击“生成图解”来创建图文卡片" 14 | }) => { 15 | return ( 16 |
17 |
{icon}
18 |

{title}

19 |

20 | {message} 21 |

22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "experimentalDecorators": true, 5 | "useDefineForClassFields": false, 6 | "module": "ESNext", 7 | "lib": [ 8 | "ES2022", 9 | "DOM", 10 | "DOM.Iterable" 11 | ], 12 | "skipLibCheck": true, 13 | "types": [ 14 | "node" 15 | ], 16 | "moduleResolution": "bundler", 17 | "isolatedModules": true, 18 | "moduleDetection": "force", 19 | "allowJs": true, 20 | "jsx": "react-jsx", 21 | "paths": { 22 | "@/*": [ 23 | "./*" 24 | ] 25 | }, 26 | "allowImportingTsExtensions": true, 27 | "noEmit": true 28 | } 29 | } -------------------------------------------------------------------------------- /components/icons/VideoIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const VideoIcon: React.FC = (props) => ( 6 | 14 | 19 | 20 | ); -------------------------------------------------------------------------------- /components/icons/LightBulbIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const LightBulbIcon: React.FC = (props) => ( 6 | 14 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /components/icons/RedoIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const RedoIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/ResetIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const ResetIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); -------------------------------------------------------------------------------- /components/icons/BookOpenIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const BookOpenIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); -------------------------------------------------------------------------------- /components/icons/EditIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const EditIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/CollectionIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const CollectionIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); -------------------------------------------------------------------------------- /components/icons/TagIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const TagIcon: React.FC = (props) => ( 6 | 14 | 19 | 24 | 25 | ); -------------------------------------------------------------------------------- /components/icons/SparklesIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const SparklesIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /components/icons/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const TrashIcon: React.FC = (props) => ( 6 | 14 | 19 | 20 | ); -------------------------------------------------------------------------------- /components/icons/StarIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps { 5 | filled?: boolean; 6 | } 7 | 8 | export const StarIcon: React.FC = ({ filled = false, ...props }) => ( 9 | 17 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /components/icons/AspectRatioIcons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const SquareIcon: React.FC = (props) => ( 6 | 7 | 8 | 9 | ); 10 | 11 | export const RectangleHorizontalIcon: React.FC = (props) => ( 12 | 13 | 14 | 15 | ); 16 | 17 | export const RectangleVerticalIcon: React.FC = (props) => ( 18 | 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Image Studio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | pnpm-debug.log* 7 | 8 | # Build outputs 9 | dist/ 10 | build/ 11 | .out/ 12 | 13 | # Environment files 14 | .env* 15 | !.env.local.example 16 | 17 | # IDE and editor files 18 | .vscode/ 19 | .idea/ 20 | *.swp 21 | *.swo 22 | *~ 23 | .DS_Store 24 | 25 | # Logs 26 | logs/ 27 | *.log 28 | 29 | # Runtime data 30 | pids/ 31 | *.pid 32 | *.seed 33 | *.pid.lock 34 | 35 | # Testing 36 | coverage/ 37 | *.lcov 38 | 39 | # Vite related 40 | .vite/ 41 | # Vite build output (default) 42 | dist-client/ 43 | # Vite cache 44 | .vite/cache/ 45 | 46 | # Typescript 47 | *.tsbuildinfo 48 | 49 | # Local development 50 | .env.local 51 | .env.development.local 52 | .env.test.local 53 | .env.production.local 54 | 55 | # Debug 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | 60 | # Optional npm cache directory 61 | .npm 62 | 63 | # Optional eslint cache 64 | .eslintcache 65 | 66 | # parcel-bundler cache (if you use parcel) 67 | .parcel-cache 68 | 69 | # next.js build output (if you use next.js) 70 | .next 71 | 72 | # browserslist cache (if you use browserslist) 73 | .browserslistrc 74 | 75 | # Local IndexedDB data (if stored in project directory) 76 | indexeddb/ 77 | 78 | # FFmpeg.wasm temporary files 79 | ffmpeg-* -------------------------------------------------------------------------------- /components/icons/KeyIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IconProps extends React.SVGProps {} 4 | 5 | export const KeyIcon: React.FC = (props) => ( 6 | 14 | 19 | 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /components/icons/ChevronIcons.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | 4 | interface IconProps extends React.SVGProps {} 5 | 6 | export const ChevronLeftIcon: React.FC = (props) => ( 7 | 15 | 20 | 21 | ); 22 | 23 | export const ChevronRightIcon: React.FC = (props) => ( 24 | 32 | 37 | 38 | ); 39 | 40 | export const ChevronUpIcon: React.FC = (props) => ( 41 | 49 | 54 | 55 | ); 56 | 57 | 58 | export const ChevronDownIcon: React.FC = (props) => ( 59 | 67 | 72 | 73 | ); 74 | -------------------------------------------------------------------------------- /components/LoadingState.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useState, useEffect } from 'react'; 3 | 4 | const SkeletonCard: React.FC = () => ( 5 |
6 | ); 7 | 8 | interface LoadingStateProps { 9 | title?: string; 10 | message?: string; 11 | messages?: string[]; 12 | } 13 | 14 | export const LoadingState: React.FC = ({ 15 | title = "正在为您生成图片...", 16 | message = "这可能需要一点时间,请稍候。", 17 | messages 18 | }) => { 19 | // Fix: Add state to handle cycling messages 20 | const [displayedMessage, setDisplayedMessage] = useState(message); 21 | 22 | useEffect(() => { 23 | const effectiveMessages = messages && messages.length > 0 ? messages : null; 24 | 25 | if (effectiveMessages) { 26 | setDisplayedMessage(effectiveMessages[0]); 27 | if (effectiveMessages.length > 1) { 28 | let index = 0; 29 | const intervalId = setInterval(() => { 30 | index = (index + 1) % effectiveMessages.length; 31 | setDisplayedMessage(effectiveMessages[index]); 32 | }, 3000); 33 | return () => clearInterval(intervalId); 34 | } 35 | } else { 36 | setDisplayedMessage(message); 37 | } 38 | }, [messages, message]); 39 | 40 | return ( 41 |
42 |
43 |

{title}

44 |

{displayedMessage}

45 |
46 |
47 | 48 | 49 | 50 | 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /components/ImageGrid.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import React from 'react'; 4 | import { GeneratedImage } from '../types'; 5 | import { ImageCard } from './ImageCard'; 6 | 7 | interface ImageGridProps { 8 | images: GeneratedImage[]; 9 | onImageClick: (index: number) => void; 10 | onToggleFavorite?: (id: string) => void; 11 | onEdit?: (index: number) => void; 12 | videoUrls?: (string | null)[]; 13 | videoGenerationProgress?: { current: number; total: number } | null; 14 | } 15 | 16 | export const ImageGrid: React.FC = ({ images, onImageClick, onToggleFavorite, onEdit, videoUrls, videoGenerationProgress }) => { 17 | // If there is only one image, display it centered to provide a better layout 18 | // when the AI returns fewer images than expected. 19 | if (images.length === 1) { 20 | const isVideoLoading = videoGenerationProgress?.total === 1 && videoGenerationProgress?.current === 0; 21 | return ( 22 |
23 |
24 | 34 |
35 |
36 | ); 37 | } 38 | 39 | return ( 40 |
41 | {images.map((image, index) => { 42 | const isVideoLoading = videoGenerationProgress?.current === index; 43 | return ( 44 | 55 | ) 56 | })} 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /utils/imageUtils.ts: -------------------------------------------------------------------------------- 1 | 2 | export const resizeImage = (base64Str: string, maxWidth: number = 1024): Promise => { 3 | // Bypassing resizing to allow for full-resolution images. 4 | // The original logic would scale down images wider than maxWidth. 5 | return Promise.resolve(base64Str); 6 | }; 7 | 8 | export const createThumbnail = (base64Str: string, width: number = 160, quality: number = 0.7): Promise => { 9 | return new Promise((resolve) => { 10 | const img = new Image(); 11 | img.src = base64Str; 12 | img.onload = () => { 13 | const canvas = document.createElement('canvas'); 14 | const ratio = width / img.width; 15 | canvas.width = width; 16 | canvas.height = img.height * ratio; 17 | const ctx = canvas.getContext('2d'); 18 | if (!ctx) { 19 | resolve(base64Str); // fallback 20 | return; 21 | } 22 | ctx.drawImage(img, 0, 0, canvas.width, canvas.height); 23 | resolve(canvas.toDataURL('image/jpeg', quality)); 24 | }; 25 | img.onerror = () => { 26 | resolve(base64Str); // fallback 27 | }; 28 | }); 29 | }; 30 | 31 | export const base64ToFile = (dataUrl: string, filename: string): Promise => { 32 | return new Promise((resolve, reject) => { 33 | const arr = dataUrl.split(','); 34 | if (arr.length < 2) { 35 | return reject(new Error('Invalid data URL')); 36 | } 37 | const mimeMatch = arr[0].match(/:(.*?);/); 38 | if (!mimeMatch || mimeMatch.length < 2) { 39 | return reject(new Error('Could not determine MIME type from data URL')); 40 | } 41 | const mime = mimeMatch[1]; 42 | const bstr = atob(arr[1]); 43 | let n = bstr.length; 44 | const u8arr = new Uint8Array(n); 45 | while (n--) { 46 | u8arr[n] = bstr.charCodeAt(n); 47 | } 48 | resolve(new File([u8arr], filename, { type: mime })); 49 | }); 50 | }; 51 | 52 | export const fileToBase64 = (file: File): Promise => { 53 | return new Promise((resolve, reject) => { 54 | const reader = new FileReader(); 55 | reader.readAsDataURL(file); 56 | reader.onload = () => resolve(reader.result as string); 57 | reader.onerror = (error) => reject(error); 58 | }); 59 | }; -------------------------------------------------------------------------------- /components/PromptBuilder.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import { LightBulbIcon } from './icons/LightBulbIcon'; 4 | import { promptCategories } from '../utils/promptUtils'; 5 | 6 | interface PromptBuilderProps { 7 | onToggleKeyword: (keyword: string) => void; 8 | selectedKeywords: string[]; 9 | disabled: boolean; 10 | } 11 | 12 | const KeywordButton: React.FC<{ 13 | label: string; 14 | value: string; 15 | onClick: (value: string) => void; 16 | disabled: boolean; 17 | isSelected: boolean; 18 | }> = ({ label, value, onClick, disabled, isSelected }) => ( 19 | 31 | ); 32 | 33 | export const PromptBuilder: React.FC = ({ onToggleKeyword, selectedKeywords, disabled }) => { 34 | return ( 35 |
36 |
37 | 38 |

灵感词库

39 | (点击添加) 40 |
41 | 42 | {promptCategories.map((category) => ( 43 |
44 | 45 | {category.title} 46 | 47 |
48 | {category.keywords.map((keyword) => ( 49 | 57 | ))} 58 |
59 |
60 | ))} 61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /utils/videoUtils.ts: -------------------------------------------------------------------------------- 1 | // utils/videoUtils.ts 2 | 3 | declare global { 4 | interface Window { 5 | FFmpeg: any; 6 | } 7 | } 8 | 9 | const fetchFile = async (url: string): Promise => { 10 | const response = await fetch(url); 11 | if (!response.ok) { 12 | throw new Error(`Failed to fetch ${url}: ${response.statusText}`); 13 | } 14 | return new Uint8Array(await response.arrayBuffer()); 15 | }; 16 | 17 | export const stitchVideos = async ( 18 | videoUrls: string[], 19 | onProgress: (progress: number) => void 20 | ): Promise => { 21 | if (videoUrls.length === 0) { 22 | throw new Error("没有可拼接的视频。"); 23 | } 24 | 25 | onProgress(0); 26 | 27 | const { FFmpeg } = window.FFmpeg; 28 | const ffmpeg = new FFmpeg(); 29 | 30 | ffmpeg.on('log', ({ message }: { message: string }) => { 31 | console.log('[FFMPEG Log]', message); 32 | }); 33 | 34 | ffmpeg.on('progress', ({ progress }: { progress: number }) => { 35 | onProgress(Math.round(progress * 100)); 36 | }); 37 | 38 | await ffmpeg.load({ 39 | coreURL: "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js", 40 | }); 41 | 42 | const concatListContent = videoUrls.map((_, index) => `file 'input${index}.mp4'`).join('\n'); 43 | await ffmpeg.writeFile('concat_list.txt', concatListContent); 44 | 45 | // Fetch and write all files concurrently 46 | await Promise.all(videoUrls.map(async (url, index) => { 47 | const data = await fetchFile(url); 48 | await ffmpeg.writeFile(`input${index}.mp4`, data); 49 | })); 50 | 51 | try { 52 | // Run ffmpeg command 53 | await ffmpeg.exec(['-f', 'concat', '-safe', '0', '-i', 'concat_list.txt', '-c', 'copy', 'output.mp4']); 54 | 55 | const data = await ffmpeg.readFile('output.mp4'); 56 | const blob = new Blob([(data as Uint8Array).buffer], { type: 'video/mp4' }); 57 | const url = URL.createObjectURL(blob); 58 | 59 | const a = document.createElement('a'); 60 | a.href = url; 61 | a.download = `comic-story-video-${Date.now()}.mp4`; 62 | document.body.appendChild(a); 63 | a.click(); 64 | document.body.removeChild(a); 65 | URL.revokeObjectURL(url); 66 | onProgress(100); 67 | } catch (error) { 68 | console.error("FFmpeg execution failed:", error); 69 | throw new Error("视频拼接失败。请检查控制台日志获取更多信息。"); 70 | } finally { 71 | try { 72 | await ffmpeg.terminate(); 73 | } catch (e) { 74 | console.warn("Could not terminate ffmpeg instance.", e); 75 | } 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Image Studio 8 | 9 | 19 | 20 | 21 | 22 | 77 | 78 |
79 | 80 | 81 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | // types.ts 2 | 3 | export enum ImageStyle { 4 | ILLUSTRATION = 'ILLUSTRATION', 5 | CLAY = 'CLAY', 6 | DOODLE = 'DOODLE', 7 | CARTOON = 'CARTOON', 8 | INK_WASH = 'INK_WASH', 9 | AMERICAN_COMIC = 'AMERICAN_COMIC', 10 | WATERCOLOR = 'WATERCOLOR', 11 | PHOTOREALISTIC = 'PHOTOREALISTIC', 12 | JAPANESE_MANGA = 'JAPANESE_MANGA', 13 | THREE_D_ANIMATION = 'THREE_D_ANIMATION', 14 | } 15 | 16 | export enum ImageModel { 17 | IMAGEN = 'imagen-4.0-generate-001', 18 | NANO_BANANA = 'gemini-2.5-flash-image-preview', 19 | } 20 | 21 | export interface GeneratedImage { 22 | id: string; 23 | src: string; 24 | isFavorite?: boolean; 25 | } 26 | 27 | export type AppMode = 'wiki' | 'textToImage' | 'imageToImage' | 'video' | 'infiniteCanvas' | 'comicStrip'; 28 | export type CameraMovement = 'subtle' | 'zoomIn' | 'zoomOut'; 29 | export type AspectRatio = "1:1" | "16:9" | "9:16" | "4:3" | "3:4"; 30 | export type InspirationStrength = 'low' | 'medium' | 'high' | 'veryHigh'; 31 | export type ComicStripGenerationPhase = 'idle' | 'editing' | 'generating_panels' | 'generating_transitions' | 'completed'; 32 | export type ComicStripPanelStatus = 'queued' | 'generating' | 'completed'; 33 | export type ComicStripTransitionStatus = 'queued' | 'generating' | 'completed'; 34 | export type ComicStripTransitionOption = 'none' | 'ai_smart'; 35 | 36 | 37 | export interface HistoryRecord { 38 | id:string; 39 | mode: AppMode; 40 | prompt: string; 41 | style?: ImageStyle; // For wiki mode & comicStrip mode 42 | model?: ImageModel; // For wiki mode 43 | thumbnail: string; // Base64 thumbnail 44 | images?: GeneratedImage[]; // For image modes 45 | videoUrl?: string; // For video mode 46 | sourceImage?: string; // For imageToImage and video modes 47 | timestamp: number; 48 | cameraMovement?: CameraMovement; // For video mode 49 | numberOfImages?: number; // For textToImage mode 50 | aspectRatio?: AspectRatio; // For textToImage mode 51 | comicStripNumImages?: number; 52 | comicStripPanelPrompts?: string[]; 53 | comicStripType?: 'images' | 'video'; 54 | tags?: string[]; 55 | selectedKeywords?: string[]; // For textToImage mode 56 | negativePrompt?: string; // For textToImage mode 57 | i2iMode?: 'edit' | 'inspiration'; // For imageToImage mode 58 | inspirationNumImages?: number; // For imageToImage inspiration mode 59 | inspirationAspectRatio?: AspectRatio; // For imageToImage inspiration mode 60 | inspirationStrength?: InspirationStrength; 61 | parentId?: string | null; // ID of the parent record for grouping 62 | videoScripts?: string[]; // For comic strip video mode 63 | videoUrls?: (string | null)[]; // For comic strip video mode 64 | transitionUrls?: (string | null)[]; // For AI smart transitions 65 | transitionOption?: ComicStripTransitionOption; // User choice for transitions 66 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎨 Image Studio - 您的 AI 视觉创作套件 2 | 3 | [![一键部署到Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/milan-chen/image-studio&repository-name=image-studio) 4 | 5 | [![语言](https://img.shields.io/badge/language-TypeScript-blue.svg)](https://www.typescriptlang.org/) 6 | [![框架](https://img.shields.io/badge/framework-React-cyan.svg)](https://reactjs.org/) 7 | [![API](https://img.shields.io/badge/API-Gemini-purple.svg)](https://ai.google.dev/) 8 | 9 | 从文字到杰作,从静态到动态。Image Studio 是您的终极 AI 视觉创作套件,集图解、绘画、编辑和视频生成于一体。释放想象,创造非凡。 10 | 11 | ## ✨ 功能亮点 12 | 13 | Image Studio 整合了 Google 最前沿的生成式 AI 模型,提供了一个功能丰富、流程顺畅的视觉内容创作平台。 14 | 15 | - **图解百科 (Illustrated Wiki)**: 将复杂的概念或问题,通过 AI 自动生成一系列图文并茂的解说卡片,让知识更易于理解。 16 | - **连环画本 (Comic Strip)**: 只需输入故事脚本,即可一键生成拥有统一艺术风格的多格连环画。更能进一步将静态画面转化为动态视频,并由 AI 智能生成电影般的转场效果。 17 | - **无限画布 (Infinite Canvas)**: 打破画幅限制!上传一张图片作为起点,向任意方向拖拽,AI 将为您无缝地向外延展(Outpainting)画面,创造宏大的视觉场景。 18 | - **以文生图 (Text-to-Image)**: 强大的文生图功能,内置“灵感词库”帮助您构建更专业、效果更惊艳的提示词,将想象力精准转化为高质量图像。 19 | - **以图生图 (Image-to-Image)**: 20 | - **编辑创作**: 上传图片并结合文本指令,对画面内容进行修改或二次创作。 21 | - **灵感启发**: 上传一张参考图,借鉴其独特的艺术风格,生成一个全新主题的图像。 22 | - **局部重绘 (Inpainting)**: 在任意图片上涂抹蒙版,并用文字描述您想看到的内容,AI 将智能地重绘该区域。 23 | - **图生视频 (Image-to-Video)**: 让静态图片动起来!上传图片,输入动态描述,选择运镜方式,即可生成一段生动的短视频。 24 | - **历史记录 (History)**: 所有生成的内容都会被自动保存在本地,支持全文搜索、标签管理、收藏等功能,方便您随时回顾和继续创作。 25 | - **数据导入导出 (Import/Export)**: 支持将所有创作数据导出为 JSON 文件,便于备份或在不同设备间同步。同样支持从 JSON 文件导入数据,实现跨设备无缝迁移。 26 | - **一键部署 (Deploy)**: 点击上方部署按钮,即可快速部署到Vercel体验。 27 | 28 | ## 🧠 使用的 AI 模型 29 | 30 | 本应用深度集成了多种 Google Gemini 系列模型,各司其职,协同创作: 31 | 32 | | 功能模块 | 使用模型 | 主要职责 | 33 | | :--- | :--- | :--- | 34 | | **文本理解 & 脚本生成** | `gemini-2.5-flash` | 分析故事、生成连环画的每格提示词、为视频生成专业分镜脚本。 | 35 | | **核心图像生成** | `imagen-4.0-generate-001` | 高质量的文生图,用于“图解百科”、“以文生图”和“连环画本”的画面绘制。 | 36 | | **图像编辑 & 多模态** | `gemini-2.5-flash-image-preview` | 功能强大的多模态模型,负责“以图生图”的编辑、风格启发、“无限画布”的延展和所有局部重绘任务。 | 37 | | **视频生成** | `veo-2.0-generate-001` | 负责“图生视频”和“连环画本”中所有静态图像到动态视频的转换,包括智能转场生成。 | 38 | 39 | ## 🛠️ 技术栈 40 | 41 | - **前端框架**: React 42 | - **语言**: TypeScript 43 | - **样式**: Tailwind CSS 44 | - **AI 服务**: Google Gemini API (`@google/genai`) 45 | - **客户端视频处理**: FFmpeg.wasm (用于视频拼接) 46 | - **本地存储**: IndexedDB (用于历史记录) 47 | 48 | ## 🚀 运行项目 49 | 50 | 本项目是一个纯前端应用,可以直接在现代浏览器中运行。 51 | 52 | ### 配置 API Key 53 | 54 | 您可以通过以下两种方式配置 Gemini API Key(最好是付费Key,谷歌云绑卡即可。免费Key会导致Imagen等模型暂时无法使用): 55 | 56 | #### 方法一:使用环境变量(推荐用于开发/测试环境) 57 | 58 | 1. 复制 `.env.local` 文件: 59 | ```bash 60 | cp .env.local.example .env.local 61 | ``` 62 | 63 | 2. 在 `.env.local` 文件中设置您的 API Key: 64 | ``` 65 | VITE_GEMINI_API_KEY=your_actual_api_key_here 66 | ``` 67 | 68 | #### 方法二:在应用界面中设置 69 | 70 | 1. 获取 Gemini API Key: 71 | 您需要一个 Google Gemini API Key 才能使用本应用。可以前往 [Google AI Studio](https://aistudio.google.com/app/apikey) 免费获取。 72 | 73 | 2. 配置 API Key: 74 | 应用首次启动或在需要时,会弹出一个对话框,要求您输入 API Key。您的 Key 将被保存在浏览器的本地存储 (Local Storage) 中,仅用于您本地的请求,不会上传到任何服务器。 75 | 76 | ### 启动应用 77 | 78 | ```bash 79 | npm run dev 80 | ``` 81 | 82 | 应用将在 `http://localhost:5173` 上运行。 83 | 84 | ### 数据导入导出 85 | 86 | 为了方便用户备份创作数据或在不同设备间同步,Image Studio 提供了数据导入导出功能: 87 | 88 | 1. **导出数据**: 89 | - 点击应用顶部导航栏的导入导出按钮(向下箭头图标) 90 | - 选择"导出数据"选项卡 91 | - 点击"生成导出数据"按钮 92 | - 点击"下载导出文件"保存 JSON 格式的备份文件 93 | 94 | 2. **导入数据**: 95 | - 点击应用顶部导航栏的导入导出按钮(向下箭头图标) 96 | - 选择"导入数据"选项卡 97 | - 点击"选择导入文件"并选择之前导出的 JSON 文件 98 | - 确认导入操作(注意:导入将替换当前所有数据) 99 | 100 | --- 101 | 102 | 祝您创作愉快! 103 | -------------------------------------------------------------------------------- /components/ImageCard.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import React from 'react'; 4 | import { GeneratedImage } from '../types'; 5 | import { StarIcon } from './icons/StarIcon'; 6 | import { VideoPlayIcon } from './icons/VideoPlayIcon'; 7 | import { SparklesIcon } from './icons/SparklesIcon'; 8 | import { RedoIcon } from './icons/RedoIcon'; 9 | 10 | interface ImageCardProps { 11 | image: GeneratedImage; 12 | index: number; 13 | onImageClick: (index: number) => void; 14 | onToggleFavorite?: (id: string) => void; 15 | onEdit?: (index: number) => void; 16 | videoUrl?: string | null; 17 | isLoading?: boolean; 18 | progressText?: string; 19 | } 20 | 21 | export const ImageCard: React.FC = ({ image, index, onImageClick, onToggleFavorite, onEdit, videoUrl, isLoading, progressText }) => { 22 | const showLoading = isLoading; 23 | 24 | return ( 25 |
onImageClick(index)} 28 | role="button" 29 | tabIndex={0} 30 | onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onImageClick(index)} 31 | aria-label={`View image ${image.id} in full screen`} 32 | > 33 | {`Generated 38 |
39 | 40 | {videoUrl && !showLoading && ( 41 |
42 |
43 | 44 |
45 |
46 | )} 47 | 48 | {showLoading && ( 49 |
50 |
51 | {isLoading && progressText &&

生成中... {progressText}

} 52 |
53 | )} 54 | 55 |
56 | {onEdit && ( 57 | 68 | )} 69 | {onToggleFavorite && ( 70 | 85 | )} 86 |
87 |
88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /components/ApiKeyModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | 3 | interface ApiKeyModalProps { 4 | isOpen: boolean; 5 | onClose: () => void; 6 | onSave: (apiKey: string) => void; 7 | currentApiKey: string | null; 8 | } 9 | 10 | export const ApiKeyModal: React.FC = ({ isOpen, onClose, onSave, currentApiKey }) => { 11 | const [key, setKey] = useState(''); 12 | 13 | useEffect(() => { 14 | // Populate with existing key when modal opens 15 | if (isOpen) { 16 | // 如果currentApiKey为null,说明使用的是环境变量中的API Key,不显示在输入框中 17 | setKey(currentApiKey || ''); 18 | } 19 | }, [isOpen, currentApiKey]); 20 | 21 | const handleSave = () => { 22 | if (key.trim()) { 23 | onSave(key); 24 | } 25 | }; 26 | 27 | const handleKeyDown = useCallback((event: KeyboardEvent) => { 28 | if (event.key === 'Escape') { 29 | onClose(); 30 | } 31 | }, [onClose]); 32 | 33 | useEffect(() => { 34 | if (isOpen) { 35 | window.addEventListener('keydown', handleKeyDown); 36 | } 37 | return () => { 38 | window.removeEventListener('keydown', handleKeyDown); 39 | }; 40 | }, [isOpen, handleKeyDown]); 41 | 42 | 43 | if (!isOpen) { 44 | return null; 45 | } 46 | 47 | return ( 48 |
55 |
e.stopPropagation()} 58 | > 59 |
60 |

61 | 设置您的 Gemini API Key 62 |

63 | 72 |
73 |

74 | 为了使用本应用,您需要提供自己的 Google Gemini API Key。您可以在 Google AI Studio 免费获取。 75 |

76 | 77 |
78 |
79 | 82 | setKey(e.target.value)} 87 | placeholder="请输入您的 API Key" 88 | className="w-full px-4 py-2 bg-slate-50 border border-slate-300 rounded-lg shadow-sm transition duration-200 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" 89 | /> 90 |
91 | 97 | 点击这里获取您的 API Key ↗ 98 | 99 |
100 | 101 |
102 | 108 | 115 |
116 |
117 |
118 | ); 119 | }; -------------------------------------------------------------------------------- /utils/promptUtils.ts: -------------------------------------------------------------------------------- 1 | 2 | export const promptCategories = [ 3 | { 4 | title: '艺术风格', 5 | keywords: [ 6 | { label: '照片', value: 'photorealistic' }, 7 | { label: '动漫', value: 'anime style' }, 8 | { label: '油画', value: 'oil painting' }, 9 | { label: '概念艺术', value: 'concept art' }, 10 | { label: '3D渲染', value: '3d render' }, 11 | { label: '像素艺术', value: 'pixel art' }, 12 | { label: '水彩画', value: 'watercolor' }, 13 | { label: '素描', value: 'sketch' }, 14 | { label: '电影感', value: 'cinematic' }, 15 | { label: '赛博朋克', value: 'cyberpunk' }, 16 | { label: '奇幻', value: 'fantasy' }, 17 | { label: '蒸汽朋克', value: 'steampunk' }, 18 | ], 19 | }, 20 | { 21 | title: '主体细节', 22 | keywords: [ 23 | { label: '美丽的', value: 'beautiful' }, 24 | { label: '复杂的', value: 'intricate details' }, 25 | { label: '动态姿势', value: 'dynamic pose' }, 26 | { label: '表情丰富', value: 'expressive' }, 27 | { label: '极简', value: 'minimalistic' }, 28 | { label: '发光', value: 'glowing' }, 29 | ], 30 | }, 31 | { 32 | title: '环境/背景', 33 | keywords: [ 34 | { label: '室外', value: 'outdoor' }, 35 | { label: '科幻', value: 'sci-fi' }, 36 | { label: '自然', value: 'nature' }, 37 | { label: '城市', value: 'cityscape' }, 38 | { label: '太空', value: 'outer space' }, 39 | { label: '虚化背景', value: 'bokeh background' }, 40 | ], 41 | }, 42 | { 43 | title: '构图', 44 | keywords: [ 45 | { label: '特写', value: 'close-up shot' }, 46 | { label: '全身像', value: 'full body shot' }, 47 | { label: '广角', value: 'wide angle shot' }, 48 | { label: '肖像', value: 'portrait' }, 49 | { label: '微距', value: 'macro shot' }, 50 | { label: '鸟瞰', value: "bird's-eye view" }, 51 | { label: '低角度', value: 'low angle shot' }, 52 | ], 53 | }, 54 | { 55 | title: '灯光', 56 | keywords: [ 57 | { label: '柔和光', value: 'soft light' }, 58 | { label: '戏剧性', value: 'dramatic lighting' }, 59 | { label: '体积光', value: 'volumetric lighting' }, 60 | { label: '黄昏光', value: 'golden hour' }, 61 | { label: '霓虹灯', value: 'neon lighting' }, 62 | { label: '轮廓光', value: 'rim lighting' }, 63 | { label: '演播室', value: 'studio lighting' }, 64 | ], 65 | }, 66 | { 67 | title: '质量', 68 | keywords: [ 69 | { label: '杰作', value: 'masterpiece' }, 70 | { label: '超现实', value: 'hyperrealistic' }, 71 | { label: '高细节', value: 'highly detailed' }, 72 | { label: '专业调色', value: 'professional color grading' }, 73 | { label: '2K', value: '2K' }, 74 | { label: '4K', value: '4K' }, 75 | { label: '锐利', value: 'sharp focus' }, 76 | ], 77 | }, 78 | ]; 79 | 80 | const keywordData: { [key: string]: { value: string; category: string } } = {}; 81 | promptCategories.forEach(category => { 82 | category.keywords.forEach(keyword => { 83 | keywordData[keyword.value] = { value: keyword.value, category: category.title }; 84 | }); 85 | }); 86 | 87 | export const buildStructuredPrompt = (mainPrompt: string, selectedKeywords: string[]): string => { 88 | const prompt = mainPrompt.trim(); 89 | 90 | const parts: string[] = []; 91 | if (prompt) { 92 | parts.push(prompt); 93 | } 94 | 95 | const groupedKeywords: { [key: string]: string[] } = { 96 | '艺术风格': [], 97 | '主体细节': [], 98 | '环境/背景': [], 99 | '构图': [], 100 | '灯光': [], 101 | '质量': [], 102 | }; 103 | 104 | selectedKeywords.forEach(kw => { 105 | const data = keywordData[kw]; 106 | if (data && groupedKeywords[data.category]) { 107 | groupedKeywords[data.category].push(kw); 108 | } 109 | }); 110 | 111 | // A good structure is often: Subject, Details, Style, Composition, Quality 112 | 113 | const detailParts = [...groupedKeywords['主体细节'], ...groupedKeywords['环境/背景']]; 114 | if (detailParts.length > 0) { 115 | parts.push(detailParts.join(', ')); 116 | } 117 | 118 | if (groupedKeywords['艺术风格'].length > 0) { 119 | parts.push(groupedKeywords['艺术风格'].join(', ')); 120 | } 121 | 122 | const technicalParts = [...groupedKeywords['构图'], ...groupedKeywords['灯光']]; 123 | if (technicalParts.length > 0) { 124 | parts.push(technicalParts.join(', ')); 125 | } 126 | 127 | // Quality keywords are usually best at the end to boost the overall result 128 | if (groupedKeywords['质量'].length > 0) { 129 | parts.push(groupedKeywords['质量'].join(', ')); 130 | } 131 | 132 | // Filter out any empty strings that might have crept in and join. 133 | return parts.filter(p => p.trim() !== '').join(', '); 134 | }; 135 | -------------------------------------------------------------------------------- /components/TagManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import { TrashIcon } from './icons/TrashIcon'; 3 | 4 | interface TagManagerProps { 5 | isOpen: boolean; 6 | onClose: () => void; 7 | allTags: string[]; 8 | itemTags: string[]; 9 | anchorRect: DOMRect; 10 | onUpdateItemTags: (newTags: string[]) => void; 11 | onAddTag: (newTag: string) => void; 12 | onDeleteTag: (tagToDelete: string) => void; 13 | } 14 | 15 | export const TagManager: React.FC = ({ 16 | isOpen, 17 | onClose, 18 | allTags, 19 | itemTags, 20 | anchorRect, 21 | onUpdateItemTags, 22 | onAddTag, 23 | onDeleteTag, 24 | }) => { 25 | const [newTag, setNewTag] = useState(''); 26 | const popoverRef = useRef(null); 27 | const [position, setPosition] = useState({ top: -9999, left: -9999, opacity: 0 }); 28 | 29 | useEffect(() => { 30 | if (isOpen && popoverRef.current && anchorRect) { 31 | const popoverRect = popoverRef.current.getBoundingClientRect(); 32 | const margin = 8; 33 | 34 | let top = anchorRect.bottom + margin; 35 | if (top + popoverRect.height > window.innerHeight - margin) { 36 | top = anchorRect.top - popoverRect.height - margin; 37 | } 38 | 39 | let left = anchorRect.left + (anchorRect.width / 2) - (popoverRect.width / 2); 40 | left = Math.max(margin, Math.min(left, window.innerWidth - popoverRect.width - margin)); 41 | 42 | setPosition({ top, left, opacity: 1 }); 43 | } 44 | }, [isOpen, anchorRect]); 45 | 46 | const handleToggleTag = (tag: string) => { 47 | const newTags = itemTags.includes(tag) 48 | ? itemTags.filter(t => t !== tag) 49 | : [...itemTags, tag]; 50 | onUpdateItemTags(newTags); 51 | }; 52 | 53 | const handleAddTag = (e: React.FormEvent) => { 54 | e.preventDefault(); 55 | const trimmedTag = newTag.trim(); 56 | if (trimmedTag && trimmedTag.length <= 10 && !allTags.includes(trimmedTag)) { 57 | onAddTag(trimmedTag); 58 | handleToggleTag(trimmedTag); 59 | setNewTag(''); 60 | } 61 | }; 62 | 63 | if (!isOpen) return null; 64 | 65 | return ( 66 | <> 67 |
68 |
76 |
77 |

管理标签

78 | 79 |
80 |
81 | {allTags.length > 0 ? ( 82 |
83 | {allTags.map(tag => { 84 | const isApplied = itemTags.includes(tag); 85 | return ( 86 |
87 | 95 | 102 |
103 | ); 104 | })} 105 |
106 | ) : ( 107 |

还没有标签,请在下方添加。

108 | )} 109 |
110 |
111 |
112 | setNewTag(e.target.value)} 116 | maxLength={10} 117 | placeholder="添加新标签 (最多10个字)" 118 | className="flex-grow bg-slate-700 border border-slate-600 rounded-md px-3 py-1.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none" 119 | /> 120 | 127 |
128 |
129 |
130 | 131 | ); 132 | }; -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { KeyIcon } from './icons/KeyIcon'; 3 | import { AppMode } from '../types'; 4 | import { BookOpenIcon } from './icons/BookOpenIcon'; 5 | 6 | interface HeaderProps { 7 | onApiKeyClick: (() => void) | undefined; // 修改类型以允许undefined 8 | onImportExportClick: () => void; 9 | appMode: AppMode; 10 | onModeChange: (mode: AppMode) => void; 11 | isLoading: boolean; 12 | } 13 | 14 | const ModeButton: React.FC<{ 15 | label: string; 16 | isActive: boolean; 17 | onClick: () => void; 18 | disabled: boolean; 19 | }> = ({ label, isActive, onClick, disabled }) => { 20 | return ( 21 | 32 | ); 33 | }; 34 | 35 | export const Header: React.FC = ({ onApiKeyClick, onImportExportClick, appMode, onModeChange, isLoading }) => { 36 | const titles = { 37 | wiki: { icon: '💡', text: '图解百科' }, 38 | textToImage: { icon: '✨', text: '以文生图' }, 39 | imageToImage: { icon: '🖼️', text: '以图生图' }, 40 | video: { icon: '🎬', text: '图生视频' }, 41 | infiniteCanvas: { icon: '🌌', text: '无限画布' }, 42 | comicStrip: { icon: , text: '连环画本' }, 43 | }; 44 | 45 | const { icon, text } = titles[appMode]; 46 | 47 | const Title = () => ( 48 |

49 | {icon} 50 | {text} 51 |

52 | ); 53 | 54 | const ApiKeyButton = () => ( 55 | 62 | ); 63 | 64 | // 新增导入导出按钮 65 | const ImportExportButton = () => ( 66 | 75 | ); 76 | 77 | const ModeSwitcher = () => ( 78 |
79 | onModeChange('wiki')} disabled={isLoading} /> 80 | onModeChange('comicStrip')} disabled={isLoading} /> 81 | onModeChange('infiniteCanvas')} disabled={isLoading} /> 82 | onModeChange('textToImage')} disabled={isLoading} /> 83 | onModeChange('imageToImage')} disabled={isLoading} /> 84 | onModeChange('video')} disabled={isLoading} /> 85 |
86 | ); 87 | 88 | return ( 89 |
90 |
91 | {/* Desktop Layout */} 92 |
93 |
94 | 95 | </div> 96 | <div className="flex-shrink-0"> 97 | <ModeSwitcher /> 98 | </div> 99 | <div className="flex-1 flex justify-end gap-2"> 100 | <ImportExportButton /> 101 | {/* 只有在非环境变量API Key的情况下才显示API Key按钮 */} 102 | {onApiKeyClick && ( 103 | <ApiKeyButton /> 104 | )} 105 | </div> 106 | </div> 107 | 108 | {/* Mobile Layout */} 109 | <div className="md:hidden"> 110 | <div className="flex items-center justify-between"> 111 | <Title /> 112 | <div className="flex gap-1"> 113 | <ImportExportButton /> 114 | {/* 只有在非环境变量API Key的情况下才显示API Key按钮 */} 115 | {onApiKeyClick && ( 116 | <ApiKeyButton /> 117 | )} 118 | </div> 119 | </div> 120 | <div className="mt-3 flex justify-center"> 121 | <ModeSwitcher /> 122 | </div> 123 | </div> 124 | </div> 125 | </header> 126 | ); 127 | }; -------------------------------------------------------------------------------- /components/ComicPanelEditorModal.tsx: -------------------------------------------------------------------------------- 1 | // components/ComicPanelEditorModal.tsx 2 | import React, { useState, useEffect } from 'react'; 3 | import { GeneratedImage } from '../types'; 4 | import { editComicPanel } from '../services/geminiService'; 5 | import { SparklesIcon } from './icons/SparklesIcon'; 6 | 7 | interface ComicPanelEditorModalProps { 8 | isOpen: boolean; 9 | onClose: () => void; 10 | panelData: { 11 | index: number; 12 | image: GeneratedImage; 13 | prompt: string; 14 | } | null; 15 | apiKey: string | null; 16 | onComplete: (index: number, newImageSrc: string, newPrompt: string) => void; 17 | onApiKeyNeeded: () => void; 18 | } 19 | 20 | export const ComicPanelEditorModal: React.FC<ComicPanelEditorModalProps> = ({ 21 | isOpen, 22 | onClose, 23 | panelData, 24 | apiKey, 25 | onComplete, 26 | onApiKeyNeeded, 27 | }) => { 28 | const [editedPrompt, setEditedPrompt] = useState(''); 29 | const [isLoading, setIsLoading] = useState(false); 30 | const [error, setError] = useState<string | null>(null); 31 | 32 | useEffect(() => { 33 | if (isOpen && panelData) { 34 | setEditedPrompt(panelData.prompt); 35 | setError(null); 36 | setIsLoading(false); 37 | } 38 | }, [isOpen, panelData]); 39 | 40 | const handleGenerate = async () => { 41 | if (!apiKey) { 42 | onApiKeyNeeded(); 43 | return; 44 | } 45 | if (!panelData) { 46 | setError('没有可编辑的图片数据。'); 47 | return; 48 | } 49 | if (!editedPrompt.trim()) { 50 | setError('请输入有效的提示词。'); 51 | return; 52 | } 53 | 54 | setIsLoading(true); 55 | setError(null); 56 | 57 | try { 58 | const newImageSrc = await editComicPanel(panelData.image.src, editedPrompt, apiKey); 59 | onComplete(panelData.index, newImageSrc, editedPrompt); 60 | } catch (err) { 61 | setError(err instanceof Error ? err.message : '发生未知错误。'); 62 | } finally { 63 | setIsLoading(false); 64 | } 65 | }; 66 | 67 | if (!isOpen || !panelData) { 68 | return null; 69 | } 70 | 71 | return ( 72 | <div 73 | className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in" 74 | onClick={onClose} 75 | role="dialog" 76 | aria-modal="true" 77 | > 78 | <div 79 | className="bg-slate-800 rounded-2xl shadow-2xl p-4 md:p-6 max-w-4xl w-full text-white flex flex-col md:flex-row gap-6 max-h-[95vh] overflow-y-auto" 80 | onClick={(e) => e.stopPropagation()} 81 | > 82 | <div className="flex-grow flex flex-col items-center justify-center relative md:w-1/2"> 83 | <img 84 | src={panelData.image.src} 85 | alt={`Panel ${panelData.index + 1} for editing`} 86 | className="rounded-lg max-w-full max-h-[80vh] object-contain" 87 | /> 88 | {isLoading && ( 89 | <div className="absolute inset-0 bg-black/70 flex flex-col items-center justify-center rounded-lg"> 90 | <div className="animate-spin rounded-full h-12 w-12 border-4 border-solid border-indigo-400 border-r-transparent"></div> 91 | <p className="mt-4 text-lg">正在重新生成...</p> 92 | </div> 93 | )} 94 | </div> 95 | 96 | <div className="w-full md:w-1/2 flex-shrink-0 flex flex-col gap-4"> 97 | <div className="flex items-center justify-between"> 98 | <h2 className="text-xl font-bold">编辑画板</h2> 99 | <button onClick={onClose} className="text-slate-400 text-3xl leading-none hover:text-white">×</button> 100 | </div> 101 | <p className="text-sm text-slate-300"> 102 | 您可以修改下方的提示词来重新生成这张图片,AI 将尽力保持原图的构图和角色。 103 | </p> 104 | 105 | <div> 106 | <label htmlFor="panel-edit-prompt" className="text-sm font-medium text-slate-300 mb-1 block">生成提示词</label> 107 | <div className="w-full p-2 bg-slate-700 border border-slate-600 rounded-lg transition-colors focus-within:border-indigo-500"> 108 | <textarea 109 | id="panel-edit-prompt" 110 | value={editedPrompt} 111 | onChange={e => setEditedPrompt(e.target.value)} 112 | rows={8} 113 | className="w-full bg-transparent border-none focus:outline-none focus:ring-0 text-white resize-y leading-relaxed" 114 | disabled={isLoading} 115 | /> 116 | </div> 117 | </div> 118 | 119 | {error && ( 120 | <div className="text-sm bg-red-900/50 border border-red-700 text-red-300 p-2 rounded-lg">{error}</div> 121 | )} 122 | 123 | <div className="mt-auto flex gap-3"> 124 | <button 125 | onClick={onClose} 126 | disabled={isLoading} 127 | className="w-full bg-slate-600 text-white font-semibold py-3 px-8 rounded-full hover:bg-slate-500 transition-colors disabled:opacity-60" 128 | > 129 | 取消 130 | </button> 131 | <button 132 | onClick={handleGenerate} 133 | disabled={isLoading} 134 | className="w-full flex items-center justify-center gap-2 bg-indigo-600 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-500 disabled:cursor-not-allowed" 135 | > 136 | <SparklesIcon className="w-5 h-5" /> 137 | <span>{isLoading ? '生成中...' : '重新生成'}</span> 138 | </button> 139 | </div> 140 | </div> 141 | </div> 142 | </div> 143 | ); 144 | }; -------------------------------------------------------------------------------- /components/IllustratedWiki.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useState } from 'react'; 3 | import { ImageStyle, ImageModel } from '../types'; 4 | 5 | interface IllustratedWikiProps { 6 | prompt: string; 7 | onPromptChange: (prompt: string) => void; 8 | onGenerate: () => void; 9 | isLoading: boolean; 10 | activeStyle: ImageStyle; 11 | onStyleChange: (style: ImageStyle) => void; 12 | activeModel: ImageModel; 13 | onModelChange: (model: ImageModel) => void; 14 | } 15 | 16 | export const IllustratedWiki: React.FC<IllustratedWikiProps> = ({ 17 | prompt, 18 | onPromptChange, 19 | onGenerate, 20 | isLoading, 21 | activeStyle, 22 | onStyleChange, 23 | activeModel, 24 | onModelChange 25 | }) => { 26 | const [formError, setFormError] = useState<string | null>(null); 27 | 28 | const handleSubmit = (e: React.FormEvent) => { 29 | e.preventDefault(); 30 | if (!prompt.trim()) { 31 | setFormError('请输入您想了解的概念或问题。'); 32 | return; 33 | } 34 | onGenerate(); 35 | }; 36 | 37 | const StyleButton = ({ value, label, icon }: { value: ImageStyle; label: string; icon: string }) => ( 38 | <button 39 | type="button" 40 | onClick={() => onStyleChange(value)} 41 | disabled={isLoading} 42 | className={`px-4 py-2 rounded-full text-sm font-semibold transition-all duration-200 flex items-center gap-2 border disabled:cursor-not-allowed disabled:opacity-60 ${ 43 | activeStyle === value 44 | ? 'bg-slate-800 text-white border-slate-800 shadow-md' 45 | : 'bg-white/60 text-slate-700 border-transparent hover:bg-white/90 backdrop-blur-sm' 46 | }`} 47 | > 48 | <span className="text-lg">{icon}</span> 49 | {label} 50 | </button> 51 | ); 52 | 53 | const ModelButton = ({ value, label }: { value: ImageModel; label: string; }) => ( 54 | <button 55 | type="button" 56 | onClick={() => onModelChange(value)} 57 | disabled={isLoading} 58 | className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-colors duration-200 border disabled:cursor-not-allowed disabled:opacity-60 ${ 59 | activeModel === value 60 | ? 'bg-slate-700 text-white border-slate-700' 61 | : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-100' 62 | }`} 63 | > 64 | {label} 65 | </button> 66 | ); 67 | 68 | return ( 69 | <div className="w-full max-w-4xl mx-auto text-center"> 70 | <h1 className="text-4xl md:text-6xl font-extrabold text-slate-800 tracking-tight"> 71 | 一图胜千言,<span className="text-indigo-600">知识变简单</span> 72 | </h1> 73 | <p className="mt-4 text-base md:text-lg text-slate-600 max-w-2xl mx-auto"> 74 | 输入您想了解的概念或问题,我会为您生成多张图文卡片来详细解释 75 | </p> 76 | 77 | <form onSubmit={handleSubmit} className="mt-10"> 78 | <div className="relative max-w-2xl mx-auto"> 79 | <input 80 | type="text" 81 | value={prompt} 82 | onChange={(e) => { 83 | onPromptChange(e.target.value); 84 | if (formError) { 85 | setFormError(null); 86 | } 87 | }} 88 | placeholder="例如:什么是人工智能?" 89 | className={`w-full px-6 py-4 bg-white border rounded-full shadow-lg transition duration-200 text-lg ${ 90 | formError 91 | ? 'border-red-500 focus:ring-2 focus:ring-red-500/50 focus:border-red-500' 92 | : 'border-slate-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500' 93 | }`} 94 | disabled={isLoading} 95 | aria-invalid={!!formError} 96 | aria-describedby={formError ? 'prompt-error' : undefined} 97 | /> 98 | <button 99 | type="submit" 100 | className="absolute right-2.5 top-1/2 -translate-y-1/2 bg-indigo-600 text-white font-bold py-2.5 px-8 rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-400 disabled:cursor-not-allowed disabled:scale-100" 101 | disabled={isLoading} 102 | > 103 | {isLoading ? '生成中...' : '生成图解'} 104 | </button> 105 | </div> 106 | 107 | {formError && ( 108 | <p id="prompt-error" className="mt-2 text-sm text-red-600" role="alert"> 109 | {formError} 110 | </p> 111 | )} 112 | 113 | <div className={`max-w-2xl mx-auto flex flex-col items-center gap-4 ${formError ? 'mt-4' : 'mt-6'}`}> 114 | <div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4"> 115 | <span className="text-slate-600 text-sm font-medium">生成风格:</span> 116 | <div className="flex flex-wrap items-center justify-center gap-3"> 117 | <StyleButton value={ImageStyle.ILLUSTRATION} label="插画风" icon="🏞️" /> 118 | <StyleButton value={ImageStyle.CLAY} label="粘土风" icon="🗿" /> 119 | <StyleButton value={ImageStyle.DOODLE} label="涂鸦风" icon="🎨" /> 120 | <StyleButton value={ImageStyle.CARTOON} label="卡通风" icon="🐰" /> 121 | <StyleButton value={ImageStyle.WATERCOLOR} label="水彩风" icon="🖌️" /> 122 | </div> 123 | </div> 124 | <div className="flex items-center justify-center gap-3"> 125 | <span className="text-slate-600 text-sm font-medium">模型:</span> 126 | <div className="flex items-center justify-center gap-2 bg-slate-100 p-1 rounded-full"> 127 | <ModelButton value={ImageModel.IMAGEN} label="Imagen 4.0" /> 128 | <ModelButton value={ImageModel.NANO_BANANA} label="Nano-Banana" /> 129 | </div> 130 | </div> 131 | </div> 132 | </form> 133 | </div> 134 | ); 135 | }; 136 | -------------------------------------------------------------------------------- /services/historyService.ts: -------------------------------------------------------------------------------- 1 | import { HistoryRecord } from '../types'; 2 | 3 | const DB_NAME = 'ImageStudioHistoryDB'; 4 | const STORE_NAME = 'generations'; 5 | const METADATA_STORE_NAME = 'metadata'; 6 | const DB_VERSION = 3; // Incremented version for new index 7 | 8 | let db: IDBDatabase | null = null; 9 | 10 | const initDB = (): Promise<IDBDatabase> => { 11 | return new Promise((resolve, reject) => { 12 | if (db) { 13 | resolve(db); 14 | return; 15 | } 16 | 17 | const request = indexedDB.open(DB_NAME, DB_VERSION); 18 | 19 | request.onerror = () => { 20 | console.error('IndexedDB error:', request.error); 21 | reject(new Error('Failed to open IndexedDB.')); 22 | }; 23 | 24 | request.onsuccess = () => { 25 | db = request.result; 26 | resolve(db); 27 | }; 28 | 29 | request.onupgradeneeded = (event) => { 30 | const dbInstance = (event.target as IDBOpenDBRequest).result; 31 | let store; 32 | if (!dbInstance.objectStoreNames.contains(STORE_NAME)) { 33 | store = dbInstance.createObjectStore(STORE_NAME, { keyPath: 'id' }); 34 | store.createIndex('timestamp', 'timestamp', { unique: false }); 35 | } else { 36 | store = (event.target as any).transaction.objectStore(STORE_NAME); 37 | } 38 | 39 | if (!store.indexNames.contains('sourceImage')) { 40 | store.createIndex('sourceImage', 'sourceImage', { unique: false }); 41 | } 42 | 43 | if (!dbInstance.objectStoreNames.contains(METADATA_STORE_NAME)) { 44 | dbInstance.createObjectStore(METADATA_STORE_NAME, { keyPath: 'key' }); 45 | } 46 | }; 47 | }); 48 | }; 49 | 50 | export const addHistory = async (item: HistoryRecord): Promise<void> => { 51 | const dbInstance = await initDB(); 52 | return new Promise((resolve, reject) => { 53 | const transaction = dbInstance.transaction(STORE_NAME, 'readwrite'); 54 | const store = transaction.objectStore(STORE_NAME); 55 | const request = store.put(item); 56 | 57 | request.onsuccess = () => resolve(); 58 | request.onerror = () => { 59 | console.error('Failed to add history item:', request.error); 60 | reject(new Error('Could not save history item.')); 61 | }; 62 | }); 63 | }; 64 | 65 | export const getAllHistory = async (): Promise<HistoryRecord[]> => { 66 | const dbInstance = await initDB(); 67 | return new Promise((resolve, reject) => { 68 | const transaction = dbInstance.transaction(STORE_NAME, 'readonly'); 69 | const store = transaction.objectStore(STORE_NAME); 70 | const index = store.index('timestamp'); 71 | const request = index.getAll(); 72 | 73 | request.onsuccess = () => { 74 | // Sort descending (newest first) 75 | resolve(request.result.reverse()); 76 | }; 77 | 78 | request.onerror = () => { 79 | console.error('Failed to get history:', request.error); 80 | reject(new Error('Could not retrieve history.')); 81 | }; 82 | }); 83 | }; 84 | 85 | export const findHistoryBySourceImage = async (sourceImage: string): Promise<HistoryRecord | null> => { 86 | const dbInstance = await initDB(); 87 | return new Promise((resolve, reject) => { 88 | const transaction = dbInstance.transaction(STORE_NAME, 'readonly'); 89 | const store = transaction.objectStore(STORE_NAME); 90 | const index = store.index('sourceImage'); 91 | const request = index.getAll(sourceImage); 92 | 93 | request.onsuccess = () => { 94 | const results = request.result; 95 | if (results && results.length > 0) { 96 | // Find the one that is a parent itself (no parentId) 97 | const parent = results.find(r => !r.parentId); 98 | if (parent) { 99 | resolve(parent); 100 | } else { 101 | // Fallback to the oldest if no explicit parent found 102 | results.sort((a, b) => a.timestamp - b.timestamp); 103 | resolve(results[0]); 104 | } 105 | } else { 106 | resolve(null); 107 | } 108 | }; 109 | 110 | request.onerror = () => { 111 | console.error('Failed to find history by source image:', request.error); 112 | reject(new Error('Could not find history by source image.')); 113 | }; 114 | }); 115 | }; 116 | 117 | export const removeHistoryItem = async (id: string): Promise<void> => { 118 | const dbInstance = await initDB(); 119 | return new Promise((resolve, reject) => { 120 | const transaction = dbInstance.transaction(STORE_NAME, 'readwrite'); 121 | const store = transaction.objectStore(STORE_NAME); 122 | const request = store.delete(id); 123 | 124 | request.onsuccess = () => resolve(); 125 | request.onerror = () => { 126 | console.error('Failed to remove history item:', request.error); 127 | reject(new Error('Could not remove history item.')); 128 | }; 129 | }); 130 | }; 131 | 132 | export const clearHistory = async (): Promise<void> => { 133 | const dbInstance = await initDB(); 134 | return new Promise((resolve, reject) => { 135 | const transaction = dbInstance.transaction(STORE_NAME, 'readwrite'); 136 | const store = transaction.objectStore(STORE_NAME); 137 | const request = store.clear(); 138 | 139 | request.onsuccess = () => resolve(); 140 | request.onerror = () => { 141 | console.error('Failed to clear history:', request.error); 142 | reject(new Error('Could not clear history.')); 143 | }; 144 | }); 145 | }; 146 | 147 | const getMetadata = async <T>(key: string): Promise<T | null> => { 148 | const dbInstance = await initDB(); 149 | return new Promise((resolve, reject) => { 150 | const transaction = dbInstance.transaction(METADATA_STORE_NAME, 'readonly'); 151 | const store = transaction.objectStore(METADATA_STORE_NAME); 152 | const request = store.get(key); 153 | 154 | request.onsuccess = () => { 155 | resolve(request.result ? request.result.value : null); 156 | }; 157 | request.onerror = () => { 158 | console.error(`Failed to get metadata for key: ${key}`, request.error); 159 | reject(new Error(`Could not retrieve metadata for key: ${key}.`)); 160 | }; 161 | }); 162 | }; 163 | 164 | const setMetadata = async (key: string, value: any): Promise<void> => { 165 | const dbInstance = await initDB(); 166 | return new Promise((resolve, reject) => { 167 | const transaction = dbInstance.transaction(METADATA_STORE_NAME, 'readwrite'); 168 | const store = transaction.objectStore(METADATA_STORE_NAME); 169 | const request = store.put({ key, value }); 170 | 171 | request.onsuccess = () => resolve(); 172 | request.onerror = () => { 173 | console.error(`Failed to set metadata for key: ${key}`, request.error); 174 | reject(new Error(`Could not save metadata for key: ${key}.`)); 175 | }; 176 | }); 177 | }; 178 | 179 | 180 | export const getTags = async (): Promise<string[]> => { 181 | return (await getMetadata<string[]>('allTags')) || []; 182 | }; 183 | 184 | export const saveTags = async (tags: string[]): Promise<void> => { 185 | await setMetadata('allTags', tags); 186 | }; -------------------------------------------------------------------------------- /components/ImagePreview.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useEffect, useCallback, useState, useRef } from 'react'; 3 | import { GeneratedImage } from '../types'; 4 | import { ChevronLeftIcon } from './icons/ChevronLeftIcon'; 5 | import { ChevronRightIcon } from './icons/ChevronRightIcon'; 6 | import { SwitchHorizontalIcon } from './icons/SwitchHorizontalIcon'; 7 | 8 | interface ActionButton { 9 | label: string; 10 | icon: React.ReactNode; 11 | onClick: () => void; 12 | isActive?: boolean; 13 | } 14 | 15 | interface ImagePreviewProps { 16 | images: GeneratedImage[] | null; 17 | currentIndex: number | null; 18 | onClose: () => void; 19 | onChange: (newIndex: number) => void; 20 | actions?: ActionButton[]; 21 | sourceImageSrc?: string; 22 | isComparing?: boolean; 23 | onToggleCompare?: () => void; 24 | videoUrl?: string; 25 | } 26 | 27 | export const ImagePreview: React.FC<ImagePreviewProps> = ({ images, currentIndex, onClose, onChange, actions = [], sourceImageSrc, isComparing, onToggleCompare, videoUrl }) => { 28 | const [isExiting, setIsExiting] = useState(false); 29 | const [activeImageIndex, setActiveImageIndex] = useState<number | null>(currentIndex); 30 | const videoRef = useRef<HTMLVideoElement>(null); 31 | 32 | useEffect(() => { 33 | if (currentIndex !== null) { 34 | // Prop has a value (opening or changing image), update the state. 35 | setActiveImageIndex(currentIndex); 36 | setIsExiting(false); 37 | } else if (activeImageIndex !== null) { 38 | // Prop is null (closing). Start exit animation. 39 | setIsExiting(true); 40 | if (videoRef.current) { 41 | videoRef.current.pause(); 42 | } 43 | } 44 | }, [currentIndex, activeImageIndex]); 45 | 46 | const handleClose = () => { 47 | onClose(); 48 | }; 49 | 50 | const handleAnimationEnd = () => { 51 | // When fade out animation ends, set index to null to unmount the component 52 | if (isExiting && currentIndex === null) { 53 | setActiveImageIndex(null); 54 | setIsExiting(false); 55 | } 56 | }; 57 | 58 | const handleKeyDown = useCallback((event: KeyboardEvent) => { 59 | if (currentIndex === null || !images) return; 60 | 61 | if (event.key === 'Escape') { 62 | onClose(); 63 | } 64 | if (event.key === 'ArrowLeft') { 65 | if (currentIndex > 0) { 66 | onChange(currentIndex - 1); 67 | } 68 | } 69 | if (event.key === 'ArrowRight') { 70 | if (currentIndex < images.length - 1) { 71 | onChange(currentIndex + 1); 72 | } 73 | } 74 | }, [currentIndex, images, onClose, onChange]); 75 | 76 | useEffect(() => { 77 | window.addEventListener('keydown', handleKeyDown); 78 | return () => { 79 | window.removeEventListener('keydown', handleKeyDown); 80 | }; 81 | }, [handleKeyDown]); 82 | 83 | // Use the current prop index if available, otherwise fallback to the 84 | // state index. This allows for the exit animation to complete while 85 | // instantly showing the image on open, fixing the flicker. 86 | const indexToRender = currentIndex ?? activeImageIndex; 87 | 88 | if (indexToRender === null || !images || !images[indexToRender]) { 89 | return null; 90 | } 91 | 92 | const currentImage = images[indexToRender]; 93 | const canGoLeft = indexToRender > 0; 94 | const canGoRight = indexToRender < images.length - 1; 95 | const isClosing = isExiting && currentIndex === null; 96 | 97 | return ( 98 | <div 99 | className={`fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 ${isClosing ? 'animate-fade-out' : 'animate-fade-in'}`} 100 | onClick={handleClose} 101 | onAnimationEnd={handleAnimationEnd} 102 | role="dialog" 103 | aria-modal="true" 104 | aria-label="Image preview" 105 | > 106 | <button 107 | onClick={handleClose} 108 | className="absolute top-4 right-4 text-white text-4xl leading-none font-bold hover:text-gray-300 transition-colors z-20" 109 | aria-label="Close preview" 110 | > 111 | × 112 | </button> 113 | 114 | {/* Navigation - Left */} 115 | {canGoLeft && !isClosing && ( 116 | <button 117 | onClick={(e) => { 118 | e.stopPropagation(); 119 | onChange(indexToRender - 1); 120 | }} 121 | className="absolute left-4 md:left-8 top-1/2 -translate-y-1/2 p-3 bg-black/30 text-white rounded-full backdrop-blur-md hover:bg-black/50 transition-colors focus:outline-none focus:ring-2 focus:ring-white/50 z-20" 122 | aria-label="Previous image" 123 | > 124 | <ChevronLeftIcon className="w-6 h-6" /> 125 | </button> 126 | )} 127 | 128 | {/* Image and actions container */} 129 | <div className="relative" onClick={(e) => e.stopPropagation()}> 130 | {videoUrl ? ( 131 | <video 132 | ref={videoRef} 133 | src={videoUrl} 134 | controls 135 | autoPlay 136 | loop 137 | className="max-w-[85vw] max-h-[90vh] object-contain rounded-lg shadow-2xl bg-black" 138 | aria-label={`Video preview for ${currentImage.id}`} 139 | /> 140 | ) : ( 141 | <img 142 | src={isComparing && sourceImageSrc ? sourceImageSrc : currentImage.src} 143 | alt={`Enlarged view of ${currentImage.id}`} 144 | className="max-w-[85vw] max-h-[90vh] object-contain rounded-lg shadow-2xl" 145 | /> 146 | )} 147 | 148 | {onToggleCompare && sourceImageSrc && !isClosing && !videoUrl && ( 149 | <button 150 | onClick={onToggleCompare} 151 | title={isComparing ? "显示生成图" : "对比原图"} 152 | className={`absolute top-4 right-4 p-3 rounded-full backdrop-blur-md hover:bg-black/50 transition-colors focus:outline-none focus:ring-2 focus:ring-white/50 z-30 ${ 153 | isComparing ? 'bg-indigo-500/80 text-white' : 'bg-black/30 text-white' 154 | }`} 155 | aria-label={isComparing ? "Show generated image" : "Compare with original image"} 156 | > 157 | <SwitchHorizontalIcon className="w-6 h-6" /> 158 | </button> 159 | )} 160 | {actions.length > 0 && !isClosing && ( 161 | <div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-4"> 162 | {actions.map((action) => ( 163 | <button 164 | key={action.label} 165 | onClick={() => action.onClick()} 166 | title={action.label} 167 | className={`p-3 rounded-full backdrop-blur-md transition-colors focus:outline-none focus:ring-2 focus:ring-white/50 ${ 168 | action.isActive 169 | ? 'bg-amber-400/80 text-white hover:bg-amber-500/80' 170 | : 'bg-black/40 text-white hover:bg-black/60' 171 | }`} 172 | aria-label={action.label} 173 | > 174 | {action.icon} 175 | </button> 176 | ))} 177 | </div> 178 | )} 179 | </div> 180 | 181 | {/* Navigation - Right */} 182 | {canGoRight && !isClosing && ( 183 | <button 184 | onClick={(e) => { 185 | e.stopPropagation(); 186 | onChange(indexToRender + 1); 187 | }} 188 | className="absolute right-4 md:right-8 top-1/2 -translate-y-1/2 p-3 bg-black/30 text-white rounded-full backdrop-blur-md hover:bg-black/50 transition-colors focus:outline-none focus:ring-2 focus:ring-white/50 z-20" 189 | aria-label="Next image" 190 | > 191 | <ChevronRightIcon className="w-6 h-6" /> 192 | </button> 193 | )} 194 | </div> 195 | ); 196 | }; -------------------------------------------------------------------------------- /components/ImageUploader.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useState, useEffect, useRef } from 'react'; 3 | import { UploadIcon } from './icons/UploadIcon'; 4 | import { XCircleIcon } from './icons/XCircleIcon'; 5 | import { PlusIcon } from './icons/PlusIcon'; 6 | 7 | interface ImageUploaderProps { 8 | files: File[]; 9 | onFilesChange: (files: File[]) => void; 10 | disabled?: boolean; 11 | maxFiles?: number; 12 | onPreviewClick?: (index: number) => void; 13 | } 14 | 15 | const MAX_FILES_DEFAULT = 5; 16 | const MAX_SIZE_MB = 10; 17 | const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024; 18 | const ACCEPTED_FORMATS: Record<string, string[]> = { 19 | 'image/jpeg': ['.jpg', '.jpeg'], 20 | 'image/png': ['.png'], 21 | 'image/gif': ['.gif'], 22 | 'image/webp': ['.webp'], 23 | }; 24 | const ACCEPTED_MIME_TYPES = Object.keys(ACCEPTED_FORMATS); 25 | 26 | interface FilePreview { 27 | id: string; 28 | url: string; 29 | name: string; 30 | size: number; 31 | } 32 | 33 | export const ImageUploader: React.FC<ImageUploaderProps> = ({ files, onFilesChange, disabled = false, maxFiles = MAX_FILES_DEFAULT, onPreviewClick }) => { 34 | const [previews, setPreviews] = useState<FilePreview[]>([]); 35 | const [error, setError] = useState<string | null>(null); 36 | const [isDragActive, setIsDragActive] = useState(false); 37 | const inputRef = useRef<HTMLInputElement>(null); 38 | 39 | useEffect(() => { 40 | // When the files prop changes, create new preview URLs 41 | const newPreviews = files.map(file => ({ 42 | id: `${file.name}-${file.lastModified}-${file.size}`, 43 | url: URL.createObjectURL(file), 44 | name: file.name, 45 | size: file.size, 46 | })); 47 | 48 | setPreviews(newPreviews); 49 | 50 | // Cleanup blob URLs when the component unmounts or files change 51 | return () => { 52 | newPreviews.forEach(p => URL.revokeObjectURL(p.url)); 53 | }; 54 | }, [files]); 55 | 56 | const processFiles = (newFiles: FileList | File[]) => { 57 | setError(null); 58 | const fileList = Array.from(newFiles); 59 | 60 | if (files.length + fileList.length > maxFiles) { 61 | setError(`最多只能上传 ${maxFiles} 张图片。`); 62 | return; 63 | } 64 | 65 | const validFiles: File[] = []; 66 | for (const file of fileList) { 67 | if (!ACCEPTED_MIME_TYPES.includes(file.type)) { 68 | setError(`不支持的文件格式: ${file.name}`); 69 | return; 70 | } 71 | if (file.size > MAX_SIZE_BYTES) { 72 | setError(`文件大小不能超过 ${MAX_SIZE_MB}MB: ${file.name}`); 73 | return; 74 | } 75 | validFiles.push(file); 76 | } 77 | 78 | const updatedFiles = maxFiles === 1 ? validFiles.slice(0, 1) : [...files, ...validFiles]; 79 | onFilesChange(updatedFiles); 80 | }; 81 | 82 | const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => { 83 | e.preventDefault(); 84 | e.stopPropagation(); 85 | if (disabled) return; 86 | setIsDragActive(true); 87 | }; 88 | 89 | const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { 90 | e.preventDefault(); 91 | e.stopPropagation(); 92 | setIsDragActive(false); 93 | }; 94 | 95 | const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { 96 | e.preventDefault(); 97 | e.stopPropagation(); 98 | }; 99 | 100 | const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { 101 | e.preventDefault(); 102 | e.stopPropagation(); 103 | if (disabled) return; 104 | setIsDragActive(false); 105 | if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { 106 | processFiles(e.dataTransfer.files); 107 | } 108 | }; 109 | 110 | const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 111 | if (e.target.files) { 112 | processFiles(e.target.files); 113 | } 114 | if (inputRef.current) { 115 | inputRef.current.value = ''; 116 | } 117 | }; 118 | 119 | const removeFile = (idToRemove: string) => { 120 | const fileIndex = previews.findIndex(p => p.id === idToRemove); 121 | if (fileIndex === -1) return; 122 | 123 | const newFiles = files.filter((_, index) => index !== fileIndex); 124 | onFilesChange(newFiles); 125 | }; 126 | 127 | return ( 128 | <div className="w-full"> 129 | <div 130 | onDragEnter={handleDragEnter} 131 | onDragLeave={handleDragLeave} 132 | onDragOver={handleDragOver} 133 | onDrop={handleDrop} 134 | className={`relative w-full p-4 transition-colors duration-200 rounded-xl border-2 border-dashed 135 | ${disabled ? 'bg-slate-100 cursor-not-allowed' : 'bg-slate-50'} 136 | ${isDragActive ? 'border-indigo-500 bg-indigo-100' : 'border-slate-300'}`} 137 | > 138 | <input 139 | ref={inputRef} 140 | type="file" 141 | multiple={maxFiles > 1} 142 | accept={ACCEPTED_MIME_TYPES.join(',')} 143 | onChange={handleFileSelect} 144 | className="hidden" 145 | disabled={disabled} 146 | aria-label="File uploader" 147 | /> 148 | {previews.length === 0 ? ( 149 | <div 150 | onClick={() => !disabled && inputRef.current?.click()} 151 | className="flex flex-col items-center justify-center text-slate-500 text-center py-8 cursor-pointer" 152 | role="button" 153 | tabIndex={0} 154 | > 155 | <UploadIcon className="w-10 h-10 mb-3 text-slate-400" /> 156 | <p className="font-semibold">点击或拖拽图片到这里上传</p> 157 | </div> 158 | ) : ( 159 | <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> 160 | {previews.map((preview, index) => ( 161 | <div 162 | key={preview.id} 163 | className={`relative group aspect-square bg-slate-100 rounded-lg overflow-hidden shadow-sm ${onPreviewClick ? 'cursor-pointer' : ''}`} 164 | onClick={() => onPreviewClick?.(index)} 165 | role={onPreviewClick ? "button" : undefined} 166 | tabIndex={onPreviewClick ? 0 : undefined} 167 | onKeyDown={(e) => onPreviewClick && (e.key === 'Enter' || e.key === ' ') && onPreviewClick(index)} 168 | aria-label={onPreviewClick ? `Preview uploaded image ${preview.name}` : undefined} 169 | > 170 | <img src={preview.url} alt={`Preview of ${preview.name}`} className="w-full h-full object-cover"/> 171 | {!disabled && ( 172 | <button 173 | onClick={(e) => { 174 | e.stopPropagation(); 175 | removeFile(preview.id); 176 | }} 177 | className="absolute top-1.5 right-1.5 bg-black/50 text-white rounded-full p-0.5 178 | opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70" 179 | aria-label={`Remove ${preview.name}`} 180 | > 181 | <XCircleIcon className="w-5 h-5" /> 182 | </button> 183 | )} 184 | <div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-1.5 truncate"> 185 | {preview.name} 186 | </div> 187 | </div> 188 | ))} 189 | {files.length < maxFiles && !disabled && ( 190 | <button 191 | type="button" 192 | onClick={() => inputRef.current?.click()} 193 | disabled={disabled} 194 | className="flex flex-col items-center justify-center aspect-square bg-transparent border-2 border-dashed border-slate-300 rounded-lg text-slate-500 hover:bg-slate-100 hover:border-slate-400 transition-colors" 195 | aria-label="Add more images" 196 | > 197 | <PlusIcon className="w-8 h-8 mb-1" /> 198 | <span className="text-sm font-semibold">添加图片</span> 199 | </button> 200 | )} 201 | </div> 202 | )} 203 | </div> 204 | 205 | {error && <p className="mt-2 text-sm text-red-600 text-center" role="alert">{error}</p>} 206 | 207 | <p className="text-xs text-slate-500 mt-2 text-center"> 208 | 支持 JPG, PNG, GIF, WEBP (最多 {maxFiles} 张, 每张不超过 {MAX_SIZE_MB}MB) 209 | </p> 210 | </div> 211 | ); 212 | }; -------------------------------------------------------------------------------- /components/TextToImage.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useState } from 'react'; 3 | import { GeneratedImage, AspectRatio } from '../types'; 4 | import { LoadingState } from './LoadingState'; 5 | import { ImageGrid } from './ImageGrid'; 6 | import { EmptyState } from './EmptyState'; 7 | import { ImagePreview } from './ImagePreview'; 8 | import { InpaintingModal } from './InpaintingModal'; 9 | import { PromptBuilder } from './PromptBuilder'; 10 | import { resizeImage } from '../utils/imageUtils'; 11 | import { DownloadIcon } from './icons/DownloadIcon'; 12 | import { SparklesIcon } from './icons/SparklesIcon'; 13 | import { VideoIcon } from './icons/VideoIcon'; 14 | import { EditIcon } from './icons/EditIcon'; 15 | import { SquareIcon, RectangleHorizontalIcon, RectangleVerticalIcon } from './icons/AspectRatioIcons'; 16 | import { StarIcon } from './icons/StarIcon'; 17 | 18 | interface TextToImageProps { 19 | apiKey: string | null; 20 | onApiKeyNeeded: () => void; 21 | onGenerate: () => void; 22 | prompt: string; 23 | onPromptChange: (newPrompt: string) => void; 24 | generatedImages: GeneratedImage[]; 25 | onNavigateToImageToImage: (sourceImageSrc: string) => void; 26 | onNavigateToVideo: (sourceImageSrc: string, sourcePrompt: string) => void; 27 | isLoading: boolean; 28 | onImageUpdate: (imageId: string, newSrc: string) => void; 29 | numberOfImages: number; 30 | onNumberOfImagesChange: (num: number) => void; 31 | aspectRatio: AspectRatio; 32 | onAspectRatioChange: (ratio: AspectRatio) => void; 33 | selectedKeywords: string[]; 34 | onToggleKeyword: (keyword: string) => void; 35 | onToggleFavorite: (imageId: string) => void; 36 | } 37 | 38 | const SettingButton: React.FC<{ 39 | label: string; 40 | isActive: boolean; 41 | onClick: () => void; 42 | disabled: boolean; 43 | children?: React.ReactNode; 44 | }> = ({ label, isActive, onClick, disabled, children }) => ( 45 | <button 46 | type="button" 47 | onClick={onClick} 48 | disabled={disabled} 49 | title={label} 50 | className={`w-full px-3 sm:px-4 py-2 rounded-lg transition-all duration-300 text-sm font-semibold whitespace-nowrap flex items-center justify-center gap-2 ${ 51 | isActive 52 | ? 'bg-slate-800 text-white shadow' 53 | : 'bg-white text-slate-600 hover:bg-slate-100 border border-slate-300' 54 | } disabled:cursor-not-allowed disabled:opacity-60`} 55 | > 56 | {children} 57 | </button> 58 | ); 59 | 60 | 61 | export const TextToImage: React.FC<TextToImageProps> = ({ 62 | apiKey, 63 | onApiKeyNeeded, 64 | onGenerate, 65 | prompt, 66 | onPromptChange, 67 | generatedImages, 68 | onNavigateToImageToImage, 69 | onNavigateToVideo, 70 | isLoading, 71 | onImageUpdate, 72 | numberOfImages, 73 | onNumberOfImagesChange, 74 | aspectRatio, 75 | onAspectRatioChange, 76 | selectedKeywords, 77 | onToggleKeyword, 78 | onToggleFavorite, 79 | }) => { 80 | const [error, setError] = useState<string | null>(null); 81 | const [previewImageIndex, setPreviewImageIndex] = useState<number | null>(null); 82 | const [editingImage, setEditingImage] = useState<GeneratedImage | null>(null); 83 | 84 | const handleSubmit = (e: React.FormEvent) => { 85 | e.preventDefault(); 86 | if (!prompt.trim()) { 87 | setError('请输入您的创意指令。'); 88 | return; 89 | } 90 | setError(null); 91 | onGenerate(); 92 | }; 93 | 94 | const handleDownload = () => { 95 | if (previewImageIndex === null) return; 96 | const { src, id } = generatedImages[previewImageIndex]; 97 | const link = document.createElement('a'); 98 | link.href = src; 99 | link.download = `以文生图-${id}.png`; 100 | document.body.appendChild(link); 101 | link.click(); 102 | document.body.removeChild(link); 103 | }; 104 | 105 | const handleContinueWithImage = () => { 106 | if (previewImageIndex === null) return; 107 | const { src } = generatedImages[previewImageIndex]; 108 | onNavigateToImageToImage(src); 109 | }; 110 | 111 | const handleGenerateVideo = () => { 112 | if (previewImageIndex === null) return; 113 | const { src } = generatedImages[previewImageIndex]; 114 | onNavigateToVideo(src, prompt); 115 | }; 116 | 117 | const handleInpaintingComplete = async (newImageSrc: string) => { 118 | if (!editingImage) return; 119 | try { 120 | const resizedSrc = await resizeImage(newImageSrc); 121 | onImageUpdate(editingImage.id, resizedSrc); 122 | setEditingImage(null); 123 | } catch (err) { 124 | setError("处理编辑后的图片失败。"); 125 | setEditingImage(null); 126 | } 127 | }; 128 | 129 | const currentImage = previewImageIndex !== null ? generatedImages[previewImageIndex] : null; 130 | 131 | const handleToggleFavoriteInPreview = () => { 132 | if (currentImage) { 133 | onToggleFavorite(currentImage.id); 134 | } 135 | }; 136 | 137 | const previewActions = [ 138 | { 139 | label: '收藏', 140 | icon: <StarIcon className="w-5 h-5" />, 141 | onClick: handleToggleFavoriteInPreview, 142 | isActive: !!currentImage?.isFavorite 143 | }, 144 | { 145 | label: '局部重绘', 146 | icon: <EditIcon className="w-5 h-5" />, 147 | onClick: () => { 148 | if (previewImageIndex !== null) { 149 | setEditingImage(generatedImages[previewImageIndex]); 150 | setPreviewImageIndex(null); 151 | } 152 | }, 153 | }, 154 | { 155 | label: '二次创作', 156 | icon: <SparklesIcon className="w-5 h-5" />, 157 | onClick: handleContinueWithImage, 158 | }, 159 | { 160 | label: '生成视频', 161 | icon: <VideoIcon className="w-5 h-5" />, 162 | onClick: handleGenerateVideo, 163 | }, 164 | { 165 | label: '下载', 166 | icon: <DownloadIcon className="w-5 h-5" />, 167 | onClick: handleDownload, 168 | }, 169 | ]; 170 | 171 | return ( 172 | <> 173 | <section className="w-full flex flex-col items-center justify-center py-12 md:py-16 bg-white border-b border-slate-200"> 174 | <div className="container mx-auto px-4 text-center"> 175 | <h1 className="text-4xl md:text-6xl font-extrabold text-slate-800 tracking-tight"> 176 | 妙笔生画,<span className="text-indigo-600">想象无界</span> 177 | </h1> 178 | <p className="mt-4 text-base md:text-lg text-slate-600 max-w-2xl mx-auto"> 179 | 输入您的创意指令,即可生成新的视觉杰作。 180 | </p> 181 | <form onSubmit={handleSubmit} className="mt-10 max-w-4xl mx-auto"> 182 | <div className="flex flex-col items-center gap-4"> 183 | <textarea 184 | value={prompt} 185 | onChange={(e) => { 186 | onPromptChange(e.target.value); 187 | if (error) setError(null); 188 | }} 189 | placeholder="例如:一只穿着宇航服的猫在月球上,背景是地球升起,写实风格,4k高清" 190 | rows={4} 191 | className={`w-full px-6 py-4 bg-white border rounded-2xl shadow-lg transition duration-200 text-lg resize-y ${ 192 | error 193 | ? 'border-red-500 focus:ring-2 focus:ring-red-500/50 focus:border-red-500' 194 | : 'border-slate-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500' 195 | }`} 196 | disabled={isLoading} 197 | /> 198 | 199 | <PromptBuilder 200 | onToggleKeyword={onToggleKeyword} 201 | selectedKeywords={selectedKeywords} 202 | disabled={isLoading} 203 | /> 204 | 205 | <div className="w-full grid grid-cols-1 md:grid-cols-2 gap-4 text-left mt-4"> 206 | <div> 207 | <label className="block text-sm font-medium text-slate-600 mb-2">图片数量</label> 208 | <div className="grid grid-cols-4 gap-2"> 209 | {[1, 2, 3, 4].map(num => ( 210 | <SettingButton key={num} label={`${num}张`} isActive={numberOfImages === num} onClick={() => onNumberOfImagesChange(num)} disabled={isLoading}> 211 | <span>{num}</span> 212 | </SettingButton> 213 | ))} 214 | </div> 215 | </div> 216 | <div> 217 | <label className="block text-sm font-medium text-slate-600 mb-2">图片比例</label> 218 | <div className="grid grid-cols-3 gap-2"> 219 | <SettingButton label="方形 1:1" isActive={aspectRatio === '1:1'} onClick={() => onAspectRatioChange('1:1')} disabled={isLoading}> 220 | <SquareIcon className="w-5 h-5" /> 221 | <span>1:1</span> 222 | </SettingButton> 223 | <SettingButton label="横屏 16:9" isActive={aspectRatio === '16:9'} onClick={() => onAspectRatioChange('16:9')} disabled={isLoading}> 224 | <RectangleHorizontalIcon className="w-5 h-5" /> 225 | <span>16:9</span> 226 | </SettingButton> 227 | <SettingButton label="竖屏 9:16" isActive={aspectRatio === '9:16'} onClick={() => onAspectRatioChange('9:16')} disabled={isLoading}> 228 | <RectangleVerticalIcon className="w-5 h-5" /> 229 | <span>9:16</span> 230 | </SettingButton> 231 | </div> 232 | </div> 233 | </div> 234 | 235 | {error && <p className="text-sm text-red-600">{error}</p>} 236 | <button 237 | type="submit" 238 | className="bg-indigo-600 mt-4 text-white font-bold py-3 px-12 text-lg rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-400 disabled:cursor-not-allowed disabled:scale-100" 239 | disabled={isLoading} 240 | > 241 | {isLoading ? '生成中...' : '生成图片'} 242 | </button> 243 | </div> 244 | </form> 245 | </div> 246 | </section> 247 | 248 | <main className="container mx-auto px-4 py-12"> 249 | {error && !prompt.trim() && ( 250 | <div className="mb-8 text-center bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg" role="alert"> 251 | <strong className="font-bold">错误:</strong> 252 | <span className="block sm:inline">{error}</span> 253 | </div> 254 | )} 255 | 256 | <div className="mt-4"> 257 | {isLoading ? ( 258 | <LoadingState title="正在为您生成新的杰作..." message="AI 正在努力创作,这可能需要一点时间。" /> 259 | ) : generatedImages.length > 0 ? ( 260 | <ImageGrid 261 | images={generatedImages} 262 | onImageClick={setPreviewImageIndex} 263 | onToggleFavorite={onToggleFavorite} 264 | /> 265 | ) : ( 266 | <EmptyState icon="✨" title="输入描述,开始创作" message="在上方输入您的想法,让我为您生成图片吧!" /> 267 | )} 268 | </div> 269 | </main> 270 | 271 | <ImagePreview 272 | images={generatedImages} 273 | currentIndex={previewImageIndex} 274 | onClose={() => setPreviewImageIndex(null)} 275 | onChange={setPreviewImageIndex} 276 | actions={previewActions} 277 | /> 278 | 279 | <InpaintingModal 280 | isOpen={!!editingImage} 281 | onClose={() => setEditingImage(null)} 282 | image={editingImage} 283 | apiKey={apiKey} 284 | onComplete={handleInpaintingComplete} 285 | onApiKeyNeeded={onApiKeyNeeded} 286 | /> 287 | </> 288 | ); 289 | }; 290 | -------------------------------------------------------------------------------- /components/ImageToVideo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { ImageUploader } from './ImageUploader'; 3 | import { EmptyState } from './EmptyState'; 4 | import { generateVideo, getVideosOperation } from '../services/geminiService'; 5 | import { fileToBase64 } from '../utils/imageUtils'; 6 | import { CameraMovement } from '../types'; 7 | 8 | const loadingMessages = [ 9 | "AI导演正在构思剧本...", 10 | "正在准备拍摄场景...", 11 | "灯光、摄像、开拍!", 12 | "正在渲染第一帧...", 13 | "逐帧生成中,这需要一些耐心...", 14 | "正在处理视频流...", 15 | "后期制作,加入特效...", 16 | "即将完成,敬请期待!", 17 | ]; 18 | 19 | interface ImageToVideoProps { 20 | apiKey: string | null; 21 | onApiKeyNeeded: () => void; 22 | onResult: (prompt: string, videoUrl: string, sourceImage: string, cameraMovement: CameraMovement) => Promise<void>; 23 | initialPrompt: string; 24 | onPromptChange: (newPrompt: string) => void; 25 | initialStartFile: File | null; 26 | onStartFileChange: (file: File | null) => void; 27 | generatedVideoUrl: string | null; 28 | onGenerationStart: () => void; 29 | onGenerationEnd: () => void; 30 | isLoading: boolean; 31 | cameraMovement: CameraMovement; 32 | onCameraMovementChange: (movement: CameraMovement) => void; 33 | } 34 | 35 | const SettingButton: React.FC<{ 36 | label: string; 37 | isActive: boolean; 38 | onClick: () => void; 39 | disabled: boolean; 40 | }> = ({ label, isActive, onClick, disabled }) => ( 41 | <button 42 | type="button" 43 | onClick={onClick} 44 | disabled={disabled} 45 | className={`w-full px-3 sm:px-4 py-2 rounded-full transition-all duration-300 text-sm font-semibold whitespace-nowrap ${ 46 | isActive 47 | ? 'bg-indigo-600 text-white shadow' 48 | : 'bg-white text-slate-600 hover:bg-white/80' 49 | } disabled:cursor-not-allowed disabled:opacity-60`} 50 | > 51 | {label} 52 | </button> 53 | ); 54 | 55 | export const ImageToVideo: React.FC<ImageToVideoProps> = ({ 56 | apiKey, 57 | onApiKeyNeeded, 58 | onResult, 59 | initialPrompt, 60 | onPromptChange, 61 | initialStartFile, 62 | onStartFileChange, 63 | generatedVideoUrl, 64 | onGenerationStart, 65 | onGenerationEnd, 66 | isLoading, 67 | cameraMovement, 68 | onCameraMovementChange, 69 | }) => { 70 | const [startFile, setStartFile] = useState<File[]>(initialStartFile ? [initialStartFile] : []); 71 | const [error, setError] = useState<string | null>(null); 72 | const [operation, setOperation] = useState<any>(null); 73 | const [loadingMessage, setLoadingMessage] = useState(loadingMessages[0]); 74 | const [aspectRatio, setAspectRatio] = useState<'16:9' | '9:16'>('16:9'); 75 | 76 | const pollingIntervalRef = useRef<number | null>(null); 77 | 78 | useEffect(() => { 79 | setStartFile(initialStartFile ? [initialStartFile] : []); 80 | }, [initialStartFile]); 81 | 82 | const handleStartFileChange = (files: File[]) => { 83 | setStartFile(files); 84 | onStartFileChange(files.length > 0 ? files[0] : null); 85 | }; 86 | 87 | useEffect(() => { 88 | if (isLoading) { 89 | let messageIndex = 0; 90 | const interval = setInterval(() => { 91 | messageIndex = (messageIndex + 1) % loadingMessages.length; 92 | setLoadingMessage(loadingMessages[messageIndex]); 93 | }, 4000); 94 | return () => clearInterval(interval); 95 | } 96 | }, [isLoading]); 97 | 98 | const pollOperation = async (op: any) => { 99 | if (!apiKey) { 100 | onGenerationEnd(); 101 | return; 102 | } 103 | try { 104 | const updatedOp = await getVideosOperation(op, apiKey); 105 | setOperation(updatedOp); 106 | 107 | if (updatedOp.done) { 108 | if (pollingIntervalRef.current) clearTimeout(pollingIntervalRef.current); 109 | 110 | const videoUri = updatedOp.response?.generatedVideos?.[0]?.video?.uri; 111 | if (videoUri && startFile.length > 0) { 112 | const finalUrl = `${videoUri}&key=${apiKey}`; 113 | const startImageBase64 = await fileToBase64(startFile[0]); 114 | await onResult(initialPrompt, finalUrl, startImageBase64, cameraMovement); 115 | } else { 116 | setError("视频生成完成,但未能获取视频链接或缺少起始图片。"); 117 | } 118 | onGenerationEnd(); 119 | } else { 120 | pollingIntervalRef.current = window.setTimeout(() => pollOperation(updatedOp), 10000); 121 | } 122 | } catch(err) { 123 | setError(err instanceof Error ? err.message : '轮询视频状态时发生错误。'); 124 | onGenerationEnd(); 125 | } 126 | }; 127 | 128 | const handleGenerate = async (e: React.FormEvent) => { 129 | e.preventDefault(); 130 | if (!apiKey) { 131 | onApiKeyNeeded(); 132 | return; 133 | } 134 | if (startFile.length === 0) { 135 | setError('请上传一张图片。'); 136 | return; 137 | } 138 | if (!initialPrompt.trim()) { 139 | setError('请输入您的创意指令。'); 140 | return; 141 | } 142 | 143 | setError(null); 144 | onGenerationStart(); 145 | setOperation(null); 146 | setLoadingMessage(loadingMessages[0]); 147 | 148 | try { 149 | const op = await generateVideo(initialPrompt, startFile[0], aspectRatio, cameraMovement, apiKey); 150 | setOperation(op); 151 | pollOperation(op); 152 | } catch (err) { 153 | setError(err instanceof Error ? err.message : '发生未知错误。'); 154 | onGenerationEnd(); 155 | } 156 | }; 157 | 158 | const handleDownload = () => { 159 | if (!generatedVideoUrl) return; 160 | const link = document.createElement('a'); 161 | link.href = generatedVideoUrl; 162 | link.target = '_blank'; 163 | link.download = `图生视频-${Date.now()}.mp4`; 164 | document.body.appendChild(link); 165 | link.click(); 166 | document.body.removeChild(link); 167 | }; 168 | 169 | return ( 170 | <> 171 | <section className="w-full flex flex-col items-center justify-center py-12 md:py-16 bg-slate-50 border-b border-slate-200"> 172 | <div className="container mx-auto px-4 text-center"> 173 | <h1 className="text-4xl md:text-6xl font-extrabold text-slate-800 tracking-tight"> 174 | 让图片动起来,<span className="text-indigo-600">生成精彩视频</span> 175 | </h1> 176 | <p className="mt-4 text-base md:text-lg text-slate-600 max-w-2xl mx-auto"> 177 | 上传您的图片,描述您想看到的动态场景,让 AI 为您创造视频。 178 | </p> 179 | <div className="max-w-5xl mx-auto mt-10 text-left bg-white p-6 md:p-8 rounded-2xl shadow-xl border border-slate-200"> 180 | <form onSubmit={handleGenerate}> 181 | <div className="grid md:grid-cols-12 gap-8 items-stretch"> 182 | {/* Left Column: Uploader & Settings */} 183 | <div className="md:col-span-5 flex flex-col gap-6"> 184 | <div> 185 | <h3 className="text-xl font-bold text-slate-700 mb-3">1. 上传图片</h3> 186 | <ImageUploader files={startFile} onFilesChange={handleStartFileChange} disabled={isLoading} maxFiles={1} /> 187 | </div> 188 | <div> 189 | <h3 className="text-xl font-bold text-slate-700 mb-3">2. 运镜方式</h3> 190 | <div className="grid grid-cols-3 gap-2 bg-slate-100 p-1 rounded-full"> 191 | <SettingButton label="默认" isActive={cameraMovement === 'subtle'} onClick={() => onCameraMovementChange('subtle')} disabled={isLoading} /> 192 | <SettingButton label="推近" isActive={cameraMovement === 'zoomIn'} onClick={() => onCameraMovementChange('zoomIn')} disabled={isLoading} /> 193 | <SettingButton label="拉远" isActive={cameraMovement === 'zoomOut'} onClick={() => onCameraMovementChange('zoomOut')} disabled={isLoading} /> 194 | </div> 195 | </div> 196 | <div> 197 | <h3 className="text-xl font-bold text-slate-700 mb-3">3. 视频比例</h3> 198 | <div className="grid grid-cols-2 gap-2 bg-slate-100 p-1 rounded-full"> 199 | <SettingButton label="16:9 横屏" isActive={aspectRatio === '16:9'} onClick={() => setAspectRatio('16:9')} disabled={isLoading} /> 200 | <SettingButton label="9:16 竖屏" isActive={aspectRatio === '9:16'} onClick={() => setAspectRatio('9:16')} disabled={isLoading} /> 201 | </div> 202 | </div> 203 | </div> 204 | 205 | {/* Right Column: Prompt & Generate Button */} 206 | <div className="md:col-span-7 flex flex-col"> 207 | <h3 className="text-xl font-bold text-slate-700 mb-3">4. 输入创意指令</h3> 208 | <textarea 209 | id="video-prompt" 210 | value={initialPrompt} 211 | onChange={(e) => onPromptChange(e.target.value)} 212 | placeholder="例如:一只小猫变成一只雄狮" 213 | className="w-full flex-grow px-4 py-3 bg-slate-50 border border-slate-300 rounded-lg shadow-sm transition duration-200 text-base focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" 214 | disabled={isLoading} 215 | rows={10} 216 | /> 217 | <button 218 | type="submit" 219 | className="w-full mt-4 bg-indigo-600 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-400 disabled:cursor-not-allowed disabled:scale-100 text-lg" 220 | disabled={isLoading} 221 | > 222 | {isLoading ? '生成中...' : '🎬 生成视频'} 223 | </button> 224 | </div> 225 | </div> 226 | </form> 227 | </div> 228 | </div> 229 | </section> 230 | 231 | <main className="container mx-auto px-4 py-12"> 232 | {error && ( 233 | <div className="mb-8 text-center bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg" role="alert"> 234 | <strong className="font-bold">错误:</strong> 235 | <span className="block sm:inline">{error}</span> 236 | </div> 237 | )} 238 | 239 | <div className="mt-4"> 240 | {isLoading ? ( 241 | <div className="text-center"> 242 | <div className="mb-4 inline-block animate-spin rounded-full h-12 w-12 border-4 border-solid border-indigo-500 border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"></div> 243 | <h3 className="text-2xl font-semibold text-slate-700">正在生成视频,请稍候...</h3> 244 | <p className="text-slate-500 mt-2 text-lg">{loadingMessage}</p> 245 | <p className="text-sm text-slate-400 mt-4">(视频生成通常需要几分钟时间,请勿关闭页面)</p> 246 | </div> 247 | ) : generatedVideoUrl ? ( 248 | <div className="max-w-4xl mx-auto"> 249 | <div className="aspect-video w-full bg-slate-100 rounded-2xl shadow-lg overflow-hidden border border-slate-200"> 250 | <video src={generatedVideoUrl} controls autoPlay loop className="w-full h-full object-contain"></video> 251 | </div> 252 | <div className="text-center mt-6"> 253 | <button 254 | onClick={handleDownload} 255 | className="bg-indigo-600 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105" 256 | > 257 | 下载视频 258 | </button> 259 | </div> 260 | </div> 261 | ) : ( 262 | <EmptyState icon="🎬" title="上传图片,开始创作视频" message="在上方上传图片并输入指令,让我为您生成一段精彩的视频吧!" /> 263 | )} 264 | </div> 265 | </main> 266 | </> 267 | ); 268 | }; -------------------------------------------------------------------------------- /components/InpaintingModal.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | import React, { useState, useEffect, useRef, useCallback } from 'react'; 4 | import { GeneratedImage } from '../types'; 5 | import { generateInpainting } from '../services/geminiService'; 6 | import { base64ToFile } from '../utils/imageUtils'; 7 | 8 | interface InpaintingModalProps { 9 | isOpen: boolean; 10 | onClose: () => void; 11 | image: GeneratedImage | null; 12 | apiKey: string | null; 13 | onComplete: (newImageSrc: string) => void; 14 | onApiKeyNeeded: () => void; 15 | } 16 | 17 | const BRUSH_COLOR = 'rgba(99, 102, 241, 0.6)'; 18 | const BRUSH_SIZES = { small: 3, medium: 8, large: 15 }; 19 | 20 | type Path = { 21 | size: number; 22 | points: { x: number; y: number }[]; 23 | }; 24 | 25 | const drawPaths = (ctx: CanvasRenderingContext2D, pathsToDraw: Path[]) => { 26 | ctx.lineCap = 'round'; 27 | ctx.lineJoin = 'round'; 28 | 29 | pathsToDraw.forEach(path => { 30 | if (path.points.length < 1) return; 31 | ctx.strokeStyle = BRUSH_COLOR; 32 | ctx.lineWidth = path.size; 33 | ctx.beginPath(); 34 | ctx.moveTo(path.points[0].x, path.points[0].y); 35 | path.points.slice(1).forEach(point => ctx.lineTo(point.x, point.y)); 36 | ctx.stroke(); 37 | }); 38 | }; 39 | 40 | export const InpaintingModal: React.FC<InpaintingModalProps> = ({ isOpen, onClose, image, apiKey, onComplete, onApiKeyNeeded }) => { 41 | const [prompt, setPrompt] = useState(''); 42 | const [brushSize, setBrushSize] = useState(BRUSH_SIZES.medium); 43 | const [isLoading, setIsLoading] = useState(false); 44 | const [error, setError] = useState<string | null>(null); 45 | 46 | const canvasRef = useRef<HTMLCanvasElement>(null); 47 | const imageRef = useRef<HTMLImageElement | null>(null); 48 | const isDrawing = useRef(false); 49 | 50 | const [paths, setPaths] = useState<Path[]>([]); 51 | const [currentPath, setCurrentPath] = useState<Path | null>(null); 52 | const [generatedResultSrc, setGeneratedResultSrc] = useState<string | null>(null); 53 | const [showOriginal, setShowOriginal] = useState(false); 54 | 55 | 56 | // Effect for loading image and setting up canvas dimensions 57 | useEffect(() => { 58 | if (!isOpen || !image) { 59 | setPaths([]); 60 | setCurrentPath(null); 61 | setPrompt(''); 62 | setError(null); 63 | setGeneratedResultSrc(null); 64 | setShowOriginal(false); 65 | return; 66 | } 67 | 68 | const canvas = canvasRef.current; 69 | const imageEl = new Image(); 70 | imageEl.crossOrigin = "anonymous"; 71 | imageEl.src = image.src; 72 | imageRef.current = imageEl; 73 | 74 | imageEl.onload = () => { 75 | if (canvas && imageRef.current) { 76 | const imageToDraw = imageRef.current; 77 | const aspectRatio = imageToDraw.naturalWidth / imageToDraw.naturalHeight; 78 | const maxHeight = window.innerHeight * 0.75; 79 | const maxWidth = Math.min(window.innerWidth * 0.8, imageToDraw.naturalWidth, 800, maxHeight * aspectRatio); 80 | 81 | canvas.width = maxWidth; 82 | canvas.height = maxWidth / aspectRatio; 83 | 84 | const ctx = canvas.getContext('2d'); 85 | if (ctx) { 86 | ctx.clearRect(0, 0, canvas.width, canvas.height); 87 | ctx.drawImage(imageToDraw, 0, 0, canvas.width, canvas.height); 88 | } 89 | } 90 | }; 91 | }, [isOpen, image]); 92 | 93 | // Effect for redrawing canvas when state changes 94 | useEffect(() => { 95 | const canvas = canvasRef.current; 96 | if (!isOpen || !canvas) return; 97 | const ctx = canvas.getContext('2d'); 98 | if (!ctx) return; 99 | 100 | const imageEl = imageRef.current; 101 | if (!imageEl || !imageEl.complete) return; 102 | 103 | if (generatedResultSrc) { 104 | // When showing a result, we don't want the brush strokes on the original 105 | const imageToDisplaySrc = showOriginal ? image!.src : generatedResultSrc; 106 | const displayImg = new Image(); 107 | displayImg.src = imageToDisplaySrc; 108 | displayImg.onload = () => { 109 | ctx.clearRect(0, 0, canvas.width, canvas.height); 110 | ctx.drawImage(displayImg, 0, 0, canvas.width, canvas.height); 111 | }; 112 | } else { 113 | ctx.clearRect(0, 0, canvas.width, canvas.height); 114 | ctx.drawImage(imageEl, 0, 0, canvas.width, canvas.height); 115 | const allPaths = currentPath ? [...paths, currentPath] : paths; 116 | drawPaths(ctx, allPaths); 117 | } 118 | }, [isOpen, image, paths, currentPath, generatedResultSrc, showOriginal]); 119 | 120 | 121 | const getCanvasPoint = (e: React.MouseEvent | React.TouchEvent) => { 122 | const canvas = canvasRef.current; 123 | if (!canvas) return null; 124 | const rect = canvas.getBoundingClientRect(); 125 | const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; 126 | const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; 127 | return { 128 | x: (clientX - rect.left) / rect.width * canvas.width, 129 | y: (clientY - rect.top) / rect.height * canvas.height, 130 | }; 131 | }; 132 | 133 | const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { 134 | if (isLoading || generatedResultSrc) return; 135 | isDrawing.current = true; 136 | const point = getCanvasPoint(e); 137 | if (!point) return; 138 | setCurrentPath({ size: brushSize, points: [point] }); 139 | }; 140 | 141 | const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { 142 | if (!isDrawing.current || isLoading || generatedResultSrc) return; 143 | e.preventDefault(); 144 | const point = getCanvasPoint(e); 145 | if (!point) return; 146 | 147 | setCurrentPath(prev => { 148 | if (!prev) return null; 149 | return { ...prev, points: [...prev.points, point] }; 150 | }); 151 | }; 152 | 153 | const handleMouseUp = () => { 154 | if (isDrawing.current && currentPath && currentPath.points.length > 0) { 155 | setPaths(prevPaths => [...prevPaths, currentPath]); 156 | } 157 | isDrawing.current = false; 158 | setCurrentPath(null); 159 | }; 160 | 161 | const handleUndo = () => { 162 | setPaths(prevPaths => prevPaths.slice(0, -1)); 163 | }; 164 | 165 | const handleClear = () => { 166 | setPaths([]); 167 | setCurrentPath(null); 168 | }; 169 | 170 | const createMaskFile = async (): Promise<File> => { 171 | if (!imageRef.current) throw new Error("Image reference not found"); 172 | const { naturalWidth, naturalHeight } = imageRef.current; 173 | 174 | const maskCanvas = document.createElement('canvas'); 175 | maskCanvas.width = naturalWidth; 176 | maskCanvas.height = naturalHeight; 177 | const ctx = maskCanvas.getContext('2d'); 178 | if (!ctx) throw new Error("Could not get mask context"); 179 | 180 | const scaleX = canvasRef.current ? naturalWidth / canvasRef.current.width : 1; 181 | const scaleY = canvasRef.current ? naturalHeight / canvasRef.current.height : 1; 182 | 183 | ctx.fillStyle = 'black'; 184 | ctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height); 185 | 186 | ctx.strokeStyle = 'white'; 187 | ctx.fillStyle = 'white'; 188 | ctx.lineCap = 'round'; 189 | ctx.lineJoin = 'round'; 190 | 191 | paths.forEach(path => { 192 | if (path.points.length === 0) return; 193 | ctx.lineWidth = path.size * scaleX; 194 | 195 | if (path.points.length === 1) { 196 | ctx.beginPath(); 197 | ctx.arc(path.points[0].x * scaleX, path.points[0].y * scaleY, (path.size * scaleX) / 2, 0, 2 * Math.PI); 198 | ctx.fill(); 199 | } else { 200 | ctx.beginPath(); 201 | ctx.moveTo(path.points[0].x * scaleX, path.points[0].y * scaleY); 202 | path.points.slice(1).forEach(p => ctx.lineTo(p.x * scaleX, p.y * scaleY)); 203 | ctx.stroke(); 204 | } 205 | }); 206 | 207 | const maskDataUrl = maskCanvas.toDataURL('image/png'); 208 | return base64ToFile(maskDataUrl, 'mask.png'); 209 | }; 210 | 211 | const handleGenerate = async () => { 212 | if (!apiKey) { onApiKeyNeeded(); return; } 213 | if (!image) { setError("没有可编辑的图片。"); return; } 214 | if (!prompt.trim()) { setError("请输入重绘指令。"); return; } 215 | if (paths.length === 0) { setError("请在图片上绘制您想修改的区域。"); return; } 216 | 217 | setIsLoading(true); 218 | setError(null); 219 | 220 | try { 221 | const [originalFile, maskFile] = await Promise.all([ 222 | base64ToFile(image.src, 'original.png'), 223 | createMaskFile(), 224 | ]); 225 | 226 | const result = await generateInpainting(prompt, originalFile, maskFile, apiKey); 227 | 228 | if (result.length > 0) { 229 | setGeneratedResultSrc(result[0]); 230 | } else { 231 | setError("AI未能生成图片,请重试。"); 232 | } 233 | } catch (err) { 234 | setError(err instanceof Error ? err.message : '发生未知错误。'); 235 | } finally { 236 | setIsLoading(false); 237 | } 238 | }; 239 | 240 | const handleConfirm = () => { 241 | if (generatedResultSrc) { 242 | onComplete(generatedResultSrc); 243 | } 244 | }; 245 | 246 | const handleTryAgain = () => { 247 | setGeneratedResultSrc(null); 248 | setShowOriginal(false); 249 | setError(null); 250 | setPaths([]); 251 | setCurrentPath(null); 252 | setPrompt(''); 253 | }; 254 | 255 | if (!isOpen) return null; 256 | 257 | return ( 258 | <div 259 | className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in" 260 | onClick={onClose} 261 | role="dialog" 262 | aria-modal="true" 263 | > 264 | <div 265 | className="bg-slate-800 rounded-2xl shadow-2xl p-4 md:p-6 max-w-6xl w-full text-white flex flex-col md:flex-row gap-6 max-h-[95vh] overflow-y-auto" 266 | onClick={(e) => e.stopPropagation()} 267 | > 268 | <div className="flex-grow flex flex-col items-center justify-center relative"> 269 | <canvas 270 | ref={canvasRef} 271 | className={`rounded-lg max-w-full max-h-[80vh] ${isLoading ? 'cursor-not-allowed' : generatedResultSrc ? 'cursor-default' : 'cursor-crosshair'}`} 272 | onMouseDown={handleMouseDown} 273 | onMouseMove={handleMouseMove} 274 | onMouseUp={handleMouseUp} 275 | onMouseLeave={handleMouseUp} 276 | onTouchStart={handleMouseDown} 277 | onTouchMove={handleMouseMove} 278 | onTouchEnd={handleMouseUp} 279 | /> 280 | {isLoading && ( 281 | <div className="absolute inset-0 bg-black/70 flex flex-col items-center justify-center rounded-lg"> 282 | <div className="animate-spin rounded-full h-12 w-12 border-4 border-solid border-indigo-400 border-r-transparent"></div> 283 | <p className="mt-4 text-lg">正在重绘,请稍候...</p> 284 | </div> 285 | )} 286 | </div> 287 | 288 | <div className="w-full md:w-80 flex-shrink-0 flex flex-col gap-4"> 289 | {generatedResultSrc ? ( 290 | <> 291 | <div className="flex items-center justify-between"> 292 | <h2 className="text-xl font-bold">确认结果</h2> 293 | <button onClick={onClose} className="text-slate-400 text-3xl leading-none hover:text-white">×</button> 294 | </div> 295 | <p className="text-sm text-slate-300"> 296 | 对结果满意吗?您可以确认保存,或者返回修改后重试。 297 | </p> 298 | <button onClick={() => setShowOriginal(prev => !prev)} className="w-full py-2 text-sm bg-slate-600 rounded-md hover:bg-slate-500"> 299 | {showOriginal ? '显示重绘结果' : '显示原图'} 300 | </button> 301 | {error && ( 302 | <div className="text-sm bg-red-900/50 border border-red-700 text-red-300 p-2 rounded-lg">{error}</div> 303 | )} 304 | <div className="mt-auto flex flex-col gap-3"> 305 | <button onClick={handleConfirm} className="w-full bg-indigo-600 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-700 transition-transform transform hover:scale-105"> 306 | 确认并保存 307 | </button> 308 | <button onClick={handleTryAgain} className="w-full bg-slate-600 text-white font-semibold py-3 px-8 rounded-full hover:bg-slate-500 transition-colors"> 309 | 返回重试 310 | </button> 311 | </div> 312 | </> 313 | ) : ( 314 | <> 315 | <div className="flex items-center justify-between"> 316 | <h2 className="text-xl font-bold">局部重绘</h2> 317 | <button onClick={onClose} className="text-slate-400 text-3xl leading-none hover:text-white">×</button> 318 | </div> 319 | 320 | <div> 321 | <label className="text-sm font-medium text-slate-300 mb-1 block">1. 涂抹要重绘的区域</label> 322 | <p className="text-xs text-slate-400 mb-2">用画笔涂抹您想让 AI 重新生成的部分。</p> 323 | <div className="bg-slate-700/50 p-2 rounded-lg flex items-center gap-2"> 324 | {Object.entries(BRUSH_SIZES).map(([name, size]) => ( 325 | <button key={name} onClick={() => setBrushSize(size)} className={`flex-1 py-2 text-xs rounded-md capitalize transition-colors ${brushSize === size ? 'bg-indigo-600' : 'bg-slate-600 hover:bg-slate-500'}`}> 326 | {name} 327 | </button> 328 | ))} 329 | </div> 330 | <div className="flex gap-2 mt-2"> 331 | <button onClick={handleUndo} disabled={paths.length === 0 || isLoading} className="flex-1 py-2 text-sm bg-slate-600 rounded-md hover:bg-slate-500 disabled:opacity-50 disabled:cursor-not-allowed">撤销</button> 332 | <button onClick={handleClear} disabled={paths.length === 0 || isLoading} className="flex-1 py-2 text-sm bg-slate-600 rounded-md hover:bg-slate-500 disabled:opacity-50 disabled:cursor-not-allowed">清除</button> 333 | </div> 334 | </div> 335 | 336 | <div> 337 | <label htmlFor="inpainting-prompt" className="text-sm font-medium text-slate-300 mb-1 block">2. 输入重绘指令</label> 338 | <div className={`w-full p-2 bg-slate-700 border border-slate-600 rounded-lg transition-colors focus-within:border-indigo-500 ${isLoading ? 'bg-slate-800' : ''}`}> 339 | <textarea 340 | id="inpainting-prompt" 341 | value={prompt} 342 | onChange={e => setPrompt(e.target.value)} 343 | placeholder="您想把涂抹区域变成什么?例如:一副太阳镜" 344 | rows={4} 345 | className="w-full bg-transparent border-none focus:outline-none focus:ring-0 text-white resize-none leading-relaxed" 346 | disabled={isLoading} 347 | /> 348 | </div> 349 | </div> 350 | 351 | {error && ( 352 | <div className="text-sm bg-red-900/50 border border-red-700 text-red-300 p-2 rounded-lg">{error}</div> 353 | )} 354 | 355 | <button 356 | onClick={handleGenerate} 357 | disabled={isLoading} 358 | className="w-full mt-auto bg-indigo-600 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-500 disabled:cursor-not-allowed disabled:scale-100 text-lg" 359 | > 360 | {isLoading ? '生成中...' : '✨ 开始重绘'} 361 | </button> 362 | </> 363 | )} 364 | </div> 365 | </div> 366 | </div> 367 | ); 368 | }; 369 | -------------------------------------------------------------------------------- /components/ImportExportModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import { getAllHistory, clearHistory, addHistory } from '../services/historyService'; 3 | import { getTags, saveTags } from '../services/historyService'; 4 | import { HistoryRecord } from '../types'; 5 | 6 | interface ImportExportModalProps { 7 | isOpen: boolean; 8 | onClose: () => void; 9 | onImportComplete: () => void; 10 | } 11 | 12 | export const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose, onImportComplete }) => { 13 | const [activeTab, setActiveTab] = useState<'export' | 'import'>('export'); 14 | const [isExporting, setIsExporting] = useState(false); 15 | const [exportData, setExportData] = useState<string>(''); 16 | const [isImporting, setIsImporting] = useState(false); 17 | const [importStatus, setImportStatus] = useState<{ success: boolean; message: string } | null>(null); 18 | const fileInputRef = useRef<HTMLInputElement>(null); 19 | 20 | const handleExport = async () => { 21 | setIsExporting(true); 22 | try { 23 | // 获取所有历史记录 24 | const historyRecords = await getAllHistory(); 25 | // 获取所有标签 26 | const tags = await getTags(); 27 | 28 | // 创建导出数据对象 29 | const exportData = { 30 | version: '1.0', 31 | exportDate: new Date().toISOString(), 32 | historyRecords, 33 | tags 34 | }; 35 | 36 | // 转换为 JSON 字符串 37 | const jsonData = JSON.stringify(exportData, null, 2); 38 | setExportData(jsonData); 39 | } catch (error) { 40 | console.error('导出失败:', error); 41 | setExportData('导出失败: ' + (error instanceof Error ? error.message : '未知错误')); 42 | } finally { 43 | setIsExporting(false); 44 | } 45 | }; 46 | 47 | const handleDownload = () => { 48 | if (!exportData) return; 49 | 50 | const blob = new Blob([exportData], { type: 'application/json' }); 51 | const url = URL.createObjectURL(blob); 52 | const a = document.createElement('a'); 53 | a.href = url; 54 | a.download = `image-studio-export-${new Date().toISOString().slice(0, 10)}.json`; 55 | document.body.appendChild(a); 56 | a.click(); 57 | document.body.removeChild(a); 58 | URL.revokeObjectURL(url); 59 | }; 60 | 61 | const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => { 62 | const file = event.target.files?.[0]; 63 | if (!file) return; 64 | 65 | setIsImporting(true); 66 | setImportStatus(null); 67 | 68 | try { 69 | const text = await file.text(); 70 | const importedData = JSON.parse(text); 71 | 72 | // 验证数据格式 73 | if (!importedData.historyRecords || !Array.isArray(importedData.historyRecords)) { 74 | throw new Error('无效的数据格式:缺少历史记录'); 75 | } 76 | 77 | // 清空现有数据 78 | await clearHistory(); 79 | 80 | // 导入历史记录 81 | for (const record of importedData.historyRecords as HistoryRecord[]) { 82 | // 确保记录有正确的结构 83 | const cleanRecord: HistoryRecord = { 84 | id: record.id || Date.now().toString() + Math.random().toString(36).substr(2, 9), 85 | mode: record.mode, 86 | prompt: record.prompt, 87 | thumbnail: record.thumbnail, 88 | timestamp: record.timestamp || Date.now(), 89 | // 可选字段 90 | ...(record.style && { style: record.style }), 91 | ...(record.model && { model: record.model }), 92 | ...(record.images && { images: record.images }), 93 | ...(record.videoUrl && { videoUrl: record.videoUrl }), 94 | ...(record.sourceImage && { sourceImage: record.sourceImage }), 95 | ...(record.cameraMovement && { cameraMovement: record.cameraMovement }), 96 | ...(record.numberOfImages && { numberOfImages: record.numberOfImages }), 97 | ...(record.aspectRatio && { aspectRatio: record.aspectRatio }), 98 | ...(record.comicStripNumImages && { comicStripNumImages: record.comicStripNumImages }), 99 | ...(record.comicStripPanelPrompts && { comicStripPanelPrompts: record.comicStripPanelPrompts }), 100 | ...(record.comicStripType && { comicStripType: record.comicStripType }), 101 | ...(record.tags && { tags: record.tags }), 102 | ...(record.selectedKeywords && { selectedKeywords: record.selectedKeywords }), 103 | ...(record.negativePrompt && { negativePrompt: record.negativePrompt }), 104 | ...(record.i2iMode && { i2iMode: record.i2iMode }), 105 | ...(record.inspirationNumImages && { inspirationNumImages: record.inspirationNumImages }), 106 | ...(record.inspirationAspectRatio && { inspirationAspectRatio: record.inspirationAspectRatio }), 107 | ...(record.inspirationStrength && { inspirationStrength: record.inspirationStrength }), 108 | ...(record.parentId && { parentId: record.parentId }), 109 | ...(record.videoScripts && { videoScripts: record.videoScripts }), 110 | ...(record.videoUrls && { videoUrls: record.videoUrls }), 111 | ...(record.transitionUrls && { transitionUrls: record.transitionUrls }), 112 | ...(record.transitionOption && { transitionOption: record.transitionOption }), 113 | }; 114 | 115 | await addHistory(cleanRecord); 116 | } 117 | 118 | // 导入标签 119 | if (importedData.tags && Array.isArray(importedData.tags)) { 120 | await saveTags(importedData.tags); 121 | } 122 | 123 | setImportStatus({ success: true, message: `成功导入 ${importedData.historyRecords.length} 条记录` }); 124 | onImportComplete(); 125 | } catch (error) { 126 | console.error('导入失败:', error); 127 | setImportStatus({ success: false, message: '导入失败: ' + (error instanceof Error ? error.message : '未知错误') }); 128 | } finally { 129 | setIsImporting(false); 130 | if (fileInputRef.current) { 131 | fileInputRef.current.value = ''; 132 | } 133 | } 134 | }; 135 | 136 | const triggerFileSelect = () => { 137 | if (fileInputRef.current) { 138 | fileInputRef.current.click(); 139 | } 140 | }; 141 | 142 | if (!isOpen) return null; 143 | 144 | return ( 145 | <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> 146 | <div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"> 147 | <div className="flex justify-between items-center p-6 border-b border-slate-200"> 148 | <h2 className="text-2xl font-bold text-slate-800">数据导入/导出</h2> 149 | <button 150 | onClick={onClose} 151 | className="text-slate-400 hover:text-slate-600 transition-colors" 152 | aria-label="关闭" 153 | > 154 | <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 155 | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> 156 | </svg> 157 | </button> 158 | </div> 159 | 160 | <div className="flex border-b border-slate-200"> 161 | <button 162 | className={`flex-1 py-4 px-6 text-center font-medium ${ 163 | activeTab === 'export' 164 | ? 'text-indigo-600 border-b-2 border-indigo-600' 165 | : 'text-slate-500 hover:text-slate-700' 166 | }`} 167 | onClick={() => setActiveTab('export')} 168 | > 169 | 导出数据 170 | </button> 171 | <button 172 | className={`flex-1 py-4 px-6 text-center font-medium ${ 173 | activeTab === 'import' 174 | ? 'text-indigo-600 border-b-2 border-indigo-600' 175 | : 'text-slate-500 hover:text-slate-700' 176 | }`} 177 | onClick={() => setActiveTab('import')} 178 | > 179 | 导入数据 180 | </button> 181 | </div> 182 | 183 | <div className="flex-grow overflow-y-auto p-6"> 184 | {activeTab === 'export' && ( 185 | <div className="space-y-6"> 186 | <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> 187 | <h3 className="font-semibold text-blue-800 flex items-center gap-2"> 188 | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> 189 | <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /> 190 | </svg> 191 | 导出说明 192 | </h3> 193 | <p className="text-blue-700 mt-2 text-sm"> 194 | 导出功能将保存您所有的创作历史记录和标签。导出的文件为 JSON 格式,可以在其他设备上导入使用。 195 | </p> 196 | </div> 197 | 198 | <div className="space-y-4"> 199 | <button 200 | onClick={handleExport} 201 | disabled={isExporting} 202 | className="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50" 203 | > 204 | {isExporting ? ( 205 | <> 206 | <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 207 | <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> 208 | <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 209 | </svg> 210 | 正在生成导出数据... 211 | </> 212 | ) : ( 213 | <> 214 | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> 215 | <path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> 216 | </svg> 217 | 生成导出数据 218 | </> 219 | )} 220 | </button> 221 | 222 | {exportData && !isExporting && ( 223 | <div className="space-y-4"> 224 | <div className="bg-slate-50 rounded-lg p-4"> 225 | <div className="flex justify-between items-center mb-2"> 226 | <h3 className="font-medium text-slate-700">预览导出数据</h3> 227 | <span className="text-xs text-slate-500"> 228 | {Math.ceil(new Blob([exportData]).size / 1024)} KB 229 | </span> 230 | </div> 231 | <pre className="text-xs bg-white p-3 rounded border max-h-40 overflow-auto"> 232 | {exportData.substring(0, 500)}... 233 | </pre> 234 | </div> 235 | 236 | <button 237 | onClick={handleDownload} 238 | className="w-full py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2" 239 | > 240 | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> 241 | <path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> 242 | </svg> 243 | 下载导出文件 244 | </button> 245 | </div> 246 | )} 247 | </div> 248 | </div> 249 | )} 250 | 251 | {activeTab === 'import' && ( 252 | <div className="space-y-6"> 253 | <div className="bg-amber-50 border border-amber-200 rounded-lg p-4"> 254 | <h3 className="font-semibold text-amber-800 flex items-center gap-2"> 255 | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> 256 | <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> 257 | </svg> 258 | 导入说明 259 | </h3> 260 | <p className="text-amber-700 mt-2 text-sm"> 261 | 导入功能将替换您当前的所有数据。请确保在导入前已备份重要数据。仅支持导入由本应用导出的 JSON 文件。 262 | </p> 263 | </div> 264 | 265 | <div className="space-y-4"> 266 | <input 267 | type="file" 268 | ref={fileInputRef} 269 | accept=".json" 270 | onChange={handleImport} 271 | className="hidden" 272 | /> 273 | 274 | <button 275 | onClick={triggerFileSelect} 276 | disabled={isImporting} 277 | className="w-full py-3 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50" 278 | > 279 | {isImporting ? ( 280 | <> 281 | <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 282 | <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> 283 | <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 284 | </svg> 285 | 正在导入数据... 286 | </> 287 | ) : ( 288 | <> 289 | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> 290 | <path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 011.414 0L9 8.586V3a1 1 0 112 0v5.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /> 291 | </svg> 292 | 选择导入文件 293 | </> 294 | )} 295 | </button> 296 | 297 | {importStatus && ( 298 | <div className={`p-4 rounded-lg ${importStatus.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}> 299 | <p className={`font-medium ${importStatus.success ? 'text-green-800' : 'text-red-800'}`}> 300 | {importStatus.message} 301 | </p> 302 | </div> 303 | )} 304 | 305 | <div className="bg-slate-50 rounded-lg p-4"> 306 | <h3 className="font-medium text-slate-700 mb-2">导入注意事项</h3> 307 | <ul className="text-sm text-slate-600 space-y-1"> 308 | <li className="flex items-start gap-2"> 309 | <span className="text-indigo-600 mt-0.5">•</span> 310 | <span>导入将清空当前所有数据并替换为导入的数据</span> 311 | </li> 312 | <li className="flex items-start gap-2"> 313 | <span className="text-indigo-600 mt-0.5">•</span> 314 | <span>请确保导入的文件是由本应用导出的 JSON 文件</span> 315 | </li> 316 | <li className="flex items-start gap-2"> 317 | <span className="text-indigo-600 mt-0.5">•</span> 318 | <span>导入过程可能需要一些时间,请耐心等待</span> 319 | </li> 320 | </ul> 321 | </div> 322 | </div> 323 | </div> 324 | )} 325 | </div> 326 | 327 | <div className="p-6 border-t border-slate-200 bg-slate-50"> 328 | <button 329 | onClick={onClose} 330 | className="w-full py-2.5 px-4 bg-slate-200 hover:bg-slate-300 text-slate-700 font-medium rounded-lg transition-colors" 331 | > 332 | 关闭 333 | </button> 334 | </div> 335 | </div> 336 | </div> 337 | ); 338 | }; -------------------------------------------------------------------------------- /components/ImageToImage.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useState, useEffect, useMemo } from 'react'; 3 | import { GeneratedImage, AspectRatio, InspirationStrength } from '../types'; 4 | import { generateFromImageAndPrompt, generateWithStyleInspiration } from '../services/geminiService'; 5 | import { ImageUploader } from './ImageUploader'; 6 | import { LoadingState } from './LoadingState'; 7 | import { ImageGrid } from './ImageGrid'; 8 | import { EmptyState } from './EmptyState'; 9 | import { ImagePreview } from './ImagePreview'; 10 | import { InpaintingModal } from './InpaintingModal'; 11 | import { resizeImage, base64ToFile } from '../utils/imageUtils'; 12 | import { DownloadIcon } from './icons/DownloadIcon'; 13 | import { SparklesIcon } from './icons/SparklesIcon'; 14 | import { VideoIcon } from './icons/VideoIcon'; 15 | import { EditIcon } from './icons/EditIcon'; 16 | import { StarIcon } from './icons/StarIcon'; 17 | import { SquareIcon, RectangleHorizontalIcon, RectangleVerticalIcon } from './icons/AspectRatioIcons'; 18 | 19 | interface ImageToImageProps { 20 | apiKey: string | null; 21 | onApiKeyNeeded: () => void; 22 | onGenerationStart: () => void; 23 | onGenerationEnd: () => void; 24 | onResult: (prompt: string, images: GeneratedImage[], sourceFile: File, mode: 'edit' | 'inspiration', settings: { numImages?: number, aspectRatio?: AspectRatio, strength?: InspirationStrength }) => Promise<void>; 25 | prompt: string; 26 | onPromptChange: (newPrompt: string) => void; 27 | generatedImages: GeneratedImage[]; 28 | onNavigateToVideo: (sourceImageSrc: string, sourcePrompt: string) => void; 29 | isLoading: boolean; 30 | onImageUpdate: (imageId: string, newSrc: string) => void; 31 | initialStartFile?: File | null; 32 | onStartFileChange?: (file: File | null) => void; 33 | i2iMode: 'edit' | 'inspiration'; 34 | onI2iModeChange: (mode: 'edit' | 'inspiration') => void; 35 | inspirationAspectRatio: AspectRatio; 36 | onInspirationAspectRatioChange: (ratio: AspectRatio) => void; 37 | inspirationStrength: InspirationStrength; 38 | onInspirationStrengthChange: (strength: InspirationStrength) => void; 39 | onToggleFavorite: (imageId: string) => void; 40 | } 41 | 42 | const ModeButton: React.FC<{ 43 | label: string; 44 | icon: React.ReactNode; 45 | isActive: boolean; 46 | onClick: () => void; 47 | disabled: boolean; 48 | }> = ({ label, icon, isActive, onClick, disabled }) => ( 49 | <button 50 | type="button" 51 | onClick={onClick} 52 | disabled={disabled} 53 | className={`w-full px-4 py-2 rounded-full transition-all duration-300 text-sm font-semibold whitespace-nowrap flex items-center justify-center gap-2 ${ 54 | isActive 55 | ? 'bg-indigo-600 text-white shadow-md' 56 | : 'bg-transparent text-slate-600 hover:bg-slate-100' 57 | } disabled:cursor-not-allowed disabled:opacity-60`} 58 | > 59 | {icon} 60 | {label} 61 | </button> 62 | ); 63 | 64 | export const ImageToImage: React.FC<ImageToImageProps> = ({ 65 | apiKey, 66 | onApiKeyNeeded, 67 | onGenerationStart, 68 | onGenerationEnd, 69 | onResult, 70 | prompt, 71 | onPromptChange, 72 | generatedImages, 73 | onNavigateToVideo, 74 | isLoading, 75 | onImageUpdate, 76 | initialStartFile, 77 | onStartFileChange, 78 | i2iMode, 79 | onI2iModeChange, 80 | inspirationAspectRatio, 81 | onInspirationAspectRatioChange, 82 | inspirationStrength, 83 | onInspirationStrengthChange, 84 | onToggleFavorite 85 | }) => { 86 | const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); 87 | const [error, setError] = useState<string | null>(null); 88 | const [editingImage, setEditingImage] = useState<GeneratedImage | null>(null); 89 | const [previewState, setPreviewState] = useState<{ type: 'uploaded' | 'generated', index: number } | null>(null); 90 | const [isComparing, setIsComparing] = useState(false); 91 | 92 | const strengthOptions: { value: InspirationStrength; label: string; description: string; }[] = [ 93 | { value: 'low', label: '弱', description: '轻度借鉴风格' }, 94 | { value: 'medium', label: '中', description: '明显风格倾向' }, 95 | { value: 'high', label: '强', description: '严格遵循风格' }, 96 | { value: 'veryHigh', label: '极强', description: '复刻画面风格' }, 97 | ]; 98 | 99 | const uploadedFileUrls = useMemo(() => 100 | uploadedFiles.map(file => URL.createObjectURL(file)), 101 | [uploadedFiles] 102 | ); 103 | 104 | useEffect(() => { 105 | return () => { 106 | uploadedFileUrls.forEach(url => URL.revokeObjectURL(url)); 107 | }; 108 | }, [uploadedFileUrls]); 109 | 110 | useEffect(() => { 111 | if (initialStartFile) { 112 | setUploadedFiles([initialStartFile]); 113 | } else { 114 | setUploadedFiles([]); 115 | } 116 | }, [initialStartFile]); 117 | 118 | const handleFilesChange = (newFiles: File[]) => { 119 | setUploadedFiles(newFiles); 120 | if (onStartFileChange) { 121 | onStartFileChange(newFiles.length > 0 ? newFiles[0] : null); 122 | } 123 | }; 124 | 125 | const handleGenerate = async (e: React.FormEvent) => { 126 | e.preventDefault(); 127 | if (!apiKey) { 128 | onApiKeyNeeded(); 129 | return; 130 | } 131 | if (uploadedFiles.length === 0) { 132 | setError(i2iMode === 'inspiration' ? '请上传一张参考图。' : '请至少上传一张图片。'); 133 | return; 134 | } 135 | if (!prompt.trim()) { 136 | setError(i2iMode === 'inspiration' ? '请输入您的新主题。' : '请输入您的创意指令。'); 137 | return; 138 | } 139 | 140 | setError(null); 141 | onGenerationStart(); 142 | setPreviewState(null); 143 | 144 | try { 145 | let imageUrls: string[]; 146 | let resultSettings: { strength?: InspirationStrength } = {}; 147 | 148 | if (i2iMode === 'inspiration') { 149 | imageUrls = await generateWithStyleInspiration(uploadedFiles[0], prompt, apiKey, inspirationStrength); 150 | resultSettings = { strength: inspirationStrength }; 151 | } else { 152 | imageUrls = await generateFromImageAndPrompt(prompt, uploadedFiles, apiKey); 153 | } 154 | 155 | const resizedImageUrls = await Promise.all( 156 | imageUrls.map(src => resizeImage(src)) 157 | ); 158 | 159 | const imagesWithIds = resizedImageUrls.map((src, index) => ({ 160 | id: `${Date.now()}-${index}`, 161 | src, 162 | isFavorite: false, 163 | })); 164 | 165 | await onResult(prompt, imagesWithIds, uploadedFiles[0], i2iMode, resultSettings); 166 | 167 | } catch (err) { 168 | setError(err instanceof Error ? err.message : '发生未知错误。'); 169 | } finally { 170 | onGenerationEnd(); 171 | } 172 | }; 173 | 174 | const handleDownload = () => { 175 | if (previewState === null || previewState.type !== 'generated') return; 176 | const { src } = generatedImages[previewState.index]; 177 | const link = document.createElement('a'); 178 | link.href = src; 179 | link.download = `以图生图-${Date.now()}.png`; 180 | document.body.appendChild(link); 181 | link.click(); 182 | document.body.removeChild(link); 183 | }; 184 | 185 | const handleContinueWithImage = async () => { 186 | if (previewState === null || previewState.type !== 'generated') return; 187 | const { src } = generatedImages[previewState.index]; 188 | try { 189 | const newFile = await base64ToFile(src, `continued-creation-${Date.now()}.png`); 190 | handleFilesChange([newFile]); 191 | setPreviewState(null); 192 | window.scrollTo({ top: 0, behavior: 'smooth' }); 193 | } catch (err) { 194 | setError(err instanceof Error ? err.message : '无法使用此图片继续创作。'); 195 | } 196 | }; 197 | 198 | const handleGenerateVideo = () => { 199 | if (previewState === null || previewState.type !== 'generated') return; 200 | const { src } = generatedImages[previewState.index]; 201 | onNavigateToVideo(src, prompt); 202 | }; 203 | 204 | const handleInpaintingComplete = async (newImageSrc: string) => { 205 | if (!editingImage) return; 206 | try { 207 | const resizedSrc = await resizeImage(newImageSrc); 208 | onImageUpdate(editingImage.id, resizedSrc); 209 | setEditingImage(null); // Close modal 210 | } catch (err) { 211 | setError("处理编辑后的图片失败。"); 212 | setEditingImage(null); 213 | } 214 | }; 215 | 216 | const handlePreviewUploaded = (index: number) => { 217 | setPreviewState({ type: 'uploaded', index }); 218 | }; 219 | 220 | const handlePreviewGenerated = (index: number) => { 221 | setPreviewState({ type: 'generated', index }); 222 | setIsComparing(false); 223 | }; 224 | 225 | const previewImages = useMemo(() => { 226 | if (!previewState) return null; 227 | if (previewState.type === 'generated') return generatedImages; 228 | if (previewState.type === 'uploaded') { 229 | return uploadedFiles.map((file, i) => ({ 230 | id: `uploaded-${file.name}-${i}`, 231 | src: uploadedFileUrls[i], 232 | isFavorite: false, 233 | })); 234 | } 235 | return null; 236 | }, [previewState, generatedImages, uploadedFiles, uploadedFileUrls]); 237 | 238 | 239 | const previewActions = useMemo(() => { 240 | if (previewState?.type !== 'generated') return []; 241 | const currentImage = generatedImages[previewState.index]; 242 | 243 | return [ 244 | { 245 | label: '收藏', 246 | icon: <StarIcon className="w-5 h-5" />, 247 | onClick: () => { 248 | if (currentImage) { 249 | onToggleFavorite(currentImage.id); 250 | } 251 | }, 252 | isActive: !!currentImage?.isFavorite, 253 | }, 254 | { 255 | label: '局部重绘', 256 | icon: <EditIcon className="w-5 h-5" />, 257 | onClick: () => { 258 | if (previewState !== null) { 259 | setEditingImage(generatedImages[previewState.index]); 260 | setPreviewState(null); 261 | } 262 | }, 263 | }, 264 | { 265 | label: '二次创作', 266 | icon: <SparklesIcon className="w-5 h-5" />, 267 | onClick: handleContinueWithImage, 268 | }, 269 | { 270 | label: '生成视频', 271 | icon: <VideoIcon className="w-5 h-5" />, 272 | onClick: handleGenerateVideo, 273 | }, 274 | { 275 | label: '下载', 276 | icon: <DownloadIcon className="w-5 h-5" />, 277 | onClick: handleDownload, 278 | } 279 | ]; 280 | }, [previewState, generatedImages, onToggleFavorite]); 281 | 282 | return ( 283 | <> 284 | <section className="w-full flex flex-col items-center justify-center py-12 md:py-16 bg-slate-50 border-b border-slate-200"> 285 | <div className="container mx-auto px-4 text-center"> 286 | <h1 className="text-4xl md:text-6xl font-extrabold text-slate-800 tracking-tight"> 287 | 一图万变,<span className="text-indigo-600">创意接力</span> 288 | </h1> 289 | <p className="mt-4 text-base md:text-lg text-slate-600 max-w-2xl mx-auto"> 290 | {i2iMode === 'edit' ? '上传您的图片,输入创意指令,即可进行编辑或二次创作。' : '上传一张参考图,输入全新主题,借鉴其风格生成新图片。'} 291 | </p> 292 | 293 | <div className="max-w-5xl mx-auto mt-10 text-left bg-white p-6 md:p-8 rounded-2xl shadow-xl border border-slate-200"> 294 | <div className="flex flex-col gap-8"> 295 | <div className="flex justify-center"> 296 | <div className="grid grid-cols-2 gap-2 bg-slate-100 p-1 rounded-full w-full max-w-xs"> 297 | <ModeButton label="编辑创作" icon={<EditIcon className="w-4 h-4" />} isActive={i2iMode === 'edit'} onClick={() => onI2iModeChange('edit')} disabled={isLoading} /> 298 | <ModeButton label="灵感启发" icon={<SparklesIcon className="w-4 h-4" />} isActive={i2iMode === 'inspiration'} onClick={() => onI2iModeChange('inspiration')} disabled={isLoading} /> 299 | </div> 300 | </div> 301 | 302 | <div className="grid md:grid-cols-2 gap-8 items-stretch"> 303 | 304 | {/* Left Column: Uploader & Settings */} 305 | <div className="flex flex-col gap-8"> 306 | <div> 307 | <h2 className="text-xl font-bold text-slate-700 mb-3">{i2iMode === 'inspiration' ? '1. 上传参考图' : '1. 上传图片'}</h2> 308 | <ImageUploader files={uploadedFiles} onFilesChange={handleFilesChange} disabled={isLoading} maxFiles={i2iMode === 'inspiration' ? 1 : 5} onPreviewClick={handlePreviewUploaded} /> 309 | </div> 310 | {i2iMode === 'inspiration' && ( 311 | <div className="animate-fade-in"> 312 | <h3 className="text-xl font-bold text-slate-700 mb-3">2. 风格化强度</h3> 313 | <p className="text-sm text-slate-500 mb-4">控制生成图片与参考图风格的相似程度。</p> 314 | <div className="grid grid-cols-2 md:grid-cols-4 gap-2"> 315 | {strengthOptions.map(opt => ( 316 | <button 317 | key={opt.value} 318 | type="button" 319 | onClick={() => onInspirationStrengthChange(opt.value)} 320 | disabled={isLoading} 321 | title={opt.description} 322 | className={`px-3 py-2 rounded-lg text-center transition-all ${ 323 | inspirationStrength === opt.value 324 | ? 'bg-indigo-100 text-indigo-700 border-indigo-300 ring-2 ring-indigo-200' 325 | : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50' 326 | } border disabled:opacity-60`} 327 | > 328 | <span className="font-semibold text-sm">{opt.label}</span> 329 | </button> 330 | ))} 331 | </div> 332 | </div> 333 | )} 334 | </div> 335 | 336 | {/* Right Column: Prompt & Generate Button */} 337 | <div className="flex flex-col"> 338 | <h2 className="text-xl font-bold text-slate-700 mb-3">{i2iMode === 'inspiration' ? '3. 输入新主题' : '2. 输入创意指令'}</h2> 339 | <textarea 340 | value={prompt} 341 | onChange={(e) => onPromptChange(e.target.value)} 342 | placeholder={i2iMode === 'inspiration' ? '例如:一只戴着皇冠的狮子' : '例如:为图片中的人物戴上一副太阳镜'} 343 | className="w-full flex-grow px-4 py-3 bg-slate-50 border border-slate-300 rounded-lg shadow-sm transition duration-200 text-base focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none" 344 | rows={10} 345 | disabled={isLoading} 346 | /> 347 | <button 348 | type="button" 349 | onClick={handleGenerate} 350 | className="w-full mt-4 bg-indigo-600 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-400 disabled:cursor-not-allowed disabled:scale-100 text-lg" 351 | disabled={isLoading} 352 | > 353 | {isLoading ? '生成中...' : '✨ 生成图片'} 354 | </button> 355 | </div> 356 | 357 | </div> 358 | {error && <p className="mt-4 text-center text-red-600">{error}</p>} 359 | </div> 360 | </div> 361 | </div> 362 | </section> 363 | 364 | <main className="container mx-auto px-4 py-12"> 365 | <div className="mt-4"> 366 | {isLoading ? ( 367 | <LoadingState title="AI 正在创作中..." message="请稍候,您的新图片即将呈现。" /> 368 | ) : generatedImages.length > 0 ? ( 369 | <ImageGrid 370 | images={generatedImages} 371 | onImageClick={handlePreviewGenerated} 372 | onToggleFavorite={onToggleFavorite} 373 | /> 374 | ) : ( 375 | <EmptyState icon="🖼️" title="上传图片,开始创作" message="在上方上传图片并输入指令,让我为您生成新的创意图片吧!" /> 376 | )} 377 | </div> 378 | </main> 379 | 380 | <ImagePreview 381 | images={previewImages} 382 | currentIndex={previewState?.index ?? null} 383 | onClose={() => setPreviewState(null)} 384 | onChange={(newIndex) => { 385 | setPreviewState(p => p ? { ...p, index: newIndex } : null); 386 | setIsComparing(false); 387 | }} 388 | actions={isComparing ? [] : previewActions} 389 | sourceImageSrc={previewState?.type === 'generated' && uploadedFiles.length > 0 ? uploadedFileUrls[0] : undefined} 390 | isComparing={isComparing} 391 | onToggleCompare={() => setIsComparing(p => !p)} 392 | /> 393 | 394 | <InpaintingModal 395 | isOpen={!!editingImage} 396 | onClose={() => setEditingImage(null)} 397 | image={editingImage} 398 | apiKey={apiKey} 399 | onComplete={handleInpaintingComplete} 400 | onApiKeyNeeded={onApiKeyNeeded} 401 | /> 402 | </> 403 | ); 404 | }; 405 | -------------------------------------------------------------------------------- /components/History.tsx: -------------------------------------------------------------------------------- 1 | // components/History.tsx 2 | 3 | import React, { useState, useMemo, useEffect } from 'react'; 4 | import { HistoryRecord, AppMode, ImageStyle } from '../types'; 5 | import { XMarkIcon } from './icons/XMarkIcon'; 6 | import { StarIcon } from './icons/StarIcon'; 7 | import { SearchIcon } from './icons/SearchIcon'; 8 | import { TagIcon } from './icons/TagIcon'; 9 | import { ChevronDownIcon } from './icons/ChevronDownIcon'; 10 | import { CollectionIcon } from './icons/CollectionIcon'; 11 | 12 | 13 | interface HistoryProps { 14 | history: HistoryRecord[]; 15 | onSelect: (item: HistoryRecord) => void; 16 | onClear: () => void; 17 | onRemove: (id: string, isGroup?: boolean) => void; 18 | onToggleFavorite: (id: string) => void; 19 | selectedId: string | null; 20 | isLoading: boolean; 21 | searchQuery: string; 22 | onSearchChange: (query: string) => void; 23 | onOpenTagManager: (id: string, anchor: DOMRect) => void; 24 | } 25 | 26 | const modeMap: Record<AppMode, { icon: string; label: string, className: string }> = { 27 | 'wiki': { icon: '💡', label: '图解百科', className: 'bg-indigo-200 text-indigo-700' }, 28 | 'comicStrip': { icon: '📖', label: '连环画本', className: 'bg-orange-200 text-orange-700' }, 29 | 'infiniteCanvas': { icon: '🌌', label: '无限画布', className: 'bg-yellow-200 text-yellow-700' }, 30 | 'textToImage': { icon: '✨', label: '以文生图', className: 'bg-teal-200 text-teal-700' }, 31 | 'imageToImage': { icon: '🖼️', label: '以图生图', className: 'bg-purple-200 text-purple-700' }, 32 | 'video': { icon: '🎬', label: '图生视频', className: 'bg-sky-200 text-sky-700' }, 33 | }; 34 | 35 | const ModeDisplay: React.FC<{ item: HistoryRecord; isGroupHeader?: boolean }> = ({ item, isGroupHeader = false }) => { 36 | const { mode, style, i2iMode } = item; 37 | 38 | const styleMap: Record<string, string> = { 39 | [ImageStyle.ILLUSTRATION]: '插画风', 40 | [ImageStyle.CLAY]: '粘土风', 41 | [ImageStyle.DOODLE]: '涂鸦风', 42 | [ImageStyle.CARTOON]: '卡通风', 43 | [ImageStyle.INK_WASH]: '水墨风', 44 | [ImageStyle.AMERICAN_COMIC]: '美漫风', 45 | [ImageStyle.WATERCOLOR]: '水彩风', 46 | [ImageStyle.PHOTOREALISTIC]: '写实风', 47 | [ImageStyle.JAPANESE_MANGA]: '日漫风', 48 | [ImageStyle.THREE_D_ANIMATION]: '3D动画风', 49 | }; 50 | 51 | const i2iModeMap: Record<'edit' | 'inspiration', string> = { 52 | 'edit': '编辑创作', 53 | 'inspiration': '灵感启发', 54 | }; 55 | 56 | const modeInfo = modeMap[mode]; 57 | 58 | return ( 59 | <> 60 | {modeInfo && ( 61 | <span className={`flex items-center gap-1 text-xs px-2 py-1 rounded-full ${modeInfo.className}`}> 62 | {modeInfo.icon} 63 | <span>{modeInfo.label}</span> 64 | </span> 65 | )} 66 | {item.comicStripType === 'video' && ( 67 | <span className="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-sky-200 text-sky-700"> 68 | 🎬 69 | <span>故事视频</span> 70 | </span> 71 | )} 72 | {!isGroupHeader && ( 73 | <> 74 | {/* Wiki or ComicStrip Style sub-label */} 75 | {(mode === 'wiki' || (mode === 'comicStrip' && item.comicStripType === 'images')) && style && styleMap[style] && ( 76 | <span className="text-xs px-2 py-1 bg-slate-200 text-slate-600 rounded-full"> 77 | {styleMap[style]} 78 | </span> 79 | )} 80 | {/* ImageToImage sub-label */} 81 | {mode === 'imageToImage' && i2iMode && i2iModeMap[i2iMode] && ( 82 | <span className="text-xs px-2 py-1 bg-slate-200 text-slate-600 rounded-full"> 83 | {i2iModeMap[i2iMode]} 84 | </span> 85 | )} 86 | </> 87 | )} 88 | </> 89 | ); 90 | }; 91 | 92 | interface HistoryItemProps { 93 | item: HistoryRecord; 94 | isActive: boolean; 95 | isChild?: boolean; 96 | onSelect: (item: HistoryRecord) => void; 97 | onRemove: (id: string, isGroup?: boolean) => void; 98 | onToggleFavorite: (id: string) => void; 99 | onOpenTagManager: (id: string, anchor: DOMRect) => void; 100 | } 101 | 102 | const HistoryItem: React.FC<HistoryItemProps> = ({ item, isActive, isChild = false, onSelect, onRemove, onToggleFavorite, onOpenTagManager }) => { 103 | const isFavorite = item.images?.some(img => img.isFavorite); 104 | return ( 105 | <div 106 | className={`group flex flex-col sm:flex-row items-start sm:items-center gap-4 p-4 rounded-lg border transition-all duration-200 ${ 107 | isChild ? 'ml-0 sm:ml-10' : '' 108 | } ${ 109 | isActive 110 | ? 'bg-indigo-50 border-indigo-300 shadow-md ring-2 ring-indigo-200' 111 | : 'bg-white border-slate-200' 112 | } ${!isActive ? 'hover:bg-slate-50 hover:border-slate-300 hover:shadow-sm cursor-pointer' : ''}`} 113 | role="button" 114 | tabIndex={0} 115 | onClick={() => !isActive && onSelect(item)} 116 | onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && !isActive && onSelect(item)} 117 | aria-label={`View history for prompt: ${item.prompt}`} 118 | > 119 | <div className="w-24 h-16 bg-slate-200 rounded-md overflow-hidden flex-shrink-0 border border-slate-200" onClick={(e) => { e.stopPropagation(); onSelect(item); }}> 120 | <img 121 | src={item.thumbnail} 122 | alt={`Preview for ${item.prompt}`} 123 | className="w-full h-full object-cover" 124 | /> 125 | </div> 126 | <div className="flex-grow min-w-0"> 127 | <p className="font-semibold text-slate-800 truncate" title={item.prompt}>{item.prompt.length > 20 ? `${item.prompt.substring(0, 20)}...` : item.prompt}</p> 128 | <div className="flex items-center gap-2 mt-1.5 flex-wrap"> 129 | <ModeDisplay item={item} /> 130 | {item.tags?.map(tag => ( 131 | <span key={tag} className="flex items-center gap-1 text-xs px-2 py-1 bg-indigo-100 text-indigo-700 rounded-full"> 132 | <TagIcon className="w-3 h-3" /> 133 | <span>{tag}</span> 134 | </span> 135 | ))} 136 | <span className="text-xs text-slate-400">{new Date(item.timestamp).toLocaleString()}</span> 137 | </div> 138 | </div> 139 | <div className="flex-shrink-0 flex items-center self-start sm:self-center ml-auto sm:ml-2"> 140 | <button 141 | onClick={(e) => { 142 | e.stopPropagation(); 143 | onOpenTagManager(item.id, e.currentTarget.getBoundingClientRect()); 144 | }} 145 | className="p-2 rounded-full text-slate-400 hover:text-indigo-600 hover:bg-indigo-100 transition-colors" 146 | aria-label={`管理标签: ${item.prompt}`} 147 | title="管理标签" 148 | > 149 | <TagIcon className="w-5 h-5" /> 150 | </button> 151 | <button 152 | onClick={(e) => { 153 | e.stopPropagation(); 154 | onToggleFavorite(item.id); 155 | }} 156 | className={`p-2 rounded-full transition-all duration-200 ${ 157 | isFavorite 158 | ? 'text-amber-400 hover:bg-amber-100' 159 | : 'text-slate-400 hover:text-amber-400 hover:bg-amber-100' 160 | }`} 161 | aria-label={isFavorite ? `取消收藏: ${item.prompt}` : `收藏: ${item.prompt}`} 162 | title={isFavorite ? '取消收藏' : '收藏'} 163 | > 164 | <StarIcon filled={!!isFavorite} className="w-5 h-5" /> 165 | </button> 166 | <button 167 | onClick={(e) => { 168 | e.stopPropagation(); 169 | if (isFavorite) { 170 | if (window.confirm('该记录已收藏,您确定要删除吗?')) { 171 | onRemove(item.id); 172 | } 173 | } else { 174 | onRemove(item.id); 175 | } 176 | }} 177 | className="p-2 rounded-full text-slate-400 hover:bg-red-100 hover:text-red-600 transition-colors" 178 | aria-label={`删除历史记录: ${item.prompt}`} 179 | title="删除" 180 | > 181 | <XMarkIcon className="w-5 h-5" /> 182 | </button> 183 | </div> 184 | </div> 185 | ); 186 | }; 187 | 188 | const FilterButton: React.FC<{ 189 | label: string; 190 | icon?: string; 191 | isActive: boolean; 192 | onClick: () => void; 193 | }> = ({ label, icon, isActive, onClick }) => ( 194 | <button 195 | onClick={onClick} 196 | className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1.5 ${ 197 | isActive 198 | ? 'bg-indigo-600 text-white shadow' 199 | : 'bg-white text-slate-600 hover:bg-slate-100' 200 | }`} 201 | > 202 | {icon && <span>{icon}</span>} 203 | <span>{label}</span> 204 | </button> 205 | ); 206 | 207 | 208 | export const History: React.FC<HistoryProps> = ({ 209 | history, 210 | onSelect, 211 | onClear, 212 | onRemove, 213 | onToggleFavorite, 214 | selectedId, 215 | isLoading, 216 | searchQuery, 217 | onSearchChange, 218 | onOpenTagManager, 219 | }) => { 220 | const [visibleCount, setVisibleCount] = useState(10); 221 | const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({}); 222 | const [activeModeFilter, setActiveModeFilter] = useState<AppMode | 'all'>('all'); 223 | 224 | useEffect(() => { 225 | setVisibleCount(10); 226 | }, [activeModeFilter, searchQuery]); 227 | 228 | const { visibleItems, totalFilteredCount } = useMemo(() => { 229 | const lowercasedQuery = searchQuery.toLowerCase().trim(); 230 | 231 | const modeFilteredHistory = history.filter(item => 232 | activeModeFilter === 'all' || item.mode === activeModeFilter 233 | ); 234 | 235 | const groups: Record<string, HistoryRecord[]> = {}; 236 | const childIds = new Set<string>(); 237 | for (const item of modeFilteredHistory) { 238 | if (item.parentId) { 239 | if (!groups[item.parentId]) { 240 | groups[item.parentId] = []; 241 | } 242 | groups[item.parentId].push(item); 243 | childIds.add(item.id); 244 | } 245 | } 246 | 247 | const itemMatches = (i: HistoryRecord, query: string): boolean => { 248 | if (!query) return true; 249 | const promptMatch = i.prompt.toLowerCase().includes(query); 250 | const tagMatch = i.tags?.some(tag => tag.toLowerCase().includes(query)); 251 | return promptMatch || tagMatch; 252 | }; 253 | 254 | const searchFilteredItems: (HistoryRecord | {isGroup: true, parent: HistoryRecord, children: HistoryRecord[]})[] = []; 255 | 256 | for (const item of modeFilteredHistory) { 257 | if (childIds.has(item.id)) continue; 258 | 259 | const children = groups[item.id] || []; 260 | const groupMatchesQuery = itemMatches(item, lowercasedQuery) || children.some(c => itemMatches(c, lowercasedQuery)); 261 | 262 | if (groupMatchesQuery) { 263 | if (children.length > 0) { 264 | searchFilteredItems.push({ isGroup: true, parent: item, children }); 265 | } else { 266 | searchFilteredItems.push(item); 267 | } 268 | } 269 | } 270 | 271 | return { 272 | visibleItems: searchFilteredItems.slice(0, visibleCount), 273 | totalFilteredCount: searchFilteredItems.length 274 | }; 275 | }, [history, searchQuery, visibleCount, activeModeFilter]); 276 | 277 | const toggleGroup = (id: string) => { 278 | setExpandedGroups(prev => ({ ...prev, [id]: !prev[id] })); 279 | }; 280 | 281 | const modeFilterOptions: {value: AppMode | 'all', label: string, icon?: string}[] = [ 282 | { value: 'all', label: '全部' }, 283 | ...Object.entries(modeMap).map(([key, {label, icon}]) => ({ value: key as AppMode, label, icon })), 284 | ]; 285 | 286 | return ( 287 | <section className="bg-slate-100 border-t border-slate-200"> 288 | <div className="container mx-auto px-4 py-12"> 289 | <div className="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4"> 290 | <h2 className="text-2xl font-bold text-slate-700">生成历史</h2> 291 | <div className="w-full sm:w-auto flex items-center gap-4"> 292 | <div className="relative flex-grow"> 293 | <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" /> 294 | <input 295 | type="text" 296 | placeholder="搜索提示词或标签..." 297 | value={searchQuery} 298 | onChange={(e) => onSearchChange(e.target.value)} 299 | className="w-full pl-10 pr-4 py-2 bg-white border border-slate-300 rounded-full shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" 300 | /> 301 | </div> 302 | <button 303 | onClick={onClear} 304 | disabled={isLoading} 305 | className="text-sm font-medium text-slate-500 hover:text-red-600 hover:underline transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 rounded disabled:opacity-50 disabled:cursor-not-allowed disabled:no-underline disabled:hover:text-slate-500 flex-shrink-0" 306 | aria-label="Clear all generation history" 307 | > 308 | 清空历史 309 | </button> 310 | </div> 311 | </div> 312 | 313 | <div className="flex flex-wrap items-center gap-2 mb-6 p-2 bg-slate-200/80 rounded-full"> 314 | {modeFilterOptions.map(opt => ( 315 | <FilterButton 316 | key={opt.value} 317 | label={opt.label} 318 | icon={opt.icon} 319 | isActive={activeModeFilter === opt.value} 320 | onClick={() => setActiveModeFilter(opt.value)} 321 | /> 322 | ))} 323 | </div> 324 | 325 | <div className={`space-y-4 ${isLoading ? 'opacity-60 pointer-events-none' : ''}`}> 326 | {visibleItems.map((itemOrGroup) => { 327 | if ('isGroup' in itemOrGroup) { 328 | const { parent, children } = itemOrGroup; 329 | const isExpanded = !!expandedGroups[parent.id]; 330 | const allItemsInGroup = [parent, ...children]; 331 | const latestTimestamp = Math.max(...allItemsInGroup.map(item => item.timestamp)); 332 | const groupThumbnail = parent.sourceImage || parent.thumbnail; 333 | 334 | return ( 335 | <div key={parent.id} className="bg-white rounded-lg border border-slate-200 shadow-sm transition-shadow hover:shadow-md"> 336 | <div 337 | className={`group flex items-center gap-4 p-4 cursor-pointer`} 338 | role="button" 339 | tabIndex={0} 340 | onClick={() => toggleGroup(parent.id)} 341 | onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleGroup(parent.id)} 342 | > 343 | <div className="w-24 h-16 bg-slate-200 rounded-md overflow-hidden flex-shrink-0 border border-slate-200"> 344 | <img src={groupThumbnail} alt="Source" className="w-full h-full object-cover" /> 345 | </div> 346 | <div className="flex-grow min-w-0"> 347 | <p className="font-semibold text-slate-800 flex items-center gap-2"> 348 | <CollectionIcon className="w-5 h-5 text-indigo-500" /> 349 | <span>创作系列 ({allItemsInGroup.length} 个)</span> 350 | </p> 351 | <div className="flex items-center gap-2 mt-1.5 flex-wrap"> 352 | <ModeDisplay item={parent} isGroupHeader={true} /> 353 | <span className="text-xs text-slate-400">{new Date(latestTimestamp).toLocaleString()}</span> 354 | </div> 355 | </div> 356 | <div className="flex items-center ml-auto"> 357 | <span className="text-sm text-slate-500 mr-2">{isExpanded ? '收起' : '展开'}</span> 358 | <ChevronDownIcon className={`w-5 h-5 text-slate-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`} /> 359 | <button 360 | onClick={(e) => { 361 | e.stopPropagation(); 362 | const isGroupFavorited = allItemsInGroup.some(item => item.images?.some(img => img.isFavorite)); 363 | if (isGroupFavorited) { 364 | if (window.confirm('该创作系列中包含已收藏的记录,您确定要删除整个系列吗?')) { 365 | onRemove(parent.id, true); 366 | } 367 | } else { 368 | if (window.confirm('您确定要删除整个创作系列吗?')) { 369 | onRemove(parent.id, true); 370 | } 371 | } 372 | }} 373 | className="ml-2 p-2 rounded-full text-slate-400 hover:bg-red-100 hover:text-red-600 transition-colors" 374 | aria-label={`删除系列: ${parent.prompt}`} 375 | title="删除系列" 376 | > 377 | <XMarkIcon className="w-5 h-5" /> 378 | </button> 379 | </div> 380 | </div> 381 | {isExpanded && ( 382 | <div className="p-4 border-t border-slate-200 space-y-3"> 383 | {allItemsInGroup.sort((a,b) => b.timestamp - a.timestamp).map(childItem => ( 384 | <HistoryItem 385 | key={childItem.id} 386 | item={childItem} 387 | isActive={childItem.id === selectedId} 388 | isChild={true} 389 | onSelect={onSelect} 390 | onRemove={onRemove} 391 | onToggleFavorite={onToggleFavorite} 392 | onOpenTagManager={onOpenTagManager} 393 | /> 394 | ))} 395 | </div> 396 | )} 397 | </div> 398 | ); 399 | } 400 | // Standalone item 401 | return ( 402 | <HistoryItem 403 | key={itemOrGroup.id} 404 | item={itemOrGroup} 405 | isActive={itemOrGroup.id === selectedId} 406 | onSelect={onSelect} 407 | onRemove={onRemove} 408 | onToggleFavorite={onToggleFavorite} 409 | onOpenTagManager={onOpenTagManager} 410 | /> 411 | ); 412 | })} 413 | </div> 414 | {totalFilteredCount === 0 && ( 415 | <div className="text-center py-10"> 416 | <p className="text-slate-500">{searchQuery || activeModeFilter !== 'all' ? '没有找到匹配的历史记录。' : '还没有历史记录。'}</p> 417 | </div> 418 | )} 419 | {totalFilteredCount > visibleCount && ( 420 | <div className="mt-8 text-center"> 421 | <button 422 | onClick={() => setVisibleCount(prev => prev + 10)} 423 | className="px-6 py-2 bg-white border border-slate-300 rounded-full text-sm font-semibold text-slate-700 hover:bg-slate-50 transition-colors shadow-sm" 424 | > 425 | 加载更多 426 | </button> 427 | </div> 428 | )} 429 | </div> 430 | </section> 431 | ); 432 | }; -------------------------------------------------------------------------------- /components/ComicStrip.tsx: -------------------------------------------------------------------------------- 1 | // components/ComicStrip.tsx 2 | 3 | import React, { useState } from 'react'; 4 | import { ImageStyle, GeneratedImage, ComicStripGenerationPhase, ComicStripPanelStatus, ImageModel, ComicStripTransitionStatus } from '../types'; 5 | import { ImageGrid } from './ImageGrid'; 6 | import { EmptyState } from './EmptyState'; 7 | import { LoadingState } from './LoadingState'; 8 | import { ChevronLeftIcon } from './icons/ChevronLeftIcon'; 9 | import { CheckIcon } from './icons/CheckIcon'; 10 | import { VideoPlayIcon } from './icons/VideoPlayIcon'; 11 | import { SparklesIcon } from './icons/SparklesIcon'; 12 | 13 | interface TransitionItemProps { 14 | index: number; 15 | status: ComicStripTransitionStatus; 16 | url: string | null; 17 | } 18 | 19 | const TransitionItem: React.FC<TransitionItemProps> = ({ index, status, url }) => { 20 | const handlePreview = () => { 21 | if (url) { 22 | window.open(url, '_blank'); 23 | } 24 | }; 25 | return ( 26 | <div className="flex items-center justify-center my-4"> 27 | <div className="w-full max-w-lg flex items-center justify-center gap-4 py-2 px-4 bg-slate-100 rounded-full border border-slate-200"> 28 | <SparklesIcon className="w-5 h-5 text-purple-500" /> 29 | <span className="text-sm font-semibold text-slate-600">AI 转场 {index + 1}</span> 30 | <div className="flex-grow border-t border-dashed border-slate-300"></div> 31 | {status === 'generating' && ( 32 | <div className="flex items-center gap-2 text-sm text-slate-500"> 33 | <div className="animate-spin rounded-full h-4 w-4 border-2 border-solid border-slate-400 border-r-transparent"></div> 34 | <span>生成中...</span> 35 | </div> 36 | )} 37 | {status === 'completed' && url && ( 38 | <button onClick={handlePreview} className="flex items-center gap-1.5 text-sm text-indigo-600 bg-indigo-100 hover:bg-indigo-200 px-3 py-1 rounded-full transition-colors"> 39 | <VideoPlayIcon className="w-4 h-4" /> 40 | <span>播放转场</span> 41 | </button> 42 | )} 43 | {status === 'completed' && !url && ( 44 | <span className="text-sm text-red-500">生成失败</span> 45 | )} 46 | </div> 47 | </div> 48 | ); 49 | }; 50 | 51 | interface ScriptEditorProps { 52 | images: GeneratedImage[]; 53 | scripts: string[]; 54 | onScriptChange: (index: number, newScript: string) => void; 55 | onBack: () => void; 56 | onGenerate: () => void; 57 | phase: ComicStripGenerationPhase; 58 | videoUrls: (string | null)[]; 59 | panelStatuses: ComicStripPanelStatus[]; 60 | onPreviewComicPanel: (index: number) => void; 61 | onRegeneratePanel: (index: number) => void; 62 | transitionUrls: (string | null)[]; 63 | transitionStatuses: ComicStripTransitionStatus[]; 64 | useSmartTransitions: boolean; 65 | onUseSmartTransitionsChange: (use: boolean) => void; 66 | isStitching: boolean; 67 | stitchingProgress: number; 68 | onStitchVideos: () => void; 69 | } 70 | 71 | const ScriptEditor: React.FC<ScriptEditorProps> = ({ 72 | images, 73 | scripts, 74 | onScriptChange, 75 | onBack, 76 | onGenerate, 77 | phase, 78 | videoUrls, 79 | panelStatuses, 80 | onPreviewComicPanel, 81 | onRegeneratePanel, 82 | transitionUrls, 83 | transitionStatuses, 84 | useSmartTransitions, 85 | onUseSmartTransitionsChange, 86 | isStitching, 87 | stitchingProgress, 88 | onStitchVideos, 89 | }) => { 90 | const isGloballyGenerating = panelStatuses.some(s => s === 'generating') || transitionStatuses.some(s => s === 'generating'); 91 | const isCompleted = phase === 'completed'; 92 | 93 | const allPanelsGenerated = panelStatuses.every(s => s === 'completed') && videoUrls.every(v => v); 94 | const allTransitionsGenerated = transitionStatuses.every(s => s === 'completed') && transitionUrls.every(t => t); 95 | const allVideosGenerated = isCompleted && allPanelsGenerated && (!useSmartTransitions || allTransitionsGenerated); 96 | 97 | let mainActionText = '开始生成视频'; 98 | if (isCompleted) { 99 | mainActionText = '重新生成全部'; 100 | } else if (phase === 'generating_panels') { 101 | mainActionText = '生成画板中...'; 102 | } else if (phase === 'generating_transitions') { 103 | mainActionText = '生成转场中...'; 104 | } 105 | 106 | const items: { type: 'panel' | 'transition'; index: number }[] = []; 107 | images.forEach((image, index) => { 108 | items.push({ type: 'panel', index }); 109 | if (index < images.length - 1 && useSmartTransitions) { 110 | items.push({ type: 'transition', index }); 111 | } 112 | }); 113 | 114 | return ( 115 | <div className="container mx-auto px-4 py-12 animate-fade-in"> 116 | <div className="max-w-5xl mx-auto"> 117 | <div className="flex flex-col sm:flex-row justify-between items-center mb-8 gap-4"> 118 | <div> 119 | <h2 className="text-3xl font-bold text-slate-800"> 120 | {isGloballyGenerating ? '正在生成视频...' : (isCompleted ? '预览与修改' : '编辑分镜脚本')} 121 | </h2> 122 | <p className="text-slate-500 mt-1"> 123 | {isGloballyGenerating ? 'AI 正在为您的连环画注入生命,请稍候。' : (isCompleted ? '视频已生成。您可以播放、修改脚本或重新生成。' : '审阅并修改 AI 生成的视频描述,或填写您自己的创意。')} 124 | </p> 125 | </div> 126 | <div className="flex items-center gap-4 flex-wrap justify-end"> 127 | <button 128 | onClick={onBack} 129 | disabled={isGloballyGenerating || isStitching} 130 | className="flex items-center gap-2 px-4 py-2 bg-slate-200 text-slate-700 font-semibold rounded-full hover:bg-slate-300 transition-colors disabled:opacity-60 disabled:cursor-not-allowed" 131 | > 132 | <ChevronLeftIcon className="w-5 h-5" /> 133 | <span>{isCompleted ? '完成' : '返回'}</span> 134 | </button> 135 | <button 136 | onClick={onGenerate} 137 | disabled={isGloballyGenerating || isStitching} 138 | className="px-6 py-2 bg-indigo-600 text-white font-bold rounded-full hover:bg-indigo-700 transition-transform transform hover:scale-105 disabled:bg-slate-400 disabled:cursor-not-allowed" 139 | > 140 | {mainActionText} 141 | </button> 142 | {allVideosGenerated && !isStitching && ( 143 | <button 144 | onClick={onStitchVideos} 145 | className="px-6 py-2 bg-green-600 text-white font-bold rounded-full hover:bg-green-700 transition-transform transform hover:scale-105" 146 | > 147 | 一键拼接视频 148 | </button> 149 | )} 150 | {isStitching && ( 151 | <div className="flex items-center gap-3 bg-slate-200 px-4 py-2 rounded-full"> 152 | <span className="text-sm font-semibold text-slate-700">拼接中... ({stitchingProgress}%)</span> 153 | <div className="w-24 h-2 bg-slate-300 rounded-full overflow-hidden"> 154 | <div 155 | className="h-full bg-green-500 rounded-full transition-all" 156 | style={{ width: `${stitchingProgress}%`}} 157 | ></div> 158 | </div> 159 | </div> 160 | )} 161 | </div> 162 | </div> 163 | 164 | {phase === 'editing' && ( 165 | <div className="flex items-center justify-center gap-4 p-4 mb-6 bg-slate-100 rounded-2xl border border-slate-200"> 166 | <span className="font-semibold text-slate-700">AI 智能转场</span> 167 | <p className="text-sm text-slate-500 flex-grow">自动为每个镜头之间生成电影般的过渡效果。</p> 168 | <button 169 | type="button" 170 | onClick={() => onUseSmartTransitionsChange(!useSmartTransitions)} 171 | disabled={isGloballyGenerating || isStitching} 172 | className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2 ${useSmartTransitions ? 'bg-indigo-600' : 'bg-gray-200'}`} 173 | > 174 | <span 175 | aria-hidden="true" 176 | className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${useSmartTransitions ? 'translate-x-5' : 'translate-x-0'}`} 177 | /> 178 | </button> 179 | </div> 180 | )} 181 | 182 | 183 | <div className="space-y-4"> 184 | {items.map(item => { 185 | if (item.type === 'panel') { 186 | const { index } = item; 187 | const image = images[index]; 188 | const status = panelStatuses[index] || 'queued'; 189 | const hasVideo = !!videoUrls[index]; 190 | 191 | return ( 192 | <div key={image.id} className={`grid md:grid-cols-2 gap-6 items-start bg-white p-4 rounded-2xl shadow-lg border transition-all duration-300 ${hasVideo ? 'border-green-300' : 'border-slate-200'}`}> 193 | <div 194 | className="relative aspect-video w-full bg-slate-100 rounded-xl overflow-hidden shadow-inner group" 195 | onClick={() => hasVideo && onPreviewComicPanel(index)} 196 | role={hasVideo ? "button" : undefined} 197 | > 198 | <img src={image.src} alt={`Panel ${index + 1}`} className="w-full h-full object-cover" /> 199 | {status === 'generating' && ( 200 | <div className="absolute inset-0 bg-black/70 flex flex-col items-center justify-center text-white"> 201 | <div className="animate-spin rounded-full h-8 w-8 border-4 border-solid border-white border-r-transparent"></div> 202 | <p className="mt-2 text-sm font-semibold">生成中...</p> 203 | </div> 204 | )} 205 | {hasVideo && ( 206 | <div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center text-white cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"> 207 | <VideoPlayIcon className="w-12 h-12" /> 208 | </div> 209 | )} 210 | </div> 211 | <div className="flex flex-col h-full"> 212 | <div className="flex justify-between items-center mb-2"> 213 | <label htmlFor={`script-${index}`} className="block text-sm font-medium text-slate-600"> 214 | 第 {index + 1} 段视频描述 215 | </label> 216 | {status === 'completed' && hasVideo && ( 217 | <span className="flex items-center gap-1.5 text-xs text-green-600 bg-green-100 px-2 py-1 rounded-full"> 218 | <CheckIcon className="w-4 h-4" /> 219 | 已完成 220 | </span> 221 | )} 222 | </div> 223 | <textarea 224 | id={`script-${index}`} 225 | value={scripts[index] || ''} 226 | onChange={(e) => onScriptChange(index, e.target.value)} 227 | placeholder="输入该画面的动态描述..." 228 | rows={4} 229 | disabled={isGloballyGenerating || isStitching} 230 | className="w-full flex-grow px-4 py-3 bg-slate-50 border border-slate-300 rounded-lg shadow-sm transition duration-200 text-base focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-y disabled:bg-slate-100" 231 | /> 232 | {status === 'completed' && ( 233 | <button 234 | onClick={() => onRegeneratePanel(index)} 235 | disabled={isGloballyGenerating || isStitching} 236 | className="mt-2 w-full text-sm font-semibold text-indigo-600 bg-indigo-100 hover:bg-indigo-200 py-2 rounded-lg transition-colors disabled:opacity-60 disabled:cursor-not-allowed" 237 | > 238 | 重新生成此片段 239 | </button> 240 | )} 241 | </div> 242 | </div> 243 | ); 244 | } else { // type === 'transition' 245 | const { index } = item; 246 | return ( 247 | <TransitionItem 248 | key={`transition-${index}`} 249 | index={index} 250 | status={transitionStatuses[index] || 'queued'} 251 | url={transitionUrls[index] || null} 252 | /> 253 | ); 254 | } 255 | })} 256 | </div> 257 | </div> 258 | </div> 259 | ); 260 | }; 261 | 262 | interface ComicStripProps { 263 | onGenerate: () => void; 264 | isLoading: boolean; 265 | story: string; 266 | onStoryChange: (story: string) => void; 267 | style: ImageStyle; 268 | onStyleChange: (style: ImageStyle) => void; 269 | images: GeneratedImage[]; 270 | onImageClick: (index: number) => void; 271 | onToggleFavorite: (id: string) => void; 272 | onEditPanel: (index: number) => void; 273 | numberOfImages: number; 274 | onNumberOfImagesChange: (num: number) => void; 275 | onGenerateVideoScripts: () => void; 276 | videoGenerationPhase: ComicStripGenerationPhase; 277 | onPhaseChange: (phase: ComicStripGenerationPhase) => void; 278 | isGeneratingScripts: boolean; 279 | videoScripts: string[]; 280 | onScriptChange: (index: number, newScript: string) => void; 281 | onStartVideoGeneration: () => void; 282 | videoUrls: (string | null)[]; 283 | panelStatuses: ComicStripPanelStatus[]; 284 | onPreviewComicPanel: (index: number) => void; 285 | onRegeneratePanel: (index: number) => void; 286 | useSmartTransitions: boolean; 287 | onUseSmartTransitionsChange: (use: boolean) => void; 288 | transitionUrls: (string | null)[]; 289 | transitionStatuses: ComicStripTransitionStatus[]; 290 | isStitching: boolean; 291 | stitchingProgress: number; 292 | onStitchVideos: () => void; 293 | } 294 | 295 | const StyleButton: React.FC<{ 296 | value: ImageStyle; 297 | label: string; 298 | icon: string; 299 | isActive: boolean; 300 | onClick: () => void; 301 | disabled: boolean; 302 | }> = ({ value, label, icon, isActive, onClick, disabled }) => ( 303 | <button 304 | type="button" 305 | onClick={onClick} 306 | disabled={disabled} 307 | className={`px-4 py-2 rounded-full text-sm font-semibold transition-all duration-200 flex items-center gap-2 border disabled:cursor-not-allowed disabled:opacity-60 ${ 308 | isActive 309 | ? 'bg-slate-800 text-white border-slate-800 shadow-md' 310 | : 'bg-white/60 text-slate-700 border-transparent hover:bg-white/90 backdrop-blur-sm' 311 | }`} 312 | > 313 | <span className="text-lg">{icon}</span> 314 | {label} 315 | </button> 316 | ); 317 | 318 | export const ComicStrip: React.FC<ComicStripProps> = ({ 319 | onGenerate, 320 | isLoading, 321 | story, 322 | onStoryChange, 323 | style, 324 | onStyleChange, 325 | images, 326 | onImageClick, 327 | onToggleFavorite, 328 | onEditPanel, 329 | numberOfImages, 330 | onNumberOfImagesChange, 331 | onGenerateVideoScripts, 332 | videoGenerationPhase, 333 | onPhaseChange, 334 | isGeneratingScripts, 335 | videoScripts, 336 | onScriptChange, 337 | onStartVideoGeneration, 338 | videoUrls, 339 | panelStatuses, 340 | onPreviewComicPanel, 341 | onRegeneratePanel, 342 | useSmartTransitions, 343 | onUseSmartTransitionsChange, 344 | transitionUrls, 345 | transitionStatuses, 346 | isStitching, 347 | stitchingProgress, 348 | onStitchVideos, 349 | }) => { 350 | const [formError, setFormError] = useState<string | null>(null); 351 | 352 | const handleSubmit = (e: React.FormEvent) => { 353 | e.preventDefault(); 354 | if (!story.trim()) { 355 | setFormError('请输入您的故事脚本。'); 356 | return; 357 | } 358 | setFormError(null); 359 | onGenerate(); 360 | }; 361 | 362 | if (['editing', 'generating_panels', 'generating_transitions', 'completed'].includes(videoGenerationPhase)) { 363 | return ( 364 | <ScriptEditor 365 | images={images} 366 | scripts={videoScripts} 367 | onScriptChange={onScriptChange} 368 | onBack={() => onPhaseChange('idle')} 369 | onGenerate={onStartVideoGeneration} 370 | phase={videoGenerationPhase} 371 | videoUrls={videoUrls} 372 | panelStatuses={panelStatuses} 373 | onPreviewComicPanel={onPreviewComicPanel} 374 | onRegeneratePanel={onRegeneratePanel} 375 | transitionUrls={transitionUrls} 376 | transitionStatuses={transitionStatuses} 377 | useSmartTransitions={useSmartTransitions} 378 | onUseSmartTransitionsChange={onUseSmartTransitionsChange} 379 | isStitching={isStitching} 380 | stitchingProgress={stitchingProgress} 381 | onStitchVideos={onStitchVideos} 382 | /> 383 | ); 384 | } 385 | 386 | const hasGeneratedVideos = images.length > 0 && videoUrls.some(url => url); 387 | 388 | return ( 389 | <> 390 | <section className="w-full flex flex-col items-center justify-center py-12 md:py-16 bg-white border-b border-slate-200"> 391 | <div className="container mx-auto px-4 text-center"> 392 | <h1 className="text-4xl md:text-6xl font-extrabold text-slate-800 tracking-tight"> 393 | 故事成画,<span className="text-indigo-600">一键生成</span> 394 | </h1> 395 | <p className="mt-4 text-base md:text-lg text-slate-600 max-w-2xl mx-auto"> 396 | 输入您的故事脚本,选择一种艺术风格,即可生成一整套连环画。 397 | </p> 398 | <form onSubmit={handleSubmit} className="mt-10 max-w-4xl mx-auto"> 399 | <div className="flex flex-col items-center gap-4"> 400 | <textarea 401 | value={story} 402 | onChange={(e) => { 403 | onStoryChange(e.target.value); 404 | if (formError) setFormError(null); 405 | }} 406 | placeholder={`像写剧本一样描述您的故事,每个分镜占一行或一段:\n第一格:一只小猫坐在窗台上,看着窗外的雨。\n第二格:它看到一片叶子像小船一样漂在水坑里。\n第三格:小猫决定出门,去追逐那片叶子船。`} 407 | rows={8} 408 | className={`w-full px-6 py-4 bg-white border rounded-2xl shadow-lg transition duration-200 text-base resize-y ${ 409 | formError 410 | ? 'border-red-500 focus:ring-2 focus:ring-red-500/50 focus:border-red-500' 411 | : 'border-slate-300 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500' 412 | }`} 413 | disabled={isLoading} 414 | /> 415 | {formError && <p className="text-sm text-red-600">{formError}</p>} 416 | 417 | <div className="w-full flex flex-col items-center gap-4 mt-4"> 418 | <div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4"> 419 | <span className="text-slate-600 text-sm font-medium">画板数量:</span> 420 | <div className="flex items-center justify-center gap-2 bg-slate-100 p-1 rounded-full"> 421 | {[1, 2, 3, 4, 5, 6].map(num => ( 422 | <button 423 | key={num} 424 | type="button" 425 | onClick={() => onNumberOfImagesChange(num)} 426 | disabled={isLoading} 427 | className={`px-4 py-1.5 rounded-full text-sm font-semibold transition-colors duration-200 ${ 428 | numberOfImages === num 429 | ? 'bg-white text-indigo-600 shadow-md' 430 | : 'bg-transparent text-slate-600 hover:bg-white/50' 431 | }`} 432 | > 433 | {num} 434 | </button> 435 | ))} 436 | </div> 437 | </div> 438 | <div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4"> 439 | <span className="text-slate-600 text-sm font-medium">选择风格:</span> 440 | <div className="flex flex-wrap items-center justify-center gap-3"> 441 | <StyleButton value={ImageStyle.ILLUSTRATION} label="插画风" icon="🏞️" isActive={style === ImageStyle.ILLUSTRATION} onClick={() => onStyleChange(ImageStyle.ILLUSTRATION)} disabled={isLoading} /> 442 | <StyleButton value={ImageStyle.CARTOON} label="卡通风" icon="🐰" isActive={style === ImageStyle.CARTOON} onClick={() => onStyleChange(ImageStyle.CARTOON)} disabled={isLoading} /> 443 | <StyleButton value={ImageStyle.CLAY} label="粘土风" icon="🗿" isActive={style === ImageStyle.CLAY} onClick={() => onStyleChange(ImageStyle.CLAY)} disabled={isLoading} /> 444 | <StyleButton value={ImageStyle.PHOTOREALISTIC} label="写实风" icon="📷" isActive={style === ImageStyle.PHOTOREALISTIC} onClick={() => onStyleChange(ImageStyle.PHOTOREALISTIC)} disabled={isLoading} /> 445 | <StyleButton value={ImageStyle.THREE_D_ANIMATION} label="3D动画" icon="🧸" isActive={style === ImageStyle.THREE_D_ANIMATION} onClick={() => onStyleChange(ImageStyle.THREE_D_ANIMATION)} disabled={isLoading} /> 446 | </div> 447 | </div> 448 | </div> 449 | 450 | <button 451 | type="submit" 452 | className="bg-indigo-600 mt-4 text-white font-bold py-3 px-12 text-lg rounded-full hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-400 disabled:cursor-not-allowed disabled:scale-100" 453 | disabled={isLoading} 454 | > 455 | {isLoading ? '生成中...' : '生成连环画'} 456 | </button> 457 | </div> 458 | </form> 459 | </div> 460 | </section> 461 | 462 | <main className="container mx-auto px-4 py-12"> 463 | <div className="mt-4"> 464 | {isLoading ? ( 465 | <LoadingState title="正在为您绘制连环画..." message="AI 正在努力创作,这可能需要一点时间。" /> 466 | ) : images.length > 0 ? ( 467 | <> 468 | <div className="flex flex-col sm:flex-row justify-between items-center mb-8 gap-4"> 469 | <h2 className="text-2xl font-bold text-slate-700">生成结果</h2> 470 | {!isLoading && ( 471 | <button 472 | onClick={hasGeneratedVideos ? () => onPhaseChange('completed') : onGenerateVideoScripts} 473 | disabled={isGeneratingScripts} 474 | className={`${hasGeneratedVideos ? 'bg-slate-600 hover:bg-slate-700 focus:ring-slate-500' : 'bg-purple-600 hover:bg-purple-700 focus:ring-purple-500'} text-white font-bold py-2.5 px-6 rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 transition-transform duration-200 transform hover:scale-105 disabled:bg-slate-400 disabled:cursor-not-allowed`} 475 | > 476 | {isGeneratingScripts ? '分析画面中...' : (hasGeneratedVideos ? '查看分镜详情' : '🎬 生成故事视频')} 477 | </button> 478 | )} 479 | </div> 480 | <ImageGrid 481 | images={images} 482 | onImageClick={onImageClick} 483 | onToggleFavorite={onToggleFavorite} 484 | videoUrls={videoUrls} 485 | onEdit={onEditPanel} 486 | /> 487 | </> 488 | ) : ( 489 | <EmptyState icon="📖" title="输入故事,开始创作" message="在上方输入您的故事脚本,让我为您生成连环画吧!" /> 490 | )} 491 | </div> 492 | </main> 493 | </> 494 | ); 495 | }; 496 | --------------------------------------------------------------------------------