diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c4963..d9739f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Transcoder Changelog +## 5.1.0 - UNRELEASED + +### Changed +* Organize media workflow settings into video queue, poster, and watermark tabs in the Craft control panel. +* Use Craft 5 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. +* Preserve Craft 5 volume subpaths while resolving local source assets. +* Keep automatic Control Panel video thumbnails out of temporary upload storage and in the asset's final subfolder. +* Defer upload workflow queueing until Craft has moved field uploads from temporary storage into their final folder. +* Forward the optional `generate` argument from Twig's `getVideoThumbnailUrl()` helper. +* Keep poster format handles, duration-clamped seek times, and black-bar generation flags out of thumbnail filenames so refresh cleanup, queued posters, and equivalent Twig requests reuse the same canonical file. +* Reject and remove zero-byte video, GIF, audio, and poster derivatives instead of accepting them as completed jobs. +* Scope internal encoder lock files to the full output path so identical filenames in different Asset subfolders do not collide. +* Correct legacy `transcoderUrl` migration and remove validation for the obsolete singular `transcoderPath` property. + ## 5.0.1 - 2026.09.01 ### Changed * Add in the volume's subpath, if any, to the path to the asset ([#78](https://github.com/nystudio107/craft-transcoder/issues/78)) diff --git a/README.md b/README.md index 78f87d3..2a4454c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,28 @@ [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/nystudio107/craft-transcoder/badges/quality-score.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-transcoder/?branch=v5) [![Code Coverage](https://scrutinizer-ci.com/g/nystudio107/craft-transcoder/badges/coverage.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-transcoder/?branch=v5) [![Build Status](https://scrutinizer-ci.com/g/nystudio107/craft-transcoder/badges/build.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-transcoder/build-status/v5) [![Code Intelligence Status](https://scrutinizer-ci.com/g/nystudio107/craft-transcoder/badges/code-intelligence.svg?b=v5)](https://scrutinizer-ci.com/code-intelligence) -# Transcoder plugin for Craft CMS 4.x +# Transcoder plugin for Craft CMS 5.x 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 video queue, poster, and watermark tabs, or `config/transcoder.php`. The Video queue tab includes the video, GIF, and audio upload workflows. Queue timing, URL subfolder, and watermark numeric fields support Craft environment-variable autosuggestions; the watermark path also supports environment values and 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. + ![Screenshot](./docs/docs/resources/img/plugin-banner.jpg) **Note**: _The license fee for this plugin is $59.00 via the Craft Plugin Store._ @@ -28,7 +47,7 @@ To install Transcoder, follow these steps: You can also install Transcoder via the **Plugin Store** in the Craft Control Panel. -Transcoder works on Craft 4.x. +Transcoder works on Craft 5.x. You will also need [ffmpeg](https://ffmpeg.org/) installed for Transcoder to work. On Ubuntu 16.04, you can do just: diff --git a/composer.json b/composer.json index 1d6909e..c6f789f 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,11 @@ "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", + "@php tests/upload-queue-harness.php" + ] }, "config": { "allow-plugins": { diff --git a/docs/docs/configuring.md b/docs/docs/configuring.md index 6557ad2..9aea4b2 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 Video queue, Video posters, and Video watermark tabs expose the automated media workflows. Queue timing, URL subfolder, and watermark numeric settings use Craft autosuggest fields for values that can come from environment variables. The watermark path supports environment values and 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. For field uploads that Craft first saves in temporary storage, Transcoder waits for the final move before pushing the media job. 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/index.md b/docs/docs/index.md index 3885a57..21b7877 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -32,7 +32,7 @@ To install Transcoder, follow these steps: You can also install Transcoder via the **Plugin Store** in the Craft Control Panel. -Transcoder works on Craft 3.x. +Transcoder works on Craft 5.x. You will also need [ffmpeg](https://ffmpeg.org/) installed for Transcoder to work. On Ubuntu 16.04, you can do just: 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 ![Screenshot](./resources/screenshots/admin-cp-video-thumbnails.png) -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..bde323e 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; @@ -48,6 +56,41 @@ class Transcoder extends Plugin use ServicesTrait; + // Constants + // ========================================================================= + + private const SETTINGS_TAB_FIELDS = [ + 'settings-tab-video-queue' => [ + 'queueVideosOnAssetUpload', + 'videoQueueDelaySeconds', + 'videoEncodeMaxRetries', + 'videoEncodeRetryDelaySeconds', + 'queuedVideoOptions', + 'queueGifsOnAssetUpload', + 'gifQueueDelaySeconds', + 'queuedGifOptions', + 'queueAudioOnAssetUpload', + 'audioQueueDelaySeconds', + 'queuedAudioOptions', + 'videoFilenameStrategy', + 'subfolderUrlSegment', + ], + 'settings-tab-video-posters' => [ + 'enableVideoPosters', + 'queueVideoPostersOnAssetUpload', + 'preventVideoPosterBlackBars', + 'videoPosterFormats', + ], + 'settings-tab-video-watermark' => [ + 'enableVideoWatermark', + 'videoWatermarkPath', + 'videoWatermarkWidth', + 'videoWatermarkPosition', + 'videoWatermarkPadding', + 'videoWatermarkOpacity', + ], + ]; + // Static Properties // ========================================================================= @@ -61,6 +104,9 @@ class Transcoder extends Plugin */ public static ?Settings $settings; + /** @var array Assets waiting for Craft to move them out of temporary upload storage. */ + private array $pendingTemporaryUploads = []; + // Public Properties // ========================================================================= @@ -72,7 +118,7 @@ class Transcoder extends Plugin /** * @var bool */ - public bool $hasCpSettings = false; + public bool $hasCpSettings = true; /** * @var string @@ -99,6 +145,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 +195,94 @@ protected function createSettingsModel(): ?Model return new Settings(); } + /** + * @inheritdoc + */ + protected function settingsHtml(): ?string + { + $settings = $this->getSettings(); + + return Craft::$app->getView()->renderTemplate('transcoder/settings', [ + 'settings' => $settings, + 'selectedTab' => $this->firstSettingsErrorTab($settings) ?? array_key_first(self::SETTINGS_TAB_FIELDS), + ]); + } + + /** + * 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 + ) { + $settings = $this->getSettings(); + $tabs = [ + 'settings-tab-video-queue' => [ + 'label' => Craft::t('transcoder', 'Video queue'), + 'url' => '#settings-tab-video-queue', + ], + 'settings-tab-video-posters' => [ + 'label' => Craft::t('transcoder', 'Video posters'), + 'url' => '#settings-tab-video-posters', + ], + 'settings-tab-video-watermark' => [ + 'label' => Craft::t('transcoder', 'Video watermark'), + 'url' => '#settings-tab-video-watermark', + ], + ]; + + foreach (self::SETTINGS_TAB_FIELDS as $tabId => $fields) { + if ($this->settingsFieldsHaveErrors($settings, $fields)) { + $tabs[$tabId]['class'] = ['error']; + } + } + + $event->variables['tabs'] = $tabs; + $selectedTab = $this->firstSettingsErrorTab($settings); + if ($selectedTab !== null) { + $event->variables['selectedTab'] = $selectedTab; + } + } + } + ); + } + + /** + * Return the first settings tab containing validation errors. + */ + private function firstSettingsErrorTab(Settings $settings): ?string + { + foreach (self::SETTINGS_TAB_FIELDS as $tabId => $fields) { + if ($this->settingsFieldsHaveErrors($settings, $fields)) { + return $tabId; + } + } + + return null; + } + + /** + * Return whether any of the given settings fields contain validation errors. + * + * @param string[] $fields + */ + private function settingsFieldsHaveErrors(Settings $settings, array $fields): bool + { + foreach ($fields as $field) { + if ($settings->hasErrors($field)) { + return true; + } + } + + return false; + } + /** * Add in our Craft components */ @@ -205,6 +341,92 @@ 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 (!$asset instanceof Asset || !$asset->id) { + return; + } + + $assetId = (int)$asset->id; + if ($event->isNew && $this->transcode->isTemporaryUploadAsset($asset)) { + $this->pendingTemporaryUploads[$assetId] = true; + return; + } + + if (!$event->isNew) { + if (!isset($this->pendingTemporaryUploads[$assetId])) { + return; + } + if ($this->transcode->isTemporaryUploadAsset($asset)) { + return; + } + unset($this->pendingTemporaryUploads[$assetId]); + } + + $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', + $assetId + ); + return; + } + + if ($kind === Asset::KIND_AUDIO && $settings->queueAudioOnAssetUpload) { + $this->queueUploadedMedia( + new EncodeAudio([ + 'assetId' => $asset->id, + 'audioOptions' => $settings->queuedAudioOptions, + ]), + $settings->audioQueueDelaySeconds, + 'audio', + $assetId + ); + return; + } + + if ($kind === Asset::KIND_VIDEO) { + if ($settings->queueVideosOnAssetUpload) { + $this->queueUploadedMedia( + new EncodeVideo([ + 'assetId' => $asset->id, + 'videoOptions' => $settings->queuedVideoOptions, + ]), + $settings->videoQueueDelaySeconds, + 'video', + $assetId + ); + return; + } + + if ($this->transcode->shouldQueueStandaloneVideoPostersOnUpload()) { + $this->queueUploadedMedia( + new GenerateVideoPosters([ + 'assetId' => $asset->id, + ]), + $settings->videoQueueDelaySeconds, + 'video poster', + $assetId + ); + } + } + } + ); + } // Handler: Plugins::EVENT_AFTER_INSTALL_PLUGIN Event::on( Plugins::class, @@ -248,6 +470,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..0227eaf --- /dev/null +++ b/src/jobs/EncodeVideo.php @@ -0,0 +1,152 @@ +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}."); + } + + 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 668e7df..7aecb45 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -16,14 +16,17 @@ 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; -use function count; use function function_exists; use function in_array; use function is_bool; @@ -98,36 +101,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; - } + $outputInfo = $this->getVideoOutputInfo($filePath, $videoOptions); - // 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']]; - - $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 +140,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,27 +183,32 @@ 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 // 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); @@ -206,15 +216,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 +268,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,15 +297,24 @@ 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'])) { - $timeCode = gmdate('H:i:s', $thumbnailOptions['timeInSecs']); + $thumbnailTime = $thumbnailOptions['posterSeekTimeInSecs'] ?? $thumbnailOptions['timeInSecs'] ?? null; + if (!empty($thumbnailTime)) { + $timeCode = gmdate('H:i:s', (int)$thumbnailTime); $ffmpegCmd .= ' -ss ' . $timeCode . '.00'; } @@ -285,20 +327,44 @@ 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)) { + // If a non-empty thumbnail already exists, return it. Otherwise, generate it and return it. + if (!file_exists($destThumbnailPath) || (int)@filesize($destThumbnailPath) <= 0) { + @unlink($destThumbnailPath); if ($generate) { /** @noinspection PhpUnusedLocalVariableInspection */ $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) { + @unlink($destThumbnailPath); + 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 +377,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,42 +687,56 @@ 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 = $this->getMediaLockFile('audio', $destAudioPath); + $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 - if (file_exists($destAudioPath) && (@filemtime($destAudioPath) >= @filemtime($filePath))) { + if (file_exists($destAudioPath) + && filesize($destAudioPath) > 0 + && (@filemtime($destAudioPath) >= @filemtime($filePath)) + ) { $url = $settings['transcoderUrls']['audio'] ?? $settings['transcoderUrls']['default']; $url .= $subfolder; $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 { + @unlink($destAudioPath); + 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 +830,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 +889,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 +913,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 +970,60 @@ 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 - $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destVideoFile . '.lock'; + // Make sure there isn't a lockfile for this GIF already + $lockFile = $this->getMediaLockFile('gif', $destVideoPath); $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 (file_exists($destVideoPath) && (@filemtime($destVideoPath) >= @filemtime($filePath))) { + // If the GIF output already exists and hasn't been modified, return it. Otherwise, start transcoding. + if (file_exists($destVideoPath) + && filesize($destVideoPath) > 0 + && (@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; + } + + @unlink($destVideoPath); + 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 +1039,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 +1065,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 +1079,574 @@ 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 + { + if ($asset->getVolumeId() === null) { + return true; + } + + $references = [$this->getAssetFolderPath($asset)]; + try { + $references[] = $asset->getPath(); + } catch (InvalidConfigException) { + } + + foreach ($references as $reference) { + $reference = str_replace('\\', '/', trim((string)$reference)); + if (preg_match('~(?:^|/)user_[^/]+(?:/|$)~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; + $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' => $this->getMediaLockFile('video', $directory . $filename), + '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; + } + + /** + * Return a subfolder-aware internal lock path without changing public progress URLs. + */ + protected function getMediaLockFile(string $mediaType, string $outputPath): string + { + $normalizedPath = FileHelper::normalizePath($outputPath); + + return sys_get_temp_dir() . DIRECTORY_SEPARATOR + . 'transcoder-' . $mediaType . '-' . sha1($normalizedPath) . '.lock'; + } + + /** + * 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['posterSeekTimeInSecs'] = 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', + 'posterSeekTimeInSecs', + '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']; + $blurRadius = max(1, min(20, intdiv(max(8, min($width, $height)), 4) - 1)); + + return '[0:v]split=2[background][foreground];' + . "[background]scale=$width:$height:force_original_aspect_ratio=increase," + . "crop=$width:$height,boxblur=$blurRadius: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 * @@ -843,6 +1718,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 @@ -875,14 +1763,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 @@ -914,6 +1800,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(); @@ -925,12 +1821,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..1f73f85 --- /dev/null +++ b/src/templates/settings.twig @@ -0,0 +1,249 @@ +{% import '_includes/forms' as forms %} +{% set selectedTab = selectedTab ?? 'settings-tab-video-queue' %} + + + + + + diff --git a/src/translations/en/transcoder.php b/src/translations/en/transcoder.php index 381ccfc..3dc968d 100644 --- a/src/translations/en/transcoder.php +++ b/src/translations/en/transcoder.php @@ -15,6 +15,63 @@ */ return [ 'Transcoder caches' => 'Transcoder caches', + 'Video queue' => 'Video queue', + 'Queue videos on asset upload' => 'Queue videos on asset upload', + 'Add newly uploaded video assets to Craft’s queue for background encoding.' => 'Add newly uploaded video assets to Craft’s queue for background encoding.', + 'Video queue delay' => 'Video queue delay', + 'Seconds to wait before an uploaded video starts encoding.' => 'Seconds to wait before an uploaded video starts encoding.', + 'Video encode retries' => 'Video encode retries', + 'Number of retries after a queued video encode fails. Set to 0 to disable automatic retries.' => 'Number of retries after a queued video encode fails. Set to 0 to disable automatic retries.', + 'Video encode retry delay' => 'Video encode retry delay', + 'Seconds to wait before retrying a failed queued video encode.' => 'Seconds to wait before retrying a failed queued video encode.', + 'Retrying video encode attempt {attempt} of {total} in {seconds}s' => 'Retrying video encode attempt {attempt} of {total} in {seconds}s', + 'GIF queue' => 'GIF queue', + 'Queue GIFs on asset upload' => 'Queue GIFs on asset upload', + 'Add newly uploaded GIF assets to Craft’s queue for background encoding.' => 'Add newly uploaded GIF assets to Craft’s queue for background encoding.', + 'GIF queue delay' => 'GIF queue delay', + 'Seconds to wait before an uploaded GIF starts encoding.' => 'Seconds to wait before an uploaded GIF starts encoding.', + 'Audio queue' => 'Audio queue', + 'Queue audio on asset upload' => 'Queue audio on asset upload', + 'Add newly uploaded audio Assets to Craft’s queue for background encoding.' => 'Add newly uploaded audio Assets to Craft’s queue for background encoding.', + 'Audio queue delay' => 'Audio queue delay', + 'Seconds to wait before uploaded audio starts encoding.' => 'Seconds to wait before uploaded audio starts encoding.', + 'Video filename strategy' => 'Video filename strategy', + 'Use the original option-based filename, or keep one stable filename per source asset.' => 'Use the original option-based filename, or keep one stable filename per source asset.', + 'Encoding options' => 'Encoding options', + 'Source asset' => 'Source asset', + 'URL subfolder segment' => 'URL subfolder segment', + 'For string URL/path inputs, use this one-based path segment as the output subfolder. Leave empty to disable it.' => 'For string URL/path inputs, use this one-based path segment as the output subfolder. Leave empty to disable it.', + 'Video watermark' => 'Video watermark', + 'Enable video watermark' => 'Enable video watermark', + 'Overlay the configured image on newly encoded videos.' => 'Overlay the configured image on newly encoded videos.', + 'Watermark path or URL' => 'Watermark path or URL', + 'Use a local path, Yii alias, environment value, or public image URL.' => 'Use a local path, Yii alias, environment value, or public image URL.', + 'Watermark width' => 'Watermark width', + 'Optional width in pixels. Leave empty to keep the original size.' => 'Optional width in pixels. Leave empty to keep the original size.', + 'Watermark position' => 'Watermark position', + 'Top left' => 'Top left', + 'Top right' => 'Top right', + 'Bottom left' => 'Bottom left', + 'Bottom right' => 'Bottom right', + 'Watermark padding' => 'Watermark padding', + 'Watermark opacity' => 'Watermark opacity', + 'Percentage from 0 to 100.' => 'Percentage from 0 to 100.', + 'Video posters' => 'Video posters', + 'Generate video posters' => 'Generate video posters', + 'Enable configured poster formats for automated video workflows.' => 'Enable configured poster formats for automated video workflows.', + 'Queue video posters on asset upload' => 'Queue video posters on asset upload', + 'Generate configured posters through Craft’s queue when full video encoding is not queued.' => 'Generate configured posters through Craft’s queue when full video encoding is not queued.', + 'Generate the configured poster images after a queued video is encoded.' => 'Generate the configured poster images after a queued video is encoded.', + 'Prevent poster black bars' => 'Prevent poster black bars', + 'Fill unused poster space with a blurred cover image behind the fitted video frame.' => 'Fill unused poster space with a blurred cover image behind the fitted video frame.', + 'Video poster formats' => 'Video poster formats', + 'Handle' => 'Handle', + 'Width' => 'Width', + 'Height' => 'Height', + 'Time in seconds' => 'Time in seconds', + 'Refreshing video asset #{id}' => 'Refreshing video asset #{id}', + 'Waiting for current video encoding to finish' => 'Waiting for current video encoding to finish', + 'Video refresh complete' => 'Video refresh complete', '{name} plugin loaded' => '{name} plugin loaded', '{name} cache directory cleared' => '{name} cache directory cleared', 'Manifest file not found at: {manifestPath}' => 'Manifest file not found at: {manifestPath}', diff --git a/src/variables/TranscoderVariable.php b/src/variables/TranscoderVariable.php index c67b996..3a9f0cc 100644 --- a/src/variables/TranscoderVariable.php +++ b/src/variables/TranscoderVariable.php @@ -50,13 +50,30 @@ public function getVideoUrl($filePath, $videoOptions, bool $generate = true): st * * @param $filePath * @param $thumbnailOptions + * @param bool $generate * * @return string|false|null URL or path of the video thumbnail * @throws InvalidConfigException */ - public function getVideoThumbnailUrl($filePath, $thumbnailOptions): string|false|null + public function getVideoThumbnailUrl($filePath, $thumbnailOptions, bool $generate = true): string|false|null + { + return Transcoder::$plugin->transcode->getVideoThumbnailUrl($filePath, $thumbnailOptions, $generate); + } + + /** + * Return a configured video poster URL without starting generation by default. + */ + public function getVideoPosterUrl($filePath, string $formatHandle, bool $generate = false): string + { + return Transcoder::$plugin->transcode->getVideoPosterUrl($filePath, $formatHandle, $generate); + } + + /** + * Return configured video poster URLs keyed by format handle. + */ + public function getVideoPosterUrls($filePath, bool $generate = false): array { - return Transcoder::$plugin->transcode->getVideoThumbnailUrl($filePath, $thumbnailOptions); + return Transcoder::$plugin->transcode->getVideoPosterUrls($filePath, $generate); } /** diff --git a/tests/refresh-video-asset-harness.php b/tests/refresh-video-asset-harness.php new file mode 100644 index 0000000..1e58e46 --- /dev/null +++ b/tests/refresh-video-asset-harness.php @@ -0,0 +1,1256 @@ + */ + public static array $aliases = []; + + public static function getAlias(string $value, bool $throwException = true): string|false + { + foreach (self::$aliases as $alias => $path) { + if ($value === $alias || str_starts_with($value, $alias . '/')) { + return $path . substr($value, strlen($alias)); + } + } + + return $throwException ? $value : false; + } + + public static function info(string $message, string $category = ''): void + { + self::$logs[] = $message; + } + + public static function warning(string $message, string $category = ''): void + { + self::$logs[] = $message; + } + + public static function error(string $message, string $category = ''): void + { + self::$logs[] = $message; + } + + public static function t(string $category, string $message, array $params = []): string + { + return strtr($message, array_combine( + array_map(static fn(string $key): string => "{{$key}}", array_keys($params)), + array_map('strval', $params) + ) ?: []); + } + } +} + +namespace craft\base { + class Component + { + } +} + +namespace craft\elements { + class Asset + { + public const KIND_AUDIO = 'audio'; + public const KIND_VIDEO = 'video'; + + /** @var array */ + public static array $assets = []; + + public ?int $id = null; + public string $filename = ''; + public ?string $folderPath = ''; + public string $path = ''; + public string $sourcePath = ''; + public ?object $folder = null; + public ?object $volume = null; + public ?int $volumeId = 1; + + public static function find(): AssetQuery + { + return new AssetQuery(); + } + + public function getFolder(): object + { + return $this->folder ?? (object)['path' => $this->folderPath ?? '']; + } + + public function getPath(): string + { + if ($this->path !== '') { + return $this->path; + } + + return ($this->folderPath ?? '') . $this->filename; + } + + public function getVolume(): ?object + { + return $this->volume; + } + + public function getVolumeId(): ?int + { + return $this->volumeId; + } + + public function getUrl(): ?string + { + return null; + } + } + + class AssetQuery + { + private ?int $assetId = null; + + public function id(?int $assetId): self + { + $this->assetId = $assetId; + return $this; + } + + public function one(): ?Asset + { + return $this->assetId === null ? null : (Asset::$assets[$this->assetId] ?? null); + } + } +} + +namespace craft\events { + use craft\elements\Asset; + + class DefineAssetThumbUrlEvent + { + public function __construct( + public Asset $asset, + public int $width, + public int $height, + ) { + } + } +} + +namespace craft\fs { + class Local + { + public function __construct(public string $path = '') + { + } + } +} + +namespace craft\helpers { + final class App + { + public static function parseEnv(?string $value): bool|string|null + { + if ($value !== null && str_starts_with($value, '$')) { + $environmentValue = getenv(substr($value, 1)); + return $environmentValue === false ? $value : $environmentValue; + } + + if ($value !== null && str_starts_with($value, '@')) { + return \Craft::getAlias($value, false) ?: $value; + } + + return $value; + } + } + + final class Assets + { + public static function getFileKindByExtension(string $filename): string + { + $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + if (in_array($extension, ['mp4', 'mov', 'webm'], true)) { + return \craft\elements\Asset::KIND_VIDEO; + } + if (in_array($extension, ['aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav'], true)) { + return \craft\elements\Asset::KIND_AUDIO; + } + + return 'unknown'; + } + } + + final class FileHelper + { + public static function normalizePath(string $path): string + { + return str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + } + + public static function createDirectory(string $path): bool + { + return is_dir($path) || mkdir($path, 0777, true); + } + } + + final class Json + { + public static function encode(mixed $value): string + { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + public static function decodeIfJson(string $value, bool $asArray = false): mixed + { + return json_decode($value, $asArray); + } + } + + final class UrlHelper + { + } +} + +namespace craft\queue { + class BaseJob + { + public function __construct(array $config = []) + { + foreach ($config as $name => $value) { + $this->{$name} = $value; + } + } + + protected function setProgress(mixed $queue, float $progress, string $label = ''): void + { + } + } +} + +namespace yii\base { + class Exception extends \Exception + { + } + + class InvalidConfigException extends Exception + { + } +} + +namespace yii\validators { + class UrlValidator + { + public function validate(mixed $value, mixed &$error = null): bool + { + return is_string($value) && filter_var($value, FILTER_VALIDATE_URL) !== false; + } + } +} + +namespace mikehaertl\shellcommand { + class Command + { + public bool $useExec = false; + private string $command = ''; + private string $output = ''; + private string $error = ''; + + public function setCommand(string $command): void + { + $this->command = $command; + } + + public function execute(): bool + { + $output = []; + $exitCode = 0; + exec($this->command . ' 2>&1', $output, $exitCode); + $this->output = implode("\n", $output); + $this->error = $exitCode === 0 ? '' : $this->output; + return $exitCode === 0; + } + + public function getOutput(): string + { + return $this->output; + } + + public function getError(): string + { + return $this->error; + } + } +} + +namespace nystudio107\transcoder { + final class Transcoder + { + public static object $plugin; + } +} + +namespace nystudio107\pluginvite\variables { + interface ViteVariableInterface + { + } + + trait ViteVariableTrait + { + } +} + +namespace { + use craft\elements\Asset; + use craft\events\DefineAssetThumbUrlEvent; + use craft\fs\Local; + use nystudio107\transcoder\Transcoder; + use nystudio107\transcoder\jobs\EncodeAudio; + use nystudio107\transcoder\jobs\EncodeGif; + use nystudio107\transcoder\jobs\EncodeVideo; + use nystudio107\transcoder\jobs\GenerateVideoPosters; + use nystudio107\transcoder\jobs\RefreshVideoAsset; + use nystudio107\transcoder\services\Transcode as BaseTranscode; + use nystudio107\transcoder\variables\TranscoderVariable; + + require dirname(__DIR__) . '/src/jobs/EncodeAudio.php'; + require dirname(__DIR__) . '/src/jobs/EncodeGif.php'; + require dirname(__DIR__) . '/src/jobs/EncodeVideo.php'; + require dirname(__DIR__) . '/src/jobs/GenerateVideoPosters.php'; + require dirname(__DIR__) . '/src/jobs/RefreshVideoAsset.php'; + require dirname(__DIR__) . '/src/services/Transcode.php'; + require dirname(__DIR__) . '/src/variables/TranscoderVariable.php'; + + final class HarnessSettings extends ArrayObject + { + public function __get(string $name): mixed + { + return $this[$name] ?? null; + } + } + + final class HarnessQueue + { + /** @var object[] */ + public array $jobs = []; + public int $delaySeconds = 0; + public bool $failPush = false; + + public function delay(int $seconds): self + { + $this->delaySeconds = $seconds; + return $this; + } + + public function push(object $job): ?string + { + if ($this->failPush) { + return null; + } + + $this->jobs[] = $job; + return 'job-' . count($this->jobs); + } + } + + final class HarnessApp + { + public function __construct(private readonly HarnessQueue $queue) + { + } + + public function getQueue(): HarnessQueue + { + return $this->queue; + } + } + + final class HarnessPlugin + { + public BaseTranscode $transcode; + + public function __construct(private readonly HarnessSettings $settings) + { + } + + public function getSettings(): HarnessSettings + { + return $this->settings; + } + } + + final class HarnessTranscode extends BaseTranscode + { + protected function getAssetPath(Asset|string $filePath): string + { + return $filePath instanceof Asset ? $filePath->sourcePath : $filePath; + } + + public function outputInfo(Asset|string $asset, array $options): array + { + return $this->getVideoOutputInfo($asset, $options) ?? []; + } + + public function refreshTargets(Asset $asset): array + { + return $this->getVideoAssetRefreshTargets($asset); + } + + public function versionedUrl(string $url, string $path): string + { + return $this->getVersionedMediaUrl($url, $path); + } + + public function mediaLockFile(string $mediaType, string $outputPath): string + { + return $this->getMediaLockFile($mediaType, $outputPath); + } + } + + final class AssetPathTranscode extends BaseTranscode + { + public function resolveAssetPath(Asset $asset): string + { + return $this->getAssetPath($asset); + } + } + + final class FailingVideoTranscode extends BaseTranscode + { + public int $encodeAttempts = 0; + + public function runVideoAssetWork(Asset $asset, callable $callback): bool + { + $callback(); + return true; + } + + public function getVideoUrl( + string|Asset $filePath, + array $videoOptions, + bool $generate = true, + bool $synchronous = false + ): string { + $this->encodeAttempts++; + return ''; + } + } + + final class HarnessVariableTranscode extends BaseTranscode + { + public ?bool $thumbnailGenerate = null; + + public function getVideoThumbnailUrl( + Asset|string $filePath, + array $thumbnailOptions, + bool $generate = true, + bool $asPath = false, + bool $synchronous = false + ): string|false|null { + $this->thumbnailGenerate = $generate; + return null; + } + } + + function assertSameValue(mixed $expected, mixed $actual, string $message): void + { + if ($expected !== $actual) { + throw new RuntimeException($message . "\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true)); + } + } + + function assertTrue(bool $condition, string $message): void + { + if (!$condition) { + throw new RuntimeException($message); + } + } + + $root = sys_get_temp_dir() . '/transcoder-refresh-harness-' . bin2hex(random_bytes(6)); + $sourceDirectory = $root . '/content/videos/197915/'; + $encodedRoot = $root . '/content/encoded/video/'; + $encodedDirectory = $encodedRoot . '197915/'; + $gifDirectory = $root . '/content/encoded/gif/197915/'; + $audioDirectory = $root . '/content/encoded/audio/197915/'; + $thumbnailRoot = $root . '/content/encoded/thumbnail/'; + mkdir($sourceDirectory, 0777, true); + mkdir($encodedDirectory, 0777, true); + mkdir($gifDirectory, 0777, true); + mkdir($audioDirectory, 0777, true); + mkdir($thumbnailRoot, 0777, true); + + try { + putenv('TRANSCODER_QUEUE_DELAY=9'); + putenv('TRANSCODER_SUBFOLDER_SEGMENT=3'); + putenv('TRANSCODER_WATERMARK_WIDTH=16'); + putenv('TRANSCODER_WATERMARK_PADDING=24'); + putenv('TRANSCODER_WATERMARK_OPACITY=100'); + + $filename = 'asset-89b333afee70d7c0f2d21c1230b33777.mp4'; + $sourcePath = $sourceDirectory . $filename; + $ffmpegBinary = trim((string)shell_exec('command -v ffmpeg')); + $ffprobeBinary = trim((string)shell_exec('command -v ffprobe')); + assertTrue($ffmpegBinary !== '', 'FFmpeg is required by the refresh regression harness.'); + assertTrue($ffprobeBinary !== '', 'FFprobe is required by the refresh regression harness.'); + exec( + escapeshellarg($ffmpegBinary) + . ' -loglevel error -f lavfi -i color=c=blue:s=64x64:d=0.2' + . ' -c:v libx264 -pix_fmt yuv420p -y ' . escapeshellarg($sourcePath), + $ffmpegOutput, + $ffmpegExitCode + ); + assertSameValue(0, $ffmpegExitCode, 'Could not create the focused FFmpeg source fixture.'); + + Craft::$aliases['@encoded'] = $root . '/content/encoded'; + $settings = new HarnessSettings([ + 'queueVideosOnAssetUpload' => false, + 'videoQueueDelaySeconds' => '$TRANSCODER_QUEUE_DELAY', + 'queuedVideoOptions' => [], + 'createSubfolders' => true, + 'subfolderUrlSegment' => false, + 'transcoderPaths' => [ + 'default' => '@encoded/', + 'video' => '@encoded/video/', + 'thumbnail' => '@encoded/thumbnail/', + 'gif' => '@encoded/gif/', + 'audio' => '@encoded/audio/', + ], + 'defaultVideoOptions' => [ + 'videoEncoder' => 'h264', + 'videoBitRate' => '', + 'videoFrameRate' => '', + 'audioBitRate' => '', + 'audioSampleRate' => '', + 'audioChannels' => '', + 'width' => '', + 'height' => '', + 'sharpen' => true, + 'aspectRatio' => 'letterbox', + 'letterboxColor' => '', + ], + 'videoEncoders' => [ + 'h264' => [ + 'fileSuffix' => '.mp4', + 'fileFormat' => 'mp4', + 'videoCodec' => 'libx264', + 'videoCodecOptions' => '', + 'audioCodec' => 'aac', + 'audioCodecOptions' => '', + 'threads' => '0', + ], + 'webm' => [ + 'fileSuffix' => '.webm', + 'fileFormat' => 'webm', + 'videoCodec' => 'libvpx', + 'videoCodecOptions' => '-quality good -cpu-used 0', + 'audioCodec' => 'libvorbis', + 'audioCodecOptions' => '', + 'threads' => '0', + ], + 'gif' => [ + 'fileSuffix' => '.mp4', + 'fileFormat' => 'mp4', + 'videoCodec' => 'libx264', + 'videoCodecOptions' => '-pix_fmt yuv420p -movflags +faststart', + 'threads' => '0', + ], + ], + 'defaultGifOptions' => [ + 'videoEncoder' => 'gif', + 'fileSuffix' => '', + 'fileFormat' => '', + 'videoCodec' => '', + 'videoCodecOptions' => '', + ], + 'audioEncoders' => [ + 'wav' => [ + 'fileSuffix' => '.wav', + 'fileFormat' => 'wav', + 'audioCodec' => 'pcm_s16le', + 'audioCodecOptions' => '', + 'threads' => '0', + ], + ], + 'defaultAudioOptions' => [ + 'audioEncoder' => 'wav', + 'audioBitRate' => '', + 'audioSampleRate' => '22050', + 'audioChannels' => '1', + 'timeInSecs' => '', + 'seekInSecs' => '', + 'synchronous' => false, + 'stripMetadata' => false, + ], + 'defaultThumbnailOptions' => [ + 'fileSuffix' => '.jpg', + 'timeInSecs' => 4, + 'width' => '', + 'height' => '', + 'sharpen' => true, + 'aspectRatio' => 'letterbox', + 'letterboxColor' => '', + ], + 'videoFilenameStrategy' => 'options', + 'useHashedNames' => false, + 'enableVideoWatermark' => false, + 'videoWatermarkPath' => '', + 'videoWatermarkWidth' => '$TRANSCODER_WATERMARK_WIDTH', + 'videoWatermarkPosition' => 'bottom-right', + 'videoWatermarkPadding' => '$TRANSCODER_WATERMARK_PADDING', + 'videoWatermarkOpacity' => '$TRANSCODER_WATERMARK_OPACITY', + 'enableVideoPosters' => false, + 'queueVideoPostersOnAssetUpload' => false, + 'preventVideoPosterBlackBars' => false, + 'videoPosterFormats' => [], + 'ffmpegPath' => $ffmpegBinary, + 'ffprobePath' => $ffprobeBinary, + 'ffprobeOptions' => '-v quiet -print_format json -show_format -show_streams', + 'transcoderUrls' => [ + 'default' => 'https://example.test/encoded/', + 'video' => 'https://example.test/encoded/video/', + 'thumbnail' => 'https://example.test/encoded/thumbnail/', + 'gif' => 'https://example.test/encoded/gif/', + 'audio' => 'https://example.test/encoded/audio/', + ], + ], ArrayObject::ARRAY_AS_PROPS); + + $queue = new HarnessQueue(); + Craft::$app = new HarnessApp($queue); + $plugin = new HarnessPlugin($settings); + $service = new HarnessTranscode(); + $plugin->transcode = $service; + Transcoder::$plugin = $plugin; + + $asset = new Asset(); + $asset->id = 197915; + $asset->filename = $filename; + $asset->folderPath = '197915/'; + $asset->sourcePath = $sourcePath; + Asset::$assets[$asset->id] = $asset; + + $volumeRoot = $root . '/volume'; + $volumeSubpath = 'site-assets'; + $volumeAssetDirectory = $volumeRoot . '/' . $volumeSubpath . '/197915/'; + mkdir($volumeAssetDirectory, 0777, true); + copy($sourcePath, $volumeAssetDirectory . $filename); + $volumeAsset = new Asset(); + $volumeAsset->filename = $filename; + $volumeAsset->folder = (object)['path' => '197915/']; + $volumeAsset->volume = new class(new Local($volumeRoot), $volumeSubpath) { + public function __construct( + private readonly Local $fs, + private readonly string $subpath, + ) { + } + + public function getFs(): Local + { + return $this->fs; + } + + public function getSubPath(): string + { + return $this->subpath; + } + }; + assertSameValue( + $volumeAssetDirectory . $filename, + (new AssetPathTranscode())->resolveAssetPath($volumeAsset), + 'Craft 5 volume subpaths were dropped from the local source path.' + ); + + $cpFilename = 'asset-cp-thumbnail.mp4'; + $cpSourcePath = $sourceDirectory . $cpFilename; + copy($sourcePath, $cpSourcePath); + $persistedCpAsset = new Asset(); + $persistedCpAsset->id = 200961; + $persistedCpAsset->filename = $cpFilename; + $persistedCpAsset->folderPath = null; + $persistedCpAsset->folder = (object)['path' => '200961/']; + $persistedCpAsset->path = '200961/' . $cpFilename; + $persistedCpAsset->sourcePath = $cpSourcePath; + Asset::$assets[$persistedCpAsset->id] = $persistedCpAsset; + + $uploadEventAsset = new Asset(); + $uploadEventAsset->id = $persistedCpAsset->id; + $uploadEventAsset->filename = $cpFilename; + $uploadEventAsset->folderPath = null; + $uploadEventAsset->folder = (object)['path' => 'user_42/']; + $uploadEventAsset->path = 'user_42/' . $cpFilename; + $uploadEventAsset->sourcePath = $cpSourcePath; + + $cpThumbnailFilename = 'asset-cp-thumbnail_4s_800w_450h_letterbox_.jpg'; + $cpThumbnailDirectory = $thumbnailRoot . '200961/'; + mkdir($cpThumbnailDirectory, 0777, true); + file_put_contents($cpThumbnailDirectory . $cpThumbnailFilename, 'cp-thumbnail'); + file_put_contents($thumbnailRoot . $cpThumbnailFilename, 'stale-root-thumbnail'); + $cpThumbnailUrl = $service->handleGetAssetThumbPath( + new DefineAssetThumbUrlEvent($uploadEventAsset, 800, 450) + ); + assertTrue( + is_string($cpThumbnailUrl) + && str_starts_with( + $cpThumbnailUrl, + 'https://example.test/encoded/thumbnail/200961/' . $cpThumbnailFilename . '?v=' + ), + 'The Control Panel thumbnail event did not reload the final Asset folder.' + ); + assertSameValue( + 'stale-root-thumbnail', + file_get_contents($thumbnailRoot . $cpThumbnailFilename), + 'The Control Panel thumbnail event reused or replaced the stale root derivative.' + ); + assertSameValue( + $cpThumbnailDirectory . $cpThumbnailFilename, + $service->getVideoThumbnailUrl( + $persistedCpAsset, + ['width' => 800, 'height' => 450], + false, + true + ), + 'Thumbnail generation did not fall back to the Craft folder model when folderPath was empty.' + ); + + $temporaryAsset = new Asset(); + $temporaryAsset->id = 200962; + $temporaryAsset->filename = 'temporary-upload.mp4'; + $temporaryAsset->folderPath = null; + $temporaryAsset->folder = (object)['path' => 'user_84/']; + $temporaryAsset->path = 'user_84/temporary-upload.mp4'; + $temporaryAsset->sourcePath = $cpSourcePath; + $temporaryAsset->volumeId = null; + Asset::$assets[$temporaryAsset->id] = $temporaryAsset; + assertSameValue( + null, + $service->handleGetAssetThumbPath(new DefineAssetThumbUrlEvent($temporaryAsset, 800, 450)), + 'A temporary Control Panel upload should not generate a Transcoder thumbnail.' + ); + assertTrue( + in_array( + 'Skipped Control Panel video thumbnail generation for temporary asset #200962.', + Craft::$logs, + true + ), + 'The skipped temporary Control Panel thumbnail was not logged.' + ); + + $variableService = new HarnessVariableTranscode(); + $plugin->transcode = $variableService; + (new TranscoderVariable())->getVideoThumbnailUrl($asset, [], false); + assertSameValue( + false, + $variableService->thumbnailGenerate, + 'The Twig thumbnail variable did not forward its generate argument.' + ); + $plugin->transcode = $service; + + $settings->enableVideoPosters = true; + $settings->queueVideoPostersOnAssetUpload = true; + assertSameValue( + true, + $service->shouldQueueStandaloneVideoPostersOnUpload(), + 'Poster-only upload queueing was not enabled independently.' + ); + $settings->queueVideosOnAssetUpload = true; + assertSameValue( + false, + $service->shouldQueueStandaloneVideoPostersOnUpload(), + 'Poster-only upload queueing would duplicate the full video job.' + ); + $settings->queueVideosOnAssetUpload = false; + $settings->videoPosterFormats = [ + 'upload' => [ + 'width' => 80, + 'height' => 45, + 'timeInSecs' => 3, + ], + ]; + $settings->preventVideoPosterBlackBars = true; + $posterJob = new GenerateVideoPosters([ + 'assetId' => $asset->id, + ]); + $posterJob->execute($queue); + $configuredPosterUrl = $service->getVideoPosterUrl($asset, 'upload', false); + $posterPath = $service->getVideoThumbnailUrl( + $asset, + [ + 'width' => 80, + 'height' => 45, + 'timeInSecs' => 3, + ], + false, + true + ); + assertTrue(is_string($posterPath) && is_file($posterPath), 'The standalone poster job did not create its output.'); + assertSameValue( + 'asset-89b333afee70d7c0f2d21c1230b33777_3s_80w_45h_letterbox_.jpg', + basename($posterPath), + 'Internal duration clamping changed the canonical filename used by refresh and direct Twig requests.' + ); + clearstatcache(true, $posterPath); + assertSameValue( + 'https://example.test/encoded/thumbnail/197915/' . basename($posterPath) . '?v=' . filemtime($posterPath), + $configuredPosterUrl, + 'The configured poster URL was not cache-busted from the generated file modification time.' + ); + assertTrue( + str_contains($posterPath, DIRECTORY_SEPARATOR . 'thumbnail' . DIRECTORY_SEPARATOR . '197915' . DIRECTORY_SEPARATOR), + 'The standalone poster job ignored the Asset subfolder.' + ); + assertTrue( + in_array('Generated 1 video poster(s) for asset #197915.', Craft::$logs, true), + 'The standalone poster job did not log successful completion.' + ); + file_put_contents($posterPath, ''); + touch($posterPath, time() + 5); + $posterJob->execute($queue); + clearstatcache(true, $posterPath); + assertTrue( + is_file($posterPath) && filesize($posterPath) > 0, + 'A newer zero-byte poster was incorrectly accepted as a successful cached output.' + ); + + $settings->enableVideoPosters = false; + $settings->preventVideoPosterBlackBars = false; + assertSameValue( + false, + $service->shouldQueueStandaloneVideoPostersOnUpload(), + 'Disabled poster generation still requested an upload job.' + ); + $disabledPosterMtime = filemtime($posterPath); + $posterJob->execute($queue); + clearstatcache(true, $posterPath); + assertSameValue( + $disabledPosterMtime, + filemtime($posterPath), + 'A disabled standalone poster job changed its existing output.' + ); + assertTrue( + in_array('Skipped disabled video poster generation for asset #197915.', Craft::$logs, true), + 'The disabled standalone poster job did not log its no-op.' + ); + + $settings->enableVideoPosters = true; + try { + (new GenerateVideoPosters(['assetId' => $temporaryAsset->id]))->execute($queue); + throw new RuntimeException('A temporary upload poster job should fail visibly.'); + } catch (RuntimeException $e) { + assertTrue( + str_contains($e->getMessage(), 'temporary upload storage'), + 'A temporary upload poster job did not report the expected failure.' + ); + } + $settings->enableVideoPosters = false; + $settings->queueVideoPostersOnAssetUpload = false; + $settings->videoPosterFormats = []; + + $gifSourcePath = $sourceDirectory . 'animated.gif'; + exec( + escapeshellarg($ffmpegBinary) + . ' -loglevel error -f lavfi -i color=c=red:s=64x64:d=0.4' + . ' -vf fps=5 -y ' . escapeshellarg($gifSourcePath), + $gifFixtureOutput, + $gifFixtureExitCode + ); + assertSameValue(0, $gifFixtureExitCode, 'Could not create the focused GIF source fixture.'); + $gifAsset = new Asset(); + $gifAsset->id = 197916; + $gifAsset->filename = 'animated.gif'; + $gifAsset->folderPath = '197915/'; + $gifAsset->sourcePath = $gifSourcePath; + Asset::$assets[$gifAsset->id] = $gifAsset; + + $gifJob = new EncodeGif([ + 'assetId' => $gifAsset->id, + 'gifOptions' => [], + ]); + $gifJob->execute($queue); + $gifFilename = $service->getGifFilename($gifAsset, []); + assertTrue(is_file($gifDirectory . $gifFilename), 'Queued GIF encoding did not create its expected output.'); + assertTrue(filesize($gifDirectory . $gifFilename) > 0, 'Queued GIF encoding created an empty output.'); + assertTrue( + in_array("Encoded GIF asset #197916: https://example.test/encoded/gif/197915/$gifFilename", Craft::$logs, true), + 'Queued GIF encoding did not log successful completion.' + ); + file_put_contents($gifDirectory . $gifFilename, ''); + touch($gifDirectory . $gifFilename, time() + 5); + $gifJob->execute($queue); + clearstatcache(true, $gifDirectory . $gifFilename); + assertTrue( + filesize($gifDirectory . $gifFilename) > 0, + 'A newer zero-byte GIF derivative was incorrectly accepted as a completed queue job.' + ); + $gifPidLock = $service->mediaLockFile('gif', $gifDirectory . $gifFilename); + file_put_contents($gifPidLock, (string)getmypid()); + try { + $gifJob->execute($queue); + throw new RuntimeException('Queued GIF encoding ignored an active legacy PID lock.'); + } catch (RuntimeException $e) { + assertTrue( + str_contains($e->getMessage(), 'GIF encoding failed'), + 'An active GIF PID lock did not fail visibly in Craft’s queue.' + ); + } finally { + @unlink($gifPidLock); + } + + $audioSourcePath = $sourceDirectory . 'tone.wav'; + exec( + escapeshellarg($ffmpegBinary) + . ' -loglevel error -f lavfi -i sine=frequency=1000:duration=0.2' + . ' -c:a pcm_s16le -y ' . escapeshellarg($audioSourcePath), + $audioFixtureOutput, + $audioFixtureExitCode + ); + assertSameValue(0, $audioFixtureExitCode, 'Could not create the focused audio source fixture.'); + $audioAsset = new Asset(); + $audioAsset->id = 197917; + $audioAsset->filename = 'tone.wav'; + $audioAsset->folderPath = '197915/'; + $audioAsset->sourcePath = $audioSourcePath; + Asset::$assets[$audioAsset->id] = $audioAsset; + + $audioJob = new EncodeAudio([ + 'assetId' => $audioAsset->id, + 'audioOptions' => [], + ]); + $audioJob->execute($queue); + $audioFilename = $service->getAudioFilename($audioAsset, []); + assertTrue(is_file($audioDirectory . $audioFilename), 'Queued audio encoding did not create its expected output.'); + assertTrue(filesize($audioDirectory . $audioFilename) > 0, 'Queued audio encoding created an empty output.'); + assertTrue( + in_array("Encoded audio asset #197917: https://example.test/encoded/audio/197915/$audioFilename", Craft::$logs, true), + 'Queued audio encoding did not log successful completion.' + ); + file_put_contents($audioDirectory . $audioFilename, ''); + touch($audioDirectory . $audioFilename, time() + 5); + $audioJob->execute($queue); + clearstatcache(true, $audioDirectory . $audioFilename); + assertTrue( + filesize($audioDirectory . $audioFilename) > 0, + 'A newer zero-byte audio derivative was incorrectly accepted as a completed queue job.' + ); + $audioPidLock = $service->mediaLockFile('audio', $audioDirectory . $audioFilename); + file_put_contents($audioPidLock, (string)getmypid()); + try { + $audioJob->execute($queue); + throw new RuntimeException('Queued audio encoding ignored an active legacy PID lock.'); + } catch (RuntimeException $e) { + assertTrue( + str_contains($e->getMessage(), 'Audio encoding failed'), + 'An active audio PID lock did not fail visibly in Craft’s queue.' + ); + } finally { + @unlink($audioPidLock); + } + + $expectedFilename = 'asset-89b333afee70d7c0f2d21c1230b33777_bps_fps_bps__c_w_h_letterbox_.mp4'; + $expectedPath = $encodedDirectory . $expectedFilename; + file_put_contents($expectedPath, 'old-encoded-bytes'); + + $outputInfo = $service->outputInfo($asset, $settings->queuedVideoOptions); + $targets = $service->refreshTargets($asset); + $sameNameOtherFolder = new Asset(); + $sameNameOtherFolder->id = 197918; + $sameNameOtherFolder->filename = $filename; + $sameNameOtherFolder->folderPath = '197918/'; + $sameNameOtherFolder->sourcePath = $sourcePath; + assertTrue( + $outputInfo['lockFile'] !== $service->outputInfo($sameNameOtherFolder, [])['lockFile'], + 'Video process locks collide for identical filenames in different Asset subfolders.' + ); + assertSameValue($expectedFilename, $outputInfo['filename'], 'The production empty-option filename shape changed.'); + assertSameValue($expectedPath, $outputInfo['path'], 'Encode output path does not match the production subfolder shape.'); + assertSameValue($outputInfo['path'], $targets['output'][0], 'Refresh and EncodeVideo must use a byte-identical output path.'); + file_put_contents($expectedPath, ''); + touch($expectedPath, time() + 5); + assertSameValue( + '', + $service->getVideoUrl($asset, $settings->queuedVideoOptions, false, true), + 'A zero-byte failed encode was treated as a reusable video output.' + ); + file_put_contents($expectedPath, 'old-encoded-bytes'); + + $settings->videoFilenameStrategy = 'source'; + $sourceStrategy = $service->outputInfo($asset, []); + assertSameValue('asset-89b333afee70d7c0f2d21c1230b33777.mp4', $sourceStrategy['filename'], 'Source filename strategy changed.'); + assertSameValue($sourceStrategy['path'], $service->refreshTargets($asset)['output'][0], 'Source-strategy paths diverge.'); + + $settings->videoFilenameStrategy = 'options'; + $settings->useHashedNames = true; + $hashed = $service->outputInfo($asset, []); + assertTrue((bool)preg_match('/^asset-89b333afee70d7c0f2d21c1230b33777[0-9a-f]{32}\.mp4$/', $hashed['filename']), 'Hashed filename shape changed.'); + assertSameValue($hashed['path'], $service->refreshTargets($asset)['output'][0], 'Hashed paths diverge.'); + + $settings->useHashedNames = false; + $watermarkPath = $root . '/watermark.png'; + exec( + escapeshellarg($ffmpegBinary) + . ' -loglevel error -f lavfi -i color=c=red:s=16x16:d=0.1' + . ' -frames:v 1 -y ' . escapeshellarg($watermarkPath), + $watermarkFixtureOutput, + $watermarkFixtureExitCode + ); + assertSameValue(0, $watermarkFixtureExitCode, 'Could not create the watermark image fixture.'); + $settings->enableVideoWatermark = true; + $settings->videoWatermarkPath = $watermarkPath; + $watermarked = $service->outputInfo($asset, []); + assertTrue((bool)preg_match('/_[0-9a-f]{10}\.mp4$/', $watermarked['filename']), 'Watermark fingerprint is missing from the filename.'); + assertSameValue($watermarked['path'], $service->refreshTargets($asset)['output'][0], 'Watermarked paths diverge.'); + $watermarkedUrl = $service->getVideoUrl($asset, [], true, true); + assertTrue( + is_file($watermarked['path']) + && filesize($watermarked['path']) > 0 + && str_starts_with($watermarkedUrl, $watermarked['url'] . '?v='), + 'The real FFmpeg watermark filter did not produce a non-empty cache-versioned output.' + ); + + $settings->enableVideoWatermark = false; + $settings->videoWatermarkPath = ''; + $settings->createSubfolders = false; + $flat = $service->outputInfo($asset, []); + assertSameValue($encodedRoot . $expectedFilename, $flat['path'], 'Flat output path or trailing separator changed.'); + assertSameValue($flat, $service->outputInfo($sourcePath, []), 'Asset and string source conversion produce different output metadata.'); + assertSameValue($flat['path'], $service->refreshTargets($asset)['output'][0], 'Flat paths diverge.'); + + $settings->createSubfolders = true; + $settings->subfolderUrlSegment = '$TRANSCODER_SUBFOLDER_SEGMENT'; + $paths = $settings->transcoderPaths; + $paths['video'] = '@encoded/video'; + $settings->transcoderPaths = $paths; + $urls = $settings->transcoderUrls; + $urls['video'] = 'https://example.test/encoded/video'; + $settings->transcoderUrls = $urls; + $urlInput = 'https://example.test/content/videos/197915/' . $filename; + $urlOutput = $service->outputInfo($urlInput, []); + assertSameValue($expectedPath, $urlOutput['path'], 'String URL input ignored its configured output subfolder.'); + assertSameValue( + 'https://example.test/encoded/video/197915/' . $expectedFilename, + $urlOutput['url'], + 'String URL output URL ignored its configured subfolder.' + ); + assertSameValue($outputInfo['path'], $urlOutput['path'], 'Asset and URL callers do not resolve to the same output path.'); + $unsafeUrl = 'https://example.test/content/videos/%2E%2E/' . $filename; + assertSameValue( + $encodedRoot . $expectedFilename, + $service->outputInfo($unsafeUrl, [])['path'], + 'An unsafe URL segment escaped the configured video output directory.' + ); + $settings->subfolderUrlSegment = false; + + $webmOptions = ['videoEncoder' => 'webm']; + $webmUrl = $service->getVideoUrl($asset, $webmOptions, true, true); + $webmFilename = $service->getVideoFilename($asset, $webmOptions); + $webmPath = $encodedDirectory . $webmFilename; + assertTrue( + is_file($webmPath) && filesize($webmPath) > 0, + 'The upstream Craft 5 WebM workflow no longer produces a non-empty output.' + ); + assertTrue( + str_starts_with($webmUrl, 'https://example.test/encoded/video/197915/' . $webmFilename . '?v='), + 'The WebM output did not retain the generated subfolder or cache version.' + ); + + $publicRefresh = $service->refreshVideoAsset($asset); + assertSameValue(true, $publicRefresh['queued'], 'The public refresh API did not report a queued job.'); + assertSameValue('job-1', $publicRefresh['jobId'], 'The public refresh API returned the wrong job ID.'); + assertTrue( + $queue->jobs[0] instanceof RefreshVideoAsset, + 'The public refresh API did not queue a RefreshVideoAsset job.' + ); + $queue->jobs = []; + $queue->delaySeconds = 0; + + $settings->enableVideoPosters = true; + $settings->preventVideoPosterBlackBars = true; + $settings->videoPosterFormats = [ + 'upload' => [ + 'width' => 80, + 'height' => 45, + 'timeInSecs' => 3, + ], + ]; + assertTrue(is_file($posterPath), 'The managed poster fixture disappeared before refresh cleanup.'); + exec( + escapeshellarg($ffmpegBinary) + . ' -loglevel error -f lavfi -i color=c=green:s=64x64:d=1.2' + . ' -c:v libx264 -pix_fmt yuv420p -y ' . escapeshellarg($sourcePath), + $replacementFixtureOutput, + $replacementFixtureExitCode + ); + assertSameValue(0, $replacementFixtureExitCode, 'Could not replace the video source fixture before refresh.'); + $targets = $service->refreshTargets($asset); + assertTrue( + in_array($posterPath, $targets['output'], true), + 'Refresh cleanup did not target the canonical poster generated for the previous source duration.' + ); + file_put_contents($targets['temporary'][0], 'stale-lock'); + file_put_contents($targets['temporary'][1], 'stale-progress'); + $result = $service->performVideoAssetRefresh($asset); + assertSameValue(false, $settings->queueVideosOnAssetUpload, 'The regression must run with upload queueing disabled.'); + assertTrue(!file_exists($expectedPath), 'Explicit refresh did not remove the old managed derivative.'); + assertTrue(!file_exists($posterPath), 'Explicit refresh did not remove the exact configured poster derivative.'); + assertTrue(is_file($webmPath), 'Explicit refresh removed an unconfigured video derivative.'); + assertTrue(!file_exists($targets['temporary'][0]), 'Explicit refresh did not remove its exact stale lock file.'); + assertTrue(!file_exists($targets['temporary'][1]), 'Explicit refresh did not remove its exact stale progress file.'); + assertSameValue(4, $result['removedFiles'], 'Explicit refresh did not report its exact managed files.'); + assertSameValue(true, $result['queued'], 'Explicit refresh did not queue replacement encoding.'); + assertSameValue('job-1', $result['jobId'], 'Unexpected follow-up job ID.'); + assertTrue($queue->jobs[0] instanceof EncodeVideo, 'The follow-up job is not EncodeVideo.'); + assertSameValue(197915, $queue->jobs[0]->assetId, 'EncodeVideo received the wrong asset ID.'); + assertSameValue(9, $queue->delaySeconds, 'Configured video queue delay was not applied.'); + assertTrue( + in_array('Refreshed video asset #197915; removed files: 4; follow-up encode job ID: job-1', Craft::$logs, true), + 'The refresh audit log is missing removedFiles and follow-up job ID.' + ); + + $encodedUrl = ''; + assertTrue( + $service->runVideoAssetWork($asset, function() use ($asset, $service, &$encodedUrl): void { + $encodedUrl = $service->getVideoUrl($asset, [], true, true); + }), + 'The queued EncodeVideo-equivalent work lock could not be acquired.' + ); + assertTrue(is_file($expectedPath) && filesize($expectedPath) > 0, 'FFmpeg did not recreate the deleted output.'); + assertTrue(file_get_contents($expectedPath) !== 'old-encoded-bytes', 'FFmpeg returned or preserved the old derivative.'); + assertTrue(str_contains($encodedUrl, '?v='), 'The regenerated video URL is not cache-versioned.'); + + file_put_contents($expectedPath, 'old-encoded-bytes-again'); + touch($expectedPath, 1786700000); + assertSameValue( + 'https://example.test/encoded/video/197915/' . $expectedFilename . '?v=1786700000', + $service->versionedUrl('https://example.test/encoded/video/197915/' . $expectedFilename, $expectedPath), + 'Stable generated media URLs are not versioned from the actual output modification time.' + ); + $queue->failPush = true; + try { + $service->performVideoAssetRefresh($asset); + throw new RuntimeException('A null follow-up queue ID must fail the refresh job.'); + } catch (RuntimeException $e) { + assertTrue( + str_contains($e->getMessage(), 'Unable to queue replacement video encoding'), + 'A failed follow-up queue push did not surface the expected error.' + ); + } + assertTrue( + in_array( + 'Video refresh handoff failed for asset #197915; removed files: 1; follow-up encode job ID: none', + Craft::$logs, + true + ), + 'The failed handoff log does not expose removedFiles=1 and a missing follow-up job ID.' + ); + + $queue->failPush = false; + $missingResult = $service->performVideoAssetRefresh($asset); + assertSameValue(0, $missingResult['removedFiles'], 'Missing derivatives must be a successful no-op.'); + assertTrue( + in_array('Refreshed video asset #197915; removed files: 0; follow-up encode job ID: job-2', Craft::$logs, true), + 'The refresh log does not make removedFiles=0 obvious.' + ); + + $queue->jobs = []; + $queue->delaySeconds = 0; + (new RefreshVideoAsset(['assetId' => $asset->id]))->execute($queue); + assertTrue( + $queue->jobs[0] instanceof EncodeVideo, + 'The queued refresh job did not hand off to EncodeVideo.' + ); + (new RefreshVideoAsset(['assetId' => 999999]))->execute($queue); + assertTrue( + in_array('Video refresh skipped missing asset #999999.', Craft::$logs, true), + 'The queued refresh job did not safely skip a missing Asset.' + ); + + putenv('TRANSCODER_VIDEO_RETRIES=2'); + putenv('TRANSCODER_VIDEO_RETRY_DELAY=17'); + $settings->videoEncodeMaxRetries = '$TRANSCODER_VIDEO_RETRIES'; + $settings->videoEncodeRetryDelaySeconds = '$TRANSCODER_VIDEO_RETRY_DELAY'; + $settings->enableVideoPosters = false; + $queue->jobs = []; + $queue->delaySeconds = 0; + $retryService = new FailingVideoTranscode(); + $plugin->transcode = $retryService; + + $firstAttempt = new EncodeVideo([ + 'assetId' => $asset->id, + 'videoOptions' => ['width' => 1280], + ]); + $firstAttempt->execute($queue); + assertSameValue(1, $retryService->encodeAttempts, 'The initial queued video encode did not run once.'); + assertSameValue(1, count($queue->jobs), 'A failed video encode did not queue exactly one retry.'); + assertTrue($queue->jobs[0] instanceof EncodeVideo, 'The queued video retry is not an EncodeVideo job.'); + assertSameValue(2, $queue->jobs[0]->attempt, 'The queued video retry has the wrong attempt number.'); + assertSameValue(2, $queue->jobs[0]->maxRetries, 'The queued video retry lost the configured retry count.'); + assertSameValue(17, $queue->jobs[0]->retryDelaySeconds, 'The queued video retry lost its configured delay.'); + assertSameValue(['width' => 1280], $queue->jobs[0]->videoOptions, 'The queued video retry lost its encoding options.'); + assertSameValue(17, $queue->delaySeconds, 'The configured video retry delay was not applied.'); + $retryLog = 'Retrying video encode attempt 2 of 3 in 17s; asset #197915; retry job ID: job-1;' + . ' previous error: Video encoding failed for asset #197915.'; + assertTrue( + in_array( + $retryLog, + Craft::$logs, + true + ), + 'The queued video retry was not logged with its attempt, delay, and job ID.' + ); + + $queue->jobs = []; + $finalAttempt = new EncodeVideo([ + 'assetId' => $asset->id, + 'attempt' => 3, + 'maxRetries' => 2, + 'retryDelaySeconds' => 17, + ]); + try { + $finalAttempt->execute($queue); + throw new RuntimeException('The final video attempt must remain a failed Craft queue job.'); + } catch (RuntimeException $e) { + assertTrue( + str_contains($e->getMessage(), 'Video encoding failed'), + 'The final video attempt did not expose the original encoding failure.' + ); + } + assertSameValue([], $queue->jobs, 'The final video attempt exceeded the configured retry count.'); + + $queue->failPush = true; + try { + (new EncodeVideo(['assetId' => $asset->id]))->execute($queue); + throw new RuntimeException('A failed video retry push must leave the current Craft job failed.'); + } catch (RuntimeException $e) { + assertTrue( + str_contains($e->getMessage(), 'Video encoding failed'), + 'A failed video retry push hid the original encoding failure.' + ); + } + assertTrue( + in_array('Unable to queue video encoding retry for asset #197915.', Craft::$logs, true), + 'A failed video retry push was not logged.' + ); + $queue->failPush = false; + $plugin->transcode = $service; + + echo "refresh-video-asset harness: OK\n"; + } finally { + putenv('TRANSCODER_QUEUE_DELAY'); + putenv('TRANSCODER_SUBFOLDER_SEGMENT'); + putenv('TRANSCODER_WATERMARK_WIDTH'); + putenv('TRANSCODER_WATERMARK_PADDING'); + putenv('TRANSCODER_WATERMARK_OPACITY'); + putenv('TRANSCODER_VIDEO_RETRIES'); + putenv('TRANSCODER_VIDEO_RETRY_DELAY'); + + if (is_dir($root)) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($root); + } + } +} diff --git a/tests/upload-queue-harness.php b/tests/upload-queue-harness.php new file mode 100644 index 0000000..13dfbb3 --- /dev/null +++ b/tests/upload-queue-harness.php @@ -0,0 +1,556 @@ + */ + public static array $logs = []; + + public static function debug(string $message, string $category = ''): void + { + self::log('debug', $message, $category); + } + + public static function error(string $message, string $category = ''): void + { + self::log('error', $message, $category); + } + + public static function info(string $message, string $category = ''): void + { + self::log('info', $message, $category); + } + + public static function warning(string $message, string $category = ''): void + { + self::log('warning', $message, $category); + } + + public static function t(string $category, string $message, array $params = []): string + { + foreach ($params as $key => $value) { + $message = str_replace('{' . $key . '}', (string)$value, $message); + } + + return $message; + } + + public static function parseEnv(string $value): string + { + return $value; + } + + private static function log(string $level, string $message, string $category): void + { + self::$logs[] = compact('level', 'message', 'category'); + } + } +} + +namespace craft\base { + class Model + { + public function __construct(array $config = []) + { + foreach ($config as $name => $value) { + $this->{$name} = $value; + } + } + } + + class Plugin + { + public string $controllerNamespace = ''; + public string $handle = 'transcoder'; + public string $name = 'Transcoder'; + + private Model $settings; + + public function __construct(array $config = []) + { + $this->settings = $config['settings']; + } + + public function init(): void + { + } + + public function getSettings(): Model + { + return $this->settings; + } + } +} + +namespace craft\console { + class Application + { + } +} + +namespace craft\elements { + class Asset + { + public const EVENT_AFTER_SAVE = 'afterSave'; + public const KIND_AUDIO = 'audio'; + public const KIND_VIDEO = 'video'; + + public function __construct( + public ?int $id = null, + public string $filename = '', + public bool $temporary = false, + ) { + } + } +} + +namespace craft\events { + class DefineAssetThumbUrlEvent + { + } + + class ModelEvent extends \yii\base\Event + { + public bool $isNew = false; + } + + class PluginEvent extends \yii\base\Event + { + public mixed $plugin = null; + } + + class RegisterCacheOptionsEvent extends \yii\base\Event + { + public array $options = []; + } + + class RegisterUrlRulesEvent extends \yii\base\Event + { + public array $rules = []; + } + + class TemplateEvent extends \yii\base\Event + { + public string $template = ''; + public array $variables = []; + } +} + +namespace craft\helpers { + final class App + { + public static function parseEnv(?string $value): bool|string|null + { + if ($value !== null && str_starts_with($value, '$')) { + $environmentValue = getenv(substr($value, 1)); + + return $environmentValue === false ? $value : $environmentValue; + } + + return $value; + } + } + + final class Assets + { + public static function getFileKindByExtension(string $filename): string + { + return match (strtolower(pathinfo($filename, PATHINFO_EXTENSION))) { + 'mp4', 'mov', 'webm' => \craft\elements\Asset::KIND_VIDEO, + 'aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav' => \craft\elements\Asset::KIND_AUDIO, + default => 'unknown', + }; + } + } + + final class FileHelper + { + public static function clearDirectory(string $directory): void + { + } + } + + final class UrlHelper + { + public static function cpUrl(string $path): string + { + return $path; + } + } +} + +namespace craft\queue { + class BaseJob + { + public function __construct(array $config = []) + { + foreach ($config as $name => $value) { + $this->{$name} = $value; + } + } + + protected function setProgress(mixed $queue, float $progress, string $label = ''): void + { + } + } +} + +namespace craft\services { + final class Assets + { + public const EVENT_DEFINE_THUMB_URL = 'defineThumbUrl'; + } + + final class Plugins + { + public const EVENT_AFTER_INSTALL_PLUGIN = 'afterInstallPlugin'; + } +} + +namespace craft\utilities { + final class ClearCaches + { + public const EVENT_REGISTER_CACHE_OPTIONS = 'registerCacheOptions'; + } +} + +namespace craft\web { + final class UrlManager + { + public const EVENT_REGISTER_SITE_URL_RULES = 'registerSiteUrlRules'; + } + + final class View + { + public const EVENT_BEFORE_RENDER_TEMPLATE = 'beforeRenderTemplate'; + } +} + +namespace craft\web\twig\variables { + final class CraftVariable + { + public const EVENT_INIT = 'init'; + + public function set(string $name, array $config): void + { + } + } +} + +namespace yii\base { + class ErrorException extends \Exception + { + } + + class Event + { + /** @var array> */ + private static array $handlers = []; + + public mixed $sender = null; + + public function __construct(array $config = []) + { + foreach ($config as $name => $value) { + $this->{$name} = $value; + } + } + + public static function on(string $class, string $name, callable $handler): void + { + self::$handlers[$class][$name][] = $handler; + } + + public static function trigger(string $class, string $name, self $event): void + { + foreach (self::$handlers[$class][$name] ?? [] as $handler) { + $handler($event); + } + } + } +} + +namespace nystudio107\transcoder\models { + final class Settings extends \craft\base\Model + { + public bool $clearCaches = false; + public bool $enableVideoPosters = true; + public bool $queueAudioOnAssetUpload = true; + public bool $queueGifsOnAssetUpload = true; + public bool $queueVideoPostersOnAssetUpload = true; + public bool $queueVideosOnAssetUpload = true; + public int|string $audioQueueDelaySeconds = 31; + public int|string $gifQueueDelaySeconds = 23; + public int|string $videoQueueDelaySeconds = '$VIDEO_UPLOAD_DELAY'; + public array $queuedAudioOptions = ['audioEncoder' => 'mp3', 'audioBitRate' => '192k']; + public array $queuedGifOptions = ['width' => 640, 'frameRate' => 12]; + public array $queuedVideoOptions = ['videoEncoder' => 'webm', 'width' => 1280]; + } +} + +namespace nystudio107\transcoder\services { + trait ServicesTrait + { + public object $transcode; + public object $vite; + } +} + +namespace nystudio107\transcoder\variables { + final class TranscoderVariable + { + } +} + +namespace { + use craft\elements\Asset; + use craft\events\ModelEvent; + 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\Transcoder; + use yii\base\Event; + + require dirname(__DIR__) . '/src/jobs/EncodeAudio.php'; + require dirname(__DIR__) . '/src/jobs/EncodeGif.php'; + require dirname(__DIR__) . '/src/jobs/EncodeVideo.php'; + require dirname(__DIR__) . '/src/jobs/GenerateVideoPosters.php'; + require dirname(__DIR__) . '/src/Transcoder.php'; + + final class HarnessQueue + { + /** @var array */ + public array $pushes = []; + public bool $failPush = false; + + private int $nextDelay = 0; + + public function delay(int $seconds): self + { + $this->nextDelay = $seconds; + + return $this; + } + + public function push(object $job): ?string + { + $delay = $this->nextDelay; + $this->nextDelay = 0; + if ($this->failPush) { + return null; + } + + $this->pushes[] = [ + 'delay' => $delay, + 'job' => $job, + ]; + + return 'job-' . count($this->pushes); + } + + public function reset(): void + { + $this->pushes = []; + $this->failPush = false; + $this->nextDelay = 0; + } + } + + final class HarnessRequest + { + public bool $isCpRequest = false; + + public function getIsConsoleRequest(): bool + { + return false; + } + + public function getIsSiteRequest(): bool + { + return false; + } + } + + final class HarnessApp + { + public function __construct( + private readonly HarnessQueue $queue, + private readonly HarnessRequest $request, + ) { + } + + public function getQueue(): HarnessQueue + { + return $this->queue; + } + + public function getRequest(): HarnessRequest + { + return $this->request; + } + } + + final class HarnessTranscode + { + public function __construct(private readonly Settings $settings) + { + } + + public function shouldQueueStandaloneVideoPostersOnUpload(): bool + { + return $this->settings->enableVideoPosters + && $this->settings->queueVideoPostersOnAssetUpload + && !$this->settings->queueVideosOnAssetUpload; + } + + public function isTemporaryUploadAsset(Asset $asset): bool + { + return $asset->temporary; + } + } + + function assertSameValue(mixed $expected, mixed $actual, string $message): void + { + if ($expected !== $actual) { + throw new RuntimeException( + $message + . "\nExpected: " . var_export($expected, true) + . "\nActual: " . var_export($actual, true) + ); + } + } + + function assertTrue(bool $condition, string $message): void + { + if (!$condition) { + throw new RuntimeException($message); + } + } + + function triggerUpload(Asset $asset, bool $isNew = true): void + { + Event::trigger(Asset::class, Asset::EVENT_AFTER_SAVE, new ModelEvent([ + 'sender' => $asset, + 'isNew' => $isNew, + ])); + } + + function assertPush( + HarnessQueue $queue, + string $expectedClass, + int $expectedDelay, + int $expectedAssetId, + ?string $optionsProperty = null, + ?array $expectedOptions = null, + ): object { + assertSameValue(1, count($queue->pushes), 'Exactly one queue job should be pushed.'); + $push = $queue->pushes[0]; + assertSameValue($expectedClass, $push['job']::class, 'The upload routed to the wrong queue job.'); + assertSameValue($expectedDelay, $push['delay'], 'The configured queue delay was not preserved.'); + assertSameValue($expectedAssetId, $push['job']->assetId, 'The Asset ID was not preserved.'); + if ($optionsProperty !== null) { + assertSameValue( + $expectedOptions, + $push['job']->{$optionsProperty}, + 'The configured encode options were not preserved.' + ); + } + + return $push['job']; + } + + putenv('VIDEO_UPLOAD_DELAY=17'); + $queue = new HarnessQueue(); + $settings = new Settings(); + Craft::$app = new HarnessApp($queue, new HarnessRequest()); + $plugin = new Transcoder(['settings' => $settings]); + $plugin->transcode = new HarnessTranscode($settings); + $plugin->vite = (object)[]; + $plugin->init(); + + try { + triggerUpload(new Asset(100, 'existing.mp4'), false); + assertSameValue([], $queue->pushes, 'Saving an existing Asset must not queue upload work.'); + + $queue->reset(); + triggerUpload(new Asset(101, 'feature.MP4')); + assertPush( + $queue, + EncodeVideo::class, + 17, + 101, + 'videoOptions', + $settings->queuedVideoOptions + ); + assertTrue( + !$queue->pushes[0]['job'] instanceof GenerateVideoPosters, + 'Video encoding must not also queue a standalone poster job.' + ); + + $queue->reset(); + $temporaryUpload = new Asset(106, 'temporary.mp4', true); + triggerUpload($temporaryUpload); + assertSameValue([], $queue->pushes, 'A temporary first save must not race the final Asset move.'); + $temporaryUpload->temporary = false; + triggerUpload($temporaryUpload, false); + assertPush( + $queue, + EncodeVideo::class, + 17, + 106, + 'videoOptions', + $settings->queuedVideoOptions + ); + + $queue->reset(); + triggerUpload(new Asset(102, 'animation.GIF')); + assertPush( + $queue, + EncodeGif::class, + 23, + 102, + 'gifOptions', + $settings->queuedGifOptions + ); + + $queue->reset(); + triggerUpload(new Asset(103, 'podcast.mp3')); + assertPush( + $queue, + EncodeAudio::class, + 31, + 103, + 'audioOptions', + $settings->queuedAudioOptions + ); + + $queue->reset(); + $settings->queueVideosOnAssetUpload = false; + triggerUpload(new Asset(104, 'poster-only.mov')); + assertPush($queue, GenerateVideoPosters::class, 17, 104); + + $queue->reset(); + Craft::$logs = []; + $queue->failPush = true; + triggerUpload(new Asset(105, 'failed.webm')); + assertSameValue([], $queue->pushes, 'A failed queue push must not be recorded as successful.'); + assertTrue( + count(array_filter( + Craft::$logs, + static fn(array $log): bool => $log['level'] === 'error' + && str_contains($log['message'], 'Unable to queue video poster asset #105 for encoding.') + )) === 1, + 'A null queue job ID should be logged as an error.' + ); + + echo "upload queue harness: OK\n"; + } finally { + putenv('VIDEO_UPLOAD_DELAY'); + } +}