/* 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 ; 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 (
Wszystkie ustawienia widoczne na raz, żywy podgląd po lewej. Przewiń od góry, ustaw co chcesz, naciśnij Generuj.
{help}
}