Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59563db3b4 | |||
| 9e3f98d3dd | |||
| a0c202546e | |||
| 2bd2a42dcf | |||
| 9b7ce49f54 | |||
| 8a0635944a | |||
| d58760a63b | |||
| da8bbb7641 | |||
| dc14bc5197 | |||
| 7ac7d86570 | |||
| 5e9e632345 | |||
| c96b6f195c | |||
| e0117ab4bd | |||
| cea9efab99 | |||
| ddef1f56bb | |||
| 707eb0f5c1 |
@@ -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 **0–1** 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 2–4MB) 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.6–1.0MB | webp ~0.25–0.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 ~7–12MB (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 214–285 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 -> ~3–8MB (-…%)` 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/...` và `/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: **0–1** (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 `/` và `/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` và `/co-vat`.
|
||||
Expected: panorama `.webp` ~0.3MB/ảnh; `.glb` nhỏ hơn nhiều lần so với 27–40MB.
|
||||
|
||||
- [ ] **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 1–3; GĐ2 (nén ảnh ✓T4–5, 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**, 2–4MB/ả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 70–85% 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 3–8MB. 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 → 0–1**.
|
||||
- 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 994 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 863 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 964 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 992 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 810 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 839 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 949 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 892 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 912 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 999 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 906 KiB |
|
After Width: | Height: | Size: 531 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
{/* 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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==",
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
|
||||