From d4892544e0b27a170c5ff564cc8f0a2d0b9b480f Mon Sep 17 00:00:00 2001 From: tuyenio Date: Wed, 24 Jun 2026 15:51:04 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20script=20n=C3=A9n=20=E1=BA=A3nh=20panor?= =?UTF-8?q?ama=20+=20LQIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 ++ scripts/optimize-images.mjs | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 scripts/optimize-images.mjs diff --git a/.gitignore b/.gitignore index 9c8a03f..96e7353 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* next-env.d.ts .vercel + +# ảnh gốc trước khi nén (backup cục bộ) +/image-originals diff --git a/scripts/optimize-images.mjs b/scripts/optimize-images.mjs new file mode 100644 index 0000000..9c0fab2 --- /dev/null +++ b/scripts/optimize-images.mjs @@ -0,0 +1,85 @@ +// Nén ảnh panorama + modal, backup bản gốc, sinh ảnh mờ (LQIP). +// Chạy: node scripts/optimize-images.mjs (chạy lại nhiều lần an toàn — luôn nén từ backup gốc) +import sharp from "sharp"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const ROOT = process.cwd(); +const PANO_DIR = path.join(ROOT, "public/images/chua-thai-lac"); +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_QUALITY = 80; +const LQIP_WIDTH = 32; +const MODAL_FILES = ["modal-1.png", "modal-2.png"]; + +const fmt = (b) => (b / 1024 / 1024).toFixed(2) + "MB"; +const ensureDir = (d) => fs.mkdir(d, { recursive: true }); +const exists = (p) => fs.access(p).then(() => true).catch(() => false); + +// Đảm bảo ảnh gốc đã được backup; luôn ĐỌC từ backup để không nén chồng. +async function sourceFrom(destPath, backupSubdir) { + const name = path.basename(destPath); + const backupPath = path.join(BACKUP_DIR, backupSubdir, name); + await ensureDir(path.dirname(backupPath)); + if (!(await exists(backupPath))) { + await fs.copyFile(destPath, backupPath); + } + return backupPath; +} + +async function optimizePanoramas() { + const files = (await fs.readdir(PANO_DIR)).filter((f) => /\.jpe?g$/i.test(f)).sort(); + const lqip = {}; + 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; + + const buf = await sharp(src) + .rotate() + .resize({ width: PANO_WIDTH, withoutEnlargement: true }) + .jpeg({ quality: PANO_QUALITY, mozjpeg: true, progressive: true }) + .toBuffer(); + await fs.writeFile(dest, buf); + + 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"); + + console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)} (-${Math.round((1 - buf.length / before) * 100)}%)`); + } + return lqip; +} + +async function optimizeModals() { + for (const f of MODAL_FILES) { + const dest = path.join(MODAL_DIR, f); + if (!(await exists(dest))) continue; + const src = await sourceFrom(dest, "modal"); + const before = (await fs.stat(dest)).size; + const buf = await sharp(src).png({ compressionLevel: 9, palette: true, quality: 90, dither: 1 }).toBuffer(); + await fs.writeFile(dest, buf); + console.log(`${f}: ${fmt(before)} -> ${fmt(buf.length)}`); + } +} + +async function writeLqip(lqip) { + const entries = Object.entries(lqip) + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`) + .join("\n"); + const content = + "// TỰ ĐỘNG SINH bởi scripts/optimize-images.mjs — KHÔNG sửa tay.\n" + + "// Ảnh mờ tạm (blur-up/LQIP) base64 cho mỗi panorama, khoá theo đường dẫn ảnh.\n" + + "export const lqip: Record = {\n" + + entries + + "\n};\n"; + await fs.writeFile(LQIP_OUT, content); + console.log("Wrote " + path.relative(ROOT, LQIP_OUT) + " (" + Object.keys(lqip).length + " entries)"); +} + +const lqip = await optimizePanoramas(); +await optimizeModals(); +await writeLqip(lqip); +console.log("Done.");