Spaces:
Running
Running
Check SKU mapping before loading CLIP, not after embedding 6k photos
#2
by bep40 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .gitattributes +0 -13
- .gitignore +0 -20
- PLACEHOLDER +0 -1
- README.md +37 -309
- app.html +0 -28
- dist/app.html +0 -29
- dist/assets/app.css +0 -2
- dist/assets/index-CX-0QtoD.js +0 -0
- dist/assets/index-CigEzVrZ.js +0 -0
- dist/assets/index-DGvjonwp.js +0 -0
- dist/assets/index-DcLscXnL.css +2 -0
- dist/assets/index.js +0 -0
- dist/assets/neon-frame.css +0 -170
- dist/assets/share-fix.js +0 -193
- dist/assets/worker-Bq7vdIsj.js +0 -0
- dist/assets/{worker-Bz7I_x9L.js → worker-DHCTNpZY.js} +0 -0
- dist/assets/worker-hEU_3YGf.js +0 -0
- dist/assets/worker-hGPAZ_gn.js +0 -0
- dist/assets/worker-n0zt6z0K.js +0 -0
- dist/assets/worker-nnOdWzlm.js +0 -0
- dist/brand/vaistudio_center.png +0 -3
- dist/catalogue/products.json +0 -0
- dist/catalogue/promos.json +0 -0
- dist/index.html +3 -15
- dist/share/og-image.png +0 -3
- index.html +4 -69
- package-lock.json +0 -30
- package.json +1 -7
- products-index.json +2 -2
- public/brand/vaistudio_center.png +0 -3
- public/catalogue/products.json +0 -0
- public/catalogue/promos.json +0 -0
- public/share/og-image.png +0 -3
- repo/rebuild.py +0 -56
- scripts/gen-thumbnail.py +0 -108
- scripts/refresh-catalogue.mjs +0 -415
- scripts/refresh.mjs +0 -74
- scripts/requirements-sync.txt +0 -6
- scripts/seed-catalogue.mjs +0 -216
- scripts/sync-catalogue.mjs +0 -141
- scripts/sync-interiors.py +0 -388
- scripts/test-avatar-interiors.mjs +0 -287
- scripts/test-catalogue.mjs +0 -187
- scripts/test-merge.mjs +0 -210
- scripts/test-misbrand.mjs +0 -109
- scripts/test-neon-ui.mjs +0 -256
- scripts/test-product-search.mjs +0 -173
- scripts/test-semantic-search.mjs +0 -123
- scripts/test-ui.mjs +0 -201
- scripts/test-viewer-ui.mjs +0 -151
.gitattributes
CHANGED
|
@@ -36,16 +36,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 36 |
products-index.json filter=lfs diff=lfs merge=lfs -text
|
| 37 |
dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
| 38 |
products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
| 39 |
-
# The V.AI STUDIO backdrop, copied from vai-avatar's public/avatars/ folder.
|
| 40 |
-
# Named explicitly rather than with a *.png pattern: a wildcard would also
|
| 41 |
-
# catch the BlurHash and thumbnail PNGs under dist/prodimgs, which are small,
|
| 42 |
-
# numerous and regenerated by the catalogue scripts -- routing ~6,400 of them
|
| 43 |
-
# through LFS would make every catalogue refresh slow and would balloon the
|
| 44 |
-
# repo. Two files, one rule, and the reason is recorded here.
|
| 45 |
-
public/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
|
| 46 |
-
dist/brand/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
-
|
| 48 |
-
# Social-share thumbnail (generated by scripts/gen-thumbnail.py). Both copies in
|
| 49 |
-
# LFS: the hub og:image resolves via resolve/main/dist/share/og-image.png.
|
| 50 |
-
public/share/og-image.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
-
dist/share/og-image.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 36 |
products-index.json filter=lfs diff=lfs merge=lfs -text
|
| 37 |
dist/products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
| 38 |
products-gallery.json filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
# Dependencies. Only `npm install` output — dist/ and public/catalogue/ are
|
| 2 |
-
# deliberately NOT ignored: this is a static Space, so the built bundle and the
|
| 3 |
-
# vai-avatar mirror must be committed to be served at all.
|
| 4 |
-
node_modules/
|
| 5 |
-
.npm/
|
| 6 |
-
|
| 7 |
-
# Local logs and caches
|
| 8 |
-
*.log
|
| 9 |
-
.vite/
|
| 10 |
-
.cache/
|
| 11 |
-
|
| 12 |
-
# Python tooling
|
| 13 |
-
__pycache__/
|
| 14 |
-
*.py[cod]
|
| 15 |
-
.venv/
|
| 16 |
-
venv/
|
| 17 |
-
|
| 18 |
-
# OS noise
|
| 19 |
-
.DS_Store
|
| 20 |
-
Thumbs.db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PLACEHOLDER
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
null
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,346 +1,74 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🛋️
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
-
short_description:
|
| 9 |
-
thumbnail: dist/share/og-image.png
|
| 10 |
header: mini
|
| 11 |
tags:
|
| 12 |
- ml-intern
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
thay 24.989 ảnh Unsplash bằng **1.222 ảnh nội thất Việt Nam**, cho phép tìm bằng tiếng Việt.
|
| 21 |
-
Danh mục sản phẩm của **vai-avatar** mở trong cùng trình xem ảnh toàn màn hình với ảnh nội thất.
|
| 22 |
-
|
| 23 |
-
> **ĐÃ XONG** — danh mục sản phẩm của vai-avatar đã nằm trong photo-viewer và tự đồng bộ.
|
| 24 |
-
> Xem [Vì sao trước đây không hoạt động](#vì-sao-trước-đây-không-hoạt-động).
|
| 25 |
-
|
| 26 |
-
## Vì sao trước đây không hoạt động
|
| 27 |
-
|
| 28 |
-
Ba lỗi chồng nhau, mỗi lỗi một mình đủ để danh mục không bao giờ hiện:
|
| 29 |
-
|
| 30 |
-
1. **Space phục vụ một bundle không có code sản phẩm.** `index.html` trỏ tới
|
| 31 |
-
`index-CigEzVrZ.js` — build từ cây source chưa hề có module catalogue
|
| 32 |
-
(`grep Malloca` trong bundle đó = 0 hit). Dù mirror có dữ liệu thì cũng vô ích.
|
| 33 |
-
2. **`candidateURLs()` bị Vite phá hủy âm thầm.** `new URL(`../../${path}`,
|
| 34 |
-
import.meta.url)` bị Vite hiểu thành glob asset và thay bằng map index theo
|
| 35 |
-
chuỗi literal `../../${path}` (còn nguyên `${}`) → không khớp key nào →
|
| 36 |
-
`undefined` → mọi candidate đều 404 **dù file mirror có thật**. Cùng lúc đó
|
| 37 |
-
build kéo README.md, package-lock.json và `products-gallery.json` (11 MB) vào
|
| 38 |
-
`dist/assets`. Đã sửa: base truyền vào `new URL` dạng biến, nên không còn bị đụng.
|
| 39 |
-
3. **Mirror rỗng và không có gì viết nó.** `public/catalogue/` và `dist/catalogue/`
|
| 40 |
-
chưa từng được commit. Người duy nhất viết là reconciler trong Space
|
| 41 |
-
vai-avatar, mà Space đó không có secret nào nên tự tắt (`no HF_TOKEN -> mirror OFF`).
|
| 42 |
-
|
| 43 |
-
## Đồng bộ bây giờ không cần token
|
| 44 |
-
|
| 45 |
-
`fetchJSON()` đọc **thẳng dataset** mà vai-avatar ghi và mirror chỉ còn là fallback
|
| 46 |
-
same-origin. Lý do: huggingface.co giờ phản chiếu `Origin` của caller trong
|
| 47 |
-
`Access-Control-Allow-Origin`, trên **cả** hop `/resolve` lẫn hop `/api/resolve-cache`
|
| 48 |
-
mà nó redirect tới — nên Space đọc được cross-origin mà không cần token.
|
| 49 |
-
|
| 50 |
-
Nhờ vậy thêm / sửa / xóa trên vai-avatar hiện lên đây trong **30 giây** (`POLL_MS`),
|
| 51 |
-
không cần ai commit, không rebuild Space, không cần secret. Mirror vẫn được thử và
|
| 52 |
-
vẫn là nguồn dự phòng: Hub lỗi thì vẫn có catalogue, chứ không trắng shop.
|
| 53 |
-
|
| 54 |
-
## Trạng thái
|
| 55 |
-
|
| 56 |
-
| Việc | Trạng thái |
|
| 57 |
-
| --- | --- |
|
| 58 |
-
| Danh mục hiện trong photo-viewer | ✅ 75 sản phẩm, mở ảnh + phân trang + giá + specs |
|
| 59 |
-
| Bundle đúng source hiện tại | ✅ `dist/assets/index.js` + `worker-CYIRI7QN.js` |
|
| 60 |
-
| `candidateURLs()` sau build | ✅ còn nguyên, không bị Vite glob |
|
| 61 |
-
| Bundle không kéo file rác | ✅ `grep 'Object.assign({"'` = 0 |
|
| 62 |
-
| `public/catalogue/` + `dist/catalogue/` | ✅ 69 products + 58 productEdits |
|
| 63 |
-
| Đọc dataset trực tiếp (không cần token) | ✅ đã build + kiểm chứng bằng browser thật |
|
| 64 |
-
| Thêm / Sửa / Xóa đồng bộ | ✅ trong 30s, không cần commit |
|
| 65 |
-
| Mirror tự cập nhật trong repo | ⚪ tuỳ chọn — cần secret `HF_TOKEN` ở vai-avatar |
|
| 66 |
-
| Token `HF_TOKEN` trong vai-avatar | ❌ chưa có — Space hiện đang chạy không cần nó |
|
| 67 |
-
| Token `HF_TOKEN` trong vai-avatar | ❓ **chưa kiểm — cần đọc log container** |
|
| 68 |
-
|
| 69 |
-
**Còn hai việc, đúng thứ tự. Không mục nào cần credits:**
|
| 70 |
-
|
| 71 |
-
1. **Kiểm tra `HF_TOKEN`** trong vai-avatar (10 giây)
|
| 72 |
-
2. **Chạy `npm run refresh:catalogue`** (~3 phút)
|
| 73 |
-
|
| 74 |
-
PR #1 đã đóng do merge conflict; [PR #2](https://huggingface.co/spaces/bep40/vai-avatar/discussions/2)
|
| 75 |
-
là bản thay thế, đã merge.
|
| 76 |
-
|
| 77 |
-
## Ghi chú vận hành
|
| 78 |
-
|
| 79 |
-
### 1. Kiểm tra token
|
| 80 |
-
|
| 81 |
-
```sh
|
| 82 |
-
curl -s -H "Authorization: Bearer $HF_TOKEN" \
|
| 83 |
-
https://huggingface.co/api/spaces/bep40/vai-avatar/secrets | jq keys
|
| 84 |
-
```
|
| 85 |
-
|
| 86 |
-
Chỉ trả **tên** secret. Không thấy `HF_TOKEN` thì thêm vào
|
| 87 |
-
[settings](https://huggingface.co/spaces/bep40/vai-avatar/settings), **không cần sửa mã**.
|
| 88 |
-
|
| 89 |
-
**Vì sao đây là nghi phạm số một:** cả hai dataset đều **public**, nên `readDataset()` chạy
|
| 90 |
-
được không cần token. Nhưng `commitMirror()` **bắt buộc** có token. Thiếu token thì cả vòng
|
| 91 |
-
reconcile `return` sớm ở dòng đầu, **không in gì**. Đúng triệu chứng "im lặng" đang thấy.
|
| 92 |
-
|
| 93 |
-
Nếu token đã có mà vẫn im lặng, xem tab **Logs** trên trang Space của vai-avatar:
|
| 94 |
-
|
| 95 |
-
| Log | Nghĩa là | Hành động |
|
| 96 |
-
| --- | --- | --- |
|
| 97 |
-
| **Không có** dòng `[catalogue-mirror-boot]` | Wrapper chưa chạy | Báo lại, đây là lỗi thật |
|
| 98 |
-
| `could not load catalogue-mirror.ts` | Lỗi cú pháp | Báo lại kèm log |
|
| 99 |
-
| `reconciler RUNNING … token=NO` | Thiếu token | Thêm `HF_TOKEN` rồi restart |
|
| 100 |
-
| `SYNCED …` | Mirror đã ghi | Chờ ~2 phút rồi làm bước 2 |
|
| 101 |
-
| Vòng 2+ im lặng | Đúng, không có gì đổi | Không làm gì |
|
| 102 |
-
| `SYNCED` **lặp mỗi vòng** | Rebuild loop | **Dừng ngay** |
|
| 103 |
-
| `commit rejected HTTP 403` | Token thiếu quyền sang repo khác | Thêm quyền, không phải lỗi mã |
|
| 104 |
-
|
| 105 |
-
### 2. Build lại bundle
|
| 106 |
-
|
| 107 |
-
```sh
|
| 108 |
-
git clone https://huggingface.co/spaces/bep40/semantic-image-field
|
| 109 |
-
cd semantic-image-field
|
| 110 |
-
npm install
|
| 111 |
-
npm run refresh:catalogue
|
| 112 |
-
```
|
| 113 |
-
|
| 114 |
-
~3 phút, miễn phí. `refresh:catalogue` = sync → guard → build → verify `dist/catalogue` →
|
| 115 |
-
repoint `index.html` → commit → push.
|
| 116 |
-
|
| 117 |
-
**Đừng dùng `npm run seed:catalogue`** — nó chỉ tải JSON, **không build**. Chạy nó sẽ cho
|
| 118 |
-
sản phẩm hiện lên mà vẫn lỗi đồng bộ, tức kết quả tệ hơn hiện tại vì trông như đã xong.
|
| 119 |
-
|
| 120 |
-
**Vì sao bước này là quyết định:** `dist/assets/` hiện chỉ có bundle **có hash**
|
| 121 |
-
(`index-CigEzVrZ.js`). `vite.config.js` đặt `entryFileNames: 'assets/index.js'` — tên không
|
| 122 |
-
hash — nên `vite build` **chưa từng chạy** kể từ khi cấu hình đó được đặt. Sáu bản vá trong
|
| 123 |
-
`src/` **đã** trên `main` nhưng **chưa** nằm trong bundle đang chạy.
|
| 124 |
-
|
| 125 |
-
## Một cảnh báo về cách kiểm
|
| 126 |
-
|
| 127 |
-
`list` với **đường dẫn tới một file** trả `File not found` dù file đó có thật. Phải `list`
|
| 128 |
-
**thư mục cha** rồi đọc danh sách.
|
| 129 |
-
|
| 130 |
-
Cụ thể: `dist/assets/index-CigEzVrZ.js` trả `File not found`, nhưng `list dist/assets`
|
| 131 |
-
liệt kê đúng file đó (263.4 KB). Tin `File not found` thì kết luận sai là bundle đã mất
|
| 132 |
-
và Space trắng trang — trong khi nó vẫn chạy.
|
| 133 |
-
|
| 134 |
-
Với **thư mục** thì `list` đáng tin: `dist/catalogue` báo not-found và `list` xác nhận nó
|
| 135 |
-
thật sự không tồn tại.
|
| 136 |
-
|
| 137 |
-
## Token: đọc ở đâu cho đúng
|
| 138 |
-
|
| 139 |
-
`startCatalogueMirrorReconciler()` in sẵn trạng thái token lúc khởi động:
|
| 140 |
-
|
| 141 |
-
```
|
| 142 |
-
[catalogue-mirror] reconciler RUNNING | target=bep40/semantic-image-field@main
|
| 143 |
-
| every 120s | token=yes
|
| 144 |
-
```
|
| 145 |
-
|
| 146 |
-
**Không cần** gọi `/api/share-persist`. Route đó được `applyPatches()` tiêm *có điều kiện*
|
| 147 |
-
vào `index.ts` lúc chạy, chỉ khi thấy anchor `'"/*": { GET: serveStatic },'` và
|
| 148 |
-
`tryWriteIndex()` qua kiểm tra cú pháp. Thiếu anchor là 404 — **tương thích với việc token
|
| 149 |
-
có tồn tại**, nên 404 không nói được gì về token.
|
| 150 |
-
|
| 151 |
-
Dòng "Space này hiện CHƯA có `HF_TOKEN`" trong `boot.ts` là lời tuyên bố trong source, có
|
| 152 |
-
thể đã cũ. Nguồn đúng là log container.
|
| 153 |
-
|
| 154 |
-
## Vì sao hai nguồn vẫn ra một sản phẩm
|
| 155 |
-
|
| 156 |
-
Một máy giặt Hitachi nằm ở **cả hai** nguồn, hai nguồn gọi nó bằng hai tên khác nhau —
|
| 157 |
-
đọc trực tiếp từ Hub, không phỏng đoán:
|
| 158 |
-
|
| 159 |
-
| | `products_url_added.json` | `promos.json` |
|
| 160 |
-
| --- | --- | --- |
|
| 161 |
-
| key / `sku` / `model` | `BD-1054HVOW` | `357869` |
|
| 162 |
-
| `slug` | `bd-1054hvow-may-giat-…` | `357869-may-giat-…` |
|
| 163 |
-
| **`name` / `n`** | **`Máy giặt Hitachi Inverter 10.5 kg BD-1054HVOW`** | **giống hệt** |
|
| 164 |
-
| giá | `pn` = 5.990.000 | `priceNum` = 6.490.000 |
|
| 165 |
-
|
| 166 |
-
`sku` và `slug` **không** khớp, nhưng `name` giống nhau và `identityAliases()` đã lấy
|
| 167 |
-
`name` làm bí danh — nên hai bản ghi là **một** sản phẩm, và giá 6.490.000 nhập đè lên bản
|
| 168 |
-
ghi gốc.
|
| 169 |
-
|
| 170 |
-
**Đã cân nhắc và loại bỏ một cách khắc phục khác.** Con số `357869` chỉ nằm trong URL ảnh,
|
| 171 |
-
không field nào chứa nó, nên đào URL để ghép cứng là cách sửa hiện trường. Không dùng: ghép
|
| 172 |
-
cứng theo id trong URL có thể hợp nhất nhầm hai sản phẩm, mà lỗi đó cho ra một bản ghi
|
| 173 |
-
*trông vẫn đầy đủ* — khó phát hiện hơn nhiều so với một thẻ bị lặp.
|
| 174 |
-
|
| 175 |
-
Bí danh theo `name` vẫn an toàn: bí danh mà hai sản phẩm cùng nhận bị loại như mơ hồ.
|
| 176 |
-
|
| 177 |
-
## Nguồn dữ liệu sản phẩm
|
| 178 |
-
|
| 179 |
-
| Thao tác trên vai-avatar | Nguồn | Nội dung |
|
| 180 |
-
| --- | --- | --- |
|
| 181 |
-
| Thêm SP | `bep40/grob-products-updated/products_url_added.json` | `n`, `imgs`, `p`, `pn`, `specs`, `feats` |
|
| 182 |
-
| Thêm / Sửa / Xóa SP | `bep40/vaistudio-data/promos.json` → `productEdits[sku]` | `{ name, priceNum, images }` hoặc `{ deleted: true }` |
|
| 183 |
-
|
| 184 |
-
**Xóa là bia mộ**, không phải xoá bản ghi. `buildCatalogue` bỏ qua bản ghi có `deleted`, đúng
|
| 185 |
-
như `getAddedProducts()` / `isDeleted()` của vai-avatar. Nếu không, sản phẩm đã xoá hiện
|
| 186 |
-
vĩnh viễn.
|
| 187 |
-
|
| 188 |
-
**Phạm vi hẹp:** hai file này là bản *thêm / sửa / xoá*, không phải catalogue đầy đủ.
|
| 189 |
-
Catalogue gốc Malloca ~6.000 sản phẩm nằm trong Parquet và không được nhân bản. Sửa một SKU
|
| 190 |
-
thuộc catalogue gốc sẽ không có bản ghi để áp overlay.
|
| 191 |
-
|
| 192 |
-
Xếp hạng dùng từ khoá, không dùng ma trận CLIP đã nướng: sản phẩm vừa thêm là tìm được
|
| 193 |
-
ngay ở truy vấn kế tiếp, không phải chờ mã hoá lại toàn bộ ảnh.
|
| 194 |
-
|
| 195 |
-
## Sáu lỗi đồng bộ đã sửa
|
| 196 |
-
|
| 197 |
-
Cả sáu đều **fail im lặng**, và cả sáu triệu chứng đều đọc ra giống nhau là "sửa sp không
|
| 198 |
-
đồng bộ".
|
| 199 |
-
|
| 200 |
-
**Trong `src/catalogue.js`:**
|
| 201 |
-
|
| 202 |
-
1. **Hai nguồn không thống nhất danh tính.** Ghép mỗi file theo field riêng sẽ liệt kê máy
|
| 203 |
-
giặt hai lần và mọi "Sửa SP" sẽ vô hình. Nay mỗi bản ghi nhận diện qua tập bí danh.
|
| 204 |
-
2. **"Xóa SP" im lặng không làm gì.** vai-avatar ghi bản sửa ở
|
| 205 |
-
`productEdits[p.sku || p.model]` — key của object là định danh chính, không phải dự
|
| 206 |
-
phòng. Trước đây chỉ field *bên trong* bản sửa được dùng để tra; một bia mộ không mang
|
| 207 |
-
field nào nên danh sách rỗng, key trần biến thành mục mới, bản ghi thật không bao giờ bị
|
| 208 |
-
đánh dấu, và sản phẩm cứ ở lại mãi. Nay key cũng được tra qua bảng bí danh.
|
| 209 |
-
3. **Bản sửa có nhưng không thấy.** Bộ đọc ưu tiên field ngắn của scraper, nên `priceNum` mới
|
| 210 |
-
bị `p` cũ che mất. `EDIT_SUPERSEDES` xoá bản ghi cũ trước khi bản sửa nhập.
|
| 211 |
-
4. **Overlay rỗng hồi sinh sản phẩm đã xoá.** `promos.json` mang mọi Sửa và mọi Xóa. Trả
|
| 212 |
-
`{}` khi đọc lỗi không chỉ mất sửa đổi mà còn hồi sinh sản phẩm đã xoá và hoàn nguyên mọi
|
| 213 |
-
giá — **không báo lỗi**, vì kết quả vẫn hợp lệ nên `tryLoadCatalogue()` báo thành công
|
| 214 |
-
và vòng poll 90 giây thay dữ liệu bị hoàn nguyên vào như một bản cập nhật thật. Nay dùng
|
| 215 |
-
overlay hợp lệ gần nhất.
|
| 216 |
-
5. **`sync-catalogue.mjs` báo thành công khi mirror đang lẫn.** Một nguồn fetch lỗi thì file
|
| 217 |
-
đó giữ bản commit trước, `refresh-catalogue.mjs` vẫn pass các guard, build, commit, in
|
| 218 |
-
`REFRESH_OK` — và `products.json` mới nằm cạnh `promos.json` cũ, nên mọi Sửa và Xóa ngừng
|
| 219 |
-
áp dụng mà không báo lỗi. Nay bất kỳ nguồn nào lỗi thì cả lần chạy fail, caller dừng
|
| 220 |
-
trước build.
|
| 221 |
-
6. **Reconciler có thể rơi vào vòng rebuild vô tận.** `readTarget()` đọc qua `/resolve/`, có
|
| 222 |
-
cache CDN: ngay sau khi commit thành công, endpoint vẫn trả nội dung cũ. Check "diff
|
| 223 |
-
trước khi commit" thấy lệch nên commit lại **byte giống hệt**, mỗi commit lại rebuild
|
| 224 |
-
Space target. Chính cái check sinh ra để chặn rebuild loop là đường đi vào nó. Nay commit
|
| 225 |
-
do chính process đó nhận 200 được lưu lại và thắng đọc cache (C7). Đánh đổi có chủ đích:
|
| 226 |
-
sửa tay mirror dưới lúc container chạy sẽ không được tự vá tới khi restart.
|
| 227 |
-
|
| 228 |
-
## Một sửa chữa đã âm thầm
|
| 229 |
-
|
| 230 |
-
Wrapper ban đầu viết static import ở dòng 1. ESM **hoist** static import: nó nạp và đánh giá
|
| 231 |
-
*trước* mọi câu lệnh trong file. Nên nếu `catalogue-mirror.ts` lỗi cú pháp, cả wrapper chết
|
| 232 |
-
trước khi reconciler kịp chạy → CMD rơi về `bun boot.ts` → **site lên, share-link chạy,
|
| 233 |
-
chỉ catalogue-mirror tắt, không một dòng log nào**. Người đọc log tưởng PR chưa merge.
|
| 234 |
-
|
| 235 |
-
Đã đổi sang dynamic `import()` bọc `try/catch`, thêm hai dòng log tường minh: `reconciler
|
| 236 |
-
STARTED` khi bật được, `could not load …` khi lỗi. **Mirror lỗi còn hơn mirror vô hình.**
|
| 237 |
-
|
| 238 |
-
## Vì sao vẫn giữ mirror, dù giờ đã đọc dataset được
|
| 239 |
-
|
| 240 |
-
**Cách đọc đã đổi, và đo lại thay vì tin tài liệu cũ.** Trước đây Hub gửi
|
| 241 |
-
`Access-Control-Allow-Origin: https://huggingface.co` cho file dataset và `/resolve`
|
| 242 |
-
redirect sang host CDN với chính sách riêng — origin của Space không thuộc về cả hai
|
| 243 |
-
nên fetch chết. **Điều đó không còn đúng:** Hub nay phản chiếu `Origin` của caller
|
| 244 |
-
trong ACAO, trên *cả* hop `/resolve` lẫn hop `/api/resolve-cache`, và kể cả
|
| 245 |
-
`Origin: null`. Nên `fetchJSON()` đọc thẳng dataset trước, không cần token.
|
| 246 |
-
|
| 247 |
-
Mirror **không bị bỏ**: nó là đường dự phòng same-origin, nên khi Hub/CDN giật
|
| 248 |
-
(`5xx`, mạng chết, offline) catalogue vẫn còn thay vì trắng shop. Thứ tự thử là
|
| 249 |
-
dataset → mirror, và mọi candidate đều phải **parse được + đúng shape** mới được
|
| 250 |
-
nhận, nên một body HTML lỗi hay bị cắt cụt không bao giờ bị coi là catalogue.
|
| 251 |
-
|
| 252 |
-
## Đồng bộ tự động — không cần writer
|
| 253 |
-
|
| 254 |
-
Space này là `sdk: static`: không có server, không thể tự đẩy dữ liệu, cũng không thể ghi bất
|
| 255 |
-
cứ thứ gì. `bep40/vai-avatar` thì có server, có sẵn reconciler 20 giây cho link chia sẻ
|
| 256 |
-
(nguồn: `boot.ts`) và self-test tự chứng minh đường ghi Hub dùng được. Mở rộng cơ chế có
|
| 257 |
-
sẵn rẻ hơn nhiều so với tạo cron mới — cron tốn credits ngay cả lúc tạo, đã kiểm chứng:
|
| 258 |
-
`POST /api/scheduled-jobs/…` trả `402`.
|
| 259 |
-
|
| 260 |
-
[PR #2](https://huggingface.co/spaces/bep40/vai-avatar/discussions/2) đã merge vào `main` của
|
| 261 |
-
vai-avatar. Nó thêm `catalogue-mirror.ts` + `catalogue-mirror-boot.ts` + sửa `Dockerfile`.
|
| 262 |
-
Wrapper chỉ start reconciler rồi `import("./boot.ts")` nên **`boot.ts` không bị sửa**. Chuỗi
|
| 263 |
-
CMD 3 tầng:
|
| 264 |
-
|
| 265 |
-
```
|
| 266 |
-
bun catalogue-mirror-boot.ts || bun boot.ts || bun entry_ai_image.ts
|
| 267 |
-
```
|
| 268 |
-
|
| 269 |
-
Reconciler đọc hai nguồn mỗi 120 giây và commit `public/catalogue/` + `dist/catalogue/`
|
| 270 |
-
**chỉ khi byte thật sự khác**. Không job định kỳ, không credit.
|
| 271 |
-
|
| 272 |
-
**Nhưng reconciler đang TẮT và điều đó không còn là vấn đề.** Space vai-avatar không
|
| 273 |
-
có secret nào (`GET /api/spaces/bep40/vai-avatar/secrets` → `[]`), nên
|
| 274 |
-
`catalogue-mirror.ts` tự báo `"no HF_TOKEN/VAISTUDIO -> mirror OFF"` và không ghi gì.
|
| 275 |
-
Trước đây điều đó chết dây chuyền đồng bộ; giờ thì không: Space này đọc thẳng dataset
|
| 276 |
-
nên **không cần writer nào cả**. Muốn bật reconciler thì chỉ để cho mirror trong repo
|
| 277 |
-
tự mới theo (tiện khi rollback, có bản dữ liệu nằm trong lịch sử git) — không bắt
|
| 278 |
-
buộc, và là thay đổi hạ tầng trên Space đang chạy production nên cần bạn quyết.
|
| 279 |
-
|
| 280 |
-
⚠️ Mỗi commit lên `main` của vai-avatar đều rebuild Docker của Space đang phục vụ khách.
|
| 281 |
|
| 282 |
## Thay đổi so với bản gốc
|
| 283 |
|
| 284 |
| | Bản gốc | Bản này |
|
| 285 |
| --- | --- | --- |
|
| 286 |
| Bộ ảnh | Unsplash, 24.989 ảnh | Nội thất VN, **1.222 ảnh** |
|
| 287 |
-
| Model text | `Xenova/clip-vit-base-patch16` | `bep40/clip-vit-b32-multilingual-vi-text` (**50+ ngôn ngữ**) |
|
| 288 |
| Nguồn ảnh | `images.unsplash.com` | `bep40/interior-image-field-assets` |
|
| 289 |
| Giao diện | Tiếng Anh | Tiếng Việt |
|
| 290 |
|
| 291 |
Model text là bản đóng gói của
|
| 292 |
-
[`sentence-transformers/clip-ViT-B-32-multilingual-v1`](https://huggingface.co/sentence-transformers/clip-ViT-B-32-multilingual-v1)
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
phím và `prefers-reduced-motion` giữ nguyên theo bản gốc. Số ảnh hiển thị đồng thời vẫn là 96
|
| 300 |
(56 trên màn hình hẹp).
|
| 301 |
|
| 302 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
```sh
|
| 305 |
npm install
|
| 306 |
-
npm run
|
| 307 |
-
npm run refresh:catalogue # sync + build + kiểm + trỏ index.html + commit + push
|
| 308 |
```
|
| 309 |
|
| 310 |
-
|
| 311 |
-
`app.html`), nên `vite build` không bao giờ sửa nó — hai dòng trong đó là thứ duy nhất nối Space
|
| 312 |
-
với bundle.
|
| 313 |
-
|
| 314 |
-
**Đừng sửa tay `index.html` sang tên cố định trước khi `dist/assets/index.js` tồn tại.**
|
| 315 |
-
Trỏ tới file không có thì Space trắng trang. `repointIndexHtml()` kiểm tra tồn tại trước khi
|
| 316 |
-
ghi nên chạy qua script là an toàn.
|
| 317 |
-
|
| 318 |
-
`vite.config.js` đặt `emptyOutDir: false` vì `dist/` còn chứa khoảng 6.400 file dữ liệu đã
|
| 319 |
-
commit mà không bước build nào sinh lại được. Nếu bật `emptyOutDir`, chúng biến mất mà ứng
|
| 320 |
-
dụng vẫn chạy bình thường, không báo lỗi.
|
| 321 |
-
|
| 322 |
-
`refresh-catalogue.mjs` chỉ commit path tường minh: `index.html`, `public/catalogue`,
|
| 323 |
-
`dist/catalogue`, `dist/assets`. Không path nào trùng pattern LFS trong `.gitattributes`
|
| 324 |
-
(pattern là `products-index.json` / `products-gallery.json` — tên cụ thể, không phải
|
| 325 |
-
`products.json`), nên con trỏ LFS không bao giờ được commit nhầm.
|
| 326 |
-
|
| 327 |
-
## Kiểm tra nghiệm thu
|
| 328 |
|
| 329 |
-
|
|
|
|
| 330 |
|
| 331 |
-
|
| 332 |
-
- giá hiện **6.490.000₫**, không phải `5.990.000₫` cũ
|
| 333 |
-
- bấm vào mở được trong cùng trình xem toàn màn hình với ảnh nội thất
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
không phải suy đoán.
|
| 338 |
|
| 339 |
-
|
|
|
|
|
|
|
| 340 |
|
| 341 |
-
Space dùng **static SDK**, nên `https://<id>.hf.space` trả 404 (đúng cả với bản gốc). Host đúng
|
| 342 |
-
là **https://bep40-semantic-image-field.static.hf.space**
|
| 343 |
|
| 344 |
Công nhận: ảnh thuộc về các tác giả trên
|
| 345 |
-
[noithatphuphat.com](https://noithatphuphat.com). Pipeline suy luận
|
| 346 |
-
[`semantic-image-search-web`](https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web).
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Semantic Image Field — Nội thất
|
| 3 |
emoji: 🛋️
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
short_description: Tìm ảnh nội thất bằng tiếng Việt, chạy trong máy
|
|
|
|
| 9 |
header: mini
|
| 10 |
tags:
|
| 11 |
- ml-intern
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Semantic Image Field — Nội thất
|
| 15 |
|
| 16 |
+
Bản phái sinh của [`webml-community/semantic-image-field`](https://huggingface.co/spaces/webml-community/semantic-image-field),
|
| 17 |
+
thay bộ ảnh Unsplash 24.989 ảnh bằng **1.222 ảnh thiết kế nội thất Việt Nam** và cho phép
|
| 18 |
+
**tìm kiếm bằng tiếng Việt**.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## Thay đổi so với bản gốc
|
| 21 |
|
| 22 |
| | Bản gốc | Bản này |
|
| 23 |
| --- | --- | --- |
|
| 24 |
| Bộ ảnh | Unsplash, 24.989 ảnh | Nội thất VN, **1.222 ảnh** |
|
| 25 |
+
| Model text | `Xenova/clip-vit-base-patch16` (tiếng Anh) | `bep40/clip-vit-b32-multilingual-vi-text` (**50+ ngôn ngữ**) |
|
| 26 |
| Nguồn ảnh | `images.unsplash.com` | `bep40/interior-image-field-assets` |
|
| 27 |
| Giao diện | Tiếng Anh | Tiếng Việt |
|
| 28 |
|
| 29 |
Model text là bản đóng gói của
|
| 30 |
+
[`sentence-transformers/clip-ViT-B-32-multilingual-v1`](https://huggingface.co/sentence-transformers/clip-ViT-B-32-multilingual-v1)
|
| 31 |
+
(text tower DistilBERT được huấn luyện để khớp không gian 512-d của CLIP-ViT-B-32).
|
| 32 |
+
Bản gốc trên Hub chỉ export tới `last_hidden_state` 768-chiều, nên bản fork này nối thêm
|
| 33 |
+
mean-pooling, phép chiếu 768→512 và chuẩn hoá L2, đặt tên `text_model.onnx` theo đúng
|
| 34 |
+
quy ước CLIP để Transformers.js nạp được không cần sửa mã nguồn.
|
| 35 |
+
|
| 36 |
+
Ảnh được mã hoá **ngoại tuyến** bằng CLIP-ViT-B-32 (`Xenova/clip-vit-base-patch32`),
|
| 37 |
+
lưu thành float32 thô 512 chiều mỗi ảnh. Mỗi bản ghi kèm `ar` và BlurHash `blur` để
|
| 38 |
+
`layout.js` và `image-loader.js` hoạt động như thiết kế gốc.
|
| 39 |
+
|
| 40 |
+
## Vẫn giữ nguyên
|
| 41 |
|
| 42 |
+
Toàn bộ chuyển động, bố cục Fibonacci, hiệu ứng liên quan, xem toàn màn hình, hỗ trợ
|
| 43 |
+
bàn phím và `prefers-reduced-motion` của bản gốc. Số ảnh hiển thị đồng thời vẫn là 96
|
|
|
|
| 44 |
(56 trên màn hình hẹp).
|
| 45 |
|
| 46 |
+
## Chạy
|
| 47 |
+
|
| 48 |
+
Không có server, API key hay cơ sở dữ liệu. Suy luận và xếp hạng diễn ra trên máy người
|
| 49 |
+
dùng; truy vấn không được gửi đi. Lần dùng đầu cần tải model (~130 MB) và ảnh từ
|
| 50 |
+
Hugging Face.
|
| 51 |
|
| 52 |
```sh
|
| 53 |
npm install
|
| 54 |
+
npm run dev
|
|
|
|
| 55 |
```
|
| 56 |
|
| 57 |
+
## URL truy cập
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
Space này dùng **static SDK**, nên không có `https://<id>.hf.space` (trả 404 —
|
| 60 |
+
đúng cả với bản gốc `webml-community/semantic-image-field`). Host đúng là:
|
| 61 |
|
| 62 |
+
**https://bep40-semantic-image-field.static.hf.space**
|
|
|
|
|
|
|
| 63 |
|
| 64 |
+
> `README.md` không được khai báo `app_file`: static Space phục vụ thư mục gốc repo,
|
| 65 |
+
> không phải đường dẫn trong `app_file`.
|
|
|
|
| 66 |
|
| 67 |
+
`products-index.json` được nhân bản vào chính Space này và app fetch cùng-origin.
|
| 68 |
+
Hub chỉ gửi `Access-Control-Allow-Origin: https://huggingface.co` cho file dataset,
|
| 69 |
+
nên fetch từ domain `*.static.hf.space` sẽ bị CORS chặn.
|
| 70 |
|
|
|
|
|
|
|
| 71 |
|
| 72 |
Công nhận: ảnh thuộc về các tác giả trên
|
| 73 |
+
[noithatphuphat.com](https://noithatphuphat.com). Pipeline suy luận dựa trên ví dụ
|
| 74 |
+
[`semantic-image-search-web`](https://github.com/huggingface/transformers.js-examples/tree/main/semantic-image-search-web).
|
app.html
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="vi">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<meta name="theme-color" content="#111210" />
|
| 7 |
-
<meta name="description" content="Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
|
| 8 |
-
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
-
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
-
<title>Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên</title>
|
| 11 |
-
</head>
|
| 12 |
-
<body>
|
| 13 |
-
<!--
|
| 14 |
-
This is the BUILD TEMPLATE, not the served page.
|
| 15 |
-
|
| 16 |
-
vite.config.js sets build.rollupOptions.input to this file, so Vite emits
|
| 17 |
-
dist/app.html with the hashed asset references injected. The Space serves
|
| 18 |
-
the ROOT index.html, which is hand-maintained and loads ./dist/assets/…
|
| 19 |
-
|
| 20 |
-
Keeping them separate matters: if the root index.html were also the build
|
| 21 |
-
input, `vite build` would rewrite it, and the very first build would point
|
| 22 |
-
the live Space at bundles that do not exist yet — a blank page with no
|
| 23 |
-
error. See app.html in the repo root.
|
| 24 |
-
-->
|
| 25 |
-
<div id="root"></div>
|
| 26 |
-
<script type="module" src="/src/main.jsx"></script>
|
| 27 |
-
</body>
|
| 28 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dist/app.html
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="vi">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<meta name="theme-color" content="#111210" />
|
| 7 |
-
<meta name="description" content="Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
|
| 8 |
-
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
-
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
-
<title>Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên</title>
|
| 11 |
-
<script type="module" crossorigin src="./assets/index.js"></script>
|
| 12 |
-
<link rel="stylesheet" crossorigin href="./assets/app.css">
|
| 13 |
-
</head>
|
| 14 |
-
<body>
|
| 15 |
-
<!--
|
| 16 |
-
This is the BUILD TEMPLATE, not the served page.
|
| 17 |
-
|
| 18 |
-
vite.config.js sets build.rollupOptions.input to this file, so Vite emits
|
| 19 |
-
dist/app.html with the hashed asset references injected. The Space serves
|
| 20 |
-
the ROOT index.html, which is hand-maintained and loads ./dist/assets/…
|
| 21 |
-
|
| 22 |
-
Keeping them separate matters: if the root index.html were also the build
|
| 23 |
-
input, `vite build` would rewrite it, and the very first build would point
|
| 24 |
-
the live Space at bundles that do not exist yet — a blank page with no
|
| 25 |
-
error. See app.html in the repo root.
|
| 26 |
-
-->
|
| 27 |
-
<div id="root"></div>
|
| 28 |
-
</body>
|
| 29 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dist/assets/app.css
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
| 2 |
-
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial;--neon-angle:0deg}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-x-0{inset-inline:0}.top-60{top:calc(var(--spacing) * 60)}.bottom-0{bottom:0}.isolate{isolation:isolate}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-dvh{height:100dvh}.w-full{width:100%}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.border{border-style:var(--tw-border-style);border-width:1px}.capitalize{text-transform:capitalize}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}@property --neon-angle{syntax:"<angle>";inherits:false;initial-value:0deg}.image-tile{--neon:#22f5d0;--neon-soft:#7ef9ff;--neon-run:2.6s}.image-tile.exact,.image-tile.featured{--relevance-opacity:1!important;--relevance-blur:0px!important}.image-tile.exact:before,.image-tile.featured:before{content:"";z-index:2;background:conic-gradient(from var(--neon-angle,0deg), var(--neon) 0deg, var(--neon-soft) 38deg, #fff 70deg, var(--neon-soft) 102deg, var(--neon) 148deg, #22f5d05c 250deg, #22f5d000 344deg, var(--neon) 360deg);-webkit-mask-composite:xor;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:1px;position:absolute;inset:0;transform:none;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.image-tile.exact:after,.image-tile.featured:after{content:"";z-index:1;background:conic-gradient(from var(--neon-angle,0deg), #22f5d000 0deg, #22f5d085 62deg, #b6fdffc4 92deg, #22f5d05e 138deg, #22f5d01f 250deg, #22f5d000 348deg, #22f5d000 360deg);-webkit-mask-composite:xor;filter:blur(4px);opacity:.82;box-shadow:none;pointer-events:none;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;-webkit-mask-composite:xor;-webkit-mask-source-type:auto,auto;border-radius:2px;padding:2.5px;position:absolute;inset:0;-webkit-mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);mask-image:linear-gradient(#000 0 0),linear-gradient(#000 0 0);-webkit-mask-position:0 0,0 0;mask-position:0 0,0 0;-webkit-mask-size:auto,auto;mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}@keyframes neon-run{0%{--neon-angle:0deg}to{--neon-angle:360deg}}@supports at-rule(@property){.image-tile.exact:before,.image-tile.featured:before,.image-tile.exact:after,.image-tile.featured:after{animation:neon-run var(--neon-run) linear infinite}}.image-tile.featured{--neon-run:2s}.image-tile.featured:before{padding:1.5px}@media (prefers-reduced-motion:reduce){.image-tile.exact:before,.image-tile.exact:after,.image-tile.featured:before,.image-tile.featured:after{animation:none}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.brand-backdrop{z-index:0;pointer-events:none;opacity:.3;background-color:#10110f;background-image:url(../brand/vaistudio_center.png);background-position:50%;background-repeat:no-repeat;background-size:cover;background-attachment:fixed;position:fixed;inset:0}@media (width<=768px){.brand-backdrop{background-position:top;background-repeat:repeat;background-size:100%;background-attachment:scroll}}.app{background:0 0;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s;position:absolute;inset:0}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-share{z-index:1;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#e6ebe0;opacity:0;background:#0d0f0bb8;border-radius:6px;place-items:center;width:22px;height:22px;transition:opacity .16s,transform .16s,background .16s,color .16s;display:grid;position:absolute;top:5px;right:5px;transform:scale(.82)}.image-share svg{width:13px;height:13px}.image-share:hover{color:#fff;background:#0d0f0be6}.image-share[hidden]{display:none}.image-tile:hover .image-share,.image-tile:focus-within .image-share,.image-tile[data-rank="1"] .image-share,.image-tile[data-rank="2"] .image-share,.image-tile[data-rank="3"] .image-share{opacity:1;transform:scale(1)}@media (prefers-reduced-motion:reduce){.image-share{transition:opacity .12s linear}}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.image-tile:hover,.image-tile:focus-within{z-index:2147483647}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error-inline{color:#e8b4a9;text-align:center}.has-error{pointer-events:auto;text-shadow:none;background:#171b16ee;border-radius:10px;flex-wrap:wrap;padding:12px;font-size:14px}.has-error button{color:#eee;background:0 0;border:0;border-bottom:1px solid #ccc;padding:0}.error-detail{text-align:center;opacity:.7;overflow-wrap:anywhere;width:100%;font-size:12px}.footer{z-index:401;color:#bac0b395;letter-spacing:.015em;padding:20px 24px;font-size:12px}.footer a{color:inherit;text-decoration:none;transition:color .2s}.footer a:hover{color:#f5f6ef}.version{opacity:.55;margin-left:5px}.footer-divider{opacity:.5;padding:0 8px}@keyframes progress-glow{0%,to{opacity:.4}50%{opacity:1}}@media (width<=650px){.search-center{width:min(82vw,400px)}.search-pill{height:60px}.search-pill input{padding-left:23px;font-size:22px}.footer{padding:16px;font-size:12px}.footer .version,.footer-divider{display:none}.footer>span{font-size:0}.footer>span:after{content:"On-device search";font-size:12px}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}.photo-viewer{color:#e8eae3;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#0c0e0bdc;border:0;width:100vw;max-width:none;height:100dvh;max-height:none;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.photo-viewer[open]{animation:.42s both viewer-reveal;display:block}.photo-viewer::backdrop{background:#0005}.photo-viewer.is-closing{opacity:0;transition:opacity .32s;animation:none}.photo-toolbar{z-index:2;pointer-events:none;justify-content:space-between;align-items:center;display:flex;position:absolute;inset:18px 24px auto}.photo-toolbar a,.photo-toolbar button{pointer-events:auto;color:#e0e5d9;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px}.photo-toolbar a{padding:12px 17px;font-size:14px;text-decoration:none}.photo-toolbar button{place-items:center;width:44px;height:44px;display:grid}.photo-share{white-space:nowrap;align-items:center;gap:9px;display:inline-flex}.photo-share svg{flex:none;width:17px;height:17px}.photo-share:hover{color:#fff;background:#1b2115f2;border-color:#ffffff2e}@media (width<=560px){.photo-share span{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.photo-share{padding:12px}}.photo-toolbar svg{width:22px;height:22px}.photo-origin{pointer-events:none;color:#e0e5d9;text-overflow:ellipsis;white-space:nowrap;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px;max-width:60vw;padding:12px 17px;font-size:14px;overflow:hidden}.photo-viewport{place-items:center;padding:72px 24px 40px;display:grid;position:absolute;inset:0;overflow:auto}.photo-frame{width:min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));aspect-ratio:var(--photo-ar);cursor:zoom-in;transform-origin:50%;background-color:#161912;background-size:100% 100%;border:0;border-radius:3px;flex:none;padding:0;display:block;box-shadow:0 12px 70px #0005}.photo-frame img{object-fit:contain;opacity:0;width:100%;height:100%;transition:opacity .3s;display:block}.photo-frame img.loaded{opacity:1}.photo-viewport.is-zoomed{display:block}.photo-viewport.is-zoomed .photo-frame{width:max(calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));cursor:zoom-out;margin:0 auto}@keyframes viewer-reveal{0%{opacity:0}to{opacity:1}}.photo-step{color:#e0e5d9;background:#12160fd9;border:1px solid #ffffff18;border-radius:50%;place-items:center;width:46px;height:46px;padding:0;transition:background .18s,border-color .18s;display:grid;position:absolute;top:50%;transform:translateY(-50%)}.photo-step:hover{background:#1b2118e6;border-color:#ffffff2e}.photo-step svg{width:22px;height:22px}.photo-step.is-prev{left:18px}.photo-step.is-next{right:18px}@media (width<=650px){.photo-step{width:38px;height:38px}.photo-step.is-prev{left:8px}.photo-step.is-next{right:8px}}.photo-caption{z-index:2;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#0f130ccc;border:1px solid #ffffff17;border-radius:10px;max-width:min(560px,100vw - 48px);padding:12px 16px;position:absolute;bottom:18px;left:24px;right:24px}.photo-caption-price{color:#dfe6d2;margin:0 0 6px;font-size:15px;font-weight:600}.photo-caption-specs{gap:3px;margin:0;font-size:12px;display:grid}.photo-caption-specs>div{grid-template-columns:minmax(0,40%) minmax(0,60%);gap:10px;display:grid}.photo-caption-specs dt{color:#98a08f;overflow-wrap:anywhere}.photo-caption-specs dd{color:#d2d7cb;overflow-wrap:anywhere;margin:0}.photo-counter{z-index:2;font-variant-numeric:tabular-nums;color:#d2d7cb;background:#0f130ccc;border:1px solid #ffffff17;border-radius:999px;margin:0;padding:6px 12px;font-size:12px;position:absolute;bottom:22px;right:24px}@media (width<=650px){.photo-caption{padding:10px 12px;bottom:12px;left:12px;right:12px}.photo-counter{display:none}}.photo-detail{z-index:2;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);background:#0f130ce8;border:1px solid #ffffff1f;border-radius:12px;width:min(460px,100vw - 48px);position:absolute;bottom:18px;left:24px;overflow:hidden}.photo-detail-summary{cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:10px;padding:11px 14px;list-style:none;display:flex}.photo-detail-summary::-webkit-details-marker{display:none}.photo-detail-summary:hover{background:#ffffff0a}.photo-detail-title{color:#e4e9dc;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden}.photo-detail-price{color:#9fd6a4;font-variant-numeric:tabular-nums;flex:none;font-size:13px;font-weight:600}.photo-detail-toggle{color:#99a291;text-transform:uppercase;letter-spacing:.05em;border:1px solid #ffffff1f;border-radius:999px;flex:none;padding:3px 9px;font-size:11px;transition:color .18s}.photo-detail[open] .photo-detail-toggle{color:#cdd6c4;border-color:#fff3}.photo-detail-body{border-top:1px solid #ffffff12;padding:0 14px 13px}.photo-detail-desc{color:#c3cabb;margin:11px 0 0;font-size:13px;line-height:1.6}.photo-detail-tags{flex-wrap:wrap;gap:6px;margin:11px 0 0;padding:0;list-style:none;display:flex}.photo-detail-tags li{color:#b6c2ab;background:#ffffff0a;border:1px solid #ffffff17;border-radius:999px;padding:3px 9px;font-size:11px}.photo-detail-facts{gap:4px;margin:11px 0 0;font-size:12px;display:grid}.photo-detail-facts>div{grid-template-columns:minmax(0,38%) minmax(0,62%);gap:10px;display:grid}.photo-detail-facts dt{color:#939d8a;overflow-wrap:anywhere}.photo-detail-facts dd{color:#d5dbcd;overflow-wrap:anywhere;margin:0}.photo-viewer:has(.photo-caption) .photo-detail{top:76px;bottom:auto}@media (width<=650px){.photo-detail{width:auto;bottom:12px;left:12px;right:12px}.photo-viewer:has(.photo-caption) .photo-detail{top:70px}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
|
|
|
|
|
|
|
|
|
dist/assets/index-CX-0QtoD.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/index-CigEzVrZ.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/index-DGvjonwp.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/index-DcLscXnL.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
| 2 |
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--ease-out:cubic-bezier(0, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-x-0{inset-inline:0}.bottom-0{bottom:0}.isolate{isolation:isolate}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.h-dvh{height:100dvh}.w-full{width:100%}.shrink{flex-shrink:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.items-center{align-items:center}.justify-between{justify-content:space-between}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.capitalize{text-transform:capitalize}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}}:root{color:#deded7;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;background:#10110f;font-family:Arial,Helvetica,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,a{-webkit-tap-highlight-color:transparent}button,input{font:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible{outline-offset:5px;outline:2px solid #d7e1d1}.app{background:#11120f;min-height:320px;overflow:clip}.image-field{contain:strict;position:absolute;inset:0;overflow:clip}.image-tile{will-change:transform;transform-origin:50%;background-color:#252823;background-size:cover;border:0;border-radius:2px;padding:0;display:block;position:absolute;top:0;left:0;overflow:hidden;box-shadow:0 1px 5px #0002}.image-tile img{object-fit:cover;opacity:0;filter:brightness(.92);width:100%;height:100%;transition:opacity .12s,filter .12s}.image-tile img.loaded{opacity:1}.image-tile:hover{box-shadow:0 10px 35px #0005}.image-tile:hover img{filter:brightness()}.image-tile{opacity:var(--relevance-opacity,1);filter:blur(var(--relevance-blur,0px));transition:opacity .65s,filter .65s}.image-tile:hover,.image-tile:focus-visible{opacity:1;filter:blur()}.vignette{pointer-events:none;z-index:300;background:radial-gradient(#0000 42%,#0c0d0b30 100%),linear-gradient(0deg,#10110f99,#0000 70px);position:absolute;inset:0}.grain{z-index:301;pointer-events:none;opacity:.045;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Cpath fill='%23fff' filter='url(%23n)' opacity='.7' d='M0 0h180v180H0z'/%3E%3C/svg%3E");position:absolute;inset:0}.search-center{z-index:400;width:clamp(350px,36vw,640px);max-width:calc(100% - 44px);position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}.search-pill{-webkit-backdrop-filter:blur(26px)saturate(.7);backdrop-filter:blur(26px)saturate(.7);background:#e4e8ddbb;border-radius:999px;height:clamp(62px,4.8vw,84px);transition:background .25s,box-shadow .25s;position:relative;overflow:hidden;box-shadow:inset 0 1px #ffffff3d,0 6px 36px #00000018}.search-pill:focus-within{background:#e6eaddd4;box-shadow:inset 0 1px #ffffff52,0 5px 40px #0002}.search-pill input{color:#20251f;letter-spacing:-.035em;caret-color:#323b2d;background:0 0;border:0;outline:none;width:100%;height:100%;padding:0 58px 0 27px;font-size:clamp(22px,1.8vw,30px)}.search-pill input::placeholder{color:#565d54;opacity:1}.search-pill input::selection{background:#80957070}.clear-button{color:#4c5449;background:0 0;border:0;border-radius:50%;place-items:center;width:34px;height:34px;padding:7px;transition:background .15s;display:grid;position:absolute;top:50%;right:13px;transform:translateY(-50%)}.clear-button:hover{background:#0000000d}.clear-button svg{width:18px;height:18px}.search-progress{opacity:0;height:1px;transition:opacity .3s;position:absolute;bottom:0;left:24px;right:24px;overflow:hidden}.search-progress.visible{opacity:.5}.search-progress span{background:#47513f;height:100%;transition:width .3s;animation:1.6s ease-in-out infinite progress-glow;display:block}.search-status{letter-spacing:.01em;color:#d2d6ccc9;text-shadow:0 1px 5px #000,0 1px 12px #000;pointer-events:none;justify-content:center;align-items:center;gap:7px;min-height:20px;font-size:12px;display:flex;position:absolute;top:calc(100% + 13px);left:-28px;right:-28px}.status-dot{background:#d2d8c9;border-radius:50%;width:4px;height:4px;animation:1.6s ease-in-out infinite progress-glow}.has-error{pointer-events:auto;text-shadow:none;background:#171b16ee;border-radius:10px;flex-wrap:wrap;padding:12px;font-size:14px}.has-error button{color:#eee;background:0 0;border:0;border-bottom:1px solid #ccc;padding:0}.error-detail{text-align:center;opacity:.7;overflow-wrap:anywhere;width:100%;font-size:12px}.footer{z-index:401;color:#bac0b395;letter-spacing:.015em;padding:20px 24px;font-size:12px}.footer a{color:inherit;text-decoration:none;transition:color .2s}.footer a:hover{color:#f5f6ef}.version{opacity:.55;margin-left:5px}.footer-divider{opacity:.5;padding:0 8px}@keyframes progress-glow{0%,to{opacity:.4}50%{opacity:1}}@media (width<=650px){.search-center{width:min(82vw,400px)}.search-pill{height:60px}.search-pill input{padding-left:23px;font-size:22px}.footer{padding:16px;font-size:12px}.footer .version,.footer-divider{display:none}.footer>span{font-size:0}.footer>span:after{content:"On-device search";font-size:12px}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition:none!important;animation:none!important}}.photo-viewer{color:#e8eae3;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);background:#0c0e0bdc;border:0;width:100vw;max-width:none;height:100dvh;max-height:none;margin:0;padding:0;position:fixed;inset:0;overflow:hidden}.photo-viewer[open]{animation:.42s both viewer-reveal;display:block}.photo-viewer::backdrop{background:#0005}.photo-viewer.is-closing{opacity:0;transition:opacity .32s;animation:none}.photo-toolbar{z-index:2;pointer-events:none;justify-content:space-between;align-items:center;display:flex;position:absolute;inset:18px 24px auto}.photo-toolbar a,.photo-toolbar button{pointer-events:auto;color:#e0e5d9;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px}.photo-toolbar a{padding:12px 17px;font-size:14px;text-decoration:none}.photo-toolbar button{place-items:center;width:44px;height:44px;display:grid}.photo-toolbar svg{width:22px;height:22px}.photo-origin{pointer-events:none;color:#e0e5d9;text-overflow:ellipsis;white-space:nowrap;background:#12160fe6;border:1px solid #ffffff18;border-radius:999px;max-width:60vw;padding:12px 17px;font-size:14px;overflow:hidden}.photo-viewport{place-items:center;padding:72px 24px 40px;display:grid;position:absolute;inset:0;overflow:auto}.photo-frame{width:min(1800px, calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));aspect-ratio:var(--photo-ar);cursor:zoom-in;transform-origin:50%;background-color:#161912;background-size:100% 100%;border:0;border-radius:3px;flex:none;padding:0;display:block;box-shadow:0 12px 70px #0005}.photo-frame img{object-fit:contain;opacity:0;width:100%;height:100%;transition:opacity .3s;display:block}.photo-frame img.loaded{opacity:1}.photo-viewport.is-zoomed{display:block}.photo-viewport.is-zoomed .photo-frame{width:max(calc(100vw - 48px), calc((100dvh - 128px) * var(--photo-ar)));cursor:zoom-out;margin:0 auto}@keyframes viewer-reveal{0%{opacity:0}to{opacity:1}}.product-panel{z-index:500;-webkit-backdrop-filter:blur(22px);backdrop-filter:blur(22px);color:#e6e9e0;background:#0f120eea;flex-direction:column;display:flex;position:fixed;inset:0}.product-panel-head{border-bottom:1px solid #ffffff14;align-items:center;gap:18px;padding:20px 26px 14px;display:flex}.product-panel-title{flex:none;align-items:baseline;gap:11px;display:flex}.product-panel-title h2{letter-spacing:-.02em;margin:0;font-size:19px;font-weight:600}.product-count{color:#a8b09e;white-space:nowrap;font-size:12px}.product-search{flex:1;min-width:0}.product-search input{color:#eef1e8;background:#ffffff0f;border:1px solid #ffffff1f;border-radius:999px;outline:none;width:100%;height:42px;padding:0 18px;font-size:15px;transition:border-color .2s,background .2s}.product-search input:focus{background:#ffffff17;border-color:#ffffff45}.product-search input::placeholder{color:#8b9382}.product-search input::-webkit-search-cancel-button{filter:invert(.7)}.product-close{color:#dfe3d8;cursor:pointer;background:#ffffff0d;border:1px solid #ffffff1f;border-radius:50%;flex:none;width:40px;height:40px;font-size:15px;transition:background .18s}.product-close:hover{background:#ffffff1c}.product-brands{border-bottom:1px solid #ffffff10;flex-wrap:wrap;gap:8px;padding:13px 26px;display:flex}.product-brands button{color:#c3c9ba;cursor:pointer;background:0 0;border:1px solid #ffffff1a;border-radius:999px;padding:7px 15px;font-size:13px;transition:background .18s,color .18s,border-color .18s}.product-brands button:hover{color:#eef1e8;background:#ffffff12}.product-brands button.active{color:#1d231b;background:#e4e8dd;border-color:#e4e8dd}.product-body{overscroll-behavior:contain;flex:1;padding:20px 26px 40px;overflow-y:auto}.product-grid{grid-template-columns:repeat(auto-fill,minmax(178px,1fr));gap:15px;display:grid}.product-card{color:inherit;text-align:left;cursor:pointer;background:#1a1e17;border:1px solid #ffffff14;border-radius:11px;flex-direction:column;padding:0;transition:transform .22s cubic-bezier(.2,.7,.3,1),border-color .22s,box-shadow .22s;display:flex;overflow:hidden}.product-card:hover,.product-card:focus-visible{border-color:#fff3;transform:translateY(-3px);box-shadow:0 12px 34px #00000059}.product-card img{aspect-ratio:4/3;object-fit:cover;background:#12160f;width:100%;display:block}.product-no-image{aspect-ratio:4/3;color:#6f7767;background:#171b14;place-items:center;width:100%;font-size:12px;display:grid}.product-card-body{flex-direction:column;gap:3px;padding:10px 12px 12px;display:flex}.product-brand{letter-spacing:.07em;text-transform:uppercase;color:#8b9a7c;font-size:10.5px}.product-name{-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:13.5px;line-height:1.34;display:-webkit-box;overflow:hidden}.product-price{color:#dfe6d2;margin-top:2px;font-size:13px;font-weight:600}.product-sku{color:#8b9382;margin-top:2px;font-size:11.5px}.product-more{color:#e8ece1;cursor:pointer;background:#ffffff0f;border:1px solid #ffffff26;border-radius:999px;margin:24px auto 0;padding:13px 30px;font-size:14px;transition:background .2s;display:block}.product-more:hover{background:#ffffff1c}.product-more-total{color:#8b9382;margin-left:5px;font-size:12.5px}.product-empty{text-align:center;color:#99a08f;padding:60px 0;font-size:14px}.footer-catalog{color:#dfe3d8;cursor:pointer;background:#ffffff0f;border:1px solid #ffffff1f;border-radius:999px;padding:9px 17px;font-size:13px;transition:background .2s,border-color .2s}.footer-catalog:hover{background:#ffffff1c;border-color:#ffffff38}.footer-catalog kbd{background:#ffffff1a;border-radius:4px;margin-left:6px;padding:1px 6px;font-family:inherit;font-size:11px}.product-detail{z-index:600;place-items:center;padding:26px;display:grid;position:fixed;inset:0}.product-detail-backdrop{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);cursor:zoom-out;background:#050705d9;border:0;position:absolute;inset:0}.product-detail-panel{background:#161a13;border-radius:15px;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:0;width:min(1080px,100%);max-height:min(88dvh,900px);animation:.32s both viewer-reveal;display:grid;position:relative;overflow:hidden;box-shadow:0 30px 90px #000000a6}.product-detail-close{z-index:2;color:#e2e6db;cursor:pointer;background:#0d100ae0;border:1px solid #ffffff24;border-radius:50%;width:38px;height:38px;font-size:14px;position:absolute;top:13px;right:13px}.product-detail-close:hover{background:#0d100a}.product-detail-media{background:#0e110c;flex-direction:column;display:flex}.product-detail-media>img{object-fit:contain;width:100%;height:auto;max-height:60dvh;display:block}.product-detail-noimage{color:#6f7767;place-items:center;min-height:260px;font-size:13px;display:grid}.product-thumbs{gap:8px;padding:12px;display:flex;overflow-x:auto}.product-thumbs button{cursor:pointer;opacity:.55;background:#171b14;border:2px solid #0000;border-radius:8px;flex:none;width:60px;height:60px;padding:0;transition:opacity .18s,border-color .18s;overflow:hidden}.product-thumbs button:hover{opacity:.85}.product-thumbs button.active{opacity:1;border-color:#e4e8dd}.product-thumbs img{object-fit:cover;width:100%;height:100%;display:block}.product-detail-info{padding:26px 30px 30px;overflow-y:auto}.product-detail-brand{letter-spacing:.09em;text-transform:uppercase;color:#8b9a7c;font-size:11px}.product-detail-info h3{letter-spacing:-.02em;margin:7px 0 12px;font-size:23px;font-weight:600;line-height:1.26}.product-detail-meta{flex-wrap:wrap;align-items:baseline;gap:14px;display:flex}.product-detail-price{color:#dfe6d2;font-size:21px;font-weight:600}.product-detail-sku{color:#8b9382;font-size:13px}.product-detail-link{color:#b9c9a4;border-bottom:1px solid #b9c9a45c;margin-top:14px;padding-bottom:1px;font-size:13.5px;text-decoration:none;display:inline-block}.product-detail-link:hover{color:#d5e3c1;border-color:#d5e3c1}.product-detail-summary{color:#c3c9ba;margin:18px 0 0;font-size:14px;line-height:1.62}.product-detail-feats{flex-wrap:wrap;gap:7px;margin-top:18px;display:flex}.product-detail-feats span{color:#c8cebd;background:#ffffff10;border:1px solid #ffffff14;border-radius:999px;padding:5px 11px;font-size:12px}.product-detail-specs{border-collapse:collapse;width:100%;margin-top:22px;font-size:13px}.product-detail-specs caption{text-align:left;letter-spacing:.08em;text-transform:uppercase;color:#8b9a7c;padding-bottom:9px;font-size:11.5px}.product-detail-specs th,.product-detail-specs td{text-align:left;vertical-align:top;border-top:1px solid #ffffff12;padding:9px 0;line-height:1.5}.product-detail-specs th{color:#929a89;width:40%;padding-right:14px;font-weight:500}.product-detail-specs td{color:#d3d8cb}@media (width<=860px){.product-detail-panel{grid-template-columns:1fr;max-height:92dvh;overflow-y:auto}.product-detail-media>img{max-height:42dvh}.product-detail-info{padding:20px}.product-panel-head{flex-wrap:wrap;padding:16px 18px 12px}.product-brands,.product-body{padding-left:18px;padding-right:18px}.product-grid{grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:11px}.footer-catalog{padding:7px 13px;font-size:12px}.footer-catalog kbd{display:none}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
|
dist/assets/index.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/neon-frame.css
DELETED
|
@@ -1,170 +0,0 @@
|
|
| 1 |
-
/* ── Neon frame: a light that runs around the picture ──────────────────────
|
| 2 |
-
*
|
| 3 |
-
* Loaded from index.html AFTER dist/assets/app.css, and deliberately overriding
|
| 4 |
-
* the ring that bundle paints. It exists because that ring is wrong in a way
|
| 5 |
-
* only shows up on non-square tiles.
|
| 6 |
-
*
|
| 7 |
-
* THE BUG IT UNDOES
|
| 8 |
-
* -----------------
|
| 9 |
-
* The @keyframes neon-run shipped in app.css rotated the whole ::before layer
|
| 10 |
-
* by one turn. That layer carries BOTH the conic gradient and the mask, so the
|
| 11 |
-
* mask rotated with it and stopped lining up with the tile's edges. On a 16:9
|
| 12 |
-
* photograph the painted border tilted off the frame and the highlight read as
|
| 13 |
-
* a skewed smear rather than a light going round the picture -- which is
|
| 14 |
-
* exactly what "a static neon border" looked like on screen. A tile that
|
| 15 |
-
* happened to be nearly square hid the bug, so the effect only ever looked
|
| 16 |
-
* broken on real photographs.
|
| 17 |
-
*
|
| 18 |
-
* WHAT MOVES NOW
|
| 19 |
-
* --------------
|
| 20 |
-
* The layer never transforms. What animates is the conic gradient's own start
|
| 21 |
-
* angle, --neon-angle, so the bright head walks the perimeter and follows the
|
| 22 |
-
* rectangle exactly: it runs down the long edge, turns the corner and comes
|
| 23 |
-
* back along the short edge, like a light circling a picture frame.
|
| 24 |
-
*
|
| 25 |
-
* `--neon-angle` is REGISTERED with @property because an unregistered custom
|
| 26 |
-
* property is an untyped token the engine cannot interpolate -- a 0deg to
|
| 27 |
-
* 360deg keyframe on one flips once at the midpoint instead of sweeping. That
|
| 28 |
-
* flip is worse than the static ring, which is why the animation is gated on
|
| 29 |
-
* @supports below: a browser without registration gets the gradient and no
|
| 30 |
-
* motion at all, i.e. a lit frame with the head parked at the top.
|
| 31 |
-
*
|
| 32 |
-
* `inherits: false` keeps the angle on the ring itself. The tile is transformed
|
| 33 |
-
* every frame by the field engine, and an inherited animated property would be
|
| 34 |
-
* recomputed against a moving box -- which is how the gradient ends up sliding
|
| 35 |
-
* relative to the picture it is supposed to be travelling around.
|
| 36 |
-
*
|
| 37 |
-
* TWO LAYERS, ONE CLOCK
|
| 38 |
-
* ---------------------
|
| 39 |
-
* ::before is the crisp stroke. ::after is the halo: the same sweep, spread
|
| 40 |
-
* over a wider arc and blurred, so the light blooms off the edge instead of
|
| 41 |
-
* being cut flat against it. They are one pseudo-element apart because a
|
| 42 |
-
* pseudo-element can only carry one background, and a neon frame needs both the
|
| 43 |
-
* stroke and the glow.
|
| 44 |
-
*
|
| 45 |
-
* Both animate over the same duration from the same start angle, so the halo
|
| 46 |
-
* stays locked to the head of the stroke on every lap rather than drifting
|
| 47 |
-
* ahead of it.
|
| 48 |
-
*
|
| 49 |
-
* `mask` is what turns the full-size gradient into a ring. Without the composite
|
| 50 |
-
* the gradient would fill the whole tile and hide the photograph. The -webkit-
|
| 51 |
-
* forms are repeated because Safari needs the prefixed one, and the standard
|
| 52 |
-
* alone would leave Safari with an opaque tile.
|
| 53 |
-
*
|
| 54 |
-
* src/neon-frame.css carries the same rules, so a rebuild produces the same
|
| 55 |
-
* frame and this file becomes a no-op rather than a divergence.
|
| 56 |
-
*/
|
| 57 |
-
@property --neon-angle {
|
| 58 |
-
syntax: '<angle>';
|
| 59 |
-
inherits: false;
|
| 60 |
-
initial-value: 0deg;
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
.image-tile {
|
| 64 |
-
--neon: #22f5d0;
|
| 65 |
-
--neon-soft: #7ef9ff;
|
| 66 |
-
--neon-run: 2.6s;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
/* The crisp stroke: a white-hot head at 0deg, a comet tail fading back round
|
| 70 |
-
to 250deg, and a clear gap before the head returns -- so the eye reads a
|
| 71 |
-
single light travelling, not a ring that pulses in place. */
|
| 72 |
-
.image-tile.exact::before,
|
| 73 |
-
.image-tile.featured::before {
|
| 74 |
-
padding: 1px;
|
| 75 |
-
background: conic-gradient(from var(--neon-angle, 0deg),
|
| 76 |
-
var(--neon) 0deg,
|
| 77 |
-
var(--neon-soft) 38deg,
|
| 78 |
-
#ffffff 70deg,
|
| 79 |
-
var(--neon-soft) 102deg,
|
| 80 |
-
var(--neon) 148deg,
|
| 81 |
-
#22f5d05c 250deg,
|
| 82 |
-
#22f5d000 344deg,
|
| 83 |
-
var(--neon) 360deg);
|
| 84 |
-
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 85 |
-
-webkit-mask-composite: xor;
|
| 86 |
-
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 87 |
-
mask-composite: exclude;
|
| 88 |
-
/* Any transform here drags the mask off the frame, which is the bug above.
|
| 89 |
-
Stated explicitly so a later edit cannot reintroduce it by accident. */
|
| 90 |
-
transform: none;
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
/* The halo. Same sweep from the same angle, spread wider so the light spills
|
| 94 |
-
both inward off the edge and outward into the dark around the tile. */
|
| 95 |
-
.image-tile.exact::after,
|
| 96 |
-
.image-tile.featured::after {
|
| 97 |
-
padding: 2.5px;
|
| 98 |
-
background: conic-gradient(from var(--neon-angle, 0deg),
|
| 99 |
-
#22f5d000 0deg,
|
| 100 |
-
#22f5d085 62deg,
|
| 101 |
-
#b6fdffc4 92deg,
|
| 102 |
-
#22f5d05e 138deg,
|
| 103 |
-
#22f5d01f 250deg,
|
| 104 |
-
#22f5d000 348deg,
|
| 105 |
-
#22f5d000 360deg);
|
| 106 |
-
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 107 |
-
-webkit-mask-composite: xor;
|
| 108 |
-
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
| 109 |
-
mask-composite: exclude;
|
| 110 |
-
filter: blur(4px);
|
| 111 |
-
opacity: .82;
|
| 112 |
-
/* The stock rule animated box-shadow here instead. A box-shadow breathes in
|
| 113 |
-
place -- it cannot run along the perimeter, because the shadow's shape is
|
| 114 |
-
the tile's shape and never travels round it. */
|
| 115 |
-
box-shadow: none;
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
/* The head sweeps one full turn clockwise; the halo sweeps the same turn over
|
| 119 |
-
the same duration, so the two re-align every lap instead of drifting apart.
|
| 120 |
-
*
|
| 121 |
-
* NOTE this is a REPLACEMENT for app.css's neon-run, not an addition. Keyframes
|
| 122 |
-
* are global and the last definition of a name wins, so this block takes the
|
| 123 |
-
* name over from the built one for the whole page -- which is exactly what
|
| 124 |
-
* retires its `rotate(1turn)`, and also why the ungated reset below matters.
|
| 125 |
-
*/
|
| 126 |
-
@keyframes neon-run {
|
| 127 |
-
from { --neon-angle: 0deg; }
|
| 128 |
-
to { --neon-angle: 360deg; }
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
/* Both pseudo-elements default to NO animation, outside any @supports gate.
|
| 132 |
-
*
|
| 133 |
-
* This has to be unconditional, and it is the subtle part of this file.
|
| 134 |
-
* app.css applies `animation: neon-run ...` to these selectors with no feature
|
| 135 |
-
* test of its own, so a browser that cannot register @property would otherwise
|
| 136 |
-
* run the keyframes above -- animating an untyped token, which snaps at the
|
| 137 |
-
* halfway point. The ring would blink once per cycle and look broken in a way
|
| 138 |
-
* the old static ring never did.
|
| 139 |
-
*
|
| 140 |
-
* Setting `animation: none` first and enabling it only inside @supports makes
|
| 141 |
-
* the unsupported case the safe one: a lit frame, head parked at the top, no
|
| 142 |
-
* motion. It also has to win on order against app.css, which this file loads
|
| 143 |
-
* after, so the @supports rule below re-asserts the animation.
|
| 144 |
-
*/
|
| 145 |
-
.image-tile.exact::before,
|
| 146 |
-
.image-tile.featured::before,
|
| 147 |
-
.image-tile.exact::after,
|
| 148 |
-
.image-tile.featured::after { animation: none; }
|
| 149 |
-
|
| 150 |
-
@supports at-rule(@property) {
|
| 151 |
-
.image-tile.exact::before,
|
| 152 |
-
.image-tile.featured::before,
|
| 153 |
-
.image-tile.exact::after,
|
| 154 |
-
.image-tile.featured::after {
|
| 155 |
-
animation: neon-run var(--neon-run) linear infinite;
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
/* A featured tile -- the exact answer to the query, or a photograph just added
|
| 160 |
-
on vai-avatar -- runs faster and thinner, so the newest photographs are
|
| 161 |
-
distinguishable from an exact hit by pace as well as by size. */
|
| 162 |
-
.image-tile.featured { --neon-run: 2s; }
|
| 163 |
-
.image-tile.featured::before { padding: 1.5px; }
|
| 164 |
-
|
| 165 |
-
/* A slow, low-frequency sweep reads as "lit"; a fast strobe reads as an alarm,
|
| 166 |
-
and this frame marks a correct answer rather than an error. */
|
| 167 |
-
@media (prefers-reduced-motion: reduce) {
|
| 168 |
-
.image-tile.exact::before, .image-tile.exact::after,
|
| 169 |
-
.image-tile.featured::before, .image-tile.featured::after { animation: none; }
|
| 170 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dist/assets/share-fix.js
DELETED
|
@@ -1,193 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Repairs the share link on vai-avatar interior photographs, in the browser.
|
| 3 |
-
* =========================================================================
|
| 4 |
-
*
|
| 5 |
-
* THE SYMPTOM
|
| 6 |
-
* A photograph published on vai-avatar opens its share page at
|
| 7 |
-
*
|
| 8 |
-
* https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e
|
| 9 |
-
*
|
| 10 |
-
* which is vai-avatar's "Not Found" page. Every interior photograph is
|
| 11 |
-
* affected, on the tile's share button and on the viewer's "Xem trang" alike.
|
| 12 |
-
*
|
| 13 |
-
* WHY IT HAPPENS
|
| 14 |
-
* avatar-interiors.js gives every such photograph a namespaced `sku` of
|
| 15 |
-
* `avatarimg:<image_id>`, so it travels the same code path as a catalogue
|
| 16 |
-
* product: same viewer, same detail panel, same neon. Namespacing is
|
| 17 |
-
* deliberate -- it is what stops a 16-hex image_id colliding with a baked
|
| 18 |
-
* interior's id in merge.js's de-duplication set.
|
| 19 |
-
*
|
| 20 |
-
* But `avatarimg:...` is not an identity the product catalogue knows, and
|
| 21 |
-
* vai-avatar's `/share/p/` resolves against exactly that catalogue. So the
|
| 22 |
-
* photograph was sent to the product route, where no such SKU exists, and the
|
| 23 |
-
* Space answered Not Found. There is nothing to fix on vai-avatar's side: the
|
| 24 |
-
* record it would need to find does not exist. The photograph is an interior,
|
| 25 |
-
* and the interior route is `/share/i/<image_id>`.
|
| 26 |
-
*
|
| 27 |
-
* WHY THIS IS A FILE AND NOT ONLY THE SOURCE FIX
|
| 28 |
-
* src/assets.js now routes on `avatarImageId` first, and that is the real fix.
|
| 29 |
-
* It is not, though, what the browser runs yet: this Space is `sdk: static`, so
|
| 30 |
-
* the page is served from dist/, and dist/assets/index.js was built before that
|
| 31 |
-
* fix existed. The neon frame has the same problem -- dist/assets/app.css still
|
| 32 |
-
* carries the old `@keyframes neon-run { transform: rotate(1turn) }`, which
|
| 33 |
-
* rotated the mask off the frame. Rebuilding needs a shell.
|
| 34 |
-
*
|
| 35 |
-
* So this corrects the URL where it is consumed, with no rebuild, and the
|
| 36 |
-
* source fix carries the same logic forward. The two agree by construction:
|
| 37 |
-
* both send `avatarimg:<id>` to `/share/i/<id>`.
|
| 38 |
-
*
|
| 39 |
-
* WHAT IT COSTS
|
| 40 |
-
* One capture-phase listener and one MutationObserver. The observer watches
|
| 41 |
-
* only `href` on the whole document -- the field already rebuilds 80 tiles per
|
| 42 |
-
* keystroke, but share hrefs change on layout passes, not on the per-frame
|
| 43 |
-
* transform writes, so this sees a handful of callbacks rather than 60/s. The
|
| 44 |
-
* work per callback is one regex against an attribute value that is almost
|
| 45 |
-
* always not a share href.
|
| 46 |
-
*
|
| 47 |
-
* Once the bundle is rebuilt from src/assets.js this repairs nothing, because
|
| 48 |
-
* the hrefs it would find are already correct. It stays loaded rather than
|
| 49 |
-
* un-loading itself: it is a few regexes against a handful of anchors, and a
|
| 50 |
-
* self-disarming timer is a far worse thing to reason about than inert code.
|
| 51 |
-
*/
|
| 52 |
-
|
| 53 |
-
const STUDIO = 'https://bep40-vai-avatar.hf.space';
|
| 54 |
-
|
| 55 |
-
/** Anchors worth checking at all: the viewer plus the tile share buttons. */
|
| 56 |
-
const SHARE_ANCHORS = 'a[href*="/share/"]';
|
| 57 |
-
|
| 58 |
-
/**
|
| 59 |
-
* A share href that names an interior photograph by its namespaced sku.
|
| 60 |
-
*
|
| 61 |
-
* Matches the literal `avatarimg` immediately after `/share/p/`, then either
|
| 62 |
-
* separator: the value may not have been encoded (`avatarimg:`) or may have
|
| 63 |
-
* been (`avatarimg%3A`). Only that one segment is taken as the separator, so an
|
| 64 |
-
* id that itself contains a colon still comes through whole.
|
| 65 |
-
*/
|
| 66 |
-
const BROKEN = /\/share\/p\/(avatarimg)(?::|%3A)([^/?#]+)/i;
|
| 67 |
-
|
| 68 |
-
/**
|
| 69 |
-
* The same URL, routed as the interior it is.
|
| 70 |
-
*
|
| 71 |
-
* The id is decoded first: it arrived percent-encoded inside the URL, and
|
| 72 |
-
* re-encoding the raw `%3A` would produce `%253A`. `decodeURIComponent` throws
|
| 73 |
-
* on a malformed escape, so a bad id is left alone rather than taking the whole
|
| 74 |
-
* listener down -- one broken href must not break the page it sits on.
|
| 75 |
-
*
|
| 76 |
-
* An empty id is rejected too: `/share/i/` with nothing after it is its own
|
| 77 |
-
* 404, so redirecting there would trade one dead link for another.
|
| 78 |
-
*/
|
| 79 |
-
function interiorURL(href) {
|
| 80 |
-
const match = BROKEN.exec(href);
|
| 81 |
-
if (!match) return null;
|
| 82 |
-
let id;
|
| 83 |
-
try {
|
| 84 |
-
id = decodeURIComponent(match[2]);
|
| 85 |
-
} catch {
|
| 86 |
-
return null;
|
| 87 |
-
}
|
| 88 |
-
id = id.trim();
|
| 89 |
-
return id ? `${STUDIO}/share/i/${encodeURIComponent(id)}` : null;
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
/** Rewrite one anchor's href if it is mis-routed. Returns true if it changed. */
|
| 93 |
-
function repair(anchor) {
|
| 94 |
-
const href = anchor?.getAttribute?.('href');
|
| 95 |
-
if (!href) return false;
|
| 96 |
-
const fixed = interiorURL(href);
|
| 97 |
-
if (!fixed || fixed === href) return false;
|
| 98 |
-
anchor.setAttribute('href', fixed);
|
| 99 |
-
// Assigning the IDL property as well is redundant with setAttribute for a
|
| 100 |
-
// fully-qualified URL, and deliberately so: it keeps `anchor.href` on the
|
| 101 |
-
// corrected string for any later read, however the property resolves.
|
| 102 |
-
anchor.href = fixed;
|
| 103 |
-
return true;
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
/**
|
| 107 |
-
* Sweep `root` and everything under it. `root` defaults to the document.
|
| 108 |
-
*
|
| 109 |
-
* `root` ITSELF is checked, not only its descendants, and that is not
|
| 110 |
-
* redundancy. React sets props on a detached element and then inserts it, so
|
| 111 |
-
* the viewer's "Xem trang" anchor arrives in the tree already carrying its
|
| 112 |
-
* href -- which means the `href` mutation happened off-tree and no observer ever
|
| 113 |
-
* saw it. The only record that describes it is "this node was added", so a
|
| 114 |
-
* descendant-only sweep would miss it and the visible link would stay wrong
|
| 115 |
-
* until it was clicked.
|
| 116 |
-
*
|
| 117 |
-
* The tile share anchors take the opposite path and are covered either way:
|
| 118 |
-
* ImageField appends the anchor empty and fills the href later on a live
|
| 119 |
-
* element, which the `attributes` branch below sees.
|
| 120 |
-
*/
|
| 121 |
-
function scan(root = document) {
|
| 122 |
-
if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(SHARE_ANCHORS)) {
|
| 123 |
-
repair(root);
|
| 124 |
-
}
|
| 125 |
-
for (const anchor of root.querySelectorAll?.(SHARE_ANCHORS) ?? []) {
|
| 126 |
-
repair(anchor);
|
| 127 |
-
}
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
const observer = new MutationObserver(records => {
|
| 131 |
-
for (const record of records) {
|
| 132 |
-
if (record.type === 'attributes') {
|
| 133 |
-
// The href changed on an element already in the field.
|
| 134 |
-
repair(record.target);
|
| 135 |
-
continue;
|
| 136 |
-
}
|
| 137 |
-
for (const node of record.addedNodes) {
|
| 138 |
-
// Both places a share href is rendered: the anchor on a field tile, and
|
| 139 |
-
// the viewer's "Xem trang".
|
| 140 |
-
if (node.nodeType === Node.ELEMENT_NODE) scan(node);
|
| 141 |
-
}
|
| 142 |
-
}
|
| 143 |
-
});
|
| 144 |
-
|
| 145 |
-
function start() {
|
| 146 |
-
scan();
|
| 147 |
-
observer.observe(document.documentElement, {
|
| 148 |
-
// href is the only attribute either the React viewer or the field engine
|
| 149 |
-
// sets to move a share link, so nothing else needs watching.
|
| 150 |
-
attributes: true,
|
| 151 |
-
attributeFilter: ['href'],
|
| 152 |
-
subtree: true,
|
| 153 |
-
childList: true,
|
| 154 |
-
});
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
if (document.readyState === 'loading') {
|
| 158 |
-
document.addEventListener('DOMContentLoaded', start, { once: true });
|
| 159 |
-
} else {
|
| 160 |
-
start();
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
/**
|
| 164 |
-
* A capture-phase click handler, which is what actually guarantees the fix.
|
| 165 |
-
*
|
| 166 |
-
* The observer corrects the visible href for the reader, but observer callbacks
|
| 167 |
-
* run as microtasks after the mutation -- and a click can be dispatched on an
|
| 168 |
-
* anchor before any callback has run for the node that inserted it. Repairing
|
| 169 |
-
* during capture makes the corrected value the one the browser navigates to,
|
| 170 |
-
* because the default action resolves the URL at activation time, after capture
|
| 171 |
-
* has run.
|
| 172 |
-
*
|
| 173 |
-
* This is also the backstop that makes the fix independent of the observer
|
| 174 |
-
* catching anything at all: however the link got into the document, clicking it
|
| 175 |
-
* resolves it correctly.
|
| 176 |
-
*
|
| 177 |
-
* The event is deliberately NOT prevented: the aim is to redirect the
|
| 178 |
-
* navigation, not suppress it, so middle-click, ctrl-click and "open in new
|
| 179 |
-
* tab" all carry the corrected URL exactly as they would on a right one.
|
| 180 |
-
*/
|
| 181 |
-
document.addEventListener('click', event => {
|
| 182 |
-
const anchor = event.target?.closest?.('a[href]');
|
| 183 |
-
if (anchor) repair(anchor);
|
| 184 |
-
}, true);
|
| 185 |
-
|
| 186 |
-
/**
|
| 187 |
-
* Exposed for scripts/test-neon-ui.mjs.
|
| 188 |
-
*
|
| 189 |
-
* Not a debug hook: that test builds an anchor by hand with a deliberately
|
| 190 |
-
* broken href, because the field's own links depend on a live network read of
|
| 191 |
-
* the vai-avatar interior index that may not have landed when the test runs.
|
| 192 |
-
*/
|
| 193 |
-
globalThis.__repairInteriorShareLinks = () => scan();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dist/assets/worker-Bq7vdIsj.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/{worker-Bz7I_x9L.js → worker-DHCTNpZY.js}
RENAMED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-hEU_3YGf.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-hGPAZ_gn.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-n0zt6z0K.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/assets/worker-nnOdWzlm.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/brand/vaistudio_center.png
DELETED
Git LFS Details
|
dist/catalogue/products.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/catalogue/promos.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dist/index.html
CHANGED
|
@@ -8,22 +8,10 @@
|
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
<title>Semantic Image Field — Nội thất</title>
|
| 11 |
-
<script type="module" crossorigin src="./assets/index.js"></script>
|
| 12 |
-
<link rel="stylesheet" crossorigin href="./assets/
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
-
<!--
|
| 16 |
-
This is the BUILD TEMPLATE, not the served page.
|
| 17 |
-
|
| 18 |
-
vite.config.js sets build.rollupOptions.input to this file, so Vite emits
|
| 19 |
-
dist/app.html with the hashed asset references injected. The Space serves
|
| 20 |
-
the ROOT index.html, which is hand-maintained and loads ./dist/assets/…
|
| 21 |
-
|
| 22 |
-
Keeping them separate matters: if the root index.html were also the build
|
| 23 |
-
input, `vite build` would rewrite it, and the very first build would point
|
| 24 |
-
the live Space at bundles that do not exist yet — a blank page with no
|
| 25 |
-
error. See app.html in the repo root.
|
| 26 |
-
-->
|
| 27 |
<div id="root"></div>
|
| 28 |
</body>
|
| 29 |
-
</html>
|
|
|
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
<title>Semantic Image Field — Nội thất</title>
|
| 11 |
+
<script type="module" crossorigin src="./assets/index-CigEzVrZ.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="./assets/index-DcLscXnL.css">
|
| 13 |
</head>
|
| 14 |
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
<div id="root"></div>
|
| 16 |
</body>
|
| 17 |
+
</html>
|
dist/share/og-image.png
DELETED
Git LFS Details
|
index.html
CHANGED
|
@@ -4,77 +4,12 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
-
<meta name="description" content="
|
| 8 |
-
<meta name="robots" content="index, follow" />
|
| 9 |
-
<meta name="keywords" content="tìm ảnh theo ngữ nghĩa, tìm kiếm ảnh nội thất, AI tìm ảnh, V.AI STUDIO, VIBE CODE AI, Malloca, Bosch, Hafele, ảnh nội thất Việt Nam" />
|
| 10 |
-
<meta property="og:title" content="Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên | V.AI STUDIO" />
|
| 11 |
-
<meta property="og:type" content="website" />
|
| 12 |
-
<meta property="og:description" content="Nhập mô tả bằng tiếng Việt và AI tìm đúng ảnh nội thất, sản phẩm gia dụng. Đồng bộ tự động ảnh mới nhất từ vai-avatar. Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
|
| 13 |
-
<meta property="og:url" content="https://huggingface.co/spaces/bep40/semantic-image-field" />
|
| 14 |
-
<meta property="og:image" content="https://huggingface.co/spaces/bep40/semantic-image-field/resolve/main/dist/share/og-image.png" />
|
| 15 |
-
<meta property="og:image:width" content="1200" />
|
| 16 |
-
<meta property="og:image:height" content="675" />
|
| 17 |
-
<meta property="og:image:alt" content="12 ảnh nội thất và sản phẩm mới nhất từ V.AI STUDIO" />
|
| 18 |
-
<meta property="og:site_name" content="V.AI STUDIO" />
|
| 19 |
-
<meta name="twitter:card" content="summary_large_image" />
|
| 20 |
-
<meta name="twitter:title" content="Thư viện tìm kiếm ảnh theo ngữ nghĩa ngôn ngữ tự nhiên | V.AI STUDIO" />
|
| 21 |
-
<meta name="twitter:description" content="Nhập mô tả bằng tiếng Việt và AI tìm đúng ảnh nội thất, sản phẩm gia dụng. Web App AI thuộc hệ sinh thái VIBE CODE AI của V.AI STUDIO" />
|
| 22 |
-
<meta name="twitter:image" content="https://huggingface.co/spaces/bep40/semantic-image-field/resolve/main/dist/share/og-image.png" />
|
| 23 |
-
<link rel="canonical" href="https://huggingface.co/spaces/bep40/semantic-image-field" />
|
| 24 |
<link rel="icon" type="image/svg+xml" href="./dist/favicon.svg" />
|
| 25 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 26 |
-
<title>
|
| 27 |
-
<
|
| 28 |
-
|
| 29 |
-
(that is app.html), so `vite build` never rewrites it.
|
| 30 |
-
|
| 31 |
-
The two references below are the only thing tying the live Space to a
|
| 32 |
-
bundle, so they must always name files that EXIST.
|
| 33 |
-
|
| 34 |
-
vite.config.js pins entryFileNames/assetsFileNames to stable names
|
| 35 |
-
(assets/index.js, assets/app.css), so `npm run build` is sufficient on its
|
| 36 |
-
own -- there is no content hash to chase. The hashed names that used to be
|
| 37 |
-
listed here (index-CigEzVrZ.js / index-DcLscXnL.css) were deleted:
|
| 38 |
-
those bundles were built from a source tree that had no product code at
|
| 39 |
-
all, so the live Space was serving a build without the catalogue
|
| 40 |
-
feature. See "Why the bundle was rebuilt" in README.md.
|
| 41 |
-
-->
|
| 42 |
-
<script type="module" crossorigin src="./dist/assets/index.js?v=20261110-1"></script>
|
| 43 |
-
<link rel="stylesheet" crossorigin href="./dist/assets/app.css?v=20261110-1">
|
| 44 |
-
|
| 45 |
-
<!--
|
| 46 |
-
Loaded AFTER app.css, and deliberately overriding it.
|
| 47 |
-
|
| 48 |
-
The ring app.css paints is the old one: its @keyframes neon-run rotated
|
| 49 |
-
the whole ::before layer by a turn, and because that layer carries the mask
|
| 50 |
-
as well as the gradient, the mask rotated with it and stopped lining up
|
| 51 |
-
with the tile's edges. On a non-square photograph the painted border tilted
|
| 52 |
-
off the frame, so the highlight read as a skewed smear rather than a light
|
| 53 |
-
going round the picture -- which is what "a static neon border" looked
|
| 54 |
-
like. src/index.css already carries the angle-based version, so a rebuild
|
| 55 |
-
makes this stylesheet a no-op rather than a divergence. Order matters: it
|
| 56 |
-
has to come after app.css to win, and it must NOT be renamed by the build.
|
| 57 |
-
-->
|
| 58 |
-
<link rel="stylesheet" href="./dist/assets/neon-frame.css?v=20261110-1" />
|
| 59 |
-
|
| 60 |
-
<!--
|
| 61 |
-
Repairs the share link on vai-avatar interior photographs.
|
| 62 |
-
|
| 63 |
-
src/assets.js routes on `avatarImageId` first, which is the real fix, but
|
| 64 |
-
this Space is `sdk: static` and serves dist/assets/index.js -- a bundle
|
| 65 |
-
built before that fix existed. So every photograph still opens
|
| 66 |
-
/share/p/avatarimg%3A... , which is vai-avatar's "Not Found" page: the
|
| 67 |
-
`avatarimg:` sku is a namespace for de-duplication, not a catalogue
|
| 68 |
-
identity, and /share/p/ resolves against the product catalogue where no
|
| 69 |
-
such SKU exists. This corrects the href where it is consumed, with no
|
| 70 |
-
rebuild, and goes inert once the bundle is rebuilt.
|
| 71 |
-
|
| 72 |
-
Loaded as a module, and BEFORE the app bundle's work matters because it
|
| 73 |
-
installs a capture-phase click listener on document -- the phase in which
|
| 74 |
-
a mis-routed href can still be corrected before the browser resolves it
|
| 75 |
-
for navigation.
|
| 76 |
-
-->
|
| 77 |
-
<script type="module" src="./dist/assets/share-fix.js?v=20261110-1"></script>
|
| 78 |
</head>
|
| 79 |
<body>
|
| 80 |
<div id="root"></div>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta name="theme-color" content="#111210" />
|
| 7 |
+
<meta name="description" content="Tìm ảnh nội thất Việt Nam theo ý nghĩa. Trường ảnh động chạy hoàn toàn trong trình duyệt bằng Transformers.js." />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
<link rel="icon" type="image/svg+xml" href="./dist/favicon.svg" />
|
| 9 |
<link rel="preconnect" href="https://huggingface.co" />
|
| 10 |
+
<title>Semantic Image Field — Nội thất</title>
|
| 11 |
+
<script type="module" crossorigin src="./dist/assets/index-CigEzVrZ.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="./dist/assets/index-DcLscXnL.css">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
package-lock.json
CHANGED
|
@@ -26,7 +26,6 @@
|
|
| 26 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 27 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 28 |
"globals": "^17.12.0",
|
| 29 |
-
"playwright": "^1.63.0",
|
| 30 |
"vite": "^8.3.0"
|
| 31 |
},
|
| 32 |
"engines": {
|
|
@@ -4346,35 +4345,6 @@
|
|
| 4346 |
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
| 4347 |
"license": "MIT"
|
| 4348 |
},
|
| 4349 |
-
"node_modules/playwright": {
|
| 4350 |
-
"version": "1.63.0",
|
| 4351 |
-
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
| 4352 |
-
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
| 4353 |
-
"dev": true,
|
| 4354 |
-
"license": "Apache-2.0",
|
| 4355 |
-
"dependencies": {
|
| 4356 |
-
"playwright-core": "1.63.0"
|
| 4357 |
-
},
|
| 4358 |
-
"bin": {
|
| 4359 |
-
"playwright": "cli.js"
|
| 4360 |
-
},
|
| 4361 |
-
"engines": {
|
| 4362 |
-
"node": ">=20"
|
| 4363 |
-
}
|
| 4364 |
-
},
|
| 4365 |
-
"node_modules/playwright-core": {
|
| 4366 |
-
"version": "1.63.0",
|
| 4367 |
-
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
| 4368 |
-
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
| 4369 |
-
"dev": true,
|
| 4370 |
-
"license": "Apache-2.0",
|
| 4371 |
-
"bin": {
|
| 4372 |
-
"playwright-core": "cli.js"
|
| 4373 |
-
},
|
| 4374 |
-
"engines": {
|
| 4375 |
-
"node": ">=20"
|
| 4376 |
-
}
|
| 4377 |
-
},
|
| 4378 |
"node_modules/possible-typed-array-names": {
|
| 4379 |
"version": "1.0.0",
|
| 4380 |
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
|
|
|
| 26 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 27 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 28 |
"globals": "^17.12.0",
|
|
|
|
| 29 |
"vite": "^8.3.0"
|
| 30 |
},
|
| 31 |
"engines": {
|
|
|
|
| 4345 |
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
| 4346 |
"license": "MIT"
|
| 4347 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4348 |
"node_modules/possible-typed-array-names": {
|
| 4349 |
"version": "1.0.0",
|
| 4350 |
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
package.json
CHANGED
|
@@ -6,12 +6,7 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
-
"preview": "vite preview"
|
| 10 |
-
"test": "node scripts/test-avatar-interiors.mjs",
|
| 11 |
-
"test:ui": "node scripts/test-neon-ui.mjs",
|
| 12 |
-
"sync:catalogue": "node scripts/sync-catalogue.mjs",
|
| 13 |
-
"refresh:catalogue": "node scripts/refresh-catalogue.mjs",
|
| 14 |
-
"seed:catalogue": "node scripts/seed-catalogue.mjs"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
"@huggingface/transformers": "4.3.0",
|
|
@@ -32,7 +27,6 @@
|
|
| 32 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 33 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 34 |
"globals": "^17.12.0",
|
| 35 |
-
"playwright": "^1.63.0",
|
| 36 |
"vite": "^8.3.0"
|
| 37 |
},
|
| 38 |
"engines": {
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "vite",
|
| 8 |
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"@huggingface/transformers": "4.3.0",
|
|
|
|
| 27 |
"eslint-plugin-react-hooks": "^7.1.1",
|
| 28 |
"eslint-plugin-react-refresh": "^0.5.7",
|
| 29 |
"globals": "^17.12.0",
|
|
|
|
| 30 |
"vite": "^8.3.0"
|
| 31 |
},
|
| 32 |
"engines": {
|
products-index.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:871670ad80cfc7409cb00d14665a2a6ef76b9a53f0ad79c7790b174d131d907b
|
| 3 |
+
size 3155324
|
public/brand/vaistudio_center.png
DELETED
Git LFS Details
|
public/catalogue/products.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
public/catalogue/promos.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
public/share/og-image.png
DELETED
Git LFS Details
|
repo/rebuild.py
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
import subprocess, os, re
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 6 |
-
REPO = "bep40/VSEARCH"
|
| 7 |
-
CLONE = "/repo"
|
| 8 |
-
SPACE_URL = "https://huggingface.co/spaces/"
|
| 9 |
-
HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"}
|
| 10 |
-
|
| 11 |
-
def run(cmd, **kw):
|
| 12 |
-
return subprocess.run(cmd, shell=True, cwd=CLONE, check=True,
|
| 13 |
-
capture_output=True, text=True, env={**os.environ, "HF_TOKEN": HF_TOKEN})
|
| 14 |
-
|
| 15 |
-
Path(CLONE).mkdir(parents=True, exist_ok=True)
|
| 16 |
-
if not Path(CLONE).exists() or not Path(CLONE, ".git").exists():
|
| 17 |
-
run("git clone -q --filter=blob:none --depth 1 " + SPACE_URL + REPO + " " + CLONE)
|
| 18 |
-
os.chdir(CLONE)
|
| 19 |
-
print("install:", run("npm install --silent --no-audit --no-fund").stdout[-200:])
|
| 20 |
-
build = run("npm run build")
|
| 21 |
-
print("build:", build.stdout[-400:])
|
| 22 |
-
import re as re2
|
| 23 |
-
html = Path(CLONE, "index.html").read_text()
|
| 24 |
-
assets = {}
|
| 25 |
-
for key in ("src", "href"):
|
| 26 |
-
for m in re2.finditer(rf"(?:{key})=\"([^\"]+)\"", html):
|
| 27 |
-
url = m.group(1).split("?")[0]
|
| 28 |
-
assets[url] = None
|
| 29 |
-
print("index.html references:", assets)
|
| 30 |
-
|
| 31 |
-
# Upload via REST upload_file to avoid git auth prompts
|
| 32 |
-
import urllib.request
|
| 33 |
-
import urllib.parse
|
| 34 |
-
import json
|
| 35 |
-
|
| 36 |
-
def hf_upload(path_in_repo, path_or_fileobj):
|
| 37 |
-
url = f"https://huggingface.co/api/spaces/{REPO}/upload_file"
|
| 38 |
-
q = urllib.parse.urlencode({"create_pr": False, "revision": "main", "commit_message": "Upload rebuilt static asset"})
|
| 39 |
-
body = b""
|
| 40 |
-
if isinstance(path_or_fileobj, str):
|
| 41 |
-
body = open(path_or_fileobj, "rb").read()
|
| 42 |
-
else:
|
| 43 |
-
body = path_or_fileobj.read()
|
| 44 |
-
data = urllib.parse.urlencode({"path_in_repo": path_in_repo}).encode()
|
| 45 |
-
data = data + b"&content=" + urllib.parse.quote(body)
|
| 46 |
-
req = urllib.request.Request(url + "?" + q, data=data, headers={"Authorization": HEADERS["Authorization"], "Content-Type": "application/x-www-form-urlencoded"})
|
| 47 |
-
with urllib.request.urlopen(req) as resp:
|
| 48 |
-
return json.loads(resp.read())
|
| 49 |
-
|
| 50 |
-
for url in sorted(assets):
|
| 51 |
-
local = Path(CLONE, url)
|
| 52 |
-
if not local.exists():
|
| 53 |
-
raise SystemExit("missing asset: " + url)
|
| 54 |
-
hf_upload(url, str(local))
|
| 55 |
-
print("uploaded:", url, local.stat().st_size)
|
| 56 |
-
print("ALL_OK")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/gen-thumbnail.py
DELETED
|
@@ -1,108 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Generate the social-share thumbnail for the semantic-image-field Space.
|
| 4 |
-
|
| 5 |
-
Always the CURRENT 12 newest photographs: 8 newest interiors (by created_at)
|
| 6 |
-
from bep40/vai-avatar2-interior + 4 newest added products (by _added_at)
|
| 7 |
-
from bep40/grob-products-updated. Composed as a 4x3 grid at 1200x675 (2:1
|
| 8 |
-
social-card crop). Written to BOTH public/ and dist/ so whichever path the
|
| 9 |
-
static Space serves, the resolved URL exists.
|
| 10 |
-
|
| 11 |
-
Intended to run from a scheduled HF job (or locally) BEFORE the catalogue
|
| 12 |
-
sync commits, so the committed thumbnail is refreshed at least as often as
|
| 13 |
-
the mirror itself.
|
| 14 |
-
"""
|
| 15 |
-
import io, json, os, sys, urllib.request
|
| 16 |
-
|
| 17 |
-
INTERIOR_INDEX = "https://huggingface.co/datasets/bep40/vai-avatar2-interior/resolve/main/index.json"
|
| 18 |
-
INTERIOR_API = "https://bep40-vai-avatar2-interior-api.hf.space"
|
| 19 |
-
PRODUCTS_URL = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_url_added.json"
|
| 20 |
-
N_INTERIORS = 8
|
| 21 |
-
N_PRODUCTS = 4
|
| 22 |
-
W, H = 1200, 675
|
| 23 |
-
GAP = 6
|
| 24 |
-
COLS, ROWS = 4, 3
|
| 25 |
-
BG = (17, 20, 28)
|
| 26 |
-
|
| 27 |
-
UA = {"User-Agent": "V.AI-STUDIO-thumbnail/1.0"}
|
| 28 |
-
|
| 29 |
-
def fetch_json(url, timeout=25):
|
| 30 |
-
req = urllib.request.Request(url, headers=UA)
|
| 31 |
-
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 32 |
-
return json.loads(r.read().decode("utf-8"))
|
| 33 |
-
|
| 34 |
-
def fetch_bytes(url, timeout=25):
|
| 35 |
-
req = urllib.request.Request(url, headers=UA)
|
| 36 |
-
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 37 |
-
return r.read()
|
| 38 |
-
|
| 39 |
-
def main():
|
| 40 |
-
from PIL import Image
|
| 41 |
-
interiors = fetch_json(INTERIOR_INDEX)
|
| 42 |
-
products = fetch_json(PRODUCTS_URL)
|
| 43 |
-
|
| 44 |
-
dated = [r for r in interiors
|
| 45 |
-
if isinstance(r, dict) and isinstance(r.get("created_at"), str)
|
| 46 |
-
and r.get("created_at") and r.get("image_id")]
|
| 47 |
-
dated.sort(key=lambda r: r["created_at"], reverse=True)
|
| 48 |
-
top_i = dated[:N_INTERIORS]
|
| 49 |
-
|
| 50 |
-
prods = [p for p in products
|
| 51 |
-
if isinstance(p, dict) and isinstance(p.get("_added_at"), str)
|
| 52 |
-
and p.get("_added_at") and (p.get("imgs") or p.get("i"))]
|
| 53 |
-
prods.sort(key=lambda p: str(p["_added_at"]), reverse=True)
|
| 54 |
-
top_p = prods[:N_PRODUCTS]
|
| 55 |
-
|
| 56 |
-
urls = [f"{INTERIOR_API}/image/{r['image_id']}.jpg" for r in top_i]
|
| 57 |
-
urls += [(p.get("imgs") or [p.get("i")])[0] for p in top_p]
|
| 58 |
-
|
| 59 |
-
cell_w = (W - GAP * (COLS - 1)) // COLS
|
| 60 |
-
cell_h = (H - GAP * (ROWS - 1)) // ROWS
|
| 61 |
-
|
| 62 |
-
canvas = Image.new("RGB", (W, H), BG)
|
| 63 |
-
cells = []
|
| 64 |
-
for u in urls:
|
| 65 |
-
try:
|
| 66 |
-
data = fetch_bytes(u)
|
| 67 |
-
im = Image.open(io.BytesIO(data))
|
| 68 |
-
if im.mode not in ("RGB", "RGBA"):
|
| 69 |
-
im = im.convert("RGB")
|
| 70 |
-
if im.mode == "RGBA":
|
| 71 |
-
im = im.convert("RGB")
|
| 72 |
-
cells.append(im)
|
| 73 |
-
except Exception as e:
|
| 74 |
-
print(f"skip {u[:80]}: {e}", file=sys.stderr)
|
| 75 |
-
|
| 76 |
-
if not cells:
|
| 77 |
-
print("FATAL: no images could be fetched", file=sys.stderr)
|
| 78 |
-
sys.exit(1)
|
| 79 |
-
|
| 80 |
-
idx = 0
|
| 81 |
-
for row in range(ROWS):
|
| 82 |
-
for col in range(COLS):
|
| 83 |
-
if idx >= len(cells):
|
| 84 |
-
break
|
| 85 |
-
im = cells[idx]
|
| 86 |
-
idx += 1
|
| 87 |
-
iw, ih = im.size
|
| 88 |
-
scale = max(cell_w / iw, cell_h / ih)
|
| 89 |
-
sw, sh = max(1, round(iw * scale)), max(1, round(ih * scale))
|
| 90 |
-
im2 = im.resize((sw, sh), Image.LANCZOS)
|
| 91 |
-
left = (sw - cell_w) // 2
|
| 92 |
-
top = (sh - cell_h) // 2
|
| 93 |
-
im2 = im2.crop((left, top, left + cell_w, top + cell_h))
|
| 94 |
-
canvas.paste(im2, (col * (cell_w + GAP), row * (cell_h + GAP)))
|
| 95 |
-
|
| 96 |
-
out = io.BytesIO()
|
| 97 |
-
canvas.save(out, format="PNG", optimize=True)
|
| 98 |
-
png = out.getvalue()
|
| 99 |
-
|
| 100 |
-
os.makedirs("public/share", exist_ok=True)
|
| 101 |
-
os.makedirs("dist/share", exist_ok=True)
|
| 102 |
-
for path in ("public/share/og-image.png", "dist/share/og-image.png"):
|
| 103 |
-
with open(path, "wb") as f:
|
| 104 |
-
f.write(png)
|
| 105 |
-
print(f"OK wrote {len(png)} bytes {W}x{H} from {len(cells)} images")
|
| 106 |
-
|
| 107 |
-
if __name__ == "__main__":
|
| 108 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/refresh-catalogue.mjs
DELETED
|
@@ -1,415 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env node
|
| 2 |
-
/**
|
| 3 |
-
* Refresh the vai-avatar product mirror inside the Space repo, build, and commit.
|
| 4 |
-
*
|
| 5 |
-
* The Space is `sdk: static`: there is no server that builds at request time,
|
| 6 |
-
* so the ONLY way a vai-avatar change reaches the browser is if the bytes under
|
| 7 |
-
* dist/ are regenerated and committed. That is this script's reason to exist.
|
| 8 |
-
*
|
| 9 |
-
* Intended to run from an HF scheduled job:
|
| 10 |
-
*
|
| 11 |
-
* npm run refresh:catalogue
|
| 12 |
-
*
|
| 13 |
-
* It is deliberately idempotent: if vai-avatar has not changed, nothing is
|
| 14 |
-
* committed or pushed, so the Space is not rebuilt pointlessly.
|
| 15 |
-
*
|
| 16 |
-
* Failure policy: every step is checked, and a failure aborts BEFORE pushing.
|
| 17 |
-
* The previously committed mirror therefore keeps serving. A half-written or
|
| 18 |
-
* empty catalogue is far worse than a slightly stale one.
|
| 19 |
-
*/
|
| 20 |
-
|
| 21 |
-
import { execFileSync } from 'node:child_process';
|
| 22 |
-
import { closeSync, existsSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
| 23 |
-
|
| 24 |
-
const REPO = process.env.SPACE_REPO || 'bep40/semantic-image-field';
|
| 25 |
-
const CLONE = process.env.CLONE_DIR || '/tmp/space';
|
| 26 |
-
|
| 27 |
-
/**
|
| 28 |
-
* The bundle names vite.config.js is configured to emit.
|
| 29 |
-
*
|
| 30 |
-
* Kept in step with `build.rollupOptions.output`, where entryFileNames and
|
| 31 |
-
* assetFileNames both produce "index". The build is what makes them exist, which
|
| 32 |
-
* is why the swap below happens only after it has run.
|
| 33 |
-
*/
|
| 34 |
-
const STABLE_BUNDLE = 'assets/index.js';
|
| 35 |
-
const STABLE_STYLE = 'assets/app.css';
|
| 36 |
-
|
| 37 |
-
/** The two mirrored files, in both the source and the served location. */
|
| 38 |
-
const MIRROR = ['products.json', 'promos.json'];
|
| 39 |
-
|
| 40 |
-
/**
|
| 41 |
-
* Everything this script is allowed to commit.
|
| 42 |
-
*
|
| 43 |
-
* An explicit path list rather than `git add -A`, and that is the single most
|
| 44 |
-
* important safety property of this file. `.gitattributes` routes
|
| 45 |
-
* products-index.json, products-gallery.json and dist/products-gallery.json
|
| 46 |
-
* through git-lfs. The clone is a shallow `--depth 1` fetch; if the environment
|
| 47 |
-
* has no git-lfs binary the smudge filter never runs and those files check out
|
| 48 |
-
* as ~130-byte pointer text. `git add -A` would then stage the pointers and the
|
| 49 |
-
* push would replace committed LFS objects with text — permanent, silent data
|
| 50 |
-
* loss on the Hub, committed by a script that reported success.
|
| 51 |
-
*
|
| 52 |
-
* None of the paths below can be an LFS pointer, so a missing git-lfs no longer
|
| 53 |
-
* has to be fatal: it only means the build output is unverifiable, not that the
|
| 54 |
-
* mirror is unsafe to publish.
|
| 55 |
-
*
|
| 56 |
-
* `dist/catalogue` is here and not merely `public/catalogue` because of WHAT THE
|
| 57 |
-
* BROWSER ACTUALLY FETCHES. Vite copies public/ into outDir/, so the bytes are
|
| 58 |
-
* served from dist/catalogue/products.json — and that is the only place they
|
| 59 |
-
* exist: there is no /catalogue/ at the Space root, because the Space serves
|
| 60 |
-
* its root index.html rather than app.html, so nothing lands outside dist/.
|
| 61 |
-
*
|
| 62 |
-
* catalogue.js candidateURLs() resolves against the bundle at dist/assets/, and
|
| 63 |
-
* the `../catalogue/…` candidate lands exactly on dist/catalogue/. (The
|
| 64 |
-
* `../../…` candidate that precedes it lands on /catalogue/, which does not
|
| 65 |
-
* exist, so it 404s first — a wasted request, then the dist/ form answers. The
|
| 66 |
-
* ordering is worth knowing but not worth depending on.)
|
| 67 |
-
*
|
| 68 |
-
* Committing only the source copy would stage the new mirror, commit the new
|
| 69 |
-
* bundle, print REFRESH_OK — and still 404 at runtime, because the copy that is
|
| 70 |
-
* served was never pushed. That is a silent failure with no error anywhere, and
|
| 71 |
-
* it is exactly the "sửa sp không đồng bộ" symptom.
|
| 72 |
-
*/
|
| 73 |
-
const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
|
| 74 |
-
|
| 75 |
-
const ok = message => console.log(`[refresh] ${message}`);
|
| 76 |
-
const warn = message => console.warn(`[refresh] WARNING: ${message}`);
|
| 77 |
-
|
| 78 |
-
/** Run a command in the clone. Throws with the captured stderr attached. */
|
| 79 |
-
function run(command, args, { cwd = CLONE } = {}) {
|
| 80 |
-
return execFileSync(command, args, {
|
| 81 |
-
cwd,
|
| 82 |
-
encoding: 'utf8',
|
| 83 |
-
stdio: ['ignore', 'pipe', 'pipe'],
|
| 84 |
-
maxBuffer: 64 * 1024 * 1024,
|
| 85 |
-
});
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
const shell = (script, cwd = CLONE) => run('bash', ['-lc', script], { cwd });
|
| 89 |
-
|
| 90 |
-
/**
|
| 91 |
-
* The token goes into an Authorization header rather than the remote URL.
|
| 92 |
-
* Embedding it in https://user:TOKEN@huggingface.co persists it in
|
| 93 |
-
* .git/config and can leak it into reflogs and trace output.
|
| 94 |
-
*/
|
| 95 |
-
function git(...args) {
|
| 96 |
-
return run(
|
| 97 |
-
'git',
|
| 98 |
-
['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args],
|
| 99 |
-
);
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
/**
|
| 103 |
-
* Is git-lfs installed at all? (The smudge filter can only exist after
|
| 104 |
-
* `git lfs install`, so availability is checked on the binary first, then
|
| 105 |
-
* the clone-local filter is (re)wired with `git lfs install --local`.)
|
| 106 |
-
*/
|
| 107 |
-
function hasGitLfsBinary() {
|
| 108 |
-
try {
|
| 109 |
-
return Boolean(shell('git lfs version 2>/dev/null || true').trim());
|
| 110 |
-
} catch {
|
| 111 |
-
return false;
|
| 112 |
-
}
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
/** Is git-lfs BOTH installed AND wired as a smudge filter for this clone? */
|
| 116 |
-
function hasGitLfs() {
|
| 117 |
-
try {
|
| 118 |
-
if (!hasGitLfsBinary()) return false;
|
| 119 |
-
return Boolean(shell('git config --get filter.lfs.smudge || true').trim());
|
| 120 |
-
} catch {
|
| 121 |
-
return false;
|
| 122 |
-
}
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
/** Is this a git-lfs pointer rather than real content? */
|
| 126 |
-
function isLfsPointer(path) {
|
| 127 |
-
let handle;
|
| 128 |
-
try {
|
| 129 |
-
handle = openSync(path, 'r');
|
| 130 |
-
const buffer = Buffer.alloc(256);
|
| 131 |
-
const read = readSync(handle, buffer, 0, 256, 0);
|
| 132 |
-
return buffer.subarray(0, read).toString('utf8')
|
| 133 |
-
.startsWith('version https://git-lfs.github.com/spec/v1');
|
| 134 |
-
} catch {
|
| 135 |
-
return false;
|
| 136 |
-
} finally {
|
| 137 |
-
if (handle !== undefined) { try { closeSync(handle); } catch { /* already gone */ } }
|
| 138 |
-
}
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
/** Size of `relative`, or null if it is missing, tiny, or an LFS pointer. */
|
| 142 |
-
function realSize(relative) {
|
| 143 |
-
const path = `${CLONE}/${relative}`;
|
| 144 |
-
if (!existsSync(path)) return null;
|
| 145 |
-
const size = statSync(path).size;
|
| 146 |
-
if (size < 256 || isLfsPointer(path)) return null;
|
| 147 |
-
return size;
|
| 148 |
-
}
|
| 149 |
-
|
| 150 |
-
/**
|
| 151 |
-
* Fail unless `relative` (resolved against the clone root) holds real content.
|
| 152 |
-
*
|
| 153 |
-
* `relative` must be a path from CLONE, not from dist/ — passing a bare filename
|
| 154 |
-
* here silently checks the wrong location, which is how a missing committed data
|
| 155 |
-
* file can pass as present.
|
| 156 |
-
*/
|
| 157 |
-
function requireRealFile(relative, { why }) {
|
| 158 |
-
const path = `${CLONE}/${relative}`;
|
| 159 |
-
if (!existsSync(path)) throw new Error(`${relative} is missing — ${why}`);
|
| 160 |
-
if (realSize(relative) === null) {
|
| 161 |
-
throw new Error(
|
| 162 |
-
`${relative} is a git-lfs pointer or a truncated file, not real content. ${why}`,
|
| 163 |
-
);
|
| 164 |
-
}
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
/** Parse a mirror file and report how many records it holds. */
|
| 168 |
-
function recordCount(relative) {
|
| 169 |
-
const parsed = JSON.parse(readFileSync(`${CLONE}/${relative}`, 'utf8'));
|
| 170 |
-
if (Array.isArray(parsed)) return parsed.length;
|
| 171 |
-
return Object.keys(parsed?.productEdits ?? {}).length;
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
/** True when index.html already names both stable assets. */
|
| 175 |
-
function indexReferencesStable(html) {
|
| 176 |
-
return /\.\/dist\/assets\/index\.js\b/.test(html)
|
| 177 |
-
&& /\.\/dist\/assets\/app\.css\b/.test(html);
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
-
/**
|
| 181 |
-
* Point index.html at the freshly built bundle.
|
| 182 |
-
*
|
| 183 |
-
* This is load-bearing, not housekeeping. index.html is NOT a Vite input (the
|
| 184 |
-
* input is app.html), so `vite build` never rewrites it — yet the build emits
|
| 185 |
-
* the STABLE names while index.html still names a hashed bundle from an earlier
|
| 186 |
-
* build. Those hashed files survive, because emptyOutDir is false precisely so
|
| 187 |
-
* the build cannot delete committed data.
|
| 188 |
-
*
|
| 189 |
-
* The result was a refresh that could not fail loudly: the new bundle was
|
| 190 |
-
* committed, the mirror was committed, the stale-but-present asset reference
|
| 191 |
-
* passed verification, and the script reported REFRESH_OK — while the Space
|
| 192 |
-
* kept serving the pre-fix JavaScript. The catalogue would update and the code
|
| 193 |
-
* reading it would not, which looks exactly like "sửa sp không đồng bộ" and
|
| 194 |
-
* survives every rebuild.
|
| 195 |
-
*
|
| 196 |
-
* So the swap is part of the refresh. It is idempotent: once index.html names
|
| 197 |
-
* the stable files, the regex matches them, the write is a no-op, and git sees
|
| 198 |
-
* no change.
|
| 199 |
-
*/
|
| 200 |
-
function repointIndexHtml() {
|
| 201 |
-
const path = `${CLONE}/index.html`;
|
| 202 |
-
const html = readFileSync(path, 'utf8');
|
| 203 |
-
|
| 204 |
-
// Already correct: leave the file untouched so git sees no change.
|
| 205 |
-
if (indexReferencesStable(html)) return false;
|
| 206 |
-
|
| 207 |
-
// Only the asset reference is touched. Matching on the full "./dist/assets/"
|
| 208 |
-
// prefix means a hand-added link to something else in that directory (a
|
| 209 |
-
// vendored worker, say) is left alone rather than silently renamed.
|
| 210 |
-
let next = html.replace(
|
| 211 |
-
/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g,
|
| 212 |
-
`$1${STABLE_BUNDLE.slice('assets/'.length)}`,
|
| 213 |
-
);
|
| 214 |
-
next = next.replace(
|
| 215 |
-
/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g,
|
| 216 |
-
`$1${STABLE_STYLE.slice('assets/'.length)}`,
|
| 217 |
-
);
|
| 218 |
-
|
| 219 |
-
// The rewrite matched nothing AND index.html is not already correct. That is
|
| 220 |
-
// the case this guard exists for: index.html names its bundle by some other
|
| 221 |
-
// scheme (a query string, a different directory, a hand-written <script>), so
|
| 222 |
-
// the regex changes nothing. Treating that as "already fine" would publish a
|
| 223 |
-
// Space whose HTML points at a file the build never emits — a blank page that
|
| 224 |
-
// survives every rebuild, because the stale reference keeps resolving to a
|
| 225 |
-
// real, still-present file.
|
| 226 |
-
if (next === html) {
|
| 227 |
-
throw new Error(
|
| 228 |
-
'index.html names its bundle in a way repointIndexHtml() cannot rewrite — '
|
| 229 |
-
+ 'refusing to publish (would blank the Space)',
|
| 230 |
-
);
|
| 231 |
-
}
|
| 232 |
-
|
| 233 |
-
// A partial rewrite is just as broken as none: JS pointing at the stable name
|
| 234 |
-
// while the <link> still names a hashed CSS (or the reverse) loads a bundle
|
| 235 |
-
// without its stylesheet.
|
| 236 |
-
if (!indexReferencesStable(next)) {
|
| 237 |
-
throw new Error(
|
| 238 |
-
'only part of index.html could be repointed — it would load the bundle '
|
| 239 |
-
+ 'without its stylesheet, or vice versa',
|
| 240 |
-
);
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
// Confirm the build actually produced what we are now pointing at. Doing this
|
| 244 |
-
// after the rewrite rather than before would be circular; doing it before is
|
| 245 |
-
// what makes the rewrite safe.
|
| 246 |
-
for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) {
|
| 247 |
-
requireRealFile(`dist/${asset}`, { why: 'the build did not produce it' });
|
| 248 |
-
}
|
| 249 |
-
writeFileSync(path, next, 'utf8');
|
| 250 |
-
ok(`index.html repointed to ${STABLE_BUNDLE} + ${STABLE_STYLE}`);
|
| 251 |
-
return true;
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
function main() {
|
| 255 |
-
if (!process.env.HF_TOKEN) {
|
| 256 |
-
throw new Error('HF_TOKEN is required to push the refreshed mirror.');
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
rmSync(CLONE, { recursive: true, force: true });
|
| 260 |
-
run('mkdir', ['-p', CLONE], { cwd: '/' });
|
| 261 |
-
|
| 262 |
-
git('init', '-q');
|
| 263 |
-
git('remote', 'add', 'origin', `https://huggingface.co/spaces/${REPO}`);
|
| 264 |
-
git('fetch', '-q', '--depth', '1', 'origin', 'main');
|
| 265 |
-
git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
|
| 266 |
-
ok(`cloned ${REPO}`);
|
| 267 |
-
|
| 268 |
-
// The og-image is LFS-tracked, and the pre-receive hook rejects raw binaries,
|
| 269 |
-
// so the clone MUST be able to smudge/clean via git-lfs. `git lfs install
|
| 270 |
-
// --local` inside the clone (a) loads the repository-local filters and (b)
|
| 271 |
-
// fails visibly when git-lfs is not installed, instead of silently committing
|
| 272 |
-
// a raw PNG pointer that a later push gets rejected for.
|
| 273 |
-
if (hasGitLfsBinary()) {
|
| 274 |
-
shell('git lfs install --local');
|
| 275 |
-
if (!hasGitLfs()) {
|
| 276 |
-
throw new Error('git lfs install --local did not wire the smudge filter');
|
| 277 |
-
}
|
| 278 |
-
ok('git-lfs wired for this clone');
|
| 279 |
-
} else {
|
| 280 |
-
throw new Error('git-lfs is required (og-image.png is LFS-tracked) — install git-lfs in the job');
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
// Informational only. COMMITTABLE excludes every LFS-tracked path, so a
|
| 284 |
-
// missing git-lfs cannot lead to pointers being committed; it just means the
|
| 285 |
-
// committed dist data cannot be verified from this clone.
|
| 286 |
-
const lfs = hasGitLfs();
|
| 287 |
-
if (!lfs) {
|
| 288 |
-
warn('git-lfs unavailable — dist data will be left unstaged and unverified (safe: it is LFS-tracked)');
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
shell('set -euo pipefail; npm install --silent --no-audit --no-fund');
|
| 292 |
-
ok('dependencies installed');
|
| 293 |
-
|
| 294 |
-
// Copies vai-avatar's live product sources into public/catalogue/.
|
| 295 |
-
shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
|
| 296 |
-
|
| 297 |
-
// Regenerate the social-share thumbnail (12 newest images) BEFORE the build,
|
| 298 |
-
// so Vite copies public/share/ into dist/share/ and the committed og-image
|
| 299 |
-
// is refreshed on every sync. Non-fatal: the previous thumbnail keeps
|
| 300 |
-
// serving if the Hub is unreachable, but a HARD failure is not silent.
|
| 301 |
-
shell('set -euo pipefail; python3 scripts/gen-thumbnail.py');
|
| 302 |
-
for (const share of ['public/share/og-image.png', 'dist/share/og-image.png']) {
|
| 303 |
-
requireRealFile(share, { why: 'the social-share thumbnail would be missing' });
|
| 304 |
-
}
|
| 305 |
-
ok('social-share thumbnail regenerated');
|
| 306 |
-
|
| 307 |
-
// Guard before building: an empty or truncated mirror must never ship.
|
| 308 |
-
for (const file of MIRROR) {
|
| 309 |
-
requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' });
|
| 310 |
-
const count = recordCount(`public/catalogue/${file}`);
|
| 311 |
-
if (!count) throw new Error(`public/catalogue/${file} is empty — refusing to publish`);
|
| 312 |
-
ok(`public/catalogue/${file}: ${count} entries`);
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
-
shell('set -euo pipefail; npm run build');
|
| 316 |
-
requireRealFile(`dist/${STABLE_BUNDLE}`, { why: 'the build did not produce it' });
|
| 317 |
-
ok('built');
|
| 318 |
-
|
| 319 |
-
/**
|
| 320 |
-
* The build copies public/ into dist/, and that copy is what the browser
|
| 321 |
-
* fetches. Checked AFTER the build: if Vite's publicDir handling, outDir or a
|
| 322 |
-
* plugin config ever changed, this is the point where it surfaces — as a loud
|
| 323 |
-
* abort before any push, instead of a Space whose product panel 404s while
|
| 324 |
-
* the refresh reports success.
|
| 325 |
-
*
|
| 326 |
-
* The counts are compared too, not just presence. A stale dist/catalogue left
|
| 327 |
-
* over from an earlier build is byte-valid and non-empty, so presence alone
|
| 328 |
-
* would happily pass while the Space serves products the sync just replaced.
|
| 329 |
-
*/
|
| 330 |
-
for (const file of MIRROR) {
|
| 331 |
-
const served = `dist/catalogue/${file}`;
|
| 332 |
-
requireRealFile(served, {
|
| 333 |
-
why: 'the build did not copy public/ into dist/, so the browser would 404',
|
| 334 |
-
});
|
| 335 |
-
const source = recordCount(`public/catalogue/${file}`);
|
| 336 |
-
const built = recordCount(served);
|
| 337 |
-
if (source !== built) {
|
| 338 |
-
throw new Error(`${served} has ${built} entries but the mirror has ${source} — the served copy is stale`);
|
| 339 |
-
}
|
| 340 |
-
ok(`served mirror verified: dist/catalogue/${file} (${built} entries)`);
|
| 341 |
-
}
|
| 342 |
-
|
| 343 |
-
// After the build, so the stable names are known to exist.
|
| 344 |
-
repointIndexHtml();
|
| 345 |
-
|
| 346 |
-
// Verify the served page BEFORE committing, and only AFTER repointing — the
|
| 347 |
-
// original order checked the stale reference against the stale file and passed.
|
| 348 |
-
const html = readFileSync(`${CLONE}/index.html`, 'utf8');
|
| 349 |
-
// Query strings (`?v=...`) are cache busters, not part of the filename —
|
| 350 |
-
// strip them so the existence check tests the real file on disk.
|
| 351 |
-
const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)]
|
| 352 |
-
.map(match => match[1].split('?')[0]);
|
| 353 |
-
if (!referenced.length) {
|
| 354 |
-
throw new Error('index.html references no dist/assets bundle — refusing to publish');
|
| 355 |
-
}
|
| 356 |
-
for (const name of referenced) {
|
| 357 |
-
requireRealFile(`dist/${name}`, { why: 'index.html points at it' });
|
| 358 |
-
}
|
| 359 |
-
ok(`index.html references verified: ${referenced.join(', ')}`);
|
| 360 |
-
|
| 361 |
-
// Confirm the build did not delete the committed data under dist/.
|
| 362 |
-
// vite.config.js sets emptyOutDir: false for exactly this reason; a revert of
|
| 363 |
-
// that setting would delete ~6,400 files with no error anywhere, and the app
|
| 364 |
-
// would keep working because src/ reads embeddings from the dataset repo and
|
| 365 |
-
// products from the mirror.
|
| 366 |
-
if (lfs) {
|
| 367 |
-
if (!existsSync(`${CLONE}/dist/prodimgs`)) {
|
| 368 |
-
throw new Error('dist/prodimgs is missing — the build deleted committed data; check emptyOutDir');
|
| 369 |
-
}
|
| 370 |
-
for (const file of ['dist/products-gallery.json', 'dist/products-index.json']) {
|
| 371 |
-
requireRealFile(file, { why: 'the build deleted committed data, or it is an LFS pointer' });
|
| 372 |
-
}
|
| 373 |
-
ok('committed dist data intact');
|
| 374 |
-
}
|
| 375 |
-
|
| 376 |
-
// Superseded hashed bundles are inert — index.html names exactly one — but they
|
| 377 |
-
// accumulate on every build. Left alone on purpose: pruning them is a separate,
|
| 378 |
-
// reviewable change, not something a sync job should do on its own.
|
| 379 |
-
|
| 380 |
-
// Explicit paths, never `git add -A` (see COMMITTABLE). Whether a commit
|
| 381 |
-
// happened must come from the commit step itself: testing it afterwards with
|
| 382 |
-
// `git diff --cached --quiet` would always report clean, because a successful
|
| 383 |
-
// commit clears the staging area — the push would then be skipped and the
|
| 384 |
-
// refresh would silently do nothing.
|
| 385 |
-
const commit = shell(`
|
| 386 |
-
set -euo pipefail
|
| 387 |
-
git config user.name "semantic-image-field refresh"
|
| 388 |
-
git config user.email "refresh@hf.space"
|
| 389 |
-
git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')}
|
| 390 |
-
if git diff --cached --quiet; then
|
| 391 |
-
echo NOCHANGE
|
| 392 |
-
else
|
| 393 |
-
git commit -q -m "chore: refresh vai-avatar catalogue mirror"
|
| 394 |
-
echo COMMITTED
|
| 395 |
-
fi
|
| 396 |
-
`).trim();
|
| 397 |
-
|
| 398 |
-
if (commit.includes('COMMITTED')) {
|
| 399 |
-
git('push', '-q', 'origin', 'main');
|
| 400 |
-
ok('pushed — the static Space will rebuild');
|
| 401 |
-
} else {
|
| 402 |
-
const head = shell('git rev-parse --short HEAD').trim();
|
| 403 |
-
ok(`catalogue unchanged (HEAD ${head})`);
|
| 404 |
-
}
|
| 405 |
-
|
| 406 |
-
console.log('REFRESH_OK');
|
| 407 |
-
}
|
| 408 |
-
|
| 409 |
-
try {
|
| 410 |
-
main();
|
| 411 |
-
} catch (error) {
|
| 412 |
-
const detail = (error.stderr && String(error.stderr).trim()) || error.message;
|
| 413 |
-
console.error(`REFRESH_FAILED: ${detail}`);
|
| 414 |
-
process.exitCode = 1;
|
| 415 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/refresh.mjs
DELETED
|
@@ -1,74 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env node
|
| 2 |
-
import { execFileSync } from 'node:child_process';
|
| 3 |
-
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
| 4 |
-
|
| 5 |
-
const REPO = 'bep40/semantic-image-field';
|
| 6 |
-
const CLONE = '/tmp/space';
|
| 7 |
-
const STABLE_BUNDLE = 'assets/index.js';
|
| 8 |
-
const STABLE_STYLE = 'assets/app.css';
|
| 9 |
-
const MIRROR = ['products.json', 'promos.json'];
|
| 10 |
-
const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share'];
|
| 11 |
-
|
| 12 |
-
const ok = m => console.log(`[refresh] ${m}`);
|
| 13 |
-
|
| 14 |
-
function run(command, args, { cwd = CLONE } = {}) {
|
| 15 |
-
return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
| 16 |
-
}
|
| 17 |
-
function shell(script, cwd = CLONE) { return run('bash', ['-lc', script], { cwd }); }
|
| 18 |
-
function git(...args) {
|
| 19 |
-
return run('git', ['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args]);
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
function main() {
|
| 23 |
-
if (!process.env.HF_TOKEN) throw new Error('HF_TOKEN required');
|
| 24 |
-
rmSync(CLONE, { recursive: true, force: true });
|
| 25 |
-
run('mkdir', ['-p', CLONE]);
|
| 26 |
-
run('git', ['clone', '-q', `--filter=blob:none`, '--no-checkout', `https://huggingface.co/spaces/${REPO}`], { cwd: '/' });
|
| 27 |
-
git('checkout', '-q', '-B', 'main', 'main');
|
| 28 |
-
ok('cloned');
|
| 29 |
-
|
| 30 |
-
shell('set -euo pipefail; npm install --silent --no-audit --no-fund');
|
| 31 |
-
ok('dependencies installed');
|
| 32 |
-
shell('set -euo pipefail; node scripts/sync-catalogue.mjs');
|
| 33 |
-
for (const file of MIRROR) {
|
| 34 |
-
const count = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
|
| 35 |
-
ok(`public/catalogue/${file}: ${count} entries`);
|
| 36 |
-
}
|
| 37 |
-
shell('set -euo pipefail; npm run build');
|
| 38 |
-
ok('built');
|
| 39 |
-
for (const file of MIRROR) {
|
| 40 |
-
const served = `dist/catalogue/${file}`;
|
| 41 |
-
const source = JSON.parse(readFileSync(`public/catalogue/${file}`, 'utf8')).length;
|
| 42 |
-
const built = JSON.parse(readFileSync(served, 'utf8')).length;
|
| 43 |
-
if (source !== built) throw new Error(`${served} has ${built} but mirror has ${source}`);
|
| 44 |
-
ok(`served mirror verified: ${file} (${built} entries)`);
|
| 45 |
-
}
|
| 46 |
-
const htmlPath = `${CLONE}/index.html`;
|
| 47 |
-
let html = readFileSync(htmlPath, 'utf8');
|
| 48 |
-
if (/\.\/dist\/assets\/index\.js\b/.test(html) && /\.\/dist\/assets\/app\.css\b/.test(html)) {
|
| 49 |
-
ok('index.html already references stable bundle');
|
| 50 |
-
} else {
|
| 51 |
-
let next = html.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g, `$1${STABLE_BUNDLE.slice('assets/'.length)}`);
|
| 52 |
-
next = next.replace(/(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g, `$1${STABLE_STYLE.slice('assets/'.length)}`);
|
| 53 |
-
if (next === html) throw new Error('index.html names bundle in unexpected way');
|
| 54 |
-
writeFileSync(htmlPath, next);
|
| 55 |
-
ok('index.html repointed to ' + STABLE_BUNDLE);
|
| 56 |
-
}
|
| 57 |
-
for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) {
|
| 58 |
-
if (!existsSync(`${CLONE}/dist/${asset}`)) throw new Error(`dist/${asset} missing`);
|
| 59 |
-
}
|
| 60 |
-
const commit = shell(`set -euo pipefail;
|
| 61 |
-
git config user.name "semantic-image-field refresh";
|
| 62 |
-
git config user.email "refresh@hf.space";
|
| 63 |
-
git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')};
|
| 64 |
-
if git diff --cached --quiet; then echo NOCHANGE; else git commit -q -m "chore: rebuild with semantic search fix"; echo COMMITTED; fi`).trim();
|
| 65 |
-
if (commit.includes('COMMITTED')) {
|
| 66 |
-
git('push', '-q', 'origin', 'main');
|
| 67 |
-
ok('pushed — the static Space will rebuild');
|
| 68 |
-
} else {
|
| 69 |
-
ok(`no change needed (HEAD ${shell('git rev-parse --short HEAD').trim()})`);
|
| 70 |
-
}
|
| 71 |
-
console.log('REFRESH_OK');
|
| 72 |
-
}
|
| 73 |
-
try { main(); }
|
| 74 |
-
catch (e) { console.error('REFRESH_FAILED:', e.message, e.stderr || ''); process.exitCode = 1; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/requirements-sync.txt
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
# Needed only by scripts/sync-interiors.py, not by the field itself.
|
| 2 |
-
# torch is CPU-only in practice: a sync embeds at most a few hundred images.
|
| 3 |
-
sentence-transformers>=3.0
|
| 4 |
-
torch>=2.2
|
| 5 |
-
Pillow>=10.0
|
| 6 |
-
huggingface_hub>=0.30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/seed-catalogue.mjs
DELETED
|
@@ -1,216 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env node
|
| 2 |
-
/**
|
| 3 |
-
* Seed the vai-avatar product mirror into the Space WITHOUT running Vite.
|
| 4 |
-
*
|
| 5 |
-
* Why this exists alongside scripts/refresh-catalogue.mjs:
|
| 6 |
-
* refresh-catalogue.mjs is the steady-state job — it syncs, builds and
|
| 7 |
-
* repoints index.html. That is the right thing to run on a schedule, but it
|
| 8 |
-
* needs `npm install` plus a full `vite build`, so it needs an environment
|
| 9 |
-
* that can afford both.
|
| 10 |
-
*
|
| 11 |
-
* The mirror, however, is currently absent on BOTH sides: neither
|
| 12 |
-
* public/catalogue/ nor dist/catalogue/ has ever been committed. Until it
|
| 13 |
-
* exists, catalogue.js finds no candidate URL, the Space fetches nothing,
|
| 14 |
-
* and no product appears in the photo-viewer — whatever the bundle does.
|
| 15 |
-
*
|
| 16 |
-
* That gap does NOT need a build to close. dist/ already holds a working
|
| 17 |
-
* bundle, index.html already names it, and vite.config.js sets
|
| 18 |
-
* emptyOutDir: false precisely so a build cannot delete committed data.
|
| 19 |
-
* Writing the two JSON files into public/catalogue/ AND dist/catalogue/ is
|
| 20 |
-
* the whole fix, and it runs anywhere node and git exist.
|
| 21 |
-
*
|
| 22 |
-
* Once seeded, go back to refresh-catalogue.mjs for ongoing updates.
|
| 23 |
-
*
|
| 24 |
-
* npm run seed:catalogue
|
| 25 |
-
*
|
| 26 |
-
* Failure policy matches refresh-catalogue.mjs: every step is checked and any
|
| 27 |
-
* failure aborts BEFORE pushing, so the last-good mirror keeps serving. A
|
| 28 |
-
* half-written catalogue is worse than a stale one.
|
| 29 |
-
*/
|
| 30 |
-
|
| 31 |
-
import { execFileSync } from 'node:child_process';
|
| 32 |
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs';
|
| 33 |
-
|
| 34 |
-
const REPO = process.env.SPACE_REPO || 'bep40/semantic-image-field';
|
| 35 |
-
const CLONE = process.env.CLONE_DIR || '/tmp/seed-space';
|
| 36 |
-
const REVISION = 'main';
|
| 37 |
-
const TIMEOUT_MS = 30_000;
|
| 38 |
-
|
| 39 |
-
const ok = message => console.log(`[seed] ${message}`);
|
| 40 |
-
|
| 41 |
-
/** Kept byte-identical to the sources declared in src/catalogue.js. */
|
| 42 |
-
const SOURCES = {
|
| 43 |
-
'products.json': {
|
| 44 |
-
repo: 'bep40/grob-products-updated',
|
| 45 |
-
file: 'products_url_added.json',
|
| 46 |
-
},
|
| 47 |
-
'promos.json': {
|
| 48 |
-
repo: 'bep40/vaistudio-data',
|
| 49 |
-
file: 'promos.json',
|
| 50 |
-
},
|
| 51 |
-
};
|
| 52 |
-
|
| 53 |
-
/**
|
| 54 |
-
* The two places the bytes must exist.
|
| 55 |
-
*
|
| 56 |
-
* public/catalogue/ is the source copy and what Vite copies on future builds;
|
| 57 |
-
* dist/catalogue/ is what the browser actually fetches, because the Space
|
| 58 |
-
* serves its root index.html and catalogue.js resolves `../catalogue/…`
|
| 59 |
-
* relative to the bundle in dist/assets/. Committing only the source copy
|
| 60 |
-
* would stage the mirror, report success, and still 404 at runtime — the
|
| 61 |
-
* exact "sửa sp không đồng bộ" symptom.
|
| 62 |
-
*/
|
| 63 |
-
const DESTINATIONS = ['public/catalogue', 'dist/catalogue'];
|
| 64 |
-
|
| 65 |
-
/**
|
| 66 |
-
* Everything this script may commit. Explicit paths, never `git add -A`:
|
| 67 |
-
* .gitattributes routes *.png/*.jpg/*.pdf and more through git-lfs, and a
|
| 68 |
-
* shallow clone without a git-lfs binary checks those out as ~130-byte
|
| 69 |
-
* pointer text. `git add -A` would stage those pointers and replace committed
|
| 70 |
-
* LFS objects with text — permanent, silent data loss, committed by a script
|
| 71 |
-
* that reported success.
|
| 72 |
-
*
|
| 73 |
-
* Note what is NOT here: dist/assets and index.html. This script neither builds
|
| 74 |
-
* nor repoints, so it can never leave the Space serving a bundle it did not
|
| 75 |
-
* produce.
|
| 76 |
-
*/
|
| 77 |
-
const COMMITTABLE = DESTINATIONS;
|
| 78 |
-
|
| 79 |
-
function run(command, args, { cwd = CLONE } = {}) {
|
| 80 |
-
return execFileSync(command, args, {
|
| 81 |
-
cwd,
|
| 82 |
-
encoding: 'utf8',
|
| 83 |
-
stdio: ['ignore', 'pipe', 'pipe'],
|
| 84 |
-
maxBuffer: 64 * 1024 * 1024,
|
| 85 |
-
});
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
const shell = (script, cwd = CLONE) => run('bash', ['-lc', script], { cwd });
|
| 89 |
-
|
| 90 |
-
/**
|
| 91 |
-
* The token goes into an Authorization header rather than the remote URL:
|
| 92 |
-
* embedding it in https://user:TOKEN@huggingface.co persists it in
|
| 93 |
-
* .git/config and leaks it into reflogs and trace output.
|
| 94 |
-
*/
|
| 95 |
-
function git(...args) {
|
| 96 |
-
return run(
|
| 97 |
-
'git',
|
| 98 |
-
['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args],
|
| 99 |
-
);
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
/** Records held by a mirror file: array length, or productEdits key count. */
|
| 103 |
-
function recordCount(relative) {
|
| 104 |
-
const parsed = JSON.parse(readFileSync(`${CLONE}/${relative}`, 'utf8'));
|
| 105 |
-
if (Array.isArray(parsed)) return parsed.length;
|
| 106 |
-
return Object.keys(parsed?.productEdits ?? {}).length;
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
/**
|
| 110 |
-
* Is this file an LFS pointer or a truncated copy rather than real content?
|
| 111 |
-
* The mirror is JSON, which .gitattributes does not route through LFS, so a
|
| 112 |
-
* pointer here would mean something upstream changed that assumption.
|
| 113 |
-
*/
|
| 114 |
-
function isUnusable(relative) {
|
| 115 |
-
const path = `${CLONE}/${relative}`;
|
| 116 |
-
if (!existsSync(path)) return 'missing';
|
| 117 |
-
if (statSync(path).size < 256) return 'truncated';
|
| 118 |
-
const head = readFileSync(path, { encoding: 'utf8', flag: 'r' }).slice(0, 200);
|
| 119 |
-
if (head.startsWith('version https://git-lfs.github.com/spec/v1')) return 'an LFS pointer';
|
| 120 |
-
return null;
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
async function fetchSource(name, source) {
|
| 124 |
-
const url = `https://huggingface.co/datasets/${source.repo}/resolve/${REVISION}/${source.file}?cts=${Date.now()}`;
|
| 125 |
-
process.stdout.write(`[seed] ${name} <- ${source.repo}/${source.file} ... `);
|
| 126 |
-
const response = await fetch(url, {
|
| 127 |
-
cache: 'no-store',
|
| 128 |
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
| 129 |
-
headers: process.env.HF_TOKEN ? { Authorization: `Bearer ${process.env.HF_TOKEN}` } : {},
|
| 130 |
-
});
|
| 131 |
-
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
|
| 132 |
-
const text = await response.text();
|
| 133 |
-
// Parse before writing: a truncated body or an HTML error page would
|
| 134 |
-
// otherwise be published as if it were a catalogue.
|
| 135 |
-
JSON.parse(text);
|
| 136 |
-
process.stdout.write('ok\n');
|
| 137 |
-
return text;
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
async function main() {
|
| 141 |
-
if (!process.env.HF_TOKEN) throw new Error('HF_TOKEN is required to push the seeded mirror.');
|
| 142 |
-
|
| 143 |
-
rmSync(CLONE, { recursive: true, force: true });
|
| 144 |
-
run('mkdir', ['-p', CLONE], { cwd: '/' });
|
| 145 |
-
|
| 146 |
-
git('init', '-q');
|
| 147 |
-
git('remote', 'add', 'origin', `https://huggingface.co/spaces/${REPO}`);
|
| 148 |
-
git('fetch', '-q', '--depth', '1', 'origin', 'main');
|
| 149 |
-
git('checkout', '-q', '-B', 'main', 'FETCH_HEAD');
|
| 150 |
-
ok(`cloned ${REPO}`);
|
| 151 |
-
|
| 152 |
-
for (const directory of DESTINATIONS) mkdirSync(`${CLONE}/${directory}`, { recursive: true });
|
| 153 |
-
|
| 154 |
-
for (const [name, source] of Object.entries(SOURCES)) {
|
| 155 |
-
const text = await fetchSource(name, source);
|
| 156 |
-
for (const directory of DESTINATIONS) {
|
| 157 |
-
writeFileSync(`${CLONE}/${directory}/${name}`, text, 'utf8');
|
| 158 |
-
}
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
/**
|
| 162 |
-
* Verify both copies, and compare their counts.
|
| 163 |
-
*
|
| 164 |
-
* Presence alone is not enough: a stale dist/catalogue left over from an
|
| 165 |
-
* earlier build is byte-valid and non-empty, so it would pass a presence
|
| 166 |
-
* check while the Space serves products the fetch just replaced. Comparing
|
| 167 |
-
* counts catches exactly that.
|
| 168 |
-
*/
|
| 169 |
-
for (const [name] of Object.entries(SOURCES)) {
|
| 170 |
-
const paths = DESTINATIONS.map(directory => `${directory}/${name}`);
|
| 171 |
-
const counts = [];
|
| 172 |
-
for (const relative of paths) {
|
| 173 |
-
const problem = isUnusable(relative);
|
| 174 |
-
if (problem) throw new Error(`${relative} is ${problem} — refusing to publish`);
|
| 175 |
-
counts.push(recordCount(relative));
|
| 176 |
-
}
|
| 177 |
-
if (counts[0] !== counts[1]) {
|
| 178 |
-
throw new Error(
|
| 179 |
-
`${paths[0]} has ${counts[0]} records but ${paths[1]} has ${counts[1]} — the served copy is stale`,
|
| 180 |
-
);
|
| 181 |
-
}
|
| 182 |
-
if (!counts[0]) throw new Error(`${paths[0]} is empty — refusing to publish`);
|
| 183 |
-
ok(`${paths.join(' + ')}: ${counts[0]} records`);
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
const commit = shell(`
|
| 187 |
-
set -euo pipefail
|
| 188 |
-
git config user.name "semantic-image-field seed"
|
| 189 |
-
git config user.email "seed@hf.space"
|
| 190 |
-
git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')}
|
| 191 |
-
if git diff --cached --quiet; then
|
| 192 |
-
echo NOCHANGE
|
| 193 |
-
else
|
| 194 |
-
git commit -q -m "chore: seed vai-avatar catalogue mirror"
|
| 195 |
-
echo COMMITTED
|
| 196 |
-
fi
|
| 197 |
-
`).trim();
|
| 198 |
-
|
| 199 |
-
if (commit.includes('COMMITTED')) {
|
| 200 |
-
git('push', '-q', 'origin', 'main');
|
| 201 |
-
ok('pushed — the static Space will serve the mirror from dist/catalogue/');
|
| 202 |
-
} else {
|
| 203 |
-
const head = shell('git rev-parse --short HEAD').trim();
|
| 204 |
-
ok(`mirror already seeded (HEAD ${head})`);
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
console.log('SEED_OK');
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
try {
|
| 211 |
-
await main();
|
| 212 |
-
} catch (error) {
|
| 213 |
-
const detail = (error.stderr && String(error.stderr).trim()) || error.message;
|
| 214 |
-
console.error(`SEED_FAILED: ${detail}`);
|
| 215 |
-
process.exitCode = 1;
|
| 216 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/sync-catalogue.mjs
DELETED
|
@@ -1,141 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env node
|
| 2 |
-
/**
|
| 3 |
-
* Mirror vai-avatar's live product sources into this Space, same-origin.
|
| 4 |
-
*
|
| 5 |
-
* Why a mirror and not a runtime cross-origin fetch:
|
| 6 |
-
* huggingface.co serves dataset files with
|
| 7 |
-
* `Access-Control-Allow-Origin: https://huggingface.co`, and `/resolve` then
|
| 8 |
-
* 302s to a CDN host that sets its own CORS policy. A page on
|
| 9 |
-
* *.static.hf.space is neither origin, so the browser hides the response
|
| 10 |
-
* body and the read fails with an opaque "Failed to fetch". Same-origin
|
| 11 |
-
* cannot be blocked, so the bytes are copied into the Space instead.
|
| 12 |
-
*
|
| 13 |
-
* Freshness therefore depends on this script running, not on a rebuild of any
|
| 14 |
-
* baked data: re-run it (or let the scheduled job run it) and the Space serves
|
| 15 |
-
* whatever vai-avatar has written at that moment.
|
| 16 |
-
*
|
| 17 |
-
* node scripts/sync-catalogue.mjs
|
| 18 |
-
*
|
| 19 |
-
* Writes public/catalogue/products.json and public/catalogue/promos.json.
|
| 20 |
-
* `public/` is what Vite copies into dist/, so both end up served with the app.
|
| 21 |
-
*/
|
| 22 |
-
|
| 23 |
-
import { mkdir, writeFile } from 'node:fs/promises';
|
| 24 |
-
import { realpathSync } from 'node:fs';
|
| 25 |
-
import { dirname, resolve } from 'node:path';
|
| 26 |
-
import { fileURLToPath } from 'node:url';
|
| 27 |
-
|
| 28 |
-
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
| 29 |
-
const OUT_DIR = resolve(ROOT, 'public/catalogue');
|
| 30 |
-
const REVISION = 'main';
|
| 31 |
-
const TIMEOUT_MS = 30_000;
|
| 32 |
-
|
| 33 |
-
/** Kept byte-identical to the sources declared in src/catalogue.js. */
|
| 34 |
-
export const SOURCES = {
|
| 35 |
-
'products.json': {
|
| 36 |
-
repo: 'bep40/grob-products-updated',
|
| 37 |
-
file: 'products_url_added.json',
|
| 38 |
-
},
|
| 39 |
-
'promos.json': {
|
| 40 |
-
repo: 'bep40/vaistudio-data',
|
| 41 |
-
file: 'promos.json',
|
| 42 |
-
},
|
| 43 |
-
};
|
| 44 |
-
|
| 45 |
-
function urlFor({ repo, file }) {
|
| 46 |
-
return `https://huggingface.co/datasets/${repo}/resolve/${REVISION}/${file}?cts=${Date.now()}`;
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
async function fetchSource(name, source) {
|
| 50 |
-
const url = urlFor(source);
|
| 51 |
-
process.stdout.write(`[sync] ${name} <- ${source.repo}/${source.file} ... `);
|
| 52 |
-
const response = await fetch(url, {
|
| 53 |
-
cache: 'no-store',
|
| 54 |
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
| 55 |
-
headers: process.env.HF_TOKEN ? { Authorization: `Bearer ${process.env.HF_TOKEN}` } : {},
|
| 56 |
-
});
|
| 57 |
-
if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
|
| 58 |
-
const text = await response.text();
|
| 59 |
-
// Validate before writing: a truncated or HTML error body would otherwise be
|
| 60 |
-
// served to the app as if it were a catalogue.
|
| 61 |
-
const parsed = JSON.parse(text);
|
| 62 |
-
const count = Array.isArray(parsed)
|
| 63 |
-
? `${parsed.length} products`
|
| 64 |
-
: `${Object.keys(parsed?.productEdits ?? {}).length} productEdits`;
|
| 65 |
-
process.stdout.write(`ok (${count})\n`);
|
| 66 |
-
return text;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
/**
|
| 70 |
-
* Was this file the process entrypoint?
|
| 71 |
-
*
|
| 72 |
-
* Comparing basenames with `endsWith` looks equivalent and is not: any file whose
|
| 73 |
-
* name merely ENDS WITH this one (`my-sync-catalogue.mjs`) also matches, so
|
| 74 |
-
* loading such a file would run main() as a side effect of importing it. Both
|
| 75 |
-
* paths are resolved before comparing.
|
| 76 |
-
*/
|
| 77 |
-
function isDirectRun() {
|
| 78 |
-
const entry = process.argv[1];
|
| 79 |
-
if (!entry) return false;
|
| 80 |
-
try {
|
| 81 |
-
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
| 82 |
-
} catch {
|
| 83 |
-
return false;
|
| 84 |
-
}
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
async function main() {
|
| 88 |
-
await mkdir(OUT_DIR, { recursive: true });
|
| 89 |
-
let failures = 0;
|
| 90 |
-
for (const [name, source] of Object.entries(SOURCES)) {
|
| 91 |
-
try {
|
| 92 |
-
const text = await fetchSource(name, source);
|
| 93 |
-
await writeFile(resolve(OUT_DIR, name), text, 'utf8');
|
| 94 |
-
} catch (error) {
|
| 95 |
-
// The good file is still written, so one transient error does not throw
|
| 96 |
-
// away a successful fetch. Whether that mixture may be PUBLISHED is
|
| 97 |
-
// decided below.
|
| 98 |
-
failures++;
|
| 99 |
-
console.error(`[sync] FAILED ${name}: ${error.message}`);
|
| 100 |
-
}
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
/**
|
| 104 |
-
* ANY failure fails the run, not only a total failure.
|
| 105 |
-
*
|
| 106 |
-
* This matters from the second successful run onward, once public/catalogue is
|
| 107 |
-
* committed: a file that failed to fetch is then still on disk as its previous
|
| 108 |
-
* version, so requireRealFile() and recordCount() in refresh-catalogue.mjs both
|
| 109 |
-
* pass on that stale copy. The caller would then build, commit and print
|
| 110 |
-
* REFRESH_OK, and the Space would serve a fresh products.json beside an old
|
| 111 |
-
* promos.json -- so every Sửa and every Xóa from vai-avatar silently stopped
|
| 112 |
-
* applying, with no error anywhere.
|
| 113 |
-
*
|
| 114 |
-
* On the very first run the mirror is not committed yet, so the failed file is
|
| 115 |
-
* simply absent and the caller's guard already catches it. Failing here makes
|
| 116 |
-
* that behaviour unconditional instead of depending on which run it is.
|
| 117 |
-
*
|
| 118 |
-
* A stale overlay is not a degraded catalogue, it is a wrong one: catalogue.js
|
| 119 |
-
* merges the two files, so old edits resurrect old prices and old tombstones
|
| 120 |
-
* fight the new file. Aborting here is what makes the caller leave the
|
| 121 |
-
* previously committed mirror intact, which is the guarantee both scripts
|
| 122 |
-
* document.
|
| 123 |
-
*/
|
| 124 |
-
if (failures > 0) {
|
| 125 |
-
console.error(
|
| 126 |
-
`[sync] ${failures}/${Object.keys(SOURCES).length} source(s) failed — the mirror on disk `
|
| 127 |
-
+ 'is now MIXED (fresh file beside last-commit file). Refusing to report success: '
|
| 128 |
-
+ 'the caller must abort so the last-good mirror stays live.',
|
| 129 |
-
);
|
| 130 |
-
process.exitCode = 1;
|
| 131 |
-
return;
|
| 132 |
-
}
|
| 133 |
-
console.log(`[sync] wrote ${OUT_DIR}`);
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
if (isDirectRun()) {
|
| 137 |
-
main().catch(error => {
|
| 138 |
-
console.error(`[sync] ${error.stack || error}`);
|
| 139 |
-
process.exitCode = 1;
|
| 140 |
-
});
|
| 141 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/sync-interiors.py
DELETED
|
@@ -1,388 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Two-way sync between this Space's interiors and vai-avatar's interior library.
|
| 4 |
-
|
| 5 |
-
WHY THIS CANNOT JUST COPY FILES
|
| 6 |
-
-------------------------------
|
| 7 |
-
The field searches interiors with its own CLIP matrix
|
| 8 |
-
(`image-embeddings_25k-512-32bit.bin`, 512 dims). vai-avatar's library uses a
|
| 9 |
-
different embedding model. A new image therefore cannot be copied across: it
|
| 10 |
-
has to be encoded with the field's own model first, or it lands in a 512-dim
|
| 11 |
-
space that is not the one the field searches.
|
| 12 |
-
|
| 13 |
-
This was measured rather than assumed. Cosine between the two datasets on the
|
| 14 |
-
377 images they share is 0.6347; cosine between two DIFFERENT images is 0.6336.
|
| 15 |
-
Same dimensionality, no signal -- the spaces are not interchangeable, so a file
|
| 16 |
-
copy would produce confidently wrong search results.
|
| 17 |
-
|
| 18 |
-
WHAT IT DOES
|
| 19 |
-
------------
|
| 20 |
-
added live on the API, absent from the field -> download, embed, upload,
|
| 21 |
-
append to both index files
|
| 22 |
-
removed present in the field, gone from the API -> drop from the index and
|
| 23 |
-
the vector blob, delete the bitmap
|
| 24 |
-
|
| 25 |
-
The source of truth is the API's own `/latest`, not the dataset's
|
| 26 |
-
`metadata.json`. That file is a history, not a manifest: it holds 1,752 rows
|
| 27 |
-
for 1,122 live images, so deleted rows linger in it and a file-driven sync
|
| 28 |
-
would resurrect whatever the user removed on the other side.
|
| 29 |
-
|
| 30 |
-
RUN
|
| 31 |
-
---
|
| 32 |
-
pip install -r scripts/requirements-sync.txt
|
| 33 |
-
python scripts/sync-interiors.py --dry-run # report, change nothing
|
| 34 |
-
python scripts/sync-interiors.py # apply
|
| 35 |
-
python scripts/sync-interiors.py --limit 5 # apply a few, for testing
|
| 36 |
-
"""
|
| 37 |
-
|
| 38 |
-
from __future__ import annotations
|
| 39 |
-
|
| 40 |
-
import argparse
|
| 41 |
-
import base64
|
| 42 |
-
import io
|
| 43 |
-
import json
|
| 44 |
-
import os
|
| 45 |
-
import struct
|
| 46 |
-
import sys
|
| 47 |
-
import time
|
| 48 |
-
import urllib.error
|
| 49 |
-
import urllib.request
|
| 50 |
-
|
| 51 |
-
from PIL import Image
|
| 52 |
-
|
| 53 |
-
# --------------------------------------------------------------------------
|
| 54 |
-
# Where things live
|
| 55 |
-
# --------------------------------------------------------------------------
|
| 56 |
-
|
| 57 |
-
API = os.environ.get("INTERIOR_API_URL", "https://bep40-vai-avatar2-interior-api.hf.space")
|
| 58 |
-
DATASET = os.environ.get("FIELD_DATASET", "bep40/interior-image-field-assets")
|
| 59 |
-
HUB = os.environ.get("HF_HUB") or os.environ.get("HF_TOKEN")
|
| 60 |
-
|
| 61 |
-
INDEX_NAME = "image-embeddings.json"
|
| 62 |
-
BLOB_NAME = "image-embeddings_25k-512-32bit.bin"
|
| 63 |
-
|
| 64 |
-
RESOLVE = f"https://huggingface.co/datasets/{DATASET}/resolve/main/"
|
| 65 |
-
|
| 66 |
-
# The field's image model, matching what produced the existing vectors.
|
| 67 |
-
IMAGE_MODEL = "sentence-transformers/clip-ViT-B-32-multilingual-v1"
|
| 68 |
-
DIMS = 512
|
| 69 |
-
|
| 70 |
-
BRANDS = ("malloca", "grob", "hafele", "eurogold", "canzy", "konox", "demax")
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def log(message: str) -> None:
|
| 74 |
-
print(message, flush=True)
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def get(url: str, timeout: int = 120) -> bytes:
|
| 78 |
-
request = urllib.request.Request(url, headers={"User-Agent": "interior-sync/1.0"})
|
| 79 |
-
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 80 |
-
return response.read()
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def post_json(url: str, payload: dict, timeout: int = 120) -> dict:
|
| 84 |
-
request = urllib.request.Request(
|
| 85 |
-
url,
|
| 86 |
-
data=json.dumps(payload).encode(),
|
| 87 |
-
headers={"Content-Type": "application/json", "User-Agent": "interior-sync/1.0"},
|
| 88 |
-
)
|
| 89 |
-
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 90 |
-
return json.loads(response.read().decode())
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
# --------------------------------------------------------------------------
|
| 94 |
-
# The field's vector format
|
| 95 |
-
#
|
| 96 |
-
# `image-embeddings_25k-512-32bit.bin` is headerless little-endian float32,
|
| 97 |
-
# 512 floats per row, row order matching `image-embeddings.json`. The "25k" in
|
| 98 |
-
# the filename is decorative: the file holds exactly as many rows as the index
|
| 99 |
-
# has entries.
|
| 100 |
-
# --------------------------------------------------------------------------
|
| 101 |
-
|
| 102 |
-
def decode_blob(raw: bytes) -> list[list[float]]:
|
| 103 |
-
count, remainder = divmod(len(raw), 4 * DIMS)
|
| 104 |
-
if remainder:
|
| 105 |
-
raise ValueError(
|
| 106 |
-
f"blob is {len(raw)} bytes, not a whole number of {DIMS}-float rows "
|
| 107 |
-
f"(remainder {remainder}) -- the index and the vectors disagree"
|
| 108 |
-
)
|
| 109 |
-
flat = struct.unpack(f"<{count * DIMS}f", raw[: count * 4 * DIMS])
|
| 110 |
-
return [list(flat[i * DIMS : (i + 1) * DIMS]) for i in range(count)]
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
def encode_blob(rows: list[list[float]]) -> bytes:
|
| 114 |
-
for index, row in enumerate(rows):
|
| 115 |
-
if len(row) != DIMS:
|
| 116 |
-
raise ValueError(f"row {index} has {len(row)} values, expected {DIMS}")
|
| 117 |
-
return struct.pack(f"<{len(rows) * DIMS}f", *(v for row in rows for v in row))
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
# --------------------------------------------------------------------------
|
| 121 |
-
# Reading the current state
|
| 122 |
-
# --------------------------------------------------------------------------
|
| 123 |
-
|
| 124 |
-
def fetch_live_images() -> dict[str, dict]:
|
| 125 |
-
"""Every image the API currently serves, keyed by image_id.
|
| 126 |
-
|
| 127 |
-
The paging parameter is `top_k`, not `limit`; sending `limit` silently
|
| 128 |
-
returns the default page of 24 no matter what is asked for.
|
| 129 |
-
"""
|
| 130 |
-
first = post_json(f"{API}/latest", {"top_k": 1, "offset": 0})
|
| 131 |
-
total = int(first.get("total", 0))
|
| 132 |
-
live: dict[str, dict] = {}
|
| 133 |
-
offset = 0
|
| 134 |
-
while offset < total:
|
| 135 |
-
page = post_json(f"{API}/latest", {"top_k": 250, "offset": offset})
|
| 136 |
-
rows = page.get("results", [])
|
| 137 |
-
if not rows:
|
| 138 |
-
break
|
| 139 |
-
for row in rows:
|
| 140 |
-
live[row["image_id"]] = row
|
| 141 |
-
offset += len(rows)
|
| 142 |
-
time.sleep(0.4)
|
| 143 |
-
log(f" API reports {total} live images; collected {len(live)}")
|
| 144 |
-
if len(live) != total:
|
| 145 |
-
log(f" WARNING: expected {total} ids but collected {len(live)}")
|
| 146 |
-
return live
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def load_field_state() -> tuple[list[dict], list[list[float]]]:
|
| 150 |
-
index = json.loads(get(RESOLVE + INDEX_NAME).decode())
|
| 151 |
-
blob = decode_blob(get(RESOLVE + BLOB_NAME))
|
| 152 |
-
if len(index) != len(blob):
|
| 153 |
-
raise ValueError(
|
| 154 |
-
f"index has {len(index)} entries but the blob holds {len(blob)} vectors; "
|
| 155 |
-
"refusing to write a file the field cannot load"
|
| 156 |
-
)
|
| 157 |
-
return index, blob
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
# --------------------------------------------------------------------------
|
| 161 |
-
# Record conventions, mirrored from the field's own scraper output
|
| 162 |
-
# --------------------------------------------------------------------------
|
| 163 |
-
|
| 164 |
-
def aspect_ratio(image: Image.Image) -> float:
|
| 165 |
-
width, height = image.size
|
| 166 |
-
return round(width / height, 4) if height else 1.0
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def blur_placeholder(image: Image.Image) -> str:
|
| 170 |
-
"""
|
| 171 |
-
A tiny base64 JPEG, like the placeholders the scraper writes.
|
| 172 |
-
|
| 173 |
-
The field paints `blur` behind each tile, so an image without one shows an
|
| 174 |
-
empty box for the frames before its bitmap arrives. This costs ~60 bytes and
|
| 175 |
-
removes that flash.
|
| 176 |
-
"""
|
| 177 |
-
buffer = io.BytesIO()
|
| 178 |
-
image.convert("RGB").resize((4, 3)).save(buffer, format="JPEG", quality=40)
|
| 179 |
-
return base64.b64encode(buffer.getvalue()).decode()
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def title_from(entry: dict) -> str:
|
| 183 |
-
for key in ("desc", "title"):
|
| 184 |
-
value = entry.get(key)
|
| 185 |
-
if isinstance(value, str) and value.strip():
|
| 186 |
-
return " ".join(value.split())[:120]
|
| 187 |
-
for key in ("labels", "keywords"):
|
| 188 |
-
value = entry.get(key)
|
| 189 |
-
if isinstance(value, list) and value:
|
| 190 |
-
return str(value[0]).strip()[:120]
|
| 191 |
-
return ""
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
def categories_from(entry: dict) -> list[str]:
|
| 195 |
-
out: list[str] = []
|
| 196 |
-
for key in ("categories", "room_hints"):
|
| 197 |
-
for value in entry.get(key) or []:
|
| 198 |
-
text = str(value).strip()
|
| 199 |
-
if text and text not in out:
|
| 200 |
-
out.append(text)
|
| 201 |
-
return out[:4] or ["Uncategorized"]
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
def build_record(entry: dict, image: Image.Image) -> dict:
|
| 205 |
-
"""One interior row, in exactly the shape image-embeddings.json uses."""
|
| 206 |
-
image_id = entry["image_id"]
|
| 207 |
-
return {
|
| 208 |
-
"id": image_id,
|
| 209 |
-
# The field resolves an interior's bitmap relative to its own dataset and
|
| 210 |
-
# the scraper stored a bare filename, so keep that convention.
|
| 211 |
-
"url": f"{image_id}.jpg",
|
| 212 |
-
"ar": aspect_ratio(image),
|
| 213 |
-
"blur": blur_placeholder(image),
|
| 214 |
-
"title": title_from(entry),
|
| 215 |
-
"categories": categories_from(entry),
|
| 216 |
-
"source": str(entry.get("source") or ""),
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
# --------------------------------------------------------------------------
|
| 221 |
-
# Embedding
|
| 222 |
-
# --------------------------------------------------------------------------
|
| 223 |
-
|
| 224 |
-
class Embedder:
|
| 225 |
-
"""The field's own image model, loaded lazily so a no-op sync is cheap."""
|
| 226 |
-
|
| 227 |
-
def __init__(self) -> None:
|
| 228 |
-
self._model = None
|
| 229 |
-
|
| 230 |
-
def _load(self):
|
| 231 |
-
if self._model is None:
|
| 232 |
-
from sentence_transformers import SentenceTransformer
|
| 233 |
-
|
| 234 |
-
log(f" loading {IMAGE_MODEL} (first run downloads ~600 MB)")
|
| 235 |
-
self._model = SentenceTransformer(IMAGE_MODEL)
|
| 236 |
-
return self._model
|
| 237 |
-
|
| 238 |
-
def image(self, image: Image.Image) -> list[float]:
|
| 239 |
-
vector = self._load().encode([image], convert_to_numpy=True)[0]
|
| 240 |
-
norm = float((vector**2).sum() ** 0.5)
|
| 241 |
-
if norm > 0:
|
| 242 |
-
vector = vector / norm
|
| 243 |
-
if len(vector) != DIMS:
|
| 244 |
-
raise ValueError(f"model returned {len(vector)} dims, expected {DIMS}")
|
| 245 |
-
return [float(v) for v in vector]
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
# --------------------------------------------------------------------------
|
| 249 |
-
# The sync itself
|
| 250 |
-
# --------------------------------------------------------------------------
|
| 251 |
-
|
| 252 |
-
def main() -> int:
|
| 253 |
-
parser = argparse.ArgumentParser(description=__doc__)
|
| 254 |
-
parser.add_argument("--dry-run", action="store_true", help="report changes, write nothing")
|
| 255 |
-
parser.add_argument("--no-upload", action="store_true", help="build everything but do not push")
|
| 256 |
-
parser.add_argument("--limit", type=int, default=0, help="cap how many images to add")
|
| 257 |
-
args = parser.parse_args()
|
| 258 |
-
|
| 259 |
-
if not HUB and not args.dry_run and not args.no_upload:
|
| 260 |
-
log("HF_TOKEN is not set. Export it, or pass --no-upload to build without pushing.")
|
| 261 |
-
return 2
|
| 262 |
-
|
| 263 |
-
log("Reading the field...")
|
| 264 |
-
index, vectors = load_field_state()
|
| 265 |
-
log(f" field holds {len(index)} interiors")
|
| 266 |
-
|
| 267 |
-
log("Reading the API...")
|
| 268 |
-
live = fetch_live_images()
|
| 269 |
-
|
| 270 |
-
have = {str(row.get("id")) for row in index}
|
| 271 |
-
to_add = sorted(set(live) - have)
|
| 272 |
-
to_drop = sorted(have - set(live))
|
| 273 |
-
|
| 274 |
-
log("")
|
| 275 |
-
log(f" to add : {len(to_add)}")
|
| 276 |
-
log(f" to remove: {len(to_drop)}")
|
| 277 |
-
if to_drop:
|
| 278 |
-
log(f" {', '.join(to_drop[:6])}{' ...' if len(to_drop) > 6 else ''}")
|
| 279 |
-
if to_add:
|
| 280 |
-
log(f" {', '.join(to_add[:6])}{' ...' if len(to_add) > 6 else ''}")
|
| 281 |
-
|
| 282 |
-
if args.limit:
|
| 283 |
-
to_add = to_add[: args.limit]
|
| 284 |
-
log(f" limited to {len(to_add)} additions for this run")
|
| 285 |
-
|
| 286 |
-
if not to_add and not to_drop:
|
| 287 |
-
log("\nNothing to do -- the two sides already agree.")
|
| 288 |
-
return 0
|
| 289 |
-
if args.dry_run:
|
| 290 |
-
log("\nDry run: nothing was written.")
|
| 291 |
-
return 0
|
| 292 |
-
if not to_add:
|
| 293 |
-
log("\nOnly removals pending, which the same run applies below.")
|
| 294 |
-
if not to_add and not to_drop:
|
| 295 |
-
return 0
|
| 296 |
-
|
| 297 |
-
added_rows: list[dict] = []
|
| 298 |
-
added_vectors: list[list[float]] = []
|
| 299 |
-
bitmaps: list[tuple[str, bytes]] = []
|
| 300 |
-
|
| 301 |
-
if to_add:
|
| 302 |
-
log(f"\nEmbedding {len(to_add)} new interiors...")
|
| 303 |
-
embedder = Embedder()
|
| 304 |
-
for n, image_id in enumerate(to_add, 1):
|
| 305 |
-
entry = live[image_id]
|
| 306 |
-
try:
|
| 307 |
-
raw = get(f"{API}/image/{image_id}.jpg")
|
| 308 |
-
image = Image.open(io.BytesIO(raw))
|
| 309 |
-
image.load()
|
| 310 |
-
except (urllib.error.URLError, OSError) as error:
|
| 311 |
-
log(f" [{n}/{len(to_add)}] {image_id}: fetch failed ({error}) -- skipped")
|
| 312 |
-
continue
|
| 313 |
-
try:
|
| 314 |
-
vector = embedder.image(image)
|
| 315 |
-
except Exception as error: # noqa: BLE001 - report and keep going
|
| 316 |
-
log(f" [{n}/{len(to_add)}] {image_id}: embed failed ({error}) -- skipped")
|
| 317 |
-
continue
|
| 318 |
-
added_rows.append(build_record(entry, image))
|
| 319 |
-
added_vectors.append(vector)
|
| 320 |
-
bitmaps.append((f"images/{image_id}.jpg", raw))
|
| 321 |
-
log(f" [{n}/{len(to_add)}] {image_id} {image.size[0]}x{image.size[1]} {title_from(entry)[:44]}")
|
| 322 |
-
|
| 323 |
-
new_index = index + added_rows
|
| 324 |
-
new_vectors = vectors + added_vectors
|
| 325 |
-
|
| 326 |
-
if to_drop:
|
| 327 |
-
# Rebuild both files from the surviving rows, so the index and the blob
|
| 328 |
-
# cannot drift out of alignment: the field refuses to load them otherwise.
|
| 329 |
-
drop = set(to_drop)
|
| 330 |
-
keep = [i for i, row in enumerate(new_index) if str(row.get("id")) not in drop]
|
| 331 |
-
new_index = [new_index[i] for i in keep]
|
| 332 |
-
new_vectors = [new_vectors[i] for i in keep]
|
| 333 |
-
|
| 334 |
-
log("")
|
| 335 |
-
log(f"Final: {len(new_index)} interiors "
|
| 336 |
-
f"(+{len(added_rows)} added, -{len(to_drop)} removed)")
|
| 337 |
-
|
| 338 |
-
if not added_rows and not to_drop:
|
| 339 |
-
log("Nothing changed.")
|
| 340 |
-
return 0
|
| 341 |
-
if args.no_upload:
|
| 342 |
-
log("Skipping push (--no-upload).")
|
| 343 |
-
return 0
|
| 344 |
-
|
| 345 |
-
from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi
|
| 346 |
-
|
| 347 |
-
client = HfApi(token=HUB) if HUB else HfApi()
|
| 348 |
-
operations = [CommitOperationAdd(path_in_repo=name, path_or_fileobj=data)
|
| 349 |
-
for name, data in bitmaps]
|
| 350 |
-
operations.append(CommitOperationAdd(
|
| 351 |
-
path_in_repo=INDEX_NAME,
|
| 352 |
-
path_or_fileobj=json.dumps(new_index, ensure_ascii=False).encode(),
|
| 353 |
-
))
|
| 354 |
-
operations.append(CommitOperationAdd(
|
| 355 |
-
path_in_repo=BLOB_NAME, path_or_fileobj=encode_blob(new_vectors)))
|
| 356 |
-
if to_drop:
|
| 357 |
-
# Only delete what is actually on the Hub, or the whole commit 404s.
|
| 358 |
-
present = set(client.list_repo_files(DATASET, repo_type="dataset"))
|
| 359 |
-
removed = 0
|
| 360 |
-
for image_id in to_drop:
|
| 361 |
-
path = f"images/{image_id}.jpg"
|
| 362 |
-
if path in present:
|
| 363 |
-
operations.append(CommitOperationDelete(path_in_repo=path))
|
| 364 |
-
removed += 1
|
| 365 |
-
log(f" deleting {removed} of {len(to_drop)} bitmaps (the rest were already gone)")
|
| 366 |
-
|
| 367 |
-
commit = client.create_commit(
|
| 368 |
-
repo_id=DATASET,
|
| 369 |
-
repo_type="dataset",
|
| 370 |
-
operations=operations,
|
| 371 |
-
commit_message=(
|
| 372 |
-
f"sync interiors with vai-avatar: +{len(added_rows)} -{len(to_drop)}\n\n"
|
| 373 |
-
"Driven by the API's live image list rather than metadata.json: that file\n"
|
| 374 |
-
"retains deleted rows (1,752 rows for 1,122 live images), so a\n"
|
| 375 |
-
"file-driven sync resurrects whatever the user removed on the other side.\n\n"
|
| 376 |
-
"New images are embedded with the field's own CLIP model. The two sides\n"
|
| 377 |
-
"use different embedding models, so copying a file would place it in an\n"
|
| 378 |
-
"unrelated vector space and search would return noise.\n\n"
|
| 379 |
-
"Generated by scripts/sync-interiors.py."
|
| 380 |
-
),
|
| 381 |
-
)
|
| 382 |
-
log(f"Pushed {commit.oid} to {DATASET}")
|
| 383 |
-
log("The field polls the dataset revision, so these appear within a minute.")
|
| 384 |
-
return 0
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
if __name__ == "__main__":
|
| 388 |
-
sys.exit(main())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-avatar-interiors.mjs
DELETED
|
@@ -1,287 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Tests for the vai-avatar interior sync and the featured/neon emphasis.
|
| 3 |
-
*
|
| 4 |
-
* The emphasis rules are the part most likely to rot silently: a wrong tile
|
| 5 |
-
* still renders, it just renders at the wrong size or without the ring, and
|
| 6 |
-
* nothing throws. These assert the geometry and the ordering, not just "no
|
| 7 |
-
* crash".
|
| 8 |
-
*
|
| 9 |
-
* The share-URL block is here for the same reason, and it is deliberately
|
| 10 |
-
* Node-only. The browser copy of this rule lives in dist/assets/share-fix.js as
|
| 11 |
-
* a string regex, because it has to run against the served bundle before a
|
| 12 |
-
* rebuild; the routing decision itself lives in src/assets.js. These tests pin
|
| 13 |
-
* the src/ half so the two cannot drift apart silently, and the browser half is
|
| 14 |
-
* asserted separately in scripts/test-neon-ui.mjs.
|
| 15 |
-
*/
|
| 16 |
-
import assert from 'node:assert/strict';
|
| 17 |
-
import { mergeResults } from '../src/merge.js';
|
| 18 |
-
import { foldText, searchAvatarInteriors } from '../src/avatar-interiors.js';
|
| 19 |
-
import { shareURL } from '../src/assets.js';
|
| 20 |
-
|
| 21 |
-
let passed = 0, failed = 0;
|
| 22 |
-
const test = (name, fn) => {
|
| 23 |
-
try { fn(); console.log(' ok ' + name); passed++; }
|
| 24 |
-
catch (error) { console.log(' FAIL ' + name + '\n ' + error.message); failed++; }
|
| 25 |
-
};
|
| 26 |
-
|
| 27 |
-
const interior = (id, score) => ({ id: `interior:${id}`, blur: 'L000', ar: 1, score });
|
| 28 |
-
const product = (sku, score) => ({ sku, name: 'Tủ bếp', ar: 1, score, images: ['https://x/y.jpg'] });
|
| 29 |
-
|
| 30 |
-
console.log('\nvai-avatar interior sync');
|
| 31 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 32 |
-
|
| 33 |
-
test('Vietnamese folding makes diacritics optional on both sides', () => {
|
| 34 |
-
// The whole reason foldText exists: a visitor types "bep da", the record says
|
| 35 |
-
// "Bếp đá". Without folding these never match and the neon never fires.
|
| 36 |
-
assert.equal(foldText('Bếp đá'), 'bep da');
|
| 37 |
-
assert.equal(foldText('Phòng Ngủ'), 'phong ngu');
|
| 38 |
-
});
|
| 39 |
-
|
| 40 |
-
test('a whole-query match outranks a single-term match', () => {
|
| 41 |
-
const items = [
|
| 42 |
-
{ avatarImageId: 'a', title: 'sofa phòng khách hiện đại', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' },
|
| 43 |
-
{ avatarImageId: 'b', title: 'ghế sofa', description: 'mẫu ghế đơn', categories: [], keywords: [], addedAt: '2026-10-02T00:00:00Z' },
|
| 44 |
-
];
|
| 45 |
-
const hits = searchAvatarInteriors('sofa phòng khách', items);
|
| 46 |
-
assert.equal(hits[0].avatarImageId, 'a', 'the full-query match must rank first');
|
| 47 |
-
});
|
| 48 |
-
|
| 49 |
-
test('a whole-query match scores above the exact-outline threshold', () => {
|
| 50 |
-
// 900 is the same floor relevance.js uses, and it is what makes merge.js treat
|
| 51 |
-
// this as identity rather than resemblance. If this dropped below 700 the neon
|
| 52 |
-
// would silently stop appearing on newly added photographs.
|
| 53 |
-
const items = [{ avatarImageId: 'a', title: 'chuyển sang phong cách classical', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
|
| 54 |
-
assert.ok(searchAvatarInteriors('classical', items)[0].score >= 700);
|
| 55 |
-
});
|
| 56 |
-
|
| 57 |
-
test('an unrelated query returns nothing rather than everything', () => {
|
| 58 |
-
const items = [{ avatarImageId: 'a', title: 'sofa phòng khách', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
|
| 59 |
-
assert.equal(searchAvatarInteriors('móc treo', items).length, 0);
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
test('a query too short to be meaningful is rejected', () => {
|
| 63 |
-
const items = [{ avatarImageId: 'a', title: 'a', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z' }];
|
| 64 |
-
assert.equal(searchAvatarInteriors('a', items).length, 0);
|
| 65 |
-
});
|
| 66 |
-
|
| 67 |
-
console.log('\nshare URLs');
|
| 68 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 69 |
-
|
| 70 |
-
const STUDIO = 'https://bep40-vai-avatar.hf.space';
|
| 71 |
-
const broken = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
|
| 72 |
-
const fixed = `${STUDIO}/share/i/b68dc779370dc70e`;
|
| 73 |
-
|
| 74 |
-
test('the reported broken URL is now the interior route', () => {
|
| 75 |
-
// The exact address from the report. `avatarimg:` is a namespace for
|
| 76 |
-
// de-duplication, not a catalogue identity, so /share/p/ resolved to nothing
|
| 77 |
-
// and vai-avatar answered "Not Found" for every interior photograph.
|
| 78 |
-
assert.equal(shareURL({ avatarImageId: 'b68dc779370dc70e', sku: 'avatarimg:b68dc779370dc70e' }), fixed);
|
| 79 |
-
});
|
| 80 |
-
|
| 81 |
-
test('the bug is reproducible against the old logic', () => {
|
| 82 |
-
// Pins what the fix is fixing, so the assertion above cannot pass for an
|
| 83 |
-
// unrelated reason. Without the avatarImageId branch, `sku` wins and the link
|
| 84 |
-
// is the dead one -- byte-identical to the reported URL.
|
| 85 |
-
const photo = { sku: 'avatarimg:b68dc779370dc70e' };
|
| 86 |
-
const oldRoute = `${STUDIO}/share/p/${encodeURIComponent(photo.sku)}`;
|
| 87 |
-
assert.equal(oldRoute, broken, 'the reported URL must be what the old routing produced');
|
| 88 |
-
assert.notEqual(shareURL(photo), oldRoute, 'shareURL still routes an avatarimg sku to /share/p/');
|
| 89 |
-
});
|
| 90 |
-
|
| 91 |
-
test('an interior is routed even when it has lost avatarImageId', () => {
|
| 92 |
-
// merge.js rewrites `id` into a namespaced tile key, so a record can arrive
|
| 93 |
-
// carrying the sku but not the original field. The prefix is unwrapped rather
|
| 94 |
-
// than treated as an identity, which is what this fallback exists for.
|
| 95 |
-
assert.equal(shareURL({ sku: 'avatarimg:b68dc779370dc70e' }), fixed);
|
| 96 |
-
});
|
| 97 |
-
|
| 98 |
-
test('an empty avatarimg prefix yields no share URL at all', () => {
|
| 99 |
-
// /share/i/ with nothing after it is its own 404. Returning null lets the
|
| 100 |
-
// caller omit the button rather than render a dead link.
|
| 101 |
-
assert.equal(shareURL({ sku: 'avatarimg:' }), null);
|
| 102 |
-
assert.equal(shareURL({ avatarImageId: ' ' }), null);
|
| 103 |
-
});
|
| 104 |
-
|
| 105 |
-
test('a real product still routes to /share/p/', () => {
|
| 106 |
-
// The fix must not disturb the route that works. A product keeps its slug, and
|
| 107 |
-
// /share/p/ resolves all three identity forms.
|
| 108 |
-
assert.equal(shareURL({ sku: 'MH800TC' }), `${STUDIO}/share/p/MH800TC`);
|
| 109 |
-
assert.equal(
|
| 110 |
-
shareURL({ sku: 'MH800TC', slug: 'mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm' }),
|
| 111 |
-
`${STUDIO}/share/p/mh800tc-malloca-may-hut-mui-gan-tu-am-tu-80cm`,
|
| 112 |
-
'the slug must still win over the sku',
|
| 113 |
-
);
|
| 114 |
-
});
|
| 115 |
-
|
| 116 |
-
test('a baked interior without a sku keeps its own route', () => {
|
| 117 |
-
// No sku at all: the id branch is what answers, and it must not be captured by
|
| 118 |
-
// the avatarimg fallback.
|
| 119 |
-
assert.equal(shareURL({ id: 'a1b2c3d4e5' }), `${STUDIO}/share/i/a1b2c3d4e5`);
|
| 120 |
-
});
|
| 121 |
-
|
| 122 |
-
test('a record with nothing to share returns null', () => {
|
| 123 |
-
assert.equal(shareURL(null), null);
|
| 124 |
-
assert.equal(shareURL(undefined), null);
|
| 125 |
-
assert.equal(shareURL({}), null);
|
| 126 |
-
assert.equal(shareURL('nope'), null);
|
| 127 |
-
});
|
| 128 |
-
|
| 129 |
-
console.log('\nfeatured tiles');
|
| 130 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 131 |
-
|
| 132 |
-
test('vai-avatar interiors land at the head of the field', () => {
|
| 133 |
-
// "near the search box" is implemented as list order: index 0 is the slot
|
| 134 |
-
// nearest the pill. A new photograph must not land mid-field.
|
| 135 |
-
const out = mergeResults(
|
| 136 |
-
[interior('a', 0.9), interior('b', 0.8)],
|
| 137 |
-
[],
|
| 138 |
-
{ avatarInteriors: [{ avatarImageId: 'new1', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' }] },
|
| 139 |
-
);
|
| 140 |
-
assert.equal(out[0].avatarImageId, 'new1');
|
| 141 |
-
assert.ok(out[0].featured, 'a new photograph is featured');
|
| 142 |
-
});
|
| 143 |
-
|
| 144 |
-
test('newest first: the most recent photograph is the first tile', () => {
|
| 145 |
-
const out = mergeResults([], [], { avatarInteriors: [
|
| 146 |
-
{ avatarImageId: 'older', title: 'Cũ', addedAt: '2026-10-01T00:00:00Z' },
|
| 147 |
-
{ avatarImageId: 'newer', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
|
| 148 |
-
] });
|
| 149 |
-
assert.equal(out[0].avatarImageId, 'newer');
|
| 150 |
-
});
|
| 151 |
-
|
| 152 |
-
test('a featured tile is measurably larger than an ordinary one', () => {
|
| 153 |
-
const out = mergeResults([], [], { avatarInteriors: [
|
| 154 |
-
{ avatarImageId: 'n', title: 'Mới', addedAt: '2026-10-05T00:00:00Z' },
|
| 155 |
-
] });
|
| 156 |
-
assert.ok(out[0].featuredScale > 1, 'featuredScale must exceed 1');
|
| 157 |
-
});
|
| 158 |
-
|
| 159 |
-
test('a featured interior keeps a finite score so it is not dimmed', () => {
|
| 160 |
-
// relevanceAppearance() dims any tile whose score is not finite -- a featured
|
| 161 |
-
// tile rendered at 14% opacity would be the opposite of the request.
|
| 162 |
-
const out = mergeResults([], [], { avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }] });
|
| 163 |
-
assert.ok(Number.isFinite(out[0].score), 'score must be finite, got ' + out[0].score);
|
| 164 |
-
});
|
| 165 |
-
|
| 166 |
-
test('a decisive product winner is featured and reaches the head', () => {
|
| 167 |
-
// 1000 = exact SKU in vai-avatar's scorer, next tier is 550, so this is
|
| 168 |
-
// identity, and it should be presented like the answer rather than like a hit.
|
| 169 |
-
// The shape is the worker's own `{ sku, score, product }` wrapper.
|
| 170 |
-
const out = mergeResults(
|
| 171 |
-
[interior('a', 0.9)],
|
| 172 |
-
[{ sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'Tủ bếp', ar: 1, images: ['https://cdn/x.jpg'] } }],
|
| 173 |
-
{ query: 'SKU1' },
|
| 174 |
-
);
|
| 175 |
-
const hit = out.find(r => r.sku === 'SKU1');
|
| 176 |
-
assert.ok(hit?.featured, 'the exact product is featured');
|
| 177 |
-
assert.ok(hit.featuredScale > 1);
|
| 178 |
-
});
|
| 179 |
-
|
| 180 |
-
test('a non-decisive product is NOT featured', () => {
|
| 181 |
-
// Two products scoring 1000 and 900 is a model family, not an answer. Enlarging
|
| 182 |
-
// it would be misleading, and the neon must not ring it either.
|
| 183 |
-
const out = mergeResults([interior('a', 0.9)], [
|
| 184 |
-
{ sku: 'SKU1', score: 1000, product: { sku: 'SKU1', name: 'A', ar: 1, images: ['https://cdn/x.jpg'] } },
|
| 185 |
-
{ sku: 'SKU2', score: 900, product: { sku: 'SKU2', name: 'B', ar: 1, images: ['https://cdn/x.jpg'] } },
|
| 186 |
-
], { query: 'SKU1' });
|
| 187 |
-
assert.ok(!out.find(r => r.sku === 'SKU1')?.featured, 'a tie must not be featured');
|
| 188 |
-
});
|
| 189 |
-
|
| 190 |
-
test('enlargement and the neon agree: every featured interior is an exact hit', () => {
|
| 191 |
-
// The two decisions come from one source (exactMatches). This asserts the
|
| 192 |
-
// consequence -- a tile is never enlarged without being ringed.
|
| 193 |
-
const out = mergeResults([interior('a', 0.9)], [], {
|
| 194 |
-
avatarInteriors: [{ avatarImageId: 'n', title: 'phong cách classical', score: 900 }],
|
| 195 |
-
query: 'classical',
|
| 196 |
-
});
|
| 197 |
-
const tile = out[0];
|
| 198 |
-
assert.ok(tile.featured && tile.featuredScale > 1);
|
| 199 |
-
});
|
| 200 |
-
|
| 201 |
-
console.log('\nnamespace safety');
|
| 202 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 203 |
-
|
| 204 |
-
test('an avatar interior cannot be shadowed by a colliding raw id', () => {
|
| 205 |
-
// A bare id in a shared namespace would let one record silently drop another
|
| 206 |
-
// with no error. Namespacing is what makes that impossible.
|
| 207 |
-
const out = mergeResults([interior('n', 0.5)], [], {
|
| 208 |
-
avatarInteriors: [{ avatarImageId: 'n', title: 'Mới' }],
|
| 209 |
-
});
|
| 210 |
-
assert.equal(out.length, 2, 'both records survive: ' + JSON.stringify(out.map(r => r.id)));
|
| 211 |
-
});
|
| 212 |
-
|
| 213 |
-
test('a duplicate avatar interior is listed once', () => {
|
| 214 |
-
const out = mergeResults([], [], { avatarInteriors: [
|
| 215 |
-
{ avatarImageId: 'n', title: 'Mới' }, { avatarImageId: 'n', title: 'Mới' },
|
| 216 |
-
] });
|
| 217 |
-
assert.equal(out.filter(r => r.avatarImageId === 'n').length, 1);
|
| 218 |
-
});
|
| 219 |
-
|
| 220 |
-
test('an interior without an avatar id is skipped, not rendered blank', () => {
|
| 221 |
-
const out = mergeResults([interior('a', 0.9)], [], { avatarInteriors: [{ title: 'không có id' }] });
|
| 222 |
-
assert.equal(out.length, 1);
|
| 223 |
-
});
|
| 224 |
-
|
| 225 |
-
test('an empty avatar list changes nothing', () => {
|
| 226 |
-
const base = mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)]);
|
| 227 |
-
assert.equal(mergeResults([interior('a', 0.9), interior('b', 0.8)], [product('S1', 500)], { avatarInteriors: [] }).length, base.length);
|
| 228 |
-
});
|
| 229 |
-
|
| 230 |
-
console.log('\nonly the newest ring');
|
| 231 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 232 |
-
|
| 233 |
-
/** Build `n` avatar interiors, newest last, and merge them into the field. */
|
| 234 |
-
const freshSet = n => Array.from({ length: n }, (_, i) => ({
|
| 235 |
-
avatarImageId: `id${i}`,
|
| 236 |
-
title: `Ảnh ${i}`,
|
| 237 |
-
addedAt: `2026-10-${String((i % 28) + 1).padStart(2, '0')}T00:00:00Z`,
|
| 238 |
-
}));
|
| 239 |
-
|
| 240 |
-
test('exactly ten of many new photographs carry the neon', () => {
|
| 241 |
-
// The brief asks for more photographs on the page AND for only the newest to
|
| 242 |
-
// ring. Those pull against each other, so the count is pinned: 24 rings is
|
| 243 |
-
// decoration, not emphasis.
|
| 244 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
|
| 245 |
-
const ringed = out.filter(r => r.featured);
|
| 246 |
-
assert.equal(ringed.length, 10, 'expected 10 ringed, got ' + ringed.length);
|
| 247 |
-
});
|
| 248 |
-
|
| 249 |
-
test('the un-ringed ones are still shown as ordinary tiles', () => {
|
| 250 |
-
// "Chỉ chọn 10 ảnh mới nhất có viền neon còn lại hiển thị bình thường" -- the
|
| 251 |
-
// rest must be VISIBLE, just not ringed. Dropping them would satisfy the first
|
| 252 |
-
// half of the sentence and break the second.
|
| 253 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
|
| 254 |
-
assert.equal(out.length, 40, 'all 40 should be in the field, got ' + out.length);
|
| 255 |
-
assert.equal(out.filter(r => !r.featured).length, 30);
|
| 256 |
-
});
|
| 257 |
-
|
| 258 |
-
test('an un-ringed tile has no featuredScale, so it renders at normal size', () => {
|
| 259 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(40) });
|
| 260 |
-
const plain = out.filter(r => !r.featured);
|
| 261 |
-
assert.ok(plain.every(r => r.featuredScale === undefined), 'an ordinary tile carries a scale');
|
| 262 |
-
});
|
| 263 |
-
|
| 264 |
-
test('the ten ringed are the newest ten, in order', () => {
|
| 265 |
-
// mergedAt is per-record, so equal dates sort by input order; distinct dates
|
| 266 |
-
// make the intent unambiguous.
|
| 267 |
-
const items = Array.from({ length: 15 }, (_, i) => ({
|
| 268 |
-
avatarImageId: `x${i}`,
|
| 269 |
-
title: `Ảnh ${i}`,
|
| 270 |
-
addedAt: `2026-10-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
|
| 271 |
-
}));
|
| 272 |
-
const ringed = mergeResults([], [], { avatarInteriors: items }).filter(r => r.featured);
|
| 273 |
-
assert.equal(ringed.length, 10);
|
| 274 |
-
// x14 is day 15, the newest. The ringed set must be days 15 down to 6.
|
| 275 |
-
assert.equal(ringed[0].avatarImageId, 'x14', 'newest is not first');
|
| 276 |
-
assert.equal(ringed[9].avatarImageId, 'x5');
|
| 277 |
-
const plain = mergeResults([], [], { avatarInteriors: items }).filter(r => !r.featured);
|
| 278 |
-
assert.equal(plain[0].avatarImageId, 'x4', 'the oldest ringed-off one is day 5');
|
| 279 |
-
});
|
| 280 |
-
|
| 281 |
-
test('fewer than ten new photographs still all ring', () => {
|
| 282 |
-
const out = mergeResults([], [], { avatarInteriors: freshSet(4) });
|
| 283 |
-
assert.equal(out.filter(r => r.featured).length, 4);
|
| 284 |
-
});
|
| 285 |
-
|
| 286 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 287 |
-
process.exit(failed ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-catalogue.mjs
DELETED
|
@@ -1,187 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Unit tests for the FULL catalogue loader and its de-duplication.
|
| 3 |
-
*
|
| 4 |
-
* Run: node scripts/test-catalogue.mjs
|
| 5 |
-
*
|
| 6 |
-
* The shop catalogue is 11,704 rows that must collapse to one row per product.
|
| 7 |
-
* Getting that wrong is invisible in review and obvious in use: a duplicated
|
| 8 |
-
* appliance takes two tiles in the field and two rows in every result list.
|
| 9 |
-
*
|
| 10 |
-
* These pin the three properties the UI depends on:
|
| 11 |
-
*
|
| 12 |
-
* 1. the stub rows carry no sku, no price and the same image as the real row
|
| 13 |
-
* next to them, so they must lose;
|
| 14 |
-
* 2. two skus that differ only by a trailing letter are ONE product;
|
| 15 |
-
* 3. the overlay (Sửa / Xóa from vai-avatar) must still resolve onto a base
|
| 16 |
-
* row, because that is what makes an edit visible instead of duplicating.
|
| 17 |
-
*/
|
| 18 |
-
|
| 19 |
-
import assert from 'node:assert/strict';
|
| 20 |
-
import { buildBaseCatalogue, buildCatalogue } from '../src/catalogue.js';
|
| 21 |
-
|
| 22 |
-
let passed = 0;
|
| 23 |
-
const test = (name, fn) => {
|
| 24 |
-
fn();
|
| 25 |
-
passed++;
|
| 26 |
-
console.log(` ok ${name}`);
|
| 27 |
-
};
|
| 28 |
-
|
| 29 |
-
console.log('buildBaseCatalogue');
|
| 30 |
-
|
| 31 |
-
test('collapses the slug-derived stub onto the real row', () => {
|
| 32 |
-
const base = buildBaseCatalogue([
|
| 33 |
-
{ sku: '892.14.719', mod: '89214719', n: 'Kệ Giày Hafele 82914719', slug: 'ke-gia-hafele', pn: 119000, imgs: ['https://x/1.jpg'] },
|
| 34 |
-
// Same slug and same image, but no sku and no price -- a stub.
|
| 35 |
-
{ sku: '', mod: '', n: 'Ke Gia Hafele 82914719 1 1 17876', slug: 'ke-gia-hafele', pn: 0, imgs: ['https://x/1.jpg'] },
|
| 36 |
-
]);
|
| 37 |
-
assert.equal(base.length, 1);
|
| 38 |
-
assert.equal(base[0].sku, '892.14.719');
|
| 39 |
-
});
|
| 40 |
-
|
| 41 |
-
test('keeps a Malloca `T` variant that shares a name with its base', () => {
|
| 42 |
-
// INDUST K140B ships as 600001297 (5 photos) and 600001297T (9 photos).
|
| 43 |
-
// Same mod, same name, different slugs -- different products. Collapsing on
|
| 44 |
-
// (mod, name) removed 355 such rows and the whole Malloca range went missing.
|
| 45 |
-
const base = buildBaseCatalogue([
|
| 46 |
-
{ sku: '600001297', mod: 'K140B', n: 'INDUST K140B | Malloca | Máy hút mùi gắn trần 140cm', imgs: ['https://cdn/a.png'], slug: 'indust-k140b' },
|
| 47 |
-
{ sku: '600001297T', mod: 'K140B', n: 'INDUST K140B | Malloca | Máy hút mùi gắn trần 140cm', imgs: ['https://cdn/b.png'], slug: 'indust-k140b-2' },
|
| 48 |
-
]);
|
| 49 |
-
assert.equal(base.length, 2);
|
| 50 |
-
});
|
| 51 |
-
|
| 52 |
-
test('keeps a range that shares ONE photo across differently-named products', () => {
|
| 53 |
-
// Eurogold sells EWG101/102/103 from a single range image. Keying on the
|
| 54 |
-
// photo alone collapsed 3,921 such ranges.
|
| 55 |
-
const photo = 'https://cdn/range-ng-nhom-kinh-eurogold-ewg101-ewg102-ewg103.jpg';
|
| 56 |
-
const base = buildBaseCatalogue([
|
| 57 |
-
{ sku: 'EWG101', n: 'EWG101 | Eurogold | Bản lề kính giảm chấn thẳng', imgs: [photo], slug: 'ewg101' },
|
| 58 |
-
{ sku: 'EWG102', n: 'EWG102 | Eurogold | Bản lề kính giảm chấn cong 1/2', imgs: [photo], slug: 'ewg102' },
|
| 59 |
-
{ sku: 'EWG103', n: 'EWG103 | Eurogold | Bản lề kính giảm chấn cong 3/4', imgs: [photo], slug: 'ewg103' },
|
| 60 |
-
]);
|
| 61 |
-
assert.equal(base.length, 3);
|
| 62 |
-
});
|
| 63 |
-
|
| 64 |
-
test('ignores the CDN cache-busting query when matching photos', () => {
|
| 65 |
-
// The same photo re-uploaded under a new ?v=<timestamp> is one product.
|
| 66 |
-
const base = buildBaseCatalogue([
|
| 67 |
-
{ sku: '1', n: 'Bếp Từ Alpha', imgs: ['https://cdn/a.png?v=1767087917033'], slug: 'bep-alpha' },
|
| 68 |
-
{ sku: '2', n: 'Bếp Từ Alpha', imgs: ['https://cdn/a.png?v=1776733402070'], slug: 'bep-alpha-2' },
|
| 69 |
-
]);
|
| 70 |
-
assert.equal(base.length, 1);
|
| 71 |
-
});
|
| 72 |
-
|
| 73 |
-
test('keeps two products that share a brand but differ in name', () => {
|
| 74 |
-
const base = buildBaseCatalogue([
|
| 75 |
-
{ sku: '1', mod: 'M1', n: 'Bếp Từ 3 Vùng', imgs: ['https://cdn/1.png'], slug: 'bep-3-vung' },
|
| 76 |
-
{ sku: '2', mod: 'M1', n: 'Bếp Từ 4 Vùng', imgs: ['https://cdn/2.png'], slug: 'bep-4-vung' },
|
| 77 |
-
]);
|
| 78 |
-
assert.equal(base.length, 2);
|
| 79 |
-
});
|
| 80 |
-
|
| 81 |
-
test('keeps rows that share nothing', () => {
|
| 82 |
-
const base = buildBaseCatalogue([
|
| 83 |
-
{ sku: '1', mod: 'A', n: 'Alpha', imgs: ['https://cdn/1.png'], slug: 'alpha' },
|
| 84 |
-
{ sku: '2', mod: 'B', n: 'Beta', imgs: ['https://cdn/2.png'], slug: 'beta' },
|
| 85 |
-
]);
|
| 86 |
-
assert.equal(base.length, 2);
|
| 87 |
-
});
|
| 88 |
-
|
| 89 |
-
test('ignores rows with no usable identity', () => {
|
| 90 |
-
// Neither a slug nor a photo means nothing to key on. Every real row has
|
| 91 |
-
// both (measured: 11,704 of 11,704), so this is a guard on junk input only.
|
| 92 |
-
assert.deepEqual(buildBaseCatalogue([{}, null, 'x', { n: '', imgs: [] }]), []);
|
| 93 |
-
});
|
| 94 |
-
|
| 95 |
-
test('keeps a row that has only a slug, with no image', () => {
|
| 96 |
-
const base = buildBaseCatalogue([
|
| 97 |
-
{ sku: '1', n: 'Không có ảnh A', slug: 'khong-co-anh-a' },
|
| 98 |
-
{ sku: '2', n: 'Không có ảnh B', slug: 'khong-co-anh-b' },
|
| 99 |
-
]);
|
| 100 |
-
assert.equal(base.length, 2);
|
| 101 |
-
});
|
| 102 |
-
|
| 103 |
-
test('tolerates a non-array input', () => {
|
| 104 |
-
assert.deepEqual(buildBaseCatalogue(null), []);
|
| 105 |
-
assert.deepEqual(buildBaseCatalogue(undefined), []);
|
| 106 |
-
});
|
| 107 |
-
|
| 108 |
-
test('the row carrying a sku wins whichever order the file lists them in', () => {
|
| 109 |
-
// Both rows carry a slug, as every row in the real file does (measured:
|
| 110 |
-
// 11,704 of 11,704). A stub that arrived FIRST must still lose to the real
|
| 111 |
-
// row, or the same product renders differently between two reloads.
|
| 112 |
-
const real = { sku: '1', mod: 'M', n: 'Kệ Giày Hafele', slug: 'ke-gia-hafele', pn: 5 };
|
| 113 |
-
const stub = { sku: '', mod: '', n: 'Ke Gia Hafele', slug: 'ke-gia-hafele', pn: 0 };
|
| 114 |
-
for (const rows of [[real, stub], [stub, real]]) {
|
| 115 |
-
const out = buildBaseCatalogue(rows);
|
| 116 |
-
assert.equal(out.length, 1);
|
| 117 |
-
assert.equal(out[0].sku, '1', 'the real row must win in either order');
|
| 118 |
-
}
|
| 119 |
-
});
|
| 120 |
-
|
| 121 |
-
console.log('\nbuildCatalogue with a base');
|
| 122 |
-
|
| 123 |
-
const base = buildBaseCatalogue([
|
| 124 |
-
{ sku: 'A1', mod: 'MA1', n: 'Bếp Từ Alpha', pn: 1000000, p: '1.000.000đ', imgs: ['https://x/alpha.jpg'], brand: 'Malloca' },
|
| 125 |
-
{ sku: 'B1', mod: 'MB1', n: 'Tủ Lạnh Beta', pn: 2000000, p: '2.000.000đ', imgs: ['https://x/beta.jpg'], brand: 'Bosch' },
|
| 126 |
-
]);
|
| 127 |
-
|
| 128 |
-
test('lists the whole base on its own', () => {
|
| 129 |
-
const items = buildCatalogue([], {}, base);
|
| 130 |
-
assert.equal(items.size, 2);
|
| 131 |
-
});
|
| 132 |
-
|
| 133 |
-
test('an edit by sku overwrites the base row rather than adding one', () => {
|
| 134 |
-
const promos = { productEdits: { A1: { name: 'Tên Mới', priceNum: 9999000 } } };
|
| 135 |
-
const items = buildCatalogue([], promos, base);
|
| 136 |
-
assert.equal(items.size, 2, 'the edit must not create a third product');
|
| 137 |
-
const edited = [...items.values()].find(p => p.sku === 'A1');
|
| 138 |
-
assert.equal(edited.name, 'Tên Mới');
|
| 139 |
-
// priceNum must beat the base display string, or the edit is invisible.
|
| 140 |
-
assert.equal(edited.priceNum, 9999000);
|
| 141 |
-
});
|
| 142 |
-
|
| 143 |
-
test('an edit by model code also resolves', () => {
|
| 144 |
-
const promos = { productEdits: { MA1: { priceNum: 7777000 } } };
|
| 145 |
-
const items = buildCatalogue([], promos, base);
|
| 146 |
-
assert.equal(items.size, 2);
|
| 147 |
-
assert.equal([...items.values()].find(p => p.sku === 'A1').priceNum, 7777000);
|
| 148 |
-
});
|
| 149 |
-
|
| 150 |
-
test('a tombstone removes the base product', () => {
|
| 151 |
-
const promos = { productEdits: { A1: { deleted: true } } };
|
| 152 |
-
const items = buildCatalogue([], promos, base);
|
| 153 |
-
assert.equal(items.size, 1);
|
| 154 |
-
assert.equal([...items.values()].some(p => p.sku === 'A1'), false);
|
| 155 |
-
});
|
| 156 |
-
|
| 157 |
-
test('a pasted URL for a base product amends it instead of duplicating', () => {
|
| 158 |
-
const added = [{ sku: 'A1', n: 'Bếp Từ Alpha Đổi Ảnh', imgs: ['https://x/new.jpg'] }];
|
| 159 |
-
const items = buildCatalogue(added, {}, base);
|
| 160 |
-
assert.equal(items.size, 2, 're-pasting a known sku must not add a row');
|
| 161 |
-
assert.equal([...items.values()].find(p => p.sku === 'A1').images[0], 'https://x/new.jpg');
|
| 162 |
-
});
|
| 163 |
-
|
| 164 |
-
test('a genuinely new product is added', () => {
|
| 165 |
-
const added = [{ sku: 'NEW1', n: 'Sản Phẩm Mới', imgs: ['https://x/new.jpg'], pn: 500000 }];
|
| 166 |
-
const items = buildCatalogue(added, {}, base);
|
| 167 |
-
assert.equal(items.size, 3);
|
| 168 |
-
assert.equal(items.has('NEW1'), true);
|
| 169 |
-
});
|
| 170 |
-
|
| 171 |
-
test('add, edit and delete compose without disturbing each other', () => {
|
| 172 |
-
const added = [{ sku: 'NEW1', n: 'SP Mới', imgs: ['https://x/new.jpg'], pn: 500000 }];
|
| 173 |
-
const promos = { productEdits: { A1: { deleted: true }, B1: { priceNum: 1234000 } } };
|
| 174 |
-
const items = buildCatalogue(added, promos, base);
|
| 175 |
-
assert.equal(items.size, 2);
|
| 176 |
-
assert.equal(items.has('A1'), false, 'deleted');
|
| 177 |
-
assert.equal(items.has('NEW1'), true, 'added');
|
| 178 |
-
assert.equal([...items.values()].find(p => p.sku === 'B1').priceNum, 1234000, 'edited');
|
| 179 |
-
});
|
| 180 |
-
|
| 181 |
-
test('a base row keeps its images after an edit touches only the price', () => {
|
| 182 |
-
const promos = { productEdits: { A1: { priceNum: 1111000 } } };
|
| 183 |
-
const items = buildCatalogue([], promos, base);
|
| 184 |
-
assert.deepEqual([...items.values()].find(p => p.sku === 'A1').images, ['https://x/alpha.jpg']);
|
| 185 |
-
});
|
| 186 |
-
|
| 187 |
-
console.log(`\n${passed} passed`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-merge.mjs
DELETED
|
@@ -1,210 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Unit tests for the product/interior merge.
|
| 3 |
-
*
|
| 4 |
-
* Run: node scripts/test-merge.mjs
|
| 5 |
-
*
|
| 6 |
-
* The merge is the one piece of this feature with no visual fallback: if it
|
| 7 |
-
* drops products, duplicates a tile or reorders interiors wrongly, the field
|
| 8 |
-
* simply looks wrong and nothing reports an error. These assert the properties
|
| 9 |
-
* the field depends on.
|
| 10 |
-
*/
|
| 11 |
-
|
| 12 |
-
import assert from 'node:assert/strict';
|
| 13 |
-
import { mergeResults } from '../src/merge.js';
|
| 14 |
-
|
| 15 |
-
let passed = 0;
|
| 16 |
-
const test = (name, fn) => {
|
| 17 |
-
fn();
|
| 18 |
-
passed++;
|
| 19 |
-
console.log(` ok ${name}`);
|
| 20 |
-
};
|
| 21 |
-
|
| 22 |
-
const interiors = count => Array.from({ length: count }, (_, i) => ({ id: `p${i}`, ar: 1.5 }));
|
| 23 |
-
|
| 24 |
-
const product = (sku, extra = {}) => ({
|
| 25 |
-
sku, name: `SP ${sku}`, ar: 1, images: [`https://cdn.example/${sku}.jpg`], ...extra,
|
| 26 |
-
});
|
| 27 |
-
|
| 28 |
-
console.log('mergeResults');
|
| 29 |
-
|
| 30 |
-
test('returns interiors unchanged when there are no products', () => {
|
| 31 |
-
const out = mergeResults(interiors(10), []);
|
| 32 |
-
assert.equal(out.length, 10);
|
| 33 |
-
assert.deepEqual(out.map(i => i.id), interiors(10).map(i => i.id));
|
| 34 |
-
});
|
| 35 |
-
|
| 36 |
-
test('tolerates a missing or null product list', () => {
|
| 37 |
-
assert.equal(mergeResults(interiors(5), null).length, 5);
|
| 38 |
-
assert.equal(mergeResults(interiors(5), undefined).length, 5);
|
| 39 |
-
assert.equal(mergeResults(null, [product('A')]).length, 1);
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
test('weaves products in rather than appending them', () => {
|
| 43 |
-
const out = mergeResults(interiors(20), [product('A'), product('B')]);
|
| 44 |
-
assert.equal(out.length, 22);
|
| 45 |
-
const a = out.findIndex(r => r.sku === 'A');
|
| 46 |
-
const b = out.findIndex(r => r.sku === 'B');
|
| 47 |
-
assert.ok(a < 20 && b < 20, 'products must not be pushed past every interior');
|
| 48 |
-
assert.ok(b > a, 'product order must follow catalogue rank');
|
| 49 |
-
});
|
| 50 |
-
|
| 51 |
-
test('gives each product a resolvable absolute src', () => {
|
| 52 |
-
const [hit] = mergeResults(interiors(4), [product('A')]).filter(r => r.sku);
|
| 53 |
-
assert.equal(hit.src, 'https://cdn.example/A.jpg');
|
| 54 |
-
// The tile component resolves `url` when `src` is absent; a product has no
|
| 55 |
-
// dataset `url`, so `src` is what makes it renderable.
|
| 56 |
-
assert.ok(!hit.url, 'product must not borrow an interior url field');
|
| 57 |
-
});
|
| 58 |
-
|
| 59 |
-
test('accepts the worker shape ({sku, score, product}) and the flat shape', () => {
|
| 60 |
-
const wrapped = mergeResults(interiors(4), [{ sku: 'A', score: 900, product: product('A') }]);
|
| 61 |
-
const flat = mergeResults(interiors(4), [product('A')]);
|
| 62 |
-
assert.equal(wrapped.filter(r => r.sku).length, 1);
|
| 63 |
-
assert.equal(flat.filter(r => r.sku).length, 1);
|
| 64 |
-
assert.equal(wrapped.find(r => r.sku).src, flat.find(r => r.sku).src);
|
| 65 |
-
});
|
| 66 |
-
|
| 67 |
-
test('skips products with no usable image instead of rendering a blank tile', () => {
|
| 68 |
-
const out = mergeResults(interiors(6), [
|
| 69 |
-
{ sku: 'NOIMG', images: [] },
|
| 70 |
-
{ sku: 'REL', images: ['/relative/path.jpg'] },
|
| 71 |
-
{ sku: 'OK', images: ['https://cdn.example/ok.jpg'] },
|
| 72 |
-
]);
|
| 73 |
-
const skus = out.filter(r => r.sku).map(r => r.sku);
|
| 74 |
-
assert.deepEqual(skus, ['OK']);
|
| 75 |
-
});
|
| 76 |
-
|
| 77 |
-
test('never duplicates an interior or a product', () => {
|
| 78 |
-
const dup = [...interiors(5), ...interiors(5)];
|
| 79 |
-
const out = mergeResults(dup, [product('A'), product('A'), product('B')]);
|
| 80 |
-
assert.equal(new Set(out.filter(r => !r.sku).map(r => r.id)).size, 5);
|
| 81 |
-
assert.equal(new Set(out.filter(r => r.sku).map(r => r.sku)).size, 2);
|
| 82 |
-
});
|
| 83 |
-
|
| 84 |
-
test('caps how many products enter the field', () => {
|
| 85 |
-
const many = Array.from({ length: 60 }, (_, i) => product(`S${i}`));
|
| 86 |
-
const out = mergeResults(interiors(60), many);
|
| 87 |
-
assert.equal(out.filter(r => r.sku).length, 24);
|
| 88 |
-
// The strongest catalogue hits are the ones kept.
|
| 89 |
-
assert.deepEqual(out.filter(r => r.sku).slice(0, 3).map(r => r.sku), ['S0', 'S1', 'S2']);
|
| 90 |
-
});
|
| 91 |
-
|
| 92 |
-
test('honours an explicit productLimit', () => {
|
| 93 |
-
const many = Array.from({ length: 40 }, (_, i) => product(`S${i}`));
|
| 94 |
-
assert.equal(mergeResults(interiors(40), many, { productLimit: 3 }).filter(r => r.sku).length, 3);
|
| 95 |
-
});
|
| 96 |
-
|
| 97 |
-
test('keeps interior rank order intact', () => {
|
| 98 |
-
const out = mergeResults(interiors(30), [product('A'), product('B'), product('C')]);
|
| 99 |
-
assert.deepEqual(out.filter(r => !r.sku).map(r => r.id), interiors(30).map(r => r.id));
|
| 100 |
-
});
|
| 101 |
-
|
| 102 |
-
test('survives a short interior list', () => {
|
| 103 |
-
const out = mergeResults(interiors(1), [product('A'), product('B')]);
|
| 104 |
-
assert.equal(out.length, 3);
|
| 105 |
-
assert.equal(out.filter(r => r.sku).length, 2);
|
| 106 |
-
});
|
| 107 |
-
|
| 108 |
-
test('a product id can never collide with a real interior id', () => {
|
| 109 |
-
// Interior ids are 10 hex chars; 'malloca:' cannot be one, so the namespaces
|
| 110 |
-
// are disjoint and a product is never dropped as a duplicate.
|
| 111 |
-
const real = [{ id: '0004d4b651', ar: 1 }, { id: '0006a82e14', ar: 1 }];
|
| 112 |
-
const out = mergeResults(real, [product('A')]);
|
| 113 |
-
assert.equal(out.length, 3);
|
| 114 |
-
assert.equal(out.filter(r => r.sku).length, 1);
|
| 115 |
-
});
|
| 116 |
-
|
| 117 |
-
test('an interior id that collides with a product namespace loses nothing', () => {
|
| 118 |
-
// Both sides are tagged before de-duplication, so even a hostile id cannot
|
| 119 |
-
// occupy the product's slot and drop it from the field.
|
| 120 |
-
const out = mergeResults([{ id: 'malloca:A', ar: 1 }], [product('A')]);
|
| 121 |
-
assert.equal(out.length, 2);
|
| 122 |
-
assert.equal(out.filter(r => !r.sku).length, 1);
|
| 123 |
-
assert.equal(out.filter(r => r.sku).length, 1);
|
| 124 |
-
});
|
| 125 |
-
|
| 126 |
-
test('a product always carries a usable id and aspect ratio', () => {
|
| 127 |
-
const hit = mergeResults(interiors(4), [product('A')]).find(r => r.sku);
|
| 128 |
-
// Every tile is keyed on id and sized from ar; a missing or NaN value makes
|
| 129 |
-
// the tile unkeyed or zero-sized.
|
| 130 |
-
assert.equal(typeof hit.id, 'string');
|
| 131 |
-
assert.ok(hit.id.length > 0);
|
| 132 |
-
assert.ok(Number.isFinite(hit.ar) && hit.ar >= 0.5 && hit.ar <= 2);
|
| 133 |
-
});
|
| 134 |
-
|
| 135 |
-
test('a product id is stable across queries, so it does not jump in the field', () => {
|
| 136 |
-
const a = mergeResults(interiors(10), [product('A')]).find(r => r.sku);
|
| 137 |
-
const b = mergeResults(interiors(30), [product('A')]).find(r => r.sku);
|
| 138 |
-
// Layout seeds position/scale/phase off the id, so an id that changed with
|
| 139 |
-
// the result count would teleport the tile on every search.
|
| 140 |
-
assert.equal(a.id, b.id);
|
| 141 |
-
});
|
| 142 |
-
|
| 143 |
-
test('a product inherits relevance so it is not dimmed as unranked', () => {
|
| 144 |
-
const ranked = interiors(20).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
|
| 145 |
-
const hit = mergeResults(ranked, [product('A')]).find(r => r.sku);
|
| 146 |
-
// relevanceAppearance() blurs any tile without a finite score, which would
|
| 147 |
-
// make a merged product almost invisible.
|
| 148 |
-
assert.ok(Number.isFinite(hit.score), 'product must have a finite score');
|
| 149 |
-
assert.ok(hit.score > 0, 'score must sit in the visible cosine range');
|
| 150 |
-
});
|
| 151 |
-
|
| 152 |
-
test('a product still scores when NO interior matched', () => {
|
| 153 |
-
const hit = mergeResults([], [product('A'), product('B')]).find(r => r.sku);
|
| 154 |
-
assert.ok(Number.isFinite(hit.score), 'a products-only query must not leave products unscored');
|
| 155 |
-
});
|
| 156 |
-
|
| 157 |
-
test('interior scores are never modified', () => {
|
| 158 |
-
const ranked = interiors(10).map((r, i) => ({ ...r, score: 0.9 - i * 0.01 }));
|
| 159 |
-
const before = ranked.map(r => r.score);
|
| 160 |
-
mergeResults(ranked, [product('A'), product('B')]);
|
| 161 |
-
assert.deepEqual(ranked.map(r => r.score), before);
|
| 162 |
-
});
|
| 163 |
-
|
| 164 |
-
test('a product query crowds interiors out of the field', () => {
|
| 165 |
-
// "kệ xoong" is a product ask, so once a product clears the keyword floor the
|
| 166 |
-
// CLIP interiors must not be sprinkled through the results -- only the racks.
|
| 167 |
-
// The product's name must actually carry the query's words (or their rack
|
| 168 |
-
// synonyms), which is what separates a product answer from a substring hit.
|
| 169 |
-
const ranked = interiors(30).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
|
| 170 |
-
const hits = [
|
| 171 |
-
{ sku: 'KV1', name: 'Kệ xoong nồi inox', ar: 1, images: ['https://cdn.example/kv1.jpg'], score: 367 },
|
| 172 |
-
{ sku: 'KV2', name: 'Giá xoong nồi inox', ar: 1, images: ['https://cdn.example/kv2.jpg'], score: 367 },
|
| 173 |
-
];
|
| 174 |
-
const out = mergeResults(ranked, hits, { query: 'kệ xoong' });
|
| 175 |
-
assert.equal(out.filter(r => r.sku).length, 2);
|
| 176 |
-
assert.equal(out.filter(r => !r.sku).length, 0, 'interiors must be dropped for a product query');
|
| 177 |
-
});
|
| 178 |
-
|
| 179 |
-
test('browse mode keeps interiors even when products arrive', () => {
|
| 180 |
-
// No query means browse: the newest interiors front row must stay; the
|
| 181 |
-
// index-time products are decoration, not the answer.
|
| 182 |
-
const ranked = interiors(6).map((r, i) => ({ ...r, score: 0.5 - i * 0.001 }));
|
| 183 |
-
const out = mergeResults(ranked, [product('A', { score: 367 })]);
|
| 184 |
-
assert.equal(out.filter(r => !r.sku).length, 6, 'browse keeps all interiors');
|
| 185 |
-
});
|
| 186 |
-
|
| 187 |
-
test('every product hit above the keyword floor is featured (neon ring + scale)', () => {
|
| 188 |
-
// The brief wants "toàn bộ ảnh là kệ xoong nội... có viền neon và kích thước
|
| 189 |
-
// thư giãn hơn": all relevant racks, not just the top 3, get the ring+scale.
|
| 190 |
-
const hits = [
|
| 191 |
-
product('A', { score: 367 }), product('B', { score: 367 }),
|
| 192 |
-
product('C', { score: 367 }), product('D', { score: 367 }),
|
| 193 |
-
];
|
| 194 |
-
const out = mergeResults(interiors(2), hits, { query: 'kệ xoong' });
|
| 195 |
-
const featured = out.filter(r => r.sku && r.featured);
|
| 196 |
-
assert.equal(featured.length, 4, 'all keyword hits above the floor are featured');
|
| 197 |
-
});
|
| 198 |
-
|
| 199 |
-
test('a product whose only lead points into a dead V.AISTUDIO dir is skipped', () => {
|
| 200 |
-
const dead = {
|
| 201 |
-
sku: 'EPD4635VX', name: 'Giá', ar: 1,
|
| 202 |
-
images: ['https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/eurogold_img/EPD4635VX.jpg'],
|
| 203 |
-
};
|
| 204 |
-
const live = product('EPO8360VX');
|
| 205 |
-
const out = mergeResults(interiors(3), [dead, live]);
|
| 206 |
-
const skus = out.filter(r => r.sku).map(r => r.sku);
|
| 207 |
-
assert.deepEqual(skus, ['EPO8360VX']);
|
| 208 |
-
});
|
| 209 |
-
|
| 210 |
-
console.log(`\n${passed} passed`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-misbrand.mjs
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* The Malloca/Hafele misbranding repair.
|
| 3 |
-
*
|
| 4 |
-
* These cover the two ways it can go wrong, both of which happened while this
|
| 5 |
-
* was being built:
|
| 6 |
-
*
|
| 7 |
-
* 1. Matching on loose tokens attaches the WRONG photograph. T90CM is a size
|
| 8 |
-
* shared by 40 hoods; swapping on it puts one kitchen's image on another's
|
| 9 |
-
* appliance. The uniqueness rule has to reject it.
|
| 10 |
-
* 2. Being too strict loses every real match. A base and a `T` variant are one
|
| 11 |
-
* product photographed twice, and they must still resolve.
|
| 12 |
-
*/
|
| 13 |
-
import assert from 'node:assert/strict';
|
| 14 |
-
import { buildBaseCatalogue, isMallocaMisbrand, repairMallocaMisbrands } from '../src/catalogue.js';
|
| 15 |
-
|
| 16 |
-
let passed = 0;
|
| 17 |
-
function test(name, fn) {
|
| 18 |
-
try { fn(); console.log(' ok ' + name); passed++; }
|
| 19 |
-
catch (e) { console.error(' FAIL ' + name + '\n ' + e.message); process.exitCode = 1; }
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
const MALLOCA_IMG = 'https://bizweb.dktcdn.net/100/567/847/products/mir593.png';
|
| 23 |
-
const OTHER_HOOD = 'https://bizweb.dktcdn.net/100/567/847/products/eclipse-k600.png';
|
| 24 |
-
const HAFELE_IMG = 'https://cdn-img-v2.mybota.vn/uploadv2/web/x/mir593.jpg';
|
| 25 |
-
|
| 26 |
-
/** A row as the scraper emits it. */
|
| 27 |
-
const row = o => ({ n: '', brand: 'Malloca', sku: '', mod: '', imgs: [], ...o });
|
| 28 |
-
|
| 29 |
-
test('detects a row whose name says Malloca and brand does not', () => {
|
| 30 |
-
assert.equal(isMallocaMisbrand(row({ n: 'Malloca | Bếp Điện Từ MIR 593', brand: 'Hafele' })), true);
|
| 31 |
-
});
|
| 32 |
-
|
| 33 |
-
test('leaves a correctly branded Malloca row alone', () => {
|
| 34 |
-
assert.equal(isMallocaMisbrand(row({ n: 'Malloca | Bếp Điện Từ MIR 593', brand: 'Malloca' })), false);
|
| 35 |
-
});
|
| 36 |
-
|
| 37 |
-
test('is not fooled by Array#filter passing the index', () => {
|
| 38 |
-
assert.equal(isMallocaMisbrand(0), false);
|
| 39 |
-
assert.equal(isMallocaMisbrand(null), false);
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
test('a model code spelled with a space matches the one spelled together', () => {
|
| 43 |
-
const out = repairMallocaMisbrands([
|
| 44 |
-
row({ n: 'MIR593 | Malloca | Bếp từ kết hợp', sku: '1', imgs: [MALLOCA_IMG] }),
|
| 45 |
-
row({ n: 'Malloca | Bếp Điện Từ Malloca MIR 593', brand: 'Hafele', sku: '2', imgs: [HAFELE_IMG] }),
|
| 46 |
-
]);
|
| 47 |
-
assert.equal(out.length, 2);
|
| 48 |
-
assert.deepEqual(out[1].imgs, [MALLOCA_IMG], 'should take the real Malloca photo');
|
| 49 |
-
assert.equal(out[1].brand, 'Malloca');
|
| 50 |
-
});
|
| 51 |
-
|
| 52 |
-
test('hyphenated model codes match their unhyphenated twin', () => {
|
| 53 |
-
const out = repairMallocaMisbrands([
|
| 54 |
-
row({ n: 'MWC46BG | Malloca | Tủ rượu', sku: '1', imgs: [MALLOCA_IMG] }),
|
| 55 |
-
row({ n: 'Malloca | Tủ Bảo Quản Rượu Vang Malloca MWC-46BG', brand: 'Hafele', sku: '2', imgs: [HAFELE_IMG] }),
|
| 56 |
-
]);
|
| 57 |
-
assert.deepEqual(out[1].imgs, [MALLOCA_IMG]);
|
| 58 |
-
});
|
| 59 |
-
|
| 60 |
-
test('a size descriptor shared by many products does NOT trigger a swap', () => {
|
| 61 |
-
// T90CM appears in three genuinely different hoods. A misbranded row whose
|
| 62 |
-
// only candidate is that token must keep its own image.
|
| 63 |
-
const many = ['A', 'B', 'C'].map((id, i) =>
|
| 64 |
-
row({ n: `Vertical MH90${i} | Malloca | Máy hút mùi áp tường chữ t 90cm`, sku: id, imgs: [`https://x/${id}.png`] }));
|
| 65 |
-
const out = repairMallocaMisbrands([
|
| 66 |
-
...many,
|
| 67 |
-
row({ n: 'Malloca | Máy hút mùi Malloca MH9000 90cm', brand: 'Hafele', sku: 'bad', imgs: [HAFELE_IMG] }),
|
| 68 |
-
]);
|
| 69 |
-
assert.deepEqual(out[out.length - 1].imgs, [HAFELE_IMG], 'ambiguous token must not swap');
|
| 70 |
-
assert.equal(out[out.length - 1].brand, 'Malloca', 'but the brand is still corrected');
|
| 71 |
-
});
|
| 72 |
-
|
| 73 |
-
test('a base and a T variant of one model still resolve to one product', () => {
|
| 74 |
-
const out = repairMallocaMisbrands([
|
| 75 |
-
row({ n: 'K279N | Malloca | Vòi rửa chén nóng lạnh', sku: 'a', imgs: [MALLOCA_IMG] }),
|
| 76 |
-
row({ n: 'K279N | Malloca | Vòi rửa chén nóng lạnh', sku: 'b', imgs: [MALLOCA_IMG] }),
|
| 77 |
-
row({ n: 'Malloca | Vòi Rửa Chén Malloca K279N', brand: 'Hafele', sku: 'c', imgs: [HAFELE_IMG] }),
|
| 78 |
-
]);
|
| 79 |
-
assert.deepEqual(out[2].imgs, [MALLOCA_IMG], 'two rows of one product are not a conflict');
|
| 80 |
-
});
|
| 81 |
-
|
| 82 |
-
test('a product with no Malloca twin keeps its image and gains the right brand', () => {
|
| 83 |
-
const out = repairMallocaMisbrands([
|
| 84 |
-
row({ n: 'Malloca | Vòi Rửa Chén Malloca ZZZZ99', brand: 'Hafele', sku: 'x', imgs: [HAFELE_IMG] }),
|
| 85 |
-
]);
|
| 86 |
-
assert.deepEqual(out[0].imgs, [HAFELE_IMG]);
|
| 87 |
-
assert.equal(out[0].brand, 'Malloca');
|
| 88 |
-
assert.equal(out[0].sku, 'x', 'identity is untouched, so vai-avatar edits still apply');
|
| 89 |
-
});
|
| 90 |
-
|
| 91 |
-
test('a non-Malloca product is never touched', () => {
|
| 92 |
-
const eurogold = row({ n: 'EWG101 | Eurogold | Bản lề', brand: 'Eurogold', sku: 'e', imgs: [OTHER_HOOD] });
|
| 93 |
-
const out = repairMallocaMisbrands([eurogold]);
|
| 94 |
-
assert.equal(out[0].brand, 'Eurogold');
|
| 95 |
-
assert.deepEqual(out[0].imgs, [OTHER_HOOD]);
|
| 96 |
-
});
|
| 97 |
-
|
| 98 |
-
test('the repair runs inside buildBaseCatalogue', () => {
|
| 99 |
-
const kept = buildBaseCatalogue([
|
| 100 |
-
row({ n: 'MIR593 | Malloca | Bếp từ kết hợp', sku: '1', slug: 'mir593-malloca', imgs: [MALLOCA_IMG] }),
|
| 101 |
-
row({ n: 'Malloca | Bếp Điện Từ Malloca MIR 593', brand: 'Hafele', sku: '2', slug: 'bep-mir593', imgs: [HAFELE_IMG] }),
|
| 102 |
-
]);
|
| 103 |
-
assert.equal(kept.length, 2);
|
| 104 |
-
const repaired = kept.find(r => r.sku === '2');
|
| 105 |
-
assert.deepEqual(repaired.imgs, [MALLOCA_IMG]);
|
| 106 |
-
assert.equal(repaired.brand, 'Malloca');
|
| 107 |
-
});
|
| 108 |
-
|
| 109 |
-
console.log(`\n${passed} passed`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-neon-ui.mjs
DELETED
|
@@ -1,256 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Browser check for the featured + running-neon emphasis.
|
| 3 |
-
*
|
| 4 |
-
* Complements test-avatar-interiors.mjs: that file proves the data is correct,
|
| 5 |
-
* this one proves the browser actually PAINTS it. The failure mode that matters
|
| 6 |
-
* most here is silent -- a mask-composite the browser ignores renders an opaque
|
| 7 |
-
* tile instead of a ring, which no assertion on the data would ever catch.
|
| 8 |
-
*
|
| 9 |
-
* node scripts/test-neon-ui.mjs # against a local preview
|
| 10 |
-
* BASE_URL=... node scripts/test-neon-ui.mjs
|
| 11 |
-
*/
|
| 12 |
-
import { chromium } from 'playwright';
|
| 13 |
-
|
| 14 |
-
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
|
| 15 |
-
let passed = 0, failed = 0;
|
| 16 |
-
const test = async (name, fn) => {
|
| 17 |
-
try { await fn(); console.log(' ok ' + name); passed++; }
|
| 18 |
-
catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
|
| 19 |
-
};
|
| 20 |
-
const assert = (cond, message) => { if (!cond) throw new Error(message); };
|
| 21 |
-
|
| 22 |
-
const browser = await chromium.launch();
|
| 23 |
-
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
| 24 |
-
const errors = [];
|
| 25 |
-
page.on('pageerror', error => errors.push(error.message));
|
| 26 |
-
|
| 27 |
-
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
| 28 |
-
// The worker downloads the CLIP text tower before it can answer; give it room.
|
| 29 |
-
await page.waitForSelector('.image-tile', { timeout: 120000 });
|
| 30 |
-
|
| 31 |
-
console.log('\nfield + controls');
|
| 32 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 33 |
-
|
| 34 |
-
await test('the field renders tiles', async () => {
|
| 35 |
-
const n = await page.locator('.image-tile').count();
|
| 36 |
-
assert(n > 10, 'expected a populated field, got ' + n);
|
| 37 |
-
});
|
| 38 |
-
|
| 39 |
-
await test('the size and position controls are gone', async () => {
|
| 40 |
-
// Explicitly asserted rather than assumed: the brief asked for them removed,
|
| 41 |
-
// and a leftover panel under the pill would be a visible regression.
|
| 42 |
-
assert(await page.locator('.field-controls').count() === 0, '.field-controls still present');
|
| 43 |
-
assert(await page.getByRole('button', { name: /kích thước|vị trí/i }).count() === 0, 'a control button is still rendered');
|
| 44 |
-
});
|
| 45 |
-
|
| 46 |
-
await test('the search pill is unobstructed', async () => {
|
| 47 |
-
const box = await page.locator('.search-pill').boundingBox();
|
| 48 |
-
assert(box && box.height > 20, 'the search pill has no box');
|
| 49 |
-
});
|
| 50 |
-
|
| 51 |
-
console.log('\nfeatured + neon');
|
| 52 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 53 |
-
|
| 54 |
-
await test('a fresh vai-avatar photograph appears as a featured tile', async () => {
|
| 55 |
-
// Waits for the live index rather than assuming the network was fast.
|
| 56 |
-
await page.waitForSelector('.image-tile.featured', { timeout: 60000 });
|
| 57 |
-
const n = await page.locator('.image-tile.featured').count();
|
| 58 |
-
assert(n > 0, 'no featured tile rendered');
|
| 59 |
-
});
|
| 60 |
-
|
| 61 |
-
await test('the featured tile is larger than an ordinary tile', async () => {
|
| 62 |
-
const featured = await page.locator('.image-tile.featured').first().boundingBox();
|
| 63 |
-
const ordinary = await page.locator('.image-tile:not(.featured)').first().boundingBox();
|
| 64 |
-
const area = b => b.width * b.height;
|
| 65 |
-
assert(area(featured) > area(ordinary) * 1.15,
|
| 66 |
-
`featured ${Math.round(area(featured))}px² vs ordinary ${Math.round(area(ordinary))}px²`);
|
| 67 |
-
});
|
| 68 |
-
|
| 69 |
-
await test('a fresh photograph loads from vai-avatar, CORS included', async () => {
|
| 70 |
-
// The tiles fall back to BlurHash silently on a CORS failure, so this is the
|
| 71 |
-
// only place that failure would show up at all.
|
| 72 |
-
await page.waitForFunction(() => {
|
| 73 |
-
const tile = document.querySelector('.image-tile.featured');
|
| 74 |
-
return tile?.querySelector('img.loaded');
|
| 75 |
-
}, { timeout: 60000 });
|
| 76 |
-
});
|
| 77 |
-
|
| 78 |
-
await test('a fresh photograph wears the running neon with no query typed', async () => {
|
| 79 |
-
// Asserted separately from the exact-match case because the two are different
|
| 80 |
-
// CSS selectors: a newest photograph rings before anything has been searched,
|
| 81 |
-
// and dropping that would make the feature invisible on page load.
|
| 82 |
-
const ring = await page.evaluate(() => {
|
| 83 |
-
const tile = document.querySelector('.image-tile.featured');
|
| 84 |
-
const style = getComputedStyle(tile, '::before');
|
| 85 |
-
return { animation: style.animationName, mask: style.maskImage || style.webkitMaskImage };
|
| 86 |
-
});
|
| 87 |
-
assert(ring.animation === 'neon-run', 'a fresh photograph has no running neon: ' + ring.animation);
|
| 88 |
-
assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
|
| 89 |
-
});
|
| 90 |
-
|
| 91 |
-
await test('the ring layer itself never moves', async () => {
|
| 92 |
-
// The regression this whole file is about. ::before carries the mask as well
|
| 93 |
-
// as the gradient, so rotating the layer rotates the mask too and the painted
|
| 94 |
-
// border slides off the tile's edges -- which on a 16:9 photograph reads as a
|
| 95 |
-
// skewed smear, not a light going round the frame.
|
| 96 |
-
//
|
| 97 |
-
// Asserted on a REAL tile, and cross-checked for shape: a square tile would
|
| 98 |
-
// hide a rotated mask just as well as it hid the original bug, so the test
|
| 99 |
-
// fails if it cannot find a tile that is demonstrably not square.
|
| 100 |
-
const ring = await page.evaluate(() => {
|
| 101 |
-
const nonSquare = [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')]
|
| 102 |
-
.map(tile => ({ tile, box: tile.getBoundingClientRect() }))
|
| 103 |
-
.filter(({ box }) => box.width > 40 && box.height > 40
|
| 104 |
-
&& Math.abs(box.width / box.height - 1) > 0.15);
|
| 105 |
-
if (!nonSquare.length) return { checked: false };
|
| 106 |
-
const { tile, box } = nonSquare[0];
|
| 107 |
-
const style = getComputedStyle(tile, '::before');
|
| 108 |
-
return {
|
| 109 |
-
checked: true,
|
| 110 |
-
transform: style.transform,
|
| 111 |
-
angle: style.getPropertyValue('--neon-angle').trim(),
|
| 112 |
-
ratio: box.width / box.height,
|
| 113 |
-
};
|
| 114 |
-
});
|
| 115 |
-
assert(ring.checked, 'no non-square ringed tile on screen to test against');
|
| 116 |
-
assert(/^(none|matrix\(1, 0, 0, 1, 0, 0\)|matrix\(1, 0, 0, 1, 0px, 0px\))$/.test(ring.transform),
|
| 117 |
-
`the ring layer is transformed (${ring.transform}) on a ${ring.ratio.toFixed(2)}:1 tile, so its mask has rotated off the frame`);
|
| 118 |
-
assert(ring.angle && /deg|rad|grad|turn/.test(ring.angle),
|
| 119 |
-
'the sweep angle is not resolving to an angle: "' + ring.angle + '"');
|
| 120 |
-
});
|
| 121 |
-
|
| 122 |
-
await test('a fresh photograph is fully lit, not dimmed as unranked', async () => {
|
| 123 |
-
const opacity = await page.evaluate(() => {
|
| 124 |
-
const tile = document.querySelector('.image-tile.featured');
|
| 125 |
-
return parseFloat(getComputedStyle(tile).opacity);
|
| 126 |
-
});
|
| 127 |
-
assert(opacity > 0.9, 'a fresh photograph is faded to ' + opacity);
|
| 128 |
-
});
|
| 129 |
-
|
| 130 |
-
console.log('\nquery emphasis');
|
| 131 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 132 |
-
|
| 133 |
-
await test('an exact query rings a tile with the running neon', async () => {
|
| 134 |
-
await page.fill('input[type=text]', 'phòng khách');
|
| 135 |
-
await page.waitForSelector('.image-tile.exact', { timeout: 60000 });
|
| 136 |
-
|
| 137 |
-
const ring = await page.evaluate(() => {
|
| 138 |
-
const tile = document.querySelector('.image-tile.exact');
|
| 139 |
-
const style = getComputedStyle(tile, '::before');
|
| 140 |
-
return {
|
| 141 |
-
animation: style.animationName,
|
| 142 |
-
background: style.backgroundImage,
|
| 143 |
-
mask: style.maskImage || style.webkitMaskImage,
|
| 144 |
-
};
|
| 145 |
-
});
|
| 146 |
-
assert(ring.animation === 'neon-run', 'the ring is not animating: ' + ring.animation);
|
| 147 |
-
assert(/conic-gradient/.test(ring.background), 'the ring is not a conic gradient');
|
| 148 |
-
assert(ring.mask && ring.mask !== 'none', 'the ring has no mask, so it would fill the tile');
|
| 149 |
-
});
|
| 150 |
-
|
| 151 |
-
await test('the neon sweep actually advances between frames', async () => {
|
| 152 |
-
// A gradient that is present but not advancing is the exact failure the brief
|
| 153 |
-
// asked to avoid, and it looks correct in a single screenshot.
|
| 154 |
-
//
|
| 155 |
-
// Read the sweep angle, NOT `transform`. The transform is now pinned to none
|
| 156 |
-
// on purpose -- it is the thing that used to drag the mask off the frame -- so
|
| 157 |
-
// asserting on it would be asserting the old bug is still there.
|
| 158 |
-
const read = () => page.evaluate(() => {
|
| 159 |
-
const tile = document.querySelector('.image-tile.exact');
|
| 160 |
-
if (!tile) return null;
|
| 161 |
-
const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
|
| 162 |
-
const value = Number.parseFloat(raw);
|
| 163 |
-
return Number.isFinite(value) ? value : raw;
|
| 164 |
-
});
|
| 165 |
-
const a = await read();
|
| 166 |
-
assert(a !== null, 'no exact tile to read');
|
| 167 |
-
await page.waitForTimeout(700);
|
| 168 |
-
const b = await read();
|
| 169 |
-
assert(a !== b, `the sweep angle did not change (${a} -> ${b})`);
|
| 170 |
-
});
|
| 171 |
-
|
| 172 |
-
await test('the head stays inside the frame on a non-square tile', async () => {
|
| 173 |
-
// The mask is the tile's own box, so the bright head has to sit on the edge
|
| 174 |
-
// for every aspect ratio. Verified by reading the gradient's own angle out of
|
| 175 |
-
// the computed style: it must be a resolved angle on every ringed tile, not
|
| 176 |
-
// just on the ones that happen to be square.
|
| 177 |
-
const angles = await page.evaluate(() => {
|
| 178 |
-
return [...document.querySelectorAll('.image-tile.exact, .image-tile.featured')].map(tile => {
|
| 179 |
-
const box = tile.getBoundingClientRect();
|
| 180 |
-
const raw = getComputedStyle(tile, '::before').getPropertyValue('--neon-angle').trim();
|
| 181 |
-
return { ratio: box.width / box.height, raw };
|
| 182 |
-
});
|
| 183 |
-
});
|
| 184 |
-
for (const { ratio, raw } of angles) {
|
| 185 |
-
assert(raw && /deg$/.test(raw),
|
| 186 |
-
`a ${ratio.toFixed(2)}:1 tile has sweep angle "${raw}", so the head is not on the frame`);
|
| 187 |
-
}
|
| 188 |
-
});
|
| 189 |
-
|
| 190 |
-
await test('an exact tile is never dimmed', async () => {
|
| 191 |
-
const opacity = await page.evaluate(() => {
|
| 192 |
-
const tile = document.querySelector('.image-tile.exact');
|
| 193 |
-
return parseFloat(getComputedStyle(tile).opacity);
|
| 194 |
-
});
|
| 195 |
-
assert(opacity > 0.9, 'an exact match is faded to ' + opacity);
|
| 196 |
-
});
|
| 197 |
-
|
| 198 |
-
await test('a query with no exact answer rings nothing', async () => {
|
| 199 |
-
await page.fill('input[type=text]', 'zzzqqqxyz không tồn tại');
|
| 200 |
-
await page.waitForTimeout(6000);
|
| 201 |
-
assert(await page.locator('.image-tile.exact').count() === 0,
|
| 202 |
-
'an unresolvable query still produced an exact match');
|
| 203 |
-
});
|
| 204 |
-
|
| 205 |
-
console.log('\nshare links');
|
| 206 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 207 |
-
|
| 208 |
-
await test('an interior photograph never points /share/p/ at an avatarimg sku', async () => {
|
| 209 |
-
// The regression: `avatarimg:` is a namespace for de-duplication, not a
|
| 210 |
-
// catalogue identity, so /share/p/ resolves to nothing and vai-avatar answers
|
| 211 |
-
// "Not Found". Every interior photograph was affected.
|
| 212 |
-
//
|
| 213 |
-
// Driven off a hand-built anchor rather than the live field: the field's own
|
| 214 |
-
// hrefs depend on a network read of the vai-avatar interior index, which may
|
| 215 |
-
// not have landed when this runs. This asserts the routing rule itself.
|
| 216 |
-
const href = await page.evaluate(() => {
|
| 217 |
-
const anchor = document.createElement('a');
|
| 218 |
-
anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/avatarimg%3Ab68dc779370dc70e';
|
| 219 |
-
document.body.append(anchor);
|
| 220 |
-
globalThis.__repairInteriorShareLinks?.();
|
| 221 |
-
const resolved = anchor.href;
|
| 222 |
-
anchor.remove();
|
| 223 |
-
return resolved;
|
| 224 |
-
});
|
| 225 |
-
assert(href === 'https://bep40-vai-avatar.hf.space/share/i/b68dc779370dc70e',
|
| 226 |
-
'the mis-routed link was not corrected, it resolved to ' + href);
|
| 227 |
-
});
|
| 228 |
-
|
| 229 |
-
await test('a real product link is left alone', async () => {
|
| 230 |
-
// The shim must not touch a link that resolves. Rewriting this would send a
|
| 231 |
-
// shopper to a 404 dressed up as a fix.
|
| 232 |
-
const href = await page.evaluate(() => {
|
| 233 |
-
const anchor = document.createElement('a');
|
| 234 |
-
anchor.href = 'https://bep40-vai-avatar.hf.space/share/p/MH800TC';
|
| 235 |
-
document.body.append(anchor);
|
| 236 |
-
globalThis.__repairInteriorShareLinks?.();
|
| 237 |
-
const resolved = anchor.href;
|
| 238 |
-
anchor.remove();
|
| 239 |
-
return resolved;
|
| 240 |
-
});
|
| 241 |
-
assert(href === 'https://bep40-vai-avatar.hf.space/share/p/MH800TC',
|
| 242 |
-
'a valid product link was rewritten to ' + href);
|
| 243 |
-
});
|
| 244 |
-
|
| 245 |
-
console.log('\nno regressions');
|
| 246 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 247 |
-
|
| 248 |
-
await test('no uncaught page errors', async () => {
|
| 249 |
-
assert(errors.length === 0, errors.slice(0, 3).join(' | '));
|
| 250 |
-
});
|
| 251 |
-
|
| 252 |
-
await page.screenshot({ path: '/tmp/neon-field.png', fullPage: false });
|
| 253 |
-
await browser.close();
|
| 254 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 255 |
-
console.log('screenshot: /tmp/neon-field.png');
|
| 256 |
-
process.exit(failed ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-product-search.mjs
DELETED
|
@@ -1,173 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Verify the vai-avatar scorer ported into product-search.js.
|
| 3 |
-
*
|
| 4 |
-
* The point of these tests is parity with vai-avatar, not a notion of "good
|
| 5 |
-
* ranking". Each case is a query vai-avatar is known to handle a specific way,
|
| 6 |
-
* and the expectation is written down so a change in behaviour here is a
|
| 7 |
-
* visible diff rather than a silent reordering nobody notices until a customer
|
| 8 |
-
* says the wrong product came up.
|
| 9 |
-
*
|
| 10 |
-
* Run: node scripts/test-product-search.mjs
|
| 11 |
-
*/
|
| 12 |
-
|
| 13 |
-
import { scoreProduct, searchLikeAvatar, extractTerms } from '../src/product-search.js';
|
| 14 |
-
import { hasDecisiveWinner, exactMatches } from '../src/relevance.js';
|
| 15 |
-
import { normalizeText } from '../src/catalogue.js';
|
| 16 |
-
|
| 17 |
-
/* ── catalogue.js's normaliseProduct() output shape ── */
|
| 18 |
-
function product({ name = '', model = '', brand = '', category = '', summary = '', feats = [], specs = [], sku = '', ...rest }) {
|
| 19 |
-
return { sku: sku || model || name, name, model, brand, category, summary, feats, specs, ...rest };
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
let passed = 0;
|
| 23 |
-
let failed = 0;
|
| 24 |
-
function check(label, actual, expected) {
|
| 25 |
-
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
| 26 |
-
if (ok) { passed++; console.log(` ok ${label}`); }
|
| 27 |
-
else { failed++; console.log(` FAIL ${label}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); }
|
| 28 |
-
}
|
| 29 |
-
function ok(label, condition, detail = '') {
|
| 30 |
-
if (condition) { passed++; console.log(` ok ${label}`); }
|
| 31 |
-
else { failed++; console.log(` FAIL ${label} ${detail}`); }
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
console.log('\nextractTerms');
|
| 35 |
-
check('folds diacritics and drops 1-char noise',
|
| 36 |
-
extractTerms('Bếp từ Mới'), ['bep', 'tu', 'moi']);
|
| 37 |
-
check('keeps domain words a stopword list would drop',
|
| 38 |
-
extractTerms('tủ lạnh'), ['tu', 'lanh'],
|
| 39 |
-
);
|
| 40 |
-
ok('"tu" survives: it is both "từ" and "tủ"', extractTerms('tu lanh').includes('tu'));
|
| 41 |
-
check('"đ" folds to d', extractTerms('đá Granite'), ['da', 'granite']);
|
| 42 |
-
|
| 43 |
-
console.log('\nexact model / sku beats everything');
|
| 44 |
-
const gp170 = product({ name: 'Bếp từ MALLOCA GP170BD 5 vùng', model: 'GP170BD', brand: 'Malloca', category: 'Bếp từ' });
|
| 45 |
-
const other = product({ name: 'Bếp từ MALLOCA GP900 3 vùng', model: 'GP900', brand: 'Malloca', category: 'Bếp từ' });
|
| 46 |
-
{
|
| 47 |
-
const terms = extractTerms('GP170BD');
|
| 48 |
-
const exact = scoreProduct(gp170, 'GP170BD', terms);
|
| 49 |
-
const near = scoreProduct(other, 'GP170BD', terms);
|
| 50 |
-
ok(`exact SKU (${exact}) outranks a near model (${near})`, exact > near);
|
| 51 |
-
ok('exact SKU crosses the neon threshold (>=700)', exact >= 700, `got ${exact}`);
|
| 52 |
-
ok('the near model does NOT', near < 700, `got ${near}`);
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
console.log('\ntitle containment');
|
| 56 |
-
{
|
| 57 |
-
const terms = extractTerms('khoá điện tử');
|
| 58 |
-
const smart = product({ name: 'Khóa cửa điện tử thông minh VAI', category: 'Khóa cửa thông minh', feats: ['Vân tay', 'Mã số'] });
|
| 59 |
-
const plainLock = product({ name: 'Khóa cửa thường cánh thông', category: 'Khóa cửa' });
|
| 60 |
-
ok(`smart lock (${scoreProduct(smart, 'khoá điện tử', terms)}) outranks plain (${scoreProduct(plainLock, 'khoá điện tử', terms)})`,
|
| 61 |
-
scoreProduct(smart, 'khoá điện tử', terms) > scoreProduct(plainLock, 'khoá điện tử', terms));
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
console.log('\nsize (mm) matching is numeric, not textual');
|
| 65 |
-
{
|
| 66 |
-
// The catalogue writes sizes inconsistently; "700" must find the 700mm rack
|
| 67 |
-
// and not be fooled by a 70cm or a price containing 700.
|
| 68 |
-
const rack700 = product({ name: 'Kệ xoong nồi inox 700mm 2 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '700 mm']] });
|
| 69 |
-
const rack900 = product({ name: 'Kệ xoong nồi inox 900mm 3 tầng', category: 'Kệ xoong nồi', specs: [['Chiều rộng', '900 mm']] });
|
| 70 |
-
const terms = extractTerms('kệ xoong nồi 700mm');
|
| 71 |
-
const hit = scoreProduct(rack700, 'kệ xoong nồi 700mm', terms);
|
| 72 |
-
const miss = scoreProduct(rack900, 'kệ xoong nồi 700mm', terms);
|
| 73 |
-
ok(`700mm rack (${hit}) outranks 900mm rack (${miss})`, hit > miss);
|
| 74 |
-
const t2 = extractTerms('kệ 700');
|
| 75 |
-
ok('a bare "700" also finds the 700mm rack',
|
| 76 |
-
scoreProduct(rack700, 'kệ 700', t2) > scoreProduct(rack900, 'kệ 700', t2));
|
| 77 |
-
ok('and the 900 rack is actually penalised for not matching', miss < hit);
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
console.log('\nmaterial matching reads specs, not just the title');
|
| 81 |
-
{
|
| 82 |
-
const title = product({ name: 'Chậu rửa inox MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
|
| 83 |
-
const specsOnly = product({ name: 'Chậu rửa bàn MALLOCA', specs: [['Chất liệu', 'Inox 304']] });
|
| 84 |
-
const terms = extractTerms('chậu rửa inox 304');
|
| 85 |
-
ok('a title mention scores', scoreProduct(title, 'chậu rửa inox 304', terms) > 0);
|
| 86 |
-
ok('a specs-only mention also scores', scoreProduct(specsOnly, 'chậu rửa inox 304', terms) > 0);
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
console.log('\nbrand bonus');
|
| 90 |
-
{
|
| 91 |
-
const malloca = product({ name: 'Bàn ăn gỗ Malloca', brand: 'Malloca', category: 'Bàn ăn' });
|
| 92 |
-
const otherBrand = product({ name: 'Bàn ăn gỗ Eurogold', brand: 'Eurogold', category: 'Bàn ăn' });
|
| 93 |
-
const terms = extractTerms('bàn ăn malloca');
|
| 94 |
-
ok(`query naming the brand wins it (${scoreProduct(malloca, 'bàn ăn malloca', terms)} > ${scoreProduct(otherBrand, 'bàn ăn malloca', terms)})`,
|
| 95 |
-
scoreProduct(malloca, 'bàn ăn malloca', terms) > scoreProduct(otherBrand, 'bàn ăn malloca', terms));
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
console.log('\ncovering the query beats one rare word');
|
| 99 |
-
{
|
| 100 |
-
const both = product({ name: 'Bếp từ MALLOCA 5 vùng', category: 'Bếp từ' });
|
| 101 |
-
const partial = product({ name: 'Bàn ghế gỗ sồi', category: 'Bàn ghế' });
|
| 102 |
-
const terms = extractTerms('bếp từ');
|
| 103 |
-
ok(`both terms in title (${scoreProduct(both, 'bếp từ', terms)}) beats one (${scoreProduct(partial, 'bếp từ', terms)})`,
|
| 104 |
-
scoreProduct(both, 'bếp từ', terms) > scoreProduct(partial, 'bếp từ', terms));
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
console.log('\nunrelated product scores zero');
|
| 108 |
-
{
|
| 109 |
-
const terms = extractTerms('bếp từ');
|
| 110 |
-
ok('a bicycle is not a kitchen product',
|
| 111 |
-
scoreProduct(product({ name: 'Xe đạp thể thao Giant', category: 'Xe đạp' }), 'bếp từ', terms) === 0);
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
console.log('\nsearchLikeAvatar ranks and limits');
|
| 115 |
-
{
|
| 116 |
-
const items = new Map([
|
| 117 |
-
['GP170BD', gp170],
|
| 118 |
-
['GP900', other],
|
| 119 |
-
['BD1054', product({ name: 'Máy giặt Hitachi Inverter 10.5kg BD-1054HVOW', brand: 'Hitachi', category: 'Máy giặt' })],
|
| 120 |
-
]);
|
| 121 |
-
const top = searchLikeAvatar('GP170BD', items, 5);
|
| 122 |
-
ok('the exact model is first', top[0]?.sku === 'GP170BD', `got ${top[0]?.sku}`);
|
| 123 |
-
check('respects the limit', searchLikeAvatar('bếp', items, 2).length <= 2, true);
|
| 124 |
-
check('an empty query returns nothing', searchLikeAvatar('', items, 5).length, 0);
|
| 125 |
-
check('a query matching nothing returns nothing', searchLikeAvatar('xe đạp', items, 5).length, 0);
|
| 126 |
-
check('a missing catalogue returns nothing', searchLikeAvatar('bếp', null, 5).length, 0);
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
console.log('\nhasDecisiveWinner (the neon gate for products)');
|
| 130 |
-
ok('a 1000-point SKU match with a weak runner-up qualifies',
|
| 131 |
-
hasDecisiveWinner([1000, 20]));
|
| 132 |
-
ok('a lone 1000 with nothing else qualifies',
|
| 133 |
-
hasDecisiveWinner([1000]));
|
| 134 |
-
ok('a mere 550 (the "contains" tier) does not',
|
| 135 |
-
!hasDecisiveWinner([550, 10]));
|
| 136 |
-
ok('a near-tie does not, so a model family does not all light up',
|
| 137 |
-
!hasDecisiveWinner([1000, 900]));
|
| 138 |
-
ok('an empty list does not',
|
| 139 |
-
!hasDecisiveWinner([]));
|
| 140 |
-
ok('a missing list does not',
|
| 141 |
-
!hasDecisiveWinner(undefined));
|
| 142 |
-
|
| 143 |
-
console.log('\nexactMatches — which tiles get the outline');
|
| 144 |
-
{
|
| 145 |
-
// A decisive product winner, keyed the way merge.js keys product tiles.
|
| 146 |
-
const ids = exactMatches([], 'GP170BD', [{ sku: 'GP170BD', score: 1316 }, { sku: 'GP900', score: 10 }]);
|
| 147 |
-
ok('the decisive product is outlined', ids.has('malloca:GP170BD'));
|
| 148 |
-
// A relevance-only winner must not light up.
|
| 149 |
-
const weak = exactMatches([], 'bếp inox', [{ sku: 'A', score: 60 }, { sku: 'B', score: 10 }]);
|
| 150 |
-
ok('a mere relevance match is not outlined', weak.size === 0);
|
| 151 |
-
// Interior: the query appears verbatim in the record's own title.
|
| 152 |
-
const interiors = exactMatches(
|
| 153 |
-
[{ id: 'abc123', score: 0.31, title: 'Bếp đá marble cao cấp', categories: ['Nội thất bếp'] }, { id: 'def456', score: 0.29, title: 'Phòng khách hiện đại' }],
|
| 154 |
-
'bếp đá',
|
| 155 |
-
);
|
| 156 |
-
ok('an interior whose title contains the query is outlined', interiors.has('abc123'));
|
| 157 |
-
ok('the merely-similar interior is not', !interiors.has('def456'));
|
| 158 |
-
// Accents must fold on both sides.
|
| 159 |
-
const folded = exactMatches([{ id: 'x', score: 0.3, title: 'Bep Da Marble' }], 'bếp đá');
|
| 160 |
-
ok('accent folding works both ways', folded.has('x'));
|
| 161 |
-
// A short query must not match everything by accident.
|
| 162 |
-
ok('a 1-2 character query outlines nothing', exactMatches([{ id: 'x', score: 0.3, title: 'a b' }], 'a').size === 0);
|
| 163 |
-
ok('an empty query outlines nothing', exactMatches([{ id: 'x', score: 0.9, title: 'Bếp đá' }], ' ').size === 0);
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
console.log('\nnormalisation parity with catalogue.js');
|
| 167 |
-
{
|
| 168 |
-
ok('normalizeText folds the same way in both modules',
|
| 169 |
-
normalizeText('Bếp Từ Mây') === 'bep tu may');
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 173 |
-
if (failed) process.exit(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-semantic-search.mjs
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Nghiệm thu hành vi tìm kiếm: ngữ nghĩa (ảnh interiors) vs lookup sản phẩm.
|
| 3 |
-
*
|
| 4 |
-
* Chạy: node scripts/test-semantic-search.mjs
|
| 5 |
-
*
|
| 6 |
-
* Bốn nhóm kiểm tra, đúng bốn cam kết của Space:
|
| 7 |
-
*
|
| 8 |
-
* 1. Phân loại từ khóa -- "bàn"/"ghế"/"nội thất"/"nhà phố" là từ ngữ
|
| 9 |
-
* nghĩa; "kệ"/"xoong"/"bếp"/"tủ"/"máy" vẫn là từ sản phẩm.
|
| 10 |
-
* 2. Đồng nghĩa -- "nhà phố" mở rộng ra "nhà ống"; "biệt thự" ra "villa".
|
| 11 |
-
* 3. Ảnh interiors -- tìm "nhà phố" thấy caption "Nhà ống", KHÔNG thấy
|
| 12 |
-
* "Đèn ống" (false positive đã được đo và loại).
|
| 13 |
-
* 4. Crowd-out -- "bàn ghế"/"nội thất" GIỮ ảnh interiors; "kệ xoong"
|
| 14 |
-
* VẪN gỡ interiors (chỉ còn sản phẩm); lookup SKU chính xác vẫn
|
| 15 |
-
* crowd-out và trả đúng 1 sản phẩm đầu tiên.
|
| 16 |
-
*/
|
| 17 |
-
import { productLikeTerms, isSemanticTerm, expandQueryTerms } from '../src/semantic-terms.js';
|
| 18 |
-
import { mergeResults } from '../src/merge.js';
|
| 19 |
-
import { searchLikeAvatar } from '../src/product-search.js';
|
| 20 |
-
import { searchAvatarInteriors } from '../src/avatar-interiors.js';
|
| 21 |
-
|
| 22 |
-
let failures = 0;
|
| 23 |
-
function check(name, ok, detail = '') {
|
| 24 |
-
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`);
|
| 25 |
-
if (!ok) failures++;
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
/** Ghi sản phẩm vào dạng Map mà catalogue.js/buildCatalogue() trả về. */
|
| 29 |
-
function toItems(products) {
|
| 30 |
-
const items = new Map();
|
| 31 |
-
for (const p of products) items.set(p.sku, p);
|
| 32 |
-
return items;
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
/* ─────────────────────── 1. Phân loại từ khóa ─────────────────────── */
|
| 36 |
-
|
| 37 |
-
check('"bàn ghế" toàn từ ngữ nghĩa', productLikeTerms(['ban', 'ghe']).length === 0);
|
| 38 |
-
check('"kệ xoong" toàn từ sản phẩm', JSON.stringify(productLikeTerms(['ke', 'xoong'])) === '["ke","xoong"]');
|
| 39 |
-
check('"nội thất" toàn từ ngữ nghĩa', productLikeTerms(['noi', 'that']).length === 0);
|
| 40 |
-
check('"nhà phố" toàn từ ngữ nghĩa', productLikeTerms(['nha', 'pho']).length === 0);
|
| 41 |
-
check('"biệt thự" toàn từ ngữ nghĩa', productLikeTerms(['biet', 'thu']).length === 0);
|
| 42 |
-
check('"bếp từ" vẫn là từ sản phẩm', productLikeTerms(['bep', 'tu']).length === 2);
|
| 43 |
-
check('"máy hút mùi" vẫn là từ sản phẩm', productLikeTerms(['may', 'hut', 'mui']).length === 3);
|
| 44 |
-
check('isSemanticTerm(ban) đúng', isSemanticTerm('ban') === true);
|
| 45 |
-
check('isSemanticTerm(ke) sai (kệ là dòng SP chủ lực)', isSemanticTerm('ke') === false);
|
| 46 |
-
|
| 47 |
-
/* ─────────────────────── 2. Đồng nghĩa ─────────────────────── */
|
| 48 |
-
|
| 49 |
-
check('mở rộng "nhà phố" có "ong" (nhà ống)', expandQueryTerms(['nha', 'pho']).includes('ong'));
|
| 50 |
-
check('mở rộng "biệt thự" có "villa"', expandQueryTerms(['biet', 'thu']).includes('villa'));
|
| 51 |
-
check('mở rộng "nội thất" có "that"', expandQueryTerms(['noi']).includes('that'));
|
| 52 |
-
check('mở rộng "ghe" sinh "ban" (cặp bàn-ghế là đồng nghĩa thật)', JSON.stringify(expandQueryTerms(['ghe'])) === '["ghe","ban"]');
|
| 53 |
-
|
| 54 |
-
/* ─────────────────────── 3. Ảnh interiors (caption lexical) ─────────────────────── */
|
| 55 |
-
|
| 56 |
-
const avatarInteriors = [
|
| 57 |
-
{ sku: 'avatarimg:1', avatarImageId: '1', title: 'Nhà ống mặt tiền', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/1.jpg'], src: 'https://cdn.example/1.jpg' },
|
| 58 |
-
{ sku: 'avatarimg:2', avatarImageId: '2', title: 'Biệt thự vườn xanh', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/2.jpg'], src: 'https://cdn.example/2.jpg' },
|
| 59 |
-
{ sku: 'avatarimg:3', avatarImageId: '3', title: 'Đèn ống LED âm trần', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/3.jpg'], src: 'https://cdn.example/3.jpg' },
|
| 60 |
-
{ sku: 'avatarimg:4', avatarImageId: '4', title: 'Nội thất phòng khách gỗ', description: '', categories: [], keywords: [], addedAt: '2026-10-01T00:00:00Z', images: ['https://cdn.example/4.jpg'], src: 'https://cdn.example/4.jpg' },
|
| 61 |
-
];
|
| 62 |
-
|
| 63 |
-
const nhaPhoHits = searchAvatarInteriors('nhà phố', avatarInteriors);
|
| 64 |
-
check('tìm "nhà phố" thấy ảnh "Nhà ống"', nhaPhoHits.some(r => r.avatarImageId === '1'), nhaPhoHits.map(r => r.title).join(' | '));
|
| 65 |
-
check('tìm "nhà phố" thấy ảnh "Biệt thự"', nhaPhoHits.some(r => r.avatarImageId === '2'));
|
| 66 |
-
check('tìm "nhà phố" KHÔNG thấy "Đèn ống" (false positive)', !nhaPhoHits.some(r => r.avatarImageId === '3'), nhaPhoHits.map(r => r.title).join(' | '));
|
| 67 |
-
|
| 68 |
-
const noiThatHits = searchAvatarInteriors('nội thất', avatarInteriors);
|
| 69 |
-
check('tìm "nội thất" thấy ảnh "Nội thất phòng khách"', noiThatHits.some(r => r.avatarImageId === '4'), noiThatHits.map(r => r.title).join(' | '));
|
| 70 |
-
|
| 71 |
-
/* ─────────────────────── 4. Crowd-out trong mergeResults ─────────────────────── */
|
| 72 |
-
|
| 73 |
-
/** Ảnh interiors đã được CLIP xếp hạng (điểm cosine), dạng worker trả về. */
|
| 74 |
-
const clipInteriors = [
|
| 75 |
-
{ id: 'a1b2c3d4e5', url: 'phong-khach-01.jpg', score: 0.91, title: 'Phòng khách gỗ', category: 'phong khach' },
|
| 76 |
-
{ id: 'f6e5d4c3b2', url: 'phong-khach-02.jpg', score: 0.87, title: 'Phòng khách sáng', category: 'phong khach' },
|
| 77 |
-
];
|
| 78 |
-
|
| 79 |
-
const banGhe = toItems([
|
| 80 |
-
{ sku: 'BAN-OC-001', model: 'BAN-OC-001', name: 'Bàn ghế gỗ óc chó', brand: 'Malloca', category: 'ban-ghe', summary: 'Bàn ghế ăn gỗ óc chó nhập khẩu', specs: [], feats: [], images: ['https://cdn.example/ban.jpg'], price: '4.500.000₫', priceNum: 4500000 },
|
| 81 |
-
]);
|
| 82 |
-
const banGheHits = searchLikeAvatar('bàn ghế', banGhe, 24);
|
| 83 |
-
console.log(` [info] score "bàn ghế" = ${banGheHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
|
| 84 |
-
|
| 85 |
-
const mergedBanGhe = mergeResults(clipInteriors, banGheHits, { query: 'bàn ghế', avatarInteriors: [], recentProducts: [] });
|
| 86 |
-
// CLIP interior tiles giữ id GỐC (a1b2c3d4e5...), không có prefix `interior:` —
|
| 87 |
-
// prefix chỉ là key de-dup nội bộ của mergeResults. Kiểm tra trên id gốc.
|
| 88 |
-
check('tìm "bàn ghế" VẪN GIỮ ảnh interiors (semantic)', mergedBanGhe.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedBanGhe.map(t => t.id).join(', '));
|
| 89 |
-
check('tìm "bàn ghế" vẫn có sản phẩm xen kẽ', mergedBanGhe.some(t => t.sku === 'BAN-OC-001'));
|
| 90 |
-
|
| 91 |
-
const noiThat = toItems([
|
| 92 |
-
{ sku: 'NT-001', model: 'NT-001', name: 'Nội thất gỗ phòng khách', brand: 'Malloca', category: 'noi-that', summary: '', specs: [], feats: [], images: ['https://cdn.example/nt.jpg'], price: '7.000.000₫', priceNum: 7000000 },
|
| 93 |
-
]);
|
| 94 |
-
const noiThatProductHits = searchLikeAvatar('nội thất', noiThat, 24);
|
| 95 |
-
const mergedNoiThat = mergeResults(clipInteriors, noiThatProductHits, { query: 'nội thất', avatarInteriors: [], recentProducts: [] });
|
| 96 |
-
check('tìm "nội thất" VẪN GIỮ ảnh interiors (semantic)', mergedNoiThat.some(t => ['a1b2c3d4e5', 'f6e5d4c3b2'].includes(String(t.id))), mergedNoiThat.map(t => t.id).join(', '));
|
| 97 |
-
|
| 98 |
-
const keXoong = toItems([
|
| 99 |
-
{ sku: 'GX-170', model: 'GX-170', name: 'Giá xoong nồi inox Eurogold', brand: 'Eurogold', category: 'ke-xoong', summary: 'Kệ xoong treo tường inox 304', specs: [], feats: [], images: ['https://cdn.example/ke.jpg'], price: '590.000₫', priceNum: 590000 },
|
| 100 |
-
]);
|
| 101 |
-
const keXoongHits = searchLikeAvatar('kệ xoong', keXoong, 24);
|
| 102 |
-
console.log(` [info] score "kệ xoong" = ${keXoongHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
|
| 103 |
-
const mergedKeXoong = mergeResults(clipInteriors, keXoongHits, { query: 'kệ xoong', avatarInteriors: [], recentProducts: [] });
|
| 104 |
-
check('tìm "kệ xoong" VẪN gỡ interiors (product query)', !mergedKeXoong.some(t => String(t.id).startsWith('interior:')), mergedKeXoong.map(t => t.id).join(', '));
|
| 105 |
-
check('tìm "kệ xoong" chỉ còn sản phẩm', mergedKeXoong.every(t => t.sku === 'GX-170'));
|
| 106 |
-
|
| 107 |
-
/* ─────────────── 5. Lookup SKU: chính xác 100%, vẫn crowd-out ─────────────── */
|
| 108 |
-
|
| 109 |
-
const skuMap = toItems([
|
| 110 |
-
{ sku: 'GP170BD', model: 'GP170BD', name: 'Giá xoong nồi Eurogold GP170BD', brand: 'Eurogold', category: 'ke-xoong', summary: '', specs: [], feats: [], images: ['https://cdn.example/gp.jpg'], price: '850.000₫', priceNum: 850000 },
|
| 111 |
-
]);
|
| 112 |
-
const skuHits = searchLikeAvatar('GP170BD', skuMap, 24);
|
| 113 |
-
console.log(` [info] score "GP170BD" = ${skuHits.map(h => `${h.sku}:${h.score}`).join(', ')}`);
|
| 114 |
-
check('lookup SKU "GP170BD" trúng chính xác sản phẩm đó', skuHits[0]?.sku === 'GP170BD');
|
| 115 |
-
check('lookup SKU đạt ngưỡng identity (≥700)', (skuHits[0]?.score ?? 0) >= 700, `score=${skuHits[0]?.score}`);
|
| 116 |
-
const mergedSku = mergeResults(clipInteriors, skuHits, { query: 'GP170BD', avatarInteriors: [], recentProducts: [] });
|
| 117 |
-
check('lookup SKU crowd-out interiors (đây là tra cứu, không phải duyệt ảnh)', !mergedSku.some(t => String(t.id).startsWith('interior:')), mergedSku.map(t => t.id).join(', '));
|
| 118 |
-
check('lookup SKU trả đúng 1 sản phẩm, đầu danh sách', mergedSku[0]?.sku === 'GP170BD');
|
| 119 |
-
|
| 120 |
-
/* ─────────────────────── tổng kết ─────────────────────── */
|
| 121 |
-
|
| 122 |
-
console.log(failures ? `\n${failures} TEST FAIL` : '\nALL TESTS PASS');
|
| 123 |
-
process.exit(failures ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-ui.mjs
DELETED
|
@@ -1,201 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Browser check for the three changes: the backdrop, the size/position
|
| 3 |
-
* controls, and the neon outline.
|
| 4 |
-
*
|
| 5 |
-
* Run against a served build:
|
| 6 |
-
* npx vite preview --port 4173 & node scripts/test-ui.mjs
|
| 7 |
-
*
|
| 8 |
-
* Every assertion is about something visible. The neon one in particular is
|
| 9 |
-
* asserted against the RENDERED pixels and computed styles rather than against
|
| 10 |
-
* a class name, because a class that is added but overridden by CSS would pass
|
| 11 |
-
* a class-name check while showing the user nothing at all.
|
| 12 |
-
*/
|
| 13 |
-
|
| 14 |
-
import { chromium } from 'playwright';
|
| 15 |
-
|
| 16 |
-
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173';
|
| 17 |
-
let passed = 0, failed = 0;
|
| 18 |
-
const ok = (label, condition, detail = '') => {
|
| 19 |
-
if (condition) { passed++; console.log(` ok ${label}`); }
|
| 20 |
-
else { failed++; console.log(` FAIL ${label} ${detail}`); }
|
| 21 |
-
};
|
| 22 |
-
|
| 23 |
-
const browser = await chromium.launch();
|
| 24 |
-
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
| 25 |
-
const errors = [];
|
| 26 |
-
page.on('pageerror', error => errors.push(error.message));
|
| 27 |
-
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
| 28 |
-
|
| 29 |
-
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
| 30 |
-
// The 130 MB model load is not what is under test here; give React time to mount
|
| 31 |
-
// and paint the field, then stop waiting on it.
|
| 32 |
-
await page.waitForSelector('.image-tile', { timeout: 30000 });
|
| 33 |
-
await page.waitForTimeout(2500);
|
| 34 |
-
|
| 35 |
-
console.log('\n1. backdrop');
|
| 36 |
-
{
|
| 37 |
-
const info = await page.evaluate(() => {
|
| 38 |
-
const el = document.querySelector('.brand-backdrop');
|
| 39 |
-
if (!el) return null;
|
| 40 |
-
const style = getComputedStyle(el);
|
| 41 |
-
return { image: style.backgroundImage, opacity: style.opacity, position: style.position };
|
| 42 |
-
});
|
| 43 |
-
ok('the backdrop element exists', !!info);
|
| 44 |
-
ok('it paints the vai-avatar artwork', !!info?.image?.includes('vaistudio_center.png'), info?.image);
|
| 45 |
-
ok('opacity is 30%', Math.abs(Number(info?.opacity) - 0.3) < 0.001, `got ${info?.opacity}`);
|
| 46 |
-
ok('it is fixed and out of the way', info?.position === 'fixed');
|
| 47 |
-
// The image must actually load, or it is a 30% transparent rectangle.
|
| 48 |
-
const loaded = await page.evaluate(() => new Promise(resolve => {
|
| 49 |
-
const url = getComputedStyle(document.querySelector('.brand-backdrop')).backgroundImage.slice(5, -2);
|
| 50 |
-
const probe = new Image();
|
| 51 |
-
probe.onload = () => resolve(probe.naturalWidth > 0);
|
| 52 |
-
probe.onerror = () => resolve(false);
|
| 53 |
-
probe.src = url;
|
| 54 |
-
setTimeout(() => resolve(false), 8000);
|
| 55 |
-
}));
|
| 56 |
-
ok('the artwork actually decodes (not a 404)', loaded);
|
| 57 |
-
// It must not swallow clicks meant for a photograph.
|
| 58 |
-
const pe = await page.evaluate(() => getComputedStyle(document.querySelector('.brand-backdrop')).pointerEvents);
|
| 59 |
-
ok('it does not intercept pointer events', pe === 'none', `got ${pe}`);
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
console.log('\n2. size & position controls, docked to the search box');
|
| 63 |
-
{
|
| 64 |
-
const toggle = await page.$('.field-controls-toggle');
|
| 65 |
-
ok('the toggle exists', !!toggle);
|
| 66 |
-
if (toggle) {
|
| 67 |
-
// "Near the search box" is a real requirement, so measure it.
|
| 68 |
-
const geo = await page.evaluate(() => {
|
| 69 |
-
const box = document.querySelector('.search-pill')?.getBoundingClientRect();
|
| 70 |
-
const knob = document.querySelector('.field-controls-toggle')?.getBoundingClientRect();
|
| 71 |
-
if (!box || !knob) return null;
|
| 72 |
-
return { gap: knob.top - box.bottom, centres: Math.abs((box.left + box.right) / 2 - (knob.left + knob.right) / 2) };
|
| 73 |
-
});
|
| 74 |
-
ok(`it sits just below the search box (${Math.round(geo?.gap ?? -1)}px gap)`, (geo?.gap ?? -1) >= 0 && (geo?.gap ?? 99) < 120);
|
| 75 |
-
ok(`and is horizontally centred with it (${Math.round(geo?.centres ?? -1)}px off)`, (geo?.centres ?? 99) < 24);
|
| 76 |
-
|
| 77 |
-
await toggle.click();
|
| 78 |
-
await page.waitForSelector('.field-controls-panel', { timeout: 5000 });
|
| 79 |
-
ok('the panel opens', true);
|
| 80 |
-
ok('it has a size control', !!(await page.$('#field-size')));
|
| 81 |
-
ok('it has a position pad', !!(await page.$('.field-controls-pad')));
|
| 82 |
-
|
| 83 |
-
// Size: measure a real tile before and after.
|
| 84 |
-
const before = await page.evaluate(() => {
|
| 85 |
-
const tile = document.querySelector('.image-tile');
|
| 86 |
-
return tile ? parseFloat(getComputedStyle(tile).width) : 0;
|
| 87 |
-
});
|
| 88 |
-
await page.evaluate(() => {
|
| 89 |
-
const slider = document.querySelector('#field-size');
|
| 90 |
-
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
| 91 |
-
setter.call(slider, '170');
|
| 92 |
-
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
| 93 |
-
slider.dispatchEvent(new Event('change', { bubbles: true }));
|
| 94 |
-
});
|
| 95 |
-
await page.waitForTimeout(1800);
|
| 96 |
-
const after = await page.evaluate(() => {
|
| 97 |
-
const tile = document.querySelector('.image-tile');
|
| 98 |
-
return tile ? parseFloat(getComputedStyle(tile).width) : 0;
|
| 99 |
-
});
|
| 100 |
-
ok(`raising the size slider grows the photographs (${Math.round(before)}px -> ${Math.round(after)}px)`,
|
| 101 |
-
after > before * 1.15, `before ${before} after ${after}`);
|
| 102 |
-
|
| 103 |
-
// Position: drag the pad and confirm the plane actually moved.
|
| 104 |
-
const pad = await page.$('.field-controls-pad');
|
| 105 |
-
const box = await pad.boundingBox();
|
| 106 |
-
// Read the recorded offset rather than guessing at a tile: which tile is
|
| 107 |
-
// "first" in the DOM is a seeded z-order detail, so asserting on it made
|
| 108 |
-
// this test depend on layout internals instead of on the observable state.
|
| 109 |
-
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
| 110 |
-
await page.mouse.down();
|
| 111 |
-
await page.mouse.move(box.x + box.width - 4, box.y + box.height - 4, { steps: 12 });
|
| 112 |
-
await page.mouse.up();
|
| 113 |
-
await page.waitForTimeout(1200);
|
| 114 |
-
const view = await page.evaluate(() => JSON.parse(sessionStorage.getItem('sif:view') || '{}'));
|
| 115 |
-
ok(`dragging the pad sets both axes (${view.offsetX}, ${view.offsetY})`,
|
| 116 |
-
(view.offsetX ?? 0) > 100 && (view.offsetY ?? 0) > 100, JSON.stringify(view));
|
| 117 |
-
// And the change must actually reach the photographs' rendered transform.
|
| 118 |
-
// Read the whole field and compare it against the SAME measurement taken
|
| 119 |
-
// before the drag: the first tile in the DOM is a seeded z-order detail,
|
| 120 |
-
// so the spread across all tiles is the reliable signal, not any one tile.
|
| 121 |
-
const span = () => page.evaluate(() => {
|
| 122 |
-
const xs = [...document.querySelectorAll('.image-tile')].map(t => {
|
| 123 |
-
const m = /translate3d\((-?[\d.]+)px/.exec(t.style.transform || getComputedStyle(t).transform);
|
| 124 |
-
return m ? Number(m[1]) : null;
|
| 125 |
-
}).filter(v => v !== null);
|
| 126 |
-
return xs.length ? { min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null;
|
| 127 |
-
});
|
| 128 |
-
const spanAfter = await span();
|
| 129 |
-
ok(`the photographs moved with it (x spans ${spanAfter ? `${Math.round(spanAfter.min)}..${Math.round(spanAfter.max)} over ${spanAfter.n} tiles` : 'no tiles'})`,
|
| 130 |
-
!!spanAfter && spanAfter.max - spanAfter.min > 200, JSON.stringify(spanAfter));
|
| 131 |
-
|
| 132 |
-
// Persistence across reload is what makes it a setting, not a gesture.
|
| 133 |
-
await page.reload({ waitUntil: 'domcontentloaded' });
|
| 134 |
-
await page.waitForSelector('.image-tile', { timeout: 30000 });
|
| 135 |
-
const persisted = await page.evaluate(() => {
|
| 136 |
-
const slider = document.querySelector('#field-size');
|
| 137 |
-
return sessionStorage.getItem('sif:view');
|
| 138 |
-
});
|
| 139 |
-
ok('the view survives a reload', !!persisted && JSON.parse(persisted).tileScale > 1, persisted);
|
| 140 |
-
await page.evaluate(() => { sessionStorage.clear(); });
|
| 141 |
-
}
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
console.log('\n3. neon outline on an exact match');
|
| 145 |
-
{
|
| 146 |
-
// Render tiles in the field's own coordinate space, with the classes and
|
| 147 |
-
// custom properties ImageField sets, then read the painted result. Typing a
|
| 148 |
-
// real query would mean loading the 130 MB model first, and the outline --
|
| 149 |
-
// not the encoder -- is what is under test here.
|
| 150 |
-
const painted = await page.evaluate(() => {
|
| 151 |
-
const field = document.querySelector('.image-field');
|
| 152 |
-
const exact = document.createElement('button');
|
| 153 |
-
exact.className = 'image-tile exact';
|
| 154 |
-
exact.dataset.exact = 'true';
|
| 155 |
-
exact.style.cssText = 'position:absolute;left:40px;top:40px;width:200px;height:160px;';
|
| 156 |
-
const plain = document.createElement('button');
|
| 157 |
-
plain.className = 'image-tile';
|
| 158 |
-
plain.dataset.exact = '';
|
| 159 |
-
plain.style.cssText = 'position:absolute;left:300px;top:40px;width:200px;height:160px;';
|
| 160 |
-
field.append(exact, plain);
|
| 161 |
-
const ring = getComputedStyle(exact, '::after');
|
| 162 |
-
const noRing = getComputedStyle(plain, '::after');
|
| 163 |
-
const out = {
|
| 164 |
-
content: ring.content,
|
| 165 |
-
borderWidth: ring.borderTopWidth,
|
| 166 |
-
shadow: ring.boxShadow,
|
| 167 |
-
exactOpacity: getComputedStyle(exact).getPropertyValue('--relevance-opacity').trim(),
|
| 168 |
-
plainContent: noRing.content,
|
| 169 |
-
};
|
| 170 |
-
exact.remove(); plain.remove();
|
| 171 |
-
return out;
|
| 172 |
-
});
|
| 173 |
-
ok('the outline pseudo-element exists on an exact tile', painted.content !== 'none' && painted.content.includes('"'));
|
| 174 |
-
ok(`it has a visible stroke (${painted.borderWidth})`, parseFloat(painted.borderWidth) >= 1);
|
| 175 |
-
// count comma-separated shadows; color-mix() resolves to oklab() in Chromium,
|
| 176 |
-
// so match on the geometry ("Npx Npx Npx") rather than on a colour function.
|
| 177 |
-
const glowLayers = (painted.shadow.match(/\d+px\s+\d+px\s+\d+px/g) || []).length;
|
| 178 |
-
ok(`it carries a multi-layer neon glow (${glowLayers} layers)`, glowLayers >= 3, painted.shadow);
|
| 179 |
-
ok('an ordinary tile gets NO outline', painted.plainContent === 'none', `got ${painted.plainContent}`);
|
| 180 |
-
ok('an exact tile is never faded', painted.exactOpacity === '' || Number(painted.exactOpacity) >= 1, `got ${painted.exactOpacity}`);
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
console.log('\n4. no console errors from the new code');
|
| 184 |
-
{
|
| 185 |
-
// Image CORS from the dataset CDN is filtered out deliberately: it is a
|
| 186 |
-
// PRE-EXISTING property of how this Space loads interior photographs (the
|
| 187 |
-
// DATASET URL in assets.js is unchanged by this work), it depends on the
|
| 188 |
-
// headless host's own network policy rather than on any code under test, and
|
| 189 |
-
// surfacing it here would bury a real regression under a thousand lines of
|
| 190 |
-
// noise. The tile layer falls back to the BlurHash placeholder, so the field
|
| 191 |
-
// still paints without it.
|
| 192 |
-
const relevant = errors.filter(e => !/transformers|onnx|fetch|Failed to load|net::|404|wasm/i.test(e) && !/CORS policy/i.test(e));
|
| 193 |
-
ok(`no unrelated page errors (${relevant.length})`, relevant.length === 0, relevant.slice(0, 2).join(' | '));
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
await page.screenshot({ path: '/tmp/sif-ui.png', fullPage: false });
|
| 197 |
-
console.log('\nscreenshot: /tmp/sif-ui.png');
|
| 198 |
-
await browser.close();
|
| 199 |
-
|
| 200 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 201 |
-
if (failed) process.exit(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/test-viewer-ui.mjs
DELETED
|
@@ -1,151 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Browser checks for the restored viewer CSS, the ten-ring limit, and pill
|
| 3 |
-
* clearance.
|
| 4 |
-
*
|
| 5 |
-
* The CSS regression this file exists for was silent: opening a photograph
|
| 6 |
-
* produced an unstyled <dialog> that still worked, still showed the image, and
|
| 7 |
-
* just looked nothing like the design. Nothing throws, so only a computed-style
|
| 8 |
-
* assertion catches it.
|
| 9 |
-
*/
|
| 10 |
-
import { chromium } from 'playwright';
|
| 11 |
-
|
| 12 |
-
const BASE = process.env.BASE_URL || 'http://127.0.0.1:4173/';
|
| 13 |
-
let passed = 0, failed = 0;
|
| 14 |
-
const test = async (name, fn) => {
|
| 15 |
-
try { await fn(); console.log(' ok ' + name); passed++; }
|
| 16 |
-
catch (error) { console.log(' FAIL ' + name + '\n ' + error.message.split('\n')[0]); failed++; }
|
| 17 |
-
};
|
| 18 |
-
const assert = (cond, message) => { if (!cond) throw new Error(message); };
|
| 19 |
-
|
| 20 |
-
const browser = await chromium.launch();
|
| 21 |
-
const page = await browser.newPage({ viewport: { width: 1600, height: 950 } });
|
| 22 |
-
const errors = [];
|
| 23 |
-
page.on('pageerror', error => errors.push(error.message));
|
| 24 |
-
|
| 25 |
-
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
| 26 |
-
await page.waitForSelector('.image-tile', { timeout: 120000 });
|
| 27 |
-
await page.waitForTimeout(6000);
|
| 28 |
-
|
| 29 |
-
console.log('\nrestored viewer CSS');
|
| 30 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 31 |
-
|
| 32 |
-
await test('the viewer opens full-screen with its own background', async () => {
|
| 33 |
-
// force: true because the field is deliberately alive -- every tile drifts a
|
| 34 |
-
// few px per frame, so a click aimed at a position sampled a moment ago can
|
| 35 |
-
// land beside the tile. That is the field working, not a bug, and it is why
|
| 36 |
-
// the shot here is dispatched at the element rather than at coordinates.
|
| 37 |
-
await page.locator('.image-tile').first().click({ force: true, timeout: 15000 });
|
| 38 |
-
await page.waitForSelector('.photo-viewer[open]', { timeout: 30000 });
|
| 39 |
-
const box = await page.locator('.photo-viewer').boundingBox();
|
| 40 |
-
const style = await page.evaluate(() => {
|
| 41 |
-
const v = document.querySelector('.photo-viewer');
|
| 42 |
-
const s = getComputedStyle(v);
|
| 43 |
-
return { position: s.position, bg: s.backgroundColor, w: s.width, h: s.height };
|
| 44 |
-
});
|
| 45 |
-
assert(style.position === 'fixed', 'viewer is ' + style.position + ', not fixed');
|
| 46 |
-
assert(box.width >= 1500 && box.height >= 900, 'viewer is ' + Math.round(box.width) + 'x' + Math.round(box.height));
|
| 47 |
-
assert(style.bg !== 'rgba(0, 0, 0, 0)', 'viewer has no background');
|
| 48 |
-
});
|
| 49 |
-
|
| 50 |
-
await test('the toolbar is a bar, not stacked plain text', async () => {
|
| 51 |
-
// The regression rendered this as inline-flow divs at full width. A real
|
| 52 |
-
// toolbar is absolutely positioned over the image, which is what stops the
|
| 53 |
-
// share button sitting on top of the photograph.
|
| 54 |
-
const style = await page.evaluate(() => {
|
| 55 |
-
const t = document.querySelector('.photo-toolbar');
|
| 56 |
-
return t ? { position: getComputedStyle(t).position, display: getComputedStyle(t).display } : null;
|
| 57 |
-
});
|
| 58 |
-
assert(style, 'no .photo-toolbar found');
|
| 59 |
-
assert(style.position === 'absolute', 'toolbar is ' + style.position);
|
| 60 |
-
});
|
| 61 |
-
|
| 62 |
-
await test('the close button is sized and round, not a bare glyph', async () => {
|
| 63 |
-
const box = await page.locator('.photo-toolbar button').first().boundingBox();
|
| 64 |
-
assert(box && box.width >= 28 && box.height >= 28,
|
| 65 |
-
'close button is ' + (box ? Math.round(box.width) + 'x' + Math.round(box.height) : 'missing'));
|
| 66 |
-
});
|
| 67 |
-
|
| 68 |
-
await test('the frame sits inside the viewport, not flush to an edge', async () => {
|
| 69 |
-
const box = await page.locator('.photo-frame').boundingBox();
|
| 70 |
-
assert(box && box.width > 100 && box.height > 100, 'frame is ' + JSON.stringify(box));
|
| 71 |
-
assert(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1601 && box.y + box.height <= 951,
|
| 72 |
-
'frame escapes the viewport');
|
| 73 |
-
});
|
| 74 |
-
|
| 75 |
-
await test('the detail panel is styled when the photograph has metadata', async () => {
|
| 76 |
-
const n = await page.locator('.photo-detail').count();
|
| 77 |
-
if (!n) { console.log(' (skipped: this photograph has no metadata panel)'); return; }
|
| 78 |
-
const style = await page.evaluate(() => {
|
| 79 |
-
const d = document.querySelector('.photo-detail');
|
| 80 |
-
const s = getComputedStyle(d);
|
| 81 |
-
return { position: s.position, z: s.zIndex };
|
| 82 |
-
});
|
| 83 |
-
assert(['absolute', 'fixed'].includes(style.position), 'panel is ' + style.position);
|
| 84 |
-
});
|
| 85 |
-
|
| 86 |
-
console.log('\nten rings, not sixty');
|
| 87 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 88 |
-
|
| 89 |
-
await test('at most ten tiles carry the neon', async () => {
|
| 90 |
-
await page.keyboard.press('Escape');
|
| 91 |
-
await page.waitForTimeout(1500);
|
| 92 |
-
const ringed = await page.locator('.image-tile.featured').count();
|
| 93 |
-
assert(ringed <= 10, ringed + ' tiles are ringed, expected at most 10');
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
-
await test('more photographs are shown than before', async () => {
|
| 97 |
-
const total = await page.locator('.image-tile').count();
|
| 98 |
-
assert(total > 96, 'only ' + total + ' tiles; the field should now hold 150 on desktop');
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
await test('un-ringed new photographs are still visible', async () => {
|
| 102 |
-
// "còn lại hiển thị bình thường" -- the rest must render as ordinary tiles.
|
| 103 |
-
const plain = await page.evaluate(() => {
|
| 104 |
-
const tiles = [...document.querySelectorAll('.image-tile')];
|
| 105 |
-
return tiles.filter(t => !t.classList.contains('featured')).length;
|
| 106 |
-
});
|
| 107 |
-
assert(plain > 10, 'no un-ringed tiles found');
|
| 108 |
-
});
|
| 109 |
-
|
| 110 |
-
console.log('\npill clearance');
|
| 111 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 112 |
-
|
| 113 |
-
await test('no ringed tile overlaps the search pill', async () => {
|
| 114 |
-
// The brief is explicit: big, near the search box, but NOT covering it.
|
| 115 |
-
// Measured against every ringed tile's real box, not its centre point.
|
| 116 |
-
const worst = await page.evaluate(() => {
|
| 117 |
-
const pill = document.querySelector('.search-pill').getBoundingClientRect();
|
| 118 |
-
let overlap = 0, who = null;
|
| 119 |
-
for (const tile of document.querySelectorAll('.image-tile.featured')) {
|
| 120 |
-
const r = tile.getBoundingClientRect();
|
| 121 |
-
const w = Math.max(0, Math.min(r.right, pill.right) - Math.max(r.left, pill.left));
|
| 122 |
-
const h = Math.max(0, Math.min(r.bottom, pill.bottom) - Math.max(r.top, pill.top));
|
| 123 |
-
const area = w * h;
|
| 124 |
-
if (area > overlap) { overlap = area; who = tile.dataset.imageId; }
|
| 125 |
-
}
|
| 126 |
-
return { overlap, who, pill: pill.width * pill.height };
|
| 127 |
-
});
|
| 128 |
-
assert(worst.overlap === 0,
|
| 129 |
-
`tile ${worst.who} covers ${Math.round(worst.overlap)}px² of the pill (${Math.round(worst.pill)}px² total)`);
|
| 130 |
-
});
|
| 131 |
-
|
| 132 |
-
await test('a ringed tile is still larger than an ordinary one', async () => {
|
| 133 |
-
const r = await page.evaluate(() => {
|
| 134 |
-
const area = el => { const b = el.getBoundingClientRect(); return b.width * b.height; };
|
| 135 |
-
const f = document.querySelector('.image-tile.featured');
|
| 136 |
-
const o = [...document.querySelectorAll('.image-tile')].find(t => !t.classList.contains('featured'));
|
| 137 |
-
return { f: area(f), o: area(o) };
|
| 138 |
-
});
|
| 139 |
-
assert(r.f > r.o * 1.15, `ringed ${Math.round(r.f)}px² vs ordinary ${Math.round(r.o)}px²`);
|
| 140 |
-
});
|
| 141 |
-
|
| 142 |
-
console.log('\nno regressions');
|
| 143 |
-
// ──────────────────────────────────────────────────────────────────────────
|
| 144 |
-
|
| 145 |
-
await test('no uncaught page errors', async () => {
|
| 146 |
-
assert(errors.length === 0, errors.slice(0, 3).join(' | '));
|
| 147 |
-
});
|
| 148 |
-
|
| 149 |
-
await browser.close();
|
| 150 |
-
console.log(`\n${passed} passed, ${failed} failed`);
|
| 151 |
-
process.exit(failed ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|