Skip to content

Commit dca4379

Browse files
committed
cron task to extract thumbnails from documents, videos, and audio (album art)
1 parent 8f3688d commit dca4379

File tree

2 files changed

+210
-1
lines changed

2 files changed

+210
-1
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
5.3.3-20240810
1+
5.3.3-20240811

classes/cron/extractthumbnails.php

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
<?php
2+
3+
namespace OB\Classes\Cron;
4+
5+
use OB\Classes\Base\Cron;
6+
7+
class ExtractThumbnails extends Cron
8+
{
9+
public function interval(): int
10+
{
11+
return 60;
12+
}
13+
14+
public function run(): bool
15+
{
16+
$db = \OBFDB::get_instance();
17+
18+
// get all media that needs thumbnail to extract
19+
$db->query('SELECT * FROM media WHERE
20+
(thumbnail_version IS NULL OR thumbnail_version < 2) AND
21+
type != "image"');
22+
23+
$media = $db->assoc_list();
24+
25+
foreach ($media as $item) {
26+
$input_file = OB_MEDIA . '/' . $item['file_location'][0] . '/' . $item['file_location'][1] . '/' . $item['filename'];
27+
$output_dir = OB_THUMBNAILS . '/media/' . $item['file_location'][0] . '/' . $item['file_location'][1];
28+
$output_file = $output_dir . '/' . $item['id'] . '.webp';
29+
30+
// create output dir if needed
31+
if (!is_dir($output_dir)) {
32+
mkdir($output_dir, 0777, true);
33+
}
34+
35+
// if thumbnail exists, don't regenerate just update version
36+
$thumbnail_search = glob(OB_THUMBNAILS . '/media/' . $item['file_location'][0] . '/' . $item['file_location'][1] . '/' . $item['id'] . '.*');
37+
foreach ($thumbnail_search as $thumbnail_file) {
38+
$db->query('UPDATE media SET thumbnail_version = 2 WHERE id = ' . $db->escape($item['id']));
39+
continue;
40+
}
41+
42+
// no thumbnail in thumbnail dir, generate it
43+
switch ($item['type']) {
44+
case 'video':
45+
$success = $this->runVideo($item, $input_file, $output_file);
46+
break;
47+
case 'audio':
48+
$success = $this->runAudio($item, $input_file, $output_file);
49+
break;
50+
case 'document':
51+
$success = $this->runDocument($item, $input_file, $output_file);
52+
break;
53+
}
54+
55+
if ($success) {
56+
$db->query('UPDATE media SET thumbnail_version = 2 WHERE id = ' . $db->escape($item['id']));
57+
}
58+
}
59+
60+
return false;
61+
}
62+
63+
private function runVideo($item, $input_file, $output_file): bool
64+
{
65+
echo 'generating video thumbnail for ' . $item['id'] . PHP_EOL;
66+
67+
$success = false;
68+
$duration = $item['duration'];
69+
70+
if ($duration) {
71+
// for short videos, start at the beginning.
72+
// for long videos, start at 25%
73+
if ($duration < 60) {
74+
$start = 0.00;
75+
} else {
76+
$start = $duration / 4;
77+
}
78+
79+
$start_hours = floor($start / 3600);
80+
$start -= $start_hours * 3600;
81+
$start_minutes = floor($start / 60);
82+
$start -= $start_minutes * 60;
83+
$start_seconds = $start;
84+
85+
if ($start_hours < 10) {
86+
$start_hours = '0' . $start_hours;
87+
}
88+
if ($start_minutes < 10) {
89+
$start_minutes = '0' . $start_minutes;
90+
}
91+
if ($start_seconds < 10) {
92+
$start_seconds = '0' . $start_seconds;
93+
}
94+
95+
$start = $start_hours . ':' . $start_minutes . ':' . round($start_seconds, 2);
96+
97+
// get unique dir name
98+
$tmp_dir = tempnam(sys_get_temp_dir(), 'ob_');
99+
100+
// tempnam creates a file, unlink and make it a directory
101+
unlink($tmp_dir);
102+
mkdir($tmp_dir);
103+
104+
// get 5 keyframes starting at 25% into the video.
105+
$command = 'ffmpeg -ss ' . escapeshellarg($start) . ' -i ' . escapeshellarg($input_file) . ' -vf "select=eq(pict_type\,I), scale=w=600:h=600:force_original_aspect_ratio=decrease" -vsync vfr -vframes 5 -q:v 0 -compression_level 6 -lossless 1 ' . escapeshellarg($tmp_dir . '/thumb%04d.webp') . ' -hide_banner 2>&1';
106+
$return_var = 0;
107+
exec($command, $output, $return_var);
108+
109+
// pick thumbnail with largest filesize
110+
$thumbs = glob($tmp_dir . '/thumb*');
111+
112+
$thumb_size = 0;
113+
$thumb_selected = false;
114+
115+
foreach ($thumbs as $thumb) {
116+
if (filesize($thumb) > $thumb_size) {
117+
$thumb_selected = $thumb;
118+
$thumb_size = filesize($thumb_selected);
119+
}
120+
}
121+
122+
if ($thumb_selected) {
123+
copy($thumb_selected, $output_file);
124+
$success = true;
125+
}
126+
127+
// clean up
128+
foreach ($thumbs as $thumb) {
129+
unlink($thumb);
130+
}
131+
rmdir($tmp_dir);
132+
}
133+
134+
return $success;
135+
}
136+
137+
private function runAudio($item, $input_file, $output_file): bool
138+
{
139+
echo 'generating audio thumbnail for ' . $item['id'] . PHP_EOL;
140+
141+
$command = 'ffmpeg -y -i ' . escapeshellarg($input_file) . ' -vf "scale=w=600:h=600:force_original_aspect_ratio=decrease" -q:v 0 -compression_level 6 -lossless 1 ' . escapeshellarg($output_file) . ' -hide_banner 2>&1';
142+
$return_var = 0;
143+
exec($command, $output, $return_var);
144+
$success = true; // assume success, because will fail if no album art, but that's okay.
145+
146+
return $success;
147+
}
148+
149+
private function runDocument($item, $input_file, $output_file): bool
150+
{
151+
echo 'generating document thumbnail for ' . $item['id'] . PHP_EOL;
152+
153+
$success = false;
154+
155+
// Use a high resolution for good quality
156+
$resolution = 300;
157+
158+
// Create a temporary file with .tiff extension
159+
$tempFile = tempnam(sys_get_temp_dir(), 'gs_output_');
160+
$tempFileTiff = $tempFile . '.tiff';
161+
rename($tempFile, $tempFileTiff);
162+
163+
// Ghostscript command to convert PDF to TIFF
164+
$gsCommand = sprintf(
165+
'gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=tiff24nc -dFirstPage=1 -dLastPage=1 ' .
166+
'-r%d -dTextAlphaBits=4 -dGraphicsAlphaBits=4 ' .
167+
'-sOutputFile=%s %s 2>&1',
168+
$resolution,
169+
escapeshellarg($tempFileTiff),
170+
escapeshellarg($input_file)
171+
);
172+
173+
// Execute Ghostscript command
174+
exec($gsCommand, $output, $returnVar);
175+
176+
if ($returnVar !== 0 || !file_exists($tempFileTiff)) {
177+
error_log("Ghostscript conversion failed: " . implode("\n", $output));
178+
if (file_exists($tempFileTiff)) {
179+
unlink($tempFileTiff);
180+
}
181+
return false;
182+
}
183+
184+
try {
185+
// Now use ImageMagick to resize the image and convert to JPG
186+
$im = new \Imagick();
187+
$im->readImage($tempFileTiff);
188+
$im->setImageBackgroundColor('white'); // Set white background
189+
$im->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); // Remove alpha channel
190+
$im->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); // Flatten image
191+
$im->thumbnailImage(1200, 1200, true);
192+
$im->setImageFormat('webp');
193+
$im->setOption('webp:lossless', 'true');
194+
$im->writeImage($output_file);
195+
$im->clear();
196+
$im->destroy();
197+
198+
$success = true;
199+
} catch (\ImagickException $e) {
200+
error_log("ImageMagick error: " . $e->getMessage());
201+
} finally {
202+
if (file_exists($tempFileTiff)) {
203+
unlink($tempFileTiff);
204+
}
205+
}
206+
207+
return $success;
208+
}
209+
}

0 commit comments

Comments
 (0)