From dd8e59cd4f4079ce6e2dfe8947329d4b077a6793 Mon Sep 17 00:00:00 2001 From: James Collins Date: Thu, 27 Jul 2023 14:36:46 +1000 Subject: [PATCH] thumbnail support --- app/Jobs/StoreUploadedFileJob.php | 95 +++++++++++++++++++ app/Models/Media.php | 1 + ...26_235416_add_thumbnail_to_media_table.php | 28 ++++++ .../js/components/dialogs/SMDialogMedia.vue | 46 +++++---- resources/js/helpers/api.types.ts | 1 + resources/js/helpers/media.ts | 12 +++ 6 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 database/migrations/2023_07_26_235416_add_thumbnail_to_media_table.php diff --git a/app/Jobs/StoreUploadedFileJob.php b/app/Jobs/StoreUploadedFileJob.php index db21a17..e0cbb64 100644 --- a/app/Jobs/StoreUploadedFileJob.php +++ b/app/Jobs/StoreUploadedFileJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Models\Media; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -12,6 +13,8 @@ use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; +use Intervention\Image\Exception\NotWritableException; +use Intervention\Image\Exception\NotSupportedException; use SplFileInfo; use Symfony\Component\HttpFoundation\File\UploadedFile; use Intervention\Image\Facades\Image; @@ -63,6 +66,8 @@ class StoreUploadedFileJob implements ShouldQueue /** * Execute the job. + * + * @return void */ public function handle(): void { @@ -195,6 +200,8 @@ class StoreUploadedFileJob implements ShouldQueue $this->media->variants = $variants; }//end if + $this->generateThumbnail(); + if (strlen($this->uploadedFilePath) > 0) { unlink($this->uploadedFilePath); } @@ -208,4 +215,92 @@ class StoreUploadedFileJob implements ShouldQueue $this->fail($e); }//end try } + + + /** + * Generate a Thumbnail for this media. + * + * @return void + */ + public function generateThumbnail(): void + { + $thumbnailWidth = 200; + $thumbnailHeight = 200; + + $storageDisk = $this->media->storage; + $fileName = $this->media->name; + $filePath = $this->uploadedFilePath; + $fileExtension = File::extension($fileName); + $mimeType = $this->media->mime_type; + $tempImagePath = tempnam(sys_get_temp_dir(), 'thumb'); + $newFilename = pathinfo($fileName, PATHINFO_FILENAME) . "-thumb.webp"; + $success = false; + + // $ffmpegPath = '/usr/bin/ffmpeg'; + $ffmpegPath = '/opt/homebrew/bin/ffmpeg'; + + if (strpos($mimeType, 'image/') === 0) { + $image = Image::make($filePath); + $image->fit($thumbnailWidth, $thumbnailHeight); + $image->encode('webp', 75)->save($tempImagePath); + $success = true; + } elseif ($mimeType === 'application/pdf' && extension_loaded('imagick') === true) { + $pdfPreview = new \Imagick(); + $pdfPreview->setResolution(300, 300); + $pdfPreview->readImage($filePath . '[0]'); + $pdfPreview->setImageFormat('webp'); + $pdfPreview->thumbnailImage($thumbnailWidth, $thumbnailHeight, true); + file_put_contents($tempImagePath, $pdfPreview); + + $success = true; + } elseif ($mimeType === 'text/plain') { + $image = Image::canvas($thumbnailWidth, $thumbnailHeight, '#FFFFFF'); + + // Read the first few lines of the text file + $numLines = 5; + $text = file_get_contents($filePath); + $lines = explode("\n", $text); + $previewText = implode("\n", array_slice($lines, 0, $numLines)); + + // Center the text on the image + $fontSize = 8; + $textColor = '#000000'; // Black text color + + // Calculate the position to start drawing the text + $x = 10; // Left padding + $y = 10; // Top padding + + // Draw the text on the canvas with text wrapping + $lines = explode("\n", wordwrap($previewText, 30, "\n", true)); + foreach ($lines as $line) { + $image->text($line, $x, $y, function ($font) use ($fontSize, $textColor) { + $font->file(1); + $font->size($fontSize); + $font->color($textColor); + }); + + // Move to the next line + $y += ($fontSize + 4); // Add some vertical spacing between lines (adjust as needed) + } + + $image->encode('webp', 75)->save($tempImagePath); + + $success = true; + } elseif (file_exists($ffmpegPath) === true && strpos($mimeType, 'video/') === 0) { + $tempImagePath .= '.webp'; + exec("$ffmpegPath -i $filePath -ss 00:00:05 -vframes 1 -s {$thumbnailWidth}x{$thumbnailHeight} -c:v webp {$tempImagePath}"); + + $success = true; + }//end if + + if ($success === true && file_exists($tempImagePath) === true) { + Storage::disk($storageDisk)->putFileAs('/', new SplFileInfo($tempImagePath), $newFilename); + unlink($tempImagePath); + + $this->media->thumbnail = $this->media->getUrlPath() . $newFilename; + } else { + $fileIconPath = '/assets/fileicons/' . ($fileExtension !== '' && file_exists(public_path('assets/fileicons/' . $fileExtension . '.webp')) ? $fileExtension : 'unknown') . '.webp'; + $this->media->thumbnail = asset($fileIconPath); + } + } } diff --git a/app/Models/Media.php b/app/Models/Media.php index 60aa6e4..5089893 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -71,6 +71,7 @@ class Media extends Model 'description' => '', 'dimensions' => '', 'permission' => '', + 'thumbnail' => '', ]; /** diff --git a/database/migrations/2023_07_26_235416_add_thumbnail_to_media_table.php b/database/migrations/2023_07_26_235416_add_thumbnail_to_media_table.php new file mode 100644 index 0000000..5550159 --- /dev/null +++ b/database/migrations/2023_07_26_235416_add_thumbnail_to_media_table.php @@ -0,0 +1,28 @@ +string('thumbnail')->default(''); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('media', function (Blueprint $table) { + $table->dropColumn('thumbnail'); + }); + } +}; diff --git a/resources/js/components/dialogs/SMDialogMedia.vue b/resources/js/components/dialogs/SMDialogMedia.vue index 3c63aa5..1406b11 100644 --- a/resources/js/components/dialogs/SMDialogMedia.vue +++ b/resources/js/components/dialogs/SMDialogMedia.vue @@ -131,28 +131,28 @@ @dblclick="handleDblClickItem(item.id)">
+
+ {{ item.title }} +

