diff --git a/lib/r2.ts b/lib/r2.ts index 3b16deb..ddaafd7 100644 --- a/lib/r2.ts +++ b/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); } diff --git a/package-lock.json b/package-lock.json index 45e1bfb..2e43f7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2b7b3e2..61e34c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260412000000_add_page_dims/migration.sql b/prisma/migrations/20260412000000_add_page_dims/migration.sql new file mode 100644 index 0000000..4c6dd88 --- /dev/null +++ b/prisma/migrations/20260412000000_add_page_dims/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Page" ADD COLUMN "height" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "width" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2a5059..c005b40 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/scripts/backfill-page-dims.ts b/scripts/backfill-page-dims.ts new file mode 100644 index 0000000..bdde175 --- /dev/null +++ b/scripts/backfill-page-dims.ts @@ -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 { + 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); +});