Plugin

Video

Take a clip from a phone or a browser and turn it into something every client plays: one H.264 MP4 that starts before it has downloaded, a poster frame, and the size and length a feed needs to lay it out. ffmpeg does the work in the background, and the upload request never waits for it.

grit plugin add video
grit migrate
pnpm install

The API's Docker image gets ffmpeg. To convert while developing, install it on your machine: winget install ffmpeg, brew install ffmpeg or apt install ffmpeg. Without it, uploaded videos wait and the log says why.

Upload, convert, play

Upload the file as usual with accepts=video, which allows up to 300 MB, then hand its key to POST /videos. The answer is 202 with the video, pending.

import { createVideo } from "@/lib/video";
import { VideoPlayer } from "@/components/video-player";
const ref = await uploader.upload(file, { accepts: ["video"] });
const video = await createVideo(ref.key);
// Polls every two seconds while it converts, then plays it.
<VideoPlayer id={video.id} />
// In a feed: muted, looping, playing.
<VideoPlayer video={post.video} autoPlay />

The Expo app has the same pair, playing with expo-video, and useUploadVideo() takes a clip straight from expo-image-picker. The owner also hears video.ready or video.failed on their realtime channel, for an app that would rather listen than poll.

What you get

EndpointDoes
POST /api/v1/videosQueues the conversion of an upload you own, by its key.
GET /api/v1/videos/:idpending, processing, ready or failed, with the URL, poster, size and length once ready. Anyone signed in sees a ready video; one still converting, only its owner.
DELETE /api/v1/videos/:idRemoves your video and the files it made.

The details that matter

  • One format, every client. H.264 and AAC in an MP4, capped at 720 on the short side, with the index at the front. Every browser, iOS and Android plays it, so there is no HLS to serve and no player to choose per platform. A portrait clip stays portrait.
  • The table is the queue. A worker claims a pending video with a conditional update, so a conversion survives a restart, several replicas share the work without Redis, and a claim held by a replica that died is taken over after fifteen minutes. One conversion at a time per replica, because ffmpeg uses every core it is given.
  • A crafted file cannot read the server. ffmpeg follows what a file says it is, and a playlist posing as a video can name other files for it to open. Only MP4, MOV and WebM are converted, each read with a forced demuxer and local files only, and a playlist is refused before ffmpeg sees it as input.
  • Only your own uploads. A video is made from an upload by its storage key, and a key that is not yours answers as not found, so it never confirms that someone else's file exists.
  • Failures say why. Too long, not a video, or a file that would not convert: the video is marked failed with a sentence for the person who uploaded it, and a failure that could be temporary is tried three times first. Limits are videoService.Options.MaxEdge and MaxDuration, set in routes.go: 720 and three minutes.

Built for the Instagram blueprint, and checked end to end on a new project: a 1080p clip uploaded over HTTP came back as a 720p H.264 MP4 with a poster within a second, served from storage, and another user sending the same key was told it does not exist.