Remove course thumbnails and the ffmpeg dependency

Course cards go back to the colour-block art that previously sat under the
captured frame, so the UI is unchanged apart from losing the photo.

- Drop thumb.php, generateThumbnails(), captureThumbnail() and
  ensureThumbnailColumns(), plus the first-video tracking the scanner only
  kept to pick a frame source.
- Drop the ffprobe fallback in probeDuration(); the pure-PHP mvhd parse is
  now the only scan-time probe, with the player's report on first play as
  the remaining fallback.
- Drop THUMBNAILS_DIR/THUMBNAIL_SEEK_PERCENT from config, the
  thumbnail_* columns from schema.sql, the thumbnail counters from scan
  output, .thumb img from the stylesheet, and the matching README parts.

Existing databases keep two now-unused folders.thumbnail_* columns; they
are inert and can be dropped by hand if wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ
This commit is contained in:
Harwinder Sekhon
2026-07-27 14:06:06 -04:00
parent 31b01a1a18
commit ff9a351091
9 changed files with 31 additions and 275 deletions
+6 -39
View File
@@ -20,8 +20,7 @@ views switch without page reloads and browser back/forward still work:
| `#/search/Q` | courses, videos and quizzes matching `Q` |
| `#/settings` | library folder, preferences, reset |
Only `stream.php` (the mp4 bytes) and `thumb.php` (cached course thumbnails)
are plain HTTP resources.
Only `stream.php` (the mp4 bytes) is a plain HTTP resource.
- Recursively scans a videos directory for `.mp4` files and mirrors the folder
hierarchy (courses → sections → videos, any nesting depth).
@@ -37,8 +36,6 @@ are plain HTTP resources.
library between **shelves and catalog**; both choices are remembered.
- **Shelves** group courses by how far you are: in progress, finished, not
started. The app has no course categories, so progress is what sorts them.
- **Course thumbnails** — a real frame captured from each course's first
video, 40% of the way through it.
- Seekable streaming via HTTP Range requests; files are addressed by DB id only
(no path traversal).
@@ -74,15 +71,9 @@ Top-level folders become courses on the home page.
the web server / PHP user).
- DB credentials (change the password in production).
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
- `THUMBNAILS_DIR`: where generated course thumbnails are cached — must be
*writable* by the web server / PHP user (default: `cache/thumbnails`
next to `config.php`).
- `THUMBNAIL_SEEK_PERCENT`: how far into a course's first video to capture
its thumbnail (default `0.4`, i.e. 40%).
An `ffmpeg` binary on `$PATH` is required for thumbnails (and is the
fallback for video duration if the pure-PHP mp4 parser can't read a file).
Without it, courses just keep their colour-block placeholder.
No external binaries are needed — PHP with PDO/MySQL is the whole
dependency list.
3. **Run** — with the PHP built-in server for testing:
@@ -128,29 +119,6 @@ correct set. Best score, last score, and attempt count are stored per user;
a quiz is *completed* once every question is answered correctly in one
attempt, and quiz scores count toward folder progress alongside videos.
## Thumbnails
Each course's card is a real frame, not just a colour block: the scanner finds
the course's first video — in the same order you'd browse to it (subfolders in
listing order, then that folder's videos) — and asks `ffmpeg` for a frame
`THUMBNAIL_SEEK_PERCENT` (default 40%) of the way through it, scaled and
cropped to 640×360. The result is cached at `THUMBNAILS_DIR/<course id>.jpg`
and served by `thumb.php?course_id=N`, which looks the file up by that ID only
(same containment check as `stream.php`) and sends normal `ETag`/
`Last-Modified` caching headers.
- Regeneration only happens when a course's first video has changed since the
last scan (tracked via `folders.thumbnail_video_id`), so rescans stay fast.
- If `ffmpeg` is missing, a video's duration can't be read, or generation
otherwise fails, the course simply keeps its colour-block placeholder — the
`<img>` 404s and the client removes it (`onerror="this.remove()"`), no error
is shown to the user. Failures are logged (`error_log`) and rolled up into
the scan's `thumbnails_failed` count.
- `thumbnail_video_id`/`thumbnail_generated_at` on `folders` are added
automatically on first use (`ensureThumbnailColumns()`) if a database
created before this feature doesn't have them yet — no manual migration
needed, the same self-healing `app_meta` uses.
## Search
The top-bar box searches as you type (from two characters, debounced) and also
@@ -201,7 +169,7 @@ line up without a separate monospace face.
Courses have no cover art, so each one gets a hue from the design's six-colour
set (clay, sage, slate, sand, plum, sky), picked from its folder id so it stays
the same across renders and reloads. That hue drives its thumbnail, its initial
the same across renders and reloads. That hue drives its card art, its initial
badge and its progress bar.
anime.js drives the motion: views fade and stagger in, progress bars grow from
@@ -241,9 +209,8 @@ next scan creates it.
- Folder progress is computed over the folder's entire subtree using a path
prefix match, with partial credit for videos in progress.
- Video duration is read at scan time by parsing the mp4's `mvhd` header in
pure PHP (ffprobe fallback if the parse fails), so listings show durations
before anyone plays the video. The browser still reports the duration on
first play as a last-resort fallback.
pure PHP, so listings show durations before anyone plays the video. If a file
can't be parsed, the browser reports its duration on first play instead.
## Production notes
+2 -1
View File
@@ -55,7 +55,8 @@ $stmt = $db->prepare(
);
$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]);
// Cache the duration on the video row so listings can show it without ffprobe.
// Cache the duration on the video row so listings can show it without waiting
// for someone to play the video (and for files the scanner couldn't parse).
if ($duration > 0) {
$db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL')
->execute([$duration, $videoId]);
+5 -9
View File
@@ -348,14 +348,10 @@
const bar = (percent, hue) =>
`<div class="bar"${hue ? ` style="--c:${hue}"` : ''}><span style="width:${Math.max(0, Math.min(100, percent))}%"></span></div>`;
// A course thumbnail is a real captured frame when the scanner made one
// (see generateThumbnails() server-side); until then, or if the request
// 404s, onerror drops the broken <img> and the colour block underneath
// (already painted — cat/dur/play/percent all sit above it) shows through.
function thumb(hue, { cat, dur, play, percent, courseId } = {}) {
// Courses have no cover art, so the card art is a colour block in the
// course's own hue, with the cat/dur/play/percent overlays on top.
function thumb(hue, { cat, dur, play, percent } = {}) {
return `<div class="thumb" style="--c:${hue}">
${courseId ? `<img src="thumb.php?course_id=${courseId}" alt="" loading="lazy"
onerror="this.remove()">` : ''}
${cat ? `<span class="thumb-cat">${esc(cat)}</span>` : ''}
${dur ? `<span class="thumb-dur">${esc(dur)}</span>` : ''}
${play ? `<span class="thumb-play"><span>${iconFill('play', 20)}</span></span>` : ''}
@@ -367,7 +363,7 @@
function courseCard(c) {
const hue = hueOf(c.id);
return `<a class="course-card" href="#/folder/${c.id}">
${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration), courseId: c.id })}
${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration) })}
<div class="course-card-body">
<div class="course-title">${esc(c.name)}</div>
<div class="course-meta">${esc(courseMeta(c))}</div>
@@ -540,7 +536,7 @@
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
const hue = hueOf(resume.course_id ?? resume.id);
return `<div class="hero">
<div class="hero-art">${thumb(hue, { play: true, percent: pct, courseId: resume.course_id })}</div>
<div class="hero-art">${thumb(hue, { play: true, percent: pct })}</div>
<div class="hero-body">
<span class="hero-eyebrow">Continue watching</span>
<div class="hero-title">${esc(resume.name)}</div>
-11
View File
@@ -568,17 +568,6 @@ button, input { font-family: inherit; font-size: inherit; }
}
.thumb::before { top: -30%; right: -12%; width: 62%; background: rgba(255, 255, 255, .2); }
.thumb::after { left: -8%; bottom: -32%; width: 48%; background: rgba(16, 15, 13, .14); }
/* A captured frame sits above the decorative blobs (opaque, so it fully
hides them once loaded) but below the text/icon overlays. onerror removes
this element on a 404, leaving the colour-block placeholder untouched. */
.thumb img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.thumb-cat {
position: absolute;
left: 10px; top: 8px;
-7
View File
@@ -18,10 +18,3 @@ define('COMPLETE_THRESHOLD', 0.95);
// Allow new users to self-register from the login page.
define('ALLOW_REGISTRATION', true);
// Where generated course thumbnails are cached. Must be writable by the PHP
// process; created automatically on first scan if missing.
define('THUMBNAILS_DIR', __DIR__ . '/cache/thumbnails');
// Capture each course's thumbnail this far into its first video (0.0-1.0).
define('THUMBNAIL_SEEK_PERCENT', 0.4);
+9 -149
View File
@@ -8,21 +8,14 @@ function h(?string $s): string
/* ---------------------------------------------------------------------------
* Video duration probing (used by the scanner so listings can show durations
* before anyone plays the video). Pure-PHP mp4 parse first, ffprobe fallback.
* before anyone plays the video). Pure PHP — no external tools involved; a
* file the mp4 parser can't read simply has no duration until the player
* reports one on first play.
* ------------------------------------------------------------------------- */
function probeDuration(string $file): ?float
{
$d = mp4Duration($file);
if ($d !== null && $d > 0) return $d;
if (function_exists('shell_exec')) {
$out = @shell_exec('ffprobe -v error -show_entries format=duration -of csv=p=0 '
. escapeshellarg($file) . ' 2>/dev/null');
if ($out !== null && is_numeric(trim($out))) {
return (float)trim($out);
}
}
return null;
return ($d !== null && $d > 0) ? $d : null;
}
/** Read the duration from an mp4's moov/mvhd box. Returns seconds or null. */
@@ -147,7 +140,6 @@ function scanLibrary(): array
$seenFolderIds = [];
$seenVideoIds = [];
$seenQuizIds = [];
$firstVideo = []; // courseFolderId => ['id' => videoId, 'path' => relPath]
$upsertFolder = $db->prepare(
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
@@ -173,14 +165,9 @@ function scanLibrary(): array
id = LAST_INSERT_ID(id)'
);
// $courseId identifies which top-level course folder the current directory
// sits under (null while walking VIDEOS_ROOT itself); it's set the moment
// we descend into a depth-0 folder and passed unchanged to every
// descendant call, so the first mp4 found anywhere in a course's subtree
// — in true directory-listing order — is recorded exactly once.
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth, ?int $courseId)
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$firstVideo): void {
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
$entries = scandir($dir) ?: [];
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
@@ -198,19 +185,15 @@ function scanLibrary(): array
$folderId = (int)$db->lastInsertId();
$seenFolderIds[] = $folderId;
$stats['folders']++;
$walk($full, $folderId, $rel, $depth + 1, $courseId ?? $folderId);
$walk($full, $folderId, $rel, $depth + 1);
} elseif (is_file($full) && strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'mp4') {
if ($parentId === null) {
continue; // mp4 directly in the root is skipped; use course folders
}
$name = pathinfo($entry, PATHINFO_FILENAME);
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
$videoId = (int)$db->lastInsertId();
$seenVideoIds[] = $videoId;
$seenVideoIds[] = (int)$db->lastInsertId();
$stats['videos']++;
if ($courseId !== null && !isset($firstVideo[$courseId])) {
$firstVideo[$courseId] = ['id' => $videoId, 'path' => $rel];
}
} elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) {
if ($parentId === null) {
continue;
@@ -229,7 +212,7 @@ function scanLibrary(): array
$db->beginTransaction();
try {
$walk($root, null, '', 0, null);
$walk($root, null, '', 0);
// Anything not seen this pass no longer exists on disk.
$flagMissing = function (string $table, array $seenIds) use ($db): int {
@@ -267,134 +250,11 @@ function scanLibrary(): array
}
}
// One thumbnail per course, captured from its first video (in the same
// order $firstVideo was built above) at THUMBNAIL_SEEK_PERCENT. Courses
// whose first video hasn't changed since last time are skipped.
$thumbStats = generateThumbnails($firstVideo);
$stats['thumbnails_generated'] = $thumbStats['generated'];
$stats['thumbnails_failed'] = $thumbStats['failed'];
appMetaSet('last_scan_at', gmdate('Y-m-d H:i:s'));
return $stats;
}
/* ---------------------------------------------------------------------------
* Course thumbnails — one frame captured from each course's first video (in
* scan order) at THUMBNAIL_SEEK_PERCENT of that video's duration, cached as a
* jpg named by folder id and served by thumb.php.
* ------------------------------------------------------------------------- */
/**
* folders.thumbnail_video_id/thumbnail_generated_at may not exist yet on a
* database created before this feature — self-heal the same way appMetaSet()
* lazily creates app_meta, so a git pull doesn't require a manual migration.
*/
function ensureThumbnailColumns(PDO $db): void
{
static $checked = false;
if ($checked) return;
try {
$db->query('SELECT thumbnail_video_id, thumbnail_generated_at FROM folders LIMIT 1');
} catch (Throwable $e) {
$db->exec('ALTER TABLE folders
ADD COLUMN thumbnail_video_id INT UNSIGNED NULL,
ADD COLUMN thumbnail_generated_at TIMESTAMP NULL');
}
$checked = true;
}
/**
* Generate (or refresh) each course's thumbnail from $firstVideo, the
* courseFolderId => ['id' => videoId, 'path' => relPath] map scanLibrary()
* builds while walking the tree. Returns ['generated' => n, 'skipped' => n,
* 'failed' => n].
*/
function generateThumbnails(array $firstVideo): array
{
$stats = ['generated' => 0, 'skipped' => 0, 'failed' => 0];
if (!$firstVideo) return $stats;
$dir = rtrim(THUMBNAILS_DIR, '/');
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
error_log("generateThumbnails: cannot create THUMBNAILS_DIR ($dir) — skipping");
$stats['failed'] = count($firstVideo);
return $stats;
}
$db = db();
ensureThumbnailColumns($db);
$upd = $db->prepare(
'UPDATE folders SET thumbnail_video_id = ?, thumbnail_generated_at = UTC_TIMESTAMP() WHERE id = ?'
);
foreach ($firstVideo as $courseId => $pick) {
$course = getFolder($courseId);
if (!$course) continue; // course itself went missing this scan
$thumbFile = "$dir/$courseId.jpg";
$alreadyCurrent = (int)($course['thumbnail_video_id'] ?? 0) === $pick['id'] && is_file($thumbFile);
if ($alreadyCurrent) {
$stats['skipped']++;
continue;
}
$abs = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $pick['path']);
$videoRoot = realpath(VIDEOS_ROOT);
if ($abs === false || $videoRoot === false
|| !str_starts_with($abs, $videoRoot . DIRECTORY_SEPARATOR)) {
$stats['failed']++;
continue;
}
$duration = probeDuration($abs);
$seekAt = ($duration !== null && $duration > 0) ? $duration * THUMBNAIL_SEEK_PERCENT : 1.0;
if (captureThumbnail($abs, $seekAt, $thumbFile)) {
$upd->execute([$pick['id'], $courseId]);
$stats['generated']++;
} else {
$stats['failed']++;
}
}
return $stats;
}
/**
* Grab a single frame from $videoPath at $seekSeconds via ffmpeg, cropped to
* a fixed 640x360 cover, and write it to $outFile. Written to a temp file
* first and renamed into place, so a request racing the write never sees a
* partial jpg. Returns false (leaving any existing thumbnail untouched) if
* ffmpeg is unavailable or the capture fails.
*/
function captureThumbnail(string $videoPath, float $seekSeconds, string $outFile): bool
{
if (!function_exists('shell_exec')) return false;
// The temp file has no recognizable extension, so the output format is
// forced explicitly with -f mjpeg rather than left for ffmpeg to guess
// from the filename (it can't, and fails outright without this).
$tmp = $outFile . '.tmp-' . bin2hex(random_bytes(4));
$cmd = sprintf(
'ffmpeg -y -ss %s -i %s -frames:v 1 -f mjpeg -vf %s -q:v 3 %s 2>&1',
escapeshellarg(number_format(max(0, $seekSeconds), 2, '.', '')),
escapeshellarg($videoPath),
escapeshellarg('scale=640:360:force_original_aspect_ratio=increase,crop=640:360'),
escapeshellarg($tmp)
);
$out = @shell_exec($cmd);
if (!is_file($tmp) || filesize($tmp) < 512) {
if (is_file($tmp)) @unlink($tmp);
if ($out !== null) {
error_log("captureThumbnail: ffmpeg produced no usable output for $videoPath\n$out");
}
return false;
}
return @rename($tmp, $outFile);
}
/* ---------------------------------------------------------------------------
* Progress helpers
* ------------------------------------------------------------------------- */
+1 -2
View File
@@ -11,5 +11,4 @@ if (PHP_SAPI !== 'cli') {
$stats = scanLibrary();
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes"
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n"
. "Thumbnails: {$stats['thumbnails_generated']} generated, {$stats['thumbnails_failed']} failed\n";
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n";
+8 -14
View File
@@ -19,21 +19,15 @@ CREATE TABLE IF NOT EXISTS app_meta (
-- One row per directory found under VIDEOS_ROOT.
-- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro".
-- thumbnail_video_id/thumbnail_generated_at are only ever set on top-level
-- (course) folders — see generateThumbnails() — and have no FK to videos
-- (that would make folders<->videos a circular reference); a stale pointer
-- is harmless and self-corrects on the next scan.
CREATE TABLE IF NOT EXISTS folders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
parent_id INT UNSIGNED NULL,
name VARCHAR(255) NOT NULL,
path VARCHAR(700) NOT NULL UNIQUE,
depth INT UNSIGNED NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
is_missing TINYINT(1) NOT NULL DEFAULT 0,
thumbnail_video_id INT UNSIGNED NULL,
thumbnail_generated_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
parent_id INT UNSIGNED NULL,
name VARCHAR(255) NOT NULL,
path VARCHAR(700) NOT NULL UNIQUE,
depth INT UNSIGNED NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
is_missing TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
REFERENCES folders(id) ON DELETE CASCADE,
INDEX idx_folders_parent (parent_id)
-43
View File
@@ -1,43 +0,0 @@
<?php
// Serves a cached course thumbnail — a frame captured from the course's first
// video at THUMBNAIL_SEEK_PERCENT, generated by the scanner (see
// generateThumbnails() in includes/functions.php). Looked up by folder ID
// only, mirroring stream.php's containment check; 404s (no thumbnail yet, or
// the id isn't a course) are expected and handled by the client falling back
// to its colour-block placeholder.
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
$userId = currentUserId();
if ($userId === null) {
http_response_code(403);
exit('Forbidden');
}
$courseId = (int)($_GET['course_id'] ?? 0);
$dir = realpath(THUMBNAILS_DIR);
$file = ($courseId > 0 && $dir !== false)
? realpath($dir . '/' . $courseId . '.jpg')
: false;
if ($dir === false || $file === false || !str_starts_with($file, $dir . DIRECTORY_SEPARATOR)
|| !is_file($file)) {
http_response_code(404);
exit('No thumbnail');
}
$mtime = filemtime($file);
$etag = '"' . md5($courseId . '-' . $mtime) . '"';
header('Cache-Control: private, max-age=86400');
header('ETag: ' . $etag);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? null) === $etag) {
http_response_code(304);
exit;
}
header('Content-Type: image/jpeg');
header('Content-Length: ' . filesize($file));
readfile($file);