Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
7 changes: 5 additions & 2 deletions app/api/media/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
28 changes: 28 additions & 0 deletions app/api/media/[id]/thumb/route.ts
Original file line number Diff line number Diff line change
@@ -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 (<name>_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",
},
});
}
7 changes: 5 additions & 2 deletions app/api/media/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ function VideosPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m
<div className="video-grid">
{reels.map((m) => (
<figure key={m.id} className="video-cell">
<video src={m.url} controls preload="metadata" playsInline />
<video src={m.url} poster={m.thumb || undefined} controls preload="metadata" playsInline />
<figcaption className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<span>{m.title}</span>
<button className="btn2 ghost" disabled={busy} onClick={() => del(m.id)}>Delete</button>
Expand Down
26 changes: 16 additions & 10 deletions app/videos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { listMedia } from "@/lib/db";
import { hasThumb } from "@/lib/media";

export const dynamic = "force-dynamic";

Expand All @@ -10,21 +11,25 @@ export const metadata: Metadata = {
description: "moshcoding mp4 reels & clips. Code hard, mosh harder — watch the pit.",
};

type Reel = { src: string; title: string; portrait: boolean };
type Reel = { src: string; title: string; portrait: boolean; poster?: string };

/** Built-in brand reels shipped in public/videos. */
/** Built-in brand reels shipped in public/videos (poster = <name>_thumb.png if present). */
function builtinReels(): Reel[] {
try {
const dir = path.join(process.cwd(), "public", "videos");
return fs
.readdirSync(dir)
const files = fs.readdirSync(dir);
return files
.filter((f) => f.toLowerCase().endsWith(".mp4"))
.sort()
.map((f) => ({
src: `/videos/${f}`,
title: f.replace(/\.mp4$/i, "").replace(/-/g, " ").trim(),
portrait: /9x16/i.test(f),
}));
.map((f) => {
const thumb = f.replace(/\.mp4$/i, "_thumb.png");
return {
src: `/videos/${f}`,
title: f.replace(/\.mp4$/i, "").replace(/-/g, " ").trim(),
portrait: /9x16/i.test(f),
poster: files.includes(thumb) ? `/videos/${thumb}` : undefined,
};
});
} catch {
return [];
}
Expand All @@ -38,6 +43,7 @@ async function uploadedReels(): Promise<Reel[]> {
src: `/api/media/${m.id}`,
title: m.title || m.orig_name || "reel",
portrait: false,
poster: hasThumb(m.filename) ? `/api/media/${m.id}/thumb` : undefined,
}));
} catch {
return [];
Expand All @@ -64,7 +70,7 @@ export default async function VideosPage() {
<div className="video-grid">
{reels.map((r) => (
<figure key={r.src} className={`video-cell${r.portrait ? " portrait" : ""}`}>
<video src={r.src} controls preload="metadata" playsInline />
<video src={r.src} poster={r.poster} controls preload="metadata" playsInline />
<figcaption>{r.title}</figcaption>
</figure>
))}
Expand Down
45 changes: 45 additions & 0 deletions lib/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// Mount a volume at DATA_DIR in production or uploads won't survive a redeploy.
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";

const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), ".data");
const MEDIA_DIR = path.join(DATA_DIR, "media");
Expand Down Expand Up @@ -53,3 +54,47 @@ export function deleteMediaFile(filename: string): void {
/* already gone — fine */
}
}

// ---- poster thumbnails (ffmpeg) -------------------------------------------

/** The thumbnail name for a video file: `<name>.mp4` → `<name>_thumb.png`. */
export function thumbFilename(videoFilename: string): string {
const base = path.basename(videoFilename).replace(/\.[^.]+$/, "");
return `${base}_thumb.png`;
}

/** True once a poster thumbnail exists on disk for this video. */
export function hasThumb(videoFilename: string): boolean {
return mediaSize(thumbFilename(videoFilename)) != null;
}

function runFfmpeg(args: string[]): Promise<boolean> {
return new Promise((resolve) => {
let proc;
try {
proc = spawn(process.env.FFMPEG_PATH || "ffmpeg", args, { stdio: "ignore" });
} catch {
resolve(false);
return;
}
proc.on("error", () => resolve(false));
proc.on("close", (code) => resolve(code === 0));
});
}

/**
* Extracts a poster frame from an uploaded video into `<name>_thumb.png`
* (640px wide, aspect preserved) using ffmpeg. Best-effort: returns false if
* ffmpeg is missing or the clip can't be decoded — the caller must not fail the
* upload on a false. Grabs a frame ~1s in, falling back to the first frame for
* very short clips.
*/
export async function generateThumbnail(videoFilename: string): Promise<boolean> {
ensureDir();
const input = mediaPath(videoFilename);
const output = mediaPath(thumbFilename(videoFilename));
const frameAt = (t: string) =>
["-y", "-ss", t, "-i", input, "-frames:v", "1", "-vf", "scale=640:-2", output];
if ((await runFfmpeg(frameAt("1"))) && fs.existsSync(output)) return true;
return (await runFfmpeg(frameAt("0"))) && fs.existsSync(output);
}
Binary file added public/videos/moshcoding-reel-16x9_thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/videos/moshcoding-reel-9x16_thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading