From cc024a0aea2c0b900748991f1b2925fbc33c33a4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:10:59 +0000 Subject: [PATCH] feat: add character creation and fitting room ui - Implemented `Character Creation` flow with dialog and image upload. - Implemented `Fitting Room` page with split view (Character Preview / Generations). - Implemented `Create Outfit` dialog with equipment filtering and selection. - Added `POST /api/generations` to handle outfit generation. - Updated `AIService` to support complex prompt structure with interleaved text and images. - Updated `CharacterService` to support fetching single character. - Added frontend routes and components. - Added relevant tests. --- src/api/character.test.ts | 14 ++ src/api/character.ts | 9 + src/api/generation.test.ts | 16 ++ src/api/generation.ts | 32 ++- src/service/ai.ts | 9 + src/service/asset.ts | 20 ++ src/service/character.ts | 9 + src/service/generation.ts | 131 ++++++++++ src/ui/App.tsx | 2 + src/ui/components/CreateCharacterDialog.tsx | 176 ++++++++++++++ src/ui/components/CreateOutfitDialog.tsx | 255 ++++++++++++++++++++ src/ui/components/ui/textarea.tsx | 24 ++ src/ui/pages/Characters.tsx | 29 ++- src/ui/pages/FittingRoom.tsx | 182 ++++++++++++++ 14 files changed, 896 insertions(+), 12 deletions(-) create mode 100644 src/ui/components/CreateCharacterDialog.tsx create mode 100644 src/ui/components/CreateOutfitDialog.tsx create mode 100644 src/ui/components/ui/textarea.tsx create mode 100644 src/ui/pages/FittingRoom.tsx diff --git a/src/api/character.test.ts b/src/api/character.test.ts index 9117534..74154f7 100644 --- a/src/api/character.test.ts +++ b/src/api/character.test.ts @@ -6,6 +6,7 @@ const mockCharacterService = { listCharacters: mock(), updateCharacter: mock(), deleteCharacter: mock(), + getCharacter: mock(), } mock.module('../service/character', () => ({ @@ -45,4 +46,17 @@ describe('Character API', () => { expect(res.status).toBe(201) expect(await res.json()).toEqual({ id: '1', name: 'Char' }) }) + + it('GET /:id should get character', async () => { + mockCharacterService.getCharacter.mockResolvedValue({ id: '1', name: 'Char' }) + + const req = new Request('http://localhost/1', { + method: 'GET', + }) + + const res = await character.fetch(req) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ id: '1', name: 'Char' }) + expect(mockCharacterService.getCharacter).toHaveBeenCalledWith('1') + }) }) diff --git a/src/api/character.ts b/src/api/character.ts index 725237d..2c7be2f 100644 --- a/src/api/character.ts +++ b/src/api/character.ts @@ -33,6 +33,15 @@ const route = app const result = await characterService.createCharacter({ name, description, imageId }) return c.json(result, 201) }) + .get('/:id', async (c) => { + const id = c.req.param('id') + const character = await characterService.getCharacter(id) + if (!character) { + // @ts-ignore + return c.json({ error: 'Character not found' }, 404) + } + return c.json(character) + }) .put('/:id', zValidator('json', updateSchema), async (c) => { const id = c.req.param('id') const body = c.req.valid('json') diff --git a/src/api/generation.test.ts b/src/api/generation.test.ts index 4220561..24ed3f1 100644 --- a/src/api/generation.test.ts +++ b/src/api/generation.test.ts @@ -3,6 +3,7 @@ import generation from './generation' const mockGenerationService = { listGenerations: mock(), + createGeneration: mock(), } mock.module('../service/generation', () => ({ @@ -28,4 +29,19 @@ describe('Generation API', () => { { characterId: undefined, equipmentId: 'eq1' }, ) }) + + it('POST / should create generation', async () => { + mockGenerationService.createGeneration.mockResolvedValue({ id: '1' }) + + const req = new Request('http://localhost/', { + method: 'POST', + body: JSON.stringify({ characterId: 'char1', equipmentIds: ['eq1', 'eq2'] }), + headers: { 'Content-Type': 'application/json' }, + }) + + const res = await generation.fetch(req) + expect(res.status).toBe(201) + expect(await res.json()).toEqual({ id: '1' }) + expect(mockGenerationService.createGeneration).toHaveBeenCalledWith('char1', ['eq1', 'eq2']) + }) }) diff --git a/src/api/generation.ts b/src/api/generation.ts index 84fdc1c..a2a5cd3 100644 --- a/src/api/generation.ts +++ b/src/api/generation.ts @@ -12,14 +12,30 @@ const listSchema = z.object({ equipmentId: z.string().optional(), }) -const route = app.get('/', zValidator('query', listSchema), async (c) => { - const { page, limit, characterId, equipmentId } = c.req.valid('query') - - const result = await generationService.listGenerations( - { page, limit }, - { characterId, equipmentId }, - ) - return c.json(result) +const createSchema = z.object({ + characterId: z.string().min(1), + equipmentIds: z.array(z.string()), }) +const route = app + .get('/', zValidator('query', listSchema), async (c) => { + const { page, limit, characterId, equipmentId } = c.req.valid('query') + + const result = await generationService.listGenerations( + { page, limit }, + { characterId, equipmentId }, + ) + return c.json(result) + }) + .post('/', zValidator('json', createSchema), async (c) => { + const { characterId, equipmentIds } = c.req.valid('json') + try { + const result = await generationService.createGeneration(characterId, equipmentIds) + return c.json(result, 201) + } catch (e) { + console.error('Failed to create generation', e) + return c.json({ error: e instanceof Error ? e.message : 'Unknown error' }, 500) + } + }) + export default route diff --git a/src/service/ai.ts b/src/service/ai.ts index 84a2c85..fb2c3d0 100644 --- a/src/service/ai.ts +++ b/src/service/ai.ts @@ -5,6 +5,9 @@ import { z } from 'zod' export interface AIService { generateText(prompt: string, images: File[], schema?: z.ZodType): Promise generateImage(prompt: string, referenceImages?: File[]): Promise + generateImageFromParts( + parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>, + ): Promise } export class GeminiService implements AIService { @@ -87,6 +90,12 @@ export class GeminiService implements AIService { parts.push({ text: prompt }) + return this.generateImageFromParts(parts) + } + + async generateImageFromParts( + parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>, + ): Promise { const result = await this.genAI.models.generateContent({ model: 'gemini-2.5-flash-image', contents: { parts }, diff --git a/src/service/asset.ts b/src/service/asset.ts index 6702021..6c18342 100644 --- a/src/service/asset.ts +++ b/src/service/asset.ts @@ -25,6 +25,26 @@ export class AssetService { return asset } + async createAssetFromBuffer(buffer: Buffer | ArrayBuffer, meta: { name: string; type: string; ext?: string }) { + const id = ulid() + const ext = meta.ext || '.bin' + const filename = `${id}${ext}` + const path = join('data/files', filename) + + await Bun.write(path, buffer) + + const asset = await prisma.asset.create({ + data: { + id, + name: meta.name, + type: meta.type, + path: filename, + }, + }) + + return asset + } + // New method to handle already saved files async createAssetRecord(params: { path: string; name: string; type: string }) { const id = ulid() diff --git a/src/service/character.ts b/src/service/character.ts index 7c97bd2..669e7ac 100644 --- a/src/service/character.ts +++ b/src/service/character.ts @@ -34,6 +34,15 @@ export class CharacterService { return { total, items, page, limit, totalPages: Math.ceil(total / limit) } } + async getCharacter(id: string) { + return prisma.character.findUnique({ + where: { id }, + include: { + image: true, + }, + }) + } + async updateCharacter( id: string, data: { name?: string; description?: string; imageId?: string }, diff --git a/src/service/generation.ts b/src/service/generation.ts index 8918b4e..da5e461 100644 --- a/src/service/generation.ts +++ b/src/service/generation.ts @@ -1,7 +1,138 @@ +import { join } from 'path' +import { ulid } from 'ulidx' import { prisma } from '../db' import { Prisma } from '../generated/prisma/client' +import { aiService } from './ai' +import { assetService } from './asset' export class GenerationService { + async createGeneration(characterId: string, equipmentIds: string[]) { + // 1. Fetch character and its image + const character = await prisma.character.findUnique({ + where: { id: characterId }, + include: { image: true }, + }) + + if (!character) { + throw new Error('Character not found') + } + + // 2. Fetch equipments and their images + const equipments = await prisma.equipment.findMany({ + where: { id: { in: equipmentIds } }, + include: { image: true }, + }) + + // 3. Construct parts + const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [] + + // Base Character + const charPath = join('data/files', character.image.path) + const charFile = Bun.file(charPath) + const charBuffer = await charFile.arrayBuffer() + const charBase64 = Buffer.from(charBuffer).toString('base64') + + parts.push({ + inlineData: { + mimeType: character.image.type, + data: charBase64, + }, + }) + parts.push({ text: 'Base Character Reference' }) + + // Equipments + for (const eq of equipments) { + const eqPath = join('data/files', eq.image.path) + const eqFile = Bun.file(eqPath) + const eqBuffer = await eqFile.arrayBuffer() + const eqBase64 = Buffer.from(eqBuffer).toString('base64') + + parts.push({ + inlineData: { + mimeType: eq.image.type, + data: eqBase64, + }, + }) + + parts.push({ + text: ` +Equipment to equip: +name: ${eq.name} +description: ${eq.description} +category: ${eq.category} +`, + }) + } + + const prompt = ` + Task: Character Synthesis and Equipment Modification. + + 1. Reference the 'Base Character' for pose, body type, facial features, and general art style. + 2. Reference the 'Equipment to equip' images. + 3. Generate a new image of the Base Character wearing the provided items. + 4. The items should replace existing clothing/armor in that slot (e.g. new helmet replaces old hat, armor replaces shirt). + 5. Seamlessly blend the items into the character's style. + 6. Maintain the exact pose and angle of the Base Character. + 7. High quality, detailed, game art style. + ` + + parts.push({ text: prompt }) + + // 4. Generate Image + const generatedImageBase64 = await aiService.generateImageFromParts(parts) + + // 5. Save generated image as Asset + const matches = generatedImageBase64.match(/^data:(.+);base64,(.+)$/) + if (!matches || matches.length !== 3) { + throw new Error('Invalid generated image format') + } + const mimeType = matches[1] + const data = matches[2] + const buffer = Buffer.from(data, 'base64') + + const ext = '.' + (mimeType.split('/')[1] || 'png') + + const asset = await assetService.createAssetFromBuffer(buffer, { + name: `${character.name} - Outfit Generation`, + type: mimeType, + ext: ext, + }) + + // 6. Create Generation record + const generationId = ulid() + await prisma.generation.create({ + data: { + id: generationId, + characterId: character.id, + imageId: asset.id, + }, + }) + + // Create relation records + if (equipmentIds.length > 0) { + await prisma.generationEquipment.createMany({ + data: equipmentIds.map((eqId) => ({ + generationId: generationId, + equipmentId: eqId, + })), + }) + } + + // Return generation with included relations + return prisma.generation.findUnique({ + where: { id: generationId }, + include: { + image: true, + character: true, + equipments: { + include: { + equipment: true, + }, + }, + }, + }) + } + async listGenerations( pagination: { page: number; limit: number }, filters?: { characterId?: string; equipmentId?: string }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index aadf2c9..fc2367e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import Layout from './Layout' import Characters from './pages/Characters' import Equipments from './pages/Equipments' +import FittingRoom from './pages/FittingRoom' export default function App() { return ( @@ -11,6 +12,7 @@ export default function App() { }> } /> } /> + } /> } /> diff --git a/src/ui/components/CreateCharacterDialog.tsx b/src/ui/components/CreateCharacterDialog.tsx new file mode 100644 index 0000000..d577b65 --- /dev/null +++ b/src/ui/components/CreateCharacterDialog.tsx @@ -0,0 +1,176 @@ +import { Loader2, Upload } from 'lucide-react' +import React, { useState } from 'react' +import { client } from '../client' +import { Button } from './ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog' +import { Input } from './ui/input' +import { Label } from './ui/label' +import { Textarea } from './ui/textarea' + +interface CreateCharacterDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export const CreateCharacterDialog: React.FC = ({ + open, + onOpenChange, + onSuccess, +}) => { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [file, setFile] = useState(null) + const [preview, setPreview] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const f = e.target.files[0] + setFile(f) + setPreview(URL.createObjectURL(f)) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!file || !name) return + + setLoading(true) + setError(null) + + try { + // 1. Upload Image + const uploadRes = await client.assets.upload.$post({ + form: { + file: file, + name: file.name, + }, + }) + + if (!uploadRes.ok) { + throw new Error('Failed to upload image') + } + + const asset = await uploadRes.json() + + // 2. Create Character + const createRes = await client.characters.$post({ + json: { + name, + description, + imageId: asset.id, + }, + }) + + if (!createRes.ok) { + throw new Error('Failed to create character') + } + + onSuccess() + handleReset() + onOpenChange(false) + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setLoading(false) + } + } + + const handleReset = () => { + setName('') + setDescription('') + setFile(null) + if (preview) URL.revokeObjectURL(preview) + setPreview(null) + setError(null) + } + + return ( + + + + Create Character + + Add a new character to your collection. + + +
+
+ +
+ {!preview ? ( + + ) : ( +
+ Preview + +
+ )} +
+
+ +
+ + setName(e.target.value)} + placeholder="Character Name" + required + /> +
+ +
+ +