/* global React, ReactDOM, Ic, NS_DATA */ const { useState, useMemo, useRef, useEffect } = React; const { COLORS, MATERIALS, SIZES_SOFA, SIZES_BED, CAMERAS, LEGS, ENVIRONMENTS, LENSES, TIMES_OF_DAY, SHADOWS, BEDDING_PRESETS, THROW_PRESETS, TIDY_LEVELS, DENSITY_LEVELS, BED_ACCENTS } = NS_DATA; /* ---------- helpers ---------- */ function LegGlyph({ id }) { const ink = "#3A3B37"; const wood = "#9B7048"; const metal = "#7A7770"; if (id === "keep") return obecne; if (id === "wood") return ; if (id === "metal") return ; if (id === "block") return ; if (id === "hidden") return ; if (id === "swivel") return ; return null; } function highlightJson(s) { const esc = s.replace(/&/g, "&").replace(//g, ">"); return esc.replace( /("[^&]*?")\s*:|("[^&]*?")|\b(true|false|null)\b|(-?\d+(?:\.\d+)?)/g, (m, key, str, kw, num) => { if (key) return `${key}:`; if (str) return `${str}`; if (kw) return `${kw}`; if (num) return `${num}`; return m; } ); } /* ============================================================ */ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "sofaWidth": 58, "sofaBottom": 30, "sofaAspect": 2.4, "sofaRadius": 22, "shadowStrength": 34, "stageVignette": true, "stageZoom": 100, "showFloorTag": true, "showVariantRail": false, "fabAlign": "center" }/*EDITMODE-END*/; const API_KEY_STORAGE = "nano-sofa-v2-api-key"; function App({ t }) { const [apiKey, setApiKey] = useState(() => { try { return localStorage.getItem(API_KEY_STORAGE) || ""; } catch { return ""; } }); useEffect(() => { try { localStorage.setItem(API_KEY_STORAGE, apiKey); } catch {} }, [apiKey]); // Open the key field automatically on first load when no key is set. const [showKeyEdit, setShowKeyEdit] = useState(() => { try { return !(localStorage.getItem(API_KEY_STORAGE) || ""); } catch { return true; } }); // Server-driven config: models + per-model constraints (max_refs, resolutions). // Falls back to a single Flash entry if the request fails so the UI still loads. const [serverConfig, setServerConfig] = useState({ models: [{ id: "gemini-2.5-flash-image", label: "gemini-2.5-flash-image", tier: "flash", max_refs: 3, resolutions: ["1K"] }], default_model: "gemini-2.5-flash-image", }); useEffect(() => { fetch("/api/config") .then(r => r.ok ? r.json() : null) .then(cfg => { if (cfg && cfg.models && cfg.models.length) setServerConfig(cfg); }) .catch(() => {}); }, []); const [st, setSt] = useState({ uploaded: false, baseFile: null, baseFileName: "", baseFileSize: 0, basePreviewUrl: null, alpha: false, kind: "sofa", color: "saliw", colorCustom: "", mat: "boucle", matNotes: "", size: "3", legs: "keep", cam: "studio", lens: "50mm_natural", tod: "noon_neutral", shadow: "soft_diffuse", env: "scandi", envFile: null, envNote: "", envMode: "reference", refs: [null, null, null], refsLock: false, // Lock camera angle + framing + object pose to the base photo (section 02). // Wizard color/material/size/scene still apply — the model just keeps the // exact same viewpoint as the uploaded base image. Useful for detail crops // where any reframing would be wrong. preserveBaseCamera: false, // Bed-only styling block (section 10). Ignored for sofas. bedding: "linen_white", beddingCustom: "", throw: "none", tidy: "lived_in", density: "balanced", accents: [], // array of BED_ACCENTS ids bedNote: "", // optional free-text styling note model: "gemini-3.1-flash-image-preview", aspect: "4:3", res: "1K", seed: "", }); const set = patch => setSt(s => ({ ...s, ...patch })); const fileRef = useRef(null); const envFileRef = useRef(null); const refFileRef = useRef(null); const refSlotRef = useRef(-1); // which reference slot the next file-pick fills const onPickBase = (file) => { if (!file) return; const url = URL.createObjectURL(file); set({ baseFile: file, baseFileName: file.name, baseFileSize: file.size, basePreviewUrl: url, uploaded: true }); }; const onPickRef = (file) => { const slot = refSlotRef.current; refSlotRef.current = -1; if (!file || slot < 0) return; const url = URL.createObjectURL(file); const next = [...st.refs]; while (next.length <= slot) next.push(null); // Revoke the old object URL if we're replacing an existing pick. if (next[slot] && next[slot].previewUrl) URL.revokeObjectURL(next[slot].previewUrl); next[slot] = { file, name: file.name, size: file.size, previewUrl: url }; set({ refs: next }); }; const clearRef = (slot) => { const next = [...st.refs]; if (next[slot] && next[slot].previewUrl) URL.revokeObjectURL(next[slot].previewUrl); next[slot] = null; set({ refs: next }); }; const fmtSize = (b) => b < 1024*1024 ? (b/1024).toFixed(0) + " KB" : (b/1024/1024).toFixed(1) + " MB"; const [stageTab, setStageTab] = useState("mockup"); const [copied, setCopied] = useState(false); const [generating, setGenerating] = useState(false); const [genError, setGenError] = useState(""); const [gallery, setGallery] = useState([]); // {url, color, tag, cost} const [activeGallery, setActiveGallery] = useState(-1); // Color-variant set state. variantColors is the user's multi-pick of color // ids (first = anchor). variantSet is the result strip after the server // returns { anchor, variants[] } from /api/generate-set. const [variantColors, setVariantColors] = useState([]); // English color ids const [variantSet, setVariantSet] = useState(null); // { anchor, variants, total_cost } const [variantBusy, setVariantBusy] = useState(false); const [variantError, setVariantError] = useState(""); // Photoshoot session state. Sources are the user's uploaded angle photos; // each has a role: "packshot" | "lifestyle" | "skip". Backdrop is the // locked cyclorama profile id. Result is { packshot[], lifestyle[], errors[] }. const [shootSources, setShootSources] = useState([]); // [{file, role, previewUrl, name}] const [shootBackdrop, setShootBackdrop] = useState("cyclorama_warm"); const [shootLifestyleEnv, setShootLifestyleEnv] = useState("scandi"); const [shootResult, setShootResult] = useState(null); const [shootBusy, setShootBusy] = useState(false); const [shootError, setShootError] = useState(""); const colorObj = useMemo(() => COLORS.find(c => c.id === st.color), [st.color]); const matObj = useMemo(() => MATERIALS.find(m => m.id === st.mat), [st.mat]); const sizes = st.kind === "bed" ? SIZES_BED : SIZES_SOFA; const sizeObj = useMemo(() => sizes.find(s => s.id === st.size) || sizes[0], [sizes, st.size]); const camObj = useMemo(() => CAMERAS.find(c => c.id === st.cam), [st.cam]); const envObj = useMemo(() => ENVIRONMENTS.find(e => e.id === st.env), [st.env]); const lensObj = useMemo(() => LENSES.find(l => l.id === st.lens), [st.lens]); const todObj = useMemo(() => TIMES_OF_DAY.find(t => t.id === st.tod), [st.tod]); const shadowObj = useMemo(() => SHADOWS.find(s => s.id === st.shadow), [st.shadow]); // Single source of truth for the public JSON contract — the same shape is // used by the JSON tab preview, the JSON-tab Copy button, and the footer // "kopiuj JSON" button. Every value is an English stable id, a hex color, // a measurement string, or null. No Polish strings, no UI-only state. const jsonPayload = useMemo(() => ({ product: { type: st.kind, base: st.uploaded ? st.baseFileName || "base.jpg" : null }, variant: { color: st.color === "custom" ? { custom: st.colorCustom } : { id: colorObj?.id, hex: colorObj?.hex }, material: { id: matObj?.id, notes: st.matNotes || null }, size: { id: sizeObj?.id, dim: sizeObj?.dim }, legs: st.kind === "bed" ? "disabled_for_bed" : st.legs, }, scene: { environment: envObj?.id, camera: camObj?.id, lens: lensObj?.id || st.lens, time_of_day: todObj?.id || st.tod, shadows: shadowObj?.id || st.shadow, }, references: st.refs.filter(Boolean).map(r => r.name || "reference"), output: { model: st.model, aspect: st.aspect, resolution: (st.res || "").split(" ")[0], seed: st.seed || null, }, }), [st, colorObj, matObj, sizeObj, envObj, camObj, lensObj, todObj, shadowObj]); const modelObj = useMemo( () => serverConfig.models.find(m => m.id === st.model) || serverConfig.models[0], [serverConfig, st.model], ); // If the server's catalogue doesn't include the currently-selected model // (e.g. dev edited the schema), snap to the server default. Same for resolution // when the chosen one isn't in the active model's allow-list. useEffect(() => { if (!serverConfig.models.length) return; const knownIds = serverConfig.models.map(m => m.id); if (!knownIds.includes(st.model)) { set({ model: serverConfig.default_model }); return; } const allowedRes = modelObj?.resolutions || ["1K"]; const currentRes = (st.res || "").split(" ")[0]; // "1K — Flash limit" → "1K" if (!allowedRes.includes(currentRes)) { set({ res: allowedRes[0] }); } }, [serverConfig, st.model]); const cost = useMemo(() => { const base = st.model.includes("pro") ? 0.12 : 0.03; const refMult = 1 + st.refs.filter(Boolean).length * 0.15; const r = (st.res || "").split(" ")[0]; const resMult = r === "4K" ? 2.4 : r === "2K" ? 1.6 : 1; return (base * refMult * resMult).toFixed(3); }, [st.model, st.refs, st.res]); const handleGenerate = async () => { setGenError(""); if (!apiKey.trim()) { setGenError("Wklej klucz Gemini API u góry sceny."); setShowKeyEdit(true); return; } if (!st.baseFile) { setGenError("Wgraj zdjęcie bazowe (sekcja 02)."); return; } const fd = new FormData(); fd.append("api_key", apiKey.trim()); fd.append("kind", st.kind); fd.append("color", st.color); fd.append("color_custom", st.colorCustom || ""); fd.append("mat", st.mat); fd.append("mat_notes", st.matNotes || ""); fd.append("size", st.size); fd.append("legs", st.legs); fd.append("cam", st.cam); fd.append("lens", st.lens); fd.append("tod", st.tod); fd.append("shadow", st.shadow); fd.append("env", st.env || ""); fd.append("env_note", st.envNote || ""); fd.append("env_mode", st.envMode || ""); fd.append("model", st.model); fd.append("aspect", st.aspect); fd.append("res", st.res); fd.append("seed", st.seed || ""); fd.append("base_image", st.baseFile); if (st.envFile && st.envFile instanceof File) { fd.append("scene_image", st.envFile); } // Section 09 "Referencje" — moodboard uploads. Send each picked file as a // separate `references` entry so FastAPI receives them as list[UploadFile]. const hasAnyRef = st.refs.some(r => r && r.file instanceof File); for (const r of st.refs) { if (r && r.file instanceof File) fd.append("references", r.file); } // Reference-lock: makes the uploaded reference the source of truth for // camera/lighting/scene; suppresses the wizard's camera + scene blocks. // Only meaningful when at least one reference is present. if (hasAnyRef && st.refsLock) fd.append("refs_lock", "1"); if (st.preserveBaseCamera) fd.append("preserve_base", "1"); if (st.kind === "bed") { fd.append("bedding", st.bedding || ""); fd.append("bedding_custom", st.beddingCustom || ""); fd.append("throw", st.throw || ""); fd.append("tidy", st.tidy || ""); fd.append("density", st.density || ""); fd.append("accents", (st.accents || []).join(",")); fd.append("bed_note", st.bedNote || ""); } setGenerating(true); try { const r = await fetch("/api/generate", { method: "POST", body: fd }); const data = await r.json(); if (!r.ok || data.error) { setGenError(data.error || `Błąd serwera (${r.status})`); } else { setGallery(g => [ { url: data.image_url, color: colorObj?.hex || "#5C7A56", tag: "v" + (g.length + 1), cost: data.cost }, ...g, ]); setActiveGallery(0); } } catch (e) { setGenError(String(e && e.message ? e.message : e)); } finally { setGenerating(false); } }; // when size list changes (sofa↔bed), correct st.size useEffect(() => { if (!sizes.find(s => s.id === st.size)) set({ size: sizes[0].id }); // eslint-disable-next-line }, [st.kind]); // Toggle a color in the variant pick list. First entry is the anchor. const toggleVariantColor = (cid) => { setVariantColors(prev => prev.includes(cid) ? prev.filter(c => c !== cid) : [...prev, cid] ); }; // Estimated cost for the whole set (anchor + N variants) using the same // per-render multipliers as the single-render cost calc above. const variantSetCost = useMemo(() => { const base = st.model.includes("pro") ? 0.12 : 0.067; const r = (st.res || "").split(" ")[0]; const resMult = r === "4K" ? 2.4 : r === "2K" ? 1.6 : 1; // Each non-anchor variant has 1 extra reference (the anchor png), so apply 1.15× ref multiplier. const anchorCost = base * resMult; const variantCost = base * 1.15 * resMult; return (anchorCost + variantCost * Math.max(0, variantColors.length - 1)).toFixed(3); }, [st.model, st.res, variantColors.length]); // -------- Fotosesja (photoshoot session) handlers -------- const onShootPickFiles = (files) => { const arr = Array.from(files || []); if (!arr.length) return; setShootSources(prev => { const next = [...prev]; arr.forEach((f, i) => { // Default the first 6 to packshot, anything after to lifestyle. const idx = next.length; next.push({ file: f, role: idx < 6 ? "packshot" : "lifestyle", previewUrl: URL.createObjectURL(f), name: f.name, }); }); return next; }); }; const setShootSourceRole = (i, role) => { setShootSources(prev => prev.map((s, idx) => idx === i ? { ...s, role } : s)); }; const removeShootSource = (i) => { setShootSources(prev => { const removed = prev[i]; if (removed?.previewUrl) try { URL.revokeObjectURL(removed.previewUrl); } catch {} return prev.filter((_, idx) => idx !== i); }); }; const shootCounts = useMemo(() => { const p = shootSources.filter(s => s.role === "packshot").length; const l = shootSources.filter(s => s.role === "lifestyle").length; return { packshot: p, lifestyle: Math.min(l, 2), skipped: shootSources.filter(s => s.role === "skip").length, lifestyleExtra: Math.max(0, l - 2) }; }, [shootSources]); const shootCost = useMemo(() => { const base = st.model.includes("pro") ? 0.12 : 0.067; const r = (st.res || "").split(" ")[0]; const resMult = r === "4K" ? 2.4 : r === "2K" ? 1.6 : 1; // Packshot variants 2..N add ~15% for the swatch reference image; lifestyle variant 2 adds ~15% for scene ref. const packshotAnchor = base * resMult; const packshotVariant = base * 1.15 * resMult; const lifestyleAnchor = base * resMult; const lifestyleVariant = base * 1.15 * resMult; const total = ( (shootCounts.packshot > 0 ? packshotAnchor : 0) + packshotVariant * Math.max(0, shootCounts.packshot - 1) + (shootCounts.lifestyle > 0 ? lifestyleAnchor : 0) + lifestyleVariant * Math.max(0, shootCounts.lifestyle - 1) ); return total.toFixed(3); }, [st.model, st.res, shootCounts]); const handleGenerateShoot = async () => { setShootError(""); setShootResult(null); if (!apiKey.trim()) { setShootError("Wklej klucz Gemini API u góry sceny."); setShowKeyEdit(true); return; } const usable = shootSources.filter(s => s.role !== "skip"); if (usable.length === 0) { setShootError("Dodaj co najmniej jedno zdjęcie źródłowe."); return; } if (usable.length > 10) { setShootError("Limit sesji: max 10 zdjęć źródłowych."); return; } const fd = new FormData(); fd.append("api_key", apiKey.trim()); fd.append("kind", st.kind); fd.append("color", st.color); fd.append("color_custom", st.colorCustom || ""); fd.append("mat", st.mat); fd.append("mat_notes", st.matNotes || ""); fd.append("size", st.size); fd.append("legs", st.legs); fd.append("cam", st.cam); fd.append("lens", st.lens); fd.append("tod", st.tod); fd.append("shadow", st.shadow); fd.append("backdrop", shootBackdrop); fd.append("lifestyle_env", shootLifestyleEnv); fd.append("env_note", st.envNote || ""); fd.append("model", st.model); fd.append("aspect", st.aspect); fd.append("res", st.res); fd.append("seed", st.seed || ""); // Sources + roles arrays are positionally paired on the server side. const roles = []; for (const s of shootSources) { if (s.role === "skip") continue; // Cap lifestyle uses to 2 — the rest become packshot extras. let r = s.role; if (r === "lifestyle" && roles.filter(x => x === "lifestyle").length >= 2) r = "packshot"; fd.append("sources", s.file); roles.push(r); } fd.append("source_roles_csv", roles.join(",")); setShootBusy(true); try { const resp = await fetch("/api/generate-photoshoot", { method: "POST", body: fd }); const data = await resp.json(); if (!resp.ok || data.error) { setShootError(data.error || `Błąd serwera (${resp.status})`); } else { setShootResult(data); } } catch (e) { setShootError(String(e && e.message ? e.message : e)); } finally { setShootBusy(false); } }; const handleGenerateSet = async () => { setVariantError(""); setVariantSet(null); if (!apiKey.trim()) { setVariantError("Wklej klucz Gemini API u góry sceny."); setShowKeyEdit(true); return; } if (!st.baseFile) { setVariantError("Wgraj zdjęcie bazowe (sekcja 02)."); return; } if (variantColors.length < 2) { setVariantError("Wybierz co najmniej 2 kolory."); return; } const fd = new FormData(); fd.append("api_key", apiKey.trim()); fd.append("kind", st.kind); fd.append("colors_csv", variantColors.join(",")); fd.append("color_custom", st.colorCustom || ""); fd.append("mat", st.mat); fd.append("mat_notes", st.matNotes || ""); fd.append("size", st.size); fd.append("legs", st.legs); fd.append("cam", st.cam); fd.append("lens", st.lens); fd.append("tod", st.tod); fd.append("shadow", st.shadow); fd.append("env", st.env || ""); fd.append("env_note", st.envNote || ""); fd.append("env_mode", st.envMode || ""); fd.append("model", st.model); fd.append("aspect", st.aspect); fd.append("res", st.res); fd.append("seed", st.seed || ""); fd.append("base_image", st.baseFile); if (st.envFile && st.envFile instanceof File) fd.append("scene_image", st.envFile); setVariantBusy(true); try { const r = await fetch("/api/generate-set", { method: "POST", body: fd }); const data = await r.json(); if (!r.ok || data.error) { setVariantError(data.error || `Błąd serwera (${r.status})`); } else { setVariantSet(data); } } catch (e) { setVariantError(String(e && e.message ? e.message : e)); } finally { setVariantBusy(false); } }; return (
{/* ============= LEFT — sticky stage ============= */}
Nano Sofa studio
{showKeyEdit ? ( setApiKey(e.target.value)} onBlur={() => setShowKeyEdit(false)} onKeyDown={e => { if (e.key === "Enter" || e.key === "Escape") setShowKeyEdit(false); }} style={{position:"absolute", top:18, right:18, width: 260, padding: "6px 10px", fontSize: 12, fontFamily: "Geist Mono"}} /> ) : (
setShowKeyEdit(true)} style={{cursor:"pointer"}} title="kliknij aby wkleić / zmienić klucz"> {apiKey ? `klucz aktywny · ••${apiKey.slice(-4)}` : "wklej klucz Gemini"}
)}
{stageTab === "mockup" && (() => { const showGen = activeGallery >= 0 && gallery[activeGallery] && gallery[activeGallery].url; if (showGen) { return rendering; } if (st.kind === "bed" && typeof Bed3DViewer !== "undefined") { return (
); } return (
{Array.from({ length: 4 }).map((_, i) => )}
); })()} {stageTab === "mockup" && t.showFloorTag &&
kolor{colorObj?.name || "—"}
tkanina{matObj?.name || "—"}
format{sizeObj?.name} · {sizeObj?.dim}
scena{envObj?.name}
} {/* variant rail — vertical, right edge */} {stageTab === "mockup" && t.showVariantRail && gallery.length > 0 &&
{gallery.map((g, i) => (
setActiveGallery(i)}> {g.url ? {g.tag} :
}
{g.tag}
))}
} {stageTab === "mockup" && (() => { const activeImg = activeGallery >= 0 && gallery[activeGallery] && gallery[activeGallery].url ? gallery[activeGallery] : null; let downloadName = ""; if (activeImg) { const tag = activeImg.tag || ("v" + (activeGallery + 1)); const slug = [colorObj?.id, matObj?.id, envObj?.id].filter(Boolean).join("-"); const ext = (activeImg.url.split(".").pop() || "png").split("?")[0]; downloadName = `nano-sofa-${tag}-${slug || "render"}.${ext}`; } return (
{activeImg && ( {Ic.upload} Pobierz PNG )}
); })()} {stageTab === "json" && (
)} {stageTab === "variants" && (
{/* Locked-setup summary — keep on one line; no wrap into tabs row */}
Zablokowane: {matObj?.name} · {sizeObj?.name} ({sizeObj?.dim}) · {envObj?.name} · {camObj?.name} · {lensObj?.name?.split(" — ")[0]} · {todObj?.name?.split(" — ")[0]} · {st.model.includes("pro") ? "pro" : "flash 3.1"}
Wybierz kolory (pierwszy = anchor, reszta dziedziczy scenę)
{COLORS.map((c, i) => { const picked = variantColors.includes(c.id); const idx = variantColors.indexOf(c.id); return ( ); })}
{variantColors.length > 0 && !variantBusy && ( )} {variantError && (
{variantError}
)}
{variantBusy && (
{Ic.sparkle} Renderuję anchor (pierwszy kolor), potem warianty równolegle z dziedziczoną sceną…
)} {variantSet && (
Zestaw gotowy · ${variantSet.total_cost?.toFixed(3)} łącznie
{[variantSet.anchor, ...variantSet.variants].map((v, i) => { const cObj = COLORS.find(c => c.id === v.color); if (v.error) { return (
{cObj?.name || v.color}
{v.error}
); } const slug = [v.color, matObj?.id, envObj?.id].filter(Boolean).join("-"); const ext = (v.image_url.split(".").pop() || "png").split("?")[0]; const dlName = `nano-sofa-${i === 0 ? "anchor" : "v" + (i + 1)}-${slug}.${ext}`; return (
{v.color} {i === 0 && ( anchor )}
{cObj?.name || v.color}
{Ic.upload} PNG
); })}
)}
)} {stageTab === "photoshoot" && (
{/* Locked-variant summary — show what's being applied to the whole session */}
Wariant: {colorObj?.name || st.color} · {matObj?.name || st.mat} · {sizeObj?.name} ({sizeObj?.dim}) · {st.model.includes("pro") ? "pro" : "flash 3.1"} · {st.aspect} · {(st.res || "").split(" ")[0]}
{/* Backdrop + lifestyle env pickers */}
tło packshot (locked cyclorama)
scena lifestyle (1-2 zdjęć)
{/* Upload zone + thumbnails */}
Zdjęcia źródłowe (kąty kamery — dodaj 6-8, pierwsze 6 = packshot, ostatnie 1-2 = lifestyle)
Tło = na cykloramie (packshot) · Pokój = wnętrze lifestyle (max 2) · Pomiń = nie używaj
{shootSources.length > 0 && (
{shootSources.map((s, i) => (
{s.name} v{i + 1}
{s.name}
{[ {id:"packshot", label:"Tło", title:"Tło — wariant na cykloramie (packshot)"}, {id:"lifestyle",label:"Pokój", title:"Pokój — wariant w wnętrzu lifestyle (max 2)"}, {id:"skip", label:"Pomiń", title:"Pomiń — nie używaj tego zdjęcia"}, ].map(role => ( ))}
))}
)} {shootCounts.lifestyleExtra > 0 && (
Uwaga: tylko 2 pierwsze zdjęcia oznaczone „Pokój" zostaną wyrenderowane jako lifestyle; pozostałe {shootCounts.lifestyleExtra} → tło.
)}
{/* Submit row */}
{shootError && (
{shootError}
)}
{shootBusy && (
{Ic.sparkle} Pass A: każdy packshot solo z lockedym tłem (cyklorama ref) · Pass B: lifestyle anchor + scene-ref dla 2. shot
)} {shootResult && (
Sesja gotowa · ${shootResult.total_cost?.toFixed(3)} łącznie {shootResult.errors?.length > 0 && ` · ${shootResult.errors.length} błędów`}
{shootResult.packshot?.length > 0 && (
TŁO / CYKLORAMA ({shootResult.packshot.length}) — każdy ze swojego kąta źródłowego, wspólne tło z referencji
{shootResult.packshot.map((r, i) => { const slug = [st.color, st.mat, shootBackdrop].filter(Boolean).join("-"); const ext = (r.image_url.split(".").pop() || "png").split("?")[0]; const dlName = `nano-sofa-${r.label}-${slug}.${ext}`; return (
{r.label} {r.anchor && ( anchor )}
); })}
)} {shootResult.lifestyle?.length > 0 && (
POKÓJ / LIFESTYLE ({shootResult.lifestyle.length}) — wspólne wnętrze, drugi shot dziedziczy scenę z anchora
{shootResult.lifestyle.map((r, i) => { const slug = [st.color, st.mat, shootLifestyleEnv].filter(Boolean).join("-"); const ext = (r.image_url.split(".").pop() || "png").split("?")[0]; const dlName = `nano-sofa-${r.label}-lifestyle-${slug}.${ext}`; return (
{r.label} {r.anchor && ( anchor )}
); })}
)} {shootResult.errors?.length > 0 && (
BŁĘDY
{shootResult.errors.map((e, i) => (
{e.label} ({e.role}{e.source_filename ? `, ${e.source_filename}` : ""}): {e.error}
))}
)}
)}
)} {generating && (
{Ic.sparkle} Renderuję wariant…
{st.model} · {st.aspect} · {st.res.split(" ")[0]} · ~12 s
)}
{/* ============= RIGHT — scrolling form ============= */}
Studio · v2 · konfigurator

