DEV Community

ahmet gedik
ahmet gedik

Posted on

Building a Video Thumbnail Generator Service in Go With FFmpeg and Sprite Sheets

A user lands on a video page, hovers over the scrubber, and expects a preview frame to appear instantly. Under the hood, that tiny preview is one of the most expensive things a video platform produces. FFmpeg has to seek into a container, decode a keyframe, scale it, and encode a still image. Do that naively — one FFmpeg process per request, on the fly — and a modest traffic spike will pin every CPU core and turn your page loads into 8-second waits.

I run DailyWatch, a free video discovery platform, and thumbnails are the single most-requested asset on the whole site: poster frames for the grid, hover-preview sprite sheets for the player, and Open Graph images for social shares. This post is a walkthrough of the Go service I built to generate all three, why FFmpeg's -ss placement matters more than any other flag, how to build a sprite sheet in one pass, and how to keep the whole thing from melting under load. Everything here is runnable — no pseudocode.

The problem with generating thumbnails on request

The first version of any thumbnail system tends to look like this: a request comes in for /thumb/abc123.jpg, the handler shells out to FFmpeg, waits, and streams the result. It works in development with one user. In production it falls over for three reasons:

  • FFmpeg is CPU-bound and unbounded. Every concurrent request spawns a decoder. Ten concurrent requests on a 4-core box means 10 decoders fighting over 4 cores, and each one gets slower, which increases concurrency, which is a feedback loop straight into an outage.
  • Seeking is not free. Decoding a frame 40 minutes into a 1080p H.264 file means the decoder may have to walk forward from the nearest keyframe. Get the flag order wrong and a single frame extract takes 6 seconds instead of 50 milliseconds.
  • You regenerate the same frame forever. The poster for a given video never changes. Computing it per request is pure waste.

The fix is architectural, not a faster flag: generate thumbnails once, asynchronously, into content-addressed storage, and let a cache serve them. The Go service below is a worker that pulls jobs, runs FFmpeg with the right flags, writes artifacts, and records what it produced. The web tier only ever serves static files.

Fast, correct frame extraction with FFmpeg

The most important thing to understand about FFmpeg seeking is the difference between putting -ss before -i (input seeking) and after -i (output seeking).

  • -ss before -i seeks by jumping to the nearest keyframe before decoding starts. It is fast — often O(1) — because it uses the container index. This is what you want for thumbnails.
  • -ss after -i decodes every frame from the start of the file and discards them until it reaches the timestamp. It is frame-accurate but can be catastrophically slow on long files.

For a preview frame, you do not care whether you land on the exact millisecond — you care that it is fast and that the frame is not black. So use input seeking, and pick a timestamp a little way into the video (the first few seconds of many videos are logos or black fades).

Here is the core extraction command I use for a single poster frame at a percentage of the video's duration:

ffmpeg -hide_banner -loglevel error \
  -ss 12.5 \
  -i input.mp4 \
  -frames:v 1 \
  -vf "scale=640:-2:force_original_aspect_ratio=decrease,setsar=1" \
  -q:v 3 \
  -y poster.jpg
Enter fullscreen mode Exit fullscreen mode

A few notes on the flags that matter:

  • -frames:v 1 grabs exactly one frame and exits. Do not use -vframes (deprecated alias).
  • scale=640:-2 scales to 640px wide and computes a height divisible by 2 (encoders hate odd dimensions). force_original_aspect_ratio=decrease keeps it from stretching.
  • -q:v 3 is a quality scale for MJPEG where lower is better (2–5 is a good range). It gives smaller files than a fixed bitrate.
  • -hide_banner -loglevel error keeps stderr clean so you can actually parse real errors.

To know where 12.5 seconds falls proportionally, you need the duration first. ffprobe gives it to you as machine-readable JSON:

ffprobe -hide_banner -loglevel error \
  -show_entries format=duration \
  -of json input.mp4
Enter fullscreen mode Exit fullscreen mode

That returns something like {"format": {"duration": "642.360000"}}. We'll parse that in Go and pick a sensible offset.

Wrapping FFmpeg in a Go service

Go is a good fit here for one specific reason: os/exec combined with context.Context gives you clean per-job timeouts and cancellation. If a corrupt file makes FFmpeg hang, the context kills the process instead of leaking a worker forever.

Here is the probe-and-extract core. It reads the duration, computes an offset, and extracts a single frame with a hard timeout:

package thumb

import (
    "context"
    "encoding/json"
    "fmt"
    "os/exec"
    "strconv"
    "time"
)

type probeResult struct {
    Format struct {
        Duration string `json:"duration"`
    } `json:"format"`
}

