${html(title)}
${html(copy)}
(function () { const root = document.getElementById("photoforge-app"); const config = JSON.parse(document.getElementById("photoforge-config")?.textContent || "{}"); const assets = config.assets || {}; const VIEWS = { LANDING: "landing", AUTH: "auth", IDLE: "idle", TEXT: "text_input", ANALYZING: "analyzing", DESCRIPTION: "description_ready", GENERATING: "generating", COMPLETE: "complete", }; const LEGAL_CONTENT = { contact: { title: "Contact Us", content: `
Ask us anything, or share your feedback. We usually answer within the same business day.
`, }, privacy: { title: "Privacy Policy", content: `We only collect the content you upload, your email (if provided), and basic technical data needed to operate the site. We use this information solely to run and improve photoforge.io and do not sell your data. Limited third-party services may process data on our behalf. You can request access or deletion of your data anytime through our Contact Us page. This site is not intended for children under 13. We may update this policy, and the latest version will always be posted here.
`, }, terms: { title: "Terms of Service", content: `By using PhotoForge, you agree to use the site lawfully and not misuse, disrupt, or copy the service. All site content is owned by Rekognize OU. The service is provided as is, without warranties, and we are not liable for any indirect or consequential damages. We may change these terms or suspend access at any time. Estonian law applies. For questions, visit our Contact Us page.
`, }, cookies: { title: "Cookie Policy", content: `PhotoForge uses essential cookies to operate the site. Optional analytics cookies may be used only with your consent. You can disable cookies in your browser, but some features may not work. Third-party services we use may set their own cookies. For questions, see our Contact Us page.
`, }, }; const state = { view: VIEWS.LANDING, isSubmitting: false, inputImage: null, photoUuid: null, description: "", outputImages: [], progress: 0, error: null, userEmail: config.user?.email || null, theme: localStorage.getItem("photoforge-theme") || "light", count: 1, imageStyle: "natural", imageQuality: "standard", gallery: [], uploadedImages: [], showGallery: false, showPurchase: false, selectedPurchaseTier: "sm", credits: Number.isFinite(config.user?.credits) ? config.user.credits : 0, activeLegalPage: null, authSent: false, purchaseMode: "select", clientSecret: null, stripePublicKey: null, }; let progressTimer = null; let generationTarget = 0; let checkoutInstance = null; let mountedClientSecret = null; let stripeInstance = null; function html(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function icon(name, classes = "h-5 w-5") { return ``; } function tooltip(label, content) { return ` ${content} ${html(label)} `; } function getCookie(name) { return document.cookie .split(";") .map((cookie) => cookie.trim()) .find((cookie) => cookie.startsWith(`${name}=`)) ?.slice(name.length + 1) || ""; } function csrfHeaders() { const token = getCookie("csrftoken") || document.querySelector('meta[name="csrf-token"]')?.content || ""; return token ? { "X-CSRFToken": decodeURIComponent(token) } : {}; } async function readJsonResponse(response) { const contentType = response.headers.get("content-type") || ""; const payload = contentType.includes("application/json") ? await response.json() : {}; if (!response.ok) { const message = payload.error || payload.detail || response.statusText || "Request failed"; const error = new Error(message); error.payload = payload; error.status = response.status; throw error; } return payload; } function setView(view) { if (![VIEWS.ANALYZING, VIEWS.GENERATING].includes(view)) { clearProgressTimer(); } state.view = view; enforceAuthGuard(); render(); } function enforceAuthGuard() { if (!state.userEmail && ![VIEWS.LANDING, VIEWS.AUTH].includes(state.view)) { state.view = VIEWS.LANDING; } } function resetFlow() { clearProgressTimer(); state.view = VIEWS.IDLE; state.inputImage = null; state.photoUuid = null; state.description = ""; state.outputImages = []; state.progress = 0; state.count = 1; state.imageStyle = "natural"; state.imageQuality = "standard"; render(); } function showError(message, returnView = VIEWS.LANDING) { state.error = message; render(); window.setTimeout(() => { state.error = null; if (returnView) { state.view = returnView; if (returnView === VIEWS.LANDING) { state.inputImage = null; } } render(); }, 3000); } function updateProgressUi() { const circle = root.querySelector("[data-progress-circle]"); if (circle) { const circumference = 283; circle.style.strokeDashoffset = String(circumference - (circumference * state.progress) / 100); } const bar = root.querySelector("[data-progress-bar]"); if (bar) { bar.style.width = `${Math.max(0, Math.min(100, state.progress))}%`; } } function clearProgressTimer() { if (progressTimer) { window.clearInterval(progressTimer); progressTimer = null; } } function startProgressTimer(kind) { clearProgressTimer(); progressTimer = window.setInterval(() => { const target = kind === VIEWS.ANALYZING ? 95 : generationTarget; const diff = target - state.progress; if (diff > 0.1) { state.progress += diff * 0.01; updateProgressUi(); } }, 100); } async function checkAuth() { try { const response = await fetch("/api/profiles/me", { credentials: "same-origin" }); if (!response.ok) { state.userEmail = null; state.credits = 0; enforceAuthGuard(); render(); return; } const data = await response.json(); state.userEmail = data.email; state.credits = data.credits ?? state.credits; render(); await fetchGallery(); } catch (_error) { render(); } } async function fetchGallery() { if (!state.userEmail) { state.gallery = []; render(); return; } try { const response = await fetch("/api/photos/gallery", { credentials: "same-origin" }); const data = await readJsonResponse(response); state.gallery = data.map((item) => ({ id: String(item.id), imageUrl: item.photo_url || "", description: item.description || "", sourceImage: item.source_photo_url || "", style: item.params?.style || "natural", quality: item.params?.quality || "standard", createdAt: new Date(item.created).getTime(), })); render(); } catch (error) { console.error("Gallery fetch failed", error); } } async function handleLogout() { try { await fetch("/api/profiles/auth/logout", { method: "POST", credentials: "same-origin", headers: { ...csrfHeaders(), "Content-Type": "application/json" }, }); } catch (error) { console.error(error); } state.userEmail = null; state.credits = 0; state.gallery = []; state.view = VIEWS.LANDING; render(); } async function requestLogin(email) { state.isSubmitting = true; render(); try { const response = await fetch("/api/profiles/auth/email", { method: "POST", credentials: "same-origin", headers: { ...csrfHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); await readJsonResponse(response); state.authSent = true; } catch (error) { window.alert("Failed to send login link. Please try again."); } finally { state.isSubmitting = false; render(); } } async function processSelectedFile(file, previewUrl = null) { if (!file) return; state.inputImage = previewUrl || URL.createObjectURL(file); state.photoUuid = null; state.description = ""; state.outputImages = []; state.progress = 0; state.view = VIEWS.ANALYZING; render(); startProgressTimer(VIEWS.ANALYZING); try { const formData = new FormData(); formData.append("photo", file); const uploadResponse = await fetch("/api/photos/upload", { method: "POST", credentials: "same-origin", headers: csrfHeaders(), body: formData, }); const uploadData = await readJsonResponse(uploadResponse); state.photoUuid = uploadData.uuid; const uploaded = { id: uploadData.uuid, url: state.inputImage, createdAt: Date.now(), description: "", }; state.uploadedImages = [uploaded, ...state.uploadedImages.filter((item) => item.id !== uploaded.id)].slice(0, 10); const describeResponse = await fetch(`/api/photos/describe/${uploadData.uuid}`, { method: "POST", credentials: "same-origin", headers: csrfHeaders(), }); const describeData = await readJsonResponse(describeResponse); state.description = describeData.description || ""; state.uploadedImages = state.uploadedImages.map((item) => item.id === uploadData.uuid ? { ...item, description: state.description } : item, ); state.progress = 100; clearProgressTimer(); state.view = VIEWS.DESCRIPTION; render(); } catch (error) { console.error("Processing failed", error); clearProgressTimer(); const code = error.payload?.code; if (code === "auth_required") { state.view = VIEWS.AUTH; state.error = "Please sign in before uploading an image."; render(); return; } showError(error.message || "Failed to process image."); } } async function processImageUrl(url) { state.showGallery = false; state.inputImage = url; state.photoUuid = null; state.description = ""; state.outputImages = []; state.progress = 0; state.view = VIEWS.ANALYZING; render(); startProgressTimer(VIEWS.ANALYZING); try { const response = await fetch(url, { credentials: "same-origin" }); if (!response.ok) throw new Error("Could not load source image."); const blob = await response.blob(); const extension = blob.type.split("/")[1] || "png"; await processSelectedFile(new File([blob], `source.${extension}`, { type: blob.type }), url); } catch (error) { console.error(error); clearProgressTimer(); showError("Could not reuse this image. Please upload it again.", VIEWS.IDLE); } } async function beginGeneration() { if (!state.description.trim()) return; if (!state.photoUuid) { window.alert("Please upload an image first."); setView(VIEWS.IDLE); return; } const generationCount = Number(state.count); const cost = state.imageQuality === "hd" ? 2 : 1; const images = []; state.outputImages = []; state.progress = 0; state.view = VIEWS.GENERATING; generationTarget = 5; render(); startProgressTimer(VIEWS.GENERATING); try { for (let index = 0; index < generationCount; index += 1) { const stepSize = 100 / generationCount; generationTarget = (index + 0.95) * stepSize; const response = await fetch(`/api/photos/forge/${state.photoUuid}`, { method: "POST", credentials: "same-origin", headers: { ...csrfHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ style: state.imageStyle, quality: state.imageQuality, }), }); const forged = await readJsonResponse(response); images.push(forged.photo_url || forged.url || ""); state.credits = Math.max(0, state.credits - cost); state.progress = (index + 1) * stepSize; updateProgressUi(); } generationTarget = 100; state.progress = 100; updateProgressUi(); await new Promise((resolve) => window.setTimeout(resolve, 400)); clearProgressTimer(); state.outputImages = images.filter(Boolean); state.gallery = [ ...state.outputImages.map((imageUrl) => ({ id: Math.random().toString(36).slice(2), imageUrl, sourceImage: state.inputImage || "", description: state.description || "Generated Image", style: state.imageStyle, quality: state.imageQuality, createdAt: Date.now(), })), ...state.gallery, ]; state.view = state.outputImages.length ? VIEWS.COMPLETE : VIEWS.DESCRIPTION; render(); checkAuth(); fetchGallery(); } catch (error) { console.error("Generation error", error); clearProgressTimer(); if (error.payload?.code === "no_credit") { window.alert("Not enough credits to continue generation."); } else { window.alert("Generation failed. Please try again."); } state.view = VIEWS.DESCRIPTION; render(); checkAuth(); } } async function ensureStripePublicKey() { if (state.stripePublicKey) return state.stripePublicKey; const response = await fetch("/payment/config", { credentials: "same-origin" }); const data = await readJsonResponse(response); state.stripePublicKey = data.publicKey; return state.stripePublicKey; } function destroyCheckout() { if (checkoutInstance) { try { checkoutInstance.destroy(); } catch (_error) { // Stripe may already have torn down the iframe after a redirect. } } checkoutInstance = null; mountedClientSecret = null; } async function waitForStripe() { for (let attempt = 0; attempt < 50; attempt += 1) { if (window.Stripe) return; await new Promise((resolve) => window.setTimeout(resolve, 100)); } throw new Error("Stripe.js could not be loaded."); } async function mountCheckout() { if (!state.clientSecret || mountedClientSecret === state.clientSecret) return; const checkoutMount = document.getElementById("checkout"); if (!checkoutMount) return; try { await waitForStripe(); const publicKey = await ensureStripePublicKey(); stripeInstance = stripeInstance || window.Stripe(publicKey); destroyCheckout(); checkoutInstance = await stripeInstance.initEmbeddedCheckout({ clientSecret: state.clientSecret }); checkoutInstance.mount("#checkout"); mountedClientSecret = state.clientSecret; } catch (error) { console.error(error); checkoutMount.innerHTML = `${html(error.message || "Could not load checkout.")}
`; } } async function openPurchase(tier = null) { if (!state.userEmail) { setView(VIEWS.AUTH); return; } destroyCheckout(); state.showPurchase = true; state.selectedPurchaseTier = tier || state.selectedPurchaseTier || "sm"; state.purchaseMode = tier ? "checkout" : "select"; state.clientSecret = null; render(); if (!tier) return; try { await ensureStripePublicKey(); const response = await fetch(`/payment/session?tier=${encodeURIComponent(tier)}`, { method: "POST", credentials: "same-origin", headers: { ...csrfHeaders(), "Content-Type": "application/json" }, }); const data = await readJsonResponse(response); state.clientSecret = data.clientSecret; render(); } catch (error) { console.error("Stripe session error", error); showError("Could not prepare checkout.", null); } } function closePurchase() { destroyCheckout(); state.showPurchase = false; state.purchaseMode = "select"; state.clientSecret = null; render(); } function downloadUrl(url, filename) { const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); } function formatDate(timestamp) { if (!timestamp) return ""; return new Date(timestamp).toLocaleDateString(); } function formatTime(timestamp) { if (!timestamp) return ""; return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function navTemplate() { const showReset = ![VIEWS.IDLE, VIEWS.LANDING, VIEWS.AUTH].includes(state.view); return ` `; } function landingTemplate() { const cards = [ ["shield-check", "Generate Royalty-Free Photos", "Create unique alternatives to stock photos. Safe for commercial use without licensing fees.", assets.stock, "Royalty Free"], ["eye-off", "Anonymize People", "Protect privacy by realistically altering faces while preserving the original context and emotion.", assets.anon, "Anonymize"], ["palette", "Creative Modifications", "Transform ordinary photos into artistic masterpieces. Change seasons, styles, or entire environments.", assets.fun, "Creative"], ["pen-tool", "Enhance Sketches", "Turn rough drawings and concepts into polished, high-fidelity illustrations and renders.", assets.sketch, "Sketches"], ]; return `Upload an image. PhotoForge analyzes and describes it in detailed text. Then, it uses this description to generate a new version of the original image.
${html(copy)}
Enter your email to continue. We'll send you a secure login link.
We've sent a secure login link to your inbox. Click the link to sign in.
Advanced image-to-image synthesis for the creative mind.
Previously Uploaded
Extracting semantic features and depth maps...
Secure payment via StripeCredits never expire
Preparing checkout...
Your gallery is empty
Generated images will automatically appear here.
${html(item.description)}