Złóż wariant zdjęcia produktu — pojedynczy formularz, jeden render.

Wszystkie ustawienia widoczne na raz, żywy podgląd po lewej. Przewiń od góry, ustaw co chcesz, naciśnij Generuj.

{/* API key banner — sticks until a key is entered. Inline so it can't be missed. */} {!apiKey && (
krok zerowy
Wklej swój klucz Gemini API, żeby zacząć
Klucz przechowujemy tylko w Twojej przeglądarce (localStorage). Nie wysyłamy go nigdzie poza wywołaniem do Google przy każdym renderze. Pobierz klucz z {" "} aistudio.google.com/app/apikey.
setApiKey(e.target.value)} style={{flex:1, fontFamily:"Geist Mono", fontSize: 13}} />
)} {/* 01 — output (was 09) */}
model
proporcje
rozdz.
limit modelu: {modelObj?.max_resolution || "1K"} · {modelObj?.max_refs || 3} ref.
seed
set({ seed: e.target.value })} />
{/* 02 — photo */}
onPickBase(e.target.files && e.target.files[0])} />
fileRef.current && fileRef.current.click()} onDragOver={e => e.preventDefault()} onDrop={e => { e.preventDefault(); onPickBase(e.dataTransfer.files && e.dataTransfer.files[0]); }}>
{st.uploaded && st.basePreviewUrl ? : Ic.upload}
{st.uploaded ? st.baseFileName : "Upuść zdjęcie tutaj"}
{st.uploaded ? fmtSize(st.baseFileSize) + " · gotowe do generowania" : "lub kliknij, JPG / PNG / WEBP, max 12 MB"}
{st.uploaded ? ( <> {(st.baseFile?.type || "image").replace("image/","")} wgrane ) : ( <> JPG ≥ 1024 px )}
set({ kind: "sofa" })}> {Ic.sofa}
sofa
tapicerka, nogi, podłokietniki
set({ kind: "bed" })}> {Ic.bed}
łóżko
rama, materac, zagłówek
{/* 02 — color */}
{COLORS.map(c => (
set({ color: c.id })}>
{c.name}
{c.hex}
))}
set({ color: "custom" })}>
+
własny
opisz
{st.color === "custom" && (