- reward-products/ 이미지를 리사이즈(메인 1080px, 상세 너비 1440px) + WebP(품질 82)로 변환하는 공용 서비스 추가 (sharp) - Storage 트리거 onRewardImageUploaded(us-central1)로 이후 업로드에도 자동 적용 — 다운로드 토큰을 보존해 기존 URL이 그대로 유효 - 기존 자산 일괄 최적화 스크립트(npm run optimize:reward-images) 추가, 실행 결과 38개 파일 135MB → 5.7MB - 재처리 무한루프는 rewardImageOptimized 메타데이터 플래그로 방지
116 lines
4.6 KiB
TypeScript
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); });
|