diff --git a/index.html b/index.html index 60805c4..8a99512 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@

Hamster Fashion Studio

Summoning hamster glam...

+ Hamster Fashion Studio @@ -142,7 +121,7 @@

Choose the next look

diff --git a/script.js b/script.js index b5345e0..d821365 100644 --- a/script.js +++ b/script.js @@ -11,10 +11,16 @@ const STYLE_SELECTOR = document.getElementById("style-selector"); const HAMSTER_FRAME = document.getElementById("hamster-frame"); const HAMSTER_CARTOON = document.getElementById("hamster-cartoon"); const HAMSTER_PHOTO = document.getElementById("hamster-photo"); +const HAMSTER_PHOTO_IMAGE = document.getElementById("hamster-photo-image"); +const HAMSTER_PHOTO_STATUS = document.getElementById("hamster-photo-status"); const SAVE_FORM = document.getElementById("save-form"); const OUTFIT_NAME_INPUT = document.getElementById("outfit-name"); const SAVED_OUTFITS_LIST = document.getElementById("saved-outfits"); +const STYLE_CARTOON = "cartoon"; +const STYLE_REALISTIC = "realistic"; +const LEGACY_STYLE_PHOTOGRAPH = "photograph"; + const FASHION_ITEMS = [ { id: "flower-crown", @@ -315,7 +321,17 @@ const selections = []; const savedOutfits = []; const SAVED_OUTFITS_STORAGE_KEY = "hamster-fashion-saved-outfits"; let storyRequestId = 0; -let currentImageStyle = "cartoon"; +let currentImageStyle = STYLE_CARTOON; +const realisticImageCache = new Map(); +let realisticPhotoRequestId = 0; +let pendingPhotoSignature = null; + +function normalizeImageStyle(style) { + if (style === STYLE_REALISTIC || style === LEGACY_STYLE_PHOTOGRAPH) { + return STYLE_REALISTIC; + } + return STYLE_CARTOON; +} function init() { hydrateSavedOutfits(); @@ -323,6 +339,7 @@ function init() { setImageStyle(STYLE_SELECTOR.value); } else { applyImageStyle(currentImageStyle); + renderHamster(); } renderSavedOutfits(); updateOptions(); @@ -368,7 +385,7 @@ function hydrateSavedOutfits() { return; } - const normalizedStyle = style === "photograph" ? "photograph" : "cartoon"; + const normalizedStyle = normalizeImageStyle(style); const filteredItems = items.filter((itemId) => ITEM_LOOKUP.has(itemId)); if (filteredItems.length === 0) { return; @@ -456,7 +473,7 @@ function handleSelection(optionId) { } toggleOptionButtons(true); - showAiOverlay(item.name); + showAiOverlay(`Dreaming up ${item.name} magic...`); const wait = 500 + Math.random() * 700; window.setTimeout(() => { @@ -465,13 +482,26 @@ function handleSelection(optionId) { renderHistory(); updateOptions(); updateAiStory(); - hideAiOverlay(); + if (currentImageStyle !== STYLE_REALISTIC) { + hideAiOverlay(); + } toggleOptionButtons(false); }, wait); } function renderHamster() { const selectedItems = selections.map((entry) => ITEM_LOOKUP.get(entry.id)).filter(Boolean); + if (currentImageStyle === STYLE_REALISTIC) { + renderRealisticHamster(selectedItems); + } else { + renderCartoonHamster(selectedItems); + } +} + +function renderCartoonHamster(selectedItems) { + if (!BACKGROUND_LAYER || !FASHION_LAYER || !EFFECT_LAYER) { + return; + } BACKGROUND_LAYER.innerHTML = selectedItems .filter((item) => item.layer === "background") @@ -489,6 +519,136 @@ function renderHamster() { .join(""); } +function clearCartoonLayers() { + if (BACKGROUND_LAYER) { + BACKGROUND_LAYER.innerHTML = ""; + } + if (FASHION_LAYER) { + FASHION_LAYER.innerHTML = ""; + } + if (EFFECT_LAYER) { + EFFECT_LAYER.innerHTML = ""; + } +} + +async function renderRealisticHamster(selectedItems) { + if (!HAMSTER_PHOTO_IMAGE) { + return; + } + + clearCartoonLayers(); + hidePhotoStatus(); + + const signature = getSelectionSignature(selectedItems); + const cached = realisticImageCache.get(signature); + if (cached) { + applyRealisticPhoto(cached); + hideAiOverlay(); + return; + } + + if (pendingPhotoSignature === signature) { + return; + } + + pendingPhotoSignature = signature; + showAiOverlay("Capturing a realistic hamster photo..."); + + const requestId = ++realisticPhotoRequestId; + + try { + const response = await fetch("/api/hamster-photo", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + selections: selectedItems.map((item) => ({ + id: item.id, + name: item.name, + category: item.category, + description: item.description, + })), + }), + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + const data = await response.json(); + if (requestId !== realisticPhotoRequestId || currentImageStyle !== STYLE_REALISTIC) { + return; + } + + if (data?.image) { + const photo = { src: data.image, alt: data.alt }; + realisticImageCache.set(signature, photo); + applyRealisticPhoto(photo); + hidePhotoStatus(); + hideAiOverlay(); + } else { + throw new Error("Missing image data"); + } + } catch (error) { + if (requestId === realisticPhotoRequestId && currentImageStyle === STYLE_REALISTIC) { + console.error("Realistic hamster generation failed", error); + showPhotoStatus("We couldn't snap a hamster photo. Try again soon.", true); + hideAiOverlay(); + } + } finally { + if (requestId === realisticPhotoRequestId) { + pendingPhotoSignature = null; + } + } +} + +function getSelectionSignature(selectedItems) { + if (!selectedItems || selectedItems.length === 0) { + return "__empty__"; + } + return selectedItems + .map((item) => item?.id) + .filter(Boolean) + .sort() + .join("|"); +} + +function applyRealisticPhoto(photo) { + if (!HAMSTER_PHOTO_IMAGE || !photo || !photo.src) { + return; + } + HAMSTER_PHOTO_IMAGE.setAttribute("href", photo.src); + if (typeof HAMSTER_PHOTO_IMAGE.setAttributeNS === "function") { + HAMSTER_PHOTO_IMAGE.setAttributeNS("http://www.w3.org/1999/xlink", "href", photo.src); + } + if (photo.alt) { + HAMSTER_PHOTO_IMAGE.setAttribute("aria-label", photo.alt); + } else { + HAMSTER_PHOTO_IMAGE.removeAttribute("aria-label"); + } +} + +function showPhotoStatus(message, isError = false) { + if (!HAMSTER_PHOTO_STATUS) { + return; + } + HAMSTER_PHOTO_STATUS.textContent = message; + if (isError) { + HAMSTER_PHOTO_STATUS.classList.add("error"); + } else { + HAMSTER_PHOTO_STATUS.classList.remove("error"); + } + HAMSTER_PHOTO_STATUS.classList.remove("is-hidden"); +} + +function hidePhotoStatus() { + if (!HAMSTER_PHOTO_STATUS) { + return; + } + HAMSTER_PHOTO_STATUS.textContent = ""; + HAMSTER_PHOTO_STATUS.classList.remove("error"); + HAMSTER_PHOTO_STATUS.classList.add("is-hidden"); +} + function renderHistory() { HISTORY_LIST.innerHTML = ""; if (selections.length === 0) { @@ -605,12 +765,18 @@ function removeSelection(optionId) { updateAiStory(); } -function showAiOverlay(name) { - AI_OVERLAY_TEXT.textContent = `Dreaming up ${name} magic...`; +function showAiOverlay(message) { + if (!AI_OVERLAY || !AI_OVERLAY_TEXT) { + return; + } + AI_OVERLAY_TEXT.textContent = message; AI_OVERLAY.classList.remove("hidden"); } function hideAiOverlay() { + if (!AI_OVERLAY) { + return; + } AI_OVERLAY.classList.add("hidden"); } @@ -622,19 +788,28 @@ function toggleOptionButtons(disabled) { } function setImageStyle(style) { - const normalized = style === "photograph" ? "photograph" : "cartoon"; + const normalized = normalizeImageStyle(style); currentImageStyle = normalized; applyImageStyle(normalized); - if (STYLE_SELECTOR && STYLE_SELECTOR.value !== normalized) { - STYLE_SELECTOR.value = normalized; + if (STYLE_SELECTOR) { + const selectorHasValue = Array.from(STYLE_SELECTOR.options).some( + (option) => option.value === normalized, + ); + const legacyValue = normalized === STYLE_REALISTIC ? LEGACY_STYLE_PHOTOGRAPH : normalized; + const targetValue = selectorHasValue ? normalized : legacyValue; + if (STYLE_SELECTOR.value !== targetValue) { + STYLE_SELECTOR.value = targetValue; + } } + renderHamster(); } function applyImageStyle(style) { if (!HAMSTER_CARTOON || !HAMSTER_PHOTO || !HAMSTER_FRAME) { return; } - if (style === "photograph") { + const isRealistic = style === STYLE_REALISTIC || style === LEGACY_STYLE_PHOTOGRAPH; + if (isRealistic) { HAMSTER_CARTOON.classList.add("is-hidden"); HAMSTER_PHOTO.classList.remove("is-hidden"); HAMSTER_FRAME.classList.add("photo-style"); @@ -642,6 +817,10 @@ function applyImageStyle(style) { HAMSTER_CARTOON.classList.remove("is-hidden"); HAMSTER_PHOTO.classList.add("is-hidden"); HAMSTER_FRAME.classList.remove("photo-style"); + hidePhotoStatus(); + realisticPhotoRequestId += 1; + pendingPhotoSignature = null; + hideAiOverlay(); } } @@ -759,7 +938,8 @@ function buildOutfitPreview(outfit) { } function formatSavedOutfitMeta(outfit) { - const styleLabel = outfit.style === "photograph" ? "Photograph" : "Cartoon"; + const normalizedStyle = normalizeImageStyle(outfit.style); + const styleLabel = normalizedStyle === STYLE_REALISTIC ? "Realistic" : "Cartoon"; const savedAt = outfit.savedAt instanceof Date ? outfit.savedAt : new Date(outfit.savedAt); return `${styleLabel} • ${savedAt.toLocaleString(undefined, { month: "short", @@ -840,7 +1020,8 @@ function arraysEqual(a, b) { } function generateOutfitName() { - const base = currentImageStyle === "photograph" ? "Photo Look" : "Cartoon Look"; + const normalizedStyle = normalizeImageStyle(currentImageStyle); + const base = normalizedStyle === STYLE_REALISTIC ? "Realistic Look" : "Cartoon Look"; const count = savedOutfits.filter((outfit) => outfit.name.startsWith(base)).length + 1; return `${base} #${count}`; } diff --git a/server.js b/server.js index a18ce87..929d765 100644 --- a/server.js +++ b/server.js @@ -57,6 +57,72 @@ app.post("/api/fashion-story", async (req, res) => { } }); +app.post("/api/hamster-photo", async (req, res) => { + if (!openaiClient) { + return res.status(500).json({ error: "Missing OpenAI API key on the server." }); + } + + const selections = Array.isArray(req.body?.selections) ? req.body.selections : []; + const accessories = selections + .filter((entry) => entry && typeof entry === "object") + .map((entry) => ({ + name: typeof entry.name === "string" ? entry.name : "", + category: typeof entry.category === "string" ? entry.category : "", + description: typeof entry.description === "string" ? entry.description : "", + })) + .filter((entry) => entry.name || entry.category || entry.description); + + const accessorySummary = accessories.length + ? accessories + .map((item) => { + const pieces = [item.name, item.category].filter(Boolean); + return pieces.join(" "); + }) + .join(", ") + : "no accessories"; + + const richDetails = accessories + .map((item) => item.description) + .filter(Boolean) + .join(" "); + + const prompt = [ + "Photorealistic, high-resolution studio photograph of an adorable hamster standing upright.", + accessories.length + ? `The hamster is wearing ${accessorySummary}, styled with ${richDetails || "playful details"}.` + : "The hamster is freshly groomed with plush fur and bright eyes.", + "Soft diffused lighting, shallow depth of field, sharp focus, cinematic glow.", + "Realistic textures, no illustrations or cartoons.", + ] + .filter(Boolean) + .join(" "); + + try { + const response = await openaiClient.images.generate({ + model: "gpt-image-1", + prompt, + size: "512x512", + quality: "high", + response_format: "b64_json", + }); + + const imageBase64 = response.data?.[0]?.b64_json; + if (!imageBase64) { + throw new Error("Missing image data from OpenAI response"); + } + + const image = `data:image/png;base64,${imageBase64}`; + const alt = accessories.length + ? `Realistic hamster wearing ${accessories.map((item) => item.name).filter(Boolean).join(", ")}.` + : "Realistic portrait of a hamster with soft lighting."; + + res.json({ image, alt }); + } catch (error) { + console.error("Failed to generate realistic hamster image", error); + res.status(500).json({ error: "Unable to generate hamster photo from OpenAI." }); + } +}); + app.use((_req, res) => { res.sendFile(path.join(__dirname, "index.html")); }); diff --git a/styles.css b/styles.css index ded8fa7..90f66bd 100644 --- a/styles.css +++ b/styles.css @@ -71,6 +71,25 @@ body { transition: transform 0.3s ease, box-shadow 0.3s ease; } +.photo-status { + position: absolute; + inset: auto 1.75rem 1.75rem 1.75rem; + padding: 0.75rem 1rem; + background: rgba(15, 23, 42, 0.85); + color: #f8fafc; + border-radius: 12px; + font-size: 0.95rem; + line-height: 1.4; + text-align: center; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.25); + z-index: 2; +} + +.photo-status.error { + background: rgba(220, 38, 38, 0.9); + box-shadow: 0 16px 30px rgba(220, 38, 38, 0.25); +} + .hamster-canvas { width: 100%; height: auto; @@ -147,53 +166,15 @@ body { fill: rgba(0, 0, 0, 0.12); } -.hamster-photo .body { - fill: url(#photoFur); -} - -.hamster-photo .belly { - fill: url(#photoBelly); -} - -.hamster-photo .ears .ear { - fill: url(#photoFur); -} - -.hamster-photo .ears .ear-inner { - fill: url(#photoEar); -} - -.hamster-photo .face .cheek { - fill: rgba(255, 182, 193, 0.75); +.hamster-photo .photo-backdrop { + fill: rgba(226, 232, 240, 0.55); + stroke: rgba(148, 163, 184, 0.6); + stroke-width: 2; } -.hamster-photo .face .eye { - fill: #1f2937; -} - -.hamster-photo .face .eye-highlight { - fill: rgba(255, 255, 255, 0.85); -} - -.hamster-photo .face .nose { - fill: #5b3318; -} - -.hamster-photo .face .mouth, -.hamster-photo .face .chin { - stroke: rgba(75, 40, 15, 0.9); - stroke-width: 3; - stroke-linecap: round; -} - -.hamster-photo .whiskers path { - stroke: rgba(75, 40, 15, 0.85); - stroke-width: 2.5; - stroke-linecap: round; -} - -.hamster-photo .paws ellipse { - fill: url(#photoFur); +.hamster-photo image { + border-radius: 16px; + overflow: hidden; } .is-hidden {