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:
parent
3745f1f316
commit
0a1365a743
11
lib/r2.ts
11
lib/r2.ts
@ -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
13
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0;
|
||||
@ -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])
|
||||
|
||||
89
scripts/backfill-page-dims.ts
Normal file
89
scripts/backfill-page-dims.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user