php/src/Core/Media/Abstracts/MediaConversion.php

534 lines
12 KiB
PHP
Raw Normal View History

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Media\Abstracts;
use Core\Media\Events\ConversionProgress;
use Core\Media\Jobs\ProcessMediaConversion;
use Core\Media\Support\ConversionProgressReporter;
use Core\Media\Support\MediaConversionData;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Abstract base class for media conversions.
*
* Provides common functionality for media processing operations
* such as image resizing, thumbnail generation, and video processing.
*
* Supports queueing for large files to prevent request timeouts.
* Configure via 'media.queue_threshold_mb' (default: 5MB).
*
* ## Progress Reporting
*
* Conversions can report progress through callbacks and/or events:
*
* ```php
* $conversion = new MediaImageResizerConversion();
* $conversion->filepath('image.jpg')
* ->onProgress(function (int $percent, string $stage, ?string $message) {
* echo "Progress: {$percent}%\n";
* })
* ->execute();
* ```
*
* Or listen for events:
*
* ```php
* Event::listen(ConversionProgress::class, function ($event) {
* Log::info("Conversion {$event->stage}: {$event->percent}%");
* });
* ```
*
* @see ConversionProgress For progress event details
* @see ConversionProgressReporter For progress reporting implementation
*/
abstract class MediaConversion
{
protected string $filepath;
protected string $fromDisk = 'local';
protected string $toDisk = 'local';
protected string $name = '';
protected string $suffix = '';
/**
* Whether to force synchronous processing (bypass queue).
*/
protected bool $forceSync = false;
/**
* Additional options for conversion-specific configuration.
*
* @var array<string, mixed>
*/
protected array $options = [];
/**
* Progress callback for reporting conversion progress.
*
* @var callable|null
*/
protected $progressCallback = null;
/**
* Whether to dispatch progress events.
*/
protected bool $dispatchProgressEvents = true;
/**
* Progress reporter instance.
*/
protected ?ConversionProgressReporter $progressReporter = null;
/**
* Image MIME types.
*/
protected const IMAGE_MIMES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
];
/**
* Video MIME types.
*/
protected const VIDEO_MIMES = [
'video/mp4',
'video/mpeg',
'video/quicktime',
'video/x-msvideo',
'video/x-ms-wmv',
'video/webm',
'video/ogg',
'video/3gpp',
];
/**
* Get the engine name for this conversion.
*/
abstract public function getEngineName(): string;
/**
* Check if this conversion can be performed.
*/
abstract public function canPerform(): bool;
/**
* Get the output file path.
*/
abstract public function getPath(): string;
/**
* Perform the conversion.
*/
abstract public function handle(): ?MediaConversionData;
/**
* Set the source file path.
*/
public function filepath(string $filepath): static
{
$this->filepath = $filepath;
return $this;
}
/**
* Set the source disk.
*/
public function fromDisk(string $disk): static
{
$this->fromDisk = $disk;
return $this;
}
/**
* Set the destination disk.
*/
public function toDisk(string $disk): static
{
$this->toDisk = $disk;
return $this;
}
/**
* Set the conversion name.
*/
public function name(string $name): static
{
$this->name = $name;
return $this;
}
/**
* Set the filename suffix.
*/
public function suffix(string $suffix): static
{
$this->suffix = $suffix;
return $this;
}
/**
* Get the source file path.
*/
public function getFilepath(): string
{
return $this->filepath;
}
/**
* Get the source disk.
*/
public function getFromDisk(): string
{
return $this->fromDisk;
}
/**
* Get the destination disk.
*/
public function getToDisk(): string
{
return $this->toDisk;
}
/**
* Get the conversion name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Get the filename suffix.
*/
public function getSuffix(): string
{
return $this->suffix;
}
/**
* Force synchronous processing, bypassing the queue.
*/
public function sync(): static
{
$this->forceSync = true;
return $this;
}
/**
* Set a conversion-specific option.
*/
public function option(string $key, mixed $value): static
{
$this->options[$key] = $value;
return $this;
}
/**
* Get the conversion options.
*
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
/**
* Set a progress callback.
*
* The callback receives three arguments:
* - int $percent: Progress percentage (0-100)
* - string $stage: Progress stage (started, processing, completed, failed)
* - ?string $message: Optional status message
*
* @param callable $callback Progress callback
* @return $this
*/
public function onProgress(callable $callback): static
{
$this->progressCallback = $callback;
return $this;
}
/**
* Enable or disable progress event dispatching.
*
* @param bool $dispatch Whether to dispatch events
* @return $this
*/
public function withProgressEvents(bool $dispatch = true): static
{
$this->dispatchProgressEvents = $dispatch;
return $this;
}
/**
* Disable progress event dispatching.
*
* @return $this
*/
public function withoutProgressEvents(): static
{
return $this->withProgressEvents(false);
}
/**
* Get the progress reporter instance.
*
* Creates and configures a progress reporter for this conversion.
* Call this in your handle() method to report progress.
*/
protected function getProgressReporter(): ConversionProgressReporter
{
if ($this->progressReporter === null) {
$this->progressReporter = new ConversionProgressReporter(
$this->filepath,
$this->getEngineName()
);
$this->progressReporter->withEvents($this->dispatchProgressEvents);
if ($this->progressCallback !== null) {
$this->progressReporter->onProgress($this->progressCallback);
}
$this->progressReporter->withContext([
'fromDisk' => $this->fromDisk,
'toDisk' => $this->toDisk,
'name' => $this->name,
]);
}
return $this->progressReporter;
}
/**
* Report progress (convenience method).
*
* Shorthand for getProgressReporter()->progress($percent, $message).
*
* @param int $percent Progress percentage (0-100)
* @param string|null $message Optional status message
*/
protected function reportProgress(int $percent, ?string $message = null): void
{
$this->getProgressReporter()->progress($percent, $message);
}
/**
* Report progress from item counts (convenience method).
*
* @param int $current Current item number
* @param int $total Total items
* @param string|null $message Optional status message
*/
protected function reportProgressItems(int $current, int $total, ?string $message = null): void
{
$this->getProgressReporter()->progressItems($current, $total, $message);
}
/**
* Execute the conversion, queueing if file exceeds threshold.
*
* @return MediaConversionData|null Returns null if queued
*/
public function execute(): ?MediaConversionData
{
if (! $this->canPerform()) {
return null;
}
// Check if we should queue this conversion
if (! $this->forceSync && $this->shouldQueue()) {
$this->dispatchToQueue();
return null;
}
// Report start
$reporter = $this->getProgressReporter();
$reporter->start();
try {
$result = $this->handle();
// Report completion
$reporter->complete($result?->path);
return $result;
} catch (\Throwable $e) {
// Report failure
$reporter->fail($e->getMessage(), $e);
throw $e;
}
}
/**
* Check if the file size exceeds the queue threshold.
*/
protected function shouldQueue(): bool
{
$thresholdMb = config('media.queue_threshold_mb', 5);
if ($thresholdMb <= 0) {
return false;
}
$fileSize = $this->getFileSize();
if ($fileSize === null) {
return false;
}
$thresholdBytes = $thresholdMb * 1024 * 1024;
return $fileSize > $thresholdBytes;
}
/**
* Get the source file size in bytes.
*/
protected function getFileSize(): ?int
{
$disk = $this->filesystem($this->fromDisk);
if (! $disk->exists($this->filepath)) {
return null;
}
return $disk->size($this->filepath);
}
/**
* Dispatch the conversion to the queue.
*/
protected function dispatchToQueue(): void
{
$config = [
'filepath' => $this->filepath,
'fromDisk' => $this->fromDisk,
'toDisk' => $this->toDisk,
'name' => $this->name,
'suffix' => $this->suffix,
'options' => $this->buildQueueOptions(),
];
$queue = config('media.queue_name', 'default');
ProcessMediaConversion::dispatch(static::class, $config)
->onQueue($queue);
}
/**
* Build options array for queue serialization.
*
* Override in subclasses to include conversion-specific options.
*
* @return array<string, mixed>
*/
protected function buildQueueOptions(): array
{
return $this->options;
}
/**
* Get a filesystem instance for the given disk.
*/
protected function filesystem(string $disk): Filesystem
{
return Storage::disk($disk);
}
/**
* Get the file path with a suffix added before the extension.
*/
protected function getFilePathWithSuffix(?string $extension = null, ?string $basePath = null): string
{
$path = $basePath ?? $this->filepath;
$directory = dirname($path);
$filename = pathinfo($path, PATHINFO_FILENAME);
$originalExtension = pathinfo($path, PATHINFO_EXTENSION);
$ext = $extension ?? $originalExtension;
$suffix = $this->suffix !== '' ? '-'.$this->suffix : '-'.Str::slug($this->name);
if ($directory === '.') {
return $filename.$suffix.'.'.$ext;
}
return $directory.'/'.$filename.$suffix.'.'.$ext;
}
/**
* Check if the source file is an image.
*/
protected function isImage(): bool
{
$mimeType = $this->getMimeType();
return in_array($mimeType, self::IMAGE_MIMES, true);
}
/**
* Check if the source file is a GIF image.
*/
protected function isGifImage(): bool
{
return $this->getMimeType() === 'image/gif';
}
/**
* Check if the source file is a video.
*/
protected function isVideo(): bool
{
$mimeType = $this->getMimeType();
return in_array($mimeType, self::VIDEO_MIMES, true);
}
/**
* Get the MIME type of the source file.
*/
protected function getMimeType(): ?string
{
$disk = $this->filesystem($this->fromDisk);
if (! $disk->exists($this->filepath)) {
return null;
}
return $disk->mimeType($this->filepath);
}
}