diff --git a/src/Drivers/PHPFFMpeg.php b/src/Drivers/PHPFFMpeg.php index 243d98f..b95704c 100644 --- a/src/Drivers/PHPFFMpeg.php +++ b/src/Drivers/PHPFFMpeg.php @@ -65,6 +65,20 @@ public function __construct(FFMpeg $ffmpeg) $this->pendingComplexFilters = new Collection; } + /** + * Enable hardware acceleration with specified codec. + * + * @param string $codec e.g., 'h264_nvenc', 'hevc_nvenc' + * @return $this + */ + public function withHardwareAcceleration(string $codec): self + { + if ($this->isVideo()) { + $this->get()->getVideoStream()->setVideoCodec($codec); + } + return $this; + } + /** * Returns a fresh instance of itself with only the underlying FFMpeg instance. */ @@ -117,29 +131,53 @@ public function open(MediaCollection $mediaCollection): self if ($mediaCollection->count() === 1 && ! $this->forceAdvanced) { $media = Arr::first($mediaCollection->collection()); - $this->ffmpeg->setFFProbe( - FFProbe::make($this->ffmpeg->getFFProbe())->setMedia($media) - ); - - $ffmpegMedia = $this->ffmpeg->open($media->getLocalPath()); - - // this should be refactored to a factory... - if ($ffmpegMedia instanceof Video) { - $this->media = VideoMedia::make($ffmpegMedia); - } elseif ($ffmpegMedia instanceof Audio) { - $this->media = AudioMedia::make($ffmpegMedia); - } else { - $this->media = $ffmpegMedia; - } - - if (method_exists($this->media, 'setHeaders')) { - $this->media->setHeaders(Arr::first($mediaCollection->getHeaders()) ?: []); + try { + $this->ffmpeg->setFFProbe( + FFProbe::make($this->ffmpeg->getFFProbe())->setMedia($media) + ); + + $ffmpegMedia = $this->ffmpeg->open($media->getLocalPath()); + + // this should be refactored to a factory... + if ($ffmpegMedia instanceof Video) { + $this->media = VideoMedia::make($ffmpegMedia); + } elseif ($ffmpegMedia instanceof Audio) { + $this->media = AudioMedia::make($ffmpegMedia); + } else { + $this->media = $ffmpegMedia; + } + + if (method_exists($this->media, 'setHeaders')) { + $this->media->setHeaders(Arr::first($mediaCollection->getHeaders()) ?: []); + } + } catch (Exception $exception) { + throw new \RuntimeException( + sprintf( + "Failed to open media file: %s. Error: %s", + $media->getLocalPath(), + $exception->getMessage() + ), + 0, + $exception + ); } } else { - $ffmpegMedia = $this->ffmpeg->openAdvanced($mediaCollection->getLocalPaths()); - - $this->media = AdvancedMedia::make($ffmpegMedia) - ->setHeaders($mediaCollection->getHeaders()); + try { + $ffmpegMedia = $this->ffmpeg->openAdvanced($mediaCollection->getLocalPaths()); + + $this->media = AdvancedMedia::make($ffmpegMedia) + ->setHeaders($mediaCollection->getHeaders()); + } catch (Exception $exception) { + throw new \RuntimeException( + sprintf( + "Failed to open advanced media: %s. Error: %s", + implode(', ', $mediaCollection->getLocalPaths()), + $exception->getMessage() + ), + 0, + $exception + ); + } } return $this; @@ -256,4 +294,4 @@ public function __call($method, $arguments) return ($result === $media) ? $this : $result; } -} +} \ No newline at end of file diff --git a/src/Exporters/HLSExporter.php b/src/Exporters/HLSExporter.php index 49c3dd8..5fd1ca0 100755 --- a/src/Exporters/HLSExporter.php +++ b/src/Exporters/HLSExporter.php @@ -3,6 +3,7 @@ namespace ProtoneMedia\LaravelFFMpeg\Exporters; use Closure; +use FFMpeg\Exception\RuntimeException; use FFMpeg\Format\Audio\DefaultAudio; use FFMpeg\Format\AudioInterface; use FFMpeg\Format\FormatInterface; @@ -17,7 +18,6 @@ class HLSExporter extends MediaExporter use EncryptsHLSSegments; public const HLS_KEY_INFO_FILENAME = 'hls_encryption.keyinfo'; - public const ENCRYPTION_LISTENER = 'listen-encryption-key'; /** @@ -45,13 +45,39 @@ class HLSExporter extends MediaExporter */ private $segmentFilenameGenerator = null; + /** + * Enable x265 codec for HLS encoding. + * + * @return $this + */ + public function useX265(): self + { + if ($this->format instanceof DefaultVideo) { + $this->format->setVideoCodec('libx265'); + } + return $this; + } + + /** + * Enable hardware acceleration with specified codec. + * + * @param string $codec e.g., 'h264_nvenc', 'hevc_nvenc' + * @return $this + */ + public function withHardwareAcceleration(string $codec): self + { + if ($this->driver->isVideo()) { + $this->driver->get()->getVideoStream()->setVideoCodec($codec); + } + return $this; + } + /** * Setter for the segment length */ public function setSegmentLength(int $length): self { $this->segmentLength = max(2, $length); - return $this; } @@ -61,7 +87,6 @@ public function setSegmentLength(int $length): self public function setKeyFrameInterval(int $interval): self { $this->keyFrameInterval = max(2, $interval); - return $this; } @@ -72,7 +97,6 @@ public function setKeyFrameInterval(int $interval): self public function withPlaylistGenerator(PlaylistGenerator $playlistGenerator): self { $this->playlistGenerator = $playlistGenerator; - return $this; } @@ -87,11 +111,9 @@ private function getPlaylistGenerator(): PlaylistGenerator public function withoutPlaylistEndLine(): self { $playlistGenerator = $this->getPlaylistGenerator(); - if ($playlistGenerator instanceof HLSPlaylistGenerator) { $playlistGenerator->withoutEndLine(); } - return $this; } @@ -101,7 +123,6 @@ public function withoutPlaylistEndLine(): self public function useSegmentFilenameGenerator(Closure $callback): self { $this->segmentFilenameGenerator = $callback; - return $this; } @@ -185,7 +206,6 @@ private function applyFiltersCallback(callable $filtersCallback, int $formatKey) ); $filterCount = $hlsVideoFilters->count(); - $outs = [$filterCount ? HLSVideoFilters::glue($formatKey, $filterCount) : '0:v']; if ($this->getAudioStream()) { @@ -236,7 +256,6 @@ private function prepareSaving(?string $path = null): Collection } $media = $this->getDisk()->makeMedia($path); - $baseName = $media->getDirectory().$media->getFilenameWithoutExtension(); return $this->pendingFormats->map(function (array $formatAndCallback, $key) use ($baseName) { @@ -249,7 +268,6 @@ private function prepareSaving(?string $path = null): Collection ); $disk = $this->getDisk()->clone(); - $this->addHLSParametersToFormat($format, $segmentsPattern, $disk, $key); if ($filtersCallback) { @@ -272,7 +290,6 @@ private function prepareSaving(?string $path = null): Collection public function getCommand(?string $path = null) { $this->prepareSaving($path); - return parent::getCommand(null); } @@ -285,7 +302,20 @@ public function getCommand(?string $path = null) public function save(?string $mainPlaylistPath = null): MediaOpener { return $this->prepareSaving($mainPlaylistPath)->pipe(function ($segmentPlaylists) use ($mainPlaylistPath) { - $result = parent::save(); + try { + $result = parent::save(); + } catch (RuntimeException $exception) { + $errorOutput = $this->driver->getErrorOutput() ?? 'No error output available'; + throw new RuntimeException( + sprintf( + "Failed to encode HLS playlist: %s. FFmpeg error output: %s", + $exception->getMessage(), + $errorOutput + ), + 0, + $exception + ); + } $playlist = $this->getPlaylistGenerator()->get( $segmentPlaylists->all(), @@ -351,7 +381,6 @@ public function getAvailableVideoCodecs() } $this->pendingFormats->push([$format, $filtersCallback]); - return $this; } -} +} \ No newline at end of file diff --git a/src/Exporters/MediaExporter.php b/src/Exporters/MediaExporter.php index fdc536f..e6dcd6d 100755 --- a/src/Exporters/MediaExporter.php +++ b/src/Exporters/MediaExporter.php @@ -4,6 +4,7 @@ use FFMpeg\Exception\RuntimeException; use FFMpeg\Format\FormatInterface; +use FFMpeg\Format\Video\X264; use Illuminate\Support\Collection; use Illuminate\Support\Traits\ForwardsCalls; use ProtoneMedia\LaravelFFMpeg\Drivers\PHPFFMpeg; @@ -58,10 +59,36 @@ class MediaExporter public function __construct(PHPFFMpeg $driver) { $this->driver = $driver; - $this->maps = new Collection; } + /** + * Enable hardware acceleration with specified codec. + * + * @param string $codec e.g., 'h264_nvenc', 'hevc_nvenc' + * @return $this + */ + public function withHardwareAcceleration(string $codec): self + { + $this->driver->get()->getVideoStream()->setVideoCodec($codec); + return $this; + } + + /** + * Convert an image to a video with specified duration. + * + * @param string $outputPath + * @param float $duration + * @param FormatInterface|null $format + * @return $this + */ + public function toVideoFromImage(string $outputPath, float $duration, ?FormatInterface $format = null): self + { + $this->driver->addFilter(['-loop', '1']); + $this->driver->addFilter(['-t', $duration]); + return $this->save($outputPath, $format ?: new X264); + } + protected function getDisk(): Disk { if ($this->toDisk) { @@ -76,24 +103,26 @@ protected function getDisk(): Disk return $this->toDisk = $disk->clone(); } - public function inFormat(FormatInterface $format): self + public function inFormat(FormatInterface $format, bool $useX265 = false): self { $this->format = $format; + if ($useX265 && $format instanceof X264) { + $format->setVideoCodec('libx265'); + } + return $this; } public function toDisk($disk) { $this->toDisk = Disk::make($disk); - return $this; } public function withVisibility(string $visibility) { $this->visibility = $visibility; - return $this; } @@ -155,7 +184,6 @@ public function dd(?string $path = null) public function afterSaving(callable $callback): self { $this->afterSavingCallbacks[] = $callback; - return $this; } @@ -169,9 +197,7 @@ private function prepareSaving(?string $path = null): ?Media if ($this->maps->isNotEmpty()) { $this->driver->getPendingComplexFilters()->each->apply($this->driver, $this->maps); - $this->maps->map->apply($this->driver->get()); - return $outputMedia; } @@ -190,12 +216,11 @@ protected function runAfterSavingCallbacks(?Media $outputMedia = null) { foreach ($this->afterSavingCallbacks as $key => $callback) { call_user_func($callback, $this, $outputMedia); - unset($this->afterSavingCallbacks[$key]); } } - public function save(?string $path = null) + public function save(?string $path = null, ?FormatInterface $format = null) { $outputMedia = $this->prepareSaving($path); @@ -217,22 +242,30 @@ public function save(?string $path = null) if ($this->returnFrameContents) { $this->runAfterSavingCallbacks($outputMedia); - return $data; } } else { $this->driver->save( - $this->format ?: new NullFormat, + $format ?: $this->format ?: new NullFormat, optional($outputMedia)->getLocalPath() ?: '/dev/null' ); } } catch (RuntimeException $exception) { - throw EncodingException::decorate($exception); + $errorOutput = $this->driver->getErrorOutput() ?? 'No error output available'; + throw new RuntimeException( + sprintf( + "Failed to encode media: %s. FFmpeg error output: %s", + $exception->getMessage(), + $errorOutput + ), + 0, + $exception + ); } if ($outputMedia) { $outputMedia->copyAllFromTemporaryDirectory($this->visibility); - $outputMedia->setVisibility($path, $this->visibility); + $outputMedia->setVisibility($this->visibility); } if ($this->onProgressCallback) { @@ -262,7 +295,16 @@ private function saveWithMappings(): MediaOpener try { $this->driver->save(); } catch (RuntimeException $exception) { - throw EncodingException::decorate($exception); + $errorOutput = $this->driver->getErrorOutput() ?? 'No error output available'; + throw new RuntimeException( + sprintf( + "Failed to encode media with mappings: %s. FFmpeg error output: %s", + $exception->getMessage(), + $errorOutput + ), + 0, + $exception + ); } if ($this->onProgressCallback) { @@ -290,7 +332,6 @@ protected function getMediaOpener(): MediaOpener public function __call($method, $arguments) { $result = $this->forwardCallTo($driver = $this->driver, $method, $arguments); - return ($result === $driver) ? $this : $result; } -} +} \ No newline at end of file diff --git a/src/FFMpeg/ImageFormat.php b/src/FFMpeg/ImageFormat.php index 548e02b..beb468f 100644 --- a/src/FFMpeg/ImageFormat.php +++ b/src/FFMpeg/ImageFormat.php @@ -6,18 +6,35 @@ class ImageFormat extends DefaultVideo { + protected $duration = null; + public function __construct() { $this->kiloBitrate = 0; $this->audioKiloBitrate = null; } + /** + * Set the duration for image-to-video conversion. + * + * @param float $duration + * @return $this + */ + public function setDuration(float $duration): self + { + if ($duration <= 0) { + throw new \InvalidArgumentException('Duration must be greater than 0.'); + } + $this->duration = $duration; + return $this; + } + /** * Gets the kiloBitrate value. * * @return int */ - public function getKiloBitrate() + public function getKiloBitrate(): int { return $this->kiloBitrate; } @@ -32,7 +49,7 @@ public function getKiloBitrate() * * @return int */ - public function getModulus() + public function getModulus(): int { return 0; } @@ -40,9 +57,9 @@ public function getModulus() /** * Returns the video codec. * - * @return string + * @return string|null */ - public function getVideoCodec() + public function getVideoCodec(): ?string { return null; } @@ -54,7 +71,7 @@ public function getVideoCodec() * * @return bool */ - public function supportBFrames() + public function supportBFrames(): bool { return false; } @@ -64,7 +81,7 @@ public function supportBFrames() * * @return array */ - public function getAvailableVideoCodecs() + public function getAvailableVideoCodecs(): array { return []; } @@ -74,9 +91,18 @@ public function getAvailableVideoCodecs() * * @return array */ - public function getAdditionalParameters() + public function getAdditionalParameters(): array { - return ['-f', 'image2']; + $parameters = ['-f', 'image2']; + + if ($this->duration !== null) { + $parameters[] = '-loop'; + $parameters[] = '1'; + $parameters[] = '-t'; + $parameters[] = $this->duration; + } + + return $parameters; } /** @@ -84,7 +110,7 @@ public function getAdditionalParameters() * * @return array */ - public function getInitialParameters() + public function getInitialParameters(): array { return []; } @@ -92,7 +118,7 @@ public function getInitialParameters() /** * {@inheritdoc} */ - public function getExtraParams() + public function getExtraParams(): array { return []; } @@ -100,8 +126,8 @@ public function getExtraParams() /** * {@inheritDoc} */ - public function getAvailableAudioCodecs() + public function getAvailableAudioCodecs(): array { return []; } -} +} \ No newline at end of file diff --git a/src/Filesystem/Media.php b/src/Filesystem/Media.php index 46f2525..44f4ec1 100755 --- a/src/Filesystem/Media.php +++ b/src/Filesystem/Media.php @@ -3,6 +3,7 @@ namespace ProtoneMedia\LaravelFFMpeg\Filesystem; use Illuminate\Filesystem\FilesystemAdapter; +use Illuminate\Support\Str; class Media { @@ -26,11 +27,28 @@ class Media public function __construct(Disk $disk, string $path) { $this->disk = $disk; - $this->path = $path; - + $this->path = $this->resolveTemporaryPath($path); $this->makeDirectory(); } + /** + * Resolve and validate temporary paths to prevent incorrect path generation. + * + * @param string $path + * @return string + */ + private function resolveTemporaryPath(string $path): string + { + if (Str::startsWith($path, sys_get_temp_dir())) { + $resolvedPath = realpath($path); + if ($resolvedPath === false) { + throw new \InvalidArgumentException("Invalid temporary path: {$path}"); + } + return $resolvedPath; + } + return $path; + } + public static function make($disk, string $path): self { return new static(Disk::make($disk), $path); @@ -116,7 +134,13 @@ public function getLocalPath(): string $temporaryDirectoryDisk = $this->temporaryDirectoryDisk(); if ($disk->exists($path) && ! $temporaryDirectoryDisk->exists($path)) { - $temporaryDirectoryDisk->writeStream($path, $disk->readStream($path)); + try { + $temporaryDirectoryDisk->writeStream($path, $disk->readStream($path)); + } catch (\Exception $e) { + throw new \RuntimeException("Failed to copy file to temporary directory: {$e->getMessage()}"); + } + } elseif (! $disk->exists($path)) { + throw new \InvalidArgumentException("File does not exist: {$path}"); } return $temporaryDirectoryDisk->path($path); @@ -129,28 +153,32 @@ public function copyAllFromTemporaryDirectory(?string $visibility = null) } $temporaryDirectoryDisk = $this->temporaryDirectoryDisk(); - - $destinationAdapater = $this->getDisk()->getFilesystemAdapter(); + $destinationAdapter = $this->getDisk()->getFilesystemAdapter(); foreach ($temporaryDirectoryDisk->allFiles() as $path) { - $destinationAdapater->writeStream($path, $temporaryDirectoryDisk->readStream($path)); - - if ($visibility) { - $destinationAdapater->setVisibility($path, $visibility); + try { + $destinationAdapter->writeStream($path, $temporaryDirectoryDisk->readStream($path)); + + if ($visibility) { + $destinationAdapter->setVisibility($path, $visibility); + } + } catch (\Exception $e) { + throw new \RuntimeException("Failed to copy file {$path} from temporary directory: {$e->getMessage()}"); } } return $this; } - public function setVisibility(string $path, ?string $visibility = null) + /** + * Set the visibility of the media file. + * + * @param string $visibility + * @return $this + */ + public function setVisibility(string $visibility) { - $disk = $this->getDisk(); - - if ($visibility && $disk->isLocalDisk()) { - $disk->setVisibility($path, $visibility); - } - + $this->getDisk()->setVisibility($this->getPath(), $visibility); return $this; } -} +} \ No newline at end of file diff --git a/src/MediaOpener.php b/src/MediaOpener.php index ed115b7..b5465b4 100755 --- a/src/MediaOpener.php +++ b/src/MediaOpener.php @@ -3,6 +3,7 @@ namespace ProtoneMedia\LaravelFFMpeg; use FFMpeg\Coordinate\TimeCode; +use FFMpeg\Format\FormatInterface; use FFMpeg\Media\AbstractMediaType; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Filesystem\FilesystemManager; @@ -62,6 +63,46 @@ public function __construct($disk = null, ?PHPFFMpeg $driver = null, ?MediaColle $this->collection = $mediaCollection ?: new MediaCollection; } + /** + * Enable x265 codec for video encoding. + * + * @return $this + * @throws \RuntimeException If no exporter is initialized + */ + public function useX265(): self + { + $this->export()->useX265(); + return $this; + } + + /** + * Enable hardware acceleration with specified codec. + * + * @param string $codec e.g., 'h264_nvenc', 'hevc_nvenc' + * @return $this + * @throws \RuntimeException If no exporter is initialized + */ + public function withHardwareAcceleration(string $codec): self + { + $this->export()->withHardwareAcceleration($codec); + return $this; + } + + /** + * Convert an image to a video with specified duration. + * + * @param string $outputPath + * @param float $duration + * @param FormatInterface|null $format + * @return $this + * @throws \RuntimeException If no exporter is initialized + */ + public function toVideoFromImage(string $outputPath, float $duration, ?FormatInterface $format = null): self + { + $this->export()->toVideoFromImage($outputPath, $duration, $format); + return $this; + } + public function clone(): self { return new MediaOpener( @@ -77,7 +118,6 @@ public function clone(): self public function fromDisk($disk): self { $this->disk = Disk::make($disk); - return $this; } @@ -106,7 +146,6 @@ public function open($paths): self foreach (Arr::wrap($paths) as $path) { if ($path instanceof UploadedFile) { $disk = static::makeLocalDiskFromPath($path->getPath()); - $media = Media::make($disk, $path->getFilename()); } else { $media = Media::make($this->disk, $path); @@ -179,7 +218,6 @@ public function getFrameFromSeconds(float $seconds): self public function getFrameFromTimecode(TimeCode $timecode): self { $this->timecode = $timecode; - return $this; } @@ -216,7 +254,6 @@ public function exportTile(callable $withTileFactory): MediaExporter public function exportFramesByAmount(int $amount, ?int $width = null, ?int $height = null, ?int $quality = null): MediaExporter { $interval = ($this->getDurationInSeconds() + 1) / $amount; - return $this->exportFramesByInterval($interval, $width, $height, $quality); } @@ -234,7 +271,6 @@ public function exportFramesByInterval(float $interval, ?int $width = null, ?int public function cleanupTemporaryFiles(): self { app(TemporaryDirectories::class)->deleteAll(); - return $this; } @@ -257,13 +293,10 @@ public function __invoke(): AbstractMediaType /** * Forwards all calls to the underlying driver. - * - * @return void */ public function __call($method, $arguments) { $result = $this->forwardCallTo($driver = $this->getDriver(), $method, $arguments); - return ($result === $driver) ? $this : $result; } -} +} \ No newline at end of file