diff --git a/apps/media-server/src/__tests__/lib/ffmpeg-video.integration.test.ts b/apps/media-server/src/__tests__/lib/ffmpeg-video.integration.test.ts index f2e9db5111..77966c6f5f 100644 --- a/apps/media-server/src/__tests__/lib/ffmpeg-video.integration.test.ts +++ b/apps/media-server/src/__tests__/lib/ffmpeg-video.integration.test.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + generatePreviewGif, generateThumbnail, materializeMpdManifest, normalizeVideoInputExtension, @@ -90,6 +91,46 @@ describe("generateThumbnail integration tests", () => { }); }); +describe("generatePreviewGif integration tests", () => { + test("generates small GIF preview from video", async () => { + const metadata = await probeVideo(`file://${TEST_VIDEO_WITH_AUDIO}`); + const preview = await generatePreviewGif( + TEST_VIDEO_WITH_AUDIO, + metadata.duration, + { maxBytes: 100_000 }, + ); + + try { + const previewData = readFileSync(preview.path); + + expect(previewData.length).toBeGreaterThan(0); + expect(previewData.length).toBeLessThanOrEqual(100_000); + expect(previewData.subarray(0, 3).toString()).toBe("GIF"); + } finally { + await preview.cleanup(); + } + }); + + test("rejects GIF previews over the size budget", async () => { + const metadata = await probeVideo(`file://${TEST_VIDEO_WITH_AUDIO}`); + + await expect( + generatePreviewGif(TEST_VIDEO_WITH_AUDIO, metadata.duration, { + maxBytes: 1, + }), + ).rejects.toThrow("Preview GIF exceeds size budget"); + }); + + test("rejects before spawning when already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + generatePreviewGif(TEST_VIDEO_WITH_AUDIO, 10, {}, controller.signal), + ).rejects.toThrow("Preview GIF generation aborted"); + }); +}); + describe("processVideo integration tests", () => { test("retries transient S3 upload failures", async () => { const originalFetch = globalThis.fetch; @@ -109,7 +150,7 @@ describe("processVideo integration tests", () => { status: 200, statusText: "OK", }); - }) as typeof fetch; + }) as unknown as typeof fetch; try { await uploadToS3( @@ -133,7 +174,7 @@ describe("processVideo integration tests", () => { status: 403, statusText: "Forbidden", }); - }) as typeof fetch; + }) as unknown as typeof fetch; try { await expect( @@ -157,7 +198,7 @@ describe("processVideo integration tests", () => { new Response( '', { status: 200, statusText: "OK" }, - )) as typeof fetch; + )) as unknown as typeof fetch; try { const path = await materializeMpdManifest( diff --git a/apps/media-server/src/__tests__/routes/audio-memory.test.ts b/apps/media-server/src/__tests__/routes/audio-memory.test.ts index 3d8799f2fc..bf7a072aff 100644 --- a/apps/media-server/src/__tests__/routes/audio-memory.test.ts +++ b/apps/media-server/src/__tests__/routes/audio-memory.test.ts @@ -97,7 +97,9 @@ describe("audio routes memory management", () => { expect(response.status).toBe(200); - const reader = response.body?.getReader(); + const body = response.body; + if (!body) throw new Error("Response body missing"); + const reader = body.getReader(); while (true) { const { done } = await reader.read(); if (done) break; @@ -118,7 +120,9 @@ describe("audio routes memory management", () => { expect(response.status).toBe(200); - const reader = response.body?.getReader(); + const body = response.body; + if (!body) throw new Error("Response body missing"); + const reader = body.getReader(); await reader.read(); await reader.cancel(); @@ -156,7 +160,9 @@ describe("audio routes memory management", () => { for (const response of responses) { expect(response.status).toBe(200); - const reader = response.body?.getReader(); + const body = response.body; + if (!body) throw new Error("Response body missing"); + const reader = body.getReader(); while (true) { const { done } = await reader.read(); if (done) break; diff --git a/apps/media-server/src/lib/ffmpeg-video.ts b/apps/media-server/src/lib/ffmpeg-video.ts index 4e505b4088..fd8401dabe 100644 --- a/apps/media-server/src/lib/ffmpeg-video.ts +++ b/apps/media-server/src/lib/ffmpeg-video.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { file, spawn } from "bun"; +import { type BunFile, file, spawn } from "bun"; import type { VideoMetadata } from "./job-manager"; import { registerSubprocess, terminateProcess } from "./subprocess"; import { @@ -15,6 +15,7 @@ const PROCESS_TIMEOUT_MS = 45 * 60 * 1000; const PROCESS_TIMEOUT_PER_SECOND_MS = 20_000; const MAX_PROCESS_TIMEOUT_MS = 2 * 60 * 60 * 1000; const THUMBNAIL_TIMEOUT_MS = 60_000; +const PREVIEW_GIF_TIMEOUT_MS = 30_000; const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000; const UPLOAD_TIMEOUT_MS = 10 * 60 * 1000; const MAX_UPLOAD_RETRIES = 4; @@ -57,6 +58,66 @@ const DEFAULT_THUMBNAIL_OPTIONS: Required = { quality: 85, }; +export interface PreviewGifOptions { + startTime?: number; + duration?: number; + fps?: number; + maxDimension?: number; + colors?: number; + maxBytes?: number; + timeoutMs?: number; +} + +const DEFAULT_PREVIEW_GIF_OPTIONS: Required = { + startTime: 1, + duration: 4, + fps: 8, + maxDimension: 480, + colors: 48, + maxBytes: 1_500_000, + timeoutMs: PREVIEW_GIF_TIMEOUT_MS, +}; + +interface PreviewGifAttempt { + startTime: number; + duration: number; + fps: number; + maxDimension: number; + colors: number; + timeoutMs: number; +} + +function getPreviewGifOptions( + options: PreviewGifOptions, +): Required { + const definedOptions = Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ) as PreviewGifOptions; + const opts = { ...DEFAULT_PREVIEW_GIF_OPTIONS, ...definedOptions }; + + return { + startTime: Math.max(0, opts.startTime), + duration: Math.min( + DEFAULT_PREVIEW_GIF_OPTIONS.duration, + Math.max(0.5, opts.duration), + ), + fps: Math.round( + Math.min(DEFAULT_PREVIEW_GIF_OPTIONS.fps, Math.max(1, opts.fps)), + ), + maxDimension: Math.round( + Math.min( + DEFAULT_PREVIEW_GIF_OPTIONS.maxDimension, + Math.max(120, opts.maxDimension), + ), + ), + colors: Math.round( + Math.min(DEFAULT_PREVIEW_GIF_OPTIONS.colors, Math.max(2, opts.colors)), + ), + maxBytes: Math.round(Math.max(1, opts.maxBytes)), + timeoutMs: Math.round(Math.max(5_000, opts.timeoutMs)), + }; +} + export function normalizeVideoInputExtension( inputExtension: string | undefined, ): `.${string}` { @@ -1032,6 +1093,224 @@ export async function generateThumbnail( } } +function getPreviewGifStartTime( + videoDuration: number, + requestedStartTime: number, + previewDuration: number, +): number { + if (!Number.isFinite(videoDuration) || videoDuration <= 0) { + return Math.max(0, requestedStartTime); + } + + return Math.min( + Math.max(0, requestedStartTime), + Math.max(0, videoDuration - previewDuration), + ); +} + +function getPreviewGifDuration( + videoDuration: number, + startTime: number, + requestedDuration: number, +): number { + if (!Number.isFinite(videoDuration) || videoDuration <= 0) { + return requestedDuration; + } + + return Math.max(0.5, Math.min(requestedDuration, videoDuration - startTime)); +} + +function getPreviewGifAttempts( + duration: number, + opts: Required, +): PreviewGifAttempt[] { + const startTime = getPreviewGifStartTime( + duration, + opts.startTime, + opts.duration, + ); + const previewDuration = getPreviewGifDuration( + duration, + startTime, + opts.duration, + ); + + return [ + { + startTime, + duration: previewDuration, + fps: opts.fps, + maxDimension: opts.maxDimension, + colors: opts.colors, + timeoutMs: opts.timeoutMs, + }, + { + startTime, + duration: Math.min(3, previewDuration), + fps: Math.min(6, opts.fps), + maxDimension: Math.min(360, opts.maxDimension), + colors: Math.min(32, opts.colors), + timeoutMs: opts.timeoutMs, + }, + { + startTime, + duration: Math.min(2, previewDuration), + fps: Math.min(5, opts.fps), + maxDimension: Math.min(320, opts.maxDimension), + colors: Math.min(24, opts.colors), + timeoutMs: opts.timeoutMs, + }, + { + startTime, + duration: Math.min(1.5, previewDuration), + fps: Math.min(4, opts.fps), + maxDimension: Math.min(240, opts.maxDimension), + colors: Math.min(16, opts.colors), + timeoutMs: opts.timeoutMs, + }, + ]; +} + +function getPreviewGifFilter(attempt: PreviewGifAttempt): string { + return `fps=${attempt.fps},scale='min(${attempt.maxDimension},iw)':'min(${attempt.maxDimension},ih)':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,split[s0][s1];[s0]palettegen=stats_mode=diff:max_colors=${attempt.colors}[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`; +} + +async function runPreviewGifAttempt( + inputPath: string, + outputPath: string, + attempt: PreviewGifAttempt, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + throw new Error("Preview GIF generation aborted"); + } + + const ffmpegArgs = [ + "ffmpeg", + "-threads", + "1", + "-ss", + attempt.startTime.toString(), + "-t", + attempt.duration.toString(), + "-i", + inputPath, + "-an", + "-filter_complex", + getPreviewGifFilter(attempt), + "-loop", + "0", + "-f", + "gif", + "-y", + outputPath, + ]; + + console.log(`[generatePreviewGif] Running FFmpeg: ${ffmpegArgs.join(" ")}`); + + const proc = registerSubprocess( + spawn({ + cmd: ffmpegArgs, + stdout: "pipe", + stderr: "pipe", + }), + ); + + let abortCleanup: (() => void) | undefined; + if (abortSignal) { + abortCleanup = () => { + void terminateProcess(proc); + }; + abortSignal.addEventListener("abort", abortCleanup, { once: true }); + } + + try { + await withTimeout( + (async () => { + drainStream(proc.stdout as ReadableStream); + + const stderrText = await readStreamWithLimit( + proc.stderr as ReadableStream, + MAX_STDERR_BYTES, + ); + + const exitCode = await proc.exited; + + if (exitCode !== 0) { + console.error( + `[generatePreviewGif] FFmpeg stderr:\n${redactUrlQueries(stderrText)}`, + ); + throw new Error(`FFmpeg preview GIF exited with code ${exitCode}`); + } + })(), + attempt.timeoutMs, + () => terminateProcess(proc), + ); + } finally { + if (abortCleanup) { + abortSignal?.removeEventListener("abort", abortCleanup); + } + await terminateProcess(proc); + } +} + +export async function generatePreviewGif( + inputPath: string, + duration: number, + options: PreviewGifOptions = {}, + abortSignal?: AbortSignal, +): Promise { + const opts = getPreviewGifOptions(options); + const attempts = getPreviewGifAttempts(duration, opts); + let lastError: unknown; + + for (const [index, attempt] of attempts.entries()) { + if (abortSignal?.aborted) { + throw new Error("Preview GIF generation aborted"); + } + + const outputTempFile = await createTempFile(".gif"); + + try { + await runPreviewGifAttempt( + inputPath, + outputTempFile.path, + attempt, + abortSignal, + ); + + const outputFile = file(outputTempFile.path); + const outputSize = await outputFile.size; + if (outputSize === 0) { + throw new Error("FFmpeg produced empty preview GIF"); + } + + if (outputSize <= opts.maxBytes) { + return outputTempFile; + } + + throw new Error( + `Preview GIF exceeds size budget: ${outputSize} bytes > ${opts.maxBytes} bytes`, + ); + } catch (err) { + await outputTempFile.cleanup(); + if (abortSignal?.aborted) { + throw err instanceof Error + ? err + : new Error("Preview GIF generation aborted"); + } + lastError = err; + if (index === attempts.length - 1) { + throw err; + } + } + } + + throw lastError instanceof Error + ? lastError + : new Error("Preview GIF generation failed"); +} + export async function uploadToS3( data: Uint8Array | Blob, presignedUrl: string, diff --git a/apps/media-server/src/lib/ffmpeg.ts b/apps/media-server/src/lib/ffmpeg.ts index 923269126f..c6dba299dc 100644 --- a/apps/media-server/src/lib/ffmpeg.ts +++ b/apps/media-server/src/lib/ffmpeg.ts @@ -8,16 +8,17 @@ export interface AudioExtractionOptions { timeoutMs?: number; } +const CHECK_TIMEOUT_MS = 30_000; +const EXTRACT_TIMEOUT_MS = 120_000; +const MAX_AUDIO_SIZE_BYTES = 100 * 1024 * 1024; + const DEFAULT_OPTIONS: Required = { format: "mp3", codec: "libmp3lame", bitrate: "128k", + timeoutMs: EXTRACT_TIMEOUT_MS, }; -const CHECK_TIMEOUT_MS = 30_000; -const EXTRACT_TIMEOUT_MS = 120_000; -const MAX_AUDIO_SIZE_BYTES = 100 * 1024 * 1024; - let activeProcesses = 0; const MAX_CONCURRENT_PROCESSES = 6; @@ -265,7 +266,7 @@ export async function extractAudio( return output; })(), - EXTRACT_TIMEOUT_MS, + opts.timeoutMs, () => terminateProcess(proc), ); @@ -292,7 +293,7 @@ export function extractAudioStream( activeProcesses++; const opts = { ...DEFAULT_OPTIONS, ...options }; - const timeout = options.timeoutMs ?? EXTRACT_TIMEOUT_MS; + const timeout = opts.timeoutMs; let proc: Subprocess; try { diff --git a/apps/media-server/src/routes/video.ts b/apps/media-server/src/routes/video.ts index a3354bd8bc..3ec464600d 100644 --- a/apps/media-server/src/routes/video.ts +++ b/apps/media-server/src/routes/video.ts @@ -5,6 +5,7 @@ import { validateMediaServerSecret } from "../lib/auth"; import type { ResilientInputFlags } from "../lib/ffmpeg-video"; import { downloadVideoToTemp, + generatePreviewGif, generateThumbnail, processVideo, repairContainer, @@ -63,6 +64,7 @@ const processSchema = z.object({ videoUrl: z.string().url(), outputPresignedUrl: z.string().url(), thumbnailPresignedUrl: z.string().url().optional(), + previewGifPresignedUrl: z.string().url().optional(), webhookUrl: z.string().url().optional(), webhookSecret: z.string().optional(), inputExtension: z.string().optional(), @@ -463,6 +465,7 @@ video.post("/process", async (c) => { videoUrl, outputPresignedUrl, thumbnailPresignedUrl, + previewGifPresignedUrl, webhookUrl, webhookSecret, } = result.data; @@ -475,6 +478,7 @@ video.post("/process", async (c) => { videoUrl, outputPresignedUrl, thumbnailPresignedUrl, + previewGifPresignedUrl, result.data, ).catch((err) => { console.error( @@ -693,11 +697,47 @@ async function processWithResilientRetry( }); } +async function generateAndUploadPreviewGif( + inputPath: string, + duration: number, + previewGifPresignedUrl: string | undefined, + abortSignal: AbortSignal | undefined, + logPrefix: string, +): Promise { + if (!previewGifPresignedUrl) return; + + let previewGifFile: TempFileHandle | null = null; + + try { + previewGifFile = await generatePreviewGif( + inputPath, + duration, + {}, + abortSignal, + ); + await uploadFileToS3( + previewGifFile.path, + previewGifPresignedUrl, + "image/gif", + ); + } catch (previewErr) { + if (abortSignal?.aborted) { + throw previewErr instanceof Error + ? previewErr + : new Error("Preview GIF generation aborted"); + } + console.warn(`[${logPrefix}] Preview GIF generation failed:`, previewErr); + } finally { + await previewGifFile?.cleanup(); + } +} + async function processVideoAsync( jobId: string, videoUrl: string, outputPresignedUrl: string, thumbnailPresignedUrl: string | undefined, + previewGifPresignedUrl: string | undefined, options: z.infer, ): Promise { const job = getJob(jobId); @@ -778,14 +818,16 @@ async function processVideoAsync( await uploadFileToS3(outputTempFile.path, outputPresignedUrl, "video/mp4"); - if (thumbnailPresignedUrl) { + if (thumbnailPresignedUrl || previewGifPresignedUrl) { updateJob(jobId, { phase: "generating_thumbnail", progress: 90, - message: "Generating thumbnail...", + message: "Generating preview assets...", }); await sendWebhook(job); + } + if (thumbnailPresignedUrl) { const thumbnailData = await generateThumbnail( outputTempFile.path, metadata.duration, @@ -793,6 +835,14 @@ async function processVideoAsync( await uploadToS3(thumbnailData, thumbnailPresignedUrl, "image/jpeg"); } + await generateAndUploadPreviewGif( + outputTempFile.path, + metadata.duration, + previewGifPresignedUrl, + abortController.signal, + "video/process", + ); + updateJob(jobId, { phase: "complete", progress: 100, @@ -991,6 +1041,7 @@ const muxSegmentsSchema = z.object({ userId: z.string(), outputPresignedUrl: z.string().url(), thumbnailPresignedUrl: z.string().url().optional(), + previewGifPresignedUrl: z.string().url().optional(), webhookUrl: z.string().url().optional(), webhookSecret: z.string().optional(), videoInitUrl: z.string().url(), @@ -1017,6 +1068,7 @@ video.post("/mux-segments", async (c) => { userId, outputPresignedUrl, thumbnailPresignedUrl, + previewGifPresignedUrl, webhookUrl, webhookSecret, } = body.data; @@ -1046,6 +1098,7 @@ video.post("/mux-segments", async (c) => { videoId, outputPresignedUrl, thumbnailPresignedUrl, + previewGifPresignedUrl, videoInitUrl, videoSegUrls, audioInitUrl ?? null, @@ -1196,6 +1249,7 @@ async function muxSegmentsAsync( videoId: string, outputPresignedUrl: string, thumbnailPresignedUrl: string | undefined, + previewGifPresignedUrl: string | undefined, videoInitUrl: string, videoSegmentUrls: string[], audioInitUrl: string | null, @@ -1210,6 +1264,8 @@ async function muxSegmentsAsync( "cap-media-server", `mux-${jobId}`, ); + const abortController = new AbortController(); + updateJob(jobId, { abortController }); try { await ensureTempDir(); @@ -1354,14 +1410,16 @@ async function muxSegmentsAsync( metadata = probeResult; } catch {} - if (thumbnailPresignedUrl) { + if (thumbnailPresignedUrl || previewGifPresignedUrl) { updateJob(jobId, { phase: "generating_thumbnail", progress: 90, - message: "Generating thumbnail...", + message: "Generating preview assets...", }); sendCurrentJobWebhook(jobId); + } + if (thumbnailPresignedUrl) { try { const duration = metadata?.duration ?? 0; const thumbnailData = await generateThumbnail(resultPath, duration); @@ -1374,6 +1432,14 @@ async function muxSegmentsAsync( } } + await generateAndUploadPreviewGif( + resultPath, + metadata?.duration ?? 0, + previewGifPresignedUrl, + abortController.signal, + "mux-segments", + ); + updateJob(jobId, { phase: "complete", progress: 100, diff --git a/apps/web/app/api/upload/[...route]/multipart.ts b/apps/web/app/api/upload/[...route]/multipart.ts index 84612f1515..c4bfed4b07 100644 --- a/apps/web/app/api/upload/[...route]/multipart.ts +++ b/apps/web/app/api/upload/[...route]/multipart.ts @@ -540,6 +540,16 @@ app.post( }, { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, ); + const previewGifKey = `${user.id}/${videoId}/preview/animated-preview.gif`; + const previewGifPresignedUrl = + yield* bucket.getInternalPresignedPutUrl( + previewGifKey, + { + ContentType: "image/gif", + CacheControl: "public, max-age=31536000, immutable", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ); yield* Effect.tryPromise({ try: async () => { @@ -558,6 +568,7 @@ app.post( userId: user.id, videoUrl: inputUrl, outputPresignedUrl, + previewGifPresignedUrl, remuxOnly: true, }), }, diff --git a/apps/web/app/api/upload/[...route]/recording-complete.ts b/apps/web/app/api/upload/[...route]/recording-complete.ts index 65d857217e..463b21b6e3 100644 --- a/apps/web/app/api/upload/[...route]/recording-complete.ts +++ b/apps/web/app/api/upload/[...route]/recording-complete.ts @@ -154,6 +154,7 @@ export const app = new Hono().post( const outputKey = `${user.id}/${videoIdRaw}/result.mp4`; const thumbnailKey = `${user.id}/${videoIdRaw}/screenshot/screen-capture.jpg`; + const previewGifKey = `${user.id}/${videoIdRaw}/preview/animated-preview.gif`; const outputPresignedUrl = yield* bucket.getInternalPresignedPutUrl( outputKey, @@ -169,10 +170,19 @@ export const app = new Hono().post( }, { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, ); + const previewGifPresignedUrl = yield* bucket.getInternalPresignedPutUrl( + previewGifKey, + { + ContentType: "image/gif", + CacheControl: "public, max-age=31536000, immutable", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ); return { outputPresignedUrl, thumbnailPresignedUrl, + previewGifPresignedUrl, videoInitUrl, videoSegmentUrls, audioInitUrl, @@ -231,6 +241,7 @@ export const app = new Hono().post( userId: user.id, outputPresignedUrl: muxPayload.outputPresignedUrl, thumbnailPresignedUrl: muxPayload.thumbnailPresignedUrl, + previewGifPresignedUrl: muxPayload.previewGifPresignedUrl, videoInitUrl: muxPayload.videoInitUrl, videoSegmentUrls: muxPayload.videoSegmentUrls, audioInitUrl: muxPayload.audioInitUrl, diff --git a/apps/web/app/api/video/preview/route.ts b/apps/web/app/api/video/preview/route.ts new file mode 100644 index 0000000000..efd6a96640 --- /dev/null +++ b/apps/web/app/api/video/preview/route.ts @@ -0,0 +1,69 @@ +import { provideOptionalAuth, Storage, Videos } from "@cap/web-backend"; +import { Video } from "@cap/web-domain"; +import { Effect, Option } from "effect"; +import { type NextRequest, NextResponse } from "next/server"; +import { runPromise } from "@/lib/server"; + +export const dynamic = "force-dynamic"; + +const PREVIEW_GIF_EXPIRES_SECONDS = 60 * 60; + +function getPreviewGifKey(ownerId: string, videoId: string) { + return `${ownerId}/${videoId}/preview/animated-preview.gif`; +} + +function getFallbackResponse(request: NextRequest, videoId: string) { + if (request.nextUrl.searchParams.get("fallback") !== "og") { + return new NextResponse(null, { status: 404 }); + } + + const fallbackUrl = new URL("/api/video/og", request.url); + fallbackUrl.searchParams.set("videoId", videoId); + const response = NextResponse.redirect(fallbackUrl, 302); + response.headers.set("Cache-Control", "private, no-store, max-age=0"); + return response; +} + +export async function GET(request: NextRequest) { + const rawVideoId = request.nextUrl.searchParams.get("videoId"); + if (!rawVideoId) { + return new NextResponse(null, { status: 400 }); + } + + const videoId = Video.VideoId.make(rawVideoId); + let previewUrl: string | null; + try { + previewUrl = await Effect.gen(function* () { + const videos = yield* Videos; + const maybeVideo = yield* videos.getByIdForViewing(videoId); + if (Option.isNone(maybeVideo)) return null; + + const [video] = maybeVideo.value; + const [bucket] = yield* Storage.getAccessForVideo(video); + const previewKey = getPreviewGifKey(video.ownerId, video.id); + const hasPreview = yield* bucket.headObject(previewKey).pipe( + Effect.as(true), + Effect.catchAll(() => Effect.succeed(false)), + ); + + if (!hasPreview) return null; + + return yield* bucket.getSignedObjectUrl(previewKey, { + expiresIn: PREVIEW_GIF_EXPIRES_SECONDS, + }); + }).pipe(provideOptionalAuth, runPromise); + } catch (error) { + console.warn("[video/preview] Failed to resolve preview GIF:", error); + return new NextResponse(null, { status: 404 }); + } + + if (!previewUrl) { + return getFallbackResponse(request, rawVideoId); + } + + const response = NextResponse.redirect(previewUrl, 302); + response.headers.set("Cache-Control", "public, max-age=300"); + return response; +} + +export const HEAD = GET; diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index 015268df84..7212e0f046 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -175,57 +175,76 @@ export async function generateMetadata( Effect.map( Option.match({ onNone: () => notFound(), - onSome: ([video]) => ({ - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - openGraph: { - images: [ - { - url: new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1200, - height: 630, - }, - ], - videos: [ - { - url: new URL( - `/api/playlist?videoId=${video.id}`, + onSome: ([video]) => { + const previewImageUrl = new URL( + `/api/video/preview?videoId=${videoId}&fallback=og`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(); + const ogImageUrl = new URL( + `/api/video/og?videoId=${videoId}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(); + const playlistUrl = new URL( + `/api/playlist?videoId=${video.id}`, + buildEnv.NEXT_PUBLIC_WEB_URL, + ).toString(); + + return { + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + openGraph: { + images: [ + { + url: previewImageUrl, + width: 480, + height: 270, + type: "image/gif", + }, + { + url: ogImageUrl, + width: 1200, + height: 630, + }, + ], + videos: [ + { + url: playlistUrl, + width: 1280, + height: 720, + type: "video/mp4", + }, + ], + }, + twitter: { + card: "player", + title: `${video.name} | Cap Recording`, + description: "Watch this video on Cap", + images: [ + { + url: previewImageUrl, + width: 480, + height: 270, + type: "image/gif", + }, + { + url: ogImageUrl, + width: 1200, + height: 630, + }, + ], + players: { + playerUrl: new URL( + `/s/${videoId}`, buildEnv.NEXT_PUBLIC_WEB_URL, ).toString(), + streamUrl: playlistUrl, width: 1280, height: 720, - type: "video/mp4", }, - ], - }, - twitter: { - card: "player", - title: `${video.name} | Cap Recording`, - description: "Watch this video on Cap", - images: [ - new URL( - `/api/video/og?videoId=${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - ], - players: { - playerUrl: new URL( - `/s/${videoId}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - streamUrl: new URL( - `/api/playlist?videoId=${video.id}`, - buildEnv.NEXT_PUBLIC_WEB_URL, - ).toString(), - width: 1280, - height: 720, }, - }, - robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", - }), + robots: isAllowedReferrer ? "index, follow" : "noindex, nofollow", + }; + }, }), ), Effect.catchTags({ diff --git a/apps/web/components/VideoThumbnail.tsx b/apps/web/components/VideoThumbnail.tsx index e1a5831e29..9cfded9e7a 100644 --- a/apps/web/components/VideoThumbnail.tsx +++ b/apps/web/components/VideoThumbnail.tsx @@ -4,17 +4,24 @@ import clsx from "clsx"; import { Effect } from "effect"; import moment from "moment"; import Image from "next/image"; -import { memo, useEffect, useRef } from "react"; +import type { CSSProperties } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { useEffectQuery } from "@/lib/EffectRuntime"; import { ThumbnailRequest } from "@/lib/Requests/ThumbnailRequest"; export type ImageLoadingStatus = "loading" | "success" | "error"; +type PreviewState = { + videoId: Video.VideoId; + hovered: boolean; + status: ImageLoadingStatus; +}; + interface VideoThumbnailProps { videoId: Video.VideoId; alt: string; imageClass?: string; - objectFit?: string; + objectFit?: CSSProperties["objectFit"]; containerClass?: string; videoDuration?: number; imageStatus: ImageLoadingStatus; @@ -49,6 +56,10 @@ function generateRandomGrayScaleColor() { return `rgb(${grayScaleValue}, ${grayScaleValue}, ${grayScaleValue})`; } +function getPreviewGifSrc(videoId: Video.VideoId) { + return `/api/video/preview?videoId=${encodeURIComponent(videoId)}&fallback=none`; +} + export const useThumnailQuery = ( videoId: Video.VideoId, enabled: boolean = true, @@ -78,7 +89,15 @@ export const VideoThumbnail: React.FC = memo( hasActiveUpload = false, }) => { const thumbnailUrl = useThumnailQuery(videoId, !hasActiveUpload); + const containerRef = useRef(null); const imageRef = useRef(null); + const latestVideoId = useRef(videoId); + const [previewState, setPreviewState] = useState(() => ({ + videoId, + hovered: false, + status: "loading", + })); + latestVideoId.current = videoId; const randomGradient = `linear-gradient(to right, ${generateRandomGrayScaleColor()}, ${generateRandomGrayScaleColor()})`; @@ -88,13 +107,52 @@ export const VideoThumbnail: React.FC = memo( } }, [setImageStatus]); + useEffect(() => { + const element = containerRef.current; + if (!element) return; + + const setHovered = (hovered: boolean) => { + const currentVideoId = latestVideoId.current; + setPreviewState((state) => ({ + videoId: currentVideoId, + hovered, + status: state.videoId === currentVideoId ? state.status : "loading", + })); + }; + + const handleMouseEnter = () => setHovered(true); + const handleMouseLeave = () => setHovered(false); + + element.addEventListener("mouseenter", handleMouseEnter); + element.addEventListener("mouseleave", handleMouseLeave); + + return () => { + element.removeEventListener("mouseenter", handleMouseEnter); + element.removeEventListener("mouseleave", handleMouseLeave); + }; + }, []); + const showError = !hasActiveUpload && (thumbnailUrl.isError || imageStatus === "error"); const showLoading = hasActiveUpload || thumbnailUrl.isPending || imageStatus === "loading"; + const previewStatus = + previewState.videoId === videoId ? previewState.status : "loading"; + const isPreviewHovered = + previewState.videoId === videoId && previewState.hovered; + const showPreview = + isPreviewHovered && !hasActiveUpload && previewStatus !== "error"; + const setCurrentPreviewStatus = (status: ImageLoadingStatus) => { + setPreviewState((state) => ({ + videoId, + hovered: state.videoId === videoId ? state.hovered : false, + status, + })); + }; return (
= memo( sizes="(max-width: 768px) 100vw, 33vw" alt={alt} key={videoId} - style={{ objectFit: objectFit as any }} + style={{ objectFit }} className={clsx( "w-full h-full rounded-t-xl", imageClass, @@ -132,6 +190,24 @@ export const VideoThumbnail: React.FC = memo( onError={() => setImageStatus("error")} /> )} + {showPreview && ( + setCurrentPreviewStatus("success")} + onError={() => setCurrentPreviewStatus("error")} + /> + )} {videoDuration && (

{formatDuration(videoDuration)} diff --git a/apps/web/workflows/import-loom-video.ts b/apps/web/workflows/import-loom-video.ts index 5bbd773875..2757258a64 100644 --- a/apps/web/workflows/import-loom-video.ts +++ b/apps/web/workflows/import-loom-video.ts @@ -313,6 +313,7 @@ async function startMediaServerProcessJob( videoUrl: string; outputPresignedUrl: string; thumbnailPresignedUrl: string; + previewGifPresignedUrl: string; webhookUrl: string; webhookSecret?: string; inputExtension?: string; @@ -378,50 +379,69 @@ async function processVideoOnMediaServer( const webhookBaseUrl = serverEnv().MEDIA_SERVER_WEBHOOK_URL || serverEnv().WEB_URL; - const { rawVideoUrl, outputPresignedUrl, thumbnailPresignedUrl } = - await Effect.gen(function* () { - const [video] = yield* Effect.promise(() => - db() - .select() - .from(videos) - .where(eq(videos.id, Video.VideoId.make(videoId))), - ); - if (!video) { - return yield* Effect.fail(new FatalError("Video does not exist")); - } - const videoDomain = Video.Video.decodeSync({ - ...video, - bucketId: video.bucket, - storageIntegrationId: video.storageIntegrationId, - createdAt: video.createdAt.toISOString(), - updatedAt: video.updatedAt.toISOString(), - metadata: video.metadata, - }); - const [bucket] = yield* Storage.getAccessForVideo(videoDomain); + const { + rawVideoUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + } = await Effect.gen(function* () { + const [video] = yield* Effect.promise(() => + db() + .select() + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))), + ); + if (!video) { + return yield* Effect.fail(new FatalError("Video does not exist")); + } + const videoDomain = Video.Video.decodeSync({ + ...video, + bucketId: video.bucket, + storageIntegrationId: video.storageIntegrationId, + createdAt: video.createdAt.toISOString(), + updatedAt: video.updatedAt.toISOString(), + metadata: video.metadata, + }); + const [bucket] = yield* Storage.getAccessForVideo(videoDomain); - const outputKey = `${userId}/${videoId}/result.mp4`; - const thumbnailKey = `${userId}/${videoId}/screenshot/screen-capture.jpg`; + const outputKey = `${userId}/${videoId}/result.mp4`; + const thumbnailKey = `${userId}/${videoId}/screenshot/screen-capture.jpg`; + const previewGifKey = `${userId}/${videoId}/preview/animated-preview.gif`; - const rawVideoUrl = yield* bucket.getInternalSignedObjectUrl(rawFileKey, { - expiresIn: MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS, - }); + const rawVideoUrl = yield* bucket.getInternalSignedObjectUrl(rawFileKey, { + expiresIn: MEDIA_SERVER_PRESIGNED_GET_EXPIRES_SECONDS, + }); - const outputPresignedUrl = yield* bucket.getInternalPresignedPutUrl( - outputKey, - { ContentType: "video/mp4" }, - { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, - ); + const outputPresignedUrl = yield* bucket.getInternalPresignedPutUrl( + outputKey, + { ContentType: "video/mp4" }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ); - const thumbnailPresignedUrl = yield* bucket.getInternalPresignedPutUrl( - thumbnailKey, - { - ContentType: "image/jpeg", - }, - { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, - ); + const thumbnailPresignedUrl = yield* bucket.getInternalPresignedPutUrl( + thumbnailKey, + { + ContentType: "image/jpeg", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ); + + const previewGifPresignedUrl = yield* bucket.getInternalPresignedPutUrl( + previewGifKey, + { + ContentType: "image/gif", + CacheControl: "public, max-age=31536000, immutable", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ); - return { rawVideoUrl, outputPresignedUrl, thumbnailPresignedUrl }; - }).pipe(runPromise); + return { + rawVideoUrl, + outputPresignedUrl, + thumbnailPresignedUrl, + previewGifPresignedUrl, + }; + }).pipe(runPromise); const webhookUrl = `${webhookBaseUrl}/api/webhooks/media-server/progress`; const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; @@ -433,6 +453,7 @@ async function processVideoOnMediaServer( videoUrl: sourceVideoUrl, outputPresignedUrl, thumbnailPresignedUrl, + previewGifPresignedUrl, webhookUrl, webhookSecret: webhookSecret || undefined, inputExtension: processingInput.inputExtension, diff --git a/apps/web/workflows/process-video.ts b/apps/web/workflows/process-video.ts index e516b6586f..922ccefe35 100644 --- a/apps/web/workflows/process-video.ts +++ b/apps/web/workflows/process-video.ts @@ -143,6 +143,7 @@ async function startMediaServerProcessJob( videoUrl: string; outputPresignedUrl: string; thumbnailPresignedUrl: string; + previewGifPresignedUrl: string; webhookUrl: string; webhookSecret?: string; inputExtension: string; @@ -253,6 +254,7 @@ async function processVideoOnMediaServer( const outputKey = `${userId}/${videoId}/result.mp4`; const thumbnailKey = `${userId}/${videoId}/screenshot/screen-capture.jpg`; + const previewGifKey = `${userId}/${videoId}/preview/animated-preview.gif`; const outputPresignedUrl = await bucket .getInternalPresignedPutUrl( @@ -274,6 +276,17 @@ async function processVideoOnMediaServer( ) .pipe(runPromise); + const previewGifPresignedUrl = await bucket + .getInternalPresignedPutUrl( + previewGifKey, + { + ContentType: "image/gif", + CacheControl: "public, max-age=31536000, immutable", + }, + { expiresIn: MEDIA_SERVER_PRESIGNED_PUT_EXPIRES_SECONDS }, + ) + .pipe(runPromise); + const webhookUrl = `${webhookBaseUrl}/api/webhooks/media-server/progress`; const webhookSecret = serverEnv().MEDIA_SERVER_WEBHOOK_SECRET; @@ -283,6 +296,7 @@ async function processVideoOnMediaServer( videoUrl: rawVideoUrl, outputPresignedUrl, thumbnailPresignedUrl, + previewGifPresignedUrl, webhookUrl, webhookSecret: webhookSecret || undefined, inputExtension: getInputExtension(rawFileKey),