diff --git a/CHANGELOG.md b/CHANGELOG.md index f1da4ce..4433df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ ## 4.0.3 - UNRELEASED ### Changed * Remove the `bufsize` parameter entirely from the FFMPEG default command, which was preventing `WebM` files from being generated properly ([#72](https://github.com/nystudio107/craft-transcoder/issues/72)) +* Organize video settings into separate queue, posters, and watermark tabs in the Craft control panel. +* Use Craft autosuggest fields for queue, subfolder, and watermark values, including environment-variable support. + +### Added + +* Queue video encoding when new video assets are uploaded, with a configurable delay. +* Queue GIF encoding when new GIF assets are uploaded, with a configurable delay. +* Queue audio encoding when new audio assets are uploaded, with a configurable delay. +* Queue configured video poster formats on asset upload without requiring full video encoding. +* Add source-asset and encoding-options video filename strategies while preserving bitrate-based filenames by default. +* Add configurable video watermark overlays. +* Generate configured video poster formats after queued encodes. +* Add blurred-background poster fitting to prevent black bars. +* Add `refreshVideoAsset()` for integrations that intentionally replace a video asset’s source file. +* Retry failed queued video encodes with configurable attempt and delay settings. + +### Fixed + +* Use one byte-identical output-path calculation for replacement cleanup and queued encoding. +* Fail refresh jobs when Craft cannot queue replacement encoding instead of reporting completion. +* Add modification-time cache versions to generated video and poster URLs after regeneration. +* Preserve configured video output subfolders when `getVideoUrl()` receives a string URL or path. +* Keep automatic Control Panel video thumbnails out of temporary upload storage and in the Asset's final subfolder. +* Forward the optional `generate` argument from Twig's `getVideoThumbnailUrl()` helper. +* Keep poster format handles and black-bar generation flags out of thumbnail filenames so queued posters and equivalent Twig requests reuse the same file. +* Correct legacy `transcoderUrl` migration and remove validation for the obsolete singular `transcoderPath` property. ## 4.0.2 - 2024.09.30 ## Added diff --git a/README.md b/README.md index 041c0e3..b3bf867 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,25 @@ Transcode video & audio files to various formats, and provide video thumbnails +## Media workflow features + +- Queue newly uploaded video assets through Craft’s queue. +- Queue newly uploaded GIF assets through Craft’s queue. +- Queue newly uploaded audio Assets through Craft’s queue. +- Delay queued encodes when other asset-save handlers need time to finish. +- Retry failed queued video encodes after a configurable delay. +- Keep the original option-based filenames, including bitrate, or opt into stable source-asset filenames. +- Overlay a configurable watermark on encoded videos. +- Generate named poster formats after queued encodes. +- Queue configured video posters on upload without requiring full video encoding. +- Replace poster letterboxing with a blurred cover background. +- Refresh managed video derivatives after a third-party plugin replaces a source asset. +- Keep Asset and string-URL video calls in the same configured output subfolder. + +Upload queueing and the other opt-in media features are disabled by default, while queued video jobs retry twice by default and the original `options` filename strategy remains unchanged. Configuration is available from the plugin settings screen, organized into media queue, poster, and watermark tabs, or `config/transcoder.php`. Text and numeric fields in these tabs support Craft environment-variable autosuggestions; the watermark path also supports aliases. + +Third-party plugins that intentionally replace a video asset can call `Transcoder::$plugin->getTranscode()->refreshVideoAsset($asset)`. Cleanup and re-encoding run asynchronously through Craft’s queue; see the usage documentation for the integration contract. +  **Note**: _The license fee for this plugin is $59.00 via the Craft Plugin Store._ diff --git a/composer.json b/composer.json index ae4c41d..5be4ec9 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "scripts": { "phpstan": "phpstan --ansi --memory-limit=1G", "check-cs": "ecs check --ansi", - "fix-cs": "ecs check --fix --ansi" + "fix-cs": "ecs check --fix --ansi", + "test-refresh": "php tests/refresh-video-asset-harness.php" }, "config": { "allow-plugins": { diff --git a/docs/docs/configuring.md b/docs/docs/configuring.md index 6557ad2..66ab28e 100644 --- a/docs/docs/configuring.md +++ b/docs/docs/configuring.md @@ -4,7 +4,9 @@ description: Configuring Transcoder documentation for the Transcoder plugin. The --- # Configuring Transcoder -The only configuration for Transcoder is in the `config.php` file, which is a multi-environment friendly way to store the default settings. Don’t edit this file, instead copy it to `craft/config` as `transcoder.php` and make your changes there. +Configure the media workflow from Transcoder’s Craft control-panel settings or with a `craft/config/transcoder.php` file. Don’t edit the plugin’s bundled `config.php`; copy it to `craft/config` when settings should be managed in code. + +The media queue and Video watermark tabs use Craft autosuggest fields for values that can come from environment variables. The watermark path also suggests Yii aliases. Numeric environment variables must resolve to integers within the same limits shown by their literal values. You will also need [ffmpeg](https://ffmpeg.org/) installed for Transcoder to work. On Ubuntu 16.04, you can do just: @@ -17,4 +19,127 @@ To install `ffmpeg` on Centos 6/7, you can follow the guide [How to Install FFmp If you have managed hosting, contact your sysadmin to get `ffmpeg` installed. +## Video queue and filenames + +Video uploads can be encoded by Craft’s queue instead of starting ffmpeg during a web request: + +```php +return [ + 'queueVideosOnAssetUpload' => true, + 'videoQueueDelaySeconds' => 5, + 'videoEncodeMaxRetries' => 2, + 'videoEncodeRetryDelaySeconds' => 120, + 'queuedVideoOptions' => [ + 'videoBitRate' => '1200k', + 'videoFrameRate' => 30, + 'width' => 1280, + 'height' => 720, + ], +]; +``` + +A working Craft queue runner is required. The queue job waits for ffmpeg to finish. When an attempt fails, Transcoder queues another attempt after `videoEncodeRetryDelaySeconds`, up to `videoEncodeMaxRetries` retries. Set the retry count to `0` to disable automatic retries; after the final attempt, the Craft queue job fails normally and remains available for manual retry. + +## GIF queue + +GIF uploads can use the same failure-visible Craft queue workflow without changing `getGifUrl()` or its string return value: + +```php +return [ + 'queueGifsOnAssetUpload' => true, + 'gifQueueDelaySeconds' => 5, + 'queuedGifOptions' => [ + 'videoEncoder' => 'gif', + ], +]; +``` + +GIF queueing is disabled by default. The queue worker runs ffmpeg synchronously and only completes after a non-empty output has been created. Existing Twig calls keep their original on-demand behavior. + +## Audio queue + +Audio uploads can also be encoded by Craft’s queue without changing `getAudioUrl()` or its string return value: + +```php +return [ + 'queueAudioOnAssetUpload' => true, + 'audioQueueDelaySeconds' => 5, + 'queuedAudioOptions' => [ + 'audioEncoder' => 'mp3', + 'audioBitRate' => '128k', + 'audioSampleRate' => '44100', + 'audioChannels' => '2', + ], +]; +``` + +Audio queueing is disabled by default. The job overrides the internal `synchronous` option so Craft only marks it complete after ffmpeg exits successfully and creates a non-empty output. Existing Twig calls and configured filenames are unchanged. + +`videoFilenameStrategy` supports: + +- `options` (default): preserves Transcoder’s original parameterized filenames. Output-affecting options such as `videoBitRate` remain part of the filename. +- `source`: uses the source asset name plus the encoder suffix, producing one stable output name per source asset. + +### Output subfolders + +Passing the actual Craft `Asset` to video and thumbnail helpers is preferred. With `createSubfolders` enabled, Transcoder uses Craft’s Asset folder for both the generated filesystem path and public URL. Automatic Control Panel thumbnails wait until an uploaded Asset has reached its final folder. + +When an integration can only pass a string URL or path, configure `subfolderUrlSegment` with the one-based path segment that contains the desired output folder. For example, segment `3` extracts `197915` from `/content/videos/197915/video.mp4`: + +```php +return [ + 'createSubfolders' => true, + 'subfolderUrlSegment' => 3, +]; +``` + +Leave `subfolderUrlSegment` as `false` (the default) when string inputs should use their base output directories. Transcoder normalizes path and URL separators, so configured video and thumbnail paths do not depend on a trailing slash. + +## Watermarks + +Watermarking accepts a local path, Yii alias, environment value, or public image URL: + +```php +return [ + 'enableVideoWatermark' => true, + 'videoWatermarkPath' => '@webroot/assets/watermark.png', + 'videoWatermarkWidth' => 180, + 'videoWatermarkPosition' => 'bottom-right', + 'videoWatermarkPadding' => 24, + 'videoWatermarkOpacity' => 80, +]; +``` + +Watermark settings are included in option-based output filenames through a short fingerprint. Changing the watermark therefore creates a new option-based output instead of reusing an incompatible encode. + +## Video posters + +Configured poster formats can be generated after a queued video encode or through their own upload job: + +```php +return [ + 'enableVideoPosters' => true, + 'queueVideoPostersOnAssetUpload' => true, + // Shared with uploaded video encoding jobs. + 'videoQueueDelaySeconds' => 5, + 'preventVideoPosterBlackBars' => true, + 'videoPosterFormats' => [ + '16_9' => [ + 'width' => 800, + 'height' => 450, + 'timeInSecs' => 3, + ], + 'square' => [ + 'width' => 800, + 'height' => 800, + 'timeInSecs' => 1, + ], + ], +]; +``` + +`queueVideoPostersOnAssetUpload` is disabled by default. When full video upload encoding is enabled, its existing `EncodeVideo` job remains responsible for poster generation and no duplicate poster job is queued. When full video upload encoding is disabled, this setting queues only the configured poster formats. The job reloads the Asset by ID and fails visibly instead of writing into Craft’s temporary upload folder. + +When `preventVideoPosterBlackBars` is enabled, the video frame is fitted over a blurred cover version of the same frame. Poster timestamps are clamped to the source duration for short videos. + Brought to you by [nystudio107](https://nystudio107.com) diff --git a/docs/docs/using.md b/docs/docs/using.md index fda5b3e..3ca9978 100644 --- a/docs/docs/using.md +++ b/docs/docs/using.md @@ -28,6 +28,8 @@ You can also pass in an URL: }) %} ``` +When passing a URL or string path, Transcoder cannot read Craft’s `folderPath` directly. Configure `subfolderUrlSegment` if the encoded output should retain a folder segment from that URL. Passing the actual `Asset` remains preferred because its subfolder is available without parsing the URL. + You can also pass in an `Asset`: ```twig @@ -89,7 +91,39 @@ The file format setting `videoEncoder` is preset to what you’ll need to genera  -Transcoder will also automatically add video thumbnails in the Control Panel Asset index. +Transcoder will also automatically add video thumbnails in the Control Panel Asset index. During an upload, thumbnail generation waits until Craft has moved the Asset into its final folder so `createSubfolders` remains consistent. + +## Reading Generated Video Posters + +Queued poster generation does not need to be started from a template. Enable `queueVideoPostersOnAssetUpload` to generate configured formats when full video upload encoding is disabled, then read a poster by its format handle: + +```twig +{% set video = entry.video.one() %} +{% set posterUrl = craft.transcoder.getVideoPosterUrl(video, '16_9') %} +{% set posterUrls = craft.transcoder.getVideoPosterUrls(video) %} +``` + +`getVideoPosterUrl()` returns an empty string until the poster exists. Passing `true` as its third argument keeps the original on-demand behavior and starts poster generation when needed. + +## Refreshing Replaced Video Assets + +Plugins that intentionally replace the source file of an existing video asset can ask Transcoder to invalidate its managed derivatives and encode the replacement again: + +```php +use nystudio107\transcoder\Transcoder; + +$result = Transcoder::$plugin + ->getTranscode() + ->refreshVideoAsset($asset); +``` + +The result contains `queued` and `jobId`. Cleanup and encoding are asynchronous, so a working Craft queue runner is required. The refresh is explicit and works independently of `queueVideosOnAssetUpload`; Transcoder does not listen globally for every asset replacement. + +Only the encoded variant produced with `queuedVideoOptions`, its lock/progress files, and currently configured poster formats are managed. Arbitrary variants generated from Twig are not removed. Missing derivatives are a successful no-op. Transcoder waits when the asset is already being processed and never signals or terminates FFmpeg. No database state or migration is required. + +The refresh and encoding jobs use the same output-path calculation, including subfolders, aliases, hashed names, filename strategy, encoding options, and watermark fingerprint. If cleanup or the follow-up queue push fails, Craft records a failed queue job rather than a completed refresh. Successful refreshes log the asset ID, removed-file count, and follow-up job ID without exposing filesystem paths. + +Generated video and poster URLs include a `v` query parameter based on the output file’s actual modification time. This prevents a stable output URL from continuing to serve cached bytes after successful regeneration. ## Generating a Transcoded Audio File @@ -155,6 +189,14 @@ The above example would cause it to not change the audio of the source audio fil The file format setting `audioEncoder` is preset to what you’ll need to generate `mp3` audio files, but it can also generate `aac`, `ogg`, or any other format that `ffmpeg` supports. See the `config.php` file for details +## Queued Audio Encoding + +When `queueAudioOnAssetUpload` is enabled, newly uploaded audio Assets are converted using Craft’s queue and `queuedAudioOptions`. The queue job executes ffmpeg synchronously so failures remain visible in Craft. Existing `craft.transcoder.getAudioUrl()` calls retain their original string response and on-demand behavior. + +## Queued GIF Encoding + +When `queueGifsOnAssetUpload` is enabled, newly uploaded GIF Assets are converted using Craft’s queue and `queuedGifOptions`. This does not change the existing `craft.transcoder.getGifUrl()` response or its on-demand behavior. Queue failures are reported as failed Craft jobs. + ## Getting Transcoding Progress Transcoding of video/audio files can take quite a bit of time, so Transcoder provides you with a way to get the status of any currently running transcoding operation via `craft.transcoder.getVideoProgressUrl()` or `craft.transcoder.getAudioProgressUrl()`. For example: @@ -205,7 +247,7 @@ To generate a thumbnail from a video, do the following: You can also pass in a URL: ```twig -{% set transVideoUrl = craft.transcoder.getVideoUrl('http://vjs.zencdn.net/v/oceans.mp4', { +{% set transVideoThumbUrl = craft.transcoder.getVideoThumbnailUrl('http://vjs.zencdn.net/v/oceans.mp4', { "width": 300, "height": 200, "timeInSecs": 20, @@ -216,14 +258,22 @@ You can also pass in an `Asset`: ```twig {% set myAsset = entry.someAsset.one() %} -{% set transVideoUrl = craft.transcoder.getVideoUrl(myAsset, { +{% set transVideoThumbUrl = craft.transcoder.getVideoThumbnailUrl(myAsset, { "width": 300, "height": 200, "timeInSecs": 20, }) %} ``` -It will return to you a URL to the thumbnail of the video, in the size you specify, from the timecode `timeInSecs` in the video. It creates this thumbnail immediately if it doesn’t already exist. +It will return a URL to the thumbnail of the video, in the size you specify, from the timecode `timeInSecs` in the video. It creates this thumbnail immediately if it doesn’t already exist. Pass `false` as the third argument to perform a read-only lookup without starting FFmpeg: + +```twig +{% set transVideoThumbUrl = craft.transcoder.getVideoThumbnailUrl(myAsset, { + "width": 300, + "height": 200, + "timeInSecs": 20, +}, false) %} +``` In the array you pass in, the default values are used if the key-value pair does not exist: diff --git a/src/Transcoder.php b/src/Transcoder.php index df5d68c..5d84889 100644 --- a/src/Transcoder.php +++ b/src/Transcoder.php @@ -16,9 +16,12 @@ use craft\console\Application as ConsoleApplication; use craft\elements\Asset; use craft\events\DefineAssetThumbUrlEvent; +use craft\events\ModelEvent; use craft\events\PluginEvent; use craft\events\RegisterCacheOptionsEvent; use craft\events\RegisterUrlRulesEvent; +use craft\events\TemplateEvent; +use craft\helpers\App; use craft\helpers\Assets as AssetsHelper; use craft\helpers\FileHelper; use craft\helpers\UrlHelper; @@ -27,6 +30,11 @@ use craft\utilities\ClearCaches; use craft\web\twig\variables\CraftVariable; use craft\web\UrlManager; +use craft\web\View; +use nystudio107\transcoder\jobs\EncodeAudio; +use nystudio107\transcoder\jobs\EncodeGif; +use nystudio107\transcoder\jobs\EncodeVideo; +use nystudio107\transcoder\jobs\GenerateVideoPosters; use nystudio107\transcoder\models\Settings; use nystudio107\transcoder\services\ServicesTrait; use nystudio107\transcoder\variables\TranscoderVariable; @@ -72,7 +80,7 @@ class Transcoder extends Plugin /** * @var bool */ - public bool $hasCpSettings = false; + public bool $hasCpSettings = true; /** * @var string @@ -99,6 +107,8 @@ public function init(): void $this->addComponents(); // Install our global event handlers $this->installEventHandlers(); + // Register settings page tabs + $this->registerSettingsTabs(); // We've loaded! Craft::info( Craft::t( @@ -147,6 +157,56 @@ protected function createSettingsModel(): ?Model return new Settings(); } + /** + * @inheritdoc + */ + protected function settingsHtml(): ?string + { + return Craft::$app->getView()->renderTemplate('transcoder/settings', [ + 'settings' => $this->getSettings(), + ]); + } + + /** + * Register Craft CP tabs for the plugin settings page. + */ + protected function registerSettingsTabs(): void + { + Event::on( + View::class, + View::EVENT_BEFORE_RENDER_TEMPLATE, + function(TemplateEvent $event) { + if ( + $event->template === 'settings/plugins/_settings.twig' + && ($event->variables['plugin']->handle ?? null) === $this->handle + ) { + $event->variables['tabs'] = [ + [ + 'label' => Craft::t('transcoder', 'Video queue'), + 'url' => '#settings-tab-video-queue', + ], + [ + 'label' => Craft::t('transcoder', 'GIF queue'), + 'url' => '#settings-tab-gif-queue', + ], + [ + 'label' => Craft::t('transcoder', 'Audio queue'), + 'url' => '#settings-tab-audio-queue', + ], + [ + 'label' => Craft::t('transcoder', 'Video posters'), + 'url' => '#settings-tab-video-posters', + ], + [ + 'label' => Craft::t('transcoder', 'Video watermark'), + 'url' => '#settings-tab-video-watermark', + ], + ]; + } + } + ); + } + /** * Add in our Craft components */ @@ -205,6 +265,76 @@ function(RegisterCacheOptionsEvent $event) { } ); } + if ($settings->queueVideosOnAssetUpload + || $settings->queueVideoPostersOnAssetUpload + || $settings->queueGifsOnAssetUpload + || $settings->queueAudioOnAssetUpload + ) { + Event::on( + Asset::class, + Asset::EVENT_AFTER_SAVE, + function(ModelEvent $event) use ($settings) { + $asset = $event->sender; + if (!$event->isNew || !$asset instanceof Asset) { + return; + } + + $isGif = strtolower(pathinfo($asset->filename, PATHINFO_EXTENSION)) === 'gif'; + $kind = AssetsHelper::getFileKindByExtension($asset->filename); + if ($isGif && $settings->queueGifsOnAssetUpload) { + $this->queueUploadedMedia( + new EncodeGif([ + 'assetId' => $asset->id, + 'gifOptions' => $settings->queuedGifOptions, + ]), + $settings->gifQueueDelaySeconds, + 'GIF', + (int)$asset->id + ); + return; + } + + if ($kind === Asset::KIND_AUDIO && $settings->queueAudioOnAssetUpload) { + $this->queueUploadedMedia( + new EncodeAudio([ + 'assetId' => $asset->id, + 'audioOptions' => $settings->queuedAudioOptions, + ]), + $settings->audioQueueDelaySeconds, + 'audio', + (int)$asset->id + ); + return; + } + + if ($kind === Asset::KIND_VIDEO) { + if ($settings->queueVideosOnAssetUpload) { + $this->queueUploadedMedia( + new EncodeVideo([ + 'assetId' => $asset->id, + 'videoOptions' => $settings->queuedVideoOptions, + ]), + $settings->videoQueueDelaySeconds, + 'video', + (int)$asset->id + ); + return; + } + + if ($this->transcode->shouldQueueStandaloneVideoPostersOnUpload()) { + $this->queueUploadedMedia( + new GenerateVideoPosters([ + 'assetId' => $asset->id, + ]), + $settings->videoQueueDelaySeconds, + 'video poster', + (int)$asset->id + ); + } + } + } + ); + } // Handler: Plugins::EVENT_AFTER_INSTALL_PLUGIN Event::on( Plugins::class, @@ -248,6 +378,26 @@ function(RegisterUrlRulesEvent $event) { ); } + /** + * Push an uploaded media job with its configured delay. + */ + protected function queueUploadedMedia(object $job, int|string $delaySeconds, string $mediaType, int $assetId): void + { + $queue = Craft::$app->getQueue(); + $queueDelay = max(0, (int)App::parseEnv((string)$delaySeconds)); + if ($queueDelay > 0) { + $queue = $queue->delay($queueDelay); + } + + $jobId = $queue->push($job); + if ($jobId === null) { + Craft::error("Unable to queue $mediaType asset #$assetId for encoding.", __METHOD__); + return; + } + + Craft::info("Queued $mediaType asset #$assetId for encoding; job ID: $jobId", __METHOD__); + } + /** * Return the custom frontend routes * diff --git a/src/config.php b/src/config.php index fcf6219..bb69124 100644 --- a/src/config.php +++ b/src/config.php @@ -63,9 +63,84 @@ // if a upload location has a subfolder defined, add this to the transcoder paths too 'createSubfolders' => true, + // One-based URL/path segment to use as the output subfolder for string inputs; false disables it + 'subfolderUrlSegment' => false, + // Add the Clear Caches utility to the CP? 'clearCaches' => false, + // Queue video encoding when a new video asset is uploaded + 'queueVideosOnAssetUpload' => false, + + // Seconds to wait before an uploaded video starts encoding + 'videoQueueDelaySeconds' => 0, + + // Retry a failed queued video encode this many times; 0 disables retries + 'videoEncodeMaxRetries' => 2, + + // Seconds to wait before retrying a failed queued video encode + 'videoEncodeRetryDelaySeconds' => 120, + + // Options passed to the queued video encode + 'queuedVideoOptions' => [], + + // Queue GIF encoding when a new GIF asset is uploaded + 'queueGifsOnAssetUpload' => false, + + // Seconds to wait before an uploaded GIF starts encoding + 'gifQueueDelaySeconds' => 0, + + // Options passed to the queued GIF encode + 'queuedGifOptions' => [], + + // Queue audio encoding when a new audio Asset is uploaded + 'queueAudioOnAssetUpload' => false, + + // Seconds to wait before uploaded audio starts encoding + 'audioQueueDelaySeconds' => 0, + + // Options passed to the queued audio encode + 'queuedAudioOptions' => [], + + // How encoded video filenames are generated: options or source + 'videoFilenameStrategy' => 'options', + + // Overlay a watermark on encoded videos + 'enableVideoWatermark' => false, + + // Local path, Yii alias, environment value, or URL for the watermark image + 'videoWatermarkPath' => '', + + // Optional watermark width in pixels; leave empty to keep its original size + 'videoWatermarkWidth' => '', + + // Watermark position: top-left, top-right, bottom-left, or bottom-right + 'videoWatermarkPosition' => 'bottom-right', + + // Watermark distance from the selected edges in pixels + 'videoWatermarkPadding' => 24, + + // Watermark opacity percentage + 'videoWatermarkOpacity' => 100, + + // Generate configured poster images after queued video encoding + 'enableVideoPosters' => false, + + // Queue configured poster generation for uploaded videos, even when video encoding is not queued + 'queueVideoPostersOnAssetUpload' => false, + + // Fill unused poster space with a blurred cover image instead of black bars + 'preventVideoPosterBlackBars' => false, + + // Poster images generated for each queued video + 'videoPosterFormats' => [ + '16_9' => [ + 'width' => 800, + 'height' => 450, + 'timeInSecs' => 3, + ], + ], + // Preset video encoders 'videoEncoders' => [ 'h264' => [ diff --git a/src/jobs/EncodeAudio.php b/src/jobs/EncodeAudio.php new file mode 100644 index 0000000..1ecac9f --- /dev/null +++ b/src/jobs/EncodeAudio.php @@ -0,0 +1,56 @@ +id($this->assetId)->one(); + if (!$asset instanceof Asset + || AssetsHelper::getFileKindByExtension($asset->filename) !== Asset::KIND_AUDIO + ) { + throw new RuntimeException("Unable to find audio asset #{$this->assetId}."); + } + + Craft::info("Starting queued audio encoding for asset #{$this->assetId}.", __METHOD__); + $executed = Transcoder::$plugin->transcode->runAudioAssetWork($asset, function() use ($asset): void { + $audioOptions = array_merge($this->audioOptions, ['synchronous' => true]); + $url = Transcoder::$plugin->transcode->getAudioUrl($asset, $audioOptions); + if ($url === '') { + throw new RuntimeException("Audio encoding failed for asset #{$this->assetId}."); + } + + Craft::info("Encoded audio asset #{$this->assetId}: $url", __METHOD__); + }); + + if (!$executed) { + throw new RuntimeException("Audio asset #{$this->assetId} is already being processed."); + } + } + + /** + * @inheritdoc + */ + protected function defaultDescription(): ?string + { + return "Encoding audio asset #{$this->assetId}"; + } +} diff --git a/src/jobs/EncodeGif.php b/src/jobs/EncodeGif.php new file mode 100644 index 0000000..398a1a3 --- /dev/null +++ b/src/jobs/EncodeGif.php @@ -0,0 +1,52 @@ +id($this->assetId)->one(); + if (!$asset instanceof Asset || strtolower(pathinfo($asset->filename, PATHINFO_EXTENSION)) !== 'gif') { + throw new RuntimeException("Unable to find GIF asset #{$this->assetId}."); + } + + Craft::info("Starting queued GIF encoding for asset #{$this->assetId}.", __METHOD__); + $executed = Transcoder::$plugin->transcode->runGifAssetWork($asset, function() use ($asset): void { + $url = Transcoder::$plugin->transcode->getGifUrl($asset, $this->gifOptions, true); + if (!is_string($url) || $url === '') { + throw new RuntimeException("GIF encoding failed for asset #{$this->assetId}."); + } + + Craft::info("Encoded GIF asset #{$this->assetId}: $url", __METHOD__); + }); + + if (!$executed) { + throw new RuntimeException("GIF asset #{$this->assetId} is already being processed."); + } + } + + /** + * @inheritdoc + */ + protected function defaultDescription(): ?string + { + return "Encoding GIF asset #{$this->assetId}"; + } +} diff --git a/src/jobs/EncodeVideo.php b/src/jobs/EncodeVideo.php new file mode 100644 index 0000000..0a81622 --- /dev/null +++ b/src/jobs/EncodeVideo.php @@ -0,0 +1,149 @@ +id($this->assetId)->one(); + if (!$asset instanceof Asset) { + throw new RuntimeException("Unable to find video asset #{$this->assetId}."); + } + + try { + Craft::info( + "Starting queued video encoding for asset #{$this->assetId}, attempt {$this->attempt}.", + __METHOD__ + ); + $executed = Transcoder::$plugin->transcode->runVideoAssetWork($asset, function() use ($asset): void { + $url = Transcoder::$plugin->transcode->getVideoUrl( + $asset, + $this->videoOptions, + true, + true + ); + + if ($url === '') { + throw new RuntimeException("Video encoding failed for asset #{$this->assetId}."); + } + + Craft::info("Encoded video asset #{$this->assetId}: $url", __METHOD__); + + if (Transcoder::$plugin->getSettings()->enableVideoPosters) { + $posters = Transcoder::$plugin->transcode->generateVideoPosters($asset); + if (in_array('', $posters, true)) { + throw new RuntimeException("Video poster generation failed for asset #{$this->assetId}."); + } + } + }); + + if (!$executed) { + throw new RuntimeException("Video asset #{$this->assetId} is already being processed."); + } + } catch (Throwable $e) { + if ($this->retryLater($queue, $asset, $e)) { + return; + } + + throw $e; + } + } + + /** + * Queue the next attempt, leaving the final failure visible to Craft. + */ + protected function retryLater(mixed $queue, Asset $asset, Throwable $error): bool + { + $settings = Transcoder::$plugin->getSettings(); + $maxRetries = max(0, (int)App::parseEnv((string)($this->maxRetries ?? $settings->videoEncodeMaxRetries))); + if ($this->attempt > $maxRetries) { + return false; + } + + $delay = max( + 0, + (int)App::parseEnv((string)($this->retryDelaySeconds ?? $settings->videoEncodeRetryDelaySeconds)) + ); + $nextAttempt = $this->attempt + 1; + $totalAttempts = $maxRetries + 1; + $message = Craft::t('transcoder', 'Retrying video encode attempt {attempt} of {total} in {seconds}s', [ + 'attempt' => $nextAttempt, + 'total' => $totalAttempts, + 'seconds' => $delay, + ]); + + try { + $queueService = Craft::$app->getQueue(); + if ($delay > 0) { + $queueService = $queueService->delay($delay); + } + $jobId = $queueService->push(new self([ + 'assetId' => (int)$asset->id, + 'videoOptions' => $this->videoOptions, + 'attempt' => $nextAttempt, + 'maxRetries' => $maxRetries, + 'retryDelaySeconds' => $delay, + ])); + } catch (Throwable $queueError) { + Craft::error( + "Unable to queue video encoding retry for asset #{$asset->id}: {$queueError->getMessage()}", + __METHOD__ + ); + return false; + } + + if ($jobId === null) { + Craft::error("Unable to queue video encoding retry for asset #{$asset->id}.", __METHOD__); + return false; + } + + Craft::warning( + "$message; asset #{$asset->id}; retry job ID: $jobId; previous error: {$error->getMessage()}", + __METHOD__ + ); + $this->setProgress($queue, 1, $message); + + return true; + } + + /** + * @inheritdoc + */ + protected function defaultDescription(): ?string + { + $description = "Encoding video asset #{$this->assetId}"; + if ($this->attempt > 1) { + $description .= " (attempt {$this->attempt})"; + } + + return $description; + } +} diff --git a/src/jobs/GenerateVideoPosters.php b/src/jobs/GenerateVideoPosters.php new file mode 100644 index 0000000..9626870 --- /dev/null +++ b/src/jobs/GenerateVideoPosters.php @@ -0,0 +1,65 @@ +id($this->assetId)->one(); + if (!$asset instanceof Asset + || AssetsHelper::getFileKindByExtension($asset->filename) !== Asset::KIND_VIDEO + ) { + throw new RuntimeException("Unable to find video asset #{$this->assetId} for poster generation."); + } + + if (!Transcoder::$plugin->getSettings()->enableVideoPosters) { + Craft::info("Skipped disabled video poster generation for asset #{$this->assetId}.", __METHOD__); + return; + } + + if (Transcoder::$plugin->transcode->isTemporaryUploadAsset($asset)) { + throw new RuntimeException("Video asset #{$this->assetId} is still in temporary upload storage."); + } + + Craft::info("Starting queued video poster generation for asset #{$this->assetId}.", __METHOD__); + $executed = Transcoder::$plugin->transcode->runVideoAssetWork($asset, function() use ($asset): void { + $posters = Transcoder::$plugin->transcode->generateVideoPosters($asset); + if (in_array('', $posters, true)) { + throw new RuntimeException("Video poster generation failed for asset #{$this->assetId}."); + } + + Craft::info( + 'Generated ' . count($posters) . " video poster(s) for asset #{$this->assetId}.", + __METHOD__ + ); + }); + + if (!$executed) { + throw new RuntimeException("Video asset #{$this->assetId} is already being processed."); + } + } + + /** + * @inheritdoc + */ + protected function defaultDescription(): ?string + { + return "Generating video posters for asset #{$this->assetId}"; + } +} diff --git a/src/jobs/RefreshVideoAsset.php b/src/jobs/RefreshVideoAsset.php new file mode 100644 index 0000000..f2f87e7 --- /dev/null +++ b/src/jobs/RefreshVideoAsset.php @@ -0,0 +1,74 @@ +id($this->assetId)->one(); + if (!$asset instanceof Asset) { + Craft::warning("Video refresh skipped missing asset #{$this->assetId}.", __METHOD__); + return; + } + + $result = Transcoder::$plugin->transcode->performVideoAssetRefresh($asset); + if (!empty($result['active'])) { + if ($this->attempt >= max(1, $this->maxAttempts)) { + throw new RuntimeException('Video refresh timed out waiting for current encoding to finish.'); + } + + $retryJobId = Craft::$app->getQueue()->delay(self::RETRY_DELAY_SECONDS)->push(new self([ + 'assetId' => $this->assetId, + 'attempt' => $this->attempt + 1, + 'maxAttempts' => $this->maxAttempts, + ])); + if ($retryJobId === null) { + throw new RuntimeException("Unable to requeue video refresh for asset #{$this->assetId}."); + } + + Craft::info( + "Video refresh for asset #{$this->assetId} is waiting; retry job ID: $retryJobId", + __METHOD__ + ); + $this->setProgress($queue, 1, Craft::t('transcoder', 'Waiting for current video encoding to finish')); + return; + } + + if (empty($result['queued']) || empty($result['jobId'])) { + throw new RuntimeException("Video refresh did not queue replacement encoding for asset #{$this->assetId}."); + } + + $this->setProgress($queue, 1, Craft::t('transcoder', 'Video refresh complete')); + } + + /** + * @inheritdoc + */ + protected function defaultDescription(): ?string + { + return Craft::t('transcoder', 'Refreshing video asset #{id}', [ + 'id' => $this->assetId ?? 'unknown', + ]); + } +} diff --git a/src/models/Settings.php b/src/models/Settings.php index 44cb1d0..bd2eadc 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -11,7 +11,9 @@ namespace nystudio107\transcoder\models; use craft\base\Model; +use craft\helpers\App; use craft\validators\ArrayValidator; +use yii\validators\NumberValidator; /** * Transcoder Settings model @@ -95,6 +97,9 @@ class Settings extends Model */ public bool $createSubfolders = true; + /** @var bool|int|string One-based URL/path segment used as the output subfolder for string inputs. */ + public bool|int|string $subfolderUrlSegment = false; + /** * clear caches when somebody clears all caches from the CP? * @@ -102,6 +107,78 @@ class Settings extends Model */ public bool $clearCaches = false; + /** @var bool Queue video encoding when a new video asset is uploaded. */ + public bool $queueVideosOnAssetUpload = false; + + /** @var int|string Seconds to wait before an uploaded video starts encoding. */ + public int|string $videoQueueDelaySeconds = 0; + + /** @var int|string Number of retries after a queued video encode fails. */ + public int|string $videoEncodeMaxRetries = 2; + + /** @var int|string Seconds to wait before retrying a failed video encode. */ + public int|string $videoEncodeRetryDelaySeconds = 120; + + /** @var array Options passed to queued video encodes. */ + public array $queuedVideoOptions = []; + + /** @var bool Queue GIF encoding when a new GIF asset is uploaded. */ + public bool $queueGifsOnAssetUpload = false; + + /** @var int|string Seconds to wait before an uploaded GIF starts encoding. */ + public int|string $gifQueueDelaySeconds = 0; + + /** @var array Options passed to queued GIF encodes. */ + public array $queuedGifOptions = []; + + /** @var bool Queue audio encoding when a new audio Asset is uploaded. */ + public bool $queueAudioOnAssetUpload = false; + + /** @var int|string Seconds to wait before uploaded audio starts encoding. */ + public int|string $audioQueueDelaySeconds = 0; + + /** @var array Options passed to queued audio encodes. */ + public array $queuedAudioOptions = []; + + /** @var string How encoded video filenames are generated: options or source. */ + public string $videoFilenameStrategy = 'options'; + + /** @var bool Overlay a watermark on encoded videos. */ + public bool $enableVideoWatermark = false; + + /** @var string Local path, alias, environment value, or URL for the watermark image. */ + public string $videoWatermarkPath = ''; + + /** @var int|string Optional watermark width in pixels. */ + public int|string $videoWatermarkWidth = ''; + + /** @var string Watermark position. */ + public string $videoWatermarkPosition = 'bottom-right'; + + /** @var int|string Watermark distance from the selected edges in pixels. */ + public int|string $videoWatermarkPadding = 24; + + /** @var int|string Watermark opacity percentage. */ + public int|string $videoWatermarkOpacity = 100; + + /** @var bool Generate configured poster images after queued video encoding. */ + public bool $enableVideoPosters = false; + + /** @var bool Queue configured poster generation when a new video Asset is uploaded. */ + public bool $queueVideoPostersOnAssetUpload = false; + + /** @var bool Fill unused poster space with a blurred cover image. */ + public bool $preventVideoPosterBlackBars = false; + + /** @var array Poster images generated for each queued video. */ + public array $videoPosterFormats = [ + '16_9' => [ + 'width' => 800, + 'height' => 450, + 'timeInSecs' => 3, + ], + ]; + /** * Preset video encoders * @@ -244,7 +321,7 @@ public function __construct(array $config = []) unset($config['transcoderPath']); } if (isset($config['transcoderUrl'])) { - $config['$transcoderUrls']['default'] = $config['transcoderUrl']; + $config['transcoderUrls']['default'] = $config['transcoderUrl']; unset($config['transcoderUrl']); } } @@ -266,15 +343,36 @@ public function rules(): array ['ffprobePath', 'required'], ['ffprobeOptions', 'string'], ['ffprobeOptions', 'safe'], - ['transcoderPath', 'string'], - ['transcoderPath', 'required'], ['transcoderPaths', ArrayValidator::class], ['transcoderPaths', 'required'], ['transcoderUrls', ArrayValidator::class], ['enableDownloadFileEndpoint', 'boolean'], ['useHashedNames', 'boolean'], ['createSubfolders', 'boolean'], + ['subfolderUrlSegment', 'validateIntegerSetting', 'params' => ['min' => 1], 'skipOnEmpty' => true], ['clearCaches', 'boolean'], + ['queueVideosOnAssetUpload', 'boolean'], + ['videoQueueDelaySeconds', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['videoEncodeMaxRetries', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['videoEncodeRetryDelaySeconds', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['queuedVideoOptions', ArrayValidator::class], + ['queueGifsOnAssetUpload', 'boolean'], + ['gifQueueDelaySeconds', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['queuedGifOptions', ArrayValidator::class], + ['queueAudioOnAssetUpload', 'boolean'], + ['audioQueueDelaySeconds', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['queuedAudioOptions', ArrayValidator::class], + ['videoFilenameStrategy', 'in', 'range' => ['options', 'source']], + ['enableVideoWatermark', 'boolean'], + ['videoWatermarkPath', 'string'], + ['videoWatermarkWidth', 'validateIntegerSetting', 'params' => ['min' => 1], 'skipOnEmpty' => true], + ['videoWatermarkPosition', 'in', 'range' => ['top-left', 'top-right', 'bottom-left', 'bottom-right']], + ['videoWatermarkPadding', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['videoWatermarkOpacity', 'validateIntegerSetting', 'params' => ['min' => 0, 'max' => 100]], + ['enableVideoPosters', 'boolean'], + ['queueVideoPostersOnAssetUpload', 'boolean'], + ['preventVideoPosterBlackBars', 'boolean'], + ['videoPosterFormats', ArrayValidator::class], ['videoEncoders', 'required'], ['audioEncoders', 'required'], ['defaultVideoOptions', 'required'], @@ -282,4 +380,48 @@ public function rules(): array ['defaultAudioOptions', 'required'], ]; } + + /** + * Validate a numeric setting after resolving its environment variable. + */ + public function validateIntegerSetting(string $attribute, array $params): void + { + if ($this->$attribute === false) { + return; + } + + $validator = new NumberValidator([ + 'integerOnly' => true, + 'min' => $params['min'] ?? null, + 'max' => $params['max'] ?? null, + ]); + $error = null; + $value = App::parseEnv((string)$this->$attribute); + + if (!$validator->validate($value, $error)) { + $this->addError($attribute, $error); + } + } + + /** + * Return poster formats as editable-table rows. + */ + public function getVideoPosterFormatRows(): array + { + $rows = []; + foreach ($this->videoPosterFormats as $handle => $format) { + if (!is_array($format)) { + continue; + } + + $rows[] = [ + 'handle' => is_string($handle) ? $handle : ($format['handle'] ?? ''), + 'width' => $format['width'] ?? '', + 'height' => $format['height'] ?? '', + 'timeInSecs' => $format['timeInSecs'] ?? '', + ]; + } + + return $rows; + } } diff --git a/src/services/Transcode.php b/src/services/Transcode.php index 9db1a36..71c5622 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -16,10 +16,14 @@ use craft\events\DefineAssetThumbUrlEvent; use craft\fs\Local; use craft\helpers\App; +use craft\helpers\Assets as AssetsHelper; use craft\helpers\FileHelper; use craft\helpers\Json as JsonHelper; use mikehaertl\shellcommand\Command as ShellCommand; +use nystudio107\transcoder\jobs\EncodeVideo; +use nystudio107\transcoder\jobs\RefreshVideoAsset; use nystudio107\transcoder\Transcoder; +use RuntimeException; use yii\base\Exception; use yii\base\InvalidConfigException; use yii\validators\UrlValidator; @@ -98,36 +102,32 @@ class Transcode extends Component * @return string URL of the transcoded video or "" * @throws InvalidConfigException */ - public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $generate = true): string - { + public function getVideoUrl( + string|Asset $filePath, + array $videoOptions, + bool $generate = true, + bool $synchronous = false, + ): string { $result = ''; $settings = Transcoder::$plugin->getSettings(); - $subfolder = ''; - - // sub folder check - if (($filePath instanceof Asset) && $settings['createSubfolders']) { - $subfolder = $filePath->folderPath; - } - - // file path - $filePath = $this->getAssetPath($filePath); - - if (!empty($filePath)) { - $destVideoPath = $settings['transcoderPaths']['video'] ?? $settings['transcoderPaths']['default']; - $destVideoPath .= $subfolder; - $destVideoPath = App::parseEnv($destVideoPath); - $videoOptions = $this->coalesceOptions('defaultVideoOptions', $videoOptions); - - // Get the video encoder presets to use - $videoEncoders = $settings['videoEncoders']; - $thisEncoder = $videoEncoders[$videoOptions['videoEncoder']]; + $outputInfo = $this->getVideoOutputInfo($filePath, $videoOptions); - $videoOptions['fileSuffix'] = $thisEncoder['fileSuffix']; + if ($outputInfo !== null) { + $filePath = $outputInfo['sourcePath']; + $destVideoPath = $outputInfo['directory']; + $videoOptions = $outputInfo['videoOptions']; + $thisEncoder = $outputInfo['encoder']; + $watermarkPath = $outputInfo['watermarkPath']; // Build the basic command for ffmpeg $ffmpegCmd = $settings['ffmpegPath'] - . ' -i ' . escapeshellarg($filePath) - . ' -vcodec ' . $thisEncoder['videoCodec'] + . ' -i ' . escapeshellarg($filePath); + + if ($watermarkPath !== null) { + $ffmpegCmd .= ' -loop 1 -i ' . escapeshellarg($watermarkPath); + } + + $ffmpegCmd .= ' -vcodec ' . $thisEncoder['videoCodec'] . ' ' . $thisEncoder['videoCodecOptions'] . ' -threads ' . $thisEncoder['threads']; @@ -141,11 +141,17 @@ public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $g $ffmpegCmd .= ' -b:v ' . $videoOptions['videoBitRate'] . ' -maxrate ' . $videoOptions['videoBitRate']; } - // Adjust the scaling if desired - $ffmpegCmd = $this->addScalingFfmpegArgs( - $videoOptions, - $ffmpegCmd - ); + if ($watermarkPath !== null) { + $ffmpegCmd .= ' -filter_complex ' . escapeshellarg($this->getVideoWatermarkFilter($videoOptions)) + . ' -map ' . escapeshellarg('[transcoded]') + . ' -map ' . escapeshellarg('0:a?'); + } else { + // Adjust the scaling if desired + $ffmpegCmd = $this->addScalingFfmpegArgs( + $videoOptions, + $ffmpegCmd + ); + } // Handle any audio transcoding if (empty($videoOptions['audioBitRate']) @@ -178,20 +184,21 @@ public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $g } } - $destVideoFile = $this->getFilename($filePath, $videoOptions); - // File to store the video encoding progress in - $progressFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destVideoFile . '.progress'; + $progressFile = $outputInfo['progressFile']; // Assemble the destination path and final ffmpeg command - $destVideoPath .= $destVideoFile; + $destVideoPath = $outputInfo['path']; $ffmpegCmd .= ' -f ' . $thisEncoder['fileFormat'] - . ' -y ' . escapeshellarg($destVideoPath) - . ' 1> ' . $progressFile . ' 2>&1 & echo $!'; + . ' -y ' . escapeshellarg($destVideoPath); + + if (!$synchronous) { + $ffmpegCmd .= ' 1> ' . $progressFile . ' 2>&1 & echo $!'; + } // Make sure there isn't a lockfile for this video already - $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destVideoFile . '.lock'; + $lockFile = $outputInfo['lockFile']; $oldPid = @file_get_contents($lockFile); if ($oldPid !== false) { // See if the process is running, and empty result means the process is still running @@ -206,15 +213,39 @@ public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $g } // If the video file already exists and hasn't been modified, return it. Otherwise, start it transcoding - if (file_exists($destVideoPath) && (@filemtime($destVideoPath) >= @filemtime($filePath))) { - $url = $settings['transcoderUrls']['video'] ?? $settings['transcoderUrls']['default']; - $url .= $subfolder; - $result = App::parseEnv($url) . $destVideoFile; + if (file_exists($destVideoPath) + && filesize($destVideoPath) > 0 + && (@filemtime($destVideoPath) >= @filemtime($filePath)) + ) { + $result = $this->getVersionedMediaUrl( + $outputInfo['url'], + $destVideoPath + ); // skip encoding } elseif (!$generate) { $result = ''; } else { // Kick off the transcoding + if ($synchronous) { + file_put_contents($lockFile, (string)getmypid()); + $execution = $this->executeShellCommandWithStatus($ffmpegCmd); + $output = $execution['output']; + @unlink($lockFile); + @unlink($progressFile); + + if ($execution['success'] && file_exists($destVideoPath) && filesize($destVideoPath) > 0) { + $result = $this->getVersionedMediaUrl( + $outputInfo['url'], + $destVideoPath + ); + } else { + @unlink($destVideoPath); + Craft::error("Video encoding failed: $output", __METHOD__); + } + + return $result; + } + $pid = $this->executeShellCommand($ffmpegCmd); Craft::info($ffmpegCmd . "\nffmpeg PID: " . $pid, __METHOD__); @@ -234,27 +265,26 @@ public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $g * @param bool $generate whether the thumbnail should be * generated if it doesn't exists * @param bool $asPath Whether we should return a path or not + * @param bool $synchronous Whether FFmpeg should finish before returning * * @return string|false|null URL or path of the video thumbnail * @throws InvalidConfigException */ - public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOptions, bool $generate = true, bool $asPath = false): string|false|null - { + public function getVideoThumbnailUrl( + Asset|string $filePath, + array $thumbnailOptions, + bool $generate = true, + bool $asPath = false, + bool $synchronous = false, + ): string|false|null { $result = null; $settings = Transcoder::$plugin->getSettings(); - $subfolder = ''; - - // sub folder check - if (($filePath instanceof Asset) && $settings['createSubfolders']) { - $subfolder = $filePath->folderPath; - } + $outputInfo = $this->getThumbnailOutputInfo($filePath); $filePath = $this->getAssetPath($filePath); if (!empty($filePath)) { - $destThumbnailPath = $settings['transcoderPaths']['thumbnail'] ?? $settings['transcoderPaths']['default']; - $destThumbnailPath .= $subfolder; - $destThumbnailPath = App::parseEnv($destThumbnailPath); + $destThumbnailPath = $outputInfo['directory']; $thumbnailOptions = $this->coalesceOptions('defaultThumbnailOptions', $thumbnailOptions); @@ -264,11 +294,19 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt . ' -vcodec mjpeg' . ' -vframes 1'; - // Adjust the scaling if desired - $ffmpegCmd = $this->addScalingFfmpegArgs( - $thumbnailOptions, - $ffmpegCmd - ); + if (!empty($thumbnailOptions['preventBlackBars']) + && !empty($thumbnailOptions['width']) + && !empty($thumbnailOptions['height']) + ) { + $ffmpegCmd .= ' -filter_complex ' . escapeshellarg($this->getPosterBlackBarFilter($thumbnailOptions)) + . ' -map ' . escapeshellarg('[poster]'); + } else { + // Adjust the scaling if desired + $ffmpegCmd = $this->addScalingFfmpegArgs( + $thumbnailOptions, + $ffmpegCmd + ); + } // Set the timecode to get the thumbnail from if desired if (!empty($thumbnailOptions['timeInSecs'])) { @@ -285,11 +323,18 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt } } - $destThumbnailFile = $this->getFilename($filePath, $thumbnailOptions); + $destThumbnailFile = $this->getFilename( + $filePath, + $thumbnailOptions, + $this->getThumbnailFilenameExcludeParams() + ); // Assemble the destination path and final ffmpeg command $destThumbnailPath .= $destThumbnailFile; - $ffmpegCmd .= ' -f image2 -y ' . escapeshellarg($destThumbnailPath) . ' >/dev/null 2>/dev/null &'; + $ffmpegCmd .= ' -f image2 -y ' . escapeshellarg($destThumbnailPath); + if (!$synchronous) { + $ffmpegCmd .= ' >/dev/null 2>/dev/null &'; + } // If the thumbnail file already exists, return it. Otherwise, generate it and return it if (!file_exists($destThumbnailPath)) { @@ -298,7 +343,22 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt $shellOutput = $this->executeShellCommand($ffmpegCmd); Craft::info($ffmpegCmd, __METHOD__); - // if ffmpeg fails which we can't check because the process is ran in the background + if ($synchronous && file_exists($destThumbnailPath) && filesize($destThumbnailPath) > 0) { + if ($asPath) { + return $destThumbnailPath; + } + + return $this->getVersionedMediaUrl( + $outputInfo['url'] . $destThumbnailFile, + $destThumbnailPath + ); + } + + if ($synchronous) { + Craft::error("Video poster generation failed: $shellOutput", __METHOD__); + } + + // if ffmpeg fails which we can't check because the process is ran in the background // don't return the future path of the image or else we can't check this in the front end } else { Craft::info('Thumbnail does not exist, but not asked to generate it: ' . $filePath, __METHOD__); @@ -311,15 +371,217 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt if ($asPath) { $result = $destThumbnailPath; } else { - $url = $settings['transcoderUrls']['thumbnail'] ?? $settings['transcoderUrls']['default']; - $url .= $subfolder; - $result = App::parseEnv($url) . $destThumbnailFile; + $result = $this->getVersionedMediaUrl( + $outputInfo['url'] . $destThumbnailFile, + $destThumbnailPath + ); } } return $result; } + /** + * Return a configured poster URL, or an empty string if it is unavailable. + */ + public function getVideoPosterUrl( + Asset|string $filePath, + string $formatHandle, + bool $generate = false, + bool $synchronous = false, + ): string { + $options = $this->getVideoPosterOptions($filePath, $formatHandle); + if ($options === null) { + return ''; + } + + $url = $this->getVideoThumbnailUrl($filePath, $options, $generate, false, $synchronous); + return is_string($url) ? $url : ''; + } + + /** + * Return configured poster URLs keyed by format handle. + */ + public function getVideoPosterUrls(Asset|string $filePath, bool $generate = false, bool $synchronous = false): array + { + $urls = []; + foreach (array_keys($this->getVideoPosterFormats()) as $formatHandle) { + $urls[$formatHandle] = $this->getVideoPosterUrl($filePath, $formatHandle, $generate, $synchronous); + } + + return $urls; + } + + /** + * Generate every configured poster inside the current process. + */ + public function generateVideoPosters(Asset|string $filePath): array + { + return $this->getVideoPosterUrls($filePath, true, true); + } + + /** + * Return whether poster upload queueing needs its own job. + * + * @internal Used by the Asset upload event handler. + */ + public function shouldQueueStandaloneVideoPostersOnUpload(): bool + { + $settings = Transcoder::$plugin->getSettings(); + + return $settings->enableVideoPosters + && $settings->queueVideoPostersOnAssetUpload + && !$settings->queueVideosOnAssetUpload; + } + + /** + * Queue invalidation and regeneration after an integration replaces a video asset. + * + * @return array{queued: bool, jobId: mixed} + */ + public function refreshVideoAsset(Asset $asset): array + { + $this->validateRefreshVideoAsset($asset); + + $jobId = Craft::$app->getQueue()->push(new RefreshVideoAsset([ + 'assetId' => (int)$asset->id, + ])); + if ($jobId === null) { + throw new RuntimeException("Unable to queue video refresh for asset #{$asset->id}."); + } + + Craft::info("Queued video refresh for asset #{$asset->id}; refresh job ID: $jobId", __METHOD__); + + return [ + 'queued' => true, + 'jobId' => $jobId, + ]; + } + + /** + * Perform queued cleanup and queue the existing video encoder. + * + * @internal Used by RefreshVideoAsset. + * @return array{removedFiles: int, queued: bool, jobId: mixed, active?: bool} + */ + public function performVideoAssetRefresh(Asset $asset): array + { + $this->validateRefreshVideoAsset($asset); + $removedFiles = 0; + $encodingActive = false; + + $locked = $this->runVideoAssetWork($asset, function() use ($asset, &$removedFiles, &$encodingActive): void { + $targets = $this->getVideoAssetRefreshTargets($asset); + if ($this->isVideoEncodingActive($targets['temporary'])) { + $encodingActive = true; + return; + } + + $removedFiles = $this->removeVideoAssetRefreshTargets($targets); + }); + + if (!$locked || $encodingActive) { + return [ + 'removedFiles' => 0, + 'queued' => false, + 'jobId' => null, + 'active' => true, + ]; + } + + $settings = Transcoder::$plugin->getSettings(); + $queue = Craft::$app->getQueue(); + $queueDelay = max(0, (int)App::parseEnv((string)$settings->videoQueueDelaySeconds)); + if ($queueDelay > 0) { + $queue = $queue->delay($queueDelay); + } + $jobId = $queue->push(new EncodeVideo([ + 'assetId' => (int)$asset->id, + 'videoOptions' => $settings->queuedVideoOptions, + ])); + if ($jobId === null) { + Craft::error( + "Video refresh handoff failed for asset #{$asset->id}; removed files: $removedFiles; follow-up encode job ID: none", + __METHOD__ + ); + throw new RuntimeException("Unable to queue replacement video encoding for asset #{$asset->id}."); + } + + Craft::info( + "Refreshed video asset #{$asset->id}; removed files: $removedFiles; follow-up encode job ID: $jobId", + __METHOD__ + ); + + return [ + 'removedFiles' => $removedFiles, + 'queued' => true, + 'jobId' => $jobId, + ]; + } + + /** + * Run asset-specific work under a non-blocking process lock. + * + * @internal Used by Transcoder queue jobs. + */ + public function runVideoAssetWork(Asset $asset, callable $callback): bool + { + return $this->runMediaAssetWork($asset, 'video', $callback); + } + + /** + * Run GIF asset work under a non-blocking process lock. + * + * @internal Used by Transcoder queue jobs. + */ + public function runGifAssetWork(Asset $asset, callable $callback): bool + { + return $this->runMediaAssetWork($asset, 'gif', $callback); + } + + /** + * Run audio asset work under a non-blocking process lock. + * + * @internal Used by Transcoder queue jobs. + */ + public function runAudioAssetWork(Asset $asset, callable $callback): bool + { + return $this->runMediaAssetWork($asset, 'audio', $callback); + } + + /** + * Run media asset work under a non-blocking process lock. + */ + protected function runMediaAssetWork(Asset $asset, string $mediaType, callable $callback): bool + { + if (!$asset->id) { + return false; + } + + $lockPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "transcoder-$mediaType-asset-" . (int)$asset->id . '.lock'; + $handle = @fopen($lockPath, 'c+'); + if ($handle === false) { + throw new RuntimeException("Unable to create the Transcoder $mediaType asset lock."); + } + + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return false; + } + + try { + ftruncate($handle, 0); + fwrite($handle, (string)getmypid()); + fflush($handle); + $callback(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + + return true; + } + /** * Returns a URL to the transcoded audio file or "" if it doesn't exist * (at which time it will create it). @@ -419,22 +681,25 @@ public function getAudioUrl(Asset|string $filePath, array $audioOptions): string if (!empty($audioOptions['synchronous'])) { $synchronous = $audioOptions['synchronous']; } - if (!$synchronous) { - $ffmpegCmd .= ' 1> ' . $progressFile . ' 2>&1 & echo $!'; - // Make sure there isn't a lockfile for this audio file already - $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destAudioFile . '.lock'; - $oldPid = @file_get_contents($lockFile); - if ($oldPid !== false) { - // See if the process is running, and empty result means the process is still running - // ref: https://stackoverflow.com/questions/3043978/how-to-check-if-a-process-id-pid-exists - exec("kill -0 $oldPid 2>&1", $ProcessState); - if (count($ProcessState) === 0) { + $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destAudioFile . '.lock'; + $oldPid = @file_get_contents($lockFile); + if ($oldPid !== false) { + // See if the process is running, and empty result means the process is still running + // ref: https://stackoverflow.com/questions/3043978/how-to-check-if-a-process-id-pid-exists + $oldPid = trim($oldPid); + if ($oldPid !== '' && ctype_digit($oldPid)) { + $processState = []; + exec('kill -0 ' . (int)$oldPid . ' 2>&1', $processState); + if ($processState === []) { return $result; } - // It's finished transcoding, so delete the lockfile and progress file - @unlink($lockFile); - @unlink($progressFile); } + // It's finished transcoding, so delete the lockfile and progress file + @unlink($lockFile); + @unlink($progressFile); + } + if (!$synchronous) { + $ffmpegCmd .= ' 1> ' . $progressFile . ' 2>&1 & echo $!'; } // If the audio file already exists and hasn't been modified, return it. Otherwise, start it transcoding @@ -444,17 +709,24 @@ public function getAudioUrl(Asset|string $filePath, array $audioOptions): string $result = App::parseEnv($url) . $destAudioFile; } else { // Kick off the transcoding - $pid = $this->executeShellCommand($ffmpegCmd); + $execution = $synchronous + ? $this->executeShellCommandWithStatus($ffmpegCmd) + : ['success' => true, 'output' => $this->executeShellCommand($ffmpegCmd)]; + $output = $execution['output']; if ($synchronous) { Craft::info($ffmpegCmd, __METHOD__); - $url = $settings['transcoderUrls']['audio'] ?? $settings['transcoderUrls']['default']; - $url .= $subfolder; - $result = App::parseEnv($url) . $destAudioFile; + if ($execution['success'] && file_exists($destAudioPath) && filesize($destAudioPath) > 0) { + $url = $settings['transcoderUrls']['audio'] ?? $settings['transcoderUrls']['default']; + $url .= $subfolder; + $result = App::parseEnv($url) . $destAudioFile; + } else { + Craft::error("Audio encoding failed: $output", __METHOD__); + } } else { - Craft::info($ffmpegCmd . "\nffmpeg PID: " . $pid, __METHOD__); + Craft::info($ffmpegCmd . "\nffmpeg PID: " . $output, __METHOD__); // Create a lockfile in tmp - file_put_contents($lockFile, $pid); + file_put_contents($lockFile, $output); } } } @@ -548,16 +820,7 @@ public function getFileInfo(Asset|string $filePath, bool $summary = false): ?arr */ public function getVideoFilename(Asset|string $filePath, array $videoOptions): string { - $settings = Transcoder::$plugin->getSettings(); - $videoOptions = $this->coalesceOptions('defaultVideoOptions', $videoOptions); - - // Get the video encoder presets to use - $videoEncoders = $settings['videoEncoders']; - $thisEncoder = $videoEncoders[$videoOptions['videoEncoder']]; - - $videoOptions['fileSuffix'] = $thisEncoder['fileSuffix']; - - return $this->getFilename($filePath, $videoOptions); + return $this->getVideoOutputInfo($filePath, $videoOptions)['filename'] ?? ''; } /** @@ -616,11 +879,20 @@ public function getGifFilename(Asset|string $filePath, array $gifOptions): strin */ public function handleGetAssetThumbPath(DefineAssetThumbUrlEvent $event): null|false|string { + $asset = $this->resolveControlPanelThumbnailAsset($event->asset); + if ($this->isTemporaryUploadAsset($asset)) { + Craft::info( + "Skipped Control Panel video thumbnail generation for temporary asset #{$asset->id}.", + __METHOD__ + ); + return null; + } + $options = [ 'width' => $event->width, 'height' => $event->height, ]; - return $this->getVideoThumbnailUrl($event->asset, $options); + return $this->getVideoThumbnailUrl($asset, $options); } // Protected Methods @@ -631,12 +903,13 @@ public function handleGetAssetThumbPath(DefineAssetThumbUrlEvent $event): null|f * * @param Asset|string $filePath path to the original video or an Asset * @param array $gifOptions of options for the GIF file + * @param bool $synchronous whether ffmpeg should finish before returning * * @return string|false|null URL or path of the GIF file * @throws InvalidConfigException */ - public function getGifUrl(Asset|string $filePath, array $gifOptions): string|false|null + public function getGifUrl(Asset|string $filePath, array $gifOptions, bool $synchronous = false): string|false|null { $result = ''; $settings = Transcoder::$plugin->getSettings(); @@ -687,37 +960,56 @@ public function getGifUrl(Asset|string $filePath, array $gifOptions): string|fal // Assemble the destination path and final ffmpeg command $destVideoPath .= $destVideoFile; - $ffmpegCmd .= ' ' - . ' -y ' . escapeshellarg($destVideoPath) - . ' 1> ' . $progressFile . ' 2>&1 & echo $!'; + $ffmpegCmd .= ' -y ' . escapeshellarg($destVideoPath); + if (!$synchronous) { + $ffmpegCmd .= ' 1> ' . $progressFile . ' 2>&1 & echo $!'; + } - // Make sure there isn't a lockfile for this video already + // Make sure there isn't a lockfile for this GIF already $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destVideoFile . '.lock'; $oldPid = @file_get_contents($lockFile); if ($oldPid !== false) { // See if the process is running, and empty result means the process is still running // ref: https://stackoverflow.com/questions/3043978/how-to-check-if-a-process-id-pid-exists - exec("kill -0 $oldPid 2>&1", $ProcessState); - if (count($ProcessState) === 0) { - return $result; + $oldPid = trim($oldPid); + if ($oldPid !== '' && ctype_digit($oldPid)) { + $processState = []; + exec('kill -0 ' . (int)$oldPid . ' 2>&1', $processState); + if ($processState === []) { + return $result; + } } // It's finished transcoding, so delete the lockfile and progress file @unlink($lockFile); @unlink($progressFile); } - // If the video file already exists and hasn't been modified, return it. Otherwise, start it transcoding + // If the GIF output already exists and hasn't been modified, return it. Otherwise, start transcoding. if (file_exists($destVideoPath) && (@filemtime($destVideoPath) >= @filemtime($filePath))) { $url = $settings['transcoderUrls']['gif'] ?? $settings['transcoderUrls']['default']; $url .= $subfolder; $result = App::parseEnv($url) . $destVideoFile; } else { // Kick off the transcoding - $pid = $this->executeShellCommand($ffmpegCmd); - Craft::info($ffmpegCmd . "\nffmpeg PID: " . $pid, __METHOD__); + $execution = $synchronous + ? $this->executeShellCommandWithStatus($ffmpegCmd) + : ['success' => true, 'output' => $this->executeShellCommand($ffmpegCmd)]; + $output = $execution['output']; + if ($synchronous) { + if ($execution['success'] && file_exists($destVideoPath) && filesize($destVideoPath) > 0) { + $url = $settings['transcoderUrls']['gif'] ?? $settings['transcoderUrls']['default']; + $url .= $subfolder; + return App::parseEnv($url) . $destVideoFile; + } + + Craft::error("GIF encoding failed: $output", __METHOD__); + return ''; + } + + Craft::info($ffmpegCmd . "\nffmpeg PID: " . $output, __METHOD__); // Create a lockfile in tmp - file_put_contents($lockFile, $pid); + file_put_contents($lockFile, $output); } } @@ -733,9 +1025,10 @@ public function getGifUrl(Asset|string $filePath, array $gifOptions): string|fal * @return string * @throws InvalidConfigException */ - protected function getFilename(Asset|string $filePath, array $options): string + protected function getFilename(Asset|string $filePath, array $options, ?array $excludeParams = null): string { $settings = Transcoder::$plugin->getSettings(); + $excludeParams ??= self::EXCLUDE_PARAMS; $filePath = $this->getAssetPath($filePath); $validator = new UrlValidator(); @@ -758,7 +1051,7 @@ protected function getFilename(Asset|string $filePath, array $options): string if (is_bool($value)) { $value = $value ? $key : 'no' . $key; } - if (!in_array($key, self::EXCLUDE_PARAMS, true)) { + if (!in_array($key, $excludeParams, true)) { $fileName .= '_' . $value . $suffix; } } @@ -772,6 +1065,554 @@ protected function getFilename(Asset|string $filePath, array $options): string return $fileName; } + /** + * Return filename options excluded by the selected video filename strategy. + */ + protected function getVideoFilenameExcludeParams(array $videoOptions): array + { + if (Transcoder::$plugin->getSettings()->videoFilenameStrategy === 'source') { + return array_values(array_unique(array_merge(self::EXCLUDE_PARAMS, array_keys($videoOptions)))); + } + + return self::EXCLUDE_PARAMS; + } + + /** + * Add a cache version from the bytes that are actually on disk. + */ + protected function getVersionedMediaUrl(string $url, string $path): string + { + clearstatcache(true, $path); + $modifiedAt = @filemtime($path); + if ($modifiedAt === false) { + return $url; + } + + return $url . (str_contains($url, '?') ? '&' : '?') . 'v=' . $modifiedAt; + } + + /** + * Validate the public replacement-refresh contract. + */ + protected function validateRefreshVideoAsset(Asset $asset): void + { + if (!$asset->id || AssetsHelper::getFileKindByExtension($asset->filename) !== Asset::KIND_VIDEO) { + throw new RuntimeException('Transcoder can only refresh a persisted video Asset.'); + } + } + + /** + * Resolve the configured output subfolder from an Asset or URL/path segment. + */ + protected function getSubfolderFromPath(Asset|string $filePath): string + { + $settings = Transcoder::$plugin->getSettings(); + if ($filePath instanceof Asset && $settings->createSubfolders) { + $folderPath = $this->getAssetFolderPath($filePath); + return $folderPath === '' ? '' : $folderPath . DIRECTORY_SEPARATOR; + } + + $segment = (int)App::parseEnv((string)$settings->subfolderUrlSegment); + if ($segment < 1 || !is_string($filePath)) { + return ''; + } + + $urlPath = parse_url($filePath, PHP_URL_PATH); + if (!is_string($urlPath)) { + return ''; + } + + $segments = array_values(array_filter( + explode('/', str_replace('\\', '/', $urlPath)), + static fn(string $value): bool => $value !== '' + )); + + if (!isset($segments[$segment - 1])) { + return ''; + } + + $subfolder = rawurldecode($segments[$segment - 1]); + if ($subfolder === '.' + || $subfolder === '..' + || str_contains($subfolder, "\0") + || str_contains($subfolder, '/') + || str_contains($subfolder, '\\') + ) { + Craft::warning('Ignored an unsafe generated media subfolder from a string input.', __METHOD__); + return ''; + } + + return $subfolder . DIRECTORY_SEPARATOR; + } + + /** + * Resolve an Asset folder from Craft's folder model, with hydrated Asset + * properties as fallbacks. + */ + protected function getAssetFolderPath(Asset $asset): string + { + $candidates = []; + try { + $candidates[] = (string)$asset->getFolder()->path; + } catch (InvalidConfigException) { + } + + $candidates[] = (string)($asset->folderPath ?? ''); + try { + $assetPath = str_replace('\\', '/', $asset->getPath()); + $candidates[] = dirname($assetPath); + } catch (InvalidConfigException) { + } + + foreach ($candidates as $candidate) { + $candidate = trim(str_replace('\\', '/', $candidate), '/'); + if ($candidate === '' || $candidate === '.') { + continue; + } + + $segments = explode('/', $candidate); + if (in_array('.', $segments, true) || in_array('..', $segments, true)) { + Craft::warning("Ignored an unsafe output folder for asset #{$asset->id}.", __METHOD__); + continue; + } + + return implode(DIRECTORY_SEPARATOR, $segments); + } + + return ''; + } + + /** + * Return normalized filesystem and public URL directories for thumbnails. + * + * @return array{subfolder: string, directory: string, url: string} + */ + protected function getThumbnailOutputInfo(Asset|string $filePath): array + { + $settings = Transcoder::$plugin->getSettings(); + $subfolder = $this->getSubfolderFromPath($filePath); + $directory = (string)App::parseEnv( + $settings->transcoderPaths['thumbnail'] ?? $settings->transcoderPaths['default'] + ); + $directory = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR; + $url = (string)App::parseEnv( + $settings->transcoderUrls['thumbnail'] ?? $settings->transcoderUrls['default'] + ); + $url = rtrim($url, '/') . '/'; + + if ($subfolder !== '') { + $directory .= trim($subfolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $url .= trim(str_replace('\\', '/', $subfolder), '/') . '/'; + } + + return [ + 'subfolder' => $subfolder, + 'directory' => $directory, + 'url' => $url, + ]; + } + + /** + * Reload an incomplete Control Panel event Asset after Craft has persisted it. + */ + protected function resolveControlPanelThumbnailAsset(Asset $asset): Asset + { + $folderPath = trim((string)($asset->folderPath ?? '')); + if ($asset->id && ($folderPath === '' || $this->isTemporaryUploadAsset($asset))) { + $persistedAsset = Asset::find()->id($asset->id)->one(); + if ($persistedAsset instanceof Asset) { + return $persistedAsset; + } + } + + return $asset; + } + + /** + * Return whether Craft has not moved an uploaded Asset into its final folder yet. + * + * @internal Used by Control Panel and queue jobs. + */ + public function isTemporaryUploadAsset(Asset $asset): bool + { + $references = [$this->getAssetFolderPath($asset)]; + try { + $references[] = $asset->getPath(); + } catch (InvalidConfigException) { + } + + foreach ($references as $reference) { + $reference = str_replace('\\', '/', trim((string)$reference)); + if (preg_match('~(?:^|/)user_\d+(?:/|$)~i', $reference) === 1 + || preg_match('~(?:^|/)(?:storage/)?runtime/assets/tempuploads(?:/|$)~i', $reference) === 1 + ) { + return true; + } + } + + return false; + } + + /** + * Resolve every output value shared by video encoding and refresh cleanup. + * + * @return array{ + * sourcePath: string, + * subfolder: string, + * directory: string, + * filename: string, + * path: string, + * url: string, + * lockFile: string, + * progressFile: string, + * videoOptions: array, + * encoder: array, + * watermarkPath: ?string + * }|null + */ + protected function getVideoOutputInfo(Asset|string $filePath, array $videoOptions): ?array + { + $settings = Transcoder::$plugin->getSettings(); + $subfolder = $this->getSubfolderFromPath($filePath); + $sourcePath = $this->getAssetPath($filePath); + if ($sourcePath === '') { + return null; + } + + $videoOptions = $this->coalesceOptions('defaultVideoOptions', $videoOptions); + $videoEncoders = $settings->videoEncoders; + $encoder = $videoEncoders[$videoOptions['videoEncoder']]; + $videoOptions['fileSuffix'] = $encoder['fileSuffix']; + $watermarkPath = $this->getVideoWatermarkPath(); + if ($watermarkPath !== null) { + $videoOptions['watermark'] = $this->getVideoWatermarkFingerprint($watermarkPath); + } + + $directory = (string)App::parseEnv( + $settings->transcoderPaths['video'] ?? $settings->transcoderPaths['default'] + ); + $directory = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $urlDirectory = (string)App::parseEnv( + $settings->transcoderUrls['video'] ?? $settings->transcoderUrls['default'] + ); + $urlDirectory = rtrim($urlDirectory, '/') . '/'; + if ($subfolder !== '') { + $directory .= trim($subfolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $urlDirectory .= trim(str_replace('\\', '/', $subfolder), '/') . '/'; + } + $filename = $this->getFilename( + $sourcePath, + $videoOptions, + $this->getVideoFilenameExcludeParams($videoOptions) + ); + + return [ + 'sourcePath' => $sourcePath, + 'subfolder' => $subfolder, + 'directory' => $directory, + 'filename' => $filename, + 'path' => $directory . $filename, + 'url' => $urlDirectory . $filename, + 'lockFile' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename . '.lock', + 'progressFile' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename . '.progress', + 'videoOptions' => $videoOptions, + 'encoder' => $encoder, + 'watermarkPath' => $watermarkPath, + ]; + } + + /** + * Return exact files managed by the configured automatic video workflow. + * + * @return array{output: string[], temporary: string[]} + */ + protected function getVideoAssetRefreshTargets(Asset $asset): array + { + $settings = Transcoder::$plugin->getSettings(); + $videoOutput = $this->getVideoOutputInfo($asset, $settings->queuedVideoOptions); + if ($videoOutput === null) { + throw new RuntimeException('Unable to resolve the source or output path for the video Asset.'); + } + + $outputs = [$videoOutput['path']]; + $temporary = [ + $videoOutput['lockFile'], + $videoOutput['progressFile'], + ]; + + if ($settings->enableVideoPosters) { + $posterDirectory = $this->getThumbnailOutputInfo($asset)['directory']; + foreach (array_keys($this->getVideoPosterFormats()) as $formatHandle) { + $options = $this->getVideoPosterOptions($asset, $formatHandle); + if ($options !== null) { + $options = $this->coalesceOptions('defaultThumbnailOptions', $options); + $outputs[] = $posterDirectory . $this->getFilename( + $asset, + $options, + $this->getThumbnailFilenameExcludeParams() + ); + } + } + } + + return [ + 'output' => array_values(array_unique($outputs)), + 'temporary' => array_values(array_unique($temporary)), + ]; + } + + /** + * Return whether the configured video encoder still owns its process lock. + * + * @param string[] $temporaryPaths + */ + protected function isVideoEncodingActive(array $temporaryPaths): bool + { + foreach ($temporaryPaths as $path) { + if (!str_ends_with($path, '.lock') || !is_file($path)) { + continue; + } + + $pid = trim((string)@file_get_contents($path)); + if ($pid === '' || !ctype_digit($pid)) { + continue; + } + + $processState = []; + exec('kill -0 ' . (int)$pid . ' 2>&1', $processState); + if ($processState === []) { + return true; + } + } + + return false; + } + + /** + * Delete exact refresh targets after validating their managed roots. + * + * @param array{output: string[], temporary: string[]} $targets + */ + protected function removeVideoAssetRefreshTargets(array $targets): int + { + $removed = 0; + foreach ($targets as $kind => $paths) { + foreach ($paths as $path) { + if (!is_file($path) && !is_link($path)) { + continue; + } + if (!$this->isSafeVideoAssetRefreshPath($path, $kind)) { + throw new RuntimeException('Refusing to remove a Transcoder file outside its managed roots.'); + } + if (!@unlink($path) && (is_file($path) || is_link($path))) { + throw new RuntimeException('Unable to remove a managed Transcoder file.'); + } + $removed++; + } + } + + return $removed; + } + + /** + * Verify a deletion target without following a file symlink outside its root. + */ + protected function isSafeVideoAssetRefreshPath(string $path, string $kind): bool + { + $settings = Transcoder::$plugin->getSettings(); + if ($kind === 'temporary') { + $roots = [sys_get_temp_dir()]; + } elseif ($kind === 'output') { + $roots = []; + foreach (['default', 'video', 'thumbnail'] as $key) { + if (!empty($settings->transcoderPaths[$key])) { + $roots[] = (string)App::parseEnv($settings->transcoderPaths[$key]); + } + } + } else { + return false; + } + + $parent = realpath(dirname(FileHelper::normalizePath($path))); + if ($parent === false) { + return false; + } + + foreach ($roots as $root) { + $root = realpath(rtrim(FileHelper::normalizePath($root), DIRECTORY_SEPARATOR)); + if ($root !== false && ($parent === $root || str_starts_with($parent, $root . DIRECTORY_SEPARATOR))) { + return true; + } + } + + return false; + } + + /** + * Resolve the configured watermark input. + */ + protected function getVideoWatermarkPath(): ?string + { + $settings = Transcoder::$plugin->getSettings(); + if (!$settings->enableVideoWatermark || $settings->videoWatermarkPath === '') { + return null; + } + + $path = (string)App::parseEnv($settings->videoWatermarkPath); + if (file_exists($path)) { + return $path; + } + + $validator = new UrlValidator(); + $error = ''; + if ($validator->validate($path, $error)) { + return $path; + } + + Craft::warning("Video watermark could not be found: $path", __METHOD__); + return null; + } + + /** + * Return a stable fingerprint for output-affecting watermark settings. + */ + protected function getVideoWatermarkFingerprint(string $path): string + { + $settings = Transcoder::$plugin->getSettings(); + + return substr(sha1(JsonHelper::encode([ + $path, + App::parseEnv((string)$settings->videoWatermarkWidth), + $settings->videoWatermarkPosition, + App::parseEnv((string)$settings->videoWatermarkPadding), + App::parseEnv((string)$settings->videoWatermarkOpacity), + ])), 0, 10); + } + + /** + * Build the ffmpeg graph that composes scaling and watermarking. + */ + protected function getVideoWatermarkFilter(array $videoOptions): string + { + $settings = Transcoder::$plugin->getSettings(); + $baseFilter = $this->getScalingFilter($videoOptions) ?? 'null'; + $watermarkFilters = ['format=rgba']; + + $width = max(0, (int)App::parseEnv((string)$settings->videoWatermarkWidth)); + if ($width > 0) { + $watermarkFilters[] = "scale=$width:-1"; + } + + $opacityPercentage = max(0, min(100, (int)App::parseEnv((string)$settings->videoWatermarkOpacity))); + if ($opacityPercentage < 100) { + $opacity = $opacityPercentage / 100; + $watermarkFilters[] = 'colorchannelmixer=aa=' . rtrim(rtrim(number_format($opacity, 2, '.', ''), '0'), '.'); + } + + [$x, $y] = $this->getVideoWatermarkPosition(); + + return '[0:v]' . $baseFilter . '[base];' + . '[1:v]' . implode(',', $watermarkFilters) . '[watermark];' + . "[base][watermark]overlay=$x:$y:shortest=1[transcoded]"; + } + + /** + * Return ffmpeg overlay coordinates for the configured watermark position. + */ + protected function getVideoWatermarkPosition(): array + { + $settings = Transcoder::$plugin->getSettings(); + $padding = max(0, (int)App::parseEnv((string)$settings->videoWatermarkPadding)); + + return match ($settings->videoWatermarkPosition) { + 'top-left' => [(string)$padding, (string)$padding], + 'top-right' => ["W-w-$padding", (string)$padding], + 'bottom-left' => [(string)$padding, "H-h-$padding"], + default => ["W-w-$padding", "H-h-$padding"], + }; + } + + /** + * Normalize configured poster formats by handle. + */ + protected function getVideoPosterFormats(): array + { + $formats = []; + foreach (Transcoder::$plugin->getSettings()->videoPosterFormats as $handle => $format) { + if (!is_array($format)) { + continue; + } + + $handle = is_string($handle) ? $handle : ($format['handle'] ?? ''); + $handle = trim((string)$handle); + if ($handle === '') { + continue; + } + + $options = []; + foreach (['width', 'height', 'timeInSecs'] as $key) { + if (isset($format[$key]) && $format[$key] !== '') { + $options[$key] = (int)$format[$key]; + } + } + $formats[$handle] = $options; + } + + return $formats; + } + + /** + * Return the exact thumbnail options used by a configured poster format. + */ + protected function getVideoPosterOptions(Asset|string $filePath, string $formatHandle): ?array + { + $formats = $this->getVideoPosterFormats(); + if (!isset($formats[$formatHandle])) { + return null; + } + + $options = $formats[$formatHandle]; + $options['posterFormat'] = $formatHandle; + if (!empty($options['timeInSecs'])) { + $fileInfo = $this->getFileInfo($filePath, true) ?? []; + $duration = (float)($fileInfo['duration'] ?? 0); + if ($duration > 0) { + $options['timeInSecs'] = min((float)$options['timeInSecs'], max(0, $duration - 0.1)); + } + } + if (Transcoder::$plugin->getSettings()->preventVideoPosterBlackBars) { + $options['preventBlackBars'] = true; + } + + return $options; + } + + /** + * Return option keys that affect poster generation but not its canonical filename. + */ + protected function getThumbnailFilenameExcludeParams(): array + { + return array_values(array_unique(array_merge(self::EXCLUDE_PARAMS, [ + 'posterFormat', + 'preventBlackBars', + ]))); + } + + /** + * Build a poster filter that fills unused space with a blurred cover frame. + */ + protected function getPosterBlackBarFilter(array $options): string + { + $width = (int)$options['width']; + $height = (int)$options['height']; + + return '[0:v]split=2[background][foreground];' + . "[background]scale=$width:$height:force_original_aspect_ratio=increase," + . "crop=$width:$height,boxblur=20:1[background];" + . "[foreground]scale=$width:$height:force_original_aspect_ratio=decrease[foreground];" + . '[background][foreground]overlay=(W-w)/2:(H-h)/2[poster]'; + } + /** * Extract a file system path if $filePath is an Asset object * @@ -838,6 +1679,19 @@ protected function getAssetPath(Asset|string $filePath): string * @return string */ protected function addScalingFfmpegArgs(array $options, string $ffmpegCmd): string + { + $filter = $this->getScalingFilter($options); + if ($filter !== null) { + $ffmpegCmd .= ' -vf ' . escapeshellarg($filter); + } + + return $ffmpegCmd; + } + + /** + * Return the original scaling filter without command-line arguments. + */ + protected function getScalingFilter(array $options): ?string { if (!empty($options['width']) && !empty($options['height'])) { // Handle "none", "crop", and "letterbox" aspectRatios @@ -870,14 +1724,12 @@ protected function addScalingFfmpegArgs(array $options, string $ffmpegCmd): stri if (!empty($options['sharpen']) && ($options['sharpen'] !== false)) { $sharpen = ',unsharp=5:5:1.0:5:5:0.0'; } - $ffmpegCmd .= ' -vf "scale=' - . $options['width'] . ':' . $options['height'] + return 'scale=' . $options['width'] . ':' . $options['height'] . $aspectRatio - . $sharpen - . '"'; + . $sharpen; } - return $ffmpegCmd; + return null; } // Protected Methods @@ -909,6 +1761,16 @@ protected function coalesceOptions(string $defaultName, array $options): array * @return string */ protected function executeShellCommand(string $command): string + { + return $this->executeShellCommandWithStatus($command)['output']; + } + + /** + * Execute a shell command and retain whether it exited successfully. + * + * @return array{success: bool, output: string} + */ + protected function executeShellCommandWithStatus(string $command): array { // Create the shell command $shellCommand = new ShellCommand(); @@ -920,12 +1782,16 @@ protected function executeShellCommand(string $command): string } // Return the result of the command's output or error - if ($shellCommand->execute()) { + $success = $shellCommand->execute(); + if ($success) { $result = $shellCommand->getOutput(); } else { $result = $shellCommand->getError(); } - return $result; + return [ + 'success' => $success, + 'output' => $result, + ]; } } diff --git a/src/templates/settings.twig b/src/templates/settings.twig new file mode 100644 index 0000000..66b2086 --- /dev/null +++ b/src/templates/settings.twig @@ -0,0 +1,228 @@ +{% import '_includes/forms' as forms %} + +