feat: add asset variants table and URL selection

This commit is contained in:
William Valentin
2026-02-01 12:08:18 -08:00
parent 24a092544e
commit 26e2d74d2b
5 changed files with 190 additions and 17 deletions

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { getDb } from "@tline/db";
import { presignGetObjectUrl } from "@tline/minio";
import { pickVariantKey } from "./variant";
export const runtime = "nodejs";
@@ -9,7 +10,15 @@ const paramsSchema = z.object({
id: z.string().uuid()
});
const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
const legacyVariantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
const kindSchema = z.enum(["original", "thumb", "poster", "video_mp4"]);
const sizeSchema = z.coerce.number().int().positive();
const legacyVariantMap = {
original: { kind: "original" as const },
thumb_small: { kind: "thumb" as const, size: 256 },
thumb_med: { kind: "thumb" as const, size: 768 },
poster: { kind: "poster" as const, size: 256 },
};
export async function GET(
request: Request,
@@ -26,14 +35,46 @@ export async function GET(
const params = paramsParsed.data;
const url = new URL(request.url);
const variantParsed = variantSchema.safeParse(url.searchParams.get("variant") ?? "original");
if (!variantParsed.success) {
return Response.json(
{ error: "invalid_query", issues: variantParsed.error.issues },
{ status: 400 },
);
const kindParam = url.searchParams.get("kind");
const sizeParam = url.searchParams.get("size");
const legacyVariantParam = url.searchParams.get("variant");
let requestedKind: z.infer<typeof kindSchema> = "original";
let requestedSize: number | null = null;
let legacyVariant: z.infer<typeof legacyVariantSchema> | null = null;
if (kindParam) {
const kindParsed = kindSchema.safeParse(kindParam);
if (!kindParsed.success) {
return Response.json(
{ error: "invalid_query", issues: kindParsed.error.issues },
{ status: 400 },
);
}
requestedKind = kindParsed.data;
if (requestedKind !== "original") {
const sizeParsed = sizeSchema.safeParse(sizeParam);
if (!sizeParsed.success) {
return Response.json(
{ error: "invalid_query", issues: sizeParsed.error.issues },
{ status: 400 },
);
}
requestedSize = sizeParsed.data;
}
} else if (legacyVariantParam) {
const legacyParsed = legacyVariantSchema.safeParse(legacyVariantParam);
if (!legacyParsed.success) {
return Response.json(
{ error: "invalid_query", issues: legacyParsed.error.issues },
{ status: 400 },
);
}
legacyVariant = legacyParsed.data;
const mapped = legacyVariantMap[legacyVariant];
requestedKind = mapped.kind;
requestedSize = "size" in mapped ? mapped.size : null;
}
const variant = variantParsed.data;
const db = getDb();
const rows = await db<
@@ -52,32 +93,70 @@ export async function GET(
limit 1
`;
const variants = await db<
{
kind: string;
size: number;
key: string;
mime_type: string;
width: number | null;
height: number | null;
}[]
>`
select kind, size, key, mime_type, width, height
from asset_variants
where asset_id = ${params.id}
`;
const asset = rows[0];
if (!asset) {
return Response.json({ error: "not_found" }, { status: 404 });
}
const legacyKey =
legacyVariant === "thumb_small"
? asset.thumb_small_key
: legacyVariant === "thumb_med"
? asset.thumb_med_key
: legacyVariant === "poster"
? asset.poster_key
: null;
const key =
variant === "original"
requestedKind === "original"
? asset.active_key
: variant === "thumb_small"
? asset.thumb_small_key
: variant === "thumb_med"
? asset.thumb_med_key
: asset.poster_key;
: requestedSize !== null
? pickVariantKey(
{ variants },
{ kind: requestedKind, size: requestedSize },
) ?? legacyKey
: null;
if (!key) {
return Response.json(
{ error: "variant_not_available", variant },
{ error: "variant_not_available", kind: requestedKind, size: requestedSize },
{ status: 404 }
);
}
// Hint the browser; especially helpful for Range playback.
const responseContentType = variant === "original" ? asset.mime_type : "image/jpeg";
const matchedVariant =
requestedKind === "original" || requestedSize === null
? null
: variants.find(
(item) => item.kind === requestedKind && item.size === requestedSize,
) ?? null;
const responseContentType =
requestedKind === "original"
? asset.mime_type
: matchedVariant?.mime_type ??
(requestedKind === "video_mp4" ? "video/mp4" : "image/jpeg");
const responseContentDisposition =
variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined;
(requestedKind === "original" && asset.mime_type.startsWith("video/")) ||
requestedKind === "video_mp4"
? "inline"
: undefined;
const signed = await presignGetObjectUrl({
bucket: asset.bucket,

View File

@@ -0,0 +1,9 @@
export function pickVariantKey(
input: { variants: Array<{ kind: string; size: number; key: string }> },
req: { kind: string; size: number },
) {
const v = input.variants.find(
(x) => x.kind === req.kind && x.size === req.size,
);
return v?.key ?? null;
}

View File

@@ -0,0 +1,9 @@
import { test, expect } from "bun:test";
test("/api/assets/:id/url returns 404 when requested variant missing", async () => {
const { pickVariantKey } = await import(
"../../app/api/assets/[id]/url/variant",
);
const key = pickVariantKey({ variants: [] }, { kind: "thumb", size: 256 });
expect(key).toBeNull();
});

View File

@@ -205,6 +205,35 @@ async function uploadObject(input: {
);
}
async function upsertVariant(input: {
assetId: string;
kind: "thumb" | "poster" | "video_mp4";
size: number;
key: string;
mimeType: string;
width?: number | null;
height?: number | null;
}) {
const db = getDb();
await db`
insert into asset_variants (asset_id, kind, size, key, mime_type, width, height)
values (
${input.assetId},
${input.kind},
${input.size},
${input.key},
${input.mimeType},
${input.width ?? null},
${input.height ?? null}
)
on conflict (asset_id, kind, size)
do update set key = excluded.key,
mime_type = excluded.mime_type,
width = excluded.width,
height = excluded.height
`;
}
async function getObjectLastModified(input: { bucket: string; key: string }): Promise<Date | null> {
const s3 = getMinioInternalClient();
const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key }));
@@ -424,6 +453,24 @@ export async function handleProcessAsset(raw: unknown) {
filePath: thumb768Path,
contentType: "image/jpeg",
});
await upsertVariant({
assetId: asset.id,
kind: "thumb",
size: 256,
key: thumb256Key,
mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
await upsertVariant({
assetId: asset.id,
kind: "thumb",
size: 768,
key: thumb768Key,
mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
updates.thumb_small_key = thumb256Key;
updates.thumb_med_key = thumb768Key;
} else if (asset.media_type === "video") {
@@ -485,6 +532,15 @@ export async function handleProcessAsset(raw: unknown) {
filePath: posterPath,
contentType: "image/jpeg",
});
await upsertVariant({
assetId: asset.id,
kind: "poster",
size: 256,
key: posterKey,
mimeType: "image/jpeg",
width: typeof updates.width === "number" ? updates.width : null,
height: typeof updates.height === "number" ? updates.height : null,
});
updates.poster_key = posterKey;
}