mmday-firebase/scripts/optimize-reward-product-images.ts
윤정민 1c60607683 Add reward image optimization pipeline
- reward-products/ 이미지를 리사이즈(메인 1080px, 상세 너비 1440px) + WebP(품질 82)로 변환하는 공용 서비스 추가 (sharp)
- Storage 트리거 onRewardImageUploaded(us-central1)로 이후 업로드에도 자동 적용 — 다운로드 토큰을 보존해 기존 URL이 그대로 유효
- 기존 자산 일괄 최적화 스크립트(npm run optimize:reward-images) 추가, 실행 결과 38개 파일 135MB → 5.7MB
- 재처리 무한루프는 rewardImageOptimized 메타데이터 플래그로 방지
2026-07-20 13:04:29 +09:00

116 lines
4.6 KiB
TypeScript

/**
* Storage의 reward-products/ 이미지 전체를 리사이즈 + WebP로 일괄 최적화한다.
* 같은 오브젝트 경로에 덮어쓰고 다운로드 토큰을 보존하므로 Firestore에 저장된 URL은 그대로 유효하다.
* 실행: npm run optimize:reward-images
*/
import { execFileSync, execSync } from "node:child_process";
import { OPTIMIZED_FLAG, optimizeRewardImage, targetForPath } from "../src/services/imageOptimizeService";
const storageBucket = "mmday-panit.firebasestorage.app";
const accessToken = (process.platform === "win32" ?
execSync("gcloud auth print-access-token", { encoding: "utf8", windowsHide: true }) :
execFileSync("gcloud", ["auth", "print-access-token"], { encoding: "utf8", windowsHide: true })).trim();
const apiBase = `https://storage.googleapis.com/storage/v1/b/${storageBucket}/o`;
const uploadBase = `https://storage.googleapis.com/upload/storage/v1/b/${storageBucket}/o`;
const authHeader = { Authorization: `Bearer ${accessToken}` };
interface StorageObject {
name: string;
size: string;
contentType?: string;
metadata?: Record<string, string>;
}
async function listObjects(): Promise<StorageObject[]> {
const items: StorageObject[] = [];
let pageToken: string | undefined;
do {
const params = new URLSearchParams({
prefix: "reward-products/",
fields: "items(name,size,contentType,metadata),nextPageToken",
});
if (pageToken) params.set("pageToken", pageToken);
const res = await fetch(`${apiBase}?${params}`, { headers: authHeader });
if (!res.ok) throw new Error(`list failed: ${res.status} ${await res.text()}`);
const body = await res.json() as { items?: StorageObject[]; nextPageToken?: string };
items.push(...(body.items ?? []));
pageToken = body.nextPageToken;
} while (pageToken);
return items;
}
async function download(name: string): Promise<Buffer> {
const res = await fetch(`${apiBase}/${encodeURIComponent(name)}?alt=media`, { headers: authHeader });
if (!res.ok) throw new Error(`download failed for ${name}: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
async function markOptimized(name: string): Promise<void> {
const res = await fetch(`${apiBase}/${encodeURIComponent(name)}`, {
method: "PATCH",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ metadata: { [OPTIMIZED_FLAG]: "true" } }),
});
if (!res.ok) throw new Error(`metadata patch failed for ${name}: ${res.status} ${await res.text()}`);
}
/** multipart 업로드로 본문과 메타데이터(토큰 보존 + 최적화 플래그)를 한 번에 교체한다. */
async function overwrite(obj: StorageObject, data: Buffer): Promise<void> {
const boundary = "reward-image-optimize-boundary";
const metadata = {
name: obj.name,
contentType: "image/webp",
metadata: { ...(obj.metadata ?? {}), [OPTIMIZED_FLAG]: "true" },
};
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n` +
`${JSON.stringify(metadata)}\r\n` +
`--${boundary}\r\nContent-Type: image/webp\r\n\r\n`),
data,
Buffer.from(`\r\n--${boundary}--`),
]);
const res = await fetch(`${uploadBase}?uploadType=multipart`, {
method: "POST",
headers: { ...authHeader, "Content-Type": `multipart/related; boundary=${boundary}` },
body,
});
if (!res.ok) throw new Error(`upload failed for ${obj.name}: ${res.status} ${await res.text()}`);
}
async function main() {
const objects = await listObjects();
let before = 0;
let after = 0;
for (const obj of objects) {
const size = Number(obj.size);
const target = targetForPath(obj.name);
if (!target) { console.log(`skip (path) ${obj.name}`); continue; }
if (obj.metadata?.[OPTIMIZED_FLAG] === "true") {
console.log(`skip (done) ${obj.name}`);
before += size; after += size;
continue;
}
const input = await download(obj.name);
const output = await optimizeRewardImage(input, target);
before += size;
if (output.data.length >= input.length) {
await markOptimized(obj.name);
after += size;
console.log(`skip (small) ${obj.name} ${(size / 1024).toFixed(0)}KB`);
continue;
}
await overwrite(obj, output.data);
after += output.data.length;
console.log(
`optimized ${obj.name} ` +
`${(size / 1048576).toFixed(1)}MB -> ${(output.data.length / 1024).toFixed(0)}KB ` +
`(${output.width}x${output.height})`);
}
console.log(`\nTOTAL ${objects.length} files: ${(before / 1048576).toFixed(1)}MB -> ${(after / 1048576).toFixed(1)}MB`);
}
main().catch((err) => { console.error(err); process.exit(1); });