Store page dimensions to reserve layout space upfront

- Add width/height columns to Page (default 0, migration SQL committed).
- Backfill script ranges first 16KB of each R2 object and parses with
  image-size. Probed all 26,209 existing pages successfully.
- Export keyFromPublicUrl from lib/r2 so the script reuses the existing
  URL→key logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-04-12 13:01:27 +08:00
parent 3745f1f316
commit 0a1365a743
6 changed files with 116 additions and 3 deletions

View File

@ -35,10 +35,15 @@ export async function getPresignedReadUrl(key: string) {
return getSignedUrl(s3, command, { expiresIn: 60 });
}
export async function signUrl(publicUrl: string) {
export function keyFromPublicUrl(publicUrl: string): string | null {
const prefix = process.env.R2_PUBLIC_URL!;
if (!publicUrl.startsWith(prefix)) return publicUrl;
const key = publicUrl.replace(prefix, "").replace(/^\//, "");
if (!publicUrl.startsWith(prefix)) return null;
return publicUrl.replace(prefix, "").replace(/^\//, "");
}
export async function signUrl(publicUrl: string) {
const key = keyFromPublicUrl(publicUrl);
if (key === null) return publicUrl;
return getPresignedReadUrl(key);
}

13
package-lock.json generated
View File

@ -11,6 +11,7 @@
"@aws-sdk/client-s3": "^3.1015.0",
"@aws-sdk/s3-request-presigner": "^3.1015.0",
"@prisma/client": "^6.19.2",
"image-size": "^2.0.2",
"next": "16.2.1",
"prisma": "^6.19.2",
"react": "19.2.4",
@ -6355,6 +6356,18 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
"license": "MIT",
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View File

@ -16,6 +16,7 @@
"@aws-sdk/client-s3": "^3.1015.0",
"@aws-sdk/s3-request-presigner": "^3.1015.0",
"@prisma/client": "^6.19.2",
"image-size": "^2.0.2",
"next": "16.2.1",
"prisma": "^6.19.2",
"react": "19.2.4",

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0;

View File

@ -36,6 +36,8 @@ model Page {
chapterId Int
number Int
imageUrl String
width Int @default(0)
height Int @default(0)
chapter Chapter @relation(fields: [chapterId], references: [id])
@@unique([chapterId, number])

View File

@ -0,0 +1,89 @@
/**
* Backfill `width` and `height` on Page rows by range-fetching the first
* 16 KB of each image from R2 and parsing its header with `image-size`.
*
* Idempotent: only targets rows where width=0 or height=0.
*
* Usage: npx tsx scripts/backfill-page-dims.ts
*/
import { PrismaClient } from "@prisma/client";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { imageSize } from "image-size";
import { keyFromPublicUrl } from "@/lib/r2";
const prisma = new PrismaClient();
const BUCKET = process.env.R2_BUCKET;
if (!BUCKET) throw new Error("R2_BUCKET must be set");
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY!,
secretAccessKey: process.env.R2_SECRET_KEY!,
},
});
const CONCURRENCY = 10;
const HEADER_BYTES = 16_384;
async function fetchHeader(key: string): Promise<Uint8Array> {
const res = await s3.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: key,
Range: `bytes=0-${HEADER_BYTES - 1}`,
})
);
if (!res.Body) throw new Error(`No body for ${key}`);
return res.Body.transformToByteArray();
}
async function main() {
const pages = await prisma.page.findMany({
where: { OR: [{ width: 0 }, { height: 0 }] },
orderBy: { id: "asc" },
});
console.log(`Probing ${pages.length} pages with dims unset`);
let done = 0;
let failed = 0;
for (let i = 0; i < pages.length; i += CONCURRENCY) {
const batch = pages.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async (page) => {
try {
const key = keyFromPublicUrl(page.imageUrl);
if (!key) throw new Error(`URL outside R2 prefix: ${page.imageUrl}`);
const header = await fetchHeader(key);
const { width, height } = imageSize(header);
if (!width || !height) {
throw new Error("image-size returned no dimensions");
}
await prisma.page.update({
where: { id: page.id },
data: { width, height },
});
done++;
} catch (err) {
failed++;
console.error(
`✗ page ${page.id} (${page.imageUrl}):`,
err instanceof Error ? err.message : err
);
}
})
);
console.log(`${Math.min(i + CONCURRENCY, pages.length)}/${pages.length}`);
}
console.log(`\nDone. Probed: ${done}, failed: ${failed}`);
await prisma.$disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});