// Duration returns the length of the video in seconds.
func Duration(ctx context.Context, path string) (float64, error) {
    cmd := exec.CommandContext(ctx, "ffprobe",
        "-hide_banner", "-loglevel", "error",
        "-show_entries", "format=duration",
        "-of", "json", path,
    )
    out, err := cmd.Output()
    if err != nil {
        return 0, fmt.Errorf("ffprobe: %w", err)
    }
    var r probeResult
    if err := json.Unmarshal(out, &r); err != nil {
        return 0, fmt.Errorf("parse probe: %w", err)
    }
    d, err := strconv.ParseFloat(r.Format.Duration, 64)
    if err != nil {
        return 0, fmt.Errorf("parse duration %q: %w", r.Format.Duration, err)
    }
    return d, nil
}

// Poster extracts a single JPEG frame at ~20% into the video (skipping
// intros) and writes it to dst. It enforces a hard timeout so a bad
// file can never wedge a worker.
func Poster(parent context.Context, src, dst string, width int) error {
    ctx, cancel := context.WithTimeout(parent, 30*time.Second)
    defer cancel()

    dur, err := Duration(ctx, src)
    if err != nil {
        return err
    }

    // Seek to 20% in, but never before 3s and never past 60s.
    offset := dur * 0.20
    if offset < 3 {
        offset = 3
    }
    if offset > 60 {
        offset = 60
    }

    vf := fmt.Sprintf(
        "scale=%d:-2:force_original_aspect_ratio=decrease,setsar=1",
        width,
    )
    cmd := exec.CommandContext(ctx, "ffmpeg",
        "-hide_banner", "-loglevel", "error",
        "-ss", strconv.FormatFloat(offset, 'f', 3, 64),
        "-i", src,
        "-frames:v", "1",
        "-vf", vf,
        "-q:v", "3",
        "-y", dst,
    )
    if out, err := cmd.CombinedOutput(); err != nil {
        return fmt.Errorf("ffmpeg poster: %w: %s", err, out)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The pattern to internalize: exec.CommandContext ties the process lifetime to the context. When the timeout fires, Go sends SIGKILL to FFmpeg and CombinedOutput returns. No zombie processes, no leaked file descriptors.

Building a hover-preview sprite sheet in one pass

The scrubber-preview feature needs many small frames — one every few seconds across the whole video. Extracting them one FFmpeg call at a time would be dozens of process spawns per video. Instead, extract them all in a single pass with the fps and tile filters, producing one sprite sheet: a grid of small thumbnails packed into a single image.

This command samples one frame every 10 seconds, scales each to 160px wide, and tiles them into a 5-column grid:

ffmpeg -hide_banner -loglevel error \
  -i input.mp4 \
  -vf "fps=1/10,scale=160:-2,tile=5x0" \
  -frames:v 1 \
  -q:v 4 \
  -y sprite.jpg
Enter fullscreen mode Exit fullscreen mode

The magic is tile=5x0. The 0 for rows tells FFmpeg to use as many rows as needed to fit every sampled frame, and -frames:v 1 because after tiling, the entire grid is a single output frame. One process, one file, all your preview frames.

The front end then needs to know the geometry to crop the right cell. Store the metadata alongside the image: interval, cell width/height, and columns. Given a playback time t, the cell index is floor(t / interval), and its pixel position is (index % cols) * cellW, (index / cols) * cellH.

Here is the Go function that generates the sprite and returns the metadata to persist:

type Sprite struct {
    Interval  int `json:"interval"`   // seconds between frames
    CellWidth int `json:"cellWidth"`
    Cols      int `json:"cols"`
    Count     int `json:"count"`      // total frames in the sheet
}

func BuildSprite(parent context.Context, src, dst string) (*Sprite, error) {
    ctx, cancel := context.WithTimeout(parent, 120*time.Second)
    defer cancel()

    dur, err := Duration(ctx, src)
    if err != nil {
        return nil, err
    }

    const interval = 10
    const cols = 5
    const cellW = 160
    count := int(dur)/interval + 1

    vf := fmt.Sprintf("fps=1/%d,scale=%d:-2,tile=%dx0", interval, cellW, cols)
    cmd := exec.CommandContext(ctx, "ffmpeg",
        "-hide_banner", "-loglevel", "error",
        "-i", src,
        "-vf", vf,
        "-frames:v", "1",
        "-q:v", "4",
        "-y", dst,
    )
    if out, err := cmd.CombinedOutput(); err != nil {
        return nil, fmt.Errorf("ffmpeg sprite: %w: %s", err, out)
    }

    return &Sprite{
        Interval:  interval,
        CellWidth: cellW,
        Cols:      cols,
        Count:     count,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

One caveat worth knowing: fps=1/10 still requires FFmpeg to decode the full video, because it samples output frames at a rate rather than seeking. For a 2-hour source this is the expensive part, which is exactly why this belongs in an async worker and not a request handler.

Bounding concurrency so FFmpeg never wins

The single most important production decision is limiting how many FFmpeg processes run at once. A weighted semaphore does this cleanly. Size it to NumCPU - 1 so the OS and your Go runtime keep a core, and jobs queue instead of thrashing.

package worker

import (
    "context"
    "runtime"

    "golang.org/x/sync/semaphore"
)

type Pool struct {
    sem *semaphore.Weighted
}

func NewPool() *Pool {
    n := int64(runtime.NumCPU() - 1)
    if n < 1 {
        n = 1
    }
    return &Pool{sem: semaphore.NewWeighted(n)}
}

// Run blocks until a slot is free, then executes fn. If ctx is
// cancelled while waiting, it returns without running fn.
func (p *Pool) Run(ctx context.Context, fn func() error) error {
    if err := p.sem.Acquire(ctx, 1); err != nil {
        return err
    }
    defer p.sem.Release(1)
    return fn()
}
Enter fullscreen mode Exit fullscreen mode

Every FFmpeg call goes through pool.Run. When all slots are busy, new jobs wait in Acquire instead of spawning. You trade latency for stability — which is the correct trade for asynchronous work nobody is watching in real time.

A few more guardrails I learned the hard way:

  • Content-address the output. Name files by a hash of videoID + variant + params. If the params never change, the filename never changes, and a CDN can cache it forever with an immutable header.
  • Write to a temp file, then rename. FFmpeg writing directly to the final path means a crash leaves a truncated, half-decoded JPEG that your CDN happily caches. Write to dst.tmp and os.Rename on success — rename is atomic on the same filesystem.
  • Validate output size. A 0-byte or suspiciously tiny JPEG usually means a black frame or a decode failure. Check os.Stat size before declaring success and retry with a different offset.

Serving the artifacts from the PHP tier

On DailyWatch the application is PHP 8.4 behind LiteSpeed and Cloudflare, with SQLite (using FTS5 for search) as the metadata store. The Go worker's only job is to produce files and record a row; the PHP tier serves them. Keeping generation and serving in separate tiers means a burst of new videos never slows page rendering.

When the worker finishes, it records the artifact so the front end knows the sprite geometry. A minimal PHP endpoint that returns the metadata for the player looks like this:

<?php
declare(strict_types=1);

// GET /api/preview.php?v=abc123
$videoId = $_GET['v'] ?? '';
if ($videoId === '' || !preg_match('/^[a-zA-Z0-9_-]{1,32}$/', $videoId)) {
    http_response_code(400);
    exit;
}

$db = new PDO('sqlite:/var/data/videos.db');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$stmt = $db->prepare(
    'SELECT sprite_path, interval_s, cell_w, cols, frame_count
     FROM thumbnails WHERE video_id = :v LIMIT 1'
);
$stmt->execute([':v' => $videoId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

if ($row === false) {
    http_response_code(404);
    exit;
}

// Immutable: the sprite for a video never changes once generated.
header('Content-Type: application/json');
header('Cache-Control: public, max-age=31536000, immutable');

echo json_encode([
    'sprite'   => '/thumbs/' . $row['sprite_path'],
    'interval' => (int) $row['interval_s'],
    'cellW'    => (int) $row['cell_w'],
    'cols'     => (int) $row['cols'],
    'count'    => (int) $row['frame_count'],
], JSON_THROW_ON_ERROR);
Enter fullscreen mode Exit fullscreen mode

Because the sprite image itself is a static file under /thumbs/, LiteSpeed serves it directly from disk and Cloudflare caches it at the edge with the immutable directive. The origin does almost no work per request — the expensive FFmpeg pass ran once, hours earlier, in the Go worker.

The player-side math to show the right frame on hover is small:

function cellFor(time, meta) {
  const i = Math.min(Math.floor(time / meta.interval), meta.count - 1);
  const x = (i % meta.cols) * meta.cellW;
  const y = Math.floor(i / meta.cols) * Math.round(meta.cellW * 9 / 16);
  return `background-position: -${x}px -${y}px`;
}
Enter fullscreen mode Exit fullscreen mode

What I would tell my past self

If you are building this, the ordering of decisions that actually matter is:

  • Generate once, serve static. Never run FFmpeg inside a request handler. The web tier should only ever read files.
  • Input-seek for single frames, decode-and-sample for sprites. Know which one each job needs; the performance gap is 100x on long videos.
  • Bound concurrency with a semaphore sized to your cores. This one change is the difference between a service that queues gracefully and one that falls over.
  • Content-address and write atomically. Immutable filenames plus temp-file renames give you free CDN caching and crash safety at the same time.
  • Put timeouts on everything. context.WithTimeout around every exec.CommandContext means a single malformed upload can never take down a worker.

The result on DailyWatch is a thumbnail pipeline that produces poster frames, hover sprites, and share images for every new video during ingest, stores them content-addressed, and serves them as immutable static files. FFmpeg does the hard work exactly once, Go keeps it bounded and cancellable, and the PHP tier never spawns a subprocess in the hot path. That separation — expensive generation offline, cheap serving online — is the whole trick, and it scales far better than any single clever FFmpeg flag ever could.

Top comments (0)