|
| 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