Compare commits

...

16 Commits

Author SHA1 Message Date
tuyenio 59563db3b4 merge: tối ưu hiệu năng web (hero/render-gate, nén ảnh+model, LRU, CSS, bundle) 2026-06-25 09:43:45 +07:00
tuyenio 9e3f98d3dd chore: dọn @keyframes không dùng + cập nhật comment .webp
- Xoá 8 @keyframes orphaned không được dùng:
  public-ambient-pan, public-back-to-top-float, public-cta-breathe,
  public-gradient-flow, public-header-glint, public-logo-breathe,
  public-photo-breathe, public-stage-float
- Giữ lại 7 @keyframes vẫn được dùng:
  public-section-reveal, public-header-drop, public-rise,
  public-stagger-reveal, public-line-draw, public-sheen, heritage-marquee
- Cập nhật comment trong chua-thai-lac.ts để phản ánh .webp thay cho .jpg

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:40:55 +07:00
tuyenio a0c202546e perf: dynamic import viewer three.js (tách khỏi bundle chính)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:31:52 +07:00
tuyenio 2bd2a42dcf perf: bật tối ưu ảnh AVIF/WebP + deviceSizes cho next/image 2026-06-25 09:28:21 +07:00
tuyenio 9b7ce49f54 perf: giảm backdrop-blur và bỏ will-change luôn-bật trên panel/card 2026-06-25 09:25:26 +07:00
tuyenio 8a0635944a perf: tắt animation CSS vô hạn ít giá trị (ambient-pan, gradient-flow, breathe, glint)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:22:47 +07:00
tuyenio d58760a63b perf: nén model .glb (meshopt + texture webp), backup bản gốc model-originals/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:18:22 +07:00
tuyenio da8bbb7641 perf: giới hạn cache panorama (LRU) + ghim cảnh hiện tại/lân cận, tránh tràn VRAM 2026-06-25 09:12:06 +07:00
tuyenio dc14bc5197 perf: tour nạp panorama WebP thay cho JPEG 2026-06-25 09:09:05 +07:00
tuyenio 7ac7d86570 perf: webp panorama q72 + effort 6 (giảm dung lượng tải)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:05:59 +07:00
tuyenio 5e9e632345 perf: nén panorama 4096px + xuất WebP, LQIP theo path webp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:01:13 +07:00
tuyenio c96b6f195c perf: AntiquityViewer render theo nhu cau + gate ngoai man hinh + pixelRatio 1.5 + meshopt 2026-06-25 08:51:40 +07:00
tuyenio e0117ab4bd perf: hero bỏ canvas three.js + còn 1 shader, gate theo tầm nhìn (giảm 3 ngữ cảnh WebGL) 2026-06-25 08:47:36 +07:00
tuyenio cea9efab99 perf: tiện ích render-gate (dừng vòng lặp vẽ khi ngoài màn hình/đổi tab) 2026-06-25 08:44:24 +07:00
tuyenio ddef1f56bb docs: kế hoạch triển khai tối ưu hiệu năng (12 task, 4 giai đoạn)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:40:42 +07:00
tuyenio 707eb0f5c1 docs: spec tối ưu hiệu năng web (phân tích nguyên nhân + phương án theo giai đoạn)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:32:35 +07:00
74 changed files with 3186 additions and 300 deletions
+3
View File
@@ -44,3 +44,6 @@ next-env.d.ts
# ảnh gốc trước khi nén (backup cục bộ)
/image-originals
# model .glb gốc trước khi nén (backup cục bộ)
/model-originals
@@ -0,0 +1,924 @@
# Kế hoạch triển khai: Tối ưu hiệu năng web phy-vr360
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Giảm tải GPU/CPU để web hết giật/lag, laptop không còn nóng/quạt rú, vẫn giữ thẩm mỹ.
**Architecture:** Tối ưu theo 4 giai đoạn — (1) chặn vòng lặp vẽ chạy nền vô ích, (2) nén tài nguyên ảnh/model + giới hạn cache VRAM, (3) cắt animation CSS luôn-bật & hiệu ứng nặng, (4) cấu hình build + chia nhỏ bundle. Mỗi giai đoạn đo lại trước khi sang bước kế.
**Tech Stack:** Next.js 16 (bản đã chỉnh sửa), React 19, three.js 0.184, framer-motion, @paper-design/shaders-react, sharp (đã cài), @gltf-transform/cli (sẽ cài).
**Spec:** `docs/superpowers/specs/2026-06-25-toi-uu-hieu-nang-design.md`
---
## Lưu ý chung cho người triển khai
- **Next.js 16 ở đây là bản đã chỉnh sửa** (`AGENTS.md`). Trước khi sửa `next.config.ts` hoặc dùng `next/dynamic`, **đọc** tài liệu tương ứng trong `node_modules/next/dist/docs/` để dùng đúng API.
- **Không có test tự động** cho hiệu năng. "Kiểm chứng" ở đây = `npm run lint` + `npm run build` pass, **xem bằng mắt** không vỡ giao diện, và **đo** (DevTools Performance/Rendering, kích thước file). Mỗi task nêu rõ cách đo.
- Làm việc trên nhánh `main` (đúng quy ước hiện tại của dự án). Commit thường xuyên, mỗi task một commit.
- Mở DevTools → **Rendering → "Frame Rendering Stats"** và tab **Performance** để so trước/sau. Đếm ngữ cảnh WebGL bằng `about:gpu` hoặc đếm thẻ `<canvas>` đang vẽ.
---
## File Structure
| File | Trách nhiệm | Hành động |
|---|---|---|
| `src/app/lib/render-gate.ts` | Tiện ích chung: bật/tắt vòng lặp vẽ theo khả năng nhìn + trạng thái tab | Create |
| `src/app/components/ui/hero-shader-background.tsx` | Nền hero: bỏ canvas three.js, còn 1 shader, gate theo tầm nhìn | Modify |
| `src/app/components/ui/hero-panorama-background.tsx` | Canvas three.js xoay (gỡ khỏi hero) | Delete (hoặc bỏ dùng) |
| `src/app/components/antiquity/AntiquityViewer.tsx` | Viewer .glb: render theo nhu cầu + gate + giảm pixelRatio + meshopt | Modify |
| `scripts/optimize-images.mjs` | Nén panorama: 4096px + xuất WebP, sinh LQIP theo path webp | Modify |
| `scripts/optimize-models.mjs` | Nén .glb (meshopt + texture webp), backup bản gốc | Create |
| `src/app/tours/chua-thai-lac.ts` | Trỏ ảnh tour sang `.webp` | Modify |
| `src/app/components/VirtualTour.tsx` | Cache texture có giới hạn (LRU) + pin cảnh hiện tại/lân cận | Modify |
| `src/app/globals.css` | Tắt animation vô hạn ít giá trị, giảm blur/will-change | Modify |
| `src/app/layout.tsx` | (Tùy chọn) tinh giản lớp grain | Modify |
| `next.config.ts` | Bật tối ưu ảnh (AVIF/WebP) | Modify |
| `src/app/tour/chua-thai-lac/page.tsx` | Dynamic import viewer | Modify |
| `src/app/co-vat/page.tsx` | Dynamic import showcase | Modify |
| `package.json` | Thêm script `optimize:images`, `optimize:models` | Modify |
---
# GIAI ĐOẠN 1 — Chặn việc chạy nền vô ích
## Task 1: Tiện ích render-gate dùng chung
**Files:**
- Create: `src/app/lib/render-gate.ts`
- [ ] **Step 1: Tạo file tiện ích**
```ts
// src/app/lib/render-gate.ts
// Chỉ cho phép vòng lặp vẽ (rAF/setAnimationLoop) chạy khi phần tử nằm trong vùng nhìn
// VÀ tab đang hiển thị. Gọi onChange(active) mỗi khi trạng thái đổi. Trả về hàm dọn dẹp.
export function createRenderGate(
element: Element,
onChange: (active: boolean) => void,
): () => void {
let onScreen = false;
let visible = typeof document === "undefined" ? true : !document.hidden;
let last = false;
const emit = () => {
const next = onScreen && visible;
if (next !== last) {
last = next;
onChange(next);
}
};
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((entry) => entry.isIntersecting);
emit();
},
{ threshold: 0.01 },
);
io.observe(element);
const onVisibility = () => {
visible = !document.hidden;
emit();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}
```
- [ ] **Step 2: Kiểm chứng biên dịch**
Run: `npm run lint`
Expected: không lỗi mới ở file vừa tạo.
- [ ] **Step 3: Commit**
```bash
git add src/app/lib/render-gate.ts
git commit -m "perf: tiện ích render-gate (dừng vòng lặp vẽ khi ngoài màn hình/đổi tab)"
```
---
## Task 2: Hero — bỏ canvas three.js, còn 1 shader, gate theo tầm nhìn
**Files:**
- Modify: `src/app/components/ui/hero-shader-background.tsx`
- Delete: `src/app/components/ui/hero-panorama-background.tsx`
Hiện hero chồng: ảnh nền CSS + `HeroPanoramaBackground` (canvas three.js xoay liên tục) + 2× `MeshGradient` + `mix-blend-mode: soft-light`. Sau task: ảnh nền CSS giữ nguyên (đã đủ "nền"), **bỏ canvas three.js**, **còn 1 `MeshGradient`**, bỏ `soft-light`, và **chỉ render shader khi hero trong tầm nhìn**.
- [ ] **Step 1: Viết lại `hero-shader-background.tsx`**
```tsx
"use client";
import { MeshGradient } from "@paper-design/shaders-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { createRenderGate } from "@/app/lib/render-gate";
interface HeroShaderBackgroundProps {
children: React.ReactNode;
imageSrc: string;
}
export function HeroShaderBackground({ children, imageSrc }: HeroShaderBackgroundProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [shaderActive, setShaderActive] = useState(false);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
return createRenderGate(el, setShaderActive);
}, []);
return (
<div
ref={rootRef}
className="relative isolate overflow-hidden border-b border-[var(--surface-border)]"
>
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center opacity-[0.58] saturate-[1.12] contrast-[1.04]"
style={{ backgroundImage: `url("${imageSrc}")` }}
/>
<div aria-hidden className="absolute inset-0 bg-[rgb(243_247_240_/_0.12)]" />
{/* Một lớp shader duy nhất, chỉ vẽ khi hero trong tầm nhìn + tab hiển thị */}
{shaderActive ? (
<div aria-hidden className="absolute inset-0 overflow-hidden opacity-40">
<MeshGradient
className="absolute inset-0 h-full w-full"
colors={["#f8fbf2", "#d6b468", "#e0eddf", "#245f4b", "#ffffff"]}
speed={0.24}
/>
</div>
) : null}
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(90deg,rgb(243_247_240_/_0.93)_0%,rgb(243_247_240_/_0.72)_42%,rgb(243_247_240_/_0.14)_100%)]"
/>
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-44 bg-[linear-gradient(180deg,transparent,rgb(243_247_240_/_0.98))]"
/>
{children}
</div>
);
}
```
> Ghi chú: đã bỏ `HeroPanoramaBackground`, bỏ `<svg>` filter `feTurbulence`, bỏ `MeshGradient` thứ 2 và `mix-blend-mode: soft-light`. Ảnh nền tĩnh (`imageSrc`, vốn là `01.jpg`) vẫn cho cảm giác panorama.
- [ ] **Step 2: Xóa component không còn dùng**
```bash
git rm src/app/components/ui/hero-panorama-background.tsx
```
- [ ] **Step 3: Kiểm chứng không còn tham chiếu**
Run: `grep -rn "hero-panorama-background\|HeroPanoramaBackground" src/`
Expected: không còn kết quả.
- [ ] **Step 4: Build + xem bằng mắt**
Run: `npm run build`
Expected: build pass. Chạy `npm run dev`, mở `/` — hero vẫn đẹp; mở DevTools → chỉ còn **01** canvas WebGL ở hero (trước đây 3 lớp). Cuộn xuống dưới: shader ngừng vẽ (kiểm tra Performance không còn frame liên tục từ hero).
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "perf: hero bỏ canvas three.js + còn 1 shader, gate theo tầm nhìn (giảm 3 ngữ cảnh WebGL)"
```
---
## Task 3: AntiquityViewer — render theo nhu cầu + gate + giảm pixelRatio
**Files:**
- Modify: `src/app/components/antiquity/AntiquityViewer.tsx`
Hiện `tick()` render **mọi frame vô điều kiện** (kể cả khi đứng yên, autoRotate tắt) và pixelRatio tới 2. Chuyển sang render-on-demand như tour viewer + gate khi ngoài màn hình + pixelRatio tối đa 1.5.
- [ ] **Step 1: Thêm import**
Sửa đầu file, thêm 2 import (sau dòng `import { RoomEnvironment } ...`):
```ts
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { createRenderGate } from "@/app/lib/render-gate";
```
- [ ] **Step 2: Giảm pixelRatio**
Sửa `AntiquityViewer.tsx` dòng pixelRatio:
```ts
// CŨ:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// MỚI:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
```
- [ ] **Step 3: Nạp meshopt decoder cho GLTFLoader**
Sửa chỗ tạo loader:
```ts
// CŨ:
const loader = new GLTFLoader();
// MỚI:
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
```
- [ ] **Step 4: Render theo nhu cầu + gate**
Thay khối từ `const resize = () => {` (giữ nguyên `resize`) và đặc biệt thay **vòng `tick`** + thêm gate. Cụ thể, thay đoạn:
```ts
const tick = () => {
controls.update();
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
};
tick();
```
bằng:
```ts
let needsRender = true;
let active = true;
const requestRender = () => {
needsRender = true;
};
controls.addEventListener("change", requestRender);
const stopGate = createRenderGate(mount, (isActive) => {
active = isActive;
if (isActive) requestRender();
});
const tick = () => {
raf = requestAnimationFrame(tick);
if (!active) return;
// controls.update() trả về true khi camera còn chuyển động (damping/autoRotate)
const moving = controls.update();
if (needsRender || moving || controls.autoRotate) {
renderer.render(scene, camera);
needsRender = false;
}
};
tick();
```
Thêm `requestRender()` sau khi model load xong (trong callback `loader.load` success, ngay sau `setStatus("ready");`):
```ts
setStatus("ready");
requestRender();
```
Trong `resize`, ép vẽ lại — sửa cuối hàm `resize`:
```ts
const resize = () => {
const w = mount.clientWidth;
const h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
needsRender = true;
};
```
> `needsRender` được khai báo sau `resize` trong code hiện tại — chuyển khai báo `let needsRender = true;` lên **trước** hàm `resize` (ngay sau dòng tạo `controls`/`controlsRef.current = controls;`) để `resize` dùng được. Đảm bảo thứ tự: khai báo `needsRender`/`active`/`requestRender` trước cả `resize` và `tick`.
- [ ] **Step 5: Dọn dẹp trong cleanup**
Trong hàm cleanup (return của useEffect), thêm trước `controls.dispose();`:
```ts
stopGate();
controls.removeEventListener("change", requestRender);
```
- [ ] **Step 6: Build + đo**
Run: `npm run build`
Expected: pass. Chạy dev, mở `/co-vat`:
- Khi **tắt tự xoay** và không tương tác → DevTools Performance cho thấy **không còn render liên tục** (đường GPU phẳng).
- Cuộn để viewer ra khỏi màn hình → vòng lặp dừng.
- Bật tự xoay vẫn mượt.
- [ ] **Step 7: Commit**
```bash
git add src/app/components/antiquity/AntiquityViewer.tsx
git commit -m "perf: AntiquityViewer render theo nhu cầu + gate ngoài màn hình + pixelRatio 1.5 + meshopt"
```
---
# GIAI ĐOẠN 2 — Pipeline tài nguyên
## Task 4: Mở rộng script nén ảnh — 4096px + xuất WebP
**Files:**
- Modify: `scripts/optimize-images.mjs`
- Modify: `package.json`
- Sinh ra: `public/images/chua-thai-lac/NN.webp` (×26) + cập nhật `src/app/tours/chua-thai-lac.lqip.ts`
Script hiện nén panorama **in-place JPEG ở 6144px** (vì vậy vẫn 24MB) và sinh LQIP theo path `.jpg`. Đổi sang: resize **4096px**, **xuất thêm WebP**, và **khóa LQIP theo path `.webp`** (vì tour sẽ nạp `.webp`). Vẫn giữ nén `.jpg` (cho các `<Image>` next/image ở trang landing đang dùng `img("NN.jpg")`).
- [ ] **Step 1: Sửa hằng số**
```js
// CŨ:
const PANO_WIDTH = 6144;
// MỚI:
const PANO_WIDTH = 4096;
const PANO_WEBP_QUALITY = 78;
```
- [ ] **Step 2: Trong `optimizePanoramas()` — xuất thêm WebP và khóa LQIP theo `.webp`**
Thay thân vòng `for (const f of files) { ... }` bằng:
```js
for (const f of files) {
const dest = path.join(PANO_DIR, f);
const src = await sourceFrom(dest, "chua-thai-lac");
const before = (await fs.stat(dest)).size;
// JPEG (giữ cho next/image ở trang landing) — cũng hạ về 4096
const jpgBuf = await sharp(src)
.rotate()
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
.jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
.toBuffer();
await fs.writeFile(dest, jpgBuf);
// WebP (tour viewer nạp bản này) — cùng tên, đuôi .webp
const webpName = f.replace(/\.jpe?g$/i, ".webp");
const webpDest = path.join(PANO_DIR, webpName);
const webpBuf = await sharp(src)
.rotate()
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
.webp({ quality: PANO_WEBP_QUALITY })
.toBuffer();
await fs.writeFile(webpDest, webpBuf);
// LQIP khóa theo path .webp (đúng path tour yêu cầu)
const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
lqip["/images/chua-thai-lac/" + webpName] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
console.log(
`${f}: ${fmt(before)} -> jpg ${fmt(jpgBuf.length)} | webp ${fmt(webpBuf.length)}`,
);
}
```
- [ ] **Step 3: Thêm npm script**
Trong `package.json`, mục `scripts`, thêm:
```json
"optimize:images": "node scripts/optimize-images.mjs",
```
- [ ] **Step 4: Chạy script (an toàn — luôn nén từ backup gốc trong `image-originals/`)**
Run: `npm run optimize:images`
Expected: in ra mỗi ảnh `… -> jpg ~0.61.0MB | webp ~0.250.5MB`; cuối cùng `Wrote src/app/tours/chua-thai-lac.lqip.ts (26 entries)`.
- [ ] **Step 5: Đo tổng dung lượng giảm**
Run (PowerShell):
```powershell
"{0:N1} MB" -f ((Get-ChildItem public/images/chua-thai-lac/*.webp | Measure-Object Length -Sum).Sum/1MB)
```
Expected: tổng WebP ~712MB (so với ~70MB JPEG 6144 trước đây).
- [ ] **Step 6: Commit**
```bash
git add scripts/optimize-images.mjs package.json public/images/chua-thai-lac/ src/app/tours/chua-thai-lac.lqip.ts
git commit -m "perf: nén panorama 4096px + xuất WebP, LQIP theo path webp"
```
---
## Task 5: Trỏ tour sang ảnh WebP
**Files:**
- Modify: `src/app/tours/chua-thai-lac.ts:12`
Đổi **một dòng** `imageForFile` để mọi `scene.image`, `welcomeBackgroundImage`, `welcomeCards[].image` tự động dùng `.webp` (đều derive từ hàm này).
- [ ] **Step 1: Sửa `imageForFile`**
```ts
// CŨ:
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.jpg`;
// MỚI:
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.webp`;
```
- [ ] **Step 2: Build + xem tour**
Run: `npm run build`
Expected: pass. Chạy dev, mở `/tour/chua-thai-lac` → ảnh 360 hiển thị bình thường (giờ tải bản webp nhẹ), blur-up LQIP vẫn hiện (vì LQIP đã khóa theo `.webp`). Mạng (Network tab) tải `.webp` ~0.3MB thay vì `.jpg` vài MB.
- [ ] **Step 3: Commit**
```bash
git add src/app/tours/chua-thai-lac.ts
git commit -m "perf: tour nạp panorama WebP thay cho JPEG"
```
---
## Task 6: Cache texture có giới hạn (LRU) trong VirtualTour
**Files:**
- Modify: `src/app/components/VirtualTour.tsx` (vùng cache module-level ~dòng 214285 và hàm `preloadTourScene` ~dòng 1312)
Hiện `panoramaTextureCache` giữ **toàn bộ** texture vĩnh viễn → tràn VRAM. Thêm giới hạn LRU + "ghim" cảnh hiện tại/lân cận để không bao giờ dispose texture đang dùng.
- [ ] **Step 1: Thêm hằng số + tiện ích cache (ngay sau khai báo `panoramaTextureCache`, gần dòng 214)**
```ts
const MAX_CACHED_PANORAMAS = 8; // đủ cho cảnh hiện tại + các cảnh lân cận
const pinnedPanoramaUrls = new Set<string>();
// Ghim các ảnh không được phép dispose (cảnh hiện tại + lân cận).
function setPinnedPanoramas(urls: string[]) {
pinnedPanoramaUrls.clear();
urls.forEach((url) => pinnedPanoramaUrls.add(url));
}
// Đánh dấu ảnh là mới-dùng-nhất (Map giữ thứ tự chèn) rồi dọn nếu vượt giới hạn.
function touchPanoramaCache(image: string, texture: THREE.Texture) {
panoramaTextureCache.delete(image);
panoramaTextureCache.set(image, texture);
evictPanoramaTextures();
}
function evictPanoramaTextures() {
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
return;
}
for (const [url, texture] of panoramaTextureCache) {
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
break;
}
if (pinnedPanoramaUrls.has(url)) {
continue; // không dispose ảnh đang ghim (đang hiển thị/lân cận)
}
panoramaTextureCache.delete(url);
texture.dispose();
}
}
```
- [ ] **Step 2: Gọi `touchPanoramaCache` trong `loadPanoramaTexture`**
Trong `loadPanoramaTexture`, nhánh cache-hit (đầu hàm):
```ts
// CŨ:
if (cachedTexture) {
onProgress?.(100);
return Promise.resolve(cachedTexture);
}
// MỚI:
if (cachedTexture) {
touchPanoramaCache(image, cachedTexture);
onProgress?.(100);
return Promise.resolve(cachedTexture);
}
```
Và trong callback load thành công:
```ts
// CŨ:
panoramaTextureCache.set(image, texture);
panoramaTexturePromises.delete(image);
// MỚI:
panoramaTextureCache.set(image, texture);
evictPanoramaTextures();
panoramaTexturePromises.delete(image);
```
- [ ] **Step 3: Ghim cảnh hiện tại + lân cận trong `preloadTourScene`**
Hàm `preloadTourScene` (≈ dòng 1312) đã liệt kê cảnh hiện tại + các cảnh đích của hotspot. Thêm ghim ở **đầu** hàm, trước khi preload:
```ts
const preloadTourScene = useCallback(
(scene: TourScene) => {
// Ghim ảnh đang dùng + các cảnh lân cận để LRU không dispose nhầm.
const neighborImages = scene.hotspots
.map((hotspot) => tourConfig.sceneById.get(hotspot.targetId)?.image)
.filter((image): image is string => Boolean(image));
setPinnedPanoramas([scene.image, ...neighborImages]);
const preloadImage = (image: string) => {
loadPanoramaTexture(image)
.then((texture) => warmPanoramaTexture(image, texture))
.catch(() => undefined);
};
// ... phần còn lại giữ nguyên
```
- [ ] **Step 4: Build + đo VRAM/độ mượt khi chuyển nhiều cảnh**
Run: `npm run build`
Expected: pass. Chạy dev, đi qua **nhiều cảnh** (>10) trong tour. Trước đây mọi texture tích lại; giờ DevTools → Memory (hoặc `about:gpu`) cho thấy số texture giữ lại ổn định (~8). Không có nhấp nháy/blank khi quay lại cảnh trước.
- [ ] **Step 5: Commit**
```bash
git add src/app/components/VirtualTour.tsx
git commit -m "perf: giới hạn cache panorama (LRU) + ghim cảnh hiện tại/lân cận, tránh tràn VRAM"
```
---
## Task 7: Nén model .glb (meshopt + texture WebP)
**Files:**
- Create: `scripts/optimize-models.mjs`
- Modify: `package.json`
- Sinh ra: `public/antiquity/chuong-1.glb`, `chuong-2.glb` (ghi đè bản nén; gốc lưu ở `model-originals/`)
`chuong-2.glb` 40MB, `chuong-1.glb` 27MB → đơ khi parse. Nén bằng `@gltf-transform/cli` (meshopt cho geometry + nén texture webp). Decoder meshopt đã nạp ở Task 3.
- [ ] **Step 1: Cài công cụ**
Run: `npm i -D @gltf-transform/cli`
Expected: cài xong, có `node_modules/.bin/gltf-transform`.
- [ ] **Step 2: Tạo script nén model**
```js
// scripts/optimize-models.mjs
// Nén .glb: backup bản gốc rồi tối ưu (meshopt + nén texture webp).
// Chạy: node scripts/optimize-models.mjs (an toàn chạy lại — luôn nén từ backup gốc)
import { promises as fs } from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const ROOT = process.cwd();
const MODEL_DIR = path.join(ROOT, "public/antiquity");
const BACKUP_DIR = path.join(ROOT, "model-originals");
const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
const exists = (p) => fs.access(p).then(() => true).catch(() => false);
async function optimizeModel(file) {
const dest = path.join(MODEL_DIR, file);
const backup = path.join(BACKUP_DIR, file);
await fs.mkdir(BACKUP_DIR, { recursive: true });
if (!(await exists(backup))) {
await fs.copyFile(dest, backup);
}
const before = (await fs.stat(dest)).size;
// Luôn tối ưu TỪ backup gốc; ghi đè ra dest.
const bin = path.join(ROOT, "node_modules/.bin/gltf-transform");
await run(bin, [
"optimize",
backup,
dest,
"--compress", "meshopt",
"--texture-compress", "webp",
]);
const after = (await fs.stat(dest)).size;
console.log(`${file}: ${fmt(before)} -> ${fmt(after)} (-${Math.round((1 - after / before) * 100)}%)`);
}
try {
const files = (await fs.readdir(MODEL_DIR)).filter((f) => /\.glb$/i.test(f)).sort();
for (const f of files) {
await optimizeModel(f);
}
console.log("Done.");
} catch (err) {
console.error("optimize-models failed:", err);
process.exit(1);
}
```
- [ ] **Step 3: Thêm npm script**
Trong `package.json` mục `scripts`, thêm:
```json
"optimize:models": "node scripts/optimize-models.mjs",
```
- [ ] **Step 4: Chạy nén**
Run: `npm run optimize:models`
Expected: in ra `chuong-1.glb: 27.26MB -> ~38MB (-…%)` và tương tự `chuong-2.glb`. Nếu công cụ báo lỗi về meshopt, thử `--compress draco` (decoder DRACOLoader cũng có sẵn — khi đó Task 3 đổi sang `DRACOLoader` + `loader.setDRACOLoader(...)`, đường dẫn `node_modules/three/examples/jsm/libs/draco/`).
- [ ] **Step 5: Build + xem model**
Run: `npm run build`
Expected: pass. Chạy dev, mở `/co-vat` → 2 mô hình tải **nhanh hơn hẳn**, không đơ tab khi parse; xoay/zoom đúng hình khối, hoa văn còn nét. Network tab cho thấy `.glb` nhỏ hơn nhiều.
- [ ] **Step 6: Commit**
```bash
git add scripts/optimize-models.mjs package.json package-lock.json public/antiquity/
git commit -m "perf: nén model .glb (meshopt + texture webp), backup bản gốc model-originals/"
```
---
# GIAI ĐOẠN 3 — Ngân sách hiệu ứng CSS
## Task 8: Tắt các animation vô hạn ít giá trị
**Files:**
- Modify: `src/app/globals.css`
Giữ hiệu ứng **xuất hiện-khi-cuộn** (chạy 1 lần) và marquee. Tắt các vòng lặp `infinite` chạy mãi: `public-ambient-pan`, `public-gradient-flow`, logo/CTA "breathe", header "glint".
- [ ] **Step 1: Bỏ animation nền `public-page::before`**
Trong `.public-page::before`, xóa dòng:
```css
animation: public-ambient-pan 18s ease-in-out infinite alternate;
```
- [ ] **Step 2: Gỡ animation chữ gradient (giữ gradient tĩnh)**
Trong `.public-gradient-text`, xóa dòng:
```css
animation: public-gradient-flow 8s ease-in-out infinite alternate;
```
- [ ] **Step 3: Tắt các vòng "breathe"/"glint" trong khối `@media (prefers-reduced-motion: no-preference)`**
Xóa các quy tắc gán animation **vô hạn** sau (giữ lại `public-header-drop`, `public-line-draw`, `public-section-reveal`, `public-stagger-reveal`, `public-rise` vì chỉ chạy 1 lần):
```css
.public-header::after { animation: public-header-glint 7s ease-in-out 1.2s infinite; }
.public-logo-mark { animation: public-logo-breathe 4.8s ease-in-out infinite; }
.public-header-cta,
.public-cta { animation: public-cta-breathe 4.2s ease-in-out infinite; }
.public-subpage-hero-image { animation: public-photo-breathe 12s ease-in-out infinite alternate; }
.public-hero-inner .public-image-stage { animation: public-stage-float 7.2s ease-in-out 900ms infinite alternate; }
.public-back-to-top[data-visible="true"] { animation: public-back-to-top-float 4.8s ease-in-out infinite; }
```
> Xóa **các quy tắc gán animation** ở trên (giữ lại các thuộc tính khác của chính selector đó nếu nằm ngoài khối media này — các selector này trong khối media chỉ có mỗi dòng animation, nên xóa cả khối). `@keyframes` tương ứng có thể để lại (không tốn gì nếu không dùng) hoặc xóa kèm.
- [ ] **Step 4: Build + đo lúc rảnh**
Run: `npm run build`
Expected: pass. Chạy dev, mở `/`, **không tương tác**. DevTools → Performance ghi 5s khi đứng yên: trước đây có nhiều "Recalculate Style/Composite" lặp lại do animation nền; sau khi sửa, hoạt động compositor lúc rảnh **giảm rõ rệt**. Hiệu ứng cuộn-hiện và marquee vẫn chạy.
- [ ] **Step 5: Commit**
```bash
git add src/app/globals.css
git commit -m "perf: tắt animation CSS vô hạn ít giá trị (ambient-pan, gradient-flow, breathe, glint)"
```
---
## Task 9: Giảm backdrop-filter blur & will-change
**Files:**
- Modify: `src/app/globals.css`
`backdrop-filter: blur(18px)` trên mọi `.public-panel` + `will-change: transform` (luôn-bật) rất tốn repaint/bộ nhớ layer.
- [ ] **Step 1: Giảm blur + bỏ will-change ở `.public-panel`**
```css
/* CŨ (trong .public-panel): */
backdrop-filter: blur(18px) saturate(132%);
-webkit-backdrop-filter: blur(18px) saturate(132%);
will-change: transform;
/* MỚI: */
backdrop-filter: blur(10px) saturate(120%);
-webkit-backdrop-filter: blur(10px) saturate(120%);
```
- [ ] **Step 2: Giảm blur + bỏ will-change ở `.public-card`**
```css
/* CŨ (trong .public-card): */
backdrop-filter: blur(12px) saturate(125%);
-webkit-backdrop-filter: blur(12px) saturate(125%);
will-change: transform;
/* MỚI: */
backdrop-filter: blur(8px) saturate(118%);
-webkit-backdrop-filter: blur(8px) saturate(118%);
```
- [ ] **Step 3: Build + đo cuộn**
Run: `npm run build`
Expected: pass. Chạy dev, cuộn trang `/di-tich` (nhiều card). DevTools → Rendering → bật "Paint flashing": vùng repaint khi cuộn ít/nhỏ hơn; FPS cuộn ổn định hơn. Giao diện vẫn có cảm giác kính mờ.
- [ ] **Step 4: Commit**
```bash
git add src/app/globals.css
git commit -m "perf: giảm backdrop-blur và bỏ will-change luôn-bật trên panel/card"
```
---
# GIAI ĐOẠN 4 — Cấu hình build & chia nhỏ bundle
## Task 10: Bật tối ưu ảnh trong next.config
**Files:**
- Modify: `next.config.ts`
- [ ] **Step 1: Đọc tài liệu Next 16 (bản đã sửa)**
Run: `ls node_modules/next/dist/docs/ 2>/dev/null; grep -rl "images" node_modules/next/dist/docs/ 2>/dev/null | head`
Đọc trang nói về cấu hình `images` để xác nhận khóa `formats`/`deviceSizes` đúng với bản này trước khi sửa.
- [ ] **Step 2: Cập nhật config**
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
minimumCacheTTL: 2678400, // 31 ngày
},
};
export default nextConfig;
```
> Nếu tài liệu bản này chỉ định khác (tên khóa/cấu trúc), theo tài liệu — mục tiêu: bật AVIF/WebP cho `next/image` và đặt `deviceSizes` hợp lý.
- [ ] **Step 3: Build + kiểm tra ảnh landing**
Run: `npm run build`
Expected: pass. Chạy dev/preview, mở `/` → Network tab cho thấy ảnh từ `<Image>` (vd `img("01.jpg")`) được phục vụ dạng AVIF/WebP, kích thước nhỏ theo viewport.
- [ ] **Step 4: Commit**
```bash
git add next.config.ts
git commit -m "perf: bật tối ưu ảnh AVIF/WebP + deviceSizes cho next/image"
```
---
## Task 11: Dynamic import các viewer nặng (three.js)
**Files:**
- Modify: `src/app/tour/chua-thai-lac/page.tsx`
- Modify: `src/app/co-vat/page.tsx`
Tách three.js khỏi bundle chính bằng `next/dynamic` (chỉ tải ở client, kèm fallback).
- [ ] **Step 1: Đọc tài liệu dynamic import của bản Next này**
Run: `grep -rl "next/dynamic\|dynamic" node_modules/next/dist/docs/ 2>/dev/null | head`
Xác nhận cú pháp `dynamic(() => import(...), { ssr: false })` còn đúng ở bản này.
- [ ] **Step 2: Tour page — dynamic import**
`src/app/tour/chua-thai-lac/page.tsx` thành:
```tsx
import type { Metadata } from "next";
import dynamic from "next/dynamic";
const VirtualTour = dynamic(() => import("@/app/components/VirtualTour"), {
ssr: false,
loading: () => (
<div className="grid min-h-[100dvh] place-items-center bg-[#f5f1e6] text-[#4a3f35]">
<p className="text-sm font-bold">Đang tải không gian 360°</p>
</div>
),
});
export const metadata: Metadata = {
title: "VR360 Chùa Thái Lạc (Pháp Vân tự)",
description:
"Trải nghiệm tham quan thực tế ảo 360° Chùa Thái Lạc (Pháp Vân tự), di tích quốc gia tại Xã Như Quỳnh, tỉnh Hưng Yên.",
};
export default function ChuaThaiLacTourPage() {
return <VirtualTour tourId="chua-thai-lac" />;
}
```
> Nếu bản Next này không cho `ssr: false` trong Server Component, tạo một wrapper client nhỏ (`"use client"`) bọc `dynamic(...)` và import wrapper vào page. Theo tài liệu ở Step 1.
- [ ] **Step 3: Co-vat page — dynamic import showcase**
Trong `src/app/co-vat/page.tsx`:
```tsx
// CŨ:
import { AntiquityShowcase } from "@/app/components/antiquity/AntiquityShowcase";
// MỚI:
import dynamic from "next/dynamic";
const AntiquityShowcase = dynamic(
() => import("@/app/components/antiquity/AntiquityShowcase").then((m) => m.AntiquityShowcase),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-7xl px-4 py-16 text-center text-sm text-[var(--muted-foreground)] sm:px-6">
Đang tải phòng trưng bày 3D
</div>
),
},
);
```
(Phần JSX `<AntiquityShowcase items={antiquities} />` giữ nguyên.)
- [ ] **Step 4: Build + kiểm tra bundle**
Run: `npm run build`
Expected: pass; báo cáo build cho thấy route `/` và các trang tĩnh **không** còn gánh three.js trong chunk chính (three nằm ở chunk động của `/tour/...``/co-vat`). Mở các trang → vẫn hoạt động, có fallback khi tải.
- [ ] **Step 5: Commit**
```bash
git add src/app/tour/chua-thai-lac/page.tsx src/app/co-vat/page.tsx
git commit -m "perf: dynamic import viewer three.js (tách khỏi bundle chính)"
```
---
## Task 12: Nghiệm thu tổng thể
**Files:** không sửa code — chỉ đo và ghi nhận.
- [ ] **Step 1: Đếm ngữ cảnh WebGL ở trang chủ**
Mở `/`, DevTools Console: `document.querySelectorAll('canvas').length`
Expected: **01** (trước đây 3 lớp ở hero).
- [ ] **Step 2: Đo lúc rảnh**
Trang `/` đứng yên, Performance ghi 5s.
Expected: GPU/CPU gần như phẳng khi không tương tác; không còn rAF nền từ hero.
- [ ] **Step 3: Đo cuộn**
Cuộn `/``/di-tich`.
Expected: ~60fps, không tụt khung rõ rệt; quạt không rú.
- [ ] **Step 4: Đo tải tài nguyên**
Network (disable cache) mở `/tour/chua-thai-lac``/co-vat`.
Expected: panorama `.webp` ~0.3MB/ảnh; `.glb` nhỏ hơn nhiều lần so với 2740MB.
- [ ] **Step 5: Lighthouse**
Chạy Lighthouse (Performance) cho `/` ở chế độ build production (`npm run build` + `npm run start`).
Expected: điểm Performance tăng so với trước; không lỗi giao diện.
- [ ] **Step 6: Ghi nhận kết quả** vào cuối file spec (`docs/superpowers/specs/2026-06-25-toi-uu-hieu-nang-design.md`), commit:
```bash
git add docs/superpowers/specs/2026-06-25-toi-uu-hieu-nang-design.md
git commit -m "docs: ghi nhận kết quả nghiệm thu tối ưu hiệu năng"
```
---
## Tự rà soát (đã thực hiện khi viết kế hoạch)
- **Phủ spec:** GĐ1 (hero + gate + antiquity) ✓ Task 13; GĐ2 (nén ảnh ✓T45, LRU ✓T6, nén model ✓T7); GĐ3 (animation ✓T8, blur/will-change ✓T9); GĐ4 (next.config ✓T10, dynamic import ✓T11); nghiệm thu ✓T12.
- **Không placeholder:** mọi step có code/lệnh cụ thể.
- **Nhất quán tên:** `createRenderGate` (T1) dùng đúng ở T2/T3; `setPinnedPanoramas`/`touchPanoramaCache`/`evictPanoramaTextures` (T6) nhất quán; `imageForFile` (T5) khớp khóa LQIP `.webp` (T4).
- **Rủi ro đã chặn:** LRU không dispose ảnh đang ghim; nén ảnh/model luôn từ backup gốc (chạy lại an toàn); đọc tài liệu Next 16 trước khi sửa config/dynamic.
@@ -0,0 +1,71 @@
# Thiết kế: Tối ưu hiệu năng web phy-vr360
- **Ngày:** 2026-06-25
- **Trạng thái:** Đã duyệt thiết kế, chờ lập kế hoạch triển khai
- **Chiến lược:** Hướng A — tối ưu theo giai đoạn, ưu tiên việc rẻ–lợi-cao
- **Ràng buộc người dùng:** Giữ thẩm mỹ tổng thể nhưng bỏ lớp thừa; được phép nén cả ảnh và model 3D.
---
## 1. Vấn đề
Web khi chạy làm laptop nóng/quạt rú, cuộn giật, trải nghiệm không mượt. Cảm nhận là **toàn bộ site đều nặng**, không khu trú một chỗ.
## 2. Nguyên nhân gốc (đã phân tích)
### Nghiêm trọng
1. **Trang chủ chồng 3 ngữ cảnh WebGL chạy liên tục**`hero-shader-background.tsx`:
- 2× `MeshGradient` (shader GPU paper-design) animation vô hạn 60fps.
- 1× `HeroPanoramaBackground` (`hero-panorama-background.tsx`): WebGLRenderer three.js, quả cầu xoay, `requestAnimationFrame` render **vô điều kiện**, tải ảnh panorama vài MB chỉ để làm nền mờ 58%.
- `mix-blend-mode: soft-light` + filter SVG `feTurbulence`.
- Không có `IntersectionObserver`/`visibilitychange` → chạy cả khi cuộn khỏi vùng nhìn hoặc đổi tab.
2. **Ảnh panorama nặng, không tối ưu** — 26 ảnh JPG **6144×3072**, 24MB/ảnh (tổng ~70MB), nạp thẳng làm texture three.js ở độ phân giải gốc. Cache `panoramaTextureCache` (`VirtualTour.tsx:214`) giữ **toàn bộ** texture vĩnh viễn → tràn VRAM trên máy card tích hợp → thrashing/khựng. `next.config.ts` rỗng.
3. **Model 3D `.glb` khổng lồ**`chuong-2.glb` 40MB, `chuong-1.glb` 27MB → đơ tab khi parse. `AntiquityViewer.tsx` render mọi frame vô điều kiện, pixelRatio tới 2.
### Phụ (tích lũy)
4. **CSS animation luôn-bật + hiệu ứng nặng** (`globals.css`): nền `body` chồng 4 lớp gradient + `body::after` `mix-blend-mode: multiply` full màn hình; `public-ambient-pan` 18s vô hạn; gradient-text flow; logo/CTA "breathe"; header "glint"; lớp `.site-grain` nhiễu SVG full màn hình; **`backdrop-filter: blur(18px)`** trên mọi `.public-panel`/`.public-card` + `will-change: transform` (ép tạo layer, tốn bộ nhớ).
5. **Điểm tốt sẵn có:** Tour viewer chính (`VirtualTour.tsx:1502`) đã render **theo nhu cầu** (`needsRenderRef`) — giữ và nhân rộng mô hình này.
## 3. Phạm vi
**Trong phạm vi:** trang chủ + các trang công khai, tour viewer 360, viewer cổ vật 3D, pipeline tài nguyên (ảnh + model), cấu hình build, ngân sách CSS.
**Ngoài phạm vi:** thay đổi nội dung/bố cục lớn, thêm tính năng mới, thiết kế lại giao diện.
## 4. Giải pháp theo giai đoạn
### Giai đoạn 1 — Chặn việc chạy nền vô ích *(đòn bẩy lớn nhất, rủi ro thấp)*
- **Hero:** thay `HeroPanoramaBackground` (canvas three.js xoay) bằng **ảnh tĩnh đã tối ưu** (next/image hoặc CSS background) → loại bỏ 1 ngữ cảnh WebGL + texture vài MB. Giảm **2 → 1** `MeshGradient`; bỏ `mix-blend-mode: soft-light`; gỡ filter SVG `feTurbulence` nếu không cần.
- **Gate mọi WebGL/animation nền** bằng `IntersectionObserver` + `document.visibilitychange`: ngoài màn hình hoặc đổi tab → **dừng `requestAnimationFrame`/`setAnimationLoop`**. Áp dụng cho hero (nếu còn shader) và `AntiquityViewer`.
- **AntiquityViewer:** chuyển sang **render theo nhu cầu** (theo mô hình tour viewer) — chỉ vẽ khi `controls` đổi hoặc đang tự xoay; pixelRatio tối đa **1.5**; dừng khi ngoài màn hình; đảm bảo chỉ **một** viewer hoạt động tại một thời điểm (lazy-mount, không khởi tạo tất cả `.glb` cùng lúc).
### Giai đoạn 2 — Pipeline tài nguyên
- **Ảnh panorama:** thêm script Node (`sharp`) tạo bản tối ưu:
- Resize tối đa **4096px** chiều rộng (giữ tỉ lệ 2:1), xuất **WebP** chất lượng ~80 (cân nhắc AVIF). Mục tiêu giảm 7085% dung lượng.
- Tour viewer nạp bản `.webp`; giữ LQIP blur-up sẵn có (`chua-thai-lac.lqip.ts`).
- **Model 3D:** dùng `gltf-transform` (Draco/meshopt + nén texture/resize) → 40MB ⟶ ước tính 38MB. Thêm `DRACOLoader`/meshopt decoder vào `AntiquityViewer`; lazy-load model.
- **Cache texture có giới hạn (LRU):** chỉ giữ cảnh hiện tại + lân cận, dispose phần xa → tránh tràn VRAM. Cân nhắc tắt mipmap cho ảnh cầu (giảm chi phí upload), kiểm chứng chất lượng.
### Giai đoạn 3 — Ngân sách hiệu ứng CSS
- Giảm **`backdrop-filter: blur`**: hạ bán kính hoặc thay bằng nền bán trong suốt cho phần tử phổ biến; **bỏ `will-change: transform`** ở phần tử luôn hiển thị (chỉ đặt khi cần, ví dụ lúc hover).
- Tắt bớt animation **vô hạn** ít giá trị: `public-ambient-pan`, gradient-text flow, logo/CTA "breathe", header glint, `.site-grain`. **Giữ** hiệu ứng xuất hiện-khi-cuộn (chạy 1 lần) và marquee.
- Gọn lại lớp gradient nền `body`/`public-page`.
### Giai đoạn 4 — Chia nhỏ bundle & tải
- `dynamic import` các thành phần nặng three.js (tour viewer, `AntiquityViewer`) để bundle chính nhẹ; đảm bảo `framer-motion`/`lottie-react` chỉ tải nơi cần.
- Cấu hình `next.config.ts`: bật `images` (AVIF/WebP, `deviceSizes`) và tối ưu build hợp lệ cho Next 16.
## 5. Cách đo thành công (nghiệm thu)
- **Trang chủ lúc rảnh ≈ 0% GPU** (không còn rAF chạy ngầm); cuộn ~60fps; không còn quạt rú.
- Số ngữ cảnh WebGL trên trang chủ: **3 → 01**.
- Dung lượng tải trang giảm rõ (ảnh panorama & model 3D).
- Lighthouse Performance tăng; không hồi quy giao diện nhìn thấy được.
## 6. Rủi ro & lưu ý
- **Next.js 16 bản đã chỉnh sửa** (theo `AGENTS.md`): trước khi sửa `next.config`/dynamic-import phải đọc tài liệu trong `node_modules/next/dist/docs/`.
- Nén ảnh/model có thể đổi nhẹ chất lượng → kiểm chứng bằng mắt sau mỗi bước; giữ file gốc (không ghi đè, xuất ra thư mục/định dạng mới).
- Việc gate animation phải dọn dẹp listener đúng cách để tránh rò rỉ bộ nhớ.
## 7. Nguyên tắc thực thi
- Làm tuần tự theo giai đoạn; **đo lại sau mỗi giai đoạn** trước khi sang bước kế tiếp.
- Mỗi thay đổi giữ đúng pattern/định dạng code hiện có của dự án.
+5 -1
View File
@@ -1,7 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
minimumCacheTTL: 2678400,
},
};
export default nextConfig;
+1902 -3
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"optimize:images": "node scripts/optimize-images.mjs",
"optimize:models": "node scripts/optimize-models.mjs"
},
"dependencies": {
"@paper-design/shaders-react": "^0.0.76",
@@ -19,6 +21,7 @@
"three": "^0.184.0"
},
"devDependencies": {
"@gltf-transform/cli": "^4.4.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 949 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 912 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

+20 -5
View File
@@ -10,7 +10,8 @@ const MODAL_DIR = path.join(ROOT, "public/modal");
const BACKUP_DIR = path.join(ROOT, "image-originals");
const LQIP_OUT = path.join(ROOT, "src/app/tours/chua-thai-lac.lqip.ts");
const PANO_WIDTH = 6144;
const PANO_WIDTH = 4096;
const PANO_WEBP_QUALITY = 72;
const PANO_QUALITY = 80;
const LQIP_WIDTH = 32;
const MODAL_FILES = ["modal-1.png", "modal-2.png"];
@@ -38,17 +39,31 @@ async function optimizePanoramas() {
const src = await sourceFrom(dest, "chua-thai-lac");
const before = (await fs.stat(dest)).size;
const buf = await sharp(src)
// JPEG (giữ cho next/image ở trang landing) — cũng hạ về 4096
const jpgBuf = await sharp(src)
.rotate()
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
.jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true })
.toBuffer();
await fs.writeFile(dest, buf);
await fs.writeFile(dest, jpgBuf);
// WebP (tour viewer nạp bản này) — cùng tên, đuôi .webp
const webpName = f.replace(/\.jpe?g$/i, ".webp");
const webpDest = path.join(PANO_DIR, webpName);
const webpBuf = await sharp(src)
.rotate()
.resize({ width: PANO_WIDTH, withoutEnlargement: true })
.webp({ quality: PANO_WEBP_QUALITY, effort: 6 })
.toBuffer();
await fs.writeFile(webpDest, webpBuf);
// LQIP khóa theo path .webp (đúng path tour yêu cầu)
const lqipBuf = await sharp(src).rotate().resize({ width: LQIP_WIDTH }).jpeg({ quality: 40 }).toBuffer();
lqip["/images/chua-thai-lac/" + f] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
lqip["/images/chua-thai-lac/" + webpName] = "data:image/jpeg;base64," + lqipBuf.toString("base64");
console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)} (-${Math.round((1 - buf.length / before) * 100)}%)`);
console.log(
`${f}: ${fmt(before)} -> jpg ${fmt(jpgBuf.length)} | webp ${fmt(webpBuf.length)}`,
);
}
return lqip;
}
+53
View File
@@ -0,0 +1,53 @@
// scripts/optimize-models.mjs
// Nén .glb: backup bản gốc rồi tối ưu (meshopt + nén texture webp).
// Chạy: node scripts/optimize-models.mjs (an toàn chạy lại — luôn nén từ backup gốc)
import { promises as fs } from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
const ROOT = process.cwd();
const MODEL_DIR = path.join(ROOT, "public/antiquity");
const BACKUP_DIR = path.join(ROOT, "model-originals");
const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB";
const exists = (p) => fs.access(p).then(() => true).catch(() => false);
async function optimizeModel(file) {
const dest = path.join(MODEL_DIR, file);
const backup = path.join(BACKUP_DIR, file);
await fs.mkdir(BACKUP_DIR, { recursive: true });
if (!(await exists(backup))) {
await fs.copyFile(dest, backup);
}
const before = (await fs.stat(dest)).size;
// Luôn tối ưu TỪ backup gốc; ghi đè ra dest.
const isWin = process.platform === "win32";
const bin = path.join(ROOT, "node_modules/.bin", isWin ? "gltf-transform.cmd" : "gltf-transform");
const q = (s) => (isWin ? `"${s}"` : s);
await run(
q(bin),
[
"optimize",
q(backup),
q(dest),
"--compress", "meshopt",
"--texture-compress", "webp",
],
// Trên Windows phải chạy .cmd qua shell (Node >=18 ném EINVAL nếu spawn .cmd trực tiếp).
{ shell: isWin }
);
const after = (await fs.stat(dest)).size;
console.log(`${file}: ${fmt(before)} -> ${fmt(after)} (-${Math.round((1 - after / before) * 100)}%)`);
}
try {
const files = (await fs.readdir(MODEL_DIR)).filter((f) => /\.glb$/i.test(f)).sort();
for (const f of files) {
await optimizeModel(f);
}
console.log("Done.");
} catch (err) {
console.error("optimize-models failed:", err);
process.exit(1);
}
@@ -0,0 +1,20 @@
"use client";
import dynamic from "next/dynamic";
import type { Antiquity } from "@/app/content";
const AntiquityShowcase = dynamic(
() => import("@/app/components/antiquity/AntiquityShowcase").then((m) => m.AntiquityShowcase),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-7xl px-4 py-16 text-center text-sm text-[var(--muted-foreground)] sm:px-6">
Đang tải phòng trưng bày 3D
</div>
),
},
);
export default function AntiquityShowcaseClient({ items }: { items: Antiquity[] }) {
return <AntiquityShowcase items={items} />;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Image from "next/image";
import { Boxes, ScanLine, Sparkles } from "lucide-react";
import { AntiquityShowcase } from "@/app/components/antiquity/AntiquityShowcase";
import AntiquityShowcaseClient from "./AntiquityShowcaseClient";
import { BackToTopButton } from "@/app/components/landing/BackToTopButton";
import { SiteFooter } from "@/app/components/landing/SiteFooter";
import { SiteHeader } from "@/app/components/landing/SiteHeader";
@@ -40,7 +40,7 @@ export default function AntiquityPage() {
</div>
</section>
<AntiquityShowcase items={antiquities} />
<AntiquityShowcaseClient items={antiquities} />
<section className="border-t border-[var(--surface-border)] bg-[var(--surface-band)]">
<div className="mx-auto grid max-w-7xl gap-4 px-4 py-14 sm:px-6 md:grid-cols-3 lg:py-18">
+40
View File
@@ -216,6 +216,38 @@ const panoramaTexturePromises = new Map<string, Promise<THREE.Texture>>();
const panoramaTextureProgress = new Map<string, number>();
const panoramaTextureProgressListeners = new Map<string, Set<(progress: number) => void>>();
const MAX_CACHED_PANORAMAS = 8; // đủ cho cảnh hiện tại + các cảnh lân cận
const pinnedPanoramaUrls = new Set<string>();
// Ghim các ảnh không được phép dispose (cảnh hiện tại + lân cận).
function setPinnedPanoramas(urls: string[]) {
pinnedPanoramaUrls.clear();
urls.forEach((url) => pinnedPanoramaUrls.add(url));
}
// Đánh dấu ảnh là mới-dùng-nhất (Map giữ thứ tự chèn) rồi dọn nếu vượt giới hạn.
function touchPanoramaCache(image: string, texture: THREE.Texture) {
panoramaTextureCache.delete(image);
panoramaTextureCache.set(image, texture);
evictPanoramaTextures();
}
function evictPanoramaTextures() {
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
return;
}
for (const [url, texture] of panoramaTextureCache) {
if (panoramaTextureCache.size <= MAX_CACHED_PANORAMAS) {
break;
}
if (pinnedPanoramaUrls.has(url)) {
continue; // không dispose ảnh đang ghim (đang hiển thị/lân cận)
}
panoramaTextureCache.delete(url);
texture.dispose();
}
}
function notifyPanoramaProgress(image: string, progress: number) {
panoramaTextureProgress.set(image, progress);
panoramaTextureProgressListeners.get(image)?.forEach((listener) => listener(progress));
@@ -243,6 +275,7 @@ function loadPanoramaTexture(image: string, onProgress?: (progress: number) => v
const cachedTexture = panoramaTextureCache.get(image);
if (cachedTexture) {
touchPanoramaCache(image, cachedTexture);
onProgress?.(100);
return Promise.resolve(cachedTexture);
}
@@ -263,6 +296,7 @@ function loadPanoramaTexture(image: string, onProgress?: (progress: number) => v
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 8;
panoramaTextureCache.set(image, texture);
evictPanoramaTextures();
panoramaTexturePromises.delete(image);
notifyPanoramaProgress(image, 100);
resolve(texture);
@@ -1311,6 +1345,12 @@ function TourExperience({
const preloadTourScene = useCallback(
(scene: TourScene) => {
// Ghim ảnh đang dùng + các cảnh lân cận để LRU không dispose nhầm.
const neighborImages = scene.hotspots
.map((hotspot) => tourConfig.sceneById.get(hotspot.targetId)?.image)
.filter((image): image is string => Boolean(image));
setPinnedPanoramas([scene.image, ...neighborImages]);
const preloadImage = (image: string) => {
loadPanoramaTexture(image)
.then((texture) => warmPanoramaTexture(image, texture))
@@ -5,6 +5,8 @@ import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { createRenderGate } from "@/app/lib/render-gate";
import { Rotate3d, Pause, Play, Maximize2 } from "lucide-react";
type Status = "loading" | "ready" | "error";
@@ -35,7 +37,7 @@ export function AntiquityViewer({ src }: { src: string }) {
let disposed = false;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab;";
@@ -70,6 +72,18 @@ export function AntiquityViewer({ src }: { src: string }) {
});
controlsRef.current = controls;
let needsRender = true;
let active = true;
const requestRender = () => {
needsRender = true;
};
controls.addEventListener("change", requestRender);
const stopGate = createRenderGate(mount, (isActive) => {
active = isActive;
if (isActive) requestRender();
});
const resize = () => {
const w = mount.clientWidth;
const h = mount.clientHeight;
@@ -77,12 +91,14 @@ export function AntiquityViewer({ src }: { src: string }) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
needsRender = true;
};
const observer = new ResizeObserver(resize);
observer.observe(mount);
resize();
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
loader.load(
src,
(gltf) => {
@@ -108,6 +124,7 @@ export function AntiquityViewer({ src }: { src: string }) {
homeRef.current = { pos: camera.position.clone(), target: controls.target.clone() };
setStatus("ready");
requestRender();
},
(event) => {
if (event.total) setProgress(Math.round((event.loaded / event.total) * 100));
@@ -118,9 +135,14 @@ export function AntiquityViewer({ src }: { src: string }) {
);
const tick = () => {
controls.update();
renderer.render(scene, camera);
raf = requestAnimationFrame(tick);
if (!active) return;
// controls.update() trả về true khi camera còn chuyển động (damping/autoRotate)
const moving = controls.update();
if (needsRender || moving || controls.autoRotate) {
renderer.render(scene, camera);
needsRender = false;
}
};
tick();
@@ -128,6 +150,8 @@ export function AntiquityViewer({ src }: { src: string }) {
disposed = true;
cancelAnimationFrame(raf);
observer.disconnect();
stopGate();
controls.removeEventListener("change", requestRender);
controls.dispose();
scene.traverse((object) => {
const mesh = object as THREE.Mesh;
@@ -1,111 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
interface HeroPanoramaBackgroundProps {
imageSrc: string;
}
export function HeroPanoramaBackground({ imageSrc }: HeroPanoramaBackgroundProps) {
const mountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount) {
return;
}
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "high-performance",
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6));
renderer.setSize(mount.clientWidth, mount.clientHeight);
renderer.domElement.className = "h-full w-full";
mount.appendChild(renderer.domElement);
const camera = new THREE.PerspectiveCamera(
58,
mount.clientWidth / Math.max(mount.clientHeight, 1),
0.1,
1000,
);
camera.position.set(0, 0, 0.1);
const scene = new THREE.Scene();
const geometry = new THREE.SphereGeometry(500, 96, 64);
geometry.scale(-1, 1, 1);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0,
});
const sphere = new THREE.Mesh(geometry, material);
sphere.rotation.y = -0.28;
scene.add(sphere);
let frame = 0;
let disposed = false;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const loader = new THREE.TextureLoader();
loader.load(
imageSrc,
(texture) => {
if (disposed) {
texture.dispose();
return;
}
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
material.map = texture;
material.opacity = 1;
material.needsUpdate = true;
},
undefined,
() => {
material.opacity = 0;
},
);
const resize = () => {
const width = mount.clientWidth;
const height = Math.max(mount.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
};
const animate = () => {
if (!reduceMotion) {
sphere.rotation.y += 0.00075;
}
renderer.render(scene, camera);
frame = requestAnimationFrame(animate);
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(mount);
animate();
return () => {
disposed = true;
cancelAnimationFrame(frame);
resizeObserver.disconnect();
geometry.dispose();
material.map?.dispose();
material.dispose();
renderer.dispose();
renderer.domElement.remove();
};
}, [imageSrc]);
return <div ref={mountRef} aria-hidden className="absolute inset-0" />;
}
@@ -2,7 +2,8 @@
import { MeshGradient } from "@paper-design/shaders-react";
import type React from "react";
import { HeroPanoramaBackground } from "@/app/components/ui/hero-panorama-background";
import { useEffect, useRef, useState } from "react";
import { createRenderGate } from "@/app/lib/render-gate";
interface HeroShaderBackgroundProps {
children: React.ReactNode;
@@ -10,45 +11,37 @@ interface HeroShaderBackgroundProps {
}
export function HeroShaderBackground({ children, imageSrc }: HeroShaderBackgroundProps) {
return (
<div className="relative isolate overflow-hidden border-b border-[var(--surface-border)]">
<svg aria-hidden className="absolute inset-0 h-0 w-0">
<defs>
<filter id="heritage-glass-effect" x="-50%" y="-50%" width="200%" height="200%">
<feTurbulence baseFrequency="0.005" numOctaves="1" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="0.3" />
<feColorMatrix
type="matrix"
values="1 0 0 0 0.03
0 1 0 0 0.04
0 0 1 0 0.02
0 0 0 0.88 0"
result="tint"
/>
</filter>
</defs>
</svg>
const rootRef = useRef<HTMLDivElement>(null);
const [shaderActive, setShaderActive] = useState(false);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
return createRenderGate(el, setShaderActive);
}, []);
return (
<div
ref={rootRef}
className="relative isolate overflow-hidden border-b border-[var(--surface-border)]"
>
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center opacity-[0.58] saturate-[1.12] contrast-[1.04]"
style={{ backgroundImage: `url("${imageSrc}")` }}
/>
<HeroPanoramaBackground imageSrc={imageSrc} />
<div aria-hidden className="absolute inset-0 bg-[rgb(243_247_240_/_0.12)]" />
<div aria-hidden className="absolute inset-0 overflow-hidden opacity-48 mix-blend-soft-light">
{/* Một lớp shader duy nhất, chỉ vẽ khi hero trong tầm nhìn + tab hiển thị */}
{shaderActive ? (
<div aria-hidden className="absolute inset-0 overflow-hidden opacity-40">
<MeshGradient
className="absolute inset-0 h-full w-full"
colors={["#f8fbf2", "#d6b468", "#e0eddf", "#245f4b", "#ffffff"]}
speed={0.28}
/>
<MeshGradient
className="absolute inset-0 h-full w-full opacity-45"
colors={["#ffffff", "#e7cb81", "#dcebdd", "#0e3427"]}
speed={0.2}
speed={0.24}
/>
</div>
) : null}
<div
aria-hidden
+4 -111
View File
@@ -240,7 +240,6 @@ a {
background-size: 180% 100%, 84px 84px, 84px 84px;
filter: none;
opacity: 0.9;
animation: public-ambient-pan 18s ease-in-out infinite alternate;
}
.public-page::after {
@@ -722,9 +721,8 @@ a {
box-shadow:
var(--surface-shadow),
inset 0 1px 0 rgb(255 255 255 / 0.88);
backdrop-filter: blur(18px) saturate(132%);
-webkit-backdrop-filter: blur(18px) saturate(132%);
will-change: transform;
backdrop-filter: blur(10px) saturate(120%);
-webkit-backdrop-filter: blur(10px) saturate(120%);
transition:
transform 280ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1),
@@ -778,9 +776,8 @@ a {
box-shadow:
0 24px 66px rgb(37 75 54 / 0.13),
inset 0 1px 0 rgb(255 255 255 / 0.9);
backdrop-filter: blur(12px) saturate(125%);
-webkit-backdrop-filter: blur(12px) saturate(125%);
will-change: transform;
backdrop-filter: blur(8px) saturate(118%);
-webkit-backdrop-filter: blur(8px) saturate(118%);
transition:
transform 280ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 280ms cubic-bezier(0.16, 1, 0.3, 1),
@@ -988,7 +985,6 @@ a {
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: public-gradient-flow 8s ease-in-out infinite alternate;
}
.public-shimmer::after {
@@ -1161,19 +1157,6 @@ a {
animation: public-header-drop 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.public-header::after {
animation: public-header-glint 7s ease-in-out 1.2s infinite;
}
.public-logo-mark {
animation: public-logo-breathe 4.8s ease-in-out infinite;
}
.public-header-cta,
.public-cta {
animation: public-cta-breathe 4.2s ease-in-out infinite;
}
.public-hero-inner::before {
animation: public-line-draw 980ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
}
@@ -1182,14 +1165,6 @@ a {
animation: public-line-draw 1100ms cubic-bezier(0.16, 1, 0.3, 1) 260ms both;
}
.public-subpage-hero-image {
animation: public-photo-breathe 12s ease-in-out infinite alternate;
}
.public-hero-inner .public-image-stage {
animation: public-stage-float 7.2s ease-in-out 900ms infinite alternate;
}
.public-hero-rule::before {
animation: public-line-draw 980ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
}
@@ -1228,10 +1203,6 @@ a {
.public-card:hover::after {
animation: public-sheen 820ms cubic-bezier(0.16, 1, 0.3, 1);
}
.public-back-to-top[data-visible="true"] {
animation: public-back-to-top-float 4.8s ease-in-out infinite;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -1267,45 +1238,8 @@ a {
}
}
@keyframes public-header-glint {
0%,
58%,
100% {
opacity: 0;
transform: translateX(0) skewX(-18deg);
}
68% {
opacity: 0.62;
}
82% {
opacity: 0;
transform: translateX(760%) skewX(-18deg);
}
}
@keyframes public-logo-breathe {
0%,
100% {
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.92),
0 10px 24px rgb(37 75 54 / 0.08);
}
50% {
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.98),
0 16px 34px rgb(142 95 11 / 0.14);
}
}
@keyframes public-cta-breathe {
0%,
100% {
filter: saturate(1);
}
50% {
filter: saturate(1.08) brightness(1.03);
}
}
@keyframes public-rise {
from {
@@ -1329,23 +1263,7 @@ a {
}
}
@keyframes public-ambient-pan {
from {
background-position: 0% 0%, 0 0, 0 0;
}
to {
background-position: 100% 0%, 84px 0, 0 84px;
}
}
@keyframes public-gradient-flow {
from {
background-position: 0% 50%;
}
to {
background-position: 100% 50%;
}
}
@keyframes public-line-draw {
from {
@@ -1372,33 +1290,8 @@ a {
}
}
@keyframes public-photo-breathe {
from {
transform: scale(1);
}
to {
transform: scale(1.035);
}
}
@keyframes public-stage-float {
from {
transform: translate3d(0, 0, 0) rotateX(0deg) rotateY(0deg);
}
to {
transform: translate3d(0, -10px, 0) rotateX(0.4deg) rotateY(-0.55deg);
}
}
@keyframes public-back-to-top-float {
0%,
100% {
filter: saturate(1);
}
50% {
filter: saturate(1.08) brightness(1.03);
}
}
/* Lớp nhiễu hạt phim cố định — tạo chiều sâu, chất liệu cao cấp cho nền tối. */
.site-grain {
+39
View File
@@ -0,0 +1,39 @@
// src/app/lib/render-gate.ts
// Chỉ cho phép vòng lặp vẽ (rAF/setAnimationLoop) chạy khi phần tử nằm trong vùng nhìn
// VÀ tab đang hiển thị. Gọi onChange(active) mỗi khi trạng thái đổi. Trả về hàm dọn dẹp.
export function createRenderGate(
element: Element,
onChange: (active: boolean) => void,
): () => void {
let onScreen = false;
let visible = typeof document === "undefined" ? true : !document.hidden;
let last = false;
const emit = () => {
const next = onScreen && visible;
if (next !== last) {
last = next;
onChange(next);
}
};
const io = new IntersectionObserver(
(entries) => {
onScreen = entries.some((entry) => entry.isIntersecting);
emit();
},
{ threshold: 0.01 },
);
io.observe(element);
const onVisibility = () => {
visible = !document.hidden;
emit();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import dynamic from "next/dynamic";
const VirtualTour = dynamic(() => import("@/app/components/VirtualTour"), {
ssr: false,
loading: () => (
<div className="grid min-h-[100dvh] place-items-center bg-[#f5f1e6] text-[#4a3f35]">
<p className="text-sm font-bold">Đang tải không gian 360°</p>
</div>
),
});
export default function TourClient({ tourId }: { tourId?: string }) {
return <VirtualTour tourId={tourId} />;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import VirtualTour from "@/app/components/VirtualTour";
import TourClient from "./TourClient";
export const metadata: Metadata = {
title: "VR360 Chùa Thái Lạc (Pháp Vân tự)",
@@ -8,5 +8,5 @@ export const metadata: Metadata = {
};
export default function ChuaThaiLacTourPage() {
return <VirtualTour tourId="chua-thai-lac" />;
return <TourClient tourId="chua-thai-lac" />;
}
+26 -26
View File
@@ -1,30 +1,30 @@
// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.
// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.
export const lqip: Record<string, string> = {
"/images/chua-thai-lac/01.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABQECBP/EACgQAAIBAwEFCQAAAAAAAAAAAAECAwAEETEFEhMhUSIjMjNBYXGBof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAwDAQACEQMRAD8As77OlaO3Ktuq3mnByPjpz1oW7sRC5KhSh5Aqxx9eppwzk+HBIOoIq8SKUFJHQsMEHUA1mrIDtrWMwtLIyDA3gC35SFlLwpxFIUaBVzJ3ecg9PeutrZJY+0iFeoIraPHFGI0IyNd3mBTTH//Z",
"/images/chua-thai-lac/02.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAMEBf/EACMQAAIBBAICAgMAAAAAAAAAAAECAwAEERIFMSFxEyJhcoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAQEBAQAAAAAAAAAAAAAAAAARARL/2gAMAwEAAhEDEQA/ACLmgZUjkttWIBOCfFXScvaxWssjRnKEAAr3no+qmexQvtoVcjGw7p0FikcJSNXkLeTkFvVTrSM+Ll5EkDXADxk4KxqNgexVZ5SJrB50t3WQHVY27b8/ymixRSSYTn9KbJZJND8ckZKn7DKkYNLpH//Z",
"/images/chua-thai-lac/03.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEBf/EACQQAAIBAwMEAwEAAAAAAAAAAAECAwAREgQFITFBcYEUFSKR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/ALNHvME72YCMY5XzB9eaZrNyHw45dOyAEkMWHI9VP9dGyhWCoAMQc+170WhgIEaqMF4AtUukQR7xqYGDTAzRvexva9u4rUbcYAiOLkOuQ5/g80mPSwLb8Jx046U2TRQakiRSM4+AA1gaXcSP/9k=",
"/images/chua-thai-lac/04.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwQC/8QAIxAAAgIBBAICAwAAAAAAAAAAAQIDEQQABRIhEzEiQWGh4f/EABQBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEgH/2gAMAwEAAhEDEQA/ALMfdMOReXl8YuvkPf8ANOd0x5MRpIeLEGqNAnRT4uPNGAWUGqHXr8ami2yGI9OoX3Siu9FmWId2idh54nRT3YN6pizsd+QYrG6mirH96zJhYjKF4lSKAYN9aOLEg5uVd+JNkX0dF6Jf/9k=",
"/images/chua-thai-lac/05.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAQUAAAAAAAAAAAAAAAAABAECAwUG/8QAIxAAAgEDAwQDAAAAAAAAAAAAAQIDAAQREiExBUFRgRMUkf/EABYBAQEBAAAAAAAAAAAAAAAAAAEAAv/EABcRAQEBAQAAAAAAAAAAAAAAAAAREgH/2gAMAwEAAhEDEQA/ACrS/wCmC2iaQIuvtIMnx6qCf6l9FK9i6oyYJDbD1ReI5LcqHXjY4GKEtLK1hlDNcK5Bzpz3rOllQtJKFUtE++4OCM+aSMvqJIcHkbYGK0FykEkufmiYDhS3H5TVit8DOhGHOptyKL0x/9k=",
"/images/chua-thai-lac/06.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAgMBAAAAAAAAAAAAAAAAAgQBAwUG/8QAJRAAAgICAQMDBQAAAAAAAAAAAQIDBAARIRIxYQUyQRVRUnHB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgH/xAAaEQACAwEBAAAAAAAAAAAAAAAAAQIRIRJR/9oADAMBAAIRAxEAPwALiU7EDmvGI50UFtNpd/bWZcdGad0H5nR6T7R3JzoJqSKVavMIjrTcA78/vFVgsSWiXnCgjlwvOC34QGKD06tMERprBX50Cr+PGMPIjzKfp6JpSSAQRv44/mRYqtG3VWeP29Oie+VR17UzASOiKO+jhcp3iEua0//Z",
"/images/chua-thai-lac/07.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAAQCAwX/xAAiEAACAQMFAAMBAAAAAAAAAAABAgMABBEFEiEiMRVBgZH/xAAVAQEBAAAAAAAAAAAAAAAAAAABAP/EABkRAAMAAwAAAAAAAAAAAAAAAAABAgMREv/aAAwDAQACEQMRAD8A0k1C0jCl5lUEZ8zUItatrqSROsYTxiR2pJLCBWyIGBGOWer5rOGSMKm0bh2AFDsVOw+RtGcgyMozw2ODRPf20Eayb94bwLyf5S1tpiQSbgDkjHY+U3Nbq8AjTn6fadrfhoWQuD//2Q==",
"/images/chua-thai-lac/08.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAP/EACkQAAIBAgIIBwAAAAAAAAAAAAECEQADBAUSExQVIUFRgSJCUmFxkdH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAAMBAAAAAAAAAAAAAAAAAAABERL/2gAMAwEAAhEDEQA/AK97ZeonXp9U21nGAvhlR1keZkianuHDtytmOgFEPbA8JUdxU0xEK3lhbl0jiqesn55dqaMwwCaJN8AMJEg/lbWWQRxSesChtGHdtFnU+zRV0xEf/9k=",
"/images/chua-thai-lac/09.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAIEA//EACMQAAIBAwQBBQAAAAAAAAAAAAECAwAEEQUSITFBIiNRcpH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABYRAQEBAAAAAAAAAAAAAAAAAAASAf/aAAwDAQACEQMRAD8Ast9Xs5I42kdYnYZIY9GmutZs40Qh1cHsoOqMQjG1EHHgDihhE+NwDY+cGpWkornW7dFjaBGmLH1LnbtFa32qw2sCSQoZWboHIAGPNU+0vACfgpSIZOGSMj6ilaTj/9k=",
"/images/chua-thai-lac/10.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAABAUAAgP/xAAkEAACAQMCBwEBAAAAAAAAAAABAgMABBEFMRITFCEiYXFRgf/EABUBAQEAAAAAAAAAAAAAAAAAAAEC/8QAGBEBAAMBAAAAAAAAAAAAAAAAAAEDEhP/2gAMAwEAAhEDEQA/ADZdZigQPMrKpOAV74+1JNdtYngUlzzdyBng+0OUik8X4WX5WhitvHgjVSuxo2vnK+qazyYV6MxSSE4O/Yfvul1hrF686dQUaLOCcAH+UwuAksYBKE+xQyW0CHOIwRsSBRoxW//Z",
"/images/chua-thai-lac/11.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAgMEAQX/xAAlEAACAQMEAgEFAAAAAAAAAAABAgMABBEFEiExEyJRFEFSYYH/xAAUAQEAAAAAAAAAAAAAAAAAAAAB/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQAREv/aAAwDAQACEQMRAD8AZqurMsEa2E+yV+WOB6j4PwaVpTXyXm++u5XhRD2cgmr7i0tpguSW29ZPVAtonj2FjtDZ4JBo1nCK31eK58uyOUCMZ98DP6FQxa4wdpJAWRuBH91/tdAWlqQN4LY/NycVv0ViHZ1jUMeyOKOljC//2Q==",
"/images/chua-thai-lac/12.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAnEAACAQMCAwkAAAAAAAAAAAABAgMABBEFIRJBYRMVIiMyUXGRof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBR1LyTJGwaVV9IBwT1HtQu9dRJZnUADB8KjHxTruwSVENs4R1XG52YdaKum3DkJIvAnMqwNZbocLyamyxKjMgmfccAOB986LJrNwkhjQR5GNypxmn3FgknZlHEbKOHcZGKMbAs2HmjG+5C5/aOqJh//9k=",
"/images/chua-thai-lac/13.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAQIHAQEAAAAAAAAAAAECAxEABBIFEyEiMVFhQRT/xAAWAQEBAQAAAAAAAAAAAAAAAAABAAL/xAAZEQACAwEAAAAAAAAAAAAAAAAAEQECElH/2gAMAwEAAhEDEQA/ACPMDp5DzZGlDdjDr0+3izxCTU6YwgkBVo0m8k+/GJk4dC6s6m2K1TH995KHQvCq8rbuqmLfuZdhUMy0eKPUxW8oViQ5ksLXsfcvGsDyqg1M38yjt7e4+b6ivWN1XDRIqyGTc6eFrocJyWDBBHIisKZt10Pl4atwlB//2Q==",
"/images/chua-thai-lac/14.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABAMBBf/EACUQAAIBAwIFBQAAAAAAAAAAAAECAwAEERIhEyMxQVEUMmFxkf/EABYBAQEBAAAAAAAAAAAAAAAAAAIAA//EABkRAAMAAwAAAAAAAAAAAAAAAAABEQISIf/aAAwDAQACEQMRAD8APLbBghQM506mBPt+Kk8aqoYZHbIBI+669w1nJIhkSNVC6TliMDtRZIbJITH6hAhOVOsGs3gSSChRCnEWR8/gNagZ445Uzzd13Jz560mERSqyxyZC9CW26eKpaQyRnnSRsg3VUOMUdOQah//Z",
"/images/chua-thai-lac/15.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAgICAQUAAAAAAAAAAAECAxEABAUhEyISFTEyM3H/xAAVAQEBAAAAAAAAAAAAAAAAAAACAf/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBsfNxvqRFmBn6DqRXeR+rkyyEqBEnRA7N/3E7Wlo7SqG8SuoADWLFYccdpoWZpEotdAisO0XEA5Dcn3NgrpPJ4kA/Wasn74OCfcLP4JJy0Z9uz1m/LriR1l1thY6FfEGhlUfHWX8kigMfam/LC3WjlTnT/2Q==",
"/images/chua-thai-lac/16.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAv/EACEQAAICAgMAAgMAAAAAAAAAAAECAxEAEgQhMUFREyJx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAED/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQACEf/aAAwDAQACEQMRAD8AUvJvjKyMRfZ2HRxZeQwgvG6sR0D85Y8HGcBASF9Ftd/3A61CY3ZWQEVZFA5m5aoNiBDHCzu0RfWvxte1/YycpKpYvsGUbaeULyvjiKOczPIu2pXrw3jJV48nrsLFfq3x9YRYcL//2Q==",
"/images/chua-thai-lac/17.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABQMG/8QAJBAAAgIBAwIHAAAAAAAAAAAAAQIDEQAEEiExQQUTFCJRgaH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAAH/xAAaEQACAgMAAAAAAAAAAAAAAAAAAQMREyFB/9oADAMBAAIRAxEAPwBYy8Hb2NEnoMlLq3iVAIxIz8rR4/czom1BdTvZU6n3VzlfVvI4Ll5CtgBjYw7Y6iY2urlWPzZoNsYPJRgSPrKLroHra1k9B3wrUeJARiOMKhHcR2DhxMrQrU7ijzTE38ZW+GY3uz//2Q==",
"/images/chua-thai-lac/18.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAED/8QAIRAAAgEEAgIDAAAAAAAAAAAAAQIDAAQRIRITImEUQdH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAQL/xAAXEQEBAQEAAAAAAAAAAAAAAAAAEQEh/9oADAMBAAIRAxEAPwAHWwlCRCVig8goIJFLhmmiUE2UrKD5bDYrb45knuJo2ALgceRyCfyrbW9zJKnbxjjXZKnJPqjJ2iNIbu+IjgcoWIXOtD1Vmh6mKyRMFxrioNPmtl7i+wcnBFDWGPsZps5RsAknQ+qjU1//2Q==",
"/images/chua-thai-lac/19.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAgMEBf/EACUQAAIBAwQBBAMAAAAAAAAAAAECAwAEERIhMUEFExQiUSRhcf/EABYBAQEBAAAAAAAAAAAAAAAAAAIBA//EABcRAQEBAQAAAAAAAAAAAAAAAAEAAhH/2gAMAwEAAhEDEQA/AJ4vIe5DARMpHG3NaPj54HtzI7a3zsgxsPs0qO2iRhobSOxqzn978U6WC2lh0EIp41KBmga7a6EIze2pYqVxjsEEfzmsmTyP5cygFowcpgcjqr4LCCGQP6hkb6bBo7mCKRmLYBOPl3VdMcnb/9k=",
"/images/chua-thai-lac/20.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAQIFAAT/xAAkEAACAQMDBAMBAAAAAAAAAAABAgMABBEFEiETMUFxIlFhYv/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAGBEBAQADAAAAAAAAAAAAAAAAAQACEVH/2gAMAwEAAhEDEQA/AO6bVjbKDPDsUnAZW349jio0+oztemSJk6bN4zwPuqUmnQzbRliB/VONMtou0aj9zUVYHZ7u9PSjeBXXPLbozz6oLqkZChY3LngruwM+6ElvgIhmcIO6k5yKxtLRhkJ8gc5Hig5TRf/Z",
"/images/chua-thai-lac/21.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAID/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQRIRIxBUETFDKB/8QAFgEBAQEAAAAAAAAAAAAAAAAABAEC/8QAGBEAAwEBAAAAAAAAAAAAAAAAABESIRP/2gAMAwEAAhEDEQA/AMpLCyikdtMq6XA/Rotxaq7ZijVR0Me6uO2EkTqJ8nGgw0KTBbgyc/lRZSMZ6AFHt4J5rTCLx0Uh4SJwcAaHs1MfjLYuqknJbeegKZLEebs8o5dDjvIoU1pGwJ+w6nJJB7P9rTRJZ//Z",
"/images/chua-thai-lac/22.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAiEAACAQQCAgMBAAAAAAAAAAABAgMABBExEiETQWGBkaH/xAAWAQEBAQAAAAAAAAAAAAAAAAABAwT/xAAZEQEBAQADAAAAAAAAAAAAAAABAAIREzH/2gAMAwEAAhEDEQA/AIzXEnhwlvEGXZGDmi8JbkBiRGvyQKVPZwuV8cgQrvB3R5beXI4Trjvotkn+VI02hwHkV4WSZAUYq28mlZQkGGIDBxxY7+6qtlcOqiV+Sb67NVSxRAQWYD2Dv99UG0nq5v/Z",
"/images/chua-thai-lac/23.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAgME/8QAIxAAAgEEAAYDAAAAAAAAAAAAAQIDAAQRIQUSMTJBURMikf/EABcBAAMBAAAAAAAAAAAAAAAAAAABAwT/xAAZEQACAwEAAAAAAAAAAAAAAAAAAQIREkH/2gAMAwEAAhEDEQA/ACJE2wim5R0OD0pxzLLN8SqqNnucHQ85oSWTsw5CwX0TmtlpaKA4cdw81FUaHskXRQAzIHzjAqN2qxrHKJ8c3cM6G8ftaJOEAn6ysN7yM69VR7GKK1ESgtvJJO6MrolOdn//2Q==",
"/images/chua-thai-lac/24.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAMB/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQREiExE1EUInGx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAwAB/8QAGhEAAgMBAQAAAAAAAAAAAAAAAAECEkERIf/aAAwDAQACEQMRAD8AF5L1pmAJAxkBjilJLcQIkayCSY847B56o+gEqs10HUceuKS3xzNFNGRvEQwOeCaNSfPRHBYXW/vlXJjjEYOGYYOD6/alK90WEks4WJusD+it2thbNCFKh38hO+ftUDbW7RAJcmMnJYbd1tmVFp//2Q==",
"/images/chua-thai-lac/25.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwIE/8QAJBAAAgEEAQMFAQAAAAAAAAAAAQIDAAQRIRITMXEFFCJBQoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAA//EABkRAQADAQEAAAAAAAAAAAAAAAEAAhESQf/aAAwDAQACEQMRAD8AOWNRbxlRKJydqdCoje5t0EjcXJ3wJzgeaeaCzCgK7Hee/ajmtldgIkbiR+c/Gpjb2U5I7+pzOFX28agjOdHX8rDcRXKyO3UyoOQeWiPFKLAIRzeUj6BGKZ7dG6ZkYFgSWxRs5BU2f//Z",
"/images/chua-thai-lac/26.jpg": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAwQBAgX/xAAjEAACAgICAgEFAAAAAAAAAAABAgMRAAQSIRMUBSIjJDJh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAP/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQISEf/aAAwDAQACEQMRAD8AC/togWGZpZWPSEUBh4/kdyG1j4OFq2YgViiPohgW2mYHo2xwPlgUhX2PtH9lFWTdjJp0UzJpS7W3wb2x4/qKWpHRq8Qlj3omDNOHH8Y5abc053X8jnbciG6s1VnJl2dWRI4jOvJBTMGq8N1wZk//2Q==",
"/images/chua-thai-lac/01.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABQECBP/EACgQAAIBAwEFCQAAAAAAAAAAAAECAwAEETEFEhMhUSIjMjNBYXGBof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAwDAQACEQMRAD8As77OlaO3Ktuq3mnByPjpz1oW7sRC5KhSh5Aqxx9eppwzk+HBIOoIq8SKUFJHQsMEHUA1mrIDtrWMwtLIyDA3gC35SFlLwpxFIUaBVzJ3ecg9PeutrZJY+0iFeoIraPHFGI0IyNd3mBTTH//Z",
"/images/chua-thai-lac/02.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAMEBf/EACMQAAIBBAICAgMAAAAAAAAAAAECAwAEERIFMSFxEyJhcoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAQEBAQAAAAAAAAAAAAAAAAARARL/2gAMAwEAAhEDEQA/ACLmgZUjkttWIBOCfFXScvaxWssjRnKEAAr3no+qmexQvtoVcjGw7p0FikcJSNXkLeTkFvVTrSM+Ll5EkDXADxk4KxqNgexVZ5SJrB50t3WQHVY27b8/ymixRSSYTn9KbJZJND8ckZKn7DKkYNLpH//Z",
"/images/chua-thai-lac/03.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEBf/EACQQAAIBAwMEAwEAAAAAAAAAAAECAwAREgQFITFBcYEUFSKR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/ALNHvME72YCMY5XzB9eaZrNyHw45dOyAEkMWHI9VP9dGyhWCoAMQc+170WhgIEaqMF4AtUukQR7xqYGDTAzRvexva9u4rUbcYAiOLkOuQ5/g80mPSwLb8Jx046U2TRQakiRSM4+AA1gaXcSP/9k=",
"/images/chua-thai-lac/04.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwQC/8QAIxAAAgIBBAICAwAAAAAAAAAAAQIDEQQABRIhEzEiQWGh4f/EABQBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEgH/2gAMAwEAAhEDEQA/ALMfdMOReXl8YuvkPf8ANOd0x5MRpIeLEGqNAnRT4uPNGAWUGqHXr8ami2yGI9OoX3Siu9FmWId2idh54nRT3YN6pizsd+QYrG6mirH96zJhYjKF4lSKAYN9aOLEg5uVd+JNkX0dF6Jf/9k=",
"/images/chua-thai-lac/05.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAQUAAAAAAAAAAAAAAAAABAECAwUG/8QAIxAAAgEDAwQDAAAAAAAAAAAAAQIDAAQREiExBUFRgRMUkf/EABYBAQEBAAAAAAAAAAAAAAAAAAEAAv/EABcRAQEBAQAAAAAAAAAAAAAAAAAREgH/2gAMAwEAAhEDEQA/ACrS/wCmC2iaQIuvtIMnx6qCf6l9FK9i6oyYJDbD1ReI5LcqHXjY4GKEtLK1hlDNcK5Bzpz3rOllQtJKFUtE++4OCM+aSMvqJIcHkbYGK0FykEkufmiYDhS3H5TVit8DOhGHOptyKL0x/9k=",
"/images/chua-thai-lac/06.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGQAAAgMBAAAAAAAAAAAAAAAAAgQBAwUG/8QAJRAAAgICAQMDBQAAAAAAAAAAAQIDBAARIRIxYQUyQRVRUnHB/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgH/xAAaEQACAwEBAAAAAAAAAAAAAAAAAQIRIRJR/9oADAMBAAIRAxEAPwALiU7EDmvGI50UFtNpd/bWZcdGad0H5nR6T7R3JzoJqSKVavMIjrTcA78/vFVgsSWiXnCgjlwvOC34QGKD06tMERprBX50Cr+PGMPIjzKfp6JpSSAQRv44/mRYqtG3VWeP29Oie+VR17UzASOiKO+jhcp3iEua0//Z",
"/images/chua-thai-lac/07.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAAQCAwX/xAAiEAACAQMFAAMBAAAAAAAAAAABAgMABBEFEiEiMRVBgZH/xAAVAQEBAAAAAAAAAAAAAAAAAAABAP/EABkRAAMAAwAAAAAAAAAAAAAAAAABAgMREv/aAAwDAQACEQMRAD8A0k1C0jCl5lUEZ8zUItatrqSROsYTxiR2pJLCBWyIGBGOWer5rOGSMKm0bh2AFDsVOw+RtGcgyMozw2ODRPf20Eayb94bwLyf5S1tpiQSbgDkjHY+U3Nbq8AjTn6fadrfhoWQuD//2Q==",
"/images/chua-thai-lac/08.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAP/EACkQAAIBAgIIBwAAAAAAAAAAAAECEQADBAUSExQVIUFRgSJCUmFxkdH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABcRAAMBAAAAAAAAAAAAAAAAAAABERL/2gAMAwEAAhEDEQA/AK97ZeonXp9U21nGAvhlR1keZkianuHDtytmOgFEPbA8JUdxU0xEK3lhbl0jiqesn55dqaMwwCaJN8AMJEg/lbWWQRxSesChtGHdtFnU+zRV0xEf/9k=",
"/images/chua-thai-lac/09.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAAIEA//EACMQAAIBAwQBBQAAAAAAAAAAAAECAwAEEQUSITFBIiNRcpH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABYRAQEBAAAAAAAAAAAAAAAAAAASAf/aAAwDAQACEQMRAD8Ast9Xs5I42kdYnYZIY9GmutZs40Qh1cHsoOqMQjG1EHHgDihhE+NwDY+cGpWkornW7dFjaBGmLH1LnbtFa32qw2sCSQoZWboHIAGPNU+0vACfgpSIZOGSMj6ilaTj/9k=",
"/images/chua-thai-lac/10.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAABAUAAgP/xAAkEAACAQMCBwEBAAAAAAAAAAABAgMABBEFMRITFCEiYXFRgf/EABUBAQEAAAAAAAAAAAAAAAAAAAEC/8QAGBEBAAMBAAAAAAAAAAAAAAAAAAEDEhP/2gAMAwEAAhEDEQA/ADZdZigQPMrKpOAV74+1JNdtYngUlzzdyBng+0OUik8X4WX5WhitvHgjVSuxo2vnK+qazyYV6MxSSE4O/Yfvul1hrF686dQUaLOCcAH+UwuAksYBKE+xQyW0CHOIwRsSBRoxW//Z",
"/images/chua-thai-lac/11.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAgMEAQX/xAAlEAACAQMEAgEFAAAAAAAAAAABAgMABBEFEiExEyJRFEFSYYH/xAAUAQEAAAAAAAAAAAAAAAAAAAAB/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQAREv/aAAwDAQACEQMRAD8AZqurMsEa2E+yV+WOB6j4PwaVpTXyXm++u5XhRD2cgmr7i0tpguSW29ZPVAtonj2FjtDZ4JBo1nCK31eK58uyOUCMZ98DP6FQxa4wdpJAWRuBH91/tdAWlqQN4LY/NycVv0ViHZ1jUMeyOKOljC//2Q==",
"/images/chua-thai-lac/12.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAnEAACAQMCAwkAAAAAAAAAAAABAgMABBEFIRJBYRMVIiMyUXGRof/EABYBAQEBAAAAAAAAAAAAAAAAAAABAv/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBR1LyTJGwaVV9IBwT1HtQu9dRJZnUADB8KjHxTruwSVENs4R1XG52YdaKum3DkJIvAnMqwNZbocLyamyxKjMgmfccAOB986LJrNwkhjQR5GNypxmn3FgknZlHEbKOHcZGKMbAs2HmjG+5C5/aOqJh//9k=",
"/images/chua-thai-lac/13.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAQIHAQEAAAAAAAAAAAECAxEABBIFEyEiMVFhQRT/xAAWAQEBAQAAAAAAAAAAAAAAAAABAAL/xAAZEQACAwEAAAAAAAAAAAAAAAAAEQECElH/2gAMAwEAAhEDEQA/ACPMDp5DzZGlDdjDr0+3izxCTU6YwgkBVo0m8k+/GJk4dC6s6m2K1TH995KHQvCq8rbuqmLfuZdhUMy0eKPUxW8oViQ5ksLXsfcvGsDyqg1M38yjt7e4+b6ivWN1XDRIqyGTc6eFrocJyWDBBHIisKZt10Pl4atwlB//2Q==",
"/images/chua-thai-lac/14.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAABAMBBf/EACUQAAIBAwIFBQAAAAAAAAAAAAECAwAEERIhEyMxQVEUMmFxkf/EABYBAQEBAAAAAAAAAAAAAAAAAAIAA//EABkRAAMAAwAAAAAAAAAAAAAAAAABEQISIf/aAAwDAQACEQMRAD8APLbBghQM506mBPt+Kk8aqoYZHbIBI+669w1nJIhkSNVC6TliMDtRZIbJITH6hAhOVOsGs3gSSChRCnEWR8/gNagZ445Uzzd13Jz560mERSqyxyZC9CW26eKpaQyRnnSRsg3VUOMUdOQah//Z",
"/images/chua-thai-lac/15.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwABAAMAAAAAAAAAAAAAAAAABAIDBf/EACMQAAICAgICAQUAAAAAAAAAAAECAxEABAUhEyISFTEyM3H/xAAVAQEBAAAAAAAAAAAAAAAAAAACAf/EABgRAAMBAQAAAAAAAAAAAAAAAAABAhEh/9oADAMBAAIRAxEAPwBsfNxvqRFmBn6DqRXeR+rkyyEqBEnRA7N/3E7Wlo7SqG8SuoADWLFYccdpoWZpEotdAisO0XEA5Dcn3NgrpPJ4kA/Wasn74OCfcLP4JJy0Z9uz1m/LriR1l1thY6FfEGhlUfHWX8kigMfam/LC3WjlTnT/2Q==",
"/images/chua-thai-lac/16.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEAv/EACEQAAICAgMAAgMAAAAAAAAAAAECAxEAEgQhMUFREyJx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAED/8QAFxEBAQEBAAAAAAAAAAAAAAAAAQACEf/aAAwDAQACEQMRAD8AUvJvjKyMRfZ2HRxZeQwgvG6sR0D85Y8HGcBASF9Ftd/3A61CY3ZWQEVZFA5m5aoNiBDHCzu0RfWvxte1/YycpKpYvsGUbaeULyvjiKOczPIu2pXrw3jJV48nrsLFfq3x9YRYcL//2Q==",
"/images/chua-thai-lac/17.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABQMG/8QAJBAAAgIBAwIHAAAAAAAAAAAAAQIDEQAEEiExQQUTFCJRgaH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAAH/xAAaEQACAgMAAAAAAAAAAAAAAAAAAQMREyFB/9oADAMBAAIRAxEAPwBYy8Hb2NEnoMlLq3iVAIxIz8rR4/czom1BdTvZU6n3VzlfVvI4Ll5CtgBjYw7Y6iY2urlWPzZoNsYPJRgSPrKLroHra1k9B3wrUeJARiOMKhHcR2DhxMrQrU7ijzTE38ZW+GY3uz//2Q==",
"/images/chua-thai-lac/18.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAED/8QAIRAAAgEEAgIDAAAAAAAAAAAAAQIDAAQRIRITImEUQdH/xAAWAQEBAQAAAAAAAAAAAAAAAAADAQL/xAAXEQEBAQEAAAAAAAAAAAAAAAAAEQEh/9oADAMBAAIRAxEAPwAHWwlCRCVig8goIJFLhmmiUE2UrKD5bDYrb45knuJo2ALgceRyCfyrbW9zJKnbxjjXZKnJPqjJ2iNIbu+IjgcoWIXOtD1Vmh6mKyRMFxrioNPmtl7i+wcnBFDWGPsZps5RsAknQ+qjU1//2Q==",
"/images/chua-thai-lac/19.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAgMEBf/EACUQAAIBAwQBBAMAAAAAAAAAAAECAwAEERIhMUEFExQiUSRhcf/EABYBAQEBAAAAAAAAAAAAAAAAAAIBA//EABcRAQEBAQAAAAAAAAAAAAAAAAEAAhH/2gAMAwEAAhEDEQA/AJ4vIe5DARMpHG3NaPj54HtzI7a3zsgxsPs0qO2iRhobSOxqzn978U6WC2lh0EIp41KBmga7a6EIze2pYqVxjsEEfzmsmTyP5cygFowcpgcjqr4LCCGQP6hkb6bBo7mCKRmLYBOPl3VdMcnb/9k=",
"/images/chua-thai-lac/20.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAQIFAAT/xAAkEAACAQMDBAMBAAAAAAAAAAABAgMABBEFEiETMUFxIlFhYv/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAGBEBAQADAAAAAAAAAAAAAAAAAQACEVH/2gAMAwEAAhEDEQA/AO6bVjbKDPDsUnAZW349jio0+oztemSJk6bN4zwPuqUmnQzbRliB/VONMtou0aj9zUVYHZ7u9PSjeBXXPLbozz6oLqkZChY3LngruwM+6ElvgIhmcIO6k5yKxtLRhkJ8gc5Hig5TRf/Z",
"/images/chua-thai-lac/21.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAID/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQRIRIxBUETFDKB/8QAFgEBAQEAAAAAAAAAAAAAAAAABAEC/8QAGBEAAwEBAAAAAAAAAAAAAAAAABESIRP/2gAMAwEAAhEDEQA/AMpLCyikdtMq6XA/Rotxaq7ZijVR0Me6uO2EkTqJ8nGgw0KTBbgyc/lRZSMZ6AFHt4J5rTCLx0Uh4SJwcAaHs1MfjLYuqknJbeegKZLEebs8o5dDjvIoU1pGwJ+w6nJJB7P9rTRJZ//Z",
"/images/chua-thai-lac/22.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAABAP/xAAiEAACAQQCAgMBAAAAAAAAAAABAgMABBExEiETQWGBkaH/xAAWAQEBAQAAAAAAAAAAAAAAAAABAwT/xAAZEQEBAQADAAAAAAAAAAAAAAABAAIREzH/2gAMAwEAAhEDEQA/AIzXEnhwlvEGXZGDmi8JbkBiRGvyQKVPZwuV8cgQrvB3R5beXI4Trjvotkn+VI02hwHkV4WSZAUYq28mlZQkGGIDBxxY7+6qtlcOqiV+Sb67NVSxRAQWYD2Dv99UG0nq5v/Z",
"/images/chua-thai-lac/23.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAgME/8QAIxAAAgEEAAYDAAAAAAAAAAAAAQIDAAQRIQUSMTJBURMikf/EABcBAAMBAAAAAAAAAAAAAAAAAAABAwT/xAAZEQACAwEAAAAAAAAAAAAAAAAAAQIREkH/2gAMAwEAAhEDEQA/ACJE2wim5R0OD0pxzLLN8SqqNnucHQ85oSWTsw5CwX0TmtlpaKA4cdw81FUaHskXRQAzIHzjAqN2qxrHKJ8c3cM6G8ftaJOEAn6ysN7yM69VR7GKK1ESgtvJJO6MrolOdn//2Q==",
"/images/chua-thai-lac/24.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAABAMB/8QAIhAAAgEEAgIDAQAAAAAAAAAAAQIDAAQREiExE1EUInGx/8QAFgEBAQEAAAAAAAAAAAAAAAAAAwAB/8QAGhEAAgMBAQAAAAAAAAAAAAAAAAECEkERIf/aAAwDAQACEQMRAD8AF5L1pmAJAxkBjilJLcQIkayCSY847B56o+gEqs10HUceuKS3xzNFNGRvEQwOeCaNSfPRHBYXW/vlXJjjEYOGYYOD6/alK90WEks4WJusD+it2thbNCFKh38hO+ftUDbW7RAJcmMnJYbd1tmVFp//2Q==",
"/images/chua-thai-lac/25.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAwIE/8QAJBAAAgEEAQMFAQAAAAAAAAAAAQIDAAQRIRITMXEFFCJBQoH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAA//EABkRAQADAQEAAAAAAAAAAAAAAAEAAhESQf/aAAwDAQACEQMRAD8AOWNRbxlRKJydqdCoje5t0EjcXJ3wJzgeaeaCzCgK7Hee/ajmtldgIkbiR+c/Gpjb2U5I7+pzOFX28agjOdHX8rDcRXKyO3UyoOQeWiPFKLAIRzeUj6BGKZ7dG6ZkYFgSWxRs5BU2f//Z",
"/images/chua-thai-lac/26.webp": "data:image/jpeg;base64,/9j/2wBDABQODxIPDRQSEBIXFRQYHjIhHhwcHj0sLiQySUBMS0dARkVQWnNiUFVtVkVGZIhlbXd7gYKBTmCNl4x9lnN+gXz/2wBDARUXFx4aHjshITt8U0ZTfHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHz/wAARCAAQACADASIAAhEBAxEB/8QAGAAAAgMAAAAAAAAAAAAAAAAAAwQBAgX/xAAjEAACAgICAgEFAAAAAAAAAAABAgMRAAQSIRMUBSIjJDJh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAP/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQISEf/aAAwDAQACEQMRAD8AC/togWGZpZWPSEUBh4/kdyG1j4OFq2YgViiPohgW2mYHo2xwPlgUhX2PtH9lFWTdjJp0UzJpS7W3wb2x4/qKWpHRq8Qlj3omDNOHH8Y5abc053X8jnbciG6s1VnJl2dWRI4jOvJBTMGq8N1wZk//2Q==",
};
+3 -3
View File
@@ -2,14 +2,14 @@
// File chỉ chứa DỮ LIỆU (type-only import từ engine để tránh circular import runtime).
// Engine `VirtualTour.tsx` import `chuaThaiLacTour` rồi bọc qua createTourConfig().
//
// Ảnh panorama đặt tại public/images/chua-thai-lac/01.jpg … 26.jpg
// Ảnh panorama đặt tại public/images/chua-thai-lac/01.webp … 26.webp
// (đặt tên ASCII theo thứ tự cảnh để tránh lỗi mã hóa Unicode/đường dẫn khi triển khai).
import type { TourConfig, TourScene, InfoMarker, Hotspot } from "@/app/components/VirtualTour";
const base = "/images/chua-thai-lac";
// Trỏ tới đúng file ảnh gốc theo SỐ FILE (01..26), độc lập với thứ tự cảnh sau khi
// đánh số lại — nhờ vậy có thể bỏ cảnh giữa chừng mà không phải đổi tên ảnh.
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.jpg`;
const imageForFile = (fileNumber: number) => `${base}/${String(fileNumber).padStart(2, "0")}.webp`;
type SceneSeed = {
title: string;
@@ -408,7 +408,7 @@ export const chuaThaiLacTour: Omit<TourConfig, "sceneById" | "locationGroups"> =
welcomeAccent: "Pháp Vân tự",
welcomeSubtitle:
"Tham quan 360° ngôi chùa cổ thờ Pháp Vân, nổi tiếng với mảng chạm khắc gỗ thời Trần.",
welcomeBackgroundImage: imageForFile(8), // 08.jpg — Chính sảnh
welcomeBackgroundImage: imageForFile(8), // 08.webp — Chính sảnh
welcomeCards: [
{ image: imageForFile(1), label: "Cổng chùa", rotate: "-rotate-6", sceneId: "scene-1" },
{ image: imageForFile(10), label: "Đài Quan Âm", rotate: "rotate-5", sceneId: "scene-10" },