refactor: [30388f42] Entferne ungenutztes Projekt 'k-pop-thumbnail-genie'
- Löscht das gesamte Verzeichnis und die zugehörigen Dateien des 'k-pop-thumbnail-genie'-Projekts, da es nicht mehr benötigt wird.
This commit is contained in:
24
k-pop-thumbnail-genie/.gitignore
vendored
24
k-pop-thumbnail-genie/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
@@ -1,223 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { UploadedImage, AppStep, GenerationResult } from './types';
|
|
||||||
import { expandPrompt, generateImage, refineImage } from './services/geminiService';
|
|
||||||
import ImageUploader from './components/ImageUploader';
|
|
||||||
import ImageSegmenter from './components/ImageSegmenter';
|
|
||||||
import PromptCustomizer from './components/PromptCustomizer';
|
|
||||||
import ImageResult from './components/ImageResult';
|
|
||||||
import StepIndicator from './components/StepIndicator';
|
|
||||||
import { SparklesIcon } from './components/icons/SparklesIcon';
|
|
||||||
import { applyMask } from './utils/canvasUtils';
|
|
||||||
import { LoggingProvider, useLogger } from './contexts/LoggingContext';
|
|
||||||
import DebugConsole from './components/DebugConsole';
|
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
|
||||||
const [step, setStep] = useState<AppStep>(AppStep.Upload);
|
|
||||||
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
|
|
||||||
const [masterPrompt, setMasterPrompt] = useState<string>('');
|
|
||||||
const [generationResult, setGenerationResult] = useState<GenerationResult | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
||||||
const [loadingMessage, setLoadingMessage] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const { log } = useLogger();
|
|
||||||
|
|
||||||
const handleImagesUploaded = (images: UploadedImage[]) => {
|
|
||||||
log('info', `${images.length} images uploaded. Moving to segmentation step.`);
|
|
||||||
setUploadedImages(images);
|
|
||||||
setStep(AppStep.Segment);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSegmentationComplete = (imagesWithMasks: UploadedImage[]) => {
|
|
||||||
log('success', `Segmentation complete for all ${imagesWithMasks.length} images. Moving to prompt step.`);
|
|
||||||
setUploadedImages(imagesWithMasks);
|
|
||||||
setStep(AppStep.Prompt);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePromptExpanded = useCallback(async (scenario: string, userInstruction: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setLoadingMessage('Expanding your idea into a master prompt...');
|
|
||||||
setError(null);
|
|
||||||
log('info', `Expanding prompt with scenario: "${scenario}"`);
|
|
||||||
try {
|
|
||||||
const prompt = await expandPrompt(scenario, userInstruction, uploadedImages);
|
|
||||||
setMasterPrompt(prompt);
|
|
||||||
log('success', 'Master prompt created successfully.');
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred during prompt expansion.';
|
|
||||||
log('error', `Prompt expansion failed: ${errorMessage}`);
|
|
||||||
setError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setLoadingMessage('');
|
|
||||||
}
|
|
||||||
}, [uploadedImages, log]);
|
|
||||||
|
|
||||||
const handleFinalGeneration = useCallback(async (finalPrompt: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setLoadingMessage('Generating your K-Pop thumbnail...');
|
|
||||||
setError(null);
|
|
||||||
log('info', 'Starting final image generation.');
|
|
||||||
try {
|
|
||||||
log('info', 'Applying masks to create segmented images...');
|
|
||||||
const segmentedImages = await Promise.all(
|
|
||||||
uploadedImages.map(async (image) => {
|
|
||||||
if (!image.maskDataUrl) {
|
|
||||||
throw new Error(`Mask is missing for image: ${image.file.name}`);
|
|
||||||
}
|
|
||||||
const segmentedData = await applyMask(image.previewUrl, image.maskDataUrl);
|
|
||||||
return { ...image, segmentedDataUrl: `data:image/png;base64,${segmentedData}` };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
log('success', 'Masks applied successfully.');
|
|
||||||
|
|
||||||
const result = await generateImage(finalPrompt, segmentedImages);
|
|
||||||
setGenerationResult({
|
|
||||||
baseImage: result,
|
|
||||||
currentImage: result,
|
|
||||||
history: [result],
|
|
||||||
});
|
|
||||||
setStep(AppStep.Result);
|
|
||||||
log('success', 'Thumbnail generated successfully.');
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred during image generation.';
|
|
||||||
log('error', `Final image generation failed: ${errorMessage}`);
|
|
||||||
setError(errorMessage);
|
|
||||||
setStep(AppStep.Prompt);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setLoadingMessage('');
|
|
||||||
}
|
|
||||||
}, [uploadedImages, log]);
|
|
||||||
|
|
||||||
const handleImageRefinement = useCallback(async (refinementPrompt: string) => {
|
|
||||||
if (!generationResult) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
setLoadingMessage('Applying your refinements...');
|
|
||||||
setError(null);
|
|
||||||
log('info', `Refining image with prompt: "${refinementPrompt}"`);
|
|
||||||
try {
|
|
||||||
const newImage = await refineImage(refinementPrompt, generationResult.currentImage);
|
|
||||||
setGenerationResult(prev => {
|
|
||||||
if (!prev) return null;
|
|
||||||
const newHistory = [...prev.history, newImage];
|
|
||||||
return {
|
|
||||||
baseImage: prev.baseImage,
|
|
||||||
currentImage: newImage,
|
|
||||||
history: newHistory,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
log('success', 'Image refined successfully.');
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred during image refinement.';
|
|
||||||
log('error', `Image refinement failed: ${errorMessage}`);
|
|
||||||
setError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setLoadingMessage('');
|
|
||||||
}
|
|
||||||
}, [generationResult, log]);
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
setError(null);
|
|
||||||
if (step === AppStep.Result) {
|
|
||||||
log('info', 'Navigating back from Result to Prompt step.');
|
|
||||||
setMasterPrompt('');
|
|
||||||
setStep(AppStep.Prompt);
|
|
||||||
} else if (step === AppStep.Prompt) {
|
|
||||||
log('info', 'Navigating back from Prompt to Segment step.');
|
|
||||||
setMasterPrompt('');
|
|
||||||
setStep(AppStep.Segment);
|
|
||||||
} else if (step === AppStep.Segment) {
|
|
||||||
log('info', 'Navigating back from Segment to Upload step.');
|
|
||||||
setStep(AppStep.Upload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartOver = () => {
|
|
||||||
log('info', 'Starting over. Resetting application state.');
|
|
||||||
setStep(AppStep.Upload);
|
|
||||||
setUploadedImages([]);
|
|
||||||
setMasterPrompt('');
|
|
||||||
setGenerationResult(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
setLoadingMessage('');
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderStep = () => {
|
|
||||||
switch (step) {
|
|
||||||
case AppStep.Upload:
|
|
||||||
return <ImageUploader onImagesUploaded={handleImagesUploaded} />;
|
|
||||||
case AppStep.Segment:
|
|
||||||
return <ImageSegmenter
|
|
||||||
images={uploadedImages}
|
|
||||||
onComplete={handleSegmentationComplete}
|
|
||||||
onBack={handleBack}
|
|
||||||
/>;
|
|
||||||
case AppStep.Prompt:
|
|
||||||
return <PromptCustomizer
|
|
||||||
onPromptExpanded={handlePromptExpanded}
|
|
||||||
onFinalSubmit={handleFinalGeneration}
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingMessage={loadingMessage}
|
|
||||||
uploadedImages={uploadedImages}
|
|
||||||
onBack={handleBack}
|
|
||||||
masterPrompt={masterPrompt}
|
|
||||||
setMasterPrompt={setMasterPrompt}
|
|
||||||
/>;
|
|
||||||
case AppStep.Result:
|
|
||||||
return <ImageResult
|
|
||||||
result={generationResult}
|
|
||||||
onRefine={handleImageRefinement}
|
|
||||||
masterPrompt={masterPrompt}
|
|
||||||
isLoading={isLoading}
|
|
||||||
loadingMessage={loadingMessage}
|
|
||||||
onStartOver={handleStartOver}
|
|
||||||
/>;
|
|
||||||
default:
|
|
||||||
return <ImageUploader onImagesUploaded={handleImagesUploaded} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-900 text-gray-100 flex flex-col items-center p-4 sm:p-6 lg:p-8 pb-32">
|
|
||||||
<header className="w-full max-w-6xl text-center mb-6">
|
|
||||||
<div className="flex items-center justify-center gap-3">
|
|
||||||
<SparklesIcon className="w-10 h-10 text-purple-400" />
|
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-teko tracking-wider uppercase bg-gradient-to-r from-purple-400 to-pink-500 text-transparent bg-clip-text">
|
|
||||||
K-Pop Thumbnail Genie
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 mt-2 text-sm sm:text-base">Create stunning, emotional YouTube thumbnails with the magic of AI</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="w-full max-w-6xl flex-grow">
|
|
||||||
<StepIndicator currentStep={step} />
|
|
||||||
<div className="mt-8 bg-gray-800/50 p-6 sm:p-8 rounded-2xl shadow-2xl shadow-purple-900/10 border border-gray-700">
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-6 text-center">
|
|
||||||
<p><span className="font-bold">Error:</span> {error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{renderStep()}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="w-full max-w-6xl text-center mt-8 text-gray-500 text-xs">
|
|
||||||
<p>Powered by Google Gemini. Designed for K-Pop content creators.</p>
|
|
||||||
</footer>
|
|
||||||
<DebugConsole />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const App: React.FC = () => (
|
|
||||||
<LoggingProvider>
|
|
||||||
<AppContent />
|
|
||||||
</LoggingProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Run and deploy your AI Studio app
|
|
||||||
|
|
||||||
This contains everything you need to run your app locally.
|
|
||||||
|
|
||||||
View your app in AI Studio: https://ai.studio/apps/drive/18Jqma41iTUAZu0UduZKdv9IMtoJJI3KW
|
|
||||||
|
|
||||||
## Run Locally
|
|
||||||
|
|
||||||
**Prerequisites:** Node.js
|
|
||||||
|
|
||||||
|
|
||||||
1. Install dependencies:
|
|
||||||
`npm install`
|
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
|
||||||
3. Run the app:
|
|
||||||
`npm run dev`
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB |
@@ -1,79 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useLogger, LogMessage, LogType } from '../contexts/LoggingContext';
|
|
||||||
|
|
||||||
const LOG_COLORS: Record<LogType, string> = {
|
|
||||||
info: 'text-gray-300',
|
|
||||||
success: 'text-green-400',
|
|
||||||
error: 'text-red-400',
|
|
||||||
warn: 'text-yellow-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DebugConsole: React.FC = () => {
|
|
||||||
const { logs, clearLogs } = useLogger();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [copyStatus, setCopyStatus] = useState('Copy');
|
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(scrollToBottom, [logs]);
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
const logText = logs.map(log => `[${log.timestamp}] [${log.type.toUpperCase()}] ${log.message}`).join('\n');
|
|
||||||
navigator.clipboard.writeText(logText).then(() => {
|
|
||||||
setCopyStatus('Copied!');
|
|
||||||
setTimeout(() => setCopyStatus('Copy'), 2000);
|
|
||||||
}, () => {
|
|
||||||
setCopyStatus('Failed!');
|
|
||||||
setTimeout(() => setCopyStatus('Copy'), 2000);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="fixed bottom-4 right-4 bg-purple-700 hover:bg-purple-800 text-white rounded-full p-3 shadow-lg z-50 transition-transform hover:scale-110"
|
|
||||||
aria-label="Toggle Debug Console"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 h-1/3 bg-gray-900/95 backdrop-blur-sm border-t-2 border-purple-800 z-40 flex flex-col p-2 shadow-2xl">
|
|
||||||
<div className="flex items-center justify-between mb-2 px-2 flex-shrink-0">
|
|
||||||
<h3 className="font-teko text-2xl text-purple-300 tracking-wide">DEBUG CONSOLE</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={handleCopy} className="text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 py-1 rounded-md transition-colors">{copyStatus}</button>
|
|
||||||
<button onClick={clearLogs} className="text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 py-1 rounded-md transition-colors">Clear</button>
|
|
||||||
<button onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-white">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto flex-grow bg-black/50 p-2 rounded-md font-mono text-sm">
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<p className="text-gray-500">No logs yet. Start using the app to see messages here.</p>
|
|
||||||
) : (
|
|
||||||
logs.map((log, index) => (
|
|
||||||
<div key={index} className="flex">
|
|
||||||
<span className="text-gray-500 mr-2 flex-shrink-0">{log.timestamp}</span>
|
|
||||||
<span className={`${LOG_COLORS[log.type]} whitespace-pre-wrap break-all`}>
|
|
||||||
<span className='font-bold mr-2'>[{log.type.toUpperCase()}]</span>
|
|
||||||
{log.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<div ref={logsEndRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DebugConsole;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { GenerationResult } from '../types';
|
|
||||||
import { DownloadIcon } from './icons/DownloadIcon';
|
|
||||||
import { MagicIcon } from './icons/MagicIcon';
|
|
||||||
|
|
||||||
interface ImageResultProps {
|
|
||||||
result: GenerationResult | null;
|
|
||||||
onRefine: (refinementPrompt: string) => void;
|
|
||||||
masterPrompt: string;
|
|
||||||
isLoading: boolean;
|
|
||||||
loadingMessage: string;
|
|
||||||
onStartOver: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ImageResult: React.FC<ImageResultProps> = ({ result, onRefine, masterPrompt, isLoading, loadingMessage, onStartOver }) => {
|
|
||||||
const [refinementPrompt, setRefinementPrompt] = useState('');
|
|
||||||
|
|
||||||
const handleRefineSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (refinementPrompt.trim()) {
|
|
||||||
onRefine(refinementPrompt);
|
|
||||||
setRefinementPrompt('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<p>Something went wrong. No image was generated.</p>
|
|
||||||
<button onClick={onStartOver} className="mt-4 bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded">Start Over</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-col lg:flex-row gap-8">
|
|
||||||
<div className="lg:w-2/3 relative">
|
|
||||||
<div className="aspect-w-16 aspect-h-9 bg-black rounded-lg overflow-hidden shadow-lg">
|
|
||||||
<img src={`data:image/png;base64,${result.currentImage}`} alt="Generated thumbnail" className="w-full h-full object-contain" />
|
|
||||||
</div>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="absolute inset-0 bg-black/70 flex flex-col items-center justify-center text-center p-4 rounded-lg">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-400 mb-4"></div>
|
|
||||||
<p className="text-lg text-purple-300">{loadingMessage}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:w-1/3 flex flex-col">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-100 mb-4">Your Masterpiece</h2>
|
|
||||||
|
|
||||||
<div className="flex-grow space-y-6">
|
|
||||||
<form onSubmit={handleRefineSubmit}>
|
|
||||||
<label htmlFor="refinement-prompt" className="block text-lg font-semibold text-purple-300 mb-2">
|
|
||||||
Refine Your Image
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="refinement-prompt"
|
|
||||||
value={refinementPrompt}
|
|
||||||
onChange={(e) => setRefinementPrompt(e.target.value)}
|
|
||||||
placeholder="e.g., 'Make the smile a little softer.' or 'Change the background to be more blurry.'"
|
|
||||||
className="w-full bg-gray-700 border border-gray-600 rounded-lg p-3 text-base h-24 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || !refinementPrompt}
|
|
||||||
className="w-full mt-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white font-bold py-2.5 px-4 rounded-lg transition-colors duration-300 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<MagicIcon className="w-5 h-5"/> Refine
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<details className="bg-gray-700/50 rounded-lg">
|
|
||||||
<summary className="cursor-pointer text-purple-300 font-semibold p-3">View Master Prompt</summary>
|
|
||||||
<p className="p-3 pt-0 text-gray-400 text-sm">{masterPrompt}</p>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 space-y-3">
|
|
||||||
<a
|
|
||||||
href={`data:image/png;base64,${result.currentImage}`}
|
|
||||||
download="kpop-thumbnail.png"
|
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg transition-colors duration-300 flex items-center justify-center gap-2 text-lg"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="w-6 h-6" /> Download Image
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={onStartOver}
|
|
||||||
className="w-full bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300"
|
|
||||||
>
|
|
||||||
Start Over
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImageResult;
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { UploadedImage } from '../types';
|
|
||||||
import { segmentSubject } from '../services/geminiService';
|
|
||||||
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
|
|
||||||
import { ArrowRightIcon } from './icons/ArrowRightIcon';
|
|
||||||
import { BrushIcon } from './icons/BrushIcon';
|
|
||||||
import { EraserIcon } from './icons/EraserIcon';
|
|
||||||
import { useLogger } from '../contexts/LoggingContext';
|
|
||||||
|
|
||||||
interface ImageSegmenterProps {
|
|
||||||
images: UploadedImage[];
|
|
||||||
onComplete: (images: UploadedImage[]) => void;
|
|
||||||
onBack: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditorMode = 'brush' | 'eraser';
|
|
||||||
|
|
||||||
const ImageSegmenter: React.FC<ImageSegmenterProps> = ({ images, onComplete, onBack }) => {
|
|
||||||
const [internalImages, setInternalImages] = useState<UploadedImage[]>(images);
|
|
||||||
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>({});
|
|
||||||
const [errorStates, setErrorStates] = useState<Record<number, string | null>>({});
|
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const [mode, setMode] = useState<EditorMode>('brush');
|
|
||||||
const [brushSize, setBrushSize] = useState<number>(20);
|
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
||||||
const maskRef = useRef<HTMLImageElement | null>(null);
|
|
||||||
const isDrawing = useRef<boolean>(false);
|
|
||||||
const { log } = useLogger();
|
|
||||||
|
|
||||||
const generateMask = useCallback(async (index: number) => {
|
|
||||||
setLoadingStates(prev => ({ ...prev, [index]: true }));
|
|
||||||
setErrorStates(prev => ({ ...prev, [index]: null }));
|
|
||||||
log('info', `Requesting segmentation mask for image ${index + 1} ("${internalImages[index].subjectDescription}").`);
|
|
||||||
try {
|
|
||||||
const maskBase64 = await segmentSubject(internalImages[index].file, internalImages[index].subjectDescription);
|
|
||||||
setInternalImages(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
updated[index].maskDataUrl = `data:image/png;base64,${maskBase64}`;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
log('success', `Successfully received segmentation mask for image ${index + 1}.`);
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Mask generation failed';
|
|
||||||
|
|
||||||
let displayError = "Failed";
|
|
||||||
if (errorMessage.includes("The AI returned a message")) {
|
|
||||||
displayError = "AI Response Error";
|
|
||||||
} else if (errorMessage.includes("No segmentation mask")) {
|
|
||||||
displayError = "No Mask Found";
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorStates(prev => ({ ...prev, [index]: displayError }));
|
|
||||||
log('error', `Failed to generate mask for image ${index + 1}: ${errorMessage}`);
|
|
||||||
} finally {
|
|
||||||
setLoadingStates(prev => ({ ...prev, [index]: false }));
|
|
||||||
}
|
|
||||||
}, [internalImages, log]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
internalImages.forEach((image, index) => {
|
|
||||||
if (!image.maskDataUrl && !loadingStates[index] && !errorStates[index]) {
|
|
||||||
generateMask(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [internalImages, generateMask, loadingStates, errorStates]);
|
|
||||||
|
|
||||||
const draw = useCallback(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const originalImage = imageRef.current;
|
|
||||||
const maskImage = maskRef.current;
|
|
||||||
if (!canvas || !originalImage || !maskImage) return;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
if (originalImage.naturalWidth === 0 || maskImage.naturalWidth === 0 || !originalImage.complete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { naturalWidth: w, naturalHeight: h } = originalImage;
|
|
||||||
if(canvas.width !== w) canvas.width = w;
|
|
||||||
if(canvas.height !== h) canvas.height = h;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Use a temporary canvas for the overlay so we don't mess up the main canvas's state
|
|
||||||
const overlayCanvas = document.createElement('canvas');
|
|
||||||
overlayCanvas.width = w;
|
|
||||||
overlayCanvas.height = h;
|
|
||||||
const overlayCtx = overlayCanvas.getContext('2d');
|
|
||||||
if (!overlayCtx) return;
|
|
||||||
|
|
||||||
// Fill the overlay with a semi-transparent black
|
|
||||||
overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
|
||||||
overlayCtx.fillRect(0, 0, w, h);
|
|
||||||
|
|
||||||
// Use 'destination-out' to punch a hole in the overlay where the mask is white
|
|
||||||
overlayCtx.globalCompositeOperation = 'destination-out';
|
|
||||||
overlayCtx.drawImage(maskImage, 0, 0, w, h);
|
|
||||||
|
|
||||||
// Draw the original image on the main canvas
|
|
||||||
ctx.drawImage(originalImage, 0, 0, w, h);
|
|
||||||
|
|
||||||
// Draw the overlay (with the hole punched out) on top
|
|
||||||
ctx.drawImage(overlayCanvas, 0, 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activeImage = internalImages[activeIndex];
|
|
||||||
if (activeImage?.previewUrl && activeImage?.maskDataUrl) {
|
|
||||||
const originalImage = new Image();
|
|
||||||
const maskImage = new Image();
|
|
||||||
|
|
||||||
imageRef.current = originalImage;
|
|
||||||
maskRef.current = maskImage;
|
|
||||||
|
|
||||||
originalImage.src = activeImage.previewUrl;
|
|
||||||
maskImage.src = activeImage.maskDataUrl;
|
|
||||||
|
|
||||||
const loadImages = Promise.all([
|
|
||||||
new Promise((res, rej) => { originalImage.onload = res; originalImage.onerror = rej; }),
|
|
||||||
new Promise((res, rej) => { maskImage.onload = res; maskImage.onerror = rej; })
|
|
||||||
]);
|
|
||||||
|
|
||||||
loadImages.then(() => {
|
|
||||||
draw();
|
|
||||||
}).catch(err => {
|
|
||||||
console.error("Error loading images for canvas: ", err);
|
|
||||||
log('error', `Canvas Error: Failed to load images for editor view. ${err}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeIndex, internalImages, draw, log]);
|
|
||||||
|
|
||||||
const handleCanvasInteraction = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
|
||||||
if (!isDrawing.current && e.type !== 'mousedown' && e.type !== 'touchstart') return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas || !maskRef.current) return;
|
|
||||||
|
|
||||||
const tempCanvas = document.createElement('canvas');
|
|
||||||
tempCanvas.width = maskRef.current.naturalWidth;
|
|
||||||
tempCanvas.height = maskRef.current.naturalHeight;
|
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
|
||||||
if (!tempCtx) return;
|
|
||||||
|
|
||||||
tempCtx.drawImage(maskRef.current, 0, 0);
|
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const scaleX = tempCanvas.width / rect.width;
|
|
||||||
const scaleY = tempCanvas.height / rect.height;
|
|
||||||
|
|
||||||
const getCoords = (evt: any) => {
|
|
||||||
if (evt.touches) {
|
|
||||||
return { x: evt.touches[0].clientX - rect.left, y: evt.touches[0].clientY - rect.top };
|
|
||||||
}
|
|
||||||
return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
|
|
||||||
}
|
|
||||||
const {x, y} = getCoords(e.nativeEvent);
|
|
||||||
|
|
||||||
tempCtx.fillStyle = mode === 'brush' ? '#FFFFFF' : '#000000';
|
|
||||||
tempCtx.beginPath();
|
|
||||||
tempCtx.arc(x * scaleX, y * scaleY, (brushSize/2) * scaleX, 0, 2 * Math.PI);
|
|
||||||
tempCtx.fill();
|
|
||||||
|
|
||||||
const newMaskUrl = tempCanvas.toDataURL();
|
|
||||||
maskRef.current.src = newMaskUrl;
|
|
||||||
|
|
||||||
maskRef.current.onload = () => {
|
|
||||||
draw();
|
|
||||||
// Update state debounced or on mouse up for performance
|
|
||||||
if (e.type === 'mouseup' || e.type === 'touchend') {
|
|
||||||
setInternalImages(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
updated[activeIndex].maskDataUrl = newMaskUrl;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const startDrawing = () => { isDrawing.current = true; };
|
|
||||||
const stopDrawing = (e: any) => { isDrawing.current = false; handleCanvasInteraction(e); };
|
|
||||||
|
|
||||||
const canProceed = internalImages.every(img => img.maskDataUrl);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-100">Review & Refine Subjects</h2>
|
|
||||||
<p className="text-gray-400">The AI has extracted the subjects. Use the tools to refine the selection if needed.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
|
||||||
{/* Thumbnails */}
|
|
||||||
<div className="lg:w-1/4 flex lg:flex-col gap-2 overflow-x-auto lg:overflow-y-auto lg:max-h-[500px] p-2 bg-gray-900/50 rounded-lg">
|
|
||||||
{internalImages.map((image, index) => (
|
|
||||||
<button key={index} onClick={() => setActiveIndex(index)} className={`rounded-lg border-2 transition-all p-1 flex-shrink-0 ${activeIndex === index ? 'border-purple-500' : 'border-transparent hover:border-gray-600'}`}>
|
|
||||||
<div className="relative w-24 h-24">
|
|
||||||
{loadingStates[index] && <div className="absolute inset-0 bg-black/70 flex items-center justify-center rounded-md"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-400"></div></div>}
|
|
||||||
{errorStates[index] && <div className="absolute inset-0 bg-red-900/80 text-white text-xs text-center flex items-center justify-center p-1 rounded-md">{errorStates[index]}</div>}
|
|
||||||
{image.maskDataUrl && <img src={image.maskDataUrl} alt={`mask preview ${index}`} className="w-full h-full object-contain rounded-md bg-black" />}
|
|
||||||
{!image.maskDataUrl && !loadingStates[index] && !errorStates[index] && <div className="w-full h-full bg-gray-700 rounded-md flex items-center justify-center text-xs text-gray-400">Waiting...</div>}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editor */}
|
|
||||||
<div className="lg:w-3/4 flex flex-col items-center">
|
|
||||||
<div className="w-full flex justify-center items-center mb-4 p-2 bg-gray-700/50 rounded-lg">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button onClick={() => setMode('brush')} className={`p-2 rounded-md transition-colors ${mode === 'brush' ? 'bg-purple-600' : 'bg-gray-600 hover:bg-gray-500'}`}><BrushIcon className="w-6 h-6"/></button>
|
|
||||||
<button onClick={() => setMode('eraser')} className={`p-2 rounded-md transition-colors ${mode === 'eraser' ? 'bg-purple-600' : 'bg-gray-600 hover:bg-gray-500'}`}><EraserIcon className="w-6 h-6"/></button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label htmlFor="brushSize" className="text-sm">Size:</label>
|
|
||||||
<input type="range" id="brushSize" min="2" max="100" value={brushSize} onChange={e => setBrushSize(Number(e.target.value))} className="w-32 cursor-pointer"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="rounded-lg max-w-full h-auto"
|
|
||||||
onMouseDown={startDrawing}
|
|
||||||
onMouseUp={stopDrawing}
|
|
||||||
onMouseMove={handleCanvasInteraction}
|
|
||||||
onMouseLeave={stopDrawing}
|
|
||||||
onTouchStart={startDrawing}
|
|
||||||
onTouchEnd={stopDrawing}
|
|
||||||
onTouchMove={handleCanvasInteraction}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 pt-8 mt-6 border-t border-gray-700">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-300 flex items-center gap-2 w-full sm:w-auto justify-center"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="w-5 h-5"/> Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onComplete(internalImages)}
|
|
||||||
disabled={!canProceed}
|
|
||||||
className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 text-lg flex items-center gap-2 mx-auto w-full sm:w-auto justify-center"
|
|
||||||
>
|
|
||||||
Next: Customize Prompt <ArrowRightIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImageSegmenter;
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { UploadedImage } from '../types';
|
|
||||||
import { UploadIcon } from './icons/UploadIcon';
|
|
||||||
import { ArrowRightIcon } from './icons/ArrowRightIcon';
|
|
||||||
|
|
||||||
interface ImageUploaderProps {
|
|
||||||
onImagesUploaded: (images: UploadedImage[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ImageUploader: React.FC<ImageUploaderProps> = ({ onImagesUploaded }) => {
|
|
||||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setError(null);
|
|
||||||
if (event.target.files) {
|
|
||||||
const files = Array.from(event.target.files);
|
|
||||||
if (files.length + images.length > 5) {
|
|
||||||
setError("You can upload a maximum of 5 images.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fix: Explicitly type `file` as `File` to resolve type inference issues.
|
|
||||||
const newImages: UploadedImage[] = files.map((file: File) => ({
|
|
||||||
file,
|
|
||||||
previewUrl: URL.createObjectURL(file),
|
|
||||||
subjectDescription: '',
|
|
||||||
}));
|
|
||||||
setImages(prev => [...prev, ...newImages]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDescriptionChange = (index: number, value: string) => {
|
|
||||||
setImages(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
updated[index].subjectDescription = value;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeImage = (indexToRemove: number) => {
|
|
||||||
setImages(prev => prev.filter((_, index) => index !== indexToRemove));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (images.length < 2) {
|
|
||||||
setError('Please upload at least 2 images.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (images.some(img => img.subjectDescription.trim() === '')) {
|
|
||||||
setError('Please describe the main subject in each image.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onImagesUploaded(images);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-100">Upload Your Source Images</h2>
|
|
||||||
<p className="text-gray-400">Upload 2 or more images. Then, briefly describe the person you want to feature from each.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<label htmlFor="file-upload" className="relative cursor-pointer bg-gray-700 hover:bg-gray-600 text-purple-300 font-semibold py-3 px-5 rounded-lg border border-dashed border-gray-500 flex flex-col items-center justify-center transition-colors duration-300 h-48">
|
|
||||||
<UploadIcon className="w-12 h-12 mb-2 text-gray-400"/>
|
|
||||||
<span className="text-lg">Click to upload images</span>
|
|
||||||
<span className="text-sm text-gray-500">PNG, JPG, WEBP up to 10MB</span>
|
|
||||||
<input id="file-upload" name="file-upload" type="file" multiple accept="image/png, image/jpeg, image/webp" className="sr-only" onChange={handleFileChange} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{images.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
|
||||||
{images.map((image, index) => (
|
|
||||||
<div key={index} className="bg-gray-700/50 rounded-lg p-3 relative group">
|
|
||||||
<img src={image.previewUrl} alt={`preview ${index}`} className="w-full h-40 object-cover rounded-md mb-3" />
|
|
||||||
<textarea
|
|
||||||
value={image.subjectDescription}
|
|
||||||
onChange={(e) => handleDescriptionChange(index, e.target.value)}
|
|
||||||
placeholder={`e.g., The man with glasses`}
|
|
||||||
className="w-full bg-gray-800 border border-gray-600 rounded-md p-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => removeImage(index)}
|
|
||||||
className="absolute top-1 right-1 bg-black/50 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
aria-label="Remove image"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-center my-4">{error}</p>}
|
|
||||||
|
|
||||||
<div className="text-center mt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={images.length < 2 || images.some(i => !i.subjectDescription)}
|
|
||||||
className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 text-lg flex items-center gap-2 mx-auto"
|
|
||||||
>
|
|
||||||
Next: Customize Prompt <ArrowRightIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImageUploader;
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { PROMPT_TEMPLATES } from '../constants';
|
|
||||||
import { UploadedImage } from '../types';
|
|
||||||
import { MagicIcon } from './icons/MagicIcon';
|
|
||||||
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
|
|
||||||
|
|
||||||
interface PromptCustomizerProps {
|
|
||||||
onPromptExpanded: (scenario: string, userInstruction: string) => void;
|
|
||||||
onFinalSubmit: (masterPrompt: string) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
loadingMessage: string;
|
|
||||||
uploadedImages: UploadedImage[];
|
|
||||||
onBack: () => void;
|
|
||||||
masterPrompt: string;
|
|
||||||
setMasterPrompt: (prompt: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PromptCustomizer: React.FC<PromptCustomizerProps> = ({
|
|
||||||
onPromptExpanded,
|
|
||||||
onFinalSubmit,
|
|
||||||
isLoading,
|
|
||||||
loadingMessage,
|
|
||||||
uploadedImages,
|
|
||||||
onBack,
|
|
||||||
masterPrompt,
|
|
||||||
setMasterPrompt
|
|
||||||
}) => {
|
|
||||||
const [selectedScenario, setSelectedScenario] = useState<string>(PROMPT_TEMPLATES[0].title);
|
|
||||||
const [userInstruction, setUserInstruction] = useState<string>('');
|
|
||||||
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (masterPrompt) {
|
|
||||||
setIsPromptExpanded(true);
|
|
||||||
}
|
|
||||||
}, [masterPrompt]);
|
|
||||||
|
|
||||||
const handleExpandPrompt = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (selectedScenario && userInstruction) {
|
|
||||||
onPromptExpanded(selectedScenario, userInstruction);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFinalSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (masterPrompt) {
|
|
||||||
onFinalSubmit(masterPrompt);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading && !isPromptExpanded) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center text-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-purple-400 mb-4"></div>
|
|
||||||
<p className="text-xl text-purple-300">{loadingMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-100">{isPromptExpanded ? 'Review Your Master Prompt' : 'Describe Your Scene'}</h2>
|
|
||||||
<p className="text-gray-400">{isPromptExpanded ? 'Edit the AI-generated prompt below, then generate your image.' : 'Choose a starting scenario and describe your vision.'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
|
||||||
<div className="lg:w-1/3">
|
|
||||||
<h3 className="text-lg font-semibold text-purple-300 mb-3">Your Subjects</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{uploadedImages.map((image, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2 bg-gray-700 p-2 rounded-lg">
|
|
||||||
<img src={image.maskDataUrl || image.previewUrl} alt={`subject ${index}`} className="w-10 h-10 rounded-md object-cover bg-black" />
|
|
||||||
<p className="text-sm text-gray-300 flex-1">{image.subjectDescription}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-grow lg:w-2/3 space-y-6">
|
|
||||||
{!isPromptExpanded ? (
|
|
||||||
<form onSubmit={handleExpandPrompt} className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-lg font-semibold text-purple-300 mb-2">1. Choose a K-Pop Scenario</label>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{PROMPT_TEMPLATES.map(template => (
|
|
||||||
<button
|
|
||||||
key={template.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedScenario(template.title)}
|
|
||||||
className={`p-4 rounded-lg text-left transition-all duration-200 border-2 ${selectedScenario === template.title ? 'bg-purple-800/50 border-purple-500' : 'bg-gray-700 border-gray-600 hover:border-purple-600'}`}
|
|
||||||
>
|
|
||||||
<p className="font-bold text-white">{template.title}</p>
|
|
||||||
<p className="text-sm text-gray-400">{template.description}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="user-instruction" className="block text-lg font-semibold text-purple-300 mb-2">2. Describe Your Idea</label>
|
|
||||||
<textarea
|
|
||||||
id="user-instruction"
|
|
||||||
value={userInstruction}
|
|
||||||
onChange={(e) => setUserInstruction(e.target.value)}
|
|
||||||
placeholder="Example: The person from image 1 should stand behind the person from image 2, placing a hand on their shoulder. Use the background from image 2."
|
|
||||||
className="w-full bg-gray-800 border border-gray-600 rounded-lg p-3 text-base h-32 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 pt-4">
|
|
||||||
<button type="button" onClick={onBack} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-300 flex items-center gap-2 w-full sm:w-auto justify-center">
|
|
||||||
<ArrowLeftIcon className="w-5 h-5"/> Back
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={isLoading || !userInstruction || !selectedScenario} className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 flex items-center gap-2 w-full sm:w-auto justify-center">
|
|
||||||
Create Master Prompt <MagicIcon className="w-5 h-5"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleFinalSubmit} className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="master-prompt" className="block text-lg font-semibold text-purple-300 mb-2">Master Prompt</label>
|
|
||||||
<textarea
|
|
||||||
id="master-prompt"
|
|
||||||
value={masterPrompt}
|
|
||||||
onChange={(e) => setMasterPrompt(e.target.value)}
|
|
||||||
className="w-full bg-gray-800 border border-gray-600 rounded-lg p-3 text-base h-48 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 pt-4">
|
|
||||||
<button type="button" onClick={() => setIsPromptExpanded(false)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-300 flex items-center gap-2 w-full sm:w-auto justify-center">
|
|
||||||
<ArrowLeftIcon className="w-5 h-5"/> Edit Scenario
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={isLoading || !masterPrompt} className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 disabled:from-gray-600 disabled:to-gray-600 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 text-lg flex items-center gap-2 w-full sm:w-auto justify-center">
|
|
||||||
{isLoading ? loadingMessage : 'Generate Thumbnail'}
|
|
||||||
{!isLoading && <MagicIcon className="w-6 h-6"/>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PromptCustomizer;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { AppStep } from '../types';
|
|
||||||
|
|
||||||
interface StepIndicatorProps {
|
|
||||||
currentStep: AppStep;
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ id: AppStep.Upload, title: 'Upload' },
|
|
||||||
{ id: AppStep.Segment, title: 'Segment Subjects'},
|
|
||||||
{ id: AppStep.Prompt, title: 'Create Prompt' },
|
|
||||||
{ id: AppStep.Result, title: 'Generate & Refine' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep }) => {
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="flex items-center justify-between md:justify-center">
|
|
||||||
{steps.map((step, index) => {
|
|
||||||
const isActive = currentStep === step.id;
|
|
||||||
const isCompleted = currentStep > step.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={step.id}>
|
|
||||||
<div className="flex items-center flex-col sm:flex-row text-center">
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 ${
|
|
||||||
isActive ? 'bg-purple-600 scale-110' : isCompleted ? 'bg-green-600' : 'bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`font-bold ${isActive || isCompleted ? 'text-white' : 'text-gray-400'}`}>
|
|
||||||
{isCompleted ? (
|
|
||||||
<svg xmlns="http://www.w.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
) : step.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className={`mt-2 sm:mt-0 sm:ml-3 font-semibold text-xs sm:text-sm ${isActive ? 'text-purple-300' : 'text-gray-500'}`}>{step.title}</p>
|
|
||||||
</div>
|
|
||||||
{index < steps.length - 1 && (
|
|
||||||
<div className={`flex-auto border-t-2 transition-colors duration-300 mx-2 sm:mx-4 w-4 sm:w-auto ${isCompleted ? 'border-green-600' : 'border-gray-700'}`}></div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StepIndicator;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const ArrowLeftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const ArrowRightIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const BrushIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L15.232 5.232z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const DownloadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const EraserIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.452 7.618a.875.875 0 00-1.238 0l-7.366 7.366a.875.875 0 000 1.238l7.366 7.366a.875.875 0 001.238 0l3.85-3.85a.875.875 0 000-1.238l-7.366-7.366-3.85-3.85zM4 20h10" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 20l15-15" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const MagicIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.25278C12 6.25278 15.011 3.24178 17.761 6.00078C20.511 8.75978 17.5 12.0008 17.5 12.0008M12 6.25278C12 6.25278 8.989 3.24178 6.239 6.00078C3.489 8.75978 6.5 12.0008 6.5 12.0008M12 6.25278V21.0008M17.5 12.0008L19.25 13.7508M6.5 12.0008L4.75 13.7508M12 21.0008H14.25M12 21.0008H9.75" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const SparklesIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.898 20.572L16.5 21.75l-.398-1.178a3.375 3.375 0 00-2.456-2.456L12.5 18l1.178-.398a3.375 3.375 0 002.456-2.456L16.5 14.25l.398 1.178a3.375 3.375 0 002.456 2.456L20.5 18l-1.178.398a3.375 3.375 0 00-2.456 2.456z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const UploadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
|
|
||||||
export const PROMPT_TEMPLATES = [
|
|
||||||
{
|
|
||||||
id: 'reunion',
|
|
||||||
title: 'Emotional Reunion',
|
|
||||||
description: 'A touching and heartfelt reunion scene.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'playful',
|
|
||||||
title: 'Playful Interaction',
|
|
||||||
description: 'A fun, teasing, or lighthearted moment.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'magazine',
|
|
||||||
title: 'Magazine Cover',
|
|
||||||
description: 'A high-fashion, photoshoot-style composition.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'eye-contact',
|
|
||||||
title: 'Direct Eye Contact',
|
|
||||||
description: 'One or more subjects looking directly at the viewer.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'dramatic',
|
|
||||||
title: 'K-Drama Poster',
|
|
||||||
description: 'A dramatic, poster-like scene with emotional lighting.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
|
|
||||||
import React, { createContext, useState, useContext, useCallback } from 'react';
|
|
||||||
|
|
||||||
export type LogType = 'info' | 'success' | 'error' | 'warn';
|
|
||||||
|
|
||||||
export interface LogMessage {
|
|
||||||
type: LogType;
|
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoggingContextType {
|
|
||||||
logs: LogMessage[];
|
|
||||||
log: (type: LogType, message: string) => void;
|
|
||||||
clearLogs: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoggingContext = createContext<LoggingContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const LoggingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
||||||
const [logs, setLogs] = useState<LogMessage[]>([]);
|
|
||||||
|
|
||||||
const log = useCallback((type: LogType, message: string) => {
|
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
|
||||||
setLogs(prevLogs => [...prevLogs, { type, message, timestamp }]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearLogs = useCallback(() => {
|
|
||||||
setLogs([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoggingContext.Provider value={{ logs, log, clearLogs }}>
|
|
||||||
{children}
|
|
||||||
</LoggingContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useLogger = (): LoggingContextType => {
|
|
||||||
const context = useContext(LoggingContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useLogger must be used within a LoggingProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 414 KiB |
@@ -1,37 +0,0 @@
|
|||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>K-Pop Thumbnail Genie</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Teko:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Roboto', sans-serif;
|
|
||||||
}
|
|
||||||
.font-teko {
|
|
||||||
font-family: 'Teko', sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
|
||||||
"react": "https://aistudiocdn.com/react@^19.2.0",
|
|
||||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
|
|
||||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.29.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="/index.css">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-900 text-white">
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/index.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
|
||||||
if (!rootElement) {
|
|
||||||
throw new Error("Could not find root element to mount to");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "k-pop-thumbnail-genie",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"@google/genai": "^1.29.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.14.0",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"typescript": "~5.8.2",
|
|
||||||
"vite": "^6.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import { GoogleGenAI, Modality } from "@google/genai";
|
|
||||||
import { UploadedImage, ImageFile } from '../types';
|
|
||||||
|
|
||||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
|
||||||
|
|
||||||
const fileToGenerativePart = async (file: File): Promise<ImageFile> => {
|
|
||||||
const base64EncodedDataPromise = new Promise<string>((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => resolve((reader.result as string).split(',')[1]);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
inlineData: {
|
|
||||||
data: await base64EncodedDataPromise,
|
|
||||||
mimeType: file.type,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataUrlToGenerativePart = (dataUrl: string): ImageFile => {
|
|
||||||
const [header, data] = dataUrl.split(',');
|
|
||||||
const mimeType = header.match(/:(.*?);/)?.[1] || 'image/png';
|
|
||||||
return {
|
|
||||||
inlineData: { data, mimeType }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorMessage = (error: unknown): string => {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const segmentSubject = async (imageFile: File, description: string): Promise<string> => {
|
|
||||||
const imagePart = await fileToGenerativePart(imageFile);
|
|
||||||
// A more descriptive, less technical prompt to guide the AI model more reliably.
|
|
||||||
const prompt = `
|
|
||||||
Analyze the provided image to identify the subject described as: "${description}".
|
|
||||||
Your task is to create a new image based on this analysis.
|
|
||||||
In this new image, the area that corresponds to the identified subject MUST be solid white (#FFFFFF).
|
|
||||||
Every other part of the image, which is the background, MUST be solid black (#000000).
|
|
||||||
The final output must ONLY be the image file. Do not include any text, explanations, or any other content in your response.
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await ai.models.generateContent({
|
|
||||||
model: 'gemini-2.5-flash-image',
|
|
||||||
contents: { parts: [imagePart, { text: prompt }] },
|
|
||||||
config: {
|
|
||||||
responseModalities: [Modality.IMAGE],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Improved error detection: Check for blocking first.
|
|
||||||
if (response.promptFeedback?.blockReason) {
|
|
||||||
throw new Error(`Request blocked due to: ${response.promptFeedback.blockReason}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPart = response.candidates?.[0]?.content?.parts[0];
|
|
||||||
if (firstPart && firstPart.inlineData) {
|
|
||||||
return firstPart.inlineData.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// More detailed error reporting if no image is returned.
|
|
||||||
const fullResponseText = JSON.stringify(response, null, 2);
|
|
||||||
const textResponse = response.text?.trim();
|
|
||||||
if (textResponse) {
|
|
||||||
throw new Error(`The AI returned a message instead of a mask: "${textResponse}". Full API response: ${fullResponseText}`);
|
|
||||||
}
|
|
||||||
throw new Error(`No segmentation mask received from the AI. Full API response: ${fullResponseText}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error segmenting subject:", error);
|
|
||||||
throw new Error(`Failed to segment subject: ${getErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const expandPrompt = async (
|
|
||||||
scenario: string,
|
|
||||||
userInstruction: string,
|
|
||||||
images: UploadedImage[]
|
|
||||||
): Promise<string> => {
|
|
||||||
const subjectDescriptions = images
|
|
||||||
.map((img, i) => `Person from Image ${i + 1}: ${img.subjectDescription}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const systemInstruction = `You are a creative assistant specializing in writing detailed, effective prompts for an AI image generator. Your goal is to create a single, photorealistic, emotionally resonant 16:9 YouTube thumbnail in a K-Pop aesthetic.
|
|
||||||
- Combine the user's chosen scenario, their specific instructions, and the descriptions of the people involved.
|
|
||||||
- The output must be a single, cohesive paragraph. Do not use lists or bullet points.
|
|
||||||
- Translate the user's simple instructions into a rich, detailed description for the AI. Describe the composition, camera angle, lighting, and mood.
|
|
||||||
- Emphasize achieving high facial fidelity to the described people. The final image should look like a real photograph or a high-quality still from a music video.
|
|
||||||
- Mention specific K-Pop aesthetic elements like soft, slightly dramatic lighting, a subtle bokeh effect for the background, and a focus on emotional expression.`;
|
|
||||||
|
|
||||||
const userPrompt = `
|
|
||||||
Scenario: "${scenario}"
|
|
||||||
User Instruction: "${userInstruction}"
|
|
||||||
People to include:
|
|
||||||
${subjectDescriptions}
|
|
||||||
|
|
||||||
Generate the master prompt based on this information.
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await ai.models.generateContent({
|
|
||||||
model: 'gemini-2.5-flash',
|
|
||||||
contents: [{ parts: [{ text: userPrompt }] }],
|
|
||||||
config: { systemInstruction: systemInstruction }
|
|
||||||
});
|
|
||||||
return response.text;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error expanding prompt:", error);
|
|
||||||
throw new Error(`Failed to generate the master prompt: ${getErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateImage = async (
|
|
||||||
masterPrompt: string,
|
|
||||||
images: UploadedImage[]
|
|
||||||
): Promise<string> => {
|
|
||||||
|
|
||||||
// Now using pre-segmented images with transparent backgrounds
|
|
||||||
const imageParts = images.map(img => {
|
|
||||||
if (!img.segmentedDataUrl) throw new Error("Segmented image data is missing.");
|
|
||||||
return dataUrlToGenerativePart(img.segmentedDataUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fullPrompt = `Task: Create a new photorealistic 16:9 image by composing the subjects from the provided images into a new scene. The subjects are provided as separate images with transparent backgrounds.
|
|
||||||
Instructions: ${masterPrompt}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await ai.models.generateContent({
|
|
||||||
model: 'gemini-2.5-flash-image',
|
|
||||||
contents: {
|
|
||||||
parts: [
|
|
||||||
...imageParts,
|
|
||||||
{ text: fullPrompt },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
responseModalities: [Modality.IMAGE],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstPart = response.candidates?.[0]?.content?.parts[0];
|
|
||||||
if (firstPart && firstPart.inlineData) {
|
|
||||||
return firstPart.inlineData.data;
|
|
||||||
} else {
|
|
||||||
const textResponse = response.text?.trim();
|
|
||||||
if (textResponse) {
|
|
||||||
throw new Error(`The AI failed to generate an image and returned a message: "${textResponse}"`);
|
|
||||||
}
|
|
||||||
throw new Error('No image data received from the AI.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating image:", error);
|
|
||||||
throw new Error(`Failed to generate the image: ${getErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const refineImage = async (
|
|
||||||
refinementPrompt: string,
|
|
||||||
base64Image: string
|
|
||||||
): Promise<string> => {
|
|
||||||
|
|
||||||
const imagePart = {
|
|
||||||
inlineData: {
|
|
||||||
data: base64Image,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await ai.models.generateContent({
|
|
||||||
model: 'gemini-2.5-flash-image',
|
|
||||||
contents: {
|
|
||||||
parts: [
|
|
||||||
imagePart,
|
|
||||||
{ text: refinementPrompt },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
responseModalities: [Modality.IMAGE],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstPart = response.candidates?.[0]?.content?.parts[0];
|
|
||||||
if (firstPart && firstPart.inlineData) {
|
|
||||||
return firstPart.inlineData.data;
|
|
||||||
} else {
|
|
||||||
const textResponse = response.text?.trim();
|
|
||||||
if (textResponse) {
|
|
||||||
throw new Error(`The AI failed to refine the image and returned a message: "${textResponse}"`);
|
|
||||||
}
|
|
||||||
throw new Error('No refined image data received from the AI.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error refining image:", error);
|
|
||||||
throw new Error(`Failed to refine the image: ${getErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
|
|
||||||
export interface UploadedImage {
|
|
||||||
file: File;
|
|
||||||
previewUrl: string;
|
|
||||||
subjectDescription: string;
|
|
||||||
maskDataUrl?: string;
|
|
||||||
segmentedDataUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AppStep {
|
|
||||||
Upload = 1,
|
|
||||||
Segment = 2,
|
|
||||||
Prompt = 3,
|
|
||||||
Result = 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenerationResult {
|
|
||||||
baseImage: string; // base64 string
|
|
||||||
currentImage: string; // base64 string
|
|
||||||
history: string[]; // array of base64 strings
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageFile {
|
|
||||||
inlineData: {
|
|
||||||
data: string;
|
|
||||||
mimeType: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
|
|
||||||
export const applyMask = (originalImageUrl: string, maskDataUrl: string): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return reject(new Error('Could not get canvas context'));
|
|
||||||
|
|
||||||
const original = new Image();
|
|
||||||
original.crossOrigin = 'anonymous';
|
|
||||||
const mask = new Image();
|
|
||||||
mask.crossOrigin = 'anonymous';
|
|
||||||
|
|
||||||
let loadedImages = 0;
|
|
||||||
const onImageLoad = () => {
|
|
||||||
loadedImages++;
|
|
||||||
if (loadedImages === 2) {
|
|
||||||
canvas.width = original.naturalWidth;
|
|
||||||
canvas.height = original.naturalHeight;
|
|
||||||
|
|
||||||
// Draw the original image
|
|
||||||
ctx.drawImage(original, 0, 0);
|
|
||||||
|
|
||||||
// Use 'destination-in' to keep the parts of the original image
|
|
||||||
// that overlap with the non-transparent parts of the mask.
|
|
||||||
// The mask should have white for the subject and black for the background.
|
|
||||||
// For destination-in, any non-transparent part of the mask will be kept.
|
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
|
||||||
ctx.drawImage(mask, 0, 0);
|
|
||||||
|
|
||||||
// Return base64 data of the resulting image (PNG for transparency)
|
|
||||||
resolve(canvas.toDataURL('image/png').split(',')[1]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
original.onload = onImageLoad;
|
|
||||||
mask.onload = onImageLoad;
|
|
||||||
original.onerror = () => reject(new Error('Failed to load original image.'));
|
|
||||||
mask.onerror = () => reject(new Error('Failed to load mask image.'));
|
|
||||||
|
|
||||||
original.src = originalImageUrl;
|
|
||||||
mask.src = maskDataUrl;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const env = loadEnv(mode, '.', '');
|
|
||||||
return {
|
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
host: '0.0.0.0',
|
|
||||||
},
|
|
||||||
plugins: [react()],
|
|
||||||
define: {
|
|
||||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, '.'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user