Skip to content

Commit 847fe17

Browse files
committed
Add resilient image thumbnail generation
1 parent ede820d commit 847fe17

4 files changed

Lines changed: 145 additions & 18 deletions

File tree

app/actions/upload.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "@/lib/media/s3";
2525
import {
2626
generateAndUploadThumbnail,
27+
getThumbnailS3Key,
2728
processImageUpload,
2829
} from "@/lib/media/thumbnail";
2930
import { validateMediaFile } from "@/lib/media/validation";
@@ -398,6 +399,7 @@ export async function finalizeUpload(
398399
let realFileSize = data.fileSize;
399400
let serverExifData: Record<string, unknown> | null = null;
400401
let thumbnailS3Key = data.thumbnailS3Key;
402+
const canonicalThumbnailS3Key = getThumbnailS3Key(mediaId);
401403
try {
402404
const headCommand = new HeadObjectCommand({
403405
Bucket: S3_BUCKET_NAME,
@@ -435,6 +437,12 @@ export async function finalizeUpload(
435437
) {
436438
thumbnailS3Key = null;
437439
}
440+
if (
441+
!thumbnailS3Key &&
442+
(await objectExists(canonicalThumbnailS3Key))
443+
) {
444+
thumbnailS3Key = canonicalThumbnailS3Key;
445+
}
438446
if (data.mimeType.startsWith("image/")) {
439447
try {
440448
const getCommand = new GetObjectCommand({

app/api/cron/repair-thumbnails/route.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { db } from "@/lib/db";
1111
import { media } from "@/lib/db/schema";
1212
import { logger, recordException, serializeError } from "@/lib/logger";
1313
import { s3Client, uploadToS3 } from "@/lib/media/s3";
14-
import { generateAndUploadThumbnail } from "@/lib/media/thumbnail";
14+
import {
15+
generateAndUploadThumbnail,
16+
getThumbnailS3Key,
17+
} from "@/lib/media/thumbnail";
1518
import { recordCronJob } from "@/lib/telemetry";
1619

1720
const BATCH_SIZE = 500;
@@ -115,7 +118,7 @@ async function fallbackImageThumbnail(
115118
? lastError
116119
: new Error("Could not extract real image thumbnail");
117120
}
118-
const thumbnailS3Key = `media/${mediaId}/thumbnail.jpg`;
121+
const thumbnailS3Key = getThumbnailS3Key(mediaId);
119122
await uploadToS3(output, thumbnailS3Key, "image/jpeg", undefined, tags);
120123
return thumbnailS3Key;
121124
}
@@ -159,7 +162,7 @@ async function fallbackVideoThumbnail(
159162
.resize(400, 400, { fit: "cover", position: "center" })
160163
.jpeg({ quality: 82, mozjpeg: true })
161164
.toBuffer();
162-
const thumbnailS3Key = `media/${mediaId}/thumbnail.jpg`;
165+
const thumbnailS3Key = getThumbnailS3Key(mediaId);
163166
await uploadToS3(
164167
thumbnailBuffer,
165168
thumbnailS3Key,
@@ -198,6 +201,17 @@ export async function GET(request: NextRequest) {
198201
: false;
199202
if (hasThumbnail) return { repaired: 0, failed: 0 };
200203
try {
204+
const canonicalThumbnailS3Key = getThumbnailS3Key(item.id);
205+
if (
206+
item.thumbnailS3Key !== canonicalThumbnailS3Key &&
207+
(await objectExists(canonicalThumbnailS3Key))
208+
) {
209+
await db
210+
.update(media)
211+
.set({ thumbnailS3Key: canonicalThumbnailS3Key })
212+
.where(eq(media.id, item.id));
213+
return { repaired: 1, failed: 0 };
214+
}
201215
const sourceKeys = [
202216
item.s3Key,
203217
item.blurredS3Key,

app/media/[mediaId]/[[...variant]]/route.ts

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,60 @@ import { media, users } from "@/lib/db/schema";
1111
import { logger } from "@/lib/logger";
1212
import { convertHeicToJpeg } from "@/lib/media/heic";
1313
import { S3_BUCKET_NAME, s3Client } from "@/lib/media/s3";
14+
import {
15+
generateAndUploadThumbnail,
16+
getThumbnailS3Key,
17+
} from "@/lib/media/thumbnail";
1418
import { can } from "@/lib/policy";
1519
import { contentDispositionFilename } from "@/lib/safe-filename";
1620

21+
async function streamToBuffer(stream: AsyncIterable<Uint8Array>) {
22+
const chunks: Uint8Array[] = [];
23+
for await (const chunk of stream) {
24+
chunks.push(chunk);
25+
}
26+
return Buffer.concat(chunks);
27+
}
28+
29+
async function generateMissingImageThumbnail(
30+
mediaItem: typeof media.$inferSelect,
31+
) {
32+
if (!mediaItem.mimeType.startsWith("image/")) return null;
33+
try {
34+
const source = await s3Client.send(
35+
new GetObjectCommand({
36+
Bucket: S3_BUCKET_NAME,
37+
Key: mediaItem.s3Key,
38+
}),
39+
);
40+
if (!source.Body) throw new Error("S3 object had no body");
41+
const buffer = await streamToBuffer(
42+
source.Body as AsyncIterable<Uint8Array>,
43+
);
44+
const thumbnailS3Key = await generateAndUploadThumbnail(
45+
buffer,
46+
mediaItem.mimeType,
47+
mediaItem.id,
48+
undefined,
49+
{ uploadedBy: mediaItem.uploadedById, eventId: mediaItem.eventId },
50+
);
51+
if (!thumbnailS3Key) return null;
52+
if (thumbnailS3Key !== mediaItem.thumbnailS3Key) {
53+
await db
54+
.update(media)
55+
.set({ thumbnailS3Key })
56+
.where(eq(media.id, mediaItem.id));
57+
}
58+
return thumbnailS3Key;
59+
} catch (error) {
60+
logger.error(
61+
{ mediaId: mediaItem.id, error },
62+
"thumbnail generation failed",
63+
);
64+
return null;
65+
}
66+
}
67+
1768
export async function GET(
1869
request: NextRequest,
1970
{
@@ -126,13 +177,13 @@ export async function GET(
126177
let s3Key = mediaItem.s3Key;
127178
let filename = contentDispositionFilename(mediaItem.filename);
128179
if (variant === "thumbnail") {
180+
const baseName =
181+
filename.substring(0, filename.lastIndexOf(".")) || filename;
182+
filename = `thumbnail_${baseName}.jpg`;
129183
if (mediaItem.thumbnailS3Key) {
130184
s3Key = mediaItem.thumbnailS3Key;
131-
const baseName =
132-
filename.substring(0, filename.lastIndexOf(".")) || filename;
133-
filename = `thumbnail_${baseName}.jpg`;
134-
} else {
135-
s3Key = mediaItem.s3Key;
185+
} else if (mediaItem.mimeType.startsWith("image/")) {
186+
s3Key = getThumbnailS3Key(mediaItem.id);
136187
}
137188
}
138189
const command = new GetObjectCommand({
@@ -147,15 +198,62 @@ export async function GET(
147198
let s3Response: GetObjectCommandOutput;
148199
try {
149200
s3Response = await s3Client.send(command);
201+
if (
202+
variant === "thumbnail" &&
203+
mediaItem.mimeType.startsWith("image/") &&
204+
!mediaItem.thumbnailS3Key &&
205+
s3Key === getThumbnailS3Key(mediaItem.id)
206+
) {
207+
await db
208+
.update(media)
209+
.set({ thumbnailS3Key: s3Key })
210+
.where(eq(media.id, mediaItem.id));
211+
}
150212
} catch (error: any) {
151213
if (error?.$metadata?.httpStatusCode === 304) {
152214
return new NextResponse(null, { status: 304 });
153215
}
154-
logger.error(`Failed to fetch from S3:`, error);
155-
return new NextResponse("Failed to fetch media", { status: 502 });
216+
if (variant === "thumbnail" && mediaItem.mimeType.startsWith("image/")) {
217+
const regeneratedThumbnailKey =
218+
await generateMissingImageThumbnail(mediaItem);
219+
if (regeneratedThumbnailKey) {
220+
try {
221+
s3Response = await s3Client.send(
222+
new GetObjectCommand({
223+
Bucket: S3_BUCKET_NAME,
224+
Key: regeneratedThumbnailKey,
225+
Range: requestRange,
226+
IfNoneMatch: request.headers.get("if-none-match") ?? undefined,
227+
IfModifiedSince: request.headers.get("if-modified-since")
228+
? new Date(request.headers.get("if-modified-since") as string)
229+
: undefined,
230+
}),
231+
);
232+
} catch (retryError: any) {
233+
if (retryError?.$metadata?.httpStatusCode === 304) {
234+
return new NextResponse(null, { status: 304 });
235+
}
236+
logger.error(
237+
"Failed to fetch regenerated thumbnail from S3:",
238+
retryError,
239+
);
240+
return new NextResponse("Failed to fetch media", { status: 502 });
241+
}
242+
} else {
243+
logger.error(`Failed to fetch from S3:`, error);
244+
return new NextResponse("Failed to fetch media", { status: 502 });
245+
}
246+
} else {
247+
logger.error(`Failed to fetch from S3:`, error);
248+
return new NextResponse("Failed to fetch media", { status: 502 });
249+
}
156250
}
157251
const headers = new Headers();
158-
headers.set("Content-Type", s3Response.ContentType || mediaItem.mimeType);
252+
headers.set(
253+
"Content-Type",
254+
s3Response.ContentType ||
255+
(variant === "thumbnail" ? "image/jpeg" : mediaItem.mimeType),
256+
);
159257
headers.set("X-Content-Type-Options", "nosniff");
160258
if (s3Response.ContentLength) {
161259
headers.set("Content-Length", String(s3Response.ContentLength));

lib/media/thumbnail.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ import {
1414
traceAsync,
1515
} from "@/lib/telemetry";
1616
import { deleteFromS3, deleteFromS3Batch, uploadToS3 } from "./s3";
17+
18+
export function getThumbnailS3Key(mediaId: string) {
19+
return `media/${mediaId}/thumbnail.jpg`;
20+
}
21+
22+
export function isHeicMimeType(mimeType?: string | null) {
23+
const normalized = mimeType?.split(";")[0]?.toLowerCase();
24+
return normalized === "image/heic" || normalized === "image/heif";
25+
}
26+
1727
export async function processImageUpload(
1828
input: Readable | Buffer,
1929
mediaId: string,
@@ -124,7 +134,7 @@ async function uploadThumbnail(
124134
tags?: Record<string, string>,
125135
signal?: AbortSignal,
126136
) {
127-
const thumbnailS3Key = `media/${mediaId}/thumbnail.jpg`;
137+
const thumbnailS3Key = getThumbnailS3Key(mediaId);
128138
await uploadToS3(thumbnailBuffer, thumbnailS3Key, "image/jpeg", signal, tags);
129139
return thumbnailS3Key;
130140
}
@@ -138,10 +148,7 @@ async function generateImageThumbnailBuffer(
138148
height?: number;
139149
exifBuffer?: Buffer;
140150
}> {
141-
const isHeic =
142-
mimeType?.toLowerCase() === "image/heic" ||
143-
mimeType?.toLowerCase() === "image/heif";
144-
if (isHeic) {
151+
if (isHeicMimeType(mimeType)) {
145152
let decoder: any = decode;
146153
if (
147154
typeof decoder !== "function" &&
@@ -190,7 +197,7 @@ async function processImageUploadInternal(
190197
exifBuffer,
191198
};
192199
} catch (error: any) {
193-
if (mimeType?.toLowerCase() !== "image/heic") {
200+
if (!isHeicMimeType(mimeType)) {
194201
logger.info(
195202
"Sharp failed with unsupported format, attempting HEIC fallback...",
196203
);
@@ -336,7 +343,7 @@ async function generateVideoThumbnail(
336343
sharp(thumbnailBuffer),
337344
);
338345
if (signal?.aborted) return null;
339-
const thumbnailS3Key = `media/${mediaId}/thumbnail.jpg`;
346+
const thumbnailS3Key = getThumbnailS3Key(mediaId);
340347
await uploadToS3(
341348
processedThumbnail,
342349
thumbnailS3Key,

0 commit comments

Comments
 (0)