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 });
|
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!;
|
const prefix = process.env.R2_PUBLIC_URL!;
|
||||||
if (!publicUrl.startsWith(prefix)) return publicUrl;
|
if (!publicUrl.startsWith(prefix)) return null;
|
||||||
const key = publicUrl.replace(prefix, "").replace(/^\//, "");
|
return publicUrl.replace(prefix, "").replace(/^\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signUrl(publicUrl: string) {
|
||||||
|
const key = keyFromPublicUrl(publicUrl);
|
||||||
|
if (key === null) return publicUrl;
|
||||||
return getPresignedReadUrl(key);
|
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/client-s3": "^3.1015.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@ -6355,6 +6356,18 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"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/client-s3": "^3.1015.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.4",
|
"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
|
chapterId Int
|
||||||
number Int
|
number Int
|
||||||
imageUrl String
|
imageUrl String
|
||||||
|
width Int @default(0)
|
||||||
|
height Int @default(0)
|
||||||
chapter Chapter @relation(fields: [chapterId], references: [id])
|
chapter Chapter @relation(fields: [chapterId], references: [id])
|
||||||
|
|
||||||
@@unique([chapterId, number])
|
@@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