Thumbnails — Video Posters
has_thumb flag in the database. The library page picks them up automatically.Why we generate thumbnails ahead of time
You might wonder: why not just generate thumbnails on the fly when someone loads the library page? Two reasons. First, ffmpeg takes a few seconds per video — multiply that by 100 videos and your library page takes minutes to load. Pre-generating once is way better than re-doing it on every request. Second, caching files on disk is cheaper than computing them, both in CPU and in user wait time. This is the "do work ahead of time, serve fast at request time" pattern that runs the whole web.
The "10% offset" trick is a small but lovely detail. If you grab the very first frame of most videos, you get a black screen or a production logo. If you grab a frame at 10% of the way through, you usually hit actual content. Not perfect (some videos have long intros) but a good default. You can always sub in a different offset for stubborn cases.
The ffmpeg one-liner, explained
Here's the command we're going to run, then we'll break down every flag:
ffmpeg -ss 00:00:30 -i video.mp4 -vframes 1 -vf 'scale=320:-1' -q:v 4 thumb.jpg
Going through it:
- -ss 00:00:30 — seek to 30 seconds in before doing anything else. We'll compute this dynamically from the duration.
- -i video.mp4 — input file.
- -vframes 1 — extract exactly one frame. (Otherwise it'd try to encode the rest of the video.)
- -vf 'scale=320:-1' — apply a video filter that scales width to 320px and lets height auto-compute (the
-1means "preserve aspect ratio"). 320px is plenty for a library thumbnail; bigger just wastes disk and bandwidth. - -q:v 4 — JPEG quality (1 = best, 31 = worst). 4 is a sweet spot for thumbnails — small file, good enough quality.
- thumb.jpg — output path.
The command runs in maybe a second or two per video. Multiply by your library size and you have a coffee-break-worthy script. Once cached, it never has to run again unless the file changes.
Where to store the thumbnails
Remember in chapter 1 we made a storage/thumbs/ folder outside public/. Why outside? Because raw user-uploaded or generated files should never be in the web root by default — it's a defense-in-depth thing. We'll serve them through a thin PHP endpoint, OR add a specific Apache Alias for the thumbs folder. For HomeStream's "personal use" threat model, an Alias is fine and simpler.
Naming convention: storage/thumbs/{id}.jpg. The media ID becomes the filename. Predictable, no collisions, easy to delete when a media row is removed.
The has_thumb flag and the library page
When a thumbnail is generated successfully, we update has_thumb = 1 for that row. The library page checks this column and either shows a real <img> or a fallback placeholder. Two database flags + one Apache rule + one CLI script = a polished-feeling experience.
Add a one-line Alias so the thumbs folder is web-readable. Edit your default Apache site:
sudo nano /etc/apache2/sites-available/000-default.conf
Inside the <VirtualHost *:80> block, add:
Alias /thumbs /home/erictey/server/homestream/storage/thumbs
<Directory /home/erictey/server/homestream/storage/thumbs>
Require all granted
</Directory>
Reload Apache: sudo systemctl reload apache2. Now http://192.168.0.19/thumbs/42.jpg will serve the file (once it exists).
Build: bin/thumbs.php — Generate Posters
Last of the three CLI scripts. Walks all video rows where has_thumb = 0, generates a thumbnail, flips the flag. Run after metadata (because we need the duration to know where 10% is). Once done, the library page transforms.
- Create
/home/erictey/server/homestream/bin/thumbs.php. - Paste:
#!/usr/bin/env php <?php declare(strict_types=1); require __DIR__ . '/../lib/db.php'; $thumb_dir = __DIR__ . '/../storage/thumbs'; if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0775, true); $stmt = db()->query(" SELECT id, path, duration_s FROM media WHERE type = 'video' AND has_thumb = 0 ORDER BY id "); $made = 0; foreach ($stmt as $row) { $id = (int) $row['id']; $path = $row['path']; $dur = (int)($row['duration_s'] ?? 60); if (!is_file($path)) { echo " skip (missing): $path\n"; continue; } // 10% mark, but no less than 5 seconds and no more than 600 $offset_s = max(5, min(600, (int) round($dur * 0.10))); $offset_hms = gmdate('H:i:s', $offset_s); $out = "$thumb_dir/$id.jpg"; $cmd = sprintf( 'ffmpeg -y -ss %s -i %s -vframes 1 -vf scale=320:-1 -q:v 4 %s 2>/dev/null', escapeshellarg($offset_hms), escapeshellarg($path), escapeshellarg($out) ); shell_exec($cmd); if (is_file($out) && filesize($out) > 0) { db()->prepare("UPDATE media SET has_thumb = 1 WHERE id = ?")->execute([$id]); $made++; echo " ✓ id=$id ($offset_hms)\n"; } else { echo " ✗ failed id=$id\n"; } } echo "\nGenerated $made thumbnails.\n"; chmod +x bin/thumbs.php- Run:
bin/thumbs.php. You should see each video logged with its offset. - Check the storage folder:
ls -lh storage/thumbs/. You should see JPGs named by ID. - Test one in the browser:
http://192.168.0.19/thumbs/1.jpg(sub a real video ID). - Now update the library page to show the thumbnails. In
public/index.php, inside the foreach that renders each item, replace the simple type label with:<span class="type"> <?php if ($item['type'] === 'video' && $item['has_thumb']): ?> <img src="/thumbs/<?= (int)$item['id'] ?>.jpg" style="width:60px;height:auto" alt=""> <?php else: ?> <?= e(strtoupper($item['type'])) ?> <?php endif; ?> </span> - Refresh the library. Videos should now show actual thumbnails. Audio still shows the AUDIO label.
Stretch goals:
- For audio files, extract the embedded album art from ID3 tags (getID3 returns it as a binary blob in
$info['comments']['picture'][0]['data']) and save it the same way. - Generate a poster_url column instead of relying on the ID convention — opens the door to externally-sourced posters (TMDB API for movies).
- Add
poster="/thumbs/{id}.jpg"to the <video> tag in play.php so the poster shows before play. - Write a tiny "regenerate" link next to each item that runs the ffmpeg command with a different offset (in case the auto-pick was a bad frame).
What you flexed: ffmpeg one-liner with proper flags, gmdate for converting seconds to HH:MM:SS, escapeshellarg three times in one command, mkdir with permissions, the Apache Alias pattern repurposed for cached files, and the "flag column + conditional render" trick for showing different UI per row. The library page legitimately looks like a media app now.