piclets / src /lib /components /PicletGenerator /PicletGenerator.svelte
Fraser's picture
fingers crossed
d009748
<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';
// import { withQwenTimeout } from '$lib/utils/qwenTimeout'; // Unused since qwen is disabled
interface Props extends PicletGeneratorProps {}
let {
joyCaptionClient,
fluxClient,
gptOssClient,
picletsServerClient
}: Props = $props();
// Get current user info for discoverer attribution
const auth = $derived(authStore);
// GPT-OSS-120B text generation
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...');
// ChatInterface expects: message, history, system_prompt, temperature
const result = await gptOssClient.predict("/chat", [
prompt, // message
[], // history
"You are a helpful assistant that creates Pokemon-style monster concepts based on real-world objects.", // system_prompt
0.7 // temperature
]);
// Extract Response section only (GPT-OSS formats with Analysis and Response)
let responseText = result.data[0] || '';
const responseMatch = responseText.match(/\*\*💬 Response:\*\*\s*\n\n([\s\S]*)/);
if (responseMatch) {
return responseMatch[1].trim();
}
// Fallback: extract after "assistantfinal"
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
});
// Queue state for multi-image processing
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'
};
// Show import success
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;
}
// Single image upload - clear queue and process normally
imageQueue = [];
currentImageIndex = 0;
workflowState.userImage = file;
workflowState.error = null;
// Check if this is a piclet card with metadata
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;
}
// Multi-image upload - set up queue and start with first image
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;
// Check if this is a piclet card with metadata
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);
}
// Check if error has a different structure (like the status object from the logs)
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;
// Extract and store canonical name and variation immediately for use in other steps
// Handle both plain and bold markdown headers
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');
}
// Parse markdown code block response
let cleanedResponse = responseText.trim();
// Check if response is wrapped in markdown code blocks
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');
}
}
}
// Ensure the response contains expected markdown headers
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');
}
// Extract the Monster Image Prompt from the structured concept (handle both plain and bold markdown headers)
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
}
// Fallback: if format parsing fails, use Qwen to extract visual description
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');
}
// The image prompt should already be generated by generateImagePrompt() in the workflow
// Get tier for image quality enhancement
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(); // Remove curly braces and quotes
if (monsterName.includes(',')) {
monsterName = monsterName.split(',')[0].trim();
}
if (monsterName.length > 12) {
monsterName = monsterName.substring(0, 12);
}
monsterName = monsterName.replace(/\*/g, ''); // Remove markdown asterisks
// Extract rarity and convert to tier (handle both plain and bold markdown headers)
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(); // Remove curly braces and quotes
// Extract description (handle both plain and bold markdown headers)
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();
// Extract physical stats (handle both plain and bold markdown headers)
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;
}
// Extract personality (handle both plain and bold markdown headers)
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;
// Create stats with physical characteristics
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()
};
// Check for any non-serializable data
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);
// Update game state statistics
await incrementDiscoveryCounter('totalDiscoveries');
// Update canonical vs variation counters
if (picletInstance.isCanonical) {
await incrementDiscoveryCounter('uniqueDiscoveries');
} else {
await incrementDiscoveryCounter('variationsFound');
}
// Calculate and add rarity score
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
});
// Don't throw - we don't want to interrupt the workflow
}
}
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;
}
/* Client selector styles (hidden since only HunyuanTurbos is active) */
/*
.client-selector {
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>