diff --git a/Dockerfile b/Dockerfile index 5485ff9..9a698cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,9 @@ RUN bun run build FROM oven/bun:1 AS run WORKDIR /app ENV NODE_ENV=production +# ffmpeg generates poster thumbnails for uploaded reels (lib/media.ts). +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* COPY --from=build /app ./ EXPOSE 8080 CMD ["bun", "run", "start"] diff --git a/app/api/media/[id]/route.ts b/app/api/media/[id]/route.ts index 5e09d72..9f38939 100644 --- a/app/api/media/[id]/route.ts +++ b/app/api/media/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { Readable } from "node:stream"; import { resolveAccountId, bad, unauthorized } from "@/lib/api"; import { accountOwnsDomain, getMedia, deleteMedia } from "@/lib/db"; -import { mediaSize, mediaStream, deleteMediaFile } from "@/lib/media"; +import { mediaSize, mediaStream, deleteMediaFile, thumbFilename } from "@/lib/media"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -59,6 +59,9 @@ export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: stri if (!(await accountOwnsDomain(accountId, m.dn))) return bad("You don't own that domain.", 403); const removed = await deleteMedia(id); - if (removed) deleteMediaFile(removed.filename); + if (removed) { + deleteMediaFile(removed.filename); + deleteMediaFile(thumbFilename(removed.filename)); // drop its poster too + } return NextResponse.json({ ok: true }); } diff --git a/app/api/media/[id]/thumb/route.ts b/app/api/media/[id]/thumb/route.ts new file mode 100644 index 0000000..603ee0f --- /dev/null +++ b/app/api/media/[id]/thumb/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Readable } from "node:stream"; +import { getMedia } from "@/lib/db"; +import { mediaSize, mediaStream, thumbFilename } from "@/lib/media"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Serve the poster thumbnail (_thumb.png) generated on upload. +export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + const m = await getMedia(id); + if (!m) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const tf = thumbFilename(m.filename); + const size = mediaSize(tf); + if (size == null) return NextResponse.json({ error: "No thumbnail" }, { status: 404 }); + + const stream = Readable.toWeb(mediaStream(tf)) as ReadableStream; + return new NextResponse(stream, { + status: 200, + headers: { + "content-type": "image/png", + "content-length": String(size), + "cache-control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/app/api/media/route.ts b/app/api/media/route.ts index 251dba7..4e515c9 100644 --- a/app/api/media/route.ts +++ b/app/api/media/route.ts @@ -3,12 +3,12 @@ import { randomBytes } from "node:crypto"; import { resolveAccountId, bad, unauthorized } from "@/lib/api"; import { accountOwnsDomain, addMedia, listMedia, type Media } from "@/lib/db"; import { safeDomain } from "@/lib/config"; -import { writeMedia, ALLOWED_TYPES, MAX_UPLOAD_BYTES } from "@/lib/media"; +import { writeMedia, generateThumbnail, hasThumb, ALLOWED_TYPES, MAX_UPLOAD_BYTES } from "@/lib/media"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -/** Public view of a reel — the streaming URL + safe metadata. */ +/** Public view of a reel — the streaming URL, poster thumbnail + safe metadata. */ function mediaView(m: Media) { return { id: m.id, @@ -18,6 +18,7 @@ function mediaView(m: Media) { size: m.size, created_at: m.created_at, url: `/api/media/${m.id}`, + thumb: hasThumb(m.filename) ? `/api/media/${m.id}/thumb` : null, }; } @@ -57,6 +58,8 @@ export async function POST(req: NextRequest) { const id = randomBytes(16).toString("hex"); const filename = `${id}${ext}`; await writeMedia(filename, bytes); + // Best-effort poster frame — never fail the upload if ffmpeg is unavailable. + try { await generateThumbnail(filename); } catch { /* no poster, no problem */ } const title = String(form.get("title") || "").trim().slice(0, 120) || file.name; const media = await addMedia({ diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 32be839..9c93969 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -559,7 +559,7 @@ function VideosPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m
{reels.map((m) => (
-