@@ -343,13 +338,9 @@ 'media-selected-list-item', ]" :style="{ - backgroundImage: - item.url.length > 0 - ? `url('${mediaGetVariantUrl( - item, - 'thumb', - )}')` - : 'none', + backgroundImage: `url('${mediaGetThumbnail( + item, + )}')`, backgroundColor: item.status === 'OK' ? 'initial' @@ -443,7 +434,7 @@ import { MediaResponse, } from "../../helpers/api.types"; import { useApplicationStore } from "../../store/ApplicationStore"; -import { mediaGetVariantUrl, mimeMatches } from "../../helpers/media"; +import { mediaGetThumbnail, mimeMatches } from "../../helpers/media"; import SMInput from "../SMInput.vue"; import SMLoading from "../SMLoading.vue"; import SMTabGroup from "../SMTabGroup.vue"; @@ -593,6 +584,13 @@ const getMediaItem = (item_id: string): Media | null => { return found; }; +const showMediaName = (media: Media): boolean => { + return !( + media.mime_type.startsWith("image/") || + media.mime_type.startsWith("video/") + ); +}; + /** * Handle user clicking the cancel/close button. */ diff --git a/resources/js/helpers/api.types.ts b/resources/js/helpers/api.types.ts index 4c4f269..3f0e4f5 100644 --- a/resources/js/helpers/api.types.ts +++ b/resources/js/helpers/api.types.ts @@ -71,6 +71,7 @@ export interface Media { status: string; storage: string; url: string; + thumbnail: string; description: string; dimensions: string; variants: { [key: string]: string }; diff --git a/resources/js/helpers/media.ts b/resources/js/helpers/media.ts index f8c47af..9213a8a 100644 --- a/resources/js/helpers/media.ts +++ b/resources/js/helpers/media.ts @@ -37,3 +37,15 @@ export const mimeMatches = ( return regex.test(mimeToCheck); }; + +export const mediaGetThumbnail = (media: Media): string => { + if (!media) { + return ""; + } + + if (media.thumbnail && media.thumbnail.length > 0) { + return media.thumbnail; + } + + return mediaGetVariantUrl(media, "thumb"); +};