Add timelapse upload and display to image edit page

This commit is contained in:
2026-01-30 22:18:24 +01:00
parent 95c44b7f8e
commit 3ce4c9acfc
8 changed files with 370 additions and 2 deletions

View File

@ -0,0 +1,136 @@
"use server";
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { revalidatePath } from "next/cache";
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
const createUploadSchema = z.object({
artworkId: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/**
* Creates a presigned PUT url so the client can upload large timelapse videos directly to S3
* (avoids Next.js body-size/proxy limits).
*/
export async function createArtworkTimelapseUpload(input: z.infer<typeof createUploadSchema>) {
const { artworkId, fileName, mimeType, sizeBytes } = createUploadSchema.parse(input);
const ext = fileName.includes(".") ? fileName.split(".").pop() : undefined;
const suffix = ext ? `.${ext}` : "";
// Keep previous uploads unique so caching/CDNs won't bite you.
const fileId = uuidv4();
const s3Key = `timelapse/${artworkId}/${fileId}${suffix}`;
const cmd = new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: s3Key,
ContentType: mimeType,
// If you want size enforcement at S3 level, you'd do that via policy; presigned PUT doesn't strictly enforce.
});
const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 });
return { uploadUrl, s3Key, fileName, mimeType, sizeBytes };
}
const confirmSchema = z.object({
artworkId: z.string().min(1),
s3Key: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/** Persist uploaded timelapse metadata in DB (upsert by artworkId). */
export async function confirmArtworkTimelapseUpload(input: z.infer<typeof confirmSchema>) {
const { artworkId, s3Key, fileName, mimeType, sizeBytes } = confirmSchema.parse(input);
// If an old timelapse exists, delete the old object so you don't leak storage.
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (existing?.s3Key && existing.s3Key !== s3Key) {
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: existing.s3Key,
})
);
} catch (err) {
// don't fail the request if cleanup fails
console.warn("Failed to delete previous timelapse object", existing.s3Key, err);
}
}
await prisma.artworkTimelapse.upsert({
where: { artworkId },
create: {
artworkId,
s3Key,
fileName,
mimeType,
sizeBytes,
enabled: true,
},
update: {
s3Key,
fileName,
mimeType,
sizeBytes,
},
});
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}
const enabledSchema = z.object({
artworkId: z.string().min(1),
enabled: z.boolean(),
});
export async function setArtworkTimelapseEnabled(input: z.infer<typeof enabledSchema>) {
const { artworkId, enabled } = enabledSchema.parse(input);
await prisma.artworkTimelapse.update({
where: { artworkId },
data: { enabled },
});
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}
const deleteSchema = z.object({
artworkId: z.string().min(1),
});
export async function deleteArtworkTimelapse(input: z.infer<typeof deleteSchema>) {
const { artworkId } = deleteSchema.parse(input);
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (!existing) return { ok: true };
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: existing.s3Key,
})
);
} catch (err) {
console.warn("Failed to delete timelapse object", existing.s3Key, err);
}
await prisma.artworkTimelapse.delete({ where: { artworkId } });
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}