|
<script lang="ts"> |
|
import type { MonsterGeneratorProps, MonsterWorkflowState, CaptionType, CaptionLength } from '$lib/types'; |
|
import UploadStep from './UploadStep.svelte'; |
|
import WorkflowProgress from './WorkflowProgress.svelte'; |
|
import MonsterResult from './MonsterResult.svelte'; |
|
|
|
interface Props extends MonsterGeneratorProps {} |
|
|
|
let { joyCaptionClient, rwkvClient, fluxClient }: Props = $props(); |
|
|
|
let state: MonsterWorkflowState = $state({ |
|
currentStep: 'upload', |
|
userImage: null, |
|
imageCaption: null, |
|
monsterConcept: null, |
|
imagePrompt: null, |
|
monsterImage: null, |
|
error: null, |
|
isProcessing: false |
|
}); |
|
|
|
|
|
const MONSTER_CONCEPT_PROMPT = (caption: string) => `User: Based on this image caption: "${caption}" |
|
|
|
Come up with a monster idea (in the vein of Pokémon) that incorporates properties of what the pictured object is. |
|
Assess how unique the pictured object is. If the image is not very unique then it should result in weak & common monster. |
|
Meanwhile if the image is very unique the monster should be unique and powerful. |
|
|
|
Create a creature that reflects the essence, materials, function, and characteristics of the object. Consider: |
|
- What the object is made of (metal, wood, fabric, etc.) |
|
- How it functions or is used |
|
- Its physical properties (hard, soft, hot, cold, etc.) |
|
- Its cultural or symbolic meaning |
|
- Its shape, texture, and visual characteristics |
|
|
|
The monster should have unique strengths and weaknesses based on these properties. |
|
|
|
Include: |
|
- Physical appearance and distinguishing features |
|
- Special abilities or powers |
|
- Personality traits |
|
- Habitat or origin |
|
|
|
Assistant: **Monster Name:**`; |
|
|
|
const IMAGE_GENERATION_PROMPT = (concept: string) => `User: Convert this monster concept into a clear and succinct description of its appearance: |
|
"${concept}" |
|
|
|
Include all of its visual details, format the description as a single long sentence. |
|
|
|
Assistant:`; |
|
|
|
async function handleImageSelected(file: File) { |
|
if (!joyCaptionClient || !rwkvClient || !fluxClient) { |
|
state.error = "Services not connected. Please wait..."; |
|
return; |
|
} |
|
|
|
state.userImage = file; |
|
state.error = null; |
|
startWorkflow(); |
|
} |
|
|
|
async function startWorkflow() { |
|
state.isProcessing = true; |
|
|
|
try { |
|
|
|
await captionImage(); |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
|
|
|
|
await generateMonsterConcept(); |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
|
|
|
|
await generateImagePrompt(); |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
|
|
|
|
await generateMonsterImage(); |
|
|
|
state.currentStep = 'complete'; |
|
} catch (err) { |
|
console.error('Workflow error:', err); |
|
|
|
|
|
if (err && typeof err === 'object' && 'message' in err) { |
|
const errorMessage = String(err.message); |
|
if (errorMessage.includes('exceeded your GPU quota') || errorMessage.includes('GPU quota')) { |
|
state.error = 'GPU quota exceeded! You need to sign in with Hugging Face for free GPU time, or upgrade to Hugging Face Pro for more quota.'; |
|
} else { |
|
state.error = errorMessage; |
|
} |
|
} else if (err instanceof Error) { |
|
state.error = err.message; |
|
} else { |
|
state.error = 'An unknown error occurred'; |
|
} |
|
} finally { |
|
state.isProcessing = false; |
|
} |
|
} |
|
|
|
function handleAPIError(error: any): never { |
|
console.error('API Error:', error); |
|
|
|
|
|
if (error && typeof error === 'object' && 'message' in error) { |
|
const errorMessage = String(error.message); |
|
if (errorMessage.includes('exceeded your GPU quota') || errorMessage.includes('GPU quota')) { |
|
throw new Error('GPU quota exceeded! You need to sign in with Hugging Face for free GPU time, or upgrade to Hugging Face Pro for more quota.'); |
|
} |
|
throw new Error(errorMessage); |
|
} |
|
|
|
|
|
if (error && typeof error === 'object' && 'type' in error && error.type === 'status') { |
|
const statusError = error as any; |
|
if (statusError.message && statusError.message.includes('GPU quota')) { |
|
throw new Error('GPU quota exceeded! You need to sign in with Hugging Face for free GPU time, or upgrade to Hugging Face Pro for more quota.'); |
|
} |
|
throw new Error(statusError.message || 'API request failed'); |
|
} |
|
|
|
throw error; |
|
} |
|
|
|
async function captionImage() { |
|
state.currentStep = 'captioning'; |
|
|
|
if (!joyCaptionClient || !state.userImage) { |
|
throw new Error('Caption service not available or no image provided'); |
|
} |
|
|
|
try { |
|
const output = await joyCaptionClient.predict("/stream_chat", [ |
|
state.userImage, |
|
"Descriptive" as CaptionType, |
|
"very long" as CaptionLength, |
|
[], |
|
"", |
|
"" |
|
]); |
|
|
|
const [prompt, caption] = output.data; |
|
state.imageCaption = caption; |
|
console.log('Caption generated:', caption); |
|
} catch (error) { |
|
handleAPIError(error); |
|
} |
|
} |
|
|
|
async function generateMonsterConcept() { |
|
state.currentStep = 'conceptualizing'; |
|
|
|
if (!rwkvClient || !state.imageCaption) { |
|
throw new Error('Text generation service not available or no caption'); |
|
} |
|
|
|
const conceptPrompt = MONSTER_CONCEPT_PROMPT(state.imageCaption); |
|
|
|
console.log('Generating monster concept with prompt:', conceptPrompt); |
|
|
|
try { |
|
const output = await rwkvClient.predict(0, [ |
|
conceptPrompt, |
|
300, |
|
0.7, |
|
0.8, |
|
0.1, |
|
0.1 |
|
]); |
|
|
|
console.log('RWKV output:', output); |
|
state.monsterConcept = output.data[0]; |
|
console.log('Monster concept generated:', state.monsterConcept); |
|
|
|
if (!state.monsterConcept || state.monsterConcept.trim() === '') { |
|
throw new Error('Failed to generate monster concept - received empty response'); |
|
} |
|
} catch (error) { |
|
handleAPIError(error); |
|
} |
|
} |
|
|
|
async function generateImagePrompt() { |
|
state.currentStep = 'promptCrafting'; |
|
|
|
if (!rwkvClient || !state.monsterConcept) { |
|
throw new Error('Text generation service not available or no concept'); |
|
} |
|
|
|
const promptGenerationPrompt = IMAGE_GENERATION_PROMPT(state.monsterConcept); |
|
|
|
console.log('Generating image prompt from concept'); |
|
|
|
try { |
|
const output = await rwkvClient.predict(0, [ |
|
promptGenerationPrompt, |
|
300, |
|
0.7, |
|
0.8, |
|
0.1, |
|
0.1 |
|
]); |
|
|
|
console.log('Image prompt output:', output); |
|
state.imagePrompt = output.data[0]; |
|
console.log('Image prompt generated:', state.imagePrompt); |
|
|
|
if (!state.imagePrompt || state.imagePrompt.trim() === '') { |
|
throw new Error('Failed to generate image prompt - received empty response'); |
|
} |
|
} catch (error) { |
|
handleAPIError(error); |
|
} |
|
} |
|
|
|
async function generateMonsterImage() { |
|
state.currentStep = 'generating'; |
|
|
|
if (!fluxClient || !state.imagePrompt) { |
|
throw new Error('Image generation service not available or no prompt'); |
|
} |
|
|
|
try { |
|
const output = await fluxClient.predict("/infer", [ |
|
`${state.imagePrompt}\nNow generate an Anime-style image of the monster in an idle pose with a white background. The monster should not be attacking or in motion. The full monster must be visible within the frame.`, |
|
0, |
|
true, |
|
1024, |
|
1024, |
|
4 |
|
]); |
|
|
|
const [image, usedSeed] = output.data; |
|
let url: string | undefined; |
|
|
|
if (typeof image === "string") url = image; |
|
else if (image && image.url) url = image.url; |
|
else if (image && image.path) url = image.path; |
|
|
|
if (url) { |
|
state.monsterImage = { |
|
imageUrl: url, |
|
seed: usedSeed, |
|
prompt: state.imagePrompt |
|
}; |
|
} else { |
|
throw new Error('Failed to generate monster image'); |
|
} |
|
} catch (error) { |
|
handleAPIError(error); |
|
} |
|
} |
|
|
|
function reset() { |
|
state = { |
|
currentStep: 'upload', |
|
userImage: null, |
|
imageCaption: null, |
|
monsterConcept: null, |
|
imagePrompt: null, |
|
monsterImage: null, |
|
error: null, |
|
isProcessing: false |
|
}; |
|
} |
|
</script> |
|
|
|
<div class="monster-generator"> |
|
|
|
{#if state.currentStep !== 'upload'} |
|
<WorkflowProgress currentStep={state.currentStep} error={state.error} /> |
|
{/if} |
|
|
|
{#if state.currentStep === 'upload'} |
|
<UploadStep |
|
onImageSelected={handleImageSelected} |
|
isProcessing={state.isProcessing} |
|
/> |
|
{:else if state.currentStep === 'complete'} |
|
<MonsterResult workflowState={state} onReset={reset} /> |
|
{:else} |
|
<div class="processing-container"> |
|
<div class="spinner"></div> |
|
<p class="processing-text"> |
|
{#if state.currentStep === 'captioning'} |
|
Analyzing your image... |
|
{:else if state.currentStep === 'conceptualizing'} |
|
Creating monster concept... |
|
{:else if state.currentStep === 'promptCrafting'} |
|
Crafting generation prompt... |
|
{:else if state.currentStep === 'generating'} |
|
Generating your monster... |
|
{/if} |
|
</p> |
|
|
|
{#if state.imageCaption && state.currentStep !== 'captioning'} |
|
<div class="intermediate-result"> |
|
<h4>Image Description:</h4> |
|
<p>{state.imageCaption}</p> |
|
</div> |
|
{/if} |
|
|
|
{#if state.monsterConcept && state.currentStep !== 'captioning' && state.currentStep !== 'conceptualizing'} |
|
<div class="intermediate-result"> |
|
<h4>Monster Concept:</h4> |
|
<p>{state.monsterConcept}</p> |
|
</div> |
|
{/if} |
|
|
|
{#if state.imagePrompt && state.currentStep === 'generating'} |
|
<div class="intermediate-result"> |
|
<h4>Generation Prompt:</h4> |
|
<p>{state.imagePrompt}</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.monster-generator { |
|
width: 100%; |
|
max-width: 1200px; |
|
margin: 0 auto; |
|
padding: 2rem; |
|
} |
|
|
|
|
|
.processing-container { |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
padding: 3rem 1rem; |
|
} |
|
|
|
.spinner { |
|
width: 60px; |
|
height: 60px; |
|
border: 3px solid #f3f3f3; |
|
border-top: 3px solid #007bff; |
|
border-radius: 50%; |
|
animation: spin 1s linear infinite; |
|
margin-bottom: 2rem; |
|
} |
|
|
|
@keyframes spin { |
|
0% { transform: rotate(0deg); } |
|
100% { transform: rotate(360deg); } |
|
} |
|
|
|
.processing-text { |
|
font-size: 1.2rem; |
|
color: #333; |
|
margin-bottom: 2rem; |
|
} |
|
|
|
.intermediate-result { |
|
width: 100%; |
|
max-width: 700px; |
|
background: #f8f9fa; |
|
padding: 1.5rem; |
|
border-radius: 8px; |
|
margin-bottom: 1.5rem; |
|
text-align: left; |
|
} |
|
|
|
.intermediate-result h4 { |
|
margin: 0 0 0.5rem 0; |
|
color: #495057; |
|
} |
|
|
|
.intermediate-result p { |
|
margin: 0; |
|
line-height: 1.6; |
|
color: #333; |
|
} |
|
</style> |