|
|
<script lang="ts"> |
|
|
import type { PicletGeneratorProps, PicletWorkflowState, CaptionType, CaptionLength, PicletStats } from '$lib/types'; |
|
|
import type { PicletInstance, DiscoveryStatus } from '$lib/db/schema'; |
|
|
import UploadStep from './UploadStep.svelte'; |
|
|
import WorkflowProgress from './WorkflowProgress.svelte'; |
|
|
import PicletResult from './PicletResult.svelte'; |
|
|
import { removeBackground } from '$lib/utils/professionalImageProcessing'; |
|
|
import { extractPicletMetadata } from '$lib/services/picletMetadata'; |
|
|
import { savePicletInstance, generatedDataToPicletInstance } from '$lib/db/piclets'; |
|
|
import { PicletType, TYPE_DATA } from '$lib/types/picletTypes'; |
|
|
import { EnhancedCaptionService } from '$lib/services/enhancedCaption'; |
|
|
import { CanonicalService } from '$lib/services/canonicalService'; |
|
|
import { incrementDiscoveryCounter, addRarityScore, calculateRarityPoints } from '$lib/db/gameState'; |
|
|
import { authStore } from '$lib/stores/auth'; |
|
|
{ withQwenTimeout } from '$lib/utils/qwenTimeout'; |
|
|
|
|
|
interface Props extends PicletGeneratorProps {} |
|
|
|
|
|
let { |
|
|
joyCaptionClient, |
|
|
fluxClient, |
|
|
gptOssClient, |
|
|
picletsServerClient |
|
|
}: Props = $props(); |
|
|
|
|
|
|
|
|
const auth = $derived(authStore); |
|
|
|
|
|
|
|
|
const generateText = async (prompt: string): Promise<string> => { |
|
|
if (!gptOssClient) { |
|
|
throw new Error('GPT-OSS-120B client is not available'); |
|
|
} |
|
|
|
|
|
console.log('Generating text with GPT-OSS-120B...'); |
|
|
|
|
|
|
|
|
const result = await gptOssClient.predict("/chat", [ |
|
|
prompt, |
|
|
[], |
|
|
"You are a helpful assistant that creates Pokemon-style monster concepts based on real-world objects.", |
|
|
0.7 |
|
|
]); |
|
|
|
|
|
|
|
|
let responseText = result.data[0] || ''; |
|
|
|
|
|
const responseMatch = responseText.match(/\*\*💬 Response:\*\*\s*\n\n([\s\S]*)/); |
|
|
if (responseMatch) { |
|
|
return responseMatch[1].trim(); |
|
|
} |
|
|
|
|
|
|
|
|
const finalMatch = responseText.match(/assistantfinal\s*([\s\S]*)/); |
|
|
if (finalMatch) { |
|
|
return finalMatch[1].trim(); |
|
|
} |
|
|
|
|
|
return responseText; |
|
|
}; |
|
|
|
|
|
let workflowState: PicletWorkflowState = $state({ |
|
|
currentStep: 'upload', |
|
|
userImage: null, |
|
|
imageCaption: null, |
|
|
picletConcept: null, |
|
|
picletStats: null, |
|
|
imagePrompt: null, |
|
|
picletImage: null, |
|
|
error: null, |
|
|
isProcessing: false, |
|
|
// Discovery-specific state |
|
|
objectName: null, |
|
|
objectAttributes: [], |
|
|
visualDetails: null, |
|
|
discoveryStatus: null, |
|
|
canonicalPiclet: null |
|
|
}); |
|
|
|
|
|
|
|
|
let imageQueue: File[] = $state([]); |
|
|
let currentImageIndex: number = $state(0); |
|
|
|
|
|
const IMAGE_GENERATION_PROMPT = (concept: string) => `Extract ONLY the visual appearance from this monster concept and describe it in one concise sentence: |
|
|
"${concept}" |
|
|
|
|
|
Focus on: colors, body shape, eyes, limbs, mouth, and key visual features. Omit backstory, abilities, and non-visual details.`; |
|
|
|
|
|
|
|
|
async function importPiclet(picletData: PicletInstance) { |
|
|
workflowState.isProcessing = true; |
|
|
workflowState.currentStep = 'complete'; |
|
|
|
|
|
try { |
|
|
// Save the imported piclet |
|
|
const savedId = await savePicletInstance(picletData); |
|
|
|
|
|
// Create a success workflowState similar to generation |
|
|
workflowState.picletImage = { |
|
|
imageUrl: picletData.imageUrl, |
|
|
imageData: picletData.imageData, |
|
|
seed: 0, |
|
|
prompt: 'Imported piclet' |
|
|
}; |
|
|
|
|
|
|
|
|
workflowState.isProcessing = false; |
|
|
alert(`Successfully imported ${picletData.nickname || picletData.typeId}!`); |
|
|
|
|
|
// Reset to allow another import/generation |
|
|
setTimeout(() => reset(), 2000); |
|
|
} catch (error) { |
|
|
workflowState.error = `Failed to import piclet: ${error}`; |
|
|
workflowState.isProcessing = false; |
|
|
} |
|
|
} |
|
|
|
|
|
async function handleImageSelected(file: File) { |
|
|
if (!joyCaptionClient || !fluxClient) { |
|
|
workflowState.error = "Services not connected. Please wait..."; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
imageQueue = []; |
|
|
currentImageIndex = 0; |
|
|
|
|
|
workflowState.userImage = file; |
|
|
workflowState.error = null; |
|
|
|
|
|
|
|
|
const picletData = await extractPicletMetadata(file); |
|
|
if (picletData) { |
|
|
// Import existing piclet |
|
|
await importPiclet(picletData); |
|
|
} else { |
|
|
// Generate new piclet |
|
|
startWorkflow(); |
|
|
} |
|
|
} |
|
|
|
|
|
async function handleImagesSelected(files: File[]) { |
|
|
if (!joyCaptionClient || !fluxClient) { |
|
|
workflowState.error = "Services not connected. Please wait..."; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
imageQueue = files; |
|
|
currentImageIndex = 0; |
|
|
|
|
|
await processCurrentImage(); |
|
|
} |
|
|
|
|
|
async function processCurrentImage() { |
|
|
if (currentImageIndex >= imageQueue.length) { |
|
|
// Queue completed |
|
|
console.log('All images processed!'); |
|
|
return; |
|
|
} |
|
|
|
|
|
const currentFile = imageQueue[currentImageIndex]; |
|
|
workflowState.userImage = currentFile; |
|
|
workflowState.error = null; |
|
|
|
|
|
|
|
|
const picletData = await extractPicletMetadata(currentFile); |
|
|
if (picletData) { |
|
|
// Import existing piclet |
|
|
await importPiclet(picletData); |
|
|
// Auto-advance to next image after import |
|
|
await advanceToNextImage(); |
|
|
} else { |
|
|
// Generate new piclet |
|
|
startWorkflow(); |
|
|
} |
|
|
} |
|
|
|
|
|
async function advanceToNextImage() { |
|
|
currentImageIndex++; |
|
|
|
|
|
if (currentImageIndex < imageQueue.length) { |
|
|
// Process next image |
|
|
setTimeout(() => processCurrentImage(), 1000); // Small delay for better UX |
|
|
} else { |
|
|
// Queue completed - reset to single image mode |
|
|
imageQueue = []; |
|
|
currentImageIndex = 0; |
|
|
reset(); |
|
|
} |
|
|
} |
|
|
|
|
|
async function startWorkflow() { |
|
|
workflowState.isProcessing = true; |
|
|
|
|
|
try { |
|
|
// Step 1: Generate detailed object description with joy-caption (skip server lookup for now) |
|
|
await captionImage(); |
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for workflowState update |
|
|
|
|
|
// Step 2: Generate free-form monster concept with Qwen |
|
|
await generateConcept(); |
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for workflowState update |
|
|
|
|
|
// Step 3: Extract stats including physical characteristics |
|
|
await extractSimpleStats(); |
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for workflowState update |
|
|
|
|
|
// Step 4: Generate image prompt with Qwen |
|
|
await generateImagePrompt(); |
|
|
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for workflowState update |
|
|
|
|
|
// Step 5: Generate monster image with anime style |
|
|
await generateMonsterImage(); |
|
|
|
|
|
// Step 6: Auto-save the piclet as caught (since scanning now auto-captures) |
|
|
await autoSavePicletAsCaught(); |
|
|
|
|
|
workflowState.currentStep = 'complete'; |
|
|
|
|
|
// If processing a queue, auto-advance to next image after a short delay |
|
|
if (imageQueue.length > 1) { |
|
|
setTimeout(() => advanceToNextImage(), 2000); // 2 second delay to show completion |
|
|
} |
|
|
} catch (err) { |
|
|
console.error('Workflow error:', err); |
|
|
|
|
|
// Check for GPU quota error |
|
|
if (err && typeof err === 'object' && 'message' in err) { |
|
|
const errorMessage = String(err.message); |
|
|
if (errorMessage.includes('exceeded your GPU quota') || errorMessage.includes('GPU quota')) { |
|
|
workflowState.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 { |
|
|
workflowState.error = errorMessage; |
|
|
} |
|
|
} else if (err instanceof Error) { |
|
|
workflowState.error = err.message; |
|
|
} else { |
|
|
workflowState.error = 'An unknown error occurred'; |
|
|
} |
|
|
} finally { |
|
|
workflowState.isProcessing = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleAPIError(error: any): never { |
|
|
console.error('API Error:', error); |
|
|
|
|
|
// Check if it's a GPU quota 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() { |
|
|
workflowState.currentStep = 'captioning'; |
|
|
|
|
|
if (!joyCaptionClient || !workflowState.userImage) { |
|
|
throw new Error('Caption service not available or no image provided'); |
|
|
} |
|
|
|
|
|
try { |
|
|
// Get detailed scene description from Joy Caption |
|
|
const captionResult = await EnhancedCaptionService.generateEnhancedCaption( |
|
|
joyCaptionClient, |
|
|
workflowState.userImage |
|
|
); |
|
|
|
|
|
workflowState.imageCaption = captionResult.caption; |
|
|
console.log('Scene description:', captionResult.caption); |
|
|
|
|
|
// Skip server lookup for now - always create new piclet |
|
|
workflowState.discoveryStatus = 'new'; |
|
|
} catch (error) { |
|
|
handleAPIError(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function generateConcept() { |
|
|
workflowState.currentStep = 'conceptualizing'; |
|
|
|
|
|
// Skip if we have an existing canonical Piclet |
|
|
if (workflowState.discoveryStatus === 'existing' && workflowState.canonicalPiclet) { |
|
|
workflowState.picletConcept = workflowState.canonicalPiclet.concept; |
|
|
console.log('Using existing canonical concept'); |
|
|
return; |
|
|
} |
|
|
|
|
|
if (!gptOssClient || !workflowState.imageCaption) { |
|
|
throw new Error('Cannot generate concept without scene description'); |
|
|
} |
|
|
|
|
|
const conceptPrompt = `You are analyzing an image to create a Pokemon-style creature. Here's the image description: |
|
|
|
|
|
"${workflowState.imageCaption}" |
|
|
|
|
|
Your task: |
|
|
1. Identify the PRIMARY PHYSICAL OBJECT with SPECIFICITY (e.g., "macbook" not "laptop", "eiffel tower" not "tower", "iphone" not "phone", "starbucks mug" not "mug") |
|
|
2. Determine if there's a meaningful VARIATION (e.g., "silver", "pro", "night", "gaming", "vintage") |
|
|
3. Assess rarity based on uniqueness |
|
|
4. Create a complete Pokemon-style monster concept |
|
|
|
|
|
Format your response EXACTLY as follows: |
|
|
\`\`\`md |
|
|
# Canonical Object |
|
|
{Specific object name: "macbook", "eiffel tower", "iphone", "tesla", "le creuset mug", "nintendo switch"} |
|
|
{NOT generic terms like: "laptop", "tower", "phone", "car", "mug", "console"} |
|
|
{Include brand/model/landmark name when identifiable} |
|
|
|
|
|
# Variation |
|
|
{OPTIONAL: one distinctive attribute like "silver", "pro", "night", "gaming", OR use "canonical" if this is the standard/default version with no special variation} |
|
|
|
|
|
# Object Rarity |
|
|
{common, uncommon, rare, epic, or legendary based on object uniqueness} |
|
|
|
|
|
# Monster Name |
|
|
{Creative 8-11 letter name based on the SPECIFIC object, e.g., "Macbyte" for MacBook, "Towerfell" for Eiffel Tower} |
|
|
|
|
|
# Primary Type |
|
|
{beast, bug, aquatic, flora, mineral, space, machina, structure, culture, or cuisine} |
|
|
|
|
|
# Physical Stats |
|
|
Height: {e.g., "1.2m" or "3'5\""} |
|
|
Weight: {e.g., "15kg" or "33 lbs"} |
|
|
|
|
|
# Personality |
|
|
{1-2 sentences describing personality traits} |
|
|
|
|
|
# Monster Description |
|
|
{2-3 paragraphs describing how the SPECIFIC object's features translate into monster features. Reference the actual object by name. This is the creature's bio.} |
|
|
|
|
|
# Monster Image Prompt |
|
|
{Concise visual description for anime-style image generation focusing on colors, shapes, and key features inspired by the specific object} |
|
|
\`\`\` |
|
|
|
|
|
CRITICAL RULES: |
|
|
- Canonical Object MUST be SPECIFIC: "macbook" not "laptop", "big ben" not "clock tower", "coca cola" not "soda" |
|
|
- If you can identify a brand, model, or proper name from the description, USE IT |
|
|
- Variation should be meaningful and distinctive (material, style, color, context, or model variant) |
|
|
- Monster Description must describe the CREATURE with references to the specific object's features |
|
|
- Primary Type must match the object category (machina for electronics, structure for buildings, etc.)`; |
|
|
|
|
|
try { |
|
|
const responseText = await generateText(conceptPrompt); |
|
|
|
|
|
// Validate response has expected structure |
|
|
if (!responseText.includes('# Canonical Object') || |
|
|
!responseText.includes('# Monster Name')) { |
|
|
console.error('GPT-OSS returned invalid response:', responseText); |
|
|
throw new Error('Failed to generate valid monster concept'); |
|
|
} |
|
|
|
|
|
workflowState.picletConcept = responseText; |
|
|
|
|
|
|
|
|
|
|
|
const canonicalMatch = responseText.match(/\*{0,2}#\s*Canonical Object\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#)/m); |
|
|
const variationMatch = responseText.match(/\*{0,2}#\s*Variation\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#)/m); |
|
|
|
|
|
// Clean up extracted values (remove curly braces and quotes that GPT-OSS sometimes adds) |
|
|
let objectName = canonicalMatch ? canonicalMatch[1].trim().toLowerCase() : 'unknown'; |
|
|
objectName = objectName.replace(/^[{"]|["}]$/g, '').replace(/^.*:\s*["']|["']$/g, '').trim(); |
|
|
|
|
|
let variationText = variationMatch ? variationMatch[1].trim() : ''; |
|
|
variationText = variationText.replace(/^[{"]|["}]$/g, '').replace(/^.*:\s*["']|["']$/g, '').trim(); |
|
|
|
|
|
workflowState.objectName = objectName; |
|
|
workflowState.objectAttributes = variationText && variationText !== 'NONE' && variationText !== 'canonical' ? [variationText.toLowerCase()] : []; |
|
|
|
|
|
console.log('Parsed specific object:', workflowState.objectName); |
|
|
console.log('Parsed variation:', workflowState.objectAttributes); |
|
|
|
|
|
if (!responseText || responseText.trim() === '') { |
|
|
throw new Error('Failed to generate monster concept'); |
|
|
} |
|
|
|
|
|
|
|
|
let cleanedResponse = responseText.trim(); |
|
|
|
|
|
|
|
|
if (cleanedResponse.includes('```')) { |
|
|
// Handle different code block formats: ```md, ```, ```markdown |
|
|
const codeBlockRegex = /```(?:md|markdown)?\s*\n([\s\S]*?)```/; |
|
|
const match = cleanedResponse.match(codeBlockRegex); |
|
|
|
|
|
if (match && match[1]) { |
|
|
cleanedResponse = match[1].trim(); |
|
|
console.log('Extracted content from markdown code block'); |
|
|
} else { |
|
|
// Fallback: try to extract content between any ``` blocks |
|
|
const simpleMatch = cleanedResponse.match(/```([\s\S]*?)```/); |
|
|
if (simpleMatch && simpleMatch[1]) { |
|
|
cleanedResponse = simpleMatch[1].trim(); |
|
|
console.log('Extracted content from generic code block'); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (!cleanedResponse.includes('# Object Rarity') || !cleanedResponse.includes('# Monster Name') || !cleanedResponse.includes('# Monster Image Prompt')) { |
|
|
console.warn('Response does not contain expected markdown structure (missing Object Rarity, Monster Name, or Monster Image Prompt)'); |
|
|
} |
|
|
|
|
|
workflowState.picletConcept = cleanedResponse; |
|
|
console.log('Monster concept generated:', cleanedResponse); |
|
|
} catch (error) { |
|
|
handleAPIError(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function generateImagePrompt() { |
|
|
workflowState.currentStep = 'promptCrafting'; |
|
|
|
|
|
if (!gptOssClient || !workflowState.picletConcept || !workflowState.imageCaption) { |
|
|
throw new Error('Text generation service not available or no concept/caption available for prompt generation'); |
|
|
} |
|
|
|
|
|
|
|
|
const imagePromptMatch = workflowState.picletConcept.match(/\*{0,2}#\s*Monster Image Prompt\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#|$)/m); |
|
|
|
|
|
if (imagePromptMatch && imagePromptMatch[1]) { |
|
|
workflowState.imagePrompt = imagePromptMatch[1].trim(); |
|
|
console.log('Extracted image prompt for generation:', workflowState.imagePrompt); |
|
|
return; // Skip fallback call since we have the prompt |
|
|
} |
|
|
|
|
|
|
|
|
const imagePromptPrompt = `Based on this monster concept, extract ONLY the visual description for image generation: |
|
|
|
|
|
MONSTER CONCEPT: |
|
|
""" |
|
|
${workflowState.picletConcept} |
|
|
""" |
|
|
|
|
|
Create a concise visual description (1-3 sentences, max 100 words). Focus only on colors, shapes, materials, eyes, limbs, mouth, and distinctive features. Omit all non-visual information like abilities and backstory.`; |
|
|
|
|
|
try { |
|
|
const responseText = await generateText(imagePromptPrompt); |
|
|
|
|
|
if (!responseText || responseText.trim() === '') { |
|
|
throw new Error('Failed to generate image prompt'); |
|
|
} |
|
|
|
|
|
workflowState.imagePrompt = responseText.trim(); |
|
|
console.log('Image prompt generated:', workflowState.imagePrompt); |
|
|
} catch (error) { |
|
|
handleAPIError(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function generateMonsterImage() { |
|
|
workflowState.currentStep = 'generating'; |
|
|
|
|
|
if (!fluxClient || !workflowState.imagePrompt || !workflowState.picletStats) { |
|
|
throw new Error('Image generation service not available or no prompt/stats'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const tier = workflowState.picletStats.tier || 'medium'; |
|
|
const tierDescriptions = { |
|
|
low: 'simple and iconic design', |
|
|
medium: 'detailed and well-crafted design', |
|
|
high: 'highly detailed and impressive design with special effects', |
|
|
legendary: 'highly detailed and majestic design with dramatic lighting and aura effects' |
|
|
}; |
|
|
|
|
|
try { |
|
|
const output = await fluxClient.predict("/infer", [ |
|
|
`${workflowState.imagePrompt}\nNow generate an Pokémon Anime image of the monster in an idle pose with a plain dark-grey background. This is a ${tier} tier monster with a ${tierDescriptions[tier as keyof typeof tierDescriptions]}. The monster should not be attacking or in motion. The full monster must be visible within the frame.`, |
|
|
0, // seed |
|
|
true, // randomizeSeed |
|
|
1024, // width |
|
|
1024, // height |
|
|
4 // steps |
|
|
]); |
|
|
|
|
|
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) { |
|
|
// Process the image to remove background using professional AI method |
|
|
console.log('Processing image for background removal...'); |
|
|
try { |
|
|
const transparentBase64 = await removeBackground(url); |
|
|
workflowState.picletImage = { |
|
|
imageUrl: url, |
|
|
imageData: transparentBase64, |
|
|
seed: usedSeed, |
|
|
prompt: workflowState.imagePrompt |
|
|
}; |
|
|
console.log('Background removal completed successfully'); |
|
|
} catch (processError) { |
|
|
console.error('Failed to process image for background removal:', processError); |
|
|
// Fallback to original image |
|
|
workflowState.picletImage = { |
|
|
imageUrl: url, |
|
|
seed: usedSeed, |
|
|
prompt: workflowState.imagePrompt |
|
|
}; |
|
|
} |
|
|
} else { |
|
|
throw new Error('Failed to generate monster image'); |
|
|
} |
|
|
} catch (error) { |
|
|
handleAPIError(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function extractSimpleStats() { |
|
|
workflowState.currentStep = 'statsGenerating'; |
|
|
|
|
|
if (!workflowState.picletConcept) { |
|
|
throw new Error('No concept available for stats extraction'); |
|
|
} |
|
|
|
|
|
try { |
|
|
// Extract monster name (handle both plain and bold markdown headers) |
|
|
const monsterNameMatch = workflowState.picletConcept.match(/\*{0,2}#\s*Monster Name\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#|$)/m); |
|
|
let monsterName = monsterNameMatch ? monsterNameMatch[1].trim() : 'Unknown Monster'; |
|
|
|
|
|
// Clean and truncate name |
|
|
monsterName = monsterName.replace(/^[{"]|["}]$/g, '').trim(); |
|
|
if (monsterName.includes(',')) { |
|
|
monsterName = monsterName.split(',')[0].trim(); |
|
|
} |
|
|
if (monsterName.length > 12) { |
|
|
monsterName = monsterName.substring(0, 12); |
|
|
} |
|
|
monsterName = monsterName.replace(/\*/g, ''); |
|
|
|
|
|
|
|
|
const rarityMatch = workflowState.picletConcept.match(/\*{0,2}#\s*Object Rarity\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#)/m); |
|
|
const objectRarity = rarityMatch ? rarityMatch[1].trim().toLowerCase() : 'common'; |
|
|
|
|
|
let tier: 'low' | 'medium' | 'high' | 'legendary' = 'medium'; |
|
|
if (objectRarity.includes('common')) tier = 'low'; |
|
|
else if (objectRarity.includes('uncommon')) tier = 'medium'; |
|
|
else if (objectRarity.includes('rare')) tier = 'high'; |
|
|
else if (objectRarity.includes('legendary') || objectRarity.includes('mythical')) tier = 'legendary'; |
|
|
|
|
|
// Extract primary type (handle both plain and bold markdown headers) |
|
|
const primaryTypeMatch = workflowState.picletConcept.match(/\*{0,2}#\s*Primary Type\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#|$)/m); |
|
|
let primaryType: any = primaryTypeMatch ? primaryTypeMatch[1].trim().toLowerCase() : 'beast'; |
|
|
primaryType = primaryType.replace(/^[{"]|["}]$/g, '').trim(); |
|
|
|
|
|
|
|
|
const descriptionMatch = workflowState.picletConcept.match(/\*{0,2}#\s*Monster Description\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#|$)/m); |
|
|
if (!descriptionMatch) { |
|
|
console.error('Monster description not found in concept:', workflowState.picletConcept); |
|
|
throw new Error('Failed to extract monster description from AI response'); |
|
|
} |
|
|
let description = descriptionMatch[1].trim(); |
|
|
|
|
|
|
|
|
const physicalStatsMatch = workflowState.picletConcept.match(/\*{0,2}#\s*Physical Stats\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#|$)/m); |
|
|
let height: string | undefined; |
|
|
let weight: string | undefined; |
|
|
|
|
|
if (physicalStatsMatch) { |
|
|
const physicalStatsText = physicalStatsMatch[1]; |
|
|
const heightMatch = physicalStatsText.match(/Height:\s*(.+)/i); |
|
|
const weightMatch = physicalStatsText.match(/Weight:\s*(.+)/i); |
|
|
|
|
|
height = heightMatch ? heightMatch[1].trim().replace(/^[{"]|["}]$/g, '').trim() : undefined; |
|
|
weight = weightMatch ? weightMatch[1].trim().replace(/^[{"]|["}]$/g, '').trim() : undefined; |
|
|
} |
|
|
|
|
|
|
|
|
const personalityMatch = workflowState.picletConcept.match(/\*{0,2}#\s*Personality\s*\*{0,2}\s*\n([\s\S]*?)(?=^\*{0,2}#|$)/m); |
|
|
let personality = personalityMatch ? personalityMatch[1].trim().replace(/^[{"]|["}]$/g, '').trim() : undefined; |
|
|
|
|
|
|
|
|
const stats: PicletStats = { |
|
|
name: monsterName, |
|
|
description: description, |
|
|
tier: tier, |
|
|
primaryType: primaryType, |
|
|
height, |
|
|
weight, |
|
|
personality |
|
|
}; |
|
|
|
|
|
workflowState.picletStats = stats; |
|
|
console.log('Stats extracted:', stats); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Failed to extract stats:', error); |
|
|
handleAPIError(error); |
|
|
} |
|
|
} |
|
|
|
|
|
async function autoSavePicletAsCaught() { |
|
|
if (!workflowState.picletImage || !workflowState.imageCaption || !workflowState.picletConcept || !workflowState.imagePrompt || !workflowState.picletStats) { |
|
|
console.error('Cannot auto-save: missing required data'); |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
// Create a clean copy of stats to ensure it's serializable |
|
|
const cleanStats = JSON.parse(JSON.stringify(workflowState.picletStats)); |
|
|
|
|
|
const picletData = { |
|
|
name: workflowState.picletStats.name, |
|
|
imageUrl: workflowState.picletImage.imageUrl, |
|
|
imageData: workflowState.picletImage.imageData, |
|
|
imageCaption: workflowState.imageCaption, |
|
|
concept: workflowState.picletConcept, |
|
|
imagePrompt: workflowState.imagePrompt, |
|
|
stats: cleanStats, |
|
|
createdAt: new Date() |
|
|
}; |
|
|
|
|
|
|
|
|
console.log('Checking piclet data for serializability:'); |
|
|
console.log('- name type:', typeof picletData.name); |
|
|
console.log('- imageUrl type:', typeof picletData.imageUrl); |
|
|
console.log('- imageData type:', typeof picletData.imageData, picletData.imageData ? `length: ${picletData.imageData.length}` : 'null/undefined'); |
|
|
console.log('- imageCaption type:', typeof picletData.imageCaption); |
|
|
console.log('- concept type:', typeof picletData.concept); |
|
|
console.log('- imagePrompt type:', typeof picletData.imagePrompt); |
|
|
console.log('- stats:', cleanStats); |
|
|
|
|
|
// Convert to PicletInstance format and save as caught |
|
|
// Convert reactive Svelte state to plain values (removes Proxy wrapper for IndexedDB compatibility) |
|
|
const plainAttributes = workflowState.objectAttributes ? [...workflowState.objectAttributes] : []; |
|
|
|
|
|
const picletInstance = await generatedDataToPicletInstance( |
|
|
picletData, |
|
|
workflowState.objectName || undefined, |
|
|
plainAttributes, |
|
|
workflowState.visualDetails || undefined, |
|
|
$auth.userInfo // Pass user info for discoverer attribution |
|
|
); |
|
|
|
|
|
// Sync with server if available and user is authenticated |
|
|
if (picletsServerClient && $auth.session?.accessToken && workflowState.objectName) { |
|
|
try { |
|
|
console.log('Syncing piclet to server...', workflowState.objectName); |
|
|
|
|
|
// Search for existing canonical on server |
|
|
const searchResult = await CanonicalService.searchCanonical( |
|
|
picletsServerClient, |
|
|
workflowState.objectName, |
|
|
plainAttributes |
|
|
); |
|
|
|
|
|
console.log('Server search result:', searchResult?.status); |
|
|
|
|
|
if (searchResult?.status === 'new') { |
|
|
// Create new canonical on server |
|
|
console.log('Creating canonical piclet on server...'); |
|
|
const serverResult = await CanonicalService.createCanonical( |
|
|
picletsServerClient, |
|
|
workflowState.objectName, |
|
|
picletInstance, |
|
|
$auth.session.accessToken |
|
|
); |
|
|
console.log('Server canonical creation result:', serverResult); |
|
|
} else if (searchResult?.status === 'new_variation' && searchResult.canonicalId) { |
|
|
// Create variation on server |
|
|
console.log('Creating variation on server...'); |
|
|
const serverResult = await CanonicalService.createVariation( |
|
|
picletsServerClient, |
|
|
searchResult.canonicalId, |
|
|
workflowState.objectName, |
|
|
plainAttributes, |
|
|
picletInstance, |
|
|
$auth.session.accessToken |
|
|
); |
|
|
console.log('Server variation creation result:', serverResult); |
|
|
} else if (searchResult?.status === 'existing' || searchResult?.status === 'variation') { |
|
|
// Increment scan count for existing piclet |
|
|
const picletId = searchResult.piclet?.typeId || searchResult.canonicalId; |
|
|
if (picletId) { |
|
|
console.log('Incrementing scan count on server...'); |
|
|
await CanonicalService.incrementScanCount( |
|
|
picletsServerClient, |
|
|
picletId, |
|
|
workflowState.objectName |
|
|
); |
|
|
} |
|
|
} |
|
|
} catch (serverError) { |
|
|
console.error('Server sync failed (continuing with local save):', serverError); |
|
|
// Don't throw - continue with local save even if server fails |
|
|
} |
|
|
} |
|
|
|
|
|
const picletId = await savePicletInstance(picletInstance); |
|
|
console.log('Piclet auto-saved as caught with ID:', picletId); |
|
|
|
|
|
|
|
|
await incrementDiscoveryCounter('totalDiscoveries'); |
|
|
|
|
|
|
|
|
if (picletInstance.isCanonical) { |
|
|
await incrementDiscoveryCounter('uniqueDiscoveries'); |
|
|
} else { |
|
|
await incrementDiscoveryCounter('variationsFound'); |
|
|
} |
|
|
|
|
|
|
|
|
const rarityPoints = calculateRarityPoints(picletInstance.scanCount); |
|
|
await addRarityScore(rarityPoints); |
|
|
|
|
|
console.log('Game state updated: +1 discovery, +' + rarityPoints + ' rarity points'); |
|
|
} catch (err) { |
|
|
console.error('Failed to auto-save piclet:', err); |
|
|
console.error('Piclet data that failed to save:', { |
|
|
name: workflowState.picletStats?.name, |
|
|
hasImageUrl: !!workflowState.picletImage?.imageUrl, |
|
|
hasImageData: !!workflowState.picletImage?.imageData, |
|
|
hasStats: !!workflowState.picletStats |
|
|
}); |
|
|
|
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
function reset() { |
|
|
workflowState = { |
|
|
currentStep: 'upload', |
|
|
userImage: null, |
|
|
imageCaption: null, |
|
|
picletConcept: null, |
|
|
picletStats: null, |
|
|
imagePrompt: null, |
|
|
picletImage: null, |
|
|
error: null, |
|
|
isProcessing: false, |
|
|
objectName: null, |
|
|
objectAttributes: [], |
|
|
visualDetails: null, |
|
|
discoveryStatus: null, |
|
|
canonicalPiclet: null |
|
|
}; |
|
|
} |
|
|
</script> |
|
|
|
|
|
<div class="piclet-generator"> |
|
|
|
|
|
|
|
|
{#if workflowState.currentStep !== 'upload'} |
|
|
<WorkflowProgress currentStep={workflowState.currentStep} error={workflowState.error} /> |
|
|
{/if} |
|
|
|
|
|
{#if workflowState.currentStep === 'upload'} |
|
|
<UploadStep |
|
|
onImageSelected={handleImageSelected} |
|
|
onImagesSelected={handleImagesSelected} |
|
|
isProcessing={workflowState.isProcessing} |
|
|
imageQueue={imageQueue} |
|
|
currentImageIndex={currentImageIndex} |
|
|
/> |
|
|
{:else if workflowState.currentStep === 'complete'} |
|
|
<PicletResult workflowState={workflowState} onReset={reset} /> |
|
|
{:else} |
|
|
<div class="processing-container"> |
|
|
<div class="spinner"></div> |
|
|
<p class="processing-text"> |
|
|
{#if workflowState.currentStep === 'captioning'} |
|
|
Analyzing your image... |
|
|
{:else if workflowState.currentStep === 'conceptualizing'} |
|
|
Creating Piclet concept... |
|
|
{:else if workflowState.currentStep === 'statsGenerating'} |
|
|
Generating piclet characteristics... |
|
|
{:else if workflowState.currentStep === 'promptCrafting'} |
|
|
Creating image prompt... |
|
|
{:else if workflowState.currentStep === 'generating'} |
|
|
Generating your Piclet... |
|
|
{/if} |
|
|
</p> |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<style> |
|
|
.piclet-generator { |
|
|
width: 100%; |
|
|
max-width: 1200px; |
|
|
margin: 0 auto; |
|
|
padding: 2rem; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
{ |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
margin-bottom: 1rem; |
|
|
padding: 0.75rem; |
|
|
background: #f8f9fa; |
|
|
border-radius: 8px; |
|
|
border: 1px solid #dee2e6; |
|
|
} |
|
|
|
|
|
.client-selector label { |
|
|
font-weight: 500; |
|
|
color: #495057; |
|
|
} |
|
|
|
|
|
.client-selector select { |
|
|
padding: 0.25rem 0.5rem; |
|
|
border: 1px solid #ced4da; |
|
|
border-radius: 4px; |
|
|
background: white; |
|
|
color: #495057; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
*/ |
|
|
|
|
|
|
|
|
.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; |
|
|
} |
|
|
|
|
|
</style> |