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. Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ
This commit is contained in:
+9
-149
@@ -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
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user