diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41f20ee..7cc0aec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,3 +31,19 @@ jobs: run: pytest -q - name: Demo run: beear demo + + web-i18n: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Unit tests (EN/VI chrome strings) + run: node --test packages/web/tests/i18n.test.mjs + - name: IIFE mirror present + run: | + test -f packages/web/assets/i18n.js + grep -q "BeeARI18n" packages/web/assets/i18n.js + grep -q "i18n.js" packages/web/index.html + grep -q "i18n.js" packages/web/studio3d.html diff --git a/docs/evidence-issue-8-vi-ui.md b/docs/evidence-issue-8-vi-ui.md new file mode 100644 index 0000000..a42a865 --- /dev/null +++ b/docs/evidence-issue-8-vi-ui.md @@ -0,0 +1,44 @@ +# Evidence: Vietnamese UI strings toggle (#8) + +## What + +EN ↔ VI chrome UI toggle for BeeAR web try-on (2D camera page + 3D studio). + +## How to verify + +```bash +# unit tests +node --test packages/web/tests/i18n.test.mjs + +# static demo +node scripts/build-pages.mjs --no-minify +npx --yes serve site -p 4173 +# open http://127.0.0.1:4173/ +# click the VI button → chrome strings switch to Vietnamese +# click EN → back to English +# reload → language persists via localStorage key `beear_lang` +``` + +## Acceptance mapping + +| Requirement | Implementation | +| --- | --- | +| EN/VI chrome strings | `packages/web/i18n.mjs` + `packages/web/assets/i18n.js` | +| Toggle button | `#btn-lang` on `index.html` and `studio3d.html` | +| Persist language | `localStorage['beear_lang']` via `BeeARI18n.loadLang/saveLang` | +| Filter labels | All / Glasses / Accessories translated | +| Empty states | Catalog empty + gallery empty translated | +| 3D studio parity | Tagline, person, frames, auto-rotate, snapshot, filters | +| Tests | `packages/web/tests/i18n.test.mjs` (CI job `web-i18n`) | + +## Screenshots + +- Existing VI chrome shot: `docs/screenshots/demo-vi-ui.png` +- After toggle (EN header labels): Catalog / Compare / Snapshot / Camera +- After toggle (VI header labels): Danh mục / So sánh / Chụp / Máy ảnh + +## Risk notes + +- Language pack is chrome UI only (SKU names remain catalog data). +- No network calls; pure client-side `localStorage`. +- Private mode: load/save lang fails soft and defaults to English. diff --git a/packages/android/beear-webview/src/main/assets/beear/assets/i18n.js b/packages/android/beear-webview/src/main/assets/beear/assets/i18n.js new file mode 100644 index 0000000..9b10524 --- /dev/null +++ b/packages/android/beear-webview/src/main/assets/beear/assets/i18n.js @@ -0,0 +1,154 @@ +/** + * BeeAR chrome UI strings (EN / VI) — browser IIFE. + * Keep in sync with packages/web/i18n.mjs (unit tests). + */ +(function (g) { + const I18N = { + en: { + tagline: "Virtual try-on · glasses & accessories", + catalog: "Catalog", + pd: "PD (mm)", + pdHint: "Calibrate pupil distance for better fit scale", + select: "Select a SKU", + cam: "📷 Camera", + demo: "Demo photo", + demoNext: "Next face", + compare: "Compare", + snap: "Snapshot", + gallery: "Gallery", + person3d: "🧑 3D Person", + consentTitle: "Camera consent", + consentBody: + "BeeAR uses the camera only in your browser for try-on. Video is not uploaded to the server.", + consentOk: "Allow & start", + consentDemo: "Use demo photo", + hintIdle: "Start camera or use Demo photo · pick a frame", + hintDemo: "Photoreal demo face · PD slider · Compare A/B · Next face", + hintCam: "Camera live", + trackGeo: "tracking: geometric", + trackMp: "tracking: mediapipe", + trackDemo: "tracking: demo photo", + cloudLocal: "🔒 Local Only", + cloudServer: "☁️ Cloud Enabled", + photoConsentLabel: "Allow face photo server upload & cloud features", + filterAll: "All", + filterGlasses: "Glasses", + filterAccessory: "Accessories", + emptyFilter: "No frames match the current filter.", + emptyFilterHint: "Try adjusting filters or check your connection.", + emptyGallery: "No snapshots yet", + loadingCatalog: "Loading catalog…", + studioTagline: "Real 3D characters · glasses GLB try-on", + studioBack: "← 2D / Camera", + autoRotate: "Auto-rotate", + autoRotateOn: "Auto-rotate ON", + autoRotateOff: "Auto-rotate OFF", + person: "Person", + character: "Character", + frames3d: "Frames (3D)", + fitScale: "Fit scale", + yOffset: "Y offset", + zDepth: "Z (depth)", + loadingStudio: "Loading 3D studio…", + hintStudio: "Drag to orbit · scroll to zoom · pick person + frames", + badgeStudio: "3D try-on · GLB glasses", + loadingPerson: "Loading 3D person…", + }, + vi: { + tagline: "Thử kính & phụ kiện ảo", + catalog: "Danh mục", + pd: "Khoảng đồng tử PD (mm)", + pdHint: "Hiệu chỉnh PD để scale kính chính xác hơn", + select: "Chọn mẫu", + cam: "📷 Máy ảnh", + demo: "Ảnh demo", + demoNext: "Đổi mặt", + compare: "So sánh", + snap: "Chụp", + gallery: "Thư viện", + person3d: "🧑 Nhân vật 3D", + consentTitle: "Đồng ý camera", + consentBody: + "BeeAR chỉ dùng camera trên trình duyệt để thử đồ. Video không gửi lên server.", + consentOk: "Cho phép & bắt đầu", + consentDemo: "Dùng ảnh demo", + hintIdle: "Bật camera hoặc ảnh demo · chọn khung", + hintDemo: "Ảnh người demo · chỉnh PD · So sánh · Đổi mặt", + hintCam: "Camera đang bật", + trackGeo: "tracking: hình học", + trackMp: "tracking: mediapipe", + trackDemo: "tracking: ảnh demo", + cloudLocal: "🔒 Chỉ lưu cục bộ", + cloudServer: "☁️ Đã bật Cloud", + photoConsentLabel: "Cho phép tải ảnh mặt lên server & dùng tính năng đám mây", + filterAll: "Tất cả", + filterGlasses: "Kính", + filterAccessory: "Phụ kiện", + emptyFilter: "Không có khung khớp bộ lọc hiện tại.", + emptyFilterHint: "Thử đổi bộ lọc hoặc kiểm tra kết nối.", + emptyGallery: "Chưa có ảnh chụp", + loadingCatalog: "Đang tải danh mục…", + studioTagline: "Nhân vật 3D thật · thử kính GLB", + studioBack: "← 2D / Camera", + autoRotate: "Tự xoay", + autoRotateOn: "Tự xoay BẬT", + autoRotateOff: "Tự xoay TẮT", + person: "Nhân vật", + character: "Mẫu", + frames3d: "Khung (3D)", + fitScale: "Tỷ lệ vừa", + yOffset: "Lệch Y", + zDepth: "Z (độ sâu)", + loadingStudio: "Đang tải studio 3D…", + hintStudio: "Kéo để xoay · cuộn để zoom · chọn người + khung", + badgeStudio: "Thử 3D · kính GLB", + loadingPerson: "Đang tải nhân vật 3D…", + }, + }; + + const LANG_KEY = "beear_lang"; + + function normalizeLang(value) { + return value === "vi" ? "vi" : "en"; + } + + function t(lang, key) { + const pack = I18N[normalizeLang(lang)] || I18N.en; + return pack[key] ?? I18N.en[key] ?? key; + } + + function toggleLang(lang) { + return normalizeLang(lang) === "en" ? "vi" : "en"; + } + + function langButtonLabel(lang) { + return normalizeLang(lang) === "en" ? "VI" : "EN"; + } + + function loadLang() { + try { + return normalizeLang(localStorage.getItem(LANG_KEY)); + } catch (_) { + return "en"; + } + } + + function saveLang(lang) { + try { + localStorage.setItem(LANG_KEY, normalizeLang(lang)); + } catch (_) { + /* private mode */ + } + } + + g.BeeARI18n = { + I18N, + LANG_KEY, + normalizeLang, + t, + toggleLang, + langButtonLabel, + loadLang, + saveLang, + }; +})(typeof globalThis !== "undefined" ? globalThis : window); diff --git a/packages/android/beear-webview/src/main/assets/beear/index.html b/packages/android/beear-webview/src/main/assets/beear/index.html index d83d0ce..b014605 100644 --- a/packages/android/beear-webview/src/main/assets/beear/index.html +++ b/packages/android/beear-webview/src/main/assets/beear/index.html @@ -72,6 +72,7 @@

