PageSourceSearch

https://app.pictools.ai/api.js?v=42

js pictools.ai collected 2026-10-05 12:24:36 UTC 47,514 bytes, 810 lines download raw bytes

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.