Skip to content

generate animated GIF previews for videos (upload, share OG, hover)#1790

Merged
richiemcilroy merged 14 commits intomainfrom
gif-preview
May 9, 2026
Merged

generate animated GIF previews for videos (upload, share OG, hover)#1790
richiemcilroy merged 14 commits intomainfrom
gif-preview

Conversation

@richiemcilroy
Copy link
Copy Markdown
Member

@richiemcilroy richiemcilroy commented May 9, 2026

Adds server-side preview GIF generation via FFmpeg, uploads it to S3 on process/mux and web upload paths, exposes a signed /api/video/preview redirect, uses that GIF in share Open Graph / Twitter metadata, and shows a hover preview on thumbnails. Also fixes extractAudio so the default timeout is honored when options are merged, and tightens related media-server tests.

Greptile Summary

This PR wires up server-side animated GIF preview generation via FFmpeg across all video ingest paths (process, mux-segments, web upload, Loom import), exposes a signed /api/video/preview redirect endpoint, surfaces the GIF in share Open Graph/Twitter metadata, and shows it as a hover overlay on video thumbnails. It also fixes a pre-existing bug in extractAudio where the caller-supplied timeoutMs was silently ignored in favour of the hardcoded constant.

  • FFmpeg GIF pipeline (ffmpeg-video.ts): generatePreviewGif runs up to four attempts with progressively lower quality settings (fps, dimensions, colours, duration) until the output fits within maxBytes; abort-signal propagation and temp-file cleanup are handled at each attempt boundary.
  • Preview API route (/api/video/preview): checks S3 for the GIF via headObject, returns a 302 to a 1-hour signed URL (5-min public cache), or falls back to the static OG image; the no-GIF fallback correctly uses private, no-store to avoid stale negative caches.
  • extractAudio fix (ffmpeg.ts): timeoutMs is now included in DEFAULT_OPTIONS and the merged opts.timeoutMs is used in both extractAudio and extractAudioStream, so callers that pass a custom timeout are no longer silently overridden.

Confidence Score: 5/5

The change is safe to merge; the preview GIF path is non-blocking and the core video processing paths are unchanged.

All new code paths are best-effort: failures are swallowed with a warning rather than surfacing to users or failing jobs. The extractAudio timeout fix is straightforward. The only new code concern is unreachable dead code after the retry loop in generatePreviewGif, which is a clarity issue and does not affect runtime behaviour.

No files require special attention.

Important Files Changed

Filename Overview
apps/media-server/src/lib/ffmpeg-video.ts Adds generatePreviewGif with a 4-attempt quality-ladder retry, abort signal propagation, and temp-file cleanup; unreachable dead throw at line 1309 is a minor code-clarity issue
apps/media-server/src/lib/ffmpeg.ts Bug fix: extractAudio now uses opts.timeoutMs instead of the hardcoded constant, and extractAudioStream uses the merged opts value; timeoutMs is also included in DEFAULT_OPTIONS
apps/media-server/src/routes/video.ts Adds previewGifPresignedUrl parameter to /process and /mux-segments endpoints; muxSegmentsAsync now gets its own AbortController wired to preview GIF generation; generateAndUploadPreviewGif swallows non-abort errors gracefully
apps/web/app/api/video/preview/route.ts New route: checks S3 for preview GIF via headObject, returns 302 to signed URL (5-min public cache) or falls back to /api/video/og (private, no-store); HEAD = GET is conventional for Next.js
apps/web/components/VideoThumbnail.tsx Adds hover-triggered preview GIF overlay using a previewState machine; error state prevents re-fetching on subsequent hovers; objectFit type is properly narrowed from any
apps/web/app/s/[videoId]/page.tsx OG/Twitter metadata updated to include the animated GIF preview as the first image and the static OG image as fallback; previewImageUrl uses fallback=og so crawlers always get a usable image
apps/web/workflows/process-video.ts Generates a previewGifPresignedUrl alongside the existing thumbnail URL and threads it through to the media server process call
apps/web/workflows/import-loom-video.ts Adds preview GIF presigned URL generation to the Loom import flow; Effect.gen indentation fixed while restructuring
apps/web/app/api/upload/[...route]/recording-complete.ts Adds preview GIF presigned PUT URL to the mux-segments payload for desktop recording completions
apps/web/app/api/upload/[...route]/multipart.ts Generates a previewGifPresignedUrl and passes it to the remux-only media server call for web upload path