Catalog

+ diff --git a/packages/web/assets/app.js b/packages/web/assets/app.js index 06f0731..9e86af4 100644 --- a/packages/web/assets/app.js +++ b/packages/web/assets/app.js @@ -23,7 +23,11 @@ let compareMode = false; let svgCache = {}; let raf = 0; let pdMm = TryOn?.DEFAULT_PD_MM || 64; -let lang = "en"; +const I18n = globalThis.BeeARI18n; +if (!I18n) { + console.error("BeeARI18n missing — load /assets/i18n.js before app.js"); +} +let lang = I18n ? I18n.loadLang() : "en"; let tracking = "geometric"; // geometric | mediapipe let faceMesh = null; let meshBusy = false; @@ -113,93 +117,70 @@ let demoFaceIndex = 0; let demoImages = {}; // id -> HTMLImageElement let demoReady = false; -const I18N = { - en: { - tagline: "Virtual try-on · glasses & accessories", - catalog: "Catalog", - pd: "PD (mm)", - pdHint: "Calibrate pupil distance for better fit scale", - select: "Select a SKU", - cam: "📷 Camera", - demo: "Demo photo", - demoNext: "Next face", - compare: "Compare", - snap: "Snapshot", - gallery: "Gallery", - consentTitle: "Camera consent", - consentBody: - "BeeAR uses the camera only in your browser for try-on. Video is not uploaded to the server.", - consentOk: "Allow & start", - consentDemo: "Use demo photo", - hintIdle: "Start camera or use Demo photo · pick a frame", - hintDemo: "Photoreal demo face · PD slider · Compare A/B · Next face", - hintCam: "Camera live", - trackGeo: "tracking: geometric", - trackMp: "tracking: mediapipe", - trackDemo: "tracking: demo photo", - cloudLocal: "🔒 Local Only", - cloudServer: "☁️ Cloud Enabled", - photoConsentLabel: "Allow face photo server upload & cloud features", - }, - vi: { - tagline: "Thử kính & phụ kiện ảo", - catalog: "Danh mục", - pd: "Khoảng đồng tử PD (mm)", - pdHint: "Hiệu chỉnh PD để scale kính chính xác hơn", - select: "Chọn mẫu", - cam: "📷 Camera", - demo: "Ảnh demo", - demoNext: "Đổi mặt", - compare: "So sánh", - snap: "Chụp", - gallery: "Thư viện", - consentTitle: "Đồng ý camera", - consentBody: - "BeeAR chỉ dùng camera trên trình duyệt để thử đồ. Video không gửi lên server.", - consentOk: "Cho phép & bắt đầu", - consentDemo: "Dùng ảnh demo", - hintIdle: "Bật camera hoặc ảnh demo · chọn khung", - hintDemo: "Ảnh người demo · chỉnh PD · So sánh · Đổi mặt", - hintCam: "Camera đang bật", - trackGeo: "tracking: hình học", - trackMp: "tracking: mediapipe", - trackDemo: "tracking: ảnh demo", - cloudLocal: "🔒 Chỉ lưu cục bộ", - cloudServer: "☁️ Đã bật Cloud", - photoConsentLabel: "Cho phép tải ảnh mặt lên server & dùng tính năng đám mây", - }, -}; - function t(k) { - return (I18N[lang] || I18N.en)[k] || k; + if (I18n) return I18n.t(lang, k); + return k; } function applyLang() { - document.getElementById("tagline").textContent = t("tagline"); - document.getElementById("lbl-catalog").textContent = t("catalog"); - document.getElementById("lbl-pd").childNodes[0].textContent = t("pd") + " "; - document.getElementById("pd-hint").textContent = t("pdHint"); - document.getElementById("btn-cam").textContent = t("cam"); - document.getElementById("btn-demo").textContent = t("demo"); - const btnNext = document.getElementById("btn-demo-next"); - if (btnNext) btnNext.textContent = t("demoNext"); - document.getElementById("btn-compare").textContent = t("compare"); - document.getElementById("btn-snap").textContent = t("snap"); - document.getElementById("btn-gallery").textContent = t("gallery"); - document.getElementById("btn-lang").textContent = lang === "en" ? "VI" : "EN"; - document.getElementById("consent-title").textContent = t("consentTitle"); - document.getElementById("consent-body").textContent = t("consentBody"); - document.getElementById("consent-ok").textContent = t("consentOk"); - document.getElementById("consent-demo").textContent = t("consentDemo"); - + document.documentElement.lang = lang === "vi" ? "vi" : "en"; + if (I18n) I18n.saveLang(lang); + + const setText = (id, key) => { + const el = document.getElementById(id); + if (el) el.textContent = t(key); + }; + + setText("tagline", "tagline"); + setText("lbl-catalog", "catalog"); + const lblPd = document.getElementById("lbl-pd"); + if (lblPd && lblPd.childNodes[0]) { + lblPd.childNodes[0].textContent = t("pd") + " "; + } + setText("pd-hint", "pdHint"); + setText("btn-cam", "cam"); + setText("btn-demo", "demo"); + setText("btn-demo-next", "demoNext"); + setText("btn-compare", "compare"); + setText("btn-snap", "snap"); + setText("btn-gallery", "gallery"); + setText("btn-3d", "person3d"); + const btnLang = document.getElementById("btn-lang"); + if (btnLang) { + btnLang.textContent = I18n ? I18n.langButtonLabel(lang) : lang === "en" ? "VI" : "EN"; + btnLang.setAttribute("aria-label", lang === "en" ? "Switch to Vietnamese" : "Chuyển sang tiếng Anh"); + btnLang.setAttribute("title", btnLang.getAttribute("aria-label")); + } + setText("consent-title", "consentTitle"); + setText("consent-body", "consentBody"); + setText("consent-ok", "consentOk"); + setText("consent-demo", "consentDemo"); + + // Filter