1// Vernalabs platform client for the Pictools editor. 2// 3// Auth is cookie-based against main-service (accessToken/refreshToken/type, 4// httpOnly, SameSite=None) â the browser holds the session, we always fetch with 5// credentials. AI endpoints go through the authenticated gateway, which validates 6// the cookie and injects the actor headers pictools-service expects. CORS for 7// https://app.pictools.ai (with credentials) is provisioned on main-service. 8// 9// ?mockapi=1 runs the whole surface against an in-page fake (UI testing without 10// touching the platform). localStorage "pictools-api" overrides the base URL. 11 12// The platform API is proxied SAME-ORIGIN under /api (Caddy â main-service, pictools 13// tenant). This keeps the session cookies first-party on app.pictools.ai â direct 14// cross-site calls broke under Chrome/Safari third-party-cookie blocking. The Google 15// OAuth callback also flows through /api so Set-Cookie lands on this origin. 16const DEFAULT_BASE = "/api/v1/pictools"; 17export const API_BASE = localStorage.getItem("pictools-api") || DEFAULT_BASE; 18export const BILLING_URL = localStorage.getItem("pictools-billing") || "https://pictools.ai/pricing"; 19 20// ---- billing catalog: the SAME Stripe prices as pictools.ai/pricing ---------- 21// priceIds verified against the live pricing page's embedded product catalog and 22// the pictools tenant's Plans/Products docs (2026-08-04). Marketing site and app 23// funnel into the same Stripe account â one checkout, one wallet. 24// FALLBACK ladder only (offline / endpoint failure / mock) â the live catalog 25// is served by GET catalog/pricing so the app and pictools.ai sell from the 26// SAME source and a price change never needs a bundle deploy. Keep this in 27// sync opportunistically; the endpoint always wins. 28export const PRICING = { 29 packs: [ 30 { credits: 30, usd: 9, priceId: "price_1TA3iJE6AhGSCfZFBzANSNFk", popular: true }, 31 { credits: 75, usd: 21, priceId: "price_1TA3iIE6AhGSCfZFdUuj8uZV" }, 32 { credits: 300, usd: 79, priceId: "price_1TA3iIE6AhGSCfZF1z9v97bn" }, 33 ], 34 pro: [ 35 { planID: "6a425ff54590c940d1a277ae", credits: 40, usd: 9, annualUsd: 64.8, popular: true, 36 prices: { monthly: "price_1TA3iKE6AhGSCfZFBiofJGaP", annual: "price_1TA3iKE6AhGSCfZFpc1FVtF8" } }, 37 { planID: "6a425ff54590c940d1a277af", credits: 200, usd: 39, annualUsd: 280.8, 38 prices: { monthly: "price_1TA3iKE6AhGSCfZFha8jvWg6", annual: "price_1TA3iJE6AhGSCfZFumaQJlwY" } }, 39 { planID: "6a425ff54590c940d1a277b0", credits: 500, usd: 89, annualUsd: 640.8, 40 prices: { monthly: "price_1TA3iLE6AhGSCfZFOS144SmG", annual: "price_1TA3iJE6AhGSCfZFezLKAdy9" } }, 41 ], 42}; 43 44// Live pricing catalog â one source for app + marketing (team finding 27 ago). 45// Cached per session; resolves to the endpoint's ladder or the local fallback. 46let pricingCache = null; 47export async function pricing() { 48 if (MOCK) return PRICING; 49 if (pricingCache) return pricingCache; 50 try { 51 const r = await fetch(API_BASE + "/public-gateway/pictools-service/catalog/pricing"); 52 const j = await r.json(); 53 if (r.ok && Array.isArray(j?.packs) && j.packs.length && Array.isArray(j?.pro) && j.pro.length) { 54 pricingCache = { packs: j.packs, pro: j.pro }; 55 return pricingCache; 56 } 57 } catch { /* offline â fall back */ } 58 return PRICING; 59} 60 61// Credit-pack top-up: main-service resolves/creates the Stripe customer by the 62// authed email and returns a mode:'payment' Checkout Session; the webhook 63// grants creditAmount to the user's wallet sub (itemID stamped server-side). 64// Helpdesk SSO. The Support button opens THIS URL in a new tab; pictools-service 65// (behind the gateway, holding its own gpk key) returns a page that redirects the 66// tab to a help.pictools.ai/session/<token> for the authed user. Opening the 67// endpoint directly = a real navigation in the click gesture, so no about:blank 68// issues (Safari/strict browsers block async popup navigation). Per-product key 69// stays server-side; main-service never holds another product's key. 70export function helpdeskUrl() { 71 return API_BASE + `${GW}/helpdesk/sso`; 72} 73// Same SSO, but resolved as a plain JSON call so the caller gets the DIRECT 74// help.pictools.ai/session/<token>
74 URL. Needed for installed PWAs: an Android 75// home-screen app keeps SAME-ORIGIN /api URLs inside the app (Support looked like 76// it "just reloaded home"), so we steer the tab to the OUT-OF-SCOPE help URL, 77// which is what escapes to the system browser. Returns "" on any failure. 78export async function helpdeskSession() { 79 if (MOCK) return "https://help.pictools.ai/session/mock-token"; 80 try { 81 const r = await call(`${GW}/helpdesk/sso`, { method: "POST" }); 82 const u = r.json?.url || r.json?.data?.url; 83 return r.ok && typeof u === "string" ? u : ""; 84 } catch { return ""; } 85} 86export async function buyCredits({ priceId, credits }) { 87 if (MOCK) { mock.credits += credits; return { ok: true, mock: true } } 88 const r = await call("/subscriptions/buy-credits-session", { 89 method: "POST", 90 body: { priceId, quantity: 1, creditAmount: credits, returnUrl: location.origin + "/?billing=success" }, 91 }); 92 const d = r.json?.data; 93 return d?.url ? { ok: true, url: d.url, sessionId: d.sessionId } : { ok: false, error: r.json?.message || "Could not start the checkout" }; 94} 95 96// Pro subscription: init-subscription resolves the tenant plan doc and returns a 97// mode:'subscription' Checkout Session (success/cancel URLs are tenant-configured 98// to app.pictools.ai/subscriptions/â¦). itemID/itemType tie the subscription to 99// THIS user â without them the webhook writes a company-wide sub that the 100// per-user wallet lookups can't see. 101export async function subscribePro({ planID, priceID, billingCycle }) { 102 if (MOCK) { mock.credits += 40; return { ok: true, mock: true } } 103 const user = await currentUser(); 104 if (!user) return { ok: false, error: "Please log in first" }; 105 const r = await call("/subscriptions/init-subscription", { 106 method: "POST", 107 body: { 108 email: user.email, 109 priceID, 110 billingCycle, 111 metadata: { 112 planInfo: { planID, priceID, billingCycle }, 113 itemID: user.id || user._id, 114 itemType: "user", 115 billingInfo: {}, 116 }, 117 }, 118 }); 119 const d = r.json?.data; 120 return d?.url ? { ok: true, url: d.url, sessionId: d.sessionId } : { ok: false, error: r.json?.message || "Could not start the checkout" }; 121} 122export const MOCK = new URLSearchParams(location.search).has("mockapi"); 123// main-service credentialed CORS only allows the production origins; calling from 124// anywhere else just spams CORS errors, so those calls short-circuit to signed-out. 125export const LIVE = MOCK || /\.pictools\.ai$/.test(location.hostname) || localStorage.getItem("pictools-api") !== null; 126 127// ---- low-level fetch with refresh-on-401 (promptlab-desktop pattern) --------- 128// Note: the public gateway answers with wildcard CORS (no credentials allowed), 129// so public endpoints must be fetched WITHOUT credentials or the browser blocks. 130async function raw(path, { method = "GET", body, headers = {}, anonymous = false } = {}) { 131 const res = await fetch(API_BASE + path, { 132 method, 133 credentials: anonymous ? "omit" : "include", 134 headers: body !== undefined ? { "Content-Type": "application/json", ...headers } : headers, 135 body: body !== undefined ? JSON.stringify(body) : undefined, 136 }); 137 let json = null; 138 try { json = await res.json(); } catch { /* empty body */ } 139 return { status: res.status, ok: res.ok, json }; 140} 141let refreshing = null; 142async function refreshSession() { 143 if (!refreshing) { 144 // Transparent token refresh, resilient to transient blips. The access JWT lives 145 // ~1.5h; on expiry we silently renew via the refresh-token cookie and the user 146 // sees nothing. Retry on a NETWORK error (fetch threw â e.g. ERR_CONNECTION_CLOSED, 147 // common on the CloudFrontâorigin hop) or a 5xx; but an AUTH answer (401/403/400) 148 // is DEFINITIVE â the refresh token is dead â the caller forces re-login. Never 149 // sign the user out over a network blip. 150 refreshing = (async () => { 151 let last = null; 152 for (let attempt = 0; attempt < 3; attempt++) { 153 try { 154 last = await raw("/auth/refresh", { method: "POST", body: {} }); 155 if (last.ok || last.status < 500) return last; // 2xx renewed, or a definitive 4xx 156 } catch { last = null; } // network error â retry 157 await new Promise((r) => setTimeout(r, 350 * (attempt + 1))); 158 } 159 return last; // null (network) or a 5xx after retries â treated as transient (no logout) 160 })().finally(() => { refreshing = null; }); 161 } 162 return refreshing; 163} 164// Refresh failed AND the retried call still bounced: the session is truly dead.
165// Broadcast so the shell can flip the account UI to signed-out instead of 166// keeping a zombie chip that claims you're in while every call 403s. 167let lostAt = 0; 168function announceSessionLost() { 169 if (Date.now() - lostAt < 5000) return; // one toast per failure burst 170 lostAt = Date.now(); 171 try { window.dispatchEvent(new CustomEvent("pictools:session-lost")); } catch { /* non-DOM context */ } 172} 173async function call(path, opts) { 174 // Every mutation carries a stable operation id: if it IS ever retried (here 175 // or server-side), the backend has a dedupe key, and support can trace one 176 // logical action across gateway logs. X-Correlation-Id is the one tracing 177 // header main-service's CORS allow-list already accepts. 178 const mutating = (opts?.method || "GET").toUpperCase() !== "GET"; 179 if (mutating) opts = { ...opts, headers: { "X-Correlation-Id": (crypto.randomUUID && crypto.randomUUID()) || String(Date.now() + Math.random()), ...(opts.headers || {}) } }; 180 let r = await raw(path, opts); 181 // The gateway answers an EXPIRED/INVALID access token with 500 ("Internal 182 // undefined error") and a MISSING one with 403 â only /auth/verify 401s. 183 // All three mean "re-auth"; the access JWT lives 1.5h, so any PWA resume 184 // after idle lands here. Refresh is single-flight and sliding. BUT a 500 on 185 // a MUTATION is ambiguous â it can also be a genuine failure AFTER side 186 // effects (a charge, a Stripe session). Those are never replayed: we re-arm 187 // the session in the background so the user's next tap succeeds, and return 188 // the failure honestly. Auth-certain statuses (401/403) replay once â the 189 // original request never reached the handler. 190 if (r.status === 500 && mutating) { 191 refreshSession().then((rf) => { 192 if (!(rf.ok || rf.status === 200) && (rf.status === 401 || rf.status === 403 || rf.status === 400)) announceSessionLost(); 193 }).catch(() => {}); 194 return r; 195 } 196 if (r.status === 401 || r.status === 403 || r.status === 500) { 197 let rf = null; 198 try { rf = await refreshSession(); } catch { rf = null; } // exhausted network retries â rf null 199 if (rf && (rf.ok || rf.status === 200)) { 200 r = await raw(path, opts); // renewed â replay transparently (user sees nothing) 201 if (r.status === 401 || r.status === 403) announceSessionLost(); 202 } else if (rf && (rf.status === 401 || rf.status === 403 || rf.status === 400)) { 203 // ONLY force re-login when the refresh answered DEFINITIVELY that the token is 204 // dead (401/403/400). A network error / 5xx leaves rf null â transient: never 205 // sign out over a blip (the retry loop already absorbed a few), the caller just 206 // sees a "check your connection" failure and the next action refreshes again. 207 // (Signing out on transient failures was the regression that logged users out.) 208 announceSessionLost(); 209 } 210 } 211 return r; 212} 213 214// ---- #17 fase 2: profile (name/avatar), password change, account deletion ---- 215export async function updateProfile(body) { 216 if (MOCK) { mock.user = { ...(mock.user || {}), ...body }; return { ok: true }; } 217 const r = await call("/users/me", { method: "PATCH", body }); 218 return r.ok ? { ok: true } : { ok: false, error: r.json?.message || "Could not save the profile" }; 219} 220export async function changePassword(currentPassword, newPassword) { 221 if (MOCK) return currentPassword === "x2345678" ? { ok: true } : { ok: false, error: "Current password is incorrect" }; 222 const r = await call("/users/me/change-password", { method: "POST", body: { currentPassword, newPassword } }); 223 return r.ok ? { ok: true } : { ok: false, error: r.json?.message || "Could not change the password" }; 224} 225export async function deleteAccount(confirmEmail) { 226 if (MOCK) { mock.user = null; return { ok: true }; } 227 const r = await call("/users/me/delete-account", { method: "POST", body: { confirmEmail } }); 228 return r.ok ? { ok: true } : { ok: false, error: r.json?.message || "Could not delete the account" }; 229} 230 231// ---- #17 fase 1: plan status / invoices / billing portal --------------------- 232/** The caller's primary subscription (non-free preferred) with lifecycle fields. */ 233export async function planStatus() { 234 if (MOCK) return window.__mockPlan || { id: null, planID: null, planLabel: "Credits", isPaidPlan: false, status: "active", providerStatus: null, failState: false, cancelAtPeriodEnd: false, endsAt: null, renewalDate: null, tokens: null }; 235 const r = await call("/subscriptions/current"); 236 const list = r.json?.data || []; 237 const FREE = "6a53c91db334954db89df8a3";
238 const sub = list.find((s) => s.planID && String(s.planID) !== FREE) || list[0] || null; 239 if (!sub) return null; 240 const TIER = { "6a425ff54590c940d1a277ae": "Plus $9/mo", "6a425ff54590c940d1a277af": "Pro $39/mo", "6a425ff54590c940d1a277b0": "Elite $89/mo" }; 241 // #17 fase 3: Stripe lifecycle mirror â the UI branches on these. 242 const provider = sub.providerStatus || null; 243 const failState = ["past_due", "unpaid", "incomplete", "incomplete_expired"].includes(provider); 244 return { 245 id: sub.id || sub._id || null, 246 planID: sub.planID ? String(sub.planID) : null, 247 planLabel: sub.planID && TIER[String(sub.planID)] ? TIER[String(sub.planID)] : "Credits", 248 isPaidPlan: !!(sub.planID && TIER[String(sub.planID)]), 249 status: sub.status || "active", 250 providerStatus: provider, 251 failState, 252 cancelAtPeriodEnd: !!sub.cancelAtPeriodEnd, 253 endsAt: sub.cancelAt || sub.providerPeriodEnd || null, 254 renewalDate: sub.renewalDate || null, 255 tokens: sub.tokens || null, 256 }; 257} 258export async function changePlanPreview(priceID) { 259 if (MOCK) return { ok: true, preview: { currency: "usd", dueNow: 12.34, lines: [] } }; 260 const r = await call("/subscriptions/change-plan-preview", { method: "POST", body: { priceID } }); 261 return r.ok ? { ok: true, preview: r.json?.data || r.json } : { ok: false, error: r.json?.message || "Could not preview the change" }; 262} 263export async function changePlan(priceID) { 264 if (MOCK) return { ok: true }; 265 const r = await call("/subscriptions/change-plan", { method: "POST", body: { priceID } }); 266 return r.ok ? { ok: true } : { ok: false, error: r.json?.message || "Could not change the plan" }; 267} 268export async function invoices(subId) { 269 if (MOCK || !subId) return []; 270 const r = await call(`/subscriptions/${encodeURIComponent(subId)}/invoices`); 271 return Array.isArray(r.json?.data) ? r.json.data : Array.isArray(r.json) ? r.json : []; 272} 273/** Stripe billing portal â the server resolves OUR customer, no ids client-side. */ 274export async function openPortal() { 275 if (MOCK) return { ok: false, error: "mock" }; 276 const r = await call("/subscriptions/update-billing", { method: "POST", body: {} }); 277 const url = r.json?.data?.url || r.json?.url; 278 return url ? { ok: true, url } : { ok: false, error: r.json?.message || (r.status === 404 ? "No billing account yet â make a purchase first." : "Could not open the billing portal") }; 279} 280 281// ---- auth -------------------------------------------------------------------- 282export async function signUp(email, password) { 283 if (MOCK) return { ok: true }; 284 const r = await raw("/signUp/native", { method: "POST", body: { email, password, companyID: "" } }); 285 if (r.ok || r.status === 201) return { ok: true }; 286 return { ok: false, error: r.json?.message || "Could not create the account" }; 287} 288export async function logIn(email, password, rememberMe = true) { 289 if (MOCK) { mock.user = { email }; return { ok: true }; } 290 if (!LIVE) return { ok: false, error: "Sign-in only works on app.pictools.ai (this is a dev origin)." }; 291 const r = await raw("/login/native", { method: "POST", body: { email, password, rememberMe } }); 292 if (r.ok) return { ok: true }; 293 const msg = r.status === 404 || r.status === 401 294 ? "Wrong email or password â or the email hasn't been verified yet." 295 : (r.json?.message || "Login failed"); 296 return { ok: false, error: msg }; 297} 298export async function logOut() { 299 if (MOCK) { mock.user = null; return { ok: true }; } 300 // #51: clear ALL session providers, not just native. A native-only logout 301 // left the cognito/Google cookies behind, so a prior Google session revived 302 // the earlier identity on reload. Both are attempted; a provider that isn't 303 // enabled for this tenant just 4xxs and is ignored. The outcome is reported 304 // so the caller can confirm (rather than ignore) whether logout succeeded. 305 const settled = await Promise.allSettled([ 306 raw("/logout/native"), 307 raw("/logout/cognito", { method: "POST", body: {} }), 308 ]); 309 const ok = settled.some((r) => r.status === "fulfilled" && r.value?.ok); 310 if (!ok) console.warn("logOut: no provider confirmed a clean logout", settled); 311 return { ok }; 312} 313export async function verifySession() { 314 if (MOCK) return mock.user ? { authenticated: true, user: mock.user } : { authenticated: false }; 315 if (!LIVE) return { authenticated: false }; 316 const r = await call("/auth/verify"); 317 if (r.ok && r.json?.authenticated) return r.json; 318 return { authenticated: false }; 319}
320// Proactively renew the short-lived access token so a user action never races an 321// expired one â called on app resume from background and on a keep-warm timer. 322// One round-trip (vs verifyâ401ârefresh), single-flight, best-effort. 323export async function keepSessionWarm() { 324 if (MOCK || !LIVE) return true; 325 try { const rf = await refreshSession(); return !!(rf && (rf.ok || rf.status === 200)); } 326 catch { return false; } 327} 328export async function currentUser() { 329 if (MOCK) return mock.user; 330 const r = await call("/users/current"); 331 return r.ok ? (r.json?.data || r.json) : null; 332} 333export async function verifyEmail(token) { 334 if (MOCK) return { ok: true }; 335 const r = await raw(`/verify-email/native?token=${encodeURIComponent(token)}`, { method: "POST", body: {} }); 336 return r.ok ? { ok: true } : { ok: false, error: r.json?.message || "Verification failed" }; 337} 338// Google sign-in: full-page redirect through main-service's Cognito social flow. 339// Google â callback (sets the session cookies first-party on the API host) â 340// redirect to the app frontend's /auth/callback?success=⦠341export function googleLoginUrl(mode = "login") { 342 return `${API_BASE}/auth/cognito/social/${encodeURIComponent("google")}?mode=${mode === "signup" ? "signup" : "login"}&next=${encodeURIComponent("/")}`; 343} 344export async function passwordRecovery(email) { 345 if (MOCK) return { ok: true }; 346 const r = await raw("/login/native/password-recovery", { method: "POST", body: { email } }); 347 return r.ok ? { ok: true } : { ok: false, error: r.json?.message || "Could not send the reset email" }; 348} 349// Completes the reset started by the /forgot-password?token= email link. 350export async function passwordReset(token, password) { 351 if (MOCK) return { ok: true }; 352 const r = await raw("/login/native/password-reset", { method: "POST", body: { token, password } }); 353 return r.ok ? { ok: true } : { ok: false, error: r.json?.message || "Could not reset the password â the link may have expired." }; 354} 355 356// ---- credits ----------------------------------------------------------------- 357export async function credits() { 358 if (MOCK) return { total: mock.credits, plan: 0, purchased: mock.credits }; 359 const r = await call("/subscriptions/current"); 360 if (!r.ok) return null; 361 // Response is {data: [subscription DTOs]} â one per active subscription. 362 // Each DTO carries the combined balance in currentTokens; the plan/purchased 363 // split (tokens.{currentPlan,purchased}) is only present on raw docs, so we 364 // fall back to counting everything as one bucket when it's absent. 365 const raw = r.json?.data ?? r.json; 366 const subs = Array.isArray(raw) ? raw : raw && typeof raw === "object" ? [raw] : []; 367 let total = 0, plan = 0, purchased = 0, split = false; 368 for (const s of subs) { 369 total += s.currentTokens ?? ((s.tokens?.currentPlan ?? 0) + (s.tokens?.purchased ?? 0)); 370 if (s.tokens) { split = true; plan += s.tokens.currentPlan ?? 0; purchased += s.tokens.purchased ?? 0; } 371 } 372 if (!split) purchased = total; 373 return { total, plan, purchased }; 374} 375 376// ---- AI catalog + generation (pictools-service via the authed gateway) -------- 377const GW = "/gateway/pictools-service"; 378// Per-app-load session id â sent on every generation so server-side moderation 379// logs a user's attempts together (a blocked prompt then a reworded one that 380// passes is the evasion signal we want to see). 381const SESSION_ID = (() => { 382 try { if (crypto?.randomUUID) return crypto.randomUUID(); } catch { /* older browser */ } 383 return "s-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10); 384})(); 385const PUBGW = "/public-gateway/pictools-service"; 386let catalogCache = null; 387export async function aiCatalog() { 388 if (MOCK) return mock.catalog; 389 if (catalogCache) return catalogCache; 390 // Dev origins have no /api proxy â the public catalog is wildcard-CORS, hit it direct. 391 let r = LIVE 392 ? await raw(`${PUBGW}/catalog`, { anonymous: true }).catch(() => ({ ok: false })) 393 : { ok: false }; 394 if (!r.ok || !r.json?.effects) { 395 const res = await fetch(`https://main-service.pictools.vernalabs.net/v1/pictools${PUBGW}/catalog`); 396 if (!res.ok) throw new Error("catalog unavailable"); 397 r = { ok: true, json: await res.json() }; 398 } 399 catalogCache = r.json; 400 return catalogCache; 401} 402// ---- usage telemetry (segmentation) ------------------------------------------- 403// The app batches coarse counters in memory and flushes here (60s / tab-hide / 404// first interaction). Beacon on hide so backgrounded mobile sessions land. 405export function activityFlush(payload, viaBeacon) { 406 if (MOCK) { try { (window.__mockActivity = window.__mockActivity || []).push(payload); } catch { /* */ } return; } 407 const path = `${GW}/activity`; 408 if (viaBeacon && navigator.sendBeacon) { 409 navigator.sendBeacon(API_BASE + path, new Blob([JSON.stringify(payload)], { type: "application/json" })); 410 return; 411 } 412 raw(path, { method: "POST", body: payload }).catch(() => undefined); 413} 414 415// ---- bridge funnel telemetry -------------------------------------------------- 416// The marketing side mints a per-handoff id (bridge `source` â ?src=). The app 417// reports what became of that handoff â landing, generation, download, error â 418// so staged rollouts can be measured end to end (BRIDGE.md "Funnel events"). 419// Anonymous fire-and-forget: an opaque id + an event name, sent ONLY when a 420// src id exists (organic traffic reports nothing). keepalive survives the 421// navigation right after a download tap. 422// Conversion-funnel events fire for EVERY visitor (src defaults to "app" for 423// direct/returning users, so paywallâcheckoutâpurchase is measurable); bridge 424// handoff events (generate/download/â¦) still require a real ?src= attribution. 425const FUNNEL_EVENTS = new Set(["paywall_shown", "paywall_dismiss", "checkout_start", "purchase", "checkout_abandoned", "checkout_resumed", "checkout_resume_dismissed", "signup", "upsell_dismissed", "export_free_watermarked", "export_paid_selected"]); 426export function bridgeTrack(event, meta) { 427 try { 428 const att = JSON.parse(sessionStorage.getItem("pictools-attribution") || "{}"); 429 const src = att.src || (FUNNEL_EVENTS.has(event) ? "app" : null); 430 if (!src) return; 431 fetch(API_BASE + "/public-gateway/pictools-service/catalog/bridge-events", { 432 method: "POST", 433 keepalive: true, 434 headers: { "Content-Type": "application/json" }, 435 // bv = bridge version (the bridge stamps ?bv=N): keeps fallback rates 436 // attributable per bridge release once partners pin cached versions. 437 body: JSON.stringify({ src, event, meta: { ...(att.bv ? { bv: att.bv } : {}), ...(meta || {}) } }), 438 }).catch(() => undefined); 439 } catch { /* storage off */ } 440} 441 442// One shape for every generation outcome: {ok, imageUrl?, error: 'no_credits'|'auth'|'failed', detail?} 443function genResult(r) { 444 // savedId + watermarked ride along so the client can stamp the server 445 // generation id (genId) and entitlement onto the local record â the key that 446 // later resolves the CLEAN original and prevents a double watermark (57/63). 447 if (r.ok && r.json?.imageUrl) return { ok: true, imageUrl: r.json.imageUrl, model: r.json.model, savedId: r.json.savedId, watermarked: r.json.watermarked }; 448 if (r.status === 402) return { ok: false, error: "no_credits" }; 449 if (r.status === 401 || r.status === 403) return { ok: false, error: "auth" }; 450 // Content moderation block (no charge, no provider call). The shared gateway
451 // COLLAPSES downstream error bodies to a single string, so pictools-service's 452 // 422 { error:'blocked_content', details, suggestions } arrives here as 453 // { statusCode:422, message:'blocked_content' } â details/suggestions dropped 454 // and `error` renamed to `message`. Detect the token wherever it lands and 455 // rebuild a friendly block with client-side defaults so the popup always fires. 456 const bodyStr = [r.json?.error, r.json?.message, r.json?.details, typeof r.json === "string" ? r.json : ""].map((v) => (typeof v === "string" ? v : "")).join(" "); 457 if (r.json?.error === "blocked_content" || (r.status === 422 && /blocked_content/i.test(bodyStr))) { 458 return { ok: false, error: "blocked", category: r.json?.category, 459 detail: (typeof r.json?.details === "string" && r.json.details) || "Pictools doesn't create nudity or sexual content.", 460 suggestions: (Array.isArray(r.json?.suggestions) && r.json.suggestions.length) ? r.json.suggestions 461 : ["Change the outfit or clothing style", "Change hair colour", "Change the background or scene"] }; 462 } 463 return { ok: false, error: "failed", detail: r.json?.details || r.json?.message || `HTTP ${r.status}` }; 464} 465export async function aiApply(filterId, imageOrImages, opts = {}) { 466 if (MOCK) { const b = mockModerate(opts.userAdditions); if (b) return b; mockLastApply.quality = opts.quality || null; mockLastApply.filterId = filterId; mockLastApply.identityId = opts.identityId || null; mockLastApply.imageCount = Array.isArray(imageOrImages) ? imageOrImages.filter(Boolean).length : (imageOrImages ? 1 : 0); return trackGen(await mock.generate(), { kind: "filter", filterId }); } 467 const imgs = (Array.isArray(imageOrImages) ? imageOrImages : [imageOrImages]).filter(Boolean).slice(0, 4); 468 const body = { filterId }; 469 if (imgs.length) body.image = imgs[0]; // absent on identity-only apply â the server fills from the identity 470 if (imgs.length > 1) body.images = imgs; // multi-photo filters (inputCount > 1) 471 if (opts.identityId) body.digitalIdentityId = opts.identityId; 472 if (opts.quality) body.quality = opts.quality; // STD/HIGH/PRO tier â prices + routes the model 473 if (opts.userAdditions) body.userAdditions = opts.userAdditions; 474 if (opts.aspectRatio) body.aspectRatio = opts.aspectRatio; 475 body.sessionId = SESSION_ID; 476 return trackGen(genResult(await call(`${GW}/apply`, { method: "POST", body })), { kind: "filter", filterId }); 477} 478// Mock mirror of the server-side content gate (for the smoke battery): blocks 479// obvious NSFW so the block UX + no-charge/no-save path can be tested offline. 480function mockModerate(text) { 481 if (!text || !/\b(nude|naked|nsfw|topless|porn|desnud\w*|desvist\w*)\b/i.test(text.normalize("NFD").replace(/[Ì-ͯ]/g, ""))) return null; 482 return { ok: false, error: "blocked", category: "sexual", detail: "Pictools doesn't create nudity or sexual content. Try changing the outfit, hair colour, or background instead.", suggestions: ["Change the outfit or clothing style", "Change hair colour", "Change the background or scene"] }; 483} 484// Funnel: every generation outcome reports against the handoff id (no-op without one). 485function trackGen(res, meta) { 486 bridgeTrack(res.ok ? "generate" : "error", res.ok ? meta : { stage: "generate", code: res.error, ...meta }); 487 return res; 488} 489export async function aiGenerate(instruction, imageOrImages, opts = {}) { 490 if (MOCK) { const b = mockModerate(instruction); if (b) return b; return trackGen(await mock.generate(), { kind: "prompt" }); } 491 const imgs = (Array.isArray(imageOrImages) ? imageOrImages : [imageOrImages]).filter(Boolean); 492 // No sources at all = pure text-to-image (image-service routes it to the 493 // text-to-image PromptLab prompt). 494 const body = imgs.length > 1 ? { instruction, images: imgs.slice(0, 4) } 495 : imgs.length === 1 ? { instruction, image: imgs[0] } 496 : { instruction }; 497 if (opts.identityId) body.digitalIdentityId = opts.identityId; 498 if (opts.quality) body.quality = opts.quality; // stripped server-side today; future generate tiers 499 body.sessionId = SESSION_ID; 500 return trackGen(genResult(await call(`${GW}/generate`, { method: "POST", body })), { kind: "prompt" }); 501} 502 503// ---- Prompt optimizer ("Optimize" pill) -------------------------------------- 504// The backend (pictools-service â image-service â vernaONE) rewrites the user's 505// raw prompt into a stronger image prompt. FREE, but the endpoint is authed and 506// credit-gated server-side, so the outcome maps to the SAME {ok, error} shape as 507// generation: 402âno_credits, 401/403âauth, 422 blocked_contentâblocked. 508function optimizeResult(r) { 509 if (r.ok && typeof r.json?.prompt === "string" && r.json.prompt.trim()) return { ok: true, prompt: r.json.prompt.trim() }; 510 if (r.status === 402) return { ok: false, error: "no_credits" }; 511 if (r.status === 401 || r.status === 403) return { ok: false, error: "auth" }; 512 const bodyStr = [r.json?.error, r.json?.message, r.json?.details, typeof r.json === "string" ? r.json : ""].map((v) => (typeof v === "string" ? v : "")).join(" "); 513 if (r.json?.error === "blocked_content" || (r.status === 422 && /blocked_content/i.test(bodyStr))) { 514 return { ok: false, error: "blocked", category: r.json?.category, 515 detail: (typeof r.json?.details === "string" && r.json.details) || "Pictools can't optimize prompts for nudity, sexual, or illegal content.", 516 suggestions: (Array.isArray(r.json?.suggestions) && r.json.suggestions.length) ? r.json.suggestions 517 : ["Describe the subject, scene, and style", "Add lighting and mood", "Keep it appropriate for all audiences"] }; 518 } 519 return { ok: false, error: "failed", detail: r.json?.details || r.json?.message || `HTTP ${r.status}` }; 520} 521// Deterministic offline "improvement" so the pill + smoke battery work without a 522// backend (?mockapi=1). Still runs the mock content gate first. 523function mockOptimize(t) { 524 const base = t.replace(/\s+/g, " ").trim().replace(/[.,;\s]+$/, ""); 525 return `${base}, highly detailed, cinematic lighting, sharp focus, rich colors, professional photography, 4k`; 526} 527export async function optimizePrompt(text, opts = {}) { 528 const t = (text || "").trim(); 529 if (!t) return { ok: false, error: "failed", detail: "empty" }; 530 if (MOCK) { const b = mockModerate(t); if (b) return b; return { ok: true, prompt: mockOptimize(t) }; } 531 const body = { text: t }; 532 if (opts.quality) body.quality = opts.quality; 533 body.sessionId = SESSION_ID; 534 return optimizeResult(await call(`${GW}/prompt/optimize`, { method: "POST", body })); 535} 536// ---- server-side generation history ("My generations" â roams across devices) 537export async function history(limit = 60) { 538 if (MOCK) return mockList(); 539 const r = await call(`${GW}/images?limit=${limit}`); 540 const list = Array.isArray(r.json) ? r.json : r.json?.data; 541 // null = FAILURE â callers must not treat it as "no images" (the sync 542 // engine's deletion sweep would eat the local library). 543 return r.ok && Array.isArray(list) ? list : null; 544} 545// Mock cloud history â PER USER, like the real server (owner isolation tests
546// depend on it). mockSeedHistory() stages a list any next-logged-in user gets. 547const mockHistory = { byUser: {}, seed: null }; 548function mockList() { 549 const key = mock.user?.email || "__anon"; 550 if (!mockHistory.byUser[key]) mockHistory.byUser[key] = mockHistory.seed ? [...mockHistory.seed] : []; 551 return mockHistory.byUser[key]; 552} 553export function mockSeedHistory(items) { if (MOCK) { mockHistory.seed = items; mockHistory.byUser = {}; } } 554/** Test hook: set the mock balance (e.g. 0 to exercise the no-credits gate). */ 555export function mockSetCredits(n) { if (MOCK) mock.credits = n; } 556/** Device-library sync: upsert one local image into the account (idempotent by clientId). */ 557export async function imageSync(payload) { 558 if (MOCK) { 559 const list = mockList(); 560 const i = list.findIndex((g) => g.clientId === payload.clientId); 561 const rec = { id: i >= 0 ? list[i].id : "sync_" + payload.clientId, kind: "device", clientId: payload.clientId, name: payload.name, thumb: payload.thumb, clientUpdated: payload.clientUpdated, _data: payload.image }; 562 if (i >= 0) list[i] = rec; else list.unshift(rec); 563 return { id: rec.id }; 564 } 565 const r = await call(`${GW}/images/sync`, { method: "POST", body: payload }); 566 return r.ok ? r.json : null; 567} 568/** Full stored image for a synced record (mongo-backed). */ 569export async function imageFile(id) { 570 if (MOCK) return mockList().find((g) => String(g.id) === String(id))?._data || null; 571 const r = await call(`${GW}/images/${id}/file`); 572 return r.ok ? r.json?.image || null : null; 573} 574// #57/63: the CLEAN original of a result, resolved only after the server's 575// entitlement check (any purchase â clean forever). Returns the image (URL or 576// data URI) or null when the caller is not entitled / no clean exists (402/404). 577export async function imageFileClean(id) { 578 if (MOCK) return mockList().find((g) => String(g.id) === String(id))?._data || null; 579 const r = await call(`${GW}/images/${id}/clean`); 580 return r.ok ? r.json?.image || null : null; 581} 582// 87/64: confirm a checkout from the ACTUAL Stripe session, account-scoped. 583// Returns { found, paid, fulfilled, status, credits } or null when the session 584// can't be confirmed. NEVER infer payment from a wallet delta or return URL. 585export async function confirmCheckout(sessionId) { 586 if (MOCK) return { found: true, paid: true, fulfilled: true, status: "fulfilled" }; 587 if (!sessionId) return null; 588 const r = await call(`/subscriptions/checkout-session/${encodeURIComponent(sessionId)}`); 589 return r.ok ? r.json?.data || null : null; 590} 591/** Delete one cloud generation from the account's My Images (permanent). */ 592// Full history for the gallery strip: pages of 100 (the server clamp) up to 593// `max`. Returns what it got on a mid-flight failure, null only if page 1 fails. 594export async function historyAll(max = 500) { 595 if (MOCK) return mockList(); 596 const out = []; 597 for (let skip = 0; out.length < max; skip += 100) { 598 const r = await call(`${GW}/images?limit=100&skip=${skip}`); 599 const list = Array.isArray(r.json) ? r.json : r.json?.data; 600 if (!r.ok || !Array.isArray(list)) return out.length ? out : null; 601 out.push(...list); 602 if (list.length < 100) break; 603 } 604 return out; 605} 606export async function historyDelete(id) { 607 if (MOCK) { const key = mock.user?.email || "__anon"; mockHistory.byUser[key] = mockList().filter((g) => String(g.id || g._id) !== String(id)); return { deleted: true }; } 608 const r = await call(`${GW}/images/${id}`, { method: "DELETE" }); 609 return r.ok ? r.json : null; 610} 611 612// ---- Digital Identity (Level 1 â reference identity) ------------------------- 613// The backend resolves identities for generation; the app only passes ids. 614const mockIdentity = { list: [] }; 615export const mockLastApply = { quality: null }; 616// Test hook: ?mockwarn=different_person|no_face flags every mock upload (L2 face 617// check). Captured at load â SPA pushState (/identities) drops the query string. 618const MOCKWARN = new URLSearchParams(location.search).get("mockwarn"); 619function mockQuality(idn) { 620 const n = idn.refs.length; 621 const types = new Set(idn.refs.map((r) => r.type));
622 let score = Math.min(n * 10, 30) + (types.has("FACE_FRONT") ? 14 : 0) + 12 623 + (types.has("SMILE_SUBTLE") ? 6 : 0) + (types.has("SMILE_TEETH") ? 6 : 0); 624 const factors = []; 625 if (n) factors.push(`${n} reference photos`); 626 if (types.has("FACE_FRONT")) factors.push("Clear frontal face"); 627 if (types.has("SMILE_SUBTLE") && types.has("SMILE_TEETH")) factors.push("Good expression diversity"); 628 const suggestions = []; 629 if (!types.has("FACE_FRONT")) suggestions.push({ label: "Add a clear frontal photo", points: 14 }); 630 if (!types.has("SMILE_SUBTLE")) suggestions.push({ label: "Add a subtle smile photo", points: 6 }); 631 if (!types.has("SMILE_TEETH")) suggestions.push({ label: "Add a big smile (teeth) photo", points: 6 }); 632 if (!types.has("FULL_BODY")) suggestions.push({ label: "Add a full-body photograph", points: 8 }); 633 idn.qualityScore = Math.min(100, score); 634 idn.status = n >= 2 ? "READY" : "DRAFT"; 635 idn.qualityDetail = { factors, suggestions }; 636 // Mirror the server's silent L1âL2 upgrade: READY identities become EMBEDDING ("enhanced"). 637 idn.type = n >= 2 ? "EMBEDDING" : idn.type; 638 idn.enhanced = idn.type === "EMBEDDING"; 639 return { score: idn.qualityScore, factors, suggestions, status: idn.status }; 640} 641export async function identities() { 642 if (MOCK) return mockIdentity.list.map((i) => ({ ...i, referenceCount: i.refs.length, refs: undefined })); 643 const r = await call(`${GW}/digital-identities`); 644 // null = FAILURE (auth/network) â the UI must retry, never show the empty 645 // "create your identity" state over an error (duplicate-identity risk). 646 return r.ok && Array.isArray(r.json) ? r.json : null; 647} 648export async function identityCreate(name) { 649 if (MOCK) { 650 // Multiple identities are Pro: the mock grants Pro to emails starting "pro". 651 const pro = (mock.user?.email || "").startsWith("pro"); 652 if (mockIdentity.list.length >= (pro ? 5 : 1)) return { error: "pro_required: Multiple Digital Identities are a Pro feature." }; 653 const idn = { id: "di_" + Date.now() + Math.random().toString(36).slice(2, 5), name: name || "Me", type: "REFERENCE", status: "DRAFT", isDefault: mockIdentity.list.length === 0, qualityScore: 0, refs: [] }; 654 mockIdentity.list.push(idn); 655 return { ...idn }; 656 } 657 const r = await call(`${GW}/digital-identities`, { method: "POST", body: { name } }); 658 return r.ok ? r.json : { error: r.json?.message || "Could not create the identity" }; 659} 660export async function identityGet(id) { 661 if (MOCK) { 662 const i = mockIdentity.list.find((x) => x.id === id); 663 return i ? { ...i, avatarSetCost: 6, references: i.refs.map((r) => ({ ...r, data: undefined })) } : null; 664 } 665 const r = await call(`${GW}/digital-identities/${id}`); 666 return r.ok ? r.json : null; 667} 668export async function identityPatch(id, body) { 669 if (MOCK) { 670 const i = mockIdentity.list.find((x) => x.id === id); 671 if (i && body.isDefault) { mockIdentity.list.forEach((x) => (x.isDefault = false)); i.isDefault = true; } 672 if (i && body.name) i.name = body.name; 673 return i ? { ...i, refs: undefined } : null; 674 } 675 const r = await call(`${GW}/digital-identities/${id}`, { method: "PATCH", body }); 676 return r.ok ? r.json : null; 677} 678export async function identityDelete(id) { 679 if (MOCK) { mockIdentity.list = mockIdentity.list.filter((x) => x.id !== id); return { deleted: true }; } 680 const r = await call(`${GW}/digital-identities/${id}`, { method: "DELETE" }); 681 return r.ok ? r.json : null; 682} 683export async function identityAddRef(id, payload) { 684 if (MOCK) { 685 const i = mockIdentity.list.find((x) => x.id === id); 686 const ref = { id: "ref_" + Date.now() + Math.random().toString(36).slice(2, 6), type: payload.type || "OTHER", data: payload.image, width: payload.width, height: payload.height, status: "ACCEPTED" }; 687 if (MOCKWARN) ref.faceWarning = MOCKWARN; 688 i.refs.push(ref); mockQuality(i); 689 return { ...ref, data: undefined }; 690 } 691 const r = await call(`${GW}/digital-identities/${id}/references`, { method: "POST", body: payload }); 692 return r.ok ? r.json : { error: r.json?.message || "Upload failed" }; 693} 694/** Start avatar-set generation (charged â the app shows the cost BEFORE calling). */ 695export async function identityAvatarSet(id) { 696 if (MOCK) { 697 const i = mockIdentity.list.find((x) => x.id === id); 698 if (!i) return { error: "Identity not found" }; 699 const poses = ["front", "three-quarter-left", "three-quarter-right", "smile", "big-smile", "upper-body"]; 700 i.avatarSet = { status: "PROCESSING", version: (i.avatarSet?.version || 0) + 1, items: [], progress: { done: 0, total: poses.length, pose: poses[0] } }; 701 let step = 0; 702 const tick = setInterval(() => { 703 step++; 704 if (step >= poses.length) { 705 clearInterval(tick); 706 const face = i.refs[0]?.data; 707 i.avatarSet = { status: "READY", version: i.avatarSet.version, items: poses.map((p
707ose) => ({ pose, imageUrl: face, cosine: 0.82 })) }; 708 } else { 709 i.avatarSet.progress = { done: step, total: poses.length, pose: poses[step] }; 710 } 711 }, 1500); 712 return { status: "PROCESSING", cost: 6 }; 713 } 714 const r = await call(`${GW}/digital-identities/${id}/avatar-set`, { method: "POST" }); 715 return r.ok ? r.json : { error: r.json?.error === "no_credits" ? "no_credits" : r.json?.message || "Could not start the avatar set" }; 716} 717/** Upload an own avatar (free, face-verified server-side). */ 718export async function identityAvatarUpload(id, payload) { 719 if (MOCK) { 720 const i = mockIdentity.list.find((x) => x.id === id); 721 if (!i) return { error: "Identity not found" }; 722 if (MOCKWARN) return { error: MOCKWARN === "no_face" ? "no_face: No clear face found in that image." : "different_person: That image does not look like this identity." }; 723 const aset = i.avatarSet?.status === "READY" ? i.avatarSet : { status: "READY", version: 1, items: [] }; 724 const pose = "custom" + (aset.items.filter((x) => x.pose.startsWith("custom")).length + 1 || ""); 725 aset.items = [...aset.items, { pose, imageUrl: payload.image, cosine: 0.9, uploaded: true }]; 726 i.avatarSet = aset; 727 return aset.items[aset.items.length - 1]; 728 } 729 const r = await call(`${GW}/digital-identities/${id}/avatar-set/items`, { method: "POST", body: payload }); 730 return r.ok ? r.json : { error: r.json?.message || "Upload failed" }; 731} 732export async function identityAvatarItemDelete(id, pose) { 733 if (MOCK) { 734 const i = mockIdentity.list.find((x) => x.id === id); 735 if (i?.avatarSet) { i.avatarSet.items = i.avatarSet.items.filter((x) => x.pose !== pose); if (!i.avatarSet.items.length) delete i.avatarSet; } 736 return { deleted: true }; 737 } 738 const r = await call(`${GW}/digital-identities/${id}/avatar-set/items/${encodeURIComponent(pose)}`, { method: "DELETE" }); 739 return r.ok ? r.json : null; 740} 741export async function identityAvatarSetDelete(id) { 742 if (MOCK) { const i = mockIdentity.list.find((x) => x.id === id); if (i) delete i.avatarSet; return { deleted: true }; } 743 const r = await call(`${GW}/digital-identities/${id}/avatar-set`, { method: "DELETE" }); 744 return r.ok ? r.json : null; 745} 746export async function identityPatchRef(id, refId, body) { 747 if (MOCK) { const i = mockIdentity.list.find((x) => x.id === id); const ref = i?.refs.find((r) => r.id === refId); if (ref && body.type) ref.type = body.type; mockQuality(i); return ref; } 748 const r = await call(`${GW}/digital-identities/${id}/references/${refId}`, { method: "PATCH", body }); 749 return r.ok ? r.json : null; 750} 751export async function identityDeleteRef(id, refId) { 752 if (MOCK) { const i = mockIdentity.list.find((x) => x.id === id); i.refs = i.refs.filter((r) => r.id !== refId); mockQuality(i); return { deleted: true }; } 753 const r = await call(`${GW}/digital-identities/${id}/references/${refId}`, { method: "DELETE" }); 754 return r.ok ? r.json : null; 755} 756export async function identityRefImage(id, refId) { 757 if (MOCK) { const i = mockIdentity.list.find((x) => x.id === id); return i?.refs.find((r) => r.id === refId)?.data || null; } 758 const r = await call(`${GW}/digital-identities/${id}/references/${refId}/image`); 759 return r.ok ? r.json?.image || null : null; 760} 761export async function identityQuality(id) { 762 if (MOCK) { const i = mockIdentity.list.find((x) => x.id === id); return i ? mockQuality(i) : null; } 763 const r = await call(`${GW}/digital-identities/${id}/quality`); 764 return r.ok ? r.json : null; 765} 766 767// ---- favorites (signed-in users; the app unions with device storage) --------- 768export async function favorites() { 769 if (MOCK) return { slugs: mock.favs }; 770 const r = await call(`${GW}/favorites`); 771 return r.ok && r.json ? r.json : { slugs: [] }; 772} 773export async function saveFavorites(slugs) { 774 if (MOCK) { mock.favs = slugs.slice(); return { slugs: mock.favs }; } 775 const r = await call(`${GW}/favorites`, { method: "PUT", body: { slugs } }); 776 return r.ok && r.json ? r.json : { slugs }; 777} 778 779export async function aiRemoveBackground(imageDataUri) { 780 if (MOCK) return mock.generate(); 781 return genResult(await call(`${GW}/remove-background`, { method: "POST", body: { image: imageDataUri } })); 782} 783 784// ---- mock backend (?mockapi=1) ------------------------------------------------- 785const mock = { 786 user: null, 787 credits: 3, 788 favs: [], 789 catalog: { 790 version: 1, 791 categories: [ 792 { key: "ai-style", label: "Style", group: "AI", order: 1, count: 3 }, 793 { key: "ai-fun", label: "Fun", group: "AI", order: 2, count: 2 }, 794 ], 795 effects: [ 796 { id: "filter-studio-ghibli", kind: "ai-filter", category: "ai-style", label: "Studio Ghibli", thumbnailUrl: "assets/sample.jpg", request: { method: "POST", path: "/v1/apply", imageField: "image", body: { filterId: "studio-ghibli" } } }, 797 { id: "filter-anime", kind: "ai-filter", category: "ai-style", label: "Anime", thumbnailUrl: "assets/sample.jpg", request: { method: "POST", path: "/v1/apply", imageField: "image", body: { filterId: "anime" } } }, 798 { id: "filter-pixar", kind: "ai-filter", category: "ai-style", label: "Pixar", cost: 2, inputCount: 3, thumbnailUrl: "assets/sample.jpg", request: { method: "POST", path: "/v1/apply", imageField: "images", multiInput: true, body: { filterId: "pixar" } } }, 799 { id: "filter-lego", kind: "ai-filter", category: "ai-fun", label: "LEGO", cost: 0, description: "Turn your photo into colorful plastic brick minifigures", thumbnailUrl: "assets/sample.jpg", request: { method: "POST", path: "/v1/apply", imageField: "image", body: { filterId: "lego" } } }, 800 { id: "filter-barbie", kind: "ai-filter", category: "ai-fun", label: "Barbie", quality: "high", thumbnailUrl: "assets/sample.jpg", request: { method: "POST", path: "/v1/apply", imageField: "image", body: { filterId: "barbie" } } }, 801 ], 802 }, 803 async generate() { 804 await new Promise((r) => setTimeout(r, 1200)); 805 if (!mock.user) return { ok: false, error: "auth" }; 806 if (mock.credits <= 0) return { ok: false, error: "no_credits" }; 807 mock.credits -= 1; 808 return { ok: true, imageUrl: "assets/sample.jpg", model: "mock" }; 809 }, 810};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.