/** * Image database: 1,222 Vietnamese interior-design photographs, encoded offline with * CLIP-ViT-B-32 and stored as raw little-endian float32 in 512-d rows. */ export const DATASET = 'https://huggingface.co/datasets/bep40/interior-image-field-assets/resolve/main/'; /** * Text tower. `sentence-transformers/clip-ViT-B-32-multilingual-v1` maps 50+ * languages (including Vietnamese) into the same 512-d space as CLIP-ViT-B-32, * so a Vietnamese query retrieves the matching interior photograph. The stock * repository ships a SentenceTransformer wrapper whose ONNX export stops at * 768-d `last_hidden_state`, so this fork adds a `text_model.onnx` that * includes mean pooling and the 768->512 projection, letting Transformers.js * run it under the usual CLIP file naming. */ export const TEXT_MODEL = 'bep40/clip-vit-b32-multilingual-vi-text'; /** * Pinned to the commit whose `onnx/text_model.onnx` actually loads, instead of * following the `main` branch. * * The repo uploaded a broken export first (commit 68dc949, 136,951,792 bytes). * Its graph declares `masked` as [batch_size, sequence_length, 768] while an * attention mask only ever has 1 feature, so ONNX shape inference aborts. A * later commit (9e1d535, 136,915,522 bytes) re-exported the graph with no * `value_info` at all, which loads fine -- that is the revision pinned here. * * Pinning matters beyond reproducibility: transformers.js requests the * *unversioned* /resolve/main/ URL unless `revision` is given, and caches under * that same URL. Naming an explicit commit changes the cache key, so visitors * who cached the broken graph fetch the known-good file instead. */ export const TEXT_MODEL_REVISION = '9e1d53553228b17abfe6986fbe92e22bd6bd6bad'; export const TOKENIZER = 'sentence-transformers/clip-ViT-B-32-multilingual-v1'; export const DIMENSIONS = 512; /** The URL of an interior photograph in the dataset repo, at a CDN width. */ export function imageURL(image, width = 360) { return `${DATASET}images/${image.url}?w=${width}&q=80`; } /** * The thumbnail URL for a tile, whichever kind of record it holds. * * Interior photographs are files in the dataset repo, so they go through * imageURL and its CDN resize. A Malloca product points at a third-party shop * CDN that this Space knows nothing about -- there is no filename to build a * path from and no query string it would honour -- so its own URL is used * verbatim. * * Dispatch is on `src`, which merge.js sets only for products, rather than on * the presence of `url`: an interior record always has `url`, and keying on the * absence of a field would mis-classify a record that is merely malformed. */ export function tileURL(image, width) { return image?.src ? image.src : imageURL(image, width); } /** Cache is an optimization: unavailable storage never prevents a search. */ export async function cachedFile(url, onProgress) { let cache; try { cache = await caches.open('interior-image-field-v1'); const cached = await cache.match(url); if (cached) return cached.arrayBuffer(); } catch { /* Private browsing / storage disabled. */ } const response = await fetch(url); if (!response.ok) throw new Error(`Could not download image database (${response.status}).`); const total = Number(response.headers.get('content-length')); let buffer; if (response.body && onProgress) { const reader = response.body.getReader(); const chunks = []; let loaded = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; onProgress(loaded, total); } const joined = new Uint8Array(loaded); let offset = 0; for (const chunk of chunks) { joined.set(chunk, offset); offset += chunk.length; } buffer = joined.buffer; } else buffer = await response.arrayBuffer(); try { await cache?.put(url, new Response(buffer)); } catch { /* Cache quota reached. */ } return buffer; } /** * The V.AI STUDIO share page for whatever is currently open in the viewer. * * Both routes live on the vai-avatar Space, which serves them server-side with * OpenGraph tags so a pasted link becomes a card in Facebook or Zalo rather * than a bare page: * * /share/i/ interior photos (the gallery that image came from) * /share/p/ products (name, price and gallery) * * The product route did not exist until it was added there; before that the * only product-ish URL was `?product=`, which is an OG-tag handler and * renders nothing but the chat page. `/share/p/` resolves a sku, a model code or * a slug against the same catalogue the Space already loads, so all 3,966 * identity forms in it resolve. * * Returns null when there is no id to share, so the caller can omit the button * rather than render a link that would 404. */ const VAI_STUDIO = 'https://bep40-vai-avatar.hf.space'; /** * The prefix avatar-interiors.js puts on a vai-avatar photograph's `sku`. * * Namespacing it there is deliberate -- it is what stops a 16-hex image_id * colliding with a baked interior's id in merge.js's de-duplication set, and it * is what lets a photograph travel the same code path as a catalogue product * (same viewer, same panel, same neon, same share anchor). * * It is NOT a catalogue identity, and /share/p/ resolves against the product * catalogue -- so a photograph sent through that route answers "Not Found". * Nothing about that is fixable on vai-avatar's side: no SKU of that shape * exists there to find. It has to be routed as the interior it is. */ const AVATAR_SKU_PREFIX = 'avatarimg:'; /** * Whether this record is an interior photograph published on vai-avatar. * * Either field is conclusive on its own. `avatarImageId` is set on every such * photograph; the namespaced `sku` covers a record that has lost it. */ function isAvatarInterior(photo) { return Boolean(photo.avatarImageId) || String(photo.sku ?? '').startsWith(AVATAR_SKU_PREFIX); } /** * The interior id behind a record, or '' when it has none. * * Two sources, in order, and the second is a fallback rather than an * alternative: `avatarImageId` is the field avatar-interiors.js sets, present on * every vai-avatar photograph. But a record can reach shareURL() having lost it * -- merge.js rewrites `id` into a namespaced tile key, and any path that * rebuilds a record from its `sku` alone would carry the prefix but not the * original field. That is exactly the shape that produced * /share/p/avatarimg%3Ab68dc779370dc70e, which is a "Not Found" page for every * single photograph in the vai-avatar interior set. * * So the namespaced sku is unwrapped as well, and a prefix that is present but * empty (`avatarimg:` with nothing after it) yields ''. */ function interiorIdOf(photo) { const direct = String(photo.avatarImageId ?? '').trim(); if (direct) return direct; const sku = String(photo.sku ?? '').trim(); return sku.startsWith(AVATAR_SKU_PREFIX) ? sku.slice(AVATAR_SKU_PREFIX.length).trim() : ''; } export function shareURL(photo) { if (!photo || typeof photo !== 'object') return null; /** * A photograph published on vai-avatar carries BOTH `avatarImageId` and a * namespaced `sku` of `avatarimg:`. * * `sku` exists so the record travels the same path as a catalogue product -- * same viewer, same detail panel, same neon, same share anchor -- and it has * to stay for that. But it is not an identity the product catalogue knows, * and it must not decide the URL: `/share/p/` resolves against the product * catalogue, so every such photograph sent through it answered "Not Found". * The whole vai-avatar interior set was affected, on the tile's share button * and on the viewer's "Xem trang" alike. * * The interior identity is therefore resolved first and unconditionally, and * /share/i/ gets the raw id -- no prefix to strip, and nothing to keep in * step with whatever namespace merge.js uses for its tile ids. */ const interiorId = interiorIdOf(photo); if (interiorId) return `${VAI_STUDIO}/share/i/${encodeURIComponent(interiorId)}`; /** * A record that still says it is an interior but carries no usable id gets NO * share URL, rather than falling through to the product branch below. * * That fall-through is a trap worth naming, because it is exactly how the bug * comes back. `avatarimg:` with nothing after it has no id to share, and every * remaining branch here would still produce a URL for it: the product branch * would encode the prefix itself into `/share/p/avatarimg%3A`, which is the * dead route all over again, and the id branch would send it to `/share/i/` * with an empty segment, which 404s in its own right. Returning null lets the * caller omit the button instead, which is the only outcome that is true. */ if (isAvatarInterior(photo)) return null; if (photo.sku || photo.slug) { // slug first, then sku, then mod. A shared URL is a permanent, public, // indexable address and the one thing about a product a reader can see: // `MH800TC` says nothing about what is linked, while // `mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm` reads as the product's name // to a person and to a crawler. The slug is already the canonical key this // Space's catalogue uses, and vai-avatar's /share/p/ matches on it first, so // this changes how the link reads, not whether it resolves. const id = String(photo.slug || photo.sku || photo.mod || '').trim(); return id ? `${VAI_STUDIO}/share/p/${encodeURIComponent(id)}` : null; } const id = String(photo.id ?? '').trim(); return id ? `${VAI_STUDIO}/share/i/${encodeURIComponent(id)}` : null; }