diff --git a/app/api/account/route.ts b/app/api/account/route.ts index d39f730..8b861a6 100644 --- a/app/api/account/route.ts +++ b/app/api/account/route.ts @@ -76,9 +76,30 @@ function sanitizeConfig(body: any): Record { // (escape-first) at display time, so no HTML sanitization is needed here. const blocks = cleanBlocks(body?.blocks); if (blocks.length) c.blocks = blocks; + + // Uploaded videos ({name, url, poster?}) — pass through so a config save + // doesn't wipe uploads (they're written to the tenant config by /api/upload). + const videos = cleanVideos(body?.videos); + if (videos.length) c.videos = videos; return c; } +/** Keeps uploaded-video entries: same-origin /api/media/ url + optional poster. */ +function cleanVideos(arr: unknown): { name: string; url: string; poster?: string }[] { + if (!Array.isArray(arr)) return []; + const okUrl = (u: string) => /^\/api\/media\/[A-Za-z0-9._\-/]+$/.test(u) || normalizeUrl(u) === u; + const out: { name: string; url: string; poster?: string }[] = []; + for (const v of arr.slice(0, 24)) { + const url = String((v as any)?.url || "").trim(); + if (!okUrl(url)) continue; + const entry: { name: string; url: string; poster?: string } = { name: String((v as any)?.name || "video").slice(0, 120), url }; + const poster = String((v as any)?.poster || "").trim(); + if (poster && okUrl(poster)) entry.poster = poster; + out.push(entry); + } + return out; +} + /** Sanitizes the content-blocks array: bounded count + size, known types only. */ function cleanBlocks(arr: unknown): { id: string; type: string; content: string; enabled: boolean }[] { if (!Array.isArray(arr)) return []; diff --git a/app/api/media/[...path]/route.ts b/app/api/media/[...path]/route.ts index 4720a91..47798d4 100644 --- a/app/api/media/[...path]/route.ts +++ b/app/api/media/[...path]/route.ts @@ -23,7 +23,14 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ path: strin try { stat = await fsp.stat(full); if (!stat.isFile()) throw 0; } catch { return new Response("not found", { status: 404 }); } const size = stat.size; - const type = "video/mp4"; + const ext = path.extname(full).toLowerCase(); + const type = + ext === ".png" ? "image/png" + : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" + : ext === ".webp" ? "image/webp" + : ext === ".webm" ? "video/webm" + : ext === ".mov" ? "video/quicktime" + : "video/mp4"; const range = req.headers.get("range"); if (range) { diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 43541a4..3494dee 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session"; import { findOrCreateAccountByEmail, ownsParkedDomain, getTenantConfig, upsertTenant } from "@/lib/db"; import { safeDomain } from "@/lib/config"; +import { ffmpegPoster } from "@/lib/media"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -43,16 +44,28 @@ export async function POST(req: NextRequest) { await fs.mkdir(dir, { recursive: true }); const safeBase = (file.name || "video.mp4").replace(/[^A-Za-z0-9._-]/g, "_").replace(/_{2,}/g, "_").slice(0, 80); const fname = `${Date.now().toString(36)}-${safeBase.endsWith(".mp4") ? safeBase : safeBase + ".mp4"}`; - await fs.writeFile(path.join(dir, fname), Buffer.from(await file.arrayBuffer())); + const mp4Path = path.join(dir, fname); + await fs.writeFile(mp4Path, Buffer.from(await file.arrayBuffer())); + + // Generate a poster thumbnail (best-effort — never fail the upload on it). + const thumbName = fname.replace(/\.mp4$/i, "_thumb.png"); + let poster: string | undefined; + try { + if (await ffmpegPoster(mp4Path, path.join(dir, thumbName))) { + poster = `/api/media/${domSlug(dn)}/${thumbName}`; + } + } catch { /* no poster, no problem */ } const url = `/api/media/${domSlug(dn)}/${fname}`; + const entry: { name: string; url: string; poster?: string } = { name: (file.name || fname).slice(0, 120), url }; + if (poster) entry.poster = poster; const config: any = (await getTenantConfig(dn)) || {}; const videos = Array.isArray(config.videos) ? config.videos : []; - videos.unshift({ name: (file.name || fname).slice(0, 120), url }); + videos.unshift(entry); config.videos = videos.slice(0, 24); await upsertTenant(dn, id, config); - return NextResponse.json({ ok: true, video: { name: (file.name || fname).slice(0, 120), url }, videos: config.videos }); + return NextResponse.json({ ok: true, video: entry, videos: config.videos }); } // DELETE /api/upload?dn=&url=/api/media/... — remove a video. @@ -67,10 +80,11 @@ export async function DELETE(req: NextRequest) { const videos = Array.isArray(config.videos) ? config.videos : []; config.videos = videos.filter((v: any) => v?.url !== url); await upsertTenant(dn, id, config); - // Best-effort delete the file (path is validated to be under this domain's dir). + // Best-effort delete the file + its poster (path validated to this domain's dir). const m = /^\/api\/media\/([a-z0-9.-]+)\/([A-Za-z0-9._-]+)$/.exec(url); if (m && m[1] === domSlug(dn)) { fs.unlink(path.join(VIDEO_DIR, m[1], m[2])).catch(() => {}); + fs.unlink(path.join(VIDEO_DIR, m[1], m[2].replace(/\.mp4$/i, "_thumb.png"))).catch(() => {}); } return NextResponse.json({ ok: true, videos: config.videos }); } diff --git a/components/Tenant.tsx b/components/Tenant.tsx index 15dc300..e1f0900 100644 --- a/components/Tenant.tsx +++ b/components/Tenant.tsx @@ -91,7 +91,7 @@ export default function Tenant({ cfg }: { cfg: TenantConfig }) { {cfg.videos.length > 0 && (
{cfg.videos.map((v, i) => ( -
)} diff --git a/lib/config.ts b/lib/config.ts index 7cbefe9..72bd849 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -20,8 +20,8 @@ export type TenantConfig = { hashtags: string[]; /** Image assets pulled from a connected GitHub repo, rendered as a gallery. */ assets: TenantLink[]; - /** Uploaded MP4 videos ({name, url}), rendered in the #videos section. */ - videos: { name: string; url: string }[]; + /** Uploaded MP4 videos ({name, url, poster?}), rendered in the #videos section. */ + videos: { name: string; url: string; poster?: string }[]; /** Genres from ?style=metal,punk — drives the AI hero-image generation. */ styles: string[]; /** Optional background accent (rgba) from ?bg_rgba=; null = use the theme default. */ @@ -398,9 +398,13 @@ export function configFor(dn: string, opts: TenantOverrides = {}): TenantConfig const assets = Array.isArray(ov.assets) ? ov.assets.filter((a: any) => a && a.url).map((a: any) => ({ label: String(a.name || a.label || ""), url: String(a.url), kind: "image" })) : []; - // Uploaded MP4 videos ({name, url}). + // Uploaded MP4 videos ({name, url, poster?}). const videos = Array.isArray(ov.videos) - ? ov.videos.filter((v: any) => v && v.url).map((v: any) => ({ name: String(v.name || "video"), url: String(v.url) })).slice(0, 24) + ? ov.videos.filter((v: any) => v && v.url).map((v: any) => ({ + name: String(v.name || "video"), + url: String(v.url), + ...(v.poster ? { poster: String(v.poster) } : {}), + })).slice(0, 24) : []; // Hashtags: ?hashtags= query, else saved config, else the domain slug. const parsedTags = parseHashtags(opts.hashtags); diff --git a/lib/media.ts b/lib/media.ts index 0985983..2ace81f 100644 --- a/lib/media.ts +++ b/lib/media.ts @@ -83,18 +83,21 @@ function runFfmpeg(args: string[]): Promise { } /** - * Extracts a poster frame from an uploaded video into `_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. + * Extracts a 640px poster frame from `inputPath` into `outputPath` (PNG) with + * ffmpeg. Best-effort: returns false if ffmpeg is missing or the clip can't be + * decoded — callers must not fail an upload on a false. Grabs a frame ~1s in, + * falling back to the first frame for very short clips. Works on any absolute + * paths, so it's shared by both video stores (media/ and videos/). */ +export async function ffmpegPoster(inputPath: string, outputPath: string): Promise { + const frameAt = (t: string) => + ["-y", "-ss", t, "-i", inputPath, "-frames:v", "1", "-vf", "scale=640:-2", outputPath]; + if ((await runFfmpeg(frameAt("1"))) && fs.existsSync(outputPath)) return true; + return (await runFfmpeg(frameAt("0"))) && fs.existsSync(outputPath); +} + +/** Poster for an uploaded media-table reel: `.mp4` → `_thumb.png`. */ export async function generateThumbnail(videoFilename: string): Promise { 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); + return ffmpegPoster(mediaPath(videoFilename), mediaPath(thumbFilename(videoFilename))); } diff --git a/lib/tenant-config.ts b/lib/tenant-config.ts index 0d16b5e..f046944 100644 --- a/lib/tenant-config.ts +++ b/lib/tenant-config.ts @@ -75,12 +75,16 @@ export function sanitizeTenantConfig(body: any): Record { // Uploaded MP4 videos ({name, url}). url is a same-origin /api/media/ path // (or an http(s) URL); passed through so a config save doesn't wipe uploads. if (Array.isArray(body?.videos)) { + const okUrl = (u: string) => /^\/api\/media\/[A-Za-z0-9._\-\/]+$/.test(u) || normalizeUrl(u) === u; const videos = body.videos .slice(0, 24) .map((v: any) => { const url = String(v?.url || "").trim(); - const ok = /^\/api\/media\/[A-Za-z0-9._\-\/]+$/.test(url) || (normalizeUrl(url) === url); - return ok ? { name: String(v?.name || "video").slice(0, 120), url } : null; + if (!okUrl(url)) return null; + const entry: { name: string; url: string; poster?: string } = { name: String(v?.name || "video").slice(0, 120), url }; + const poster = String(v?.poster || "").trim(); + if (poster && okUrl(poster)) entry.poster = poster; + return entry; }) .filter(Boolean); if (videos.length) c.videos = videos;