Comments Outside Diff (1)

  1. apps/media-server/src/lib/ffmpeg-video.ts, line 169-207 (link)

    P2 timeoutMs and maxBytes are clamped to their defaults, making them effectively one-directional

    getPreviewGifOptions clamps both maxBytes and timeoutMs against DEFAULT_PREVIEW_GIF_OPTIONS.*, so callers can never supply a value larger than the built-in defaults (1.5 MB / 30 s). This may be intentional as a safety cap, but it's a silent footgun: passing { timeoutMs: 60_000 } silently returns 30 000 with no warning. If the intent is a strict upper-bound, a comment would clarify the design; if callers genuinely need wider ranges, the clamps should be removed.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/media-server/src/lib/ffmpeg-video.ts
    Line: 169-207
    
    Comment:
    **`timeoutMs` and `maxBytes` are clamped to their defaults, making them effectively one-directional**
    
    `getPreviewGifOptions` clamps both `maxBytes` and `timeoutMs` against `DEFAULT_PREVIEW_GIF_OPTIONS.*`, so callers can never supply a value larger than the built-in defaults (1.5 MB / 30 s). This may be intentional as a safety cap, but it's a silent footgun: passing `{ timeoutMs: 60_000 }` silently returns 30 000 with no warning. If the intent is a strict upper-bound, a comment would clarify the design; if callers genuinely need wider ranges, the clamps should be removed.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/media-server/src/lib/ffmpeg-video.ts:1309-1311
**Unreachable fallback throw after loop**

The `throw lastError` after the `for` loop can never execute. On the last iteration (`index === attempts.length - 1`), the catch block always re-throws `err`. Every other iteration either returns (success) or falls through to the next attempt. The post-loop throw is dead code — it will never run regardless of inputs, so it provides no safety net and may mislead future readers into thinking there is a code path that exits the loop normally without returning.

Reviews (2): Last reviewed commit: "fix: address gif preview review" | Re-trigger Greptile

@brin-security-scanner brin-security-scanner Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 9, 2026
@paragon-review
Copy link
Copy Markdown

paragon-review Bot commented May 9, 2026

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

Comment on lines +1297 to +1303
} catch (err) {
lastError = err;
await outputTempFile.cleanup();
if (index === attempts.length - 1) {
throw err;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Abort signal not honoured between retry attempts

When the abortSignal fires during attempt N, runPreviewGifAttempt kills that ffmpeg process and throws. The catch block stores the error and, because index < attempts.length - 1, silently continues to the next attempt. On the next call to runPreviewGifAttempt, abortSignal is already in the aborted state — AbortSignal.addEventListener does not re-fire for an already-aborted signal, so the freshly-spawned ffmpeg process is never killed and runs to completion or hits its 30-second timeout. This means a cancelled job can run up to three extra 30-second ffmpeg processes after cancellation.

Add a guard at the top of the catch block:

if (abortSignal?.aborted) {
    await outputTempFile.cleanup();
    throw err instanceof Error ? err : new Error("Preview GIF generation aborted");
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/lib/ffmpeg-video.ts
Line: 1297-1303

Comment:
**Abort signal not honoured between retry attempts**

When the `abortSignal` fires during attempt N, `runPreviewGifAttempt` kills that ffmpeg process and throws. The catch block stores the error and, because `index < attempts.length - 1`, silently continues to the next attempt. On the next call to `runPreviewGifAttempt`, `abortSignal` is already in the aborted state — `AbortSignal.addEventListener` does **not** re-fire for an already-aborted signal, so the freshly-spawned ffmpeg process is never killed and runs to completion or hits its 30-second timeout. This means a cancelled job can run up to three extra 30-second ffmpeg processes after cancellation.

Add a guard at the top of the catch block:

```ts
if (abortSignal?.aborted) {
    await outputTempFile.cleanup();
    throw err instanceof Error ? err : new Error("Preview GIF generation aborted");
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +64 to +66
const response = NextResponse.redirect(previewUrl, 302);
response.headers.set("Cache-Control", "public, max-age=300");
return response;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Signed S3 URL redirected with public cache-control

The redirect target is a pre-signed S3 URL (valid for 1 hour). Responding with Cache-Control: public, max-age=300 allows shared caches and CDNs to cache and replay this redirect. For public videos this is probably acceptable, but the same header is applied to the fallback (no-preview) redirect — callers within the 300-second window will receive the stale fallback even after a preview is later generated. A private directive (or s-maxage=0) on the fallback path would avoid stale negative caches.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/video/preview/route.ts
Line: 64-66

Comment:
**Signed S3 URL redirected with `public` cache-control**

The redirect target is a pre-signed S3 URL (valid for 1 hour). Responding with `Cache-Control: public, max-age=300` allows shared caches and CDNs to cache and replay this redirect. For public videos this is probably acceptable, but the same header is applied to the fallback (no-preview) redirect — callers within the 300-second window will receive the stale fallback even after a preview is later generated. A `private` directive (or `s-maxage=0`) on the fallback path would avoid stale negative caches.

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy
Copy link
Copy Markdown
Member Author

please re-review the pr @greptileai

@richiemcilroy richiemcilroy merged commit 201f84d into main May 9, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant