From 058b5a610cc100c26ce74eccb93a03a813f52550 Mon Sep 17 00:00:00 2001 From: Arjan Date: Mon, 10 Aug 2026 09:27:40 +0200 Subject: [PATCH 01/16] Add queued video encoding for asset uploads --- src/Transcoder.php | 40 ++++++++++++++++++++++++- src/config.php | 12 ++++++++ src/jobs/EncodeVideo.php | 51 +++++++++++++++++++++++++++++++ src/models/Settings.php | 16 ++++++++++ src/services/Transcode.php | 60 ++++++++++++++++++++++++++++++++----- src/templates/settings.twig | 34 +++++++++++++++++++++ 6 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/jobs/EncodeVideo.php create mode 100644 src/templates/settings.twig diff --git a/src/Transcoder.php b/src/Transcoder.php index df5d68c..db24971 100644 --- a/src/Transcoder.php +++ b/src/Transcoder.php @@ -16,6 +16,7 @@ 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; @@ -28,6 +29,7 @@ use craft\web\twig\variables\CraftVariable; use craft\web\UrlManager; use nystudio107\transcoder\models\Settings; +use nystudio107\transcoder\jobs\EncodeVideo; use nystudio107\transcoder\services\ServicesTrait; use nystudio107\transcoder\variables\TranscoderVariable; use yii\base\ErrorException; @@ -72,7 +74,7 @@ class Transcoder extends Plugin /** * @var bool */ - public bool $hasCpSettings = false; + public bool $hasCpSettings = true; /** * @var string @@ -147,6 +149,16 @@ protected function createSettingsModel(): ?Model return new Settings(); } + /** + * @inheritdoc + */ + protected function settingsHtml(): ?string + { + return Craft::$app->getView()->renderTemplate('transcoder/settings', [ + 'settings' => $this->getSettings(), + ]); + } + /** * Add in our Craft components */ @@ -205,6 +217,32 @@ function(RegisterCacheOptionsEvent $event) { } ); } + if ($settings->queueVideosOnAssetUpload) { + Event::on( + Asset::class, + Asset::EVENT_AFTER_SAVE, + function(ModelEvent $event) use ($settings) { + $asset = $event->sender; + if (!$event->isNew || !$asset instanceof Asset) { + return; + } + + if (AssetsHelper::getFileKindByExtension($asset->filename) !== Asset::KIND_VIDEO) { + return; + } + + $queue = Craft::$app->getQueue(); + if ($settings->videoQueueDelaySeconds > 0) { + $queue = $queue->delay($settings->videoQueueDelaySeconds); + } + + $queue->push(new EncodeVideo([ + 'assetId' => $asset->id, + 'videoOptions' => $settings->queuedVideoOptions, + ])); + } + ); + } // Handler: Plugins::EVENT_AFTER_INSTALL_PLUGIN Event::on( Plugins::class, diff --git a/src/config.php b/src/config.php index fcf6219..c29cd79 100644 --- a/src/config.php +++ b/src/config.php @@ -66,6 +66,18 @@ // 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, + + // Options passed to the queued video encode + 'queuedVideoOptions' => [], + + // How encoded video filenames are generated: options or source + 'videoFilenameStrategy' => 'options', + // Preset video encoders 'videoEncoders' => [ 'h264' => [ diff --git a/src/jobs/EncodeVideo.php b/src/jobs/EncodeVideo.php new file mode 100644 index 0000000..0230261 --- /dev/null +++ b/src/jobs/EncodeVideo.php @@ -0,0 +1,51 @@ +id($this->assetId)->one(); + if (!$asset instanceof Asset) { + throw new RuntimeException("Unable to find video asset #{$this->assetId}."); + } + + $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__); + } + + /** + * @inheritdoc + */ + protected function defaultDescription(): ?string + { + return "Encoding video asset #{$this->assetId}"; + } +} diff --git a/src/models/Settings.php b/src/models/Settings.php index 44cb1d0..0a53dc0 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -102,6 +102,18 @@ 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 Seconds to wait before an uploaded video starts encoding. */ + public int $videoQueueDelaySeconds = 0; + + /** @var array Options passed to queued video encodes. */ + public array $queuedVideoOptions = []; + + /** @var string How encoded video filenames are generated: options or source. */ + public string $videoFilenameStrategy = 'options'; + /** * Preset video encoders * @@ -275,6 +287,10 @@ public function rules(): array ['useHashedNames', 'boolean'], ['createSubfolders', 'boolean'], ['clearCaches', 'boolean'], + ['queueVideosOnAssetUpload', 'boolean'], + ['videoQueueDelaySeconds', 'integer', 'min' => 0], + ['queuedVideoOptions', ArrayValidator::class], + ['videoFilenameStrategy', 'in', 'range' => ['options', 'source']], ['videoEncoders', 'required'], ['audioEncoders', 'required'], ['defaultVideoOptions', 'required'], diff --git a/src/services/Transcode.php b/src/services/Transcode.php index 9db1a36..53a3c34 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -98,7 +98,12 @@ 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(); @@ -178,7 +183,11 @@ public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $g } } - $destVideoFile = $this->getFilename($filePath, $videoOptions); + $destVideoFile = $this->getFilename( + $filePath, + $videoOptions, + $this->getVideoFilenameExcludeParams($videoOptions) + ); // File to store the video encoding progress in $progressFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destVideoFile . '.progress'; @@ -187,8 +196,11 @@ public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $g $destVideoPath .= $destVideoFile; $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'; @@ -215,6 +227,23 @@ public function getVideoUrl(string|Asset $filePath, array $videoOptions, bool $g $result = ''; } else { // Kick off the transcoding + if ($synchronous) { + file_put_contents($lockFile, (string)getmypid()); + $output = $this->executeShellCommand($ffmpegCmd); + @unlink($lockFile); + @unlink($progressFile); + + if (file_exists($destVideoPath) && filesize($destVideoPath) > 0) { + $url = $settings['transcoderUrls']['video'] ?? $settings['transcoderUrls']['default']; + $url .= $subfolder; + $result = App::parseEnv($url) . $destVideoFile; + } else { + Craft::error("Video encoding failed: $output", __METHOD__); + } + + return $result; + } + $pid = $this->executeShellCommand($ffmpegCmd); Craft::info($ffmpegCmd . "\nffmpeg PID: " . $pid, __METHOD__); @@ -557,7 +586,11 @@ public function getVideoFilename(Asset|string $filePath, array $videoOptions): s $videoOptions['fileSuffix'] = $thisEncoder['fileSuffix']; - return $this->getFilename($filePath, $videoOptions); + return $this->getFilename( + $filePath, + $videoOptions, + $this->getVideoFilenameExcludeParams($videoOptions) + ); } /** @@ -733,9 +766,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 +792,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 +806,18 @@ 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; + } + /** * Extract a file system path if $filePath is an Asset object * diff --git a/src/templates/settings.twig b/src/templates/settings.twig new file mode 100644 index 0000000..a3b2c0f --- /dev/null +++ b/src/templates/settings.twig @@ -0,0 +1,34 @@ +{% import '_includes/forms' as forms %} + +

{{ 'Video queue'|t('transcoder') }}

+ +{{ forms.lightswitchField({ + label: 'Queue videos on asset upload'|t('transcoder'), + instructions: 'Add newly uploaded video assets to Craft’s queue for background encoding.'|t('transcoder'), + id: 'queueVideosOnAssetUpload', + name: 'queueVideosOnAssetUpload', + on: settings.queueVideosOnAssetUpload, +}) }} + +{{ forms.textField({ + label: 'Video queue delay'|t('transcoder'), + instructions: 'Seconds to wait before an uploaded video starts encoding.'|t('transcoder'), + id: 'videoQueueDelaySeconds', + name: 'videoQueueDelaySeconds', + value: settings.videoQueueDelaySeconds, + type: 'number', + min: 0, + size: 5, +}) }} + +{{ forms.selectField({ + label: 'Video filename strategy'|t('transcoder'), + instructions: 'Use the original option-based filename, or keep one stable filename per source asset.'|t('transcoder'), + id: 'videoFilenameStrategy', + name: 'videoFilenameStrategy', + value: settings.videoFilenameStrategy, + options: [ + { label: 'Encoding options'|t('transcoder'), value: 'options' }, + { label: 'Source asset'|t('transcoder'), value: 'source' }, + ], +}) }} From aba65afd3fb851ab3c0962fd6ed080c68397f462 Mon Sep 17 00:00:00 2001 From: Arjan Date: Mon, 10 Aug 2026 09:28:44 +0200 Subject: [PATCH 02/16] Add configurable video watermarks --- src/config.php | 18 +++++ src/models/Settings.php | 24 +++++++ src/services/Transcode.php | 137 ++++++++++++++++++++++++++++++++---- src/templates/settings.twig | 65 +++++++++++++++++ 4 files changed, 232 insertions(+), 12 deletions(-) diff --git a/src/config.php b/src/config.php index c29cd79..4f355d0 100644 --- a/src/config.php +++ b/src/config.php @@ -78,6 +78,24 @@ // 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, + // Preset video encoders 'videoEncoders' => [ 'h264' => [ diff --git a/src/models/Settings.php b/src/models/Settings.php index 0a53dc0..23f5dee 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -114,6 +114,24 @@ class Settings extends Model /** @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 Watermark distance from the selected edges in pixels. */ + public int $videoWatermarkPadding = 24; + + /** @var int Watermark opacity percentage. */ + public int $videoWatermarkOpacity = 100; + /** * Preset video encoders * @@ -291,6 +309,12 @@ public function rules(): array ['videoQueueDelaySeconds', 'integer', 'min' => 0], ['queuedVideoOptions', ArrayValidator::class], ['videoFilenameStrategy', 'in', 'range' => ['options', 'source']], + ['enableVideoWatermark', 'boolean'], + ['videoWatermarkPath', 'string'], + ['videoWatermarkWidth', 'safe'], + ['videoWatermarkPosition', 'in', 'range' => ['top-left', 'top-right', 'bottom-left', 'bottom-right']], + ['videoWatermarkPadding', 'integer', 'min' => 0], + ['videoWatermarkOpacity', 'integer', 'min' => 0, 'max' => 100], ['videoEncoders', 'required'], ['audioEncoders', 'required'], ['defaultVideoOptions', 'required'], diff --git a/src/services/Transcode.php b/src/services/Transcode.php index 53a3c34..ee1dc84 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -128,11 +128,20 @@ public function getVideoUrl( $thisEncoder = $videoEncoders[$videoOptions['videoEncoder']]; $videoOptions['fileSuffix'] = $thisEncoder['fileSuffix']; + $watermarkPath = $this->getVideoWatermarkPath(); + if ($watermarkPath !== null) { + $videoOptions['watermark'] = $this->getVideoWatermarkFingerprint($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']; @@ -146,11 +155,17 @@ public function getVideoUrl( $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']) @@ -585,6 +600,10 @@ public function getVideoFilename(Asset|string $filePath, array $videoOptions): s $thisEncoder = $videoEncoders[$videoOptions['videoEncoder']]; $videoOptions['fileSuffix'] = $thisEncoder['fileSuffix']; + $watermarkPath = $this->getVideoWatermarkPath(); + if ($watermarkPath !== null) { + $videoOptions['watermark'] = $this->getVideoWatermarkFingerprint($watermarkPath); + } return $this->getFilename( $filePath, @@ -818,6 +837,89 @@ protected function getVideoFilenameExcludeParams(array $videoOptions): array return self::EXCLUDE_PARAMS; } + /** + * 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, + $settings->videoWatermarkWidth, + $settings->videoWatermarkPosition, + $settings->videoWatermarkPadding, + $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 = (int)$settings->videoWatermarkWidth; + if ($width > 0) { + $watermarkFilters[] = "scale=$width:-1"; + } + + if ($settings->videoWatermarkOpacity < 100) { + $opacity = max(0, $settings->videoWatermarkOpacity) / 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, $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"], + }; + } + /** * Extract a file system path if $filePath is an Asset object * @@ -884,6 +986,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 @@ -916,14 +1031,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 diff --git a/src/templates/settings.twig b/src/templates/settings.twig index a3b2c0f..db5a933 100644 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -32,3 +32,68 @@ { label: 'Source asset'|t('transcoder'), value: 'source' }, ], }) }} + +
+

{{ 'Video watermark'|t('transcoder') }}

+ +{{ forms.lightswitchField({ + label: 'Enable video watermark'|t('transcoder'), + instructions: 'Overlay the configured image on newly encoded videos.'|t('transcoder'), + id: 'enableVideoWatermark', + name: 'enableVideoWatermark', + on: settings.enableVideoWatermark, +}) }} + +{{ forms.textField({ + label: 'Watermark path or URL'|t('transcoder'), + instructions: 'Use a local path, Yii alias, environment value, or public image URL.'|t('transcoder'), + id: 'videoWatermarkPath', + name: 'videoWatermarkPath', + value: settings.videoWatermarkPath, +}) }} + +{{ forms.textField({ + label: 'Watermark width'|t('transcoder'), + instructions: 'Optional width in pixels. Leave empty to keep the original size.'|t('transcoder'), + id: 'videoWatermarkWidth', + name: 'videoWatermarkWidth', + value: settings.videoWatermarkWidth, + type: 'number', + min: 1, + size: 6, +}) }} + +{{ forms.selectField({ + label: 'Watermark position'|t('transcoder'), + id: 'videoWatermarkPosition', + name: 'videoWatermarkPosition', + value: settings.videoWatermarkPosition, + options: [ + { label: 'Top left'|t('transcoder'), value: 'top-left' }, + { label: 'Top right'|t('transcoder'), value: 'top-right' }, + { label: 'Bottom left'|t('transcoder'), value: 'bottom-left' }, + { label: 'Bottom right'|t('transcoder'), value: 'bottom-right' }, + ], +}) }} + +{{ forms.textField({ + label: 'Watermark padding'|t('transcoder'), + id: 'videoWatermarkPadding', + name: 'videoWatermarkPadding', + value: settings.videoWatermarkPadding, + type: 'number', + min: 0, + size: 6, +}) }} + +{{ forms.textField({ + label: 'Watermark opacity'|t('transcoder'), + instructions: 'Percentage from 0 to 100.'|t('transcoder'), + id: 'videoWatermarkOpacity', + name: 'videoWatermarkOpacity', + value: settings.videoWatermarkOpacity, + type: 'number', + min: 0, + max: 100, + size: 6, +}) }} From 8e89775d93f3640e71e4d0f532ba4126d5171cb3 Mon Sep 17 00:00:00 2001 From: Arjan Date: Mon, 10 Aug 2026 09:30:08 +0200 Subject: [PATCH 03/16] Generate video posters without black bars --- src/config.php | 15 +++ src/jobs/EncodeVideo.php | 7 ++ src/models/Settings.php | 40 ++++++++ src/services/Transcode.php | 141 +++++++++++++++++++++++++-- src/templates/settings.twig | 47 +++++++++ src/variables/TranscoderVariable.php | 16 +++ 6 files changed, 259 insertions(+), 7 deletions(-) diff --git a/src/config.php b/src/config.php index 4f355d0..aa2a82a 100644 --- a/src/config.php +++ b/src/config.php @@ -96,6 +96,21 @@ // Watermark opacity percentage 'videoWatermarkOpacity' => 100, + // Generate configured poster images after queued video encoding + 'enableVideoPosters' => 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/EncodeVideo.php b/src/jobs/EncodeVideo.php index 0230261..7ff9aa4 100644 --- a/src/jobs/EncodeVideo.php +++ b/src/jobs/EncodeVideo.php @@ -39,6 +39,13 @@ public function execute($queue): void } 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}."); + } + } } /** diff --git a/src/models/Settings.php b/src/models/Settings.php index 23f5dee..b47b564 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -132,6 +132,21 @@ class Settings extends Model /** @var int Watermark opacity percentage. */ public int $videoWatermarkOpacity = 100; + /** @var bool Generate configured poster images after queued video encoding. */ + public bool $enableVideoPosters = 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 * @@ -315,6 +330,9 @@ public function rules(): array ['videoWatermarkPosition', 'in', 'range' => ['top-left', 'top-right', 'bottom-left', 'bottom-right']], ['videoWatermarkPadding', 'integer', 'min' => 0], ['videoWatermarkOpacity', 'integer', 'min' => 0, 'max' => 100], + ['enableVideoPosters', 'boolean'], + ['preventVideoPosterBlackBars', 'boolean'], + ['videoPosterFormats', ArrayValidator::class], ['videoEncoders', 'required'], ['audioEncoders', 'required'], ['defaultVideoOptions', 'required'], @@ -322,4 +340,26 @@ public function rules(): array ['defaultAudioOptions', 'required'], ]; } + + /** + * 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 ee1dc84..e0eb114 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -282,7 +282,13 @@ public function getVideoUrl( * @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(); @@ -308,11 +314,19 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt . ' -vcodec mjpeg' . ' -vframes 1'; - // Adjust the scaling if desired - $ffmpegCmd = $this->addScalingFfmpegArgs( - $thumbnailOptions, - $ffmpegCmd - ); + if (!empty($thumbnailOptions['preventBlackBars']) + && !empty($thumbnailOptions['width']) + && !empty($thumbnailOptions['height']) + ) { + $ffmpegCmd .= ' -filter_complex ' . escapeshellarg($this->getPosterBlackBarFilter($thumbnailOptions)) + . ' -map ' . escapeshellarg('[poster]'); + } else { + // Adjust the scaling if desired + $ffmpegCmd = $this->addScalingFfmpegArgs( + $thumbnailOptions, + $ffmpegCmd + ); + } // Set the timecode to get the thumbnail from if desired if (!empty($thumbnailOptions['timeInSecs'])) { @@ -333,7 +347,10 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt // 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)) { @@ -342,6 +359,20 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt $shellOutput = $this->executeShellCommand($ffmpegCmd); Craft::info($ffmpegCmd, __METHOD__); + if ($synchronous && file_exists($destThumbnailPath) && filesize($destThumbnailPath) > 0) { + if ($asPath) { + return $destThumbnailPath; + } + + $url = $settings['transcoderUrls']['thumbnail'] ?? $settings['transcoderUrls']['default']; + $url .= $subfolder; + return App::parseEnv($url) . $destThumbnailFile; + } + + if ($synchronous) { + Craft::error("Video poster generation failed: $shellOutput", __METHOD__); + } + // if ffmpeg fails which we can't check because the process is ran in the background // don't return the future path of the image or else we can't check this in the front end } else { @@ -364,6 +395,58 @@ public function getVideoThumbnailUrl(Asset|string $filePath, array $thumbnailOpt 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 { + $formats = $this->getVideoPosterFormats(); + if (!isset($formats[$formatHandle])) { + return ''; + } + + $options = $formats[$formatHandle]; + $options['posterFormat'] = $formatHandle; + if (!empty($options['timeInSecs'])) { + $fileInfo = $this->getFileInfo($filePath, true) ?? []; + $duration = (float)($fileInfo['duration'] ?? 0); + if ($duration > 0) { + $options['timeInSecs'] = min((float)$options['timeInSecs'], max(0, $duration - 0.1)); + } + } + if (Transcoder::$plugin->getSettings()->preventVideoPosterBlackBars) { + $options['preventBlackBars'] = true; + } + + $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); + } + /** * Returns a URL to the transcoded audio file or "" if it doesn't exist * (at which time it will create it). @@ -920,6 +1003,50 @@ protected function getVideoWatermarkPosition(): array }; } + /** + * 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; + } + + /** + * Build a poster filter that fills unused space with a blurred cover frame. + */ + protected function getPosterBlackBarFilter(array $options): string + { + $width = (int)$options['width']; + $height = (int)$options['height']; + + return '[0:v]split=2[background][foreground];' + . "[background]scale=$width:$height:force_original_aspect_ratio=increase," + . "crop=$width:$height,boxblur=20:1[background];" + . "[foreground]scale=$width:$height:force_original_aspect_ratio=decrease[foreground];" + . '[background][foreground]overlay=(W-w)/2:(H-h)/2[poster]'; + } + /** * Extract a file system path if $filePath is an Asset object * diff --git a/src/templates/settings.twig b/src/templates/settings.twig index db5a933..ade2c87 100644 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -33,6 +33,53 @@ ], }) }} +
+

{{ 'Video posters'|t('transcoder') }}

+ +{{ forms.lightswitchField({ + label: 'Generate video posters'|t('transcoder'), + instructions: 'Generate the configured poster images after a queued video is encoded.'|t('transcoder'), + id: 'enableVideoPosters', + name: 'enableVideoPosters', + on: settings.enableVideoPosters, +}) }} + +{{ forms.lightswitchField({ + label: 'Prevent poster black bars'|t('transcoder'), + instructions: 'Fill unused poster space with a blurred cover image behind the fitted video frame.'|t('transcoder'), + id: 'preventVideoPosterBlackBars', + name: 'preventVideoPosterBlackBars', + on: settings.preventVideoPosterBlackBars, +}) }} + +{{ forms.editableTableField({ + label: 'Video poster formats'|t('transcoder'), + id: 'videoPosterFormats', + name: 'videoPosterFormats', + cols: { + handle: { + type: 'singleline', + heading: 'Handle'|t('transcoder'), + }, + width: { + type: 'number', + heading: 'Width'|t('transcoder'), + }, + height: { + type: 'number', + heading: 'Height'|t('transcoder'), + }, + timeInSecs: { + type: 'number', + heading: 'Time in seconds'|t('transcoder'), + }, + }, + rows: settings.getVideoPosterFormatRows(), + allowAdd: true, + allowReorder: true, + allowDelete: true, +}) }} +

{{ 'Video watermark'|t('transcoder') }}

diff --git a/src/variables/TranscoderVariable.php b/src/variables/TranscoderVariable.php index c67b996..b20f559 100644 --- a/src/variables/TranscoderVariable.php +++ b/src/variables/TranscoderVariable.php @@ -59,6 +59,22 @@ public function getVideoThumbnailUrl($filePath, $thumbnailOptions): string|false return Transcoder::$plugin->transcode->getVideoThumbnailUrl($filePath, $thumbnailOptions); } + /** + * 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->getVideoPosterUrls($filePath, $generate); + } + /** * Returns a URL to the transcoded audio file or "" if it doesn't exist * (at which time it will create it). From 645176c99cb5c94cc7bc9d26f47f2f4b88fb9c09 Mon Sep 17 00:00:00 2001 From: Arjan Date: Mon, 10 Aug 2026 09:31:32 +0200 Subject: [PATCH 04/16] Document queued video workflows --- CHANGELOG.md | 8 ++++ README.md | 11 +++++ docs/docs/configuring.md | 66 ++++++++++++++++++++++++++++++ docs/docs/using.md | 12 ++++++ src/models/Settings.php | 4 +- src/translations/en/transcoder.php | 34 +++++++++++++++ 6 files changed, 132 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1da4ce..b607ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Changed * Remove the `bufsize` parameter entirely from the FFMPEG default command, which was preventing `WebM` files from being generated properly ([#72](https://github.com/nystudio107/craft-transcoder/issues/72)) +### Added + +* Queue video encoding when new video assets are uploaded, with a configurable delay. +* 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. + ## 4.0.2 - 2024.09.30 ## Added * Add `phpstan` and `ecs` code linting diff --git a/README.md b/README.md index 041c0e3..626b454 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ Transcode video & audio files to various formats, and provide video thumbnails +## Video workflow features + +- Queue newly uploaded video assets through Craft’s queue. +- Delay queued encodes when other asset-save handlers need time to finish. +- 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. +- Replace poster letterboxing with a blurred cover background. + +All features are disabled by default except the original `options` filename strategy, so existing installations keep their current behavior. Configuration is available from the plugin settings screen or `config/transcoder.php`. + ![Screenshot](./docs/docs/resources/img/plugin-banner.jpg) **Note**: _The license fee for this plugin is $59.00 via the Craft Plugin Store._ diff --git a/docs/docs/configuring.md b/docs/docs/configuring.md index 6557ad2..f1f1cbd 100644 --- a/docs/docs/configuring.md +++ b/docs/docs/configuring.md @@ -17,4 +17,70 @@ 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, + 'queuedVideoOptions' => [ + 'videoBitRate' => '1200k', + 'videoFrameRate' => 30, + 'width' => 1280, + 'height' => 720, + ], +]; +``` + +A working Craft queue runner is required. The queue job waits for ffmpeg to finish, so Craft can report failures and retry the job through the configured queue driver. + +`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. + +## 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 are generated after a queued video encode: + +```php +return [ + 'enableVideoPosters' => true, + 'preventVideoPosterBlackBars' => true, + 'videoPosterFormats' => [ + '16_9' => [ + 'width' => 800, + 'height' => 450, + 'timeInSecs' => 3, + ], + 'square' => [ + 'width' => 800, + 'height' => 800, + 'timeInSecs' => 1, + ], + ], +]; +``` + +When `preventVideoPosterBlackBars` is enabled, the video frame is fitted over a blurred cover version of the same frame. Poster timestamps are clamped to the source duration for short videos. + Brought to you by [nystudio107](https://nystudio107.com) diff --git a/docs/docs/using.md b/docs/docs/using.md index fda5b3e..5182e06 100644 --- a/docs/docs/using.md +++ b/docs/docs/using.md @@ -91,6 +91,18 @@ The file format setting `videoEncoder` is preset to what you’ll need to genera Transcoder will also automatically add video thumbnails in the Control Panel Asset index. +## Reading Generated Video Posters + +Queued poster generation does not need to be started from a template. Read a configured 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. + ## Generating a Transcoded Audio File To generate a transcoded audio File, do the following: diff --git a/src/models/Settings.php b/src/models/Settings.php index b47b564..877d7a4 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -289,7 +289,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']); } } @@ -311,8 +311,6 @@ public function rules(): array ['ffprobePath', 'required'], ['ffprobeOptions', 'string'], ['ffprobeOptions', 'safe'], - ['transcoderPath', 'string'], - ['transcoderPath', 'required'], ['transcoderPaths', ArrayValidator::class], ['transcoderPaths', 'required'], ['transcoderUrls', ArrayValidator::class], diff --git a/src/translations/en/transcoder.php b/src/translations/en/transcoder.php index 381ccfc..13a9024 100644 --- a/src/translations/en/transcoder.php +++ b/src/translations/en/transcoder.php @@ -15,6 +15,40 @@ */ 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 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', + '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', + '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', '{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}', From 7441a3a138f707be0f0bbbedaefc4b495e038e3b Mon Sep 17 00:00:00 2001 From: Arjan Date: Fri, 14 Aug 2026 09:51:20 +0200 Subject: [PATCH 05/16] Refresh replaced video asset outputs --- CHANGELOG.md | 1 + README.md | 3 + docs/docs/using.md | 16 ++ src/jobs/EncodeVideo.php | 36 ++-- src/jobs/RefreshVideoAsset.php | 62 +++++++ src/services/Transcode.php | 284 +++++++++++++++++++++++++++-- src/translations/en/transcoder.php | 3 + 7 files changed, 375 insertions(+), 30 deletions(-) create mode 100644 src/jobs/RefreshVideoAsset.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b607ef1..d80be83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * 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. ## 4.0.2 - 2024.09.30 ## Added diff --git a/README.md b/README.md index 626b454..93eaaf6 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,12 @@ Transcode video & audio files to various formats, and provide video thumbnails - Overlay a configurable watermark on encoded videos. - Generate named poster formats after queued encodes. - Replace poster letterboxing with a blurred cover background. +- Refresh managed video derivatives after a third-party plugin replaces a source asset. All features are disabled by default except the original `options` filename strategy, so existing installations keep their current behavior. Configuration is available from the plugin settings screen or `config/transcoder.php`. +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._ diff --git a/docs/docs/using.md b/docs/docs/using.md index 5182e06..894ada1 100644 --- a/docs/docs/using.md +++ b/docs/docs/using.md @@ -103,6 +103,22 @@ Queued poster generation does not need to be started from a template. Read a con `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. + ## Generating a Transcoded Audio File To generate a transcoded audio File, do the following: diff --git a/src/jobs/EncodeVideo.php b/src/jobs/EncodeVideo.php index 7ff9aa4..8cb8ca7 100644 --- a/src/jobs/EncodeVideo.php +++ b/src/jobs/EncodeVideo.php @@ -27,24 +27,30 @@ public function execute($queue): void throw new RuntimeException("Unable to find video asset #{$this->assetId}."); } - $url = Transcoder::$plugin->transcode->getVideoUrl( - $asset, - $this->videoOptions, - true, - true - ); - - if ($url === '') { - throw new RuntimeException("Video encoding failed for asset #{$this->assetId}."); - } + $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__); + 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 (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."); } } diff --git a/src/jobs/RefreshVideoAsset.php b/src/jobs/RefreshVideoAsset.php new file mode 100644 index 0000000..8a352e6 --- /dev/null +++ b/src/jobs/RefreshVideoAsset.php @@ -0,0 +1,62 @@ +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.'); + } + + Craft::$app->getQueue()->delay(self::RETRY_DELAY_SECONDS)->push(new self([ + 'assetId' => $this->assetId, + 'attempt' => $this->attempt + 1, + 'maxAttempts' => $this->maxAttempts, + ])); + $this->setProgress($queue, 1, Craft::t('transcoder', 'Waiting for current video encoding to finish')); + return; + } + + $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/services/Transcode.php b/src/services/Transcode.php index e0eb114..3ad8b2a 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -16,10 +16,14 @@ use craft\events\DefineAssetThumbUrlEvent; use craft\fs\Local; use craft\helpers\App; +use craft\helpers\Assets as AssetsHelper; use craft\helpers\FileHelper; use craft\helpers\Json as JsonHelper; use mikehaertl\shellcommand\Command as ShellCommand; +use nystudio107\transcoder\jobs\EncodeVideo; +use nystudio107\transcoder\jobs\RefreshVideoAsset; use nystudio107\transcoder\Transcoder; +use RuntimeException; use yii\base\Exception; use yii\base\InvalidConfigException; use yii\validators\UrlValidator; @@ -404,24 +408,11 @@ public function getVideoPosterUrl( bool $generate = false, bool $synchronous = false ): string { - $formats = $this->getVideoPosterFormats(); - if (!isset($formats[$formatHandle])) { + $options = $this->getVideoPosterOptions($filePath, $formatHandle); + if ($options === null) { return ''; } - $options = $formats[$formatHandle]; - $options['posterFormat'] = $formatHandle; - if (!empty($options['timeInSecs'])) { - $fileInfo = $this->getFileInfo($filePath, true) ?? []; - $duration = (float)($fileInfo['duration'] ?? 0); - if ($duration > 0) { - $options['timeInSecs'] = min((float)$options['timeInSecs'], max(0, $duration - 0.1)); - } - } - if (Transcoder::$plugin->getSettings()->preventVideoPosterBlackBars) { - $options['preventBlackBars'] = true; - } - $url = $this->getVideoThumbnailUrl($filePath, $options, $generate, false, $synchronous); return is_string($url) ? $url : ''; } @@ -447,6 +438,108 @@ public function generateVideoPosters(Asset|string $filePath): array return $this->getVideoPosterUrls($filePath, true, true); } + /** + * 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, + ])); + + 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(); + if ($settings->videoQueueDelaySeconds > 0) { + $queue = $queue->delay($settings->videoQueueDelaySeconds); + } + $jobId = $queue->push(new EncodeVideo([ + 'assetId' => (int)$asset->id, + 'videoOptions' => $settings->queuedVideoOptions, + ])); + + 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 + { + if (!$asset->id) { + return false; + } + + $lockPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'transcoder-video-asset-' . (int)$asset->id . '.lock'; + $handle = @fopen($lockPath, 'c+'); + if ($handle === false) { + throw new RuntimeException('Unable to create the Transcoder video 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). @@ -920,6 +1013,141 @@ protected function getVideoFilenameExcludeParams(array $videoOptions): array return self::EXCLUDE_PARAMS; } + /** + * 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.'); + } + } + + /** + * 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(); + $subfolder = $settings->createSubfolders ? $asset->folderPath : ''; + $videoDirectory = App::parseEnv( + $settings->transcoderPaths['video'] ?? $settings->transcoderPaths['default'] + ) . $subfolder; + $videoFilename = $this->getVideoFilename($asset, $settings->queuedVideoOptions); + $outputs = [$videoDirectory . $videoFilename]; + $temporary = [ + sys_get_temp_dir() . DIRECTORY_SEPARATOR . $videoFilename . '.lock', + sys_get_temp_dir() . DIRECTORY_SEPARATOR . $videoFilename . '.progress', + ]; + + if ($settings->enableVideoPosters) { + $posterDirectory = App::parseEnv( + $settings->transcoderPaths['thumbnail'] ?? $settings->transcoderPaths['default'] + ) . $subfolder; + 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); + } + } + } + + return [ + 'output' => array_values(array_unique($outputs)), + 'temporary' => array_values(array_unique($temporary)), + ]; + } + + /** + * Return whether the configured video encoder still owns its process lock. + * + * @param string[] $temporaryPaths + */ + protected function isVideoEncodingActive(array $temporaryPaths): bool + { + foreach ($temporaryPaths as $path) { + if (!str_ends_with($path, '.lock') || !is_file($path)) { + continue; + } + + $pid = trim((string)@file_get_contents($path)); + if ($pid === '' || !ctype_digit($pid)) { + continue; + } + + $processState = []; + exec('kill -0 ' . (int)$pid . ' 2>&1', $processState); + if ($processState === []) { + return true; + } + } + + return false; + } + + /** + * Delete exact refresh targets after validating their managed roots. + * + * @param array{output: string[], temporary: string[]} $targets + */ + protected function removeVideoAssetRefreshTargets(array $targets): int + { + $removed = 0; + foreach ($targets as $kind => $paths) { + foreach ($paths as $path) { + if (!is_file($path) && !is_link($path)) { + continue; + } + if (!$this->isSafeVideoAssetRefreshPath($path, $kind)) { + throw new RuntimeException('Refusing to remove a Transcoder file outside its managed roots.'); + } + if (!@unlink($path) && (is_file($path) || is_link($path))) { + throw new RuntimeException('Unable to remove a managed Transcoder file.'); + } + $removed++; + } + } + + return $removed; + } + + /** + * Verify a deletion target without following a file symlink outside its root. + */ + protected function isSafeVideoAssetRefreshPath(string $path, string $kind): bool + { + $settings = Transcoder::$plugin->getSettings(); + if ($kind === 'temporary') { + $roots = [sys_get_temp_dir()]; + } elseif ($kind === 'output') { + $roots = []; + foreach (['default', 'video', 'thumbnail'] as $key) { + if (!empty($settings->transcoderPaths[$key])) { + $roots[] = (string)App::parseEnv($settings->transcoderPaths[$key]); + } + } + } else { + return false; + } + + $parent = realpath(dirname(FileHelper::normalizePath($path))); + if ($parent === false) { + return false; + } + + foreach ($roots as $root) { + $root = realpath(rtrim(FileHelper::normalizePath($root), DIRECTORY_SEPARATOR)); + if ($root !== false && ($parent === $root || str_starts_with($parent, $root . DIRECTORY_SEPARATOR))) { + return true; + } + } + + return false; + } + /** * Resolve the configured watermark input. */ @@ -1032,6 +1260,32 @@ protected function getVideoPosterFormats(): array return $formats; } + /** + * Return the exact thumbnail options used by a configured poster format. + */ + protected function getVideoPosterOptions(Asset|string $filePath, string $formatHandle): ?array + { + $formats = $this->getVideoPosterFormats(); + if (!isset($formats[$formatHandle])) { + return null; + } + + $options = $formats[$formatHandle]; + $options['posterFormat'] = $formatHandle; + if (!empty($options['timeInSecs'])) { + $fileInfo = $this->getFileInfo($filePath, true) ?? []; + $duration = (float)($fileInfo['duration'] ?? 0); + if ($duration > 0) { + $options['timeInSecs'] = min((float)$options['timeInSecs'], max(0, $duration - 0.1)); + } + } + if (Transcoder::$plugin->getSettings()->preventVideoPosterBlackBars) { + $options['preventBlackBars'] = true; + } + + return $options; + } + /** * Build a poster filter that fills unused space with a blurred cover frame. */ diff --git a/src/translations/en/transcoder.php b/src/translations/en/transcoder.php index 13a9024..84a406e 100644 --- a/src/translations/en/transcoder.php +++ b/src/translations/en/transcoder.php @@ -49,6 +49,9 @@ '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}', From 24bf508eaa2c74a2863d0c4b647ba431745128f8 Mon Sep 17 00:00:00 2001 From: Arjan Date: Fri, 14 Aug 2026 14:53:45 +0200 Subject: [PATCH 06/16] Fix replaced video refresh handoff --- CHANGELOG.md | 6 + composer.json | 3 +- docs/docs/using.md | 4 + src/jobs/EncodeVideo.php | 1 + src/jobs/RefreshVideoAsset.php | 14 +- src/services/Transcode.php | 188 ++++++---- tests/refresh-video-asset-harness.php | 518 ++++++++++++++++++++++++++ 7 files changed, 669 insertions(+), 65 deletions(-) create mode 100644 tests/refresh-video-asset-harness.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d80be83..735a8a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ * Add blurred-background poster fitting to prevent black bars. * Add `refreshVideoAsset()` for integrations that intentionally replace a video asset’s source file. +### 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. + ## 4.0.2 - 2024.09.30 ## Added * Add `phpstan` and `ecs` code linting diff --git a/composer.json b/composer.json index ae4c41d..5be4ec9 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "scripts": { "phpstan": "phpstan --ansi --memory-limit=1G", "check-cs": "ecs check --ansi", - "fix-cs": "ecs check --fix --ansi" + "fix-cs": "ecs check --fix --ansi", + "test-refresh": "php tests/refresh-video-asset-harness.php" }, "config": { "allow-plugins": { diff --git a/docs/docs/using.md b/docs/docs/using.md index 894ada1..e7bd2f1 100644 --- a/docs/docs/using.md +++ b/docs/docs/using.md @@ -119,6 +119,10 @@ The result contains `queued` and `jobId`. Cleanup and encoding are asynchronous, 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 To generate a transcoded audio File, do the following: diff --git a/src/jobs/EncodeVideo.php b/src/jobs/EncodeVideo.php index 8cb8ca7..49947db 100644 --- a/src/jobs/EncodeVideo.php +++ b/src/jobs/EncodeVideo.php @@ -27,6 +27,7 @@ public function execute($queue): void throw new RuntimeException("Unable to find video asset #{$this->assetId}."); } + Craft::info("Starting queued video encoding for asset #{$this->assetId}.", __METHOD__); $executed = Transcoder::$plugin->transcode->runVideoAssetWork($asset, function() use ($asset): void { $url = Transcoder::$plugin->transcode->getVideoUrl( $asset, diff --git a/src/jobs/RefreshVideoAsset.php b/src/jobs/RefreshVideoAsset.php index 8a352e6..f2f87e7 100644 --- a/src/jobs/RefreshVideoAsset.php +++ b/src/jobs/RefreshVideoAsset.php @@ -38,15 +38,27 @@ public function execute($queue): void throw new RuntimeException('Video refresh timed out waiting for current encoding to finish.'); } - Craft::$app->getQueue()->delay(self::RETRY_DELAY_SECONDS)->push(new self([ + $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')); } diff --git a/src/services/Transcode.php b/src/services/Transcode.php index 3ad8b2a..d8b2059 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -111,31 +111,16 @@ public function getVideoUrl( { $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']; - $watermarkPath = $this->getVideoWatermarkPath(); - if ($watermarkPath !== null) { - $videoOptions['watermark'] = $this->getVideoWatermarkFingerprint($watermarkPath); - } + if ($outputInfo !== null) { + $filePath = $outputInfo['sourcePath']; + $subfolder = $outputInfo['subfolder']; + $destVideoPath = $outputInfo['directory']; + $destVideoFile = $outputInfo['filename']; + $videoOptions = $outputInfo['videoOptions']; + $thisEncoder = $outputInfo['encoder']; + $watermarkPath = $outputInfo['watermarkPath']; // Build the basic command for ffmpeg $ffmpegCmd = $settings['ffmpegPath'] @@ -202,17 +187,11 @@ public function getVideoUrl( } } - $destVideoFile = $this->getFilename( - $filePath, - $videoOptions, - $this->getVideoFilenameExcludeParams($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); @@ -222,7 +201,7 @@ public function getVideoUrl( } // 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 @@ -240,7 +219,10 @@ public function getVideoUrl( if (file_exists($destVideoPath) && (@filemtime($destVideoPath) >= @filemtime($filePath))) { $url = $settings['transcoderUrls']['video'] ?? $settings['transcoderUrls']['default']; $url .= $subfolder; - $result = App::parseEnv($url) . $destVideoFile; + $result = $this->getVersionedMediaUrl( + (string)App::parseEnv($url) . $destVideoFile, + $destVideoPath + ); // skip encoding } elseif (!$generate) { $result = ''; @@ -255,7 +237,10 @@ public function getVideoUrl( if (file_exists($destVideoPath) && filesize($destVideoPath) > 0) { $url = $settings['transcoderUrls']['video'] ?? $settings['transcoderUrls']['default']; $url .= $subfolder; - $result = App::parseEnv($url) . $destVideoFile; + $result = $this->getVersionedMediaUrl( + (string)App::parseEnv($url) . $destVideoFile, + $destVideoPath + ); } else { Craft::error("Video encoding failed: $output", __METHOD__); } @@ -370,7 +355,10 @@ public function getVideoThumbnailUrl( $url = $settings['transcoderUrls']['thumbnail'] ?? $settings['transcoderUrls']['default']; $url .= $subfolder; - return App::parseEnv($url) . $destThumbnailFile; + return $this->getVersionedMediaUrl( + (string)App::parseEnv($url) . $destThumbnailFile, + $destThumbnailPath + ); } if ($synchronous) { @@ -392,7 +380,10 @@ public function getVideoThumbnailUrl( } else { $url = $settings['transcoderUrls']['thumbnail'] ?? $settings['transcoderUrls']['default']; $url .= $subfolder; - $result = App::parseEnv($url) . $destThumbnailFile; + $result = $this->getVersionedMediaUrl( + (string)App::parseEnv($url) . $destThumbnailFile, + $destThumbnailPath + ); } } @@ -450,6 +441,11 @@ public function refreshVideoAsset(Asset $asset): array $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, @@ -497,6 +493,18 @@ public function performVideoAssetRefresh(Asset $asset): array '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, @@ -768,24 +776,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']; - $watermarkPath = $this->getVideoWatermarkPath(); - if ($watermarkPath !== null) { - $videoOptions['watermark'] = $this->getVideoWatermarkFingerprint($watermarkPath); - } - - return $this->getFilename( - $filePath, - $videoOptions, - $this->getVideoFilenameExcludeParams($videoOptions) - ); + return $this->getVideoOutputInfo($filePath, $videoOptions)['filename'] ?? ''; } /** @@ -1013,6 +1004,20 @@ protected function getVideoFilenameExcludeParams(array $videoOptions): array 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. */ @@ -1023,6 +1028,62 @@ protected function validateRefreshVideoAsset(Asset $asset): void } } + /** + * Resolve every output value shared by video encoding and refresh cleanup. + * + * @return array{ + * sourcePath: string, + * subfolder: string, + * directory: string, + * filename: string, + * path: 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 = $filePath instanceof Asset && $settings->createSubfolders ? $filePath->folderPath : ''; + $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 = $settings->transcoderPaths['video'] ?? $settings->transcoderPaths['default']; + $directory = (string)App::parseEnv($directory . $subfolder); + $filename = $this->getFilename( + $sourcePath, + $videoOptions, + $this->getVideoFilenameExcludeParams($videoOptions) + ); + + return [ + 'sourcePath' => $sourcePath, + 'subfolder' => $subfolder, + 'directory' => $directory, + 'filename' => $filename, + 'path' => $directory . $filename, + 'lockFile' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename . '.lock', + 'progressFile' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename . '.progress', + 'videoOptions' => $videoOptions, + 'encoder' => $encoder, + 'watermarkPath' => $watermarkPath, + ]; + } + /** * Return exact files managed by the configured automatic video workflow. * @@ -1031,15 +1092,16 @@ protected function validateRefreshVideoAsset(Asset $asset): void protected function getVideoAssetRefreshTargets(Asset $asset): array { $settings = Transcoder::$plugin->getSettings(); - $subfolder = $settings->createSubfolders ? $asset->folderPath : ''; - $videoDirectory = App::parseEnv( - $settings->transcoderPaths['video'] ?? $settings->transcoderPaths['default'] - ) . $subfolder; - $videoFilename = $this->getVideoFilename($asset, $settings->queuedVideoOptions); - $outputs = [$videoDirectory . $videoFilename]; + $videoOutput = $this->getVideoOutputInfo($asset, $settings->queuedVideoOptions); + if ($videoOutput === null) { + throw new RuntimeException('Unable to resolve the source or output path for the video Asset.'); + } + + $subfolder = $videoOutput['subfolder']; + $outputs = [$videoOutput['path']]; $temporary = [ - sys_get_temp_dir() . DIRECTORY_SEPARATOR . $videoFilename . '.lock', - sys_get_temp_dir() . DIRECTORY_SEPARATOR . $videoFilename . '.progress', + $videoOutput['lockFile'], + $videoOutput['progressFile'], ]; if ($settings->enableVideoPosters) { diff --git a/tests/refresh-video-asset-harness.php b/tests/refresh-video-asset-harness.php new file mode 100644 index 0000000..274018c --- /dev/null +++ b/tests/refresh-video-asset-harness.php @@ -0,0 +1,518 @@ + */ + 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_VIDEO = 'video'; + + public ?int $id = null; + public string $filename = ''; + public string $folderPath = ''; + public string $sourcePath = ''; + + public static function find(): object + { + throw new \RuntimeException('Asset queries are not used by this focused harness.'); + } + } +} + +namespace craft\events { + class DefineAssetThumbUrlEvent + { + } +} + +namespace craft\fs { + class Local + { + } +} + +namespace craft\helpers { + final class App + { + public static function parseEnv(?string $value): bool|string|null + { + 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 + { + return in_array(strtolower(pathinfo($filename, PATHINFO_EXTENSION)), ['mp4', 'mov', 'webm'], true) + ? \craft\elements\Asset::KIND_VIDEO + : '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); + } + } +} + +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 { + use craft\elements\Asset; + use nystudio107\transcoder\Transcoder; + use nystudio107\transcoder\jobs\EncodeVideo; + use nystudio107\transcoder\services\Transcode as BaseTranscode; + + require dirname(__DIR__) . '/src/jobs/EncodeVideo.php'; + require dirname(__DIR__) . '/src/jobs/RefreshVideoAsset.php'; + require dirname(__DIR__) . '/src/services/Transcode.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); + } + } + + 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/'; + mkdir($sourceDirectory, 0777, true); + mkdir($encodedDirectory, 0777, true); + + try { + $filename = 'asset-89b333afee70d7c0f2d21c1230b33777.mp4'; + $sourcePath = $sourceDirectory . $filename; + $ffmpegBinary = trim((string)shell_exec('command -v ffmpeg')); + assertTrue($ffmpegBinary !== '', 'FFmpeg 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' => 9, + 'queuedVideoOptions' => [], + 'createSubfolders' => true, + 'transcoderPaths' => [ + 'default' => '@encoded/', + 'video' => '@encoded/video/', + 'thumbnail' => '@encoded/thumbnail/', + ], + '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', + ], + ], + 'videoFilenameStrategy' => 'options', + 'useHashedNames' => false, + 'enableVideoWatermark' => false, + 'videoWatermarkPath' => '', + 'videoWatermarkWidth' => '', + 'videoWatermarkPosition' => 'bottom-right', + 'videoWatermarkPadding' => 24, + 'videoWatermarkOpacity' => 100, + 'enableVideoPosters' => false, + 'ffmpegPath' => $ffmpegBinary, + 'transcoderUrls' => [ + 'default' => 'https://example.test/encoded/', + 'video' => 'https://example.test/encoded/video/', + 'thumbnail' => 'https://example.test/encoded/thumbnail/', + ], + ], 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; + + $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); + 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.'); + + $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'; + file_put_contents($watermarkPath, 'watermark'); + $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.'); + + $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; + + $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.'); + assertSameValue(1, $result['removedFiles'], 'Explicit refresh should report one removed derivative.'); + 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: 1; 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.' + ); + + echo "refresh-video-asset harness: OK\n"; + } finally { + 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); + } + } +} From 7c126ee376447ec6c7d52d78e456cb917229451b Mon Sep 17 00:00:00 2001 From: Arjan Date: Fri, 14 Aug 2026 16:52:38 +0200 Subject: [PATCH 07/16] Preserve video output subfolders --- CHANGELOG.md | 1 + README.md | 1 + docs/docs/configuring.md | 15 ++++++ docs/docs/using.md | 2 + src/config.php | 3 ++ src/models/Settings.php | 4 ++ src/services/Transcode.php | 72 +++++++++++++++++++++++---- src/templates/settings.twig | 11 ++++ src/translations/en/transcoder.php | 2 + tests/refresh-video-asset-harness.php | 24 +++++++++ 10 files changed, 124 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 735a8a8..fa9913e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ * 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. ## 4.0.2 - 2024.09.30 ## Added diff --git a/README.md b/README.md index 93eaaf6..cbe7de1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Transcode video & audio files to various formats, and provide video thumbnails - Generate named poster formats after queued encodes. - 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. All features are disabled by default except the original `options` filename strategy, so existing installations keep their current behavior. Configuration is available from the plugin settings screen or `config/transcoder.php`. diff --git a/docs/docs/configuring.md b/docs/docs/configuring.md index f1f1cbd..ec0a618 100644 --- a/docs/docs/configuring.md +++ b/docs/docs/configuring.md @@ -41,6 +41,21 @@ A working Craft queue runner is required. The queue job waits for ffmpeg to fini - `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. +### Subfolders for URL inputs + +Passing the actual Craft `Asset` to `getVideoUrl()` is preferred. With `createSubfolders` enabled, Transcoder uses the Asset’s `folderPath` for both the encoded filesystem path and public URL. + +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 the base video output directory. Transcoder normalizes path and URL separators, so configured transcoder paths no longer depend on a trailing slash for video output. + ## Watermarks Watermarking accepts a local path, Yii alias, environment value, or public image URL: diff --git a/docs/docs/using.md b/docs/docs/using.md index e7bd2f1..20d29e3 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 diff --git a/src/config.php b/src/config.php index aa2a82a..f66e67f 100644 --- a/src/config.php +++ b/src/config.php @@ -63,6 +63,9 @@ // 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, diff --git a/src/models/Settings.php b/src/models/Settings.php index 877d7a4..6c9a206 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -95,6 +95,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? * @@ -317,6 +320,7 @@ public function rules(): array ['enableDownloadFileEndpoint', 'boolean'], ['useHashedNames', 'boolean'], ['createSubfolders', 'boolean'], + ['subfolderUrlSegment', 'integer', 'min' => 1, 'skipOnEmpty' => true], ['clearCaches', 'boolean'], ['queueVideosOnAssetUpload', 'boolean'], ['videoQueueDelaySeconds', 'integer', 'min' => 0], diff --git a/src/services/Transcode.php b/src/services/Transcode.php index d8b2059..e040e0b 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -115,9 +115,7 @@ public function getVideoUrl( if ($outputInfo !== null) { $filePath = $outputInfo['sourcePath']; - $subfolder = $outputInfo['subfolder']; $destVideoPath = $outputInfo['directory']; - $destVideoFile = $outputInfo['filename']; $videoOptions = $outputInfo['videoOptions']; $thisEncoder = $outputInfo['encoder']; $watermarkPath = $outputInfo['watermarkPath']; @@ -217,10 +215,8 @@ public function getVideoUrl( // 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 = $this->getVersionedMediaUrl( - (string)App::parseEnv($url) . $destVideoFile, + $outputInfo['url'], $destVideoPath ); // skip encoding @@ -235,10 +231,8 @@ public function getVideoUrl( @unlink($progressFile); if (file_exists($destVideoPath) && filesize($destVideoPath) > 0) { - $url = $settings['transcoderUrls']['video'] ?? $settings['transcoderUrls']['default']; - $url .= $subfolder; $result = $this->getVersionedMediaUrl( - (string)App::parseEnv($url) . $destVideoFile, + $outputInfo['url'], $destVideoPath ); } else { @@ -1028,6 +1022,50 @@ protected function validateRefreshVideoAsset(Asset $asset): void } } + /** + * 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 = trim((string)$filePath->folderPath, '/\\'); + return $folderPath === '' ? '' : $folderPath . DIRECTORY_SEPARATOR; + } + + $segment = (int)$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 video output subfolder from a string input.', __METHOD__); + return ''; + } + + return $subfolder . DIRECTORY_SEPARATOR; + } + /** * Resolve every output value shared by video encoding and refresh cleanup. * @@ -1037,6 +1075,7 @@ protected function validateRefreshVideoAsset(Asset $asset): void * directory: string, * filename: string, * path: string, + * url: string, * lockFile: string, * progressFile: string, * videoOptions: array, @@ -1047,7 +1086,7 @@ protected function validateRefreshVideoAsset(Asset $asset): void protected function getVideoOutputInfo(Asset|string $filePath, array $videoOptions): ?array { $settings = Transcoder::$plugin->getSettings(); - $subfolder = $filePath instanceof Asset && $settings->createSubfolders ? $filePath->folderPath : ''; + $subfolder = $this->getSubfolderFromPath($filePath); $sourcePath = $this->getAssetPath($filePath); if ($sourcePath === '') { return null; @@ -1062,8 +1101,18 @@ protected function getVideoOutputInfo(Asset|string $filePath, array $videoOption $videoOptions['watermark'] = $this->getVideoWatermarkFingerprint($watermarkPath); } - $directory = $settings->transcoderPaths['video'] ?? $settings->transcoderPaths['default']; - $directory = (string)App::parseEnv($directory . $subfolder); + $directory = (string)App::parseEnv( + $settings->transcoderPaths['video'] ?? $settings->transcoderPaths['default'] + ); + $directory = rtrim($directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $urlDirectory = (string)App::parseEnv( + $settings->transcoderUrls['video'] ?? $settings->transcoderUrls['default'] + ); + $urlDirectory = rtrim($urlDirectory, '/') . '/'; + if ($subfolder !== '') { + $directory .= trim($subfolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $urlDirectory .= trim(str_replace('\\', '/', $subfolder), '/') . '/'; + } $filename = $this->getFilename( $sourcePath, $videoOptions, @@ -1076,6 +1125,7 @@ protected function getVideoOutputInfo(Asset|string $filePath, array $videoOption 'directory' => $directory, 'filename' => $filename, 'path' => $directory . $filename, + 'url' => $urlDirectory . $filename, 'lockFile' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename . '.lock', 'progressFile' => sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename . '.progress', 'videoOptions' => $videoOptions, diff --git a/src/templates/settings.twig b/src/templates/settings.twig index ade2c87..de04c93 100644 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -33,6 +33,17 @@ ], }) }} +{{ forms.textField({ + label: 'URL subfolder segment'|t('transcoder'), + instructions: 'For string URL/path inputs, use this one-based path segment as the output subfolder. Leave empty to disable it.'|t('transcoder'), + id: 'subfolderUrlSegment', + name: 'subfolderUrlSegment', + value: settings.subfolderUrlSegment ?: '', + type: 'number', + min: 1, + size: 5, +}) }} +

{{ 'Video posters'|t('transcoder') }}

diff --git a/src/translations/en/transcoder.php b/src/translations/en/transcoder.php index 84a406e..30301fe 100644 --- a/src/translations/en/transcoder.php +++ b/src/translations/en/transcoder.php @@ -24,6 +24,8 @@ '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.', diff --git a/tests/refresh-video-asset-harness.php b/tests/refresh-video-asset-harness.php index 274018c..b6b0ab8 100644 --- a/tests/refresh-video-asset-harness.php +++ b/tests/refresh-video-asset-harness.php @@ -344,6 +344,7 @@ function assertTrue(bool $condition, string $message): void 'videoQueueDelaySeconds' => 9, 'queuedVideoOptions' => [], 'createSubfolders' => true, + 'subfolderUrlSegment' => false, 'transcoderPaths' => [ 'default' => '@encoded/', 'video' => '@encoded/video/', @@ -442,6 +443,29 @@ function assertTrue(bool $condition, string $message): void assertSameValue($flat['path'], $service->refreshTargets($asset)['output'][0], 'Flat paths diverge.'); $settings->createSubfolders = true; + $settings->subfolderUrlSegment = 3; + $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; $result = $service->performVideoAssetRefresh($asset); assertSameValue(false, $settings->queueVideosOnAssetUpload, 'The regression must run with upload queueing disabled.'); From 6be8c2f123be13cb670a0a26ade57052ede05a6a Mon Sep 17 00:00:00 2001 From: Arjan Date: Sat, 15 Aug 2026 15:58:29 +0200 Subject: [PATCH 08/16] Organize video settings into tabs --- CHANGELOG.md | 4 + README.md | 2 +- src/Transcoder.php | 36 +++++ src/templates/settings.twig | 306 ++++++++++++++++++------------------ 4 files changed, 193 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9913e..90472c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Changed * Remove the `bufsize` parameter entirely from the FFMPEG default command, which was preventing `WebM` files from being generated properly ([#72](https://github.com/nystudio107/craft-transcoder/issues/72)) +### Changed + +* Organize video settings into separate queue, posters, and watermark tabs in the Craft control panel. + ### Added * Queue video encoding when new video assets are uploaded, with a configurable delay. diff --git a/README.md b/README.md index cbe7de1..019ae2d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Transcode video & audio files to various formats, and provide video thumbnails - 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. -All features are disabled by default except the original `options` filename strategy, so existing installations keep their current behavior. Configuration is available from the plugin settings screen or `config/transcoder.php`. +All features are disabled by default except the original `options` filename strategy, so existing installations keep their current behavior. Configuration is available from the plugin settings screen, organized into Video queue, Video posters, and Video watermark tabs, or `config/transcoder.php`. 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. diff --git a/src/Transcoder.php b/src/Transcoder.php index db24971..c68ae57 100644 --- a/src/Transcoder.php +++ b/src/Transcoder.php @@ -20,6 +20,7 @@ use craft\events\PluginEvent; use craft\events\RegisterCacheOptionsEvent; use craft\events\RegisterUrlRulesEvent; +use craft\events\TemplateEvent; use craft\helpers\Assets as AssetsHelper; use craft\helpers\FileHelper; use craft\helpers\UrlHelper; @@ -28,6 +29,7 @@ use craft\utilities\ClearCaches; use craft\web\twig\variables\CraftVariable; use craft\web\UrlManager; +use craft\web\View; use nystudio107\transcoder\models\Settings; use nystudio107\transcoder\jobs\EncodeVideo; use nystudio107\transcoder\services\ServicesTrait; @@ -101,6 +103,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( @@ -159,6 +163,38 @@ protected function settingsHtml(): ?string ]); } + /** + * Register Craft CP tabs for the plugin settings page. + */ + protected function registerSettingsTabs(): void + { + Event::on( + View::class, + View::EVENT_BEFORE_RENDER_TEMPLATE, + function(TemplateEvent $event) { + if ( + $event->template === 'settings/plugins/_settings.twig' + && ($event->variables['plugin']->handle ?? null) === $this->handle + ) { + $event->variables['tabs'] = [ + [ + 'label' => Craft::t('transcoder', 'Video queue'), + 'url' => '#settings-tab-video-queue', + ], + [ + 'label' => Craft::t('transcoder', 'Video posters'), + 'url' => '#settings-tab-video-posters', + ], + [ + 'label' => Craft::t('transcoder', 'Video watermark'), + 'url' => '#settings-tab-video-watermark', + ], + ]; + } + } + ); + } + /** * Add in our Craft components */ diff --git a/src/templates/settings.twig b/src/templates/settings.twig index de04c93..d6e161f 100644 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -1,157 +1,155 @@ {% import '_includes/forms' as forms %} -

{{ 'Video queue'|t('transcoder') }}

- -{{ forms.lightswitchField({ - label: 'Queue videos on asset upload'|t('transcoder'), - instructions: 'Add newly uploaded video assets to Craft’s queue for background encoding.'|t('transcoder'), - id: 'queueVideosOnAssetUpload', - name: 'queueVideosOnAssetUpload', - on: settings.queueVideosOnAssetUpload, -}) }} - -{{ forms.textField({ - label: 'Video queue delay'|t('transcoder'), - instructions: 'Seconds to wait before an uploaded video starts encoding.'|t('transcoder'), - id: 'videoQueueDelaySeconds', - name: 'videoQueueDelaySeconds', - value: settings.videoQueueDelaySeconds, - type: 'number', - min: 0, - size: 5, -}) }} - -{{ forms.selectField({ - label: 'Video filename strategy'|t('transcoder'), - instructions: 'Use the original option-based filename, or keep one stable filename per source asset.'|t('transcoder'), - id: 'videoFilenameStrategy', - name: 'videoFilenameStrategy', - value: settings.videoFilenameStrategy, - options: [ - { label: 'Encoding options'|t('transcoder'), value: 'options' }, - { label: 'Source asset'|t('transcoder'), value: 'source' }, - ], -}) }} - -{{ forms.textField({ - label: 'URL subfolder segment'|t('transcoder'), - instructions: 'For string URL/path inputs, use this one-based path segment as the output subfolder. Leave empty to disable it.'|t('transcoder'), - id: 'subfolderUrlSegment', - name: 'subfolderUrlSegment', - value: settings.subfolderUrlSegment ?: '', - type: 'number', - min: 1, - size: 5, -}) }} - -
-

{{ 'Video posters'|t('transcoder') }}

- -{{ forms.lightswitchField({ - label: 'Generate video posters'|t('transcoder'), - instructions: 'Generate the configured poster images after a queued video is encoded.'|t('transcoder'), - id: 'enableVideoPosters', - name: 'enableVideoPosters', - on: settings.enableVideoPosters, -}) }} - -{{ forms.lightswitchField({ - label: 'Prevent poster black bars'|t('transcoder'), - instructions: 'Fill unused poster space with a blurred cover image behind the fitted video frame.'|t('transcoder'), - id: 'preventVideoPosterBlackBars', - name: 'preventVideoPosterBlackBars', - on: settings.preventVideoPosterBlackBars, -}) }} - -{{ forms.editableTableField({ - label: 'Video poster formats'|t('transcoder'), - id: 'videoPosterFormats', - name: 'videoPosterFormats', - cols: { - handle: { - type: 'singleline', - heading: 'Handle'|t('transcoder'), - }, - width: { - type: 'number', - heading: 'Width'|t('transcoder'), +
+ {{ forms.lightswitchField({ + label: 'Queue videos on asset upload'|t('transcoder'), + instructions: 'Add newly uploaded video assets to Craft’s queue for background encoding.'|t('transcoder'), + id: 'queueVideosOnAssetUpload', + name: 'queueVideosOnAssetUpload', + on: settings.queueVideosOnAssetUpload, + }) }} + + {{ forms.textField({ + label: 'Video queue delay'|t('transcoder'), + instructions: 'Seconds to wait before an uploaded video starts encoding.'|t('transcoder'), + id: 'videoQueueDelaySeconds', + name: 'videoQueueDelaySeconds', + value: settings.videoQueueDelaySeconds, + type: 'number', + min: 0, + size: 5, + }) }} + + {{ forms.selectField({ + label: 'Video filename strategy'|t('transcoder'), + instructions: 'Use the original option-based filename, or keep one stable filename per source asset.'|t('transcoder'), + id: 'videoFilenameStrategy', + name: 'videoFilenameStrategy', + value: settings.videoFilenameStrategy, + options: [ + { label: 'Encoding options'|t('transcoder'), value: 'options' }, + { label: 'Source asset'|t('transcoder'), value: 'source' }, + ], + }) }} + + {{ forms.textField({ + label: 'URL subfolder segment'|t('transcoder'), + instructions: 'For string URL/path inputs, use this one-based path segment as the output subfolder. Leave empty to disable it.'|t('transcoder'), + id: 'subfolderUrlSegment', + name: 'subfolderUrlSegment', + value: settings.subfolderUrlSegment ?: '', + type: 'number', + min: 1, + size: 5, + }) }} +
+ + + + From 1c05114762086902168de24a6fbeaf8d6fe5cb14 Mon Sep 17 00:00:00 2001 From: Arjan Date: Sat, 15 Aug 2026 17:38:36 +0200 Subject: [PATCH 09/16] Support autosuggest video settings --- CHANGELOG.md | 1 + README.md | 2 +- docs/docs/configuring.md | 4 ++- src/Transcoder.php | 6 ++-- src/models/Settings.php | 46 ++++++++++++++++++++------- src/services/Transcode.php | 22 +++++++------ src/templates/settings.twig | 35 ++++++++++---------- tests/refresh-video-asset-harness.php | 27 +++++++++++++--- 8 files changed, 96 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90472c1..b821c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Changed * Organize video settings into separate queue, posters, and watermark tabs in the Craft control panel. +* Use Craft autosuggest fields for queue, subfolder, and watermark values, including environment-variable support. ### Added diff --git a/README.md b/README.md index 019ae2d..37e1dc3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Transcode video & audio files to various formats, and provide video thumbnails - 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. -All features are disabled by default except the original `options` filename strategy, so existing installations keep their current behavior. Configuration is available from the plugin settings screen, organized into Video queue, Video posters, and Video watermark tabs, or `config/transcoder.php`. +All features are disabled by default except the original `options` filename strategy, so existing installations keep their current behavior. Configuration is available from the plugin settings screen, organized into Video queue, Video posters, and Video watermark tabs, or `config/transcoder.php`. Text and numeric fields in these tabs support Craft environment-variable autosuggestions; the watermark path also supports aliases. Third-party plugins that intentionally replace a video asset can call `Transcoder::$plugin->getTranscode()->refreshVideoAsset($asset)`. Cleanup and re-encoding run asynchronously through Craft’s queue; see the usage documentation for the integration contract. diff --git a/docs/docs/configuring.md b/docs/docs/configuring.md index ec0a618..7c7f110 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 video 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 and Video watermark tabs use Craft autosuggest fields for values that can come from environment variables. The watermark path also suggests Yii aliases. Numeric environment variables must resolve to integers within the same limits shown by their literal values. You will also need [ffmpeg](https://ffmpeg.org/) installed for Transcoder to work. On Ubuntu 16.04, you can do just: diff --git a/src/Transcoder.php b/src/Transcoder.php index c68ae57..ce5bdf8 100644 --- a/src/Transcoder.php +++ b/src/Transcoder.php @@ -21,6 +21,7 @@ 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; @@ -268,8 +269,9 @@ function(ModelEvent $event) use ($settings) { } $queue = Craft::$app->getQueue(); - if ($settings->videoQueueDelaySeconds > 0) { - $queue = $queue->delay($settings->videoQueueDelaySeconds); + $queueDelay = max(0, (int)App::parseEnv((string)$settings->videoQueueDelaySeconds)); + if ($queueDelay > 0) { + $queue = $queue->delay($queueDelay); } $queue->push(new EncodeVideo([ diff --git a/src/models/Settings.php b/src/models/Settings.php index 6c9a206..65bf15a 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 @@ -108,8 +110,8 @@ class Settings extends Model /** @var bool Queue video encoding when a new video asset is uploaded. */ public bool $queueVideosOnAssetUpload = false; - /** @var int Seconds to wait before an uploaded video starts encoding. */ - public int $videoQueueDelaySeconds = 0; + /** @var int|string Seconds to wait before an uploaded video starts encoding. */ + public int|string $videoQueueDelaySeconds = 0; /** @var array Options passed to queued video encodes. */ public array $queuedVideoOptions = []; @@ -129,11 +131,11 @@ class Settings extends Model /** @var string Watermark position. */ public string $videoWatermarkPosition = 'bottom-right'; - /** @var int Watermark distance from the selected edges in pixels. */ - public int $videoWatermarkPadding = 24; + /** @var int|string Watermark distance from the selected edges in pixels. */ + public int|string $videoWatermarkPadding = 24; - /** @var int Watermark opacity percentage. */ - public int $videoWatermarkOpacity = 100; + /** @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; @@ -320,18 +322,18 @@ public function rules(): array ['enableDownloadFileEndpoint', 'boolean'], ['useHashedNames', 'boolean'], ['createSubfolders', 'boolean'], - ['subfolderUrlSegment', 'integer', 'min' => 1, 'skipOnEmpty' => true], + ['subfolderUrlSegment', 'validateIntegerSetting', 'params' => ['min' => 1], 'skipOnEmpty' => true], ['clearCaches', 'boolean'], ['queueVideosOnAssetUpload', 'boolean'], - ['videoQueueDelaySeconds', 'integer', 'min' => 0], + ['videoQueueDelaySeconds', 'validateIntegerSetting', 'params' => ['min' => 0]], ['queuedVideoOptions', ArrayValidator::class], ['videoFilenameStrategy', 'in', 'range' => ['options', 'source']], ['enableVideoWatermark', 'boolean'], ['videoWatermarkPath', 'string'], - ['videoWatermarkWidth', 'safe'], + ['videoWatermarkWidth', 'validateIntegerSetting', 'params' => ['min' => 1], 'skipOnEmpty' => true], ['videoWatermarkPosition', 'in', 'range' => ['top-left', 'top-right', 'bottom-left', 'bottom-right']], - ['videoWatermarkPadding', 'integer', 'min' => 0], - ['videoWatermarkOpacity', 'integer', 'min' => 0, 'max' => 100], + ['videoWatermarkPadding', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['videoWatermarkOpacity', 'validateIntegerSetting', 'params' => ['min' => 0, 'max' => 100]], ['enableVideoPosters', 'boolean'], ['preventVideoPosterBlackBars', 'boolean'], ['videoPosterFormats', ArrayValidator::class], @@ -343,6 +345,28 @@ public function rules(): array ]; } + /** + * 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. */ diff --git a/src/services/Transcode.php b/src/services/Transcode.php index e040e0b..484412f 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -480,8 +480,9 @@ public function performVideoAssetRefresh(Asset $asset): array $settings = Transcoder::$plugin->getSettings(); $queue = Craft::$app->getQueue(); - if ($settings->videoQueueDelaySeconds > 0) { - $queue = $queue->delay($settings->videoQueueDelaySeconds); + $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, @@ -1033,7 +1034,7 @@ protected function getSubfolderFromPath(Asset|string $filePath): string return $folderPath === '' ? '' : $folderPath . DIRECTORY_SEPARATOR; } - $segment = (int)$settings->subfolderUrlSegment; + $segment = (int)App::parseEnv((string)$settings->subfolderUrlSegment); if ($segment < 1 || !is_string($filePath)) { return ''; } @@ -1294,10 +1295,10 @@ protected function getVideoWatermarkFingerprint(string $path): string return substr(sha1(JsonHelper::encode([ $path, - $settings->videoWatermarkWidth, + App::parseEnv((string)$settings->videoWatermarkWidth), $settings->videoWatermarkPosition, - $settings->videoWatermarkPadding, - $settings->videoWatermarkOpacity, + App::parseEnv((string)$settings->videoWatermarkPadding), + App::parseEnv((string)$settings->videoWatermarkOpacity), ])), 0, 10); } @@ -1310,13 +1311,14 @@ protected function getVideoWatermarkFilter(array $videoOptions): string $baseFilter = $this->getScalingFilter($videoOptions) ?? 'null'; $watermarkFilters = ['format=rgba']; - $width = (int)$settings->videoWatermarkWidth; + $width = max(0, (int)App::parseEnv((string)$settings->videoWatermarkWidth)); if ($width > 0) { $watermarkFilters[] = "scale=$width:-1"; } - if ($settings->videoWatermarkOpacity < 100) { - $opacity = max(0, $settings->videoWatermarkOpacity) / 100; + $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'), '.'); } @@ -1333,7 +1335,7 @@ protected function getVideoWatermarkFilter(array $videoOptions): string protected function getVideoWatermarkPosition(): array { $settings = Transcoder::$plugin->getSettings(); - $padding = max(0, $settings->videoWatermarkPadding); + $padding = max(0, (int)App::parseEnv((string)$settings->videoWatermarkPadding)); return match ($settings->videoWatermarkPosition) { 'top-left' => [(string)$padding, (string)$padding], diff --git a/src/templates/settings.twig b/src/templates/settings.twig index d6e161f..a6ddb0e 100644 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -9,14 +9,14 @@ on: settings.queueVideosOnAssetUpload, }) }} - {{ forms.textField({ + {{ forms.autosuggestField({ label: 'Video queue delay'|t('transcoder'), instructions: 'Seconds to wait before an uploaded video starts encoding.'|t('transcoder'), id: 'videoQueueDelaySeconds', name: 'videoQueueDelaySeconds', value: settings.videoQueueDelaySeconds, - type: 'number', - min: 0, + suggestEnvVars: true, + inputAttributes: { inputmode: 'numeric' }, size: 5, }) }} @@ -32,14 +32,14 @@ ], }) }} - {{ forms.textField({ + {{ forms.autosuggestField({ label: 'URL subfolder segment'|t('transcoder'), instructions: 'For string URL/path inputs, use this one-based path segment as the output subfolder. Leave empty to disable it.'|t('transcoder'), id: 'subfolderUrlSegment', name: 'subfolderUrlSegment', value: settings.subfolderUrlSegment ?: '', - type: 'number', - min: 1, + suggestEnvVars: true, + inputAttributes: { inputmode: 'numeric' }, size: 5, }) }} @@ -99,22 +99,24 @@ on: settings.enableVideoWatermark, }) }} - {{ forms.textField({ + {{ forms.autosuggestField({ label: 'Watermark path or URL'|t('transcoder'), instructions: 'Use a local path, Yii alias, environment value, or public image URL.'|t('transcoder'), id: 'videoWatermarkPath', name: 'videoWatermarkPath', value: settings.videoWatermarkPath, + suggestEnvVars: true, + suggestAliases: true, }) }} - {{ forms.textField({ + {{ forms.autosuggestField({ label: 'Watermark width'|t('transcoder'), instructions: 'Optional width in pixels. Leave empty to keep the original size.'|t('transcoder'), id: 'videoWatermarkWidth', name: 'videoWatermarkWidth', value: settings.videoWatermarkWidth, - type: 'number', - min: 1, + suggestEnvVars: true, + inputAttributes: { inputmode: 'numeric' }, size: 6, }) }} @@ -131,25 +133,24 @@ ], }) }} - {{ forms.textField({ + {{ forms.autosuggestField({ label: 'Watermark padding'|t('transcoder'), id: 'videoWatermarkPadding', name: 'videoWatermarkPadding', value: settings.videoWatermarkPadding, - type: 'number', - min: 0, + suggestEnvVars: true, + inputAttributes: { inputmode: 'numeric' }, size: 6, }) }} - {{ forms.textField({ + {{ forms.autosuggestField({ label: 'Watermark opacity'|t('transcoder'), instructions: 'Percentage from 0 to 100.'|t('transcoder'), id: 'videoWatermarkOpacity', name: 'videoWatermarkOpacity', value: settings.videoWatermarkOpacity, - type: 'number', - min: 0, - max: 100, + suggestEnvVars: true, + inputAttributes: { inputmode: 'numeric' }, size: 6, }) }} diff --git a/tests/refresh-video-asset-harness.php b/tests/refresh-video-asset-harness.php index b6b0ab8..edf1b91 100644 --- a/tests/refresh-video-asset-harness.php +++ b/tests/refresh-video-asset-harness.php @@ -89,6 +89,11 @@ 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; } @@ -325,6 +330,12 @@ function assertTrue(bool $condition, string $message): void mkdir($encodedDirectory, 0777, true); try { + putenv('TRANSCODER_QUEUE_DELAY=9'); + putenv('TRANSCODER_SUBFOLDER_SEGMENT=3'); + putenv('TRANSCODER_WATERMARK_WIDTH=180'); + 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')); @@ -341,7 +352,7 @@ function assertTrue(bool $condition, string $message): void Craft::$aliases['@encoded'] = $root . '/content/encoded'; $settings = new HarnessSettings([ 'queueVideosOnAssetUpload' => false, - 'videoQueueDelaySeconds' => 9, + 'videoQueueDelaySeconds' => '$TRANSCODER_QUEUE_DELAY', 'queuedVideoOptions' => [], 'createSubfolders' => true, 'subfolderUrlSegment' => false, @@ -378,10 +389,10 @@ function assertTrue(bool $condition, string $message): void 'useHashedNames' => false, 'enableVideoWatermark' => false, 'videoWatermarkPath' => '', - 'videoWatermarkWidth' => '', + 'videoWatermarkWidth' => '$TRANSCODER_WATERMARK_WIDTH', 'videoWatermarkPosition' => 'bottom-right', - 'videoWatermarkPadding' => 24, - 'videoWatermarkOpacity' => 100, + 'videoWatermarkPadding' => '$TRANSCODER_WATERMARK_PADDING', + 'videoWatermarkOpacity' => '$TRANSCODER_WATERMARK_OPACITY', 'enableVideoPosters' => false, 'ffmpegPath' => $ffmpegBinary, 'transcoderUrls' => [ @@ -443,7 +454,7 @@ function assertTrue(bool $condition, string $message): void assertSameValue($flat['path'], $service->refreshTargets($asset)['output'][0], 'Flat paths diverge.'); $settings->createSubfolders = true; - $settings->subfolderUrlSegment = 3; + $settings->subfolderUrlSegment = '$TRANSCODER_SUBFOLDER_SEGMENT'; $paths = $settings->transcoderPaths; $paths['video'] = '@encoded/video'; $settings->transcoderPaths = $paths; @@ -528,6 +539,12 @@ function assertTrue(bool $condition, string $message): void 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'); + if (is_dir($root)) { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), From 800f35fcb37e5e30de20511028457c2d80706695 Mon Sep 17 00:00:00 2001 From: Arjan Date: Sat, 15 Aug 2026 20:38:40 +0200 Subject: [PATCH 10/16] Queue GIF encoding on asset upload --- CHANGELOG.md | 1 + README.md | 1 + docs/docs/configuring.md | 16 ++++++ docs/docs/using.md | 4 ++ src/Transcoder.php | 61 +++++++++++++++++----- src/config.php | 9 ++++ src/jobs/EncodeGif.php | 52 +++++++++++++++++++ src/models/Settings.php | 12 +++++ src/services/Transcode.php | 75 +++++++++++++++++++-------- src/templates/settings.twig | 21 ++++++++ src/translations/en/transcoder.php | 5 ++ tests/refresh-video-asset-harness.php | 74 +++++++++++++++++++++++++- 12 files changed, 296 insertions(+), 35 deletions(-) create mode 100644 src/jobs/EncodeGif.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b821c71..75fd327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### 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. * 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. diff --git a/README.md b/README.md index 37e1dc3..822e275 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Transcode video & audio files to various formats, and provide video thumbnails ## Video workflow features - Queue newly uploaded video assets through Craft’s queue. +- Queue newly uploaded GIF assets through Craft’s queue. - Delay queued encodes when other asset-save handlers need time to finish. - Keep the original option-based filenames, including bitrate, or opt into stable source-asset filenames. - Overlay a configurable watermark on encoded videos. diff --git a/docs/docs/configuring.md b/docs/docs/configuring.md index 7c7f110..34ca6f1 100644 --- a/docs/docs/configuring.md +++ b/docs/docs/configuring.md @@ -38,6 +38,22 @@ return [ A working Craft queue runner is required. The queue job waits for ffmpeg to finish, so Craft can report failures and retry the job through the configured queue driver. +## 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. + `videoFilenameStrategy` supports: - `options` (default): preserves Transcoder’s original parameterized filenames. Output-affecting options such as `videoBitRate` remain part of the filename. diff --git a/docs/docs/using.md b/docs/docs/using.md index 20d29e3..b959e6f 100644 --- a/docs/docs/using.md +++ b/docs/docs/using.md @@ -189,6 +189,10 @@ 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 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: diff --git a/src/Transcoder.php b/src/Transcoder.php index ce5bdf8..88943da 100644 --- a/src/Transcoder.php +++ b/src/Transcoder.php @@ -31,8 +31,9 @@ use craft\web\twig\variables\CraftVariable; use craft\web\UrlManager; use craft\web\View; -use nystudio107\transcoder\models\Settings; +use nystudio107\transcoder\jobs\EncodeGif; use nystudio107\transcoder\jobs\EncodeVideo; +use nystudio107\transcoder\models\Settings; use nystudio107\transcoder\services\ServicesTrait; use nystudio107\transcoder\variables\TranscoderVariable; use yii\base\ErrorException; @@ -182,6 +183,10 @@ function(TemplateEvent $event) { 'label' => Craft::t('transcoder', 'Video queue'), 'url' => '#settings-tab-video-queue', ], + [ + 'label' => Craft::t('transcoder', 'GIF queue'), + 'url' => '#settings-tab-gif-queue', + ], [ 'label' => Craft::t('transcoder', 'Video posters'), 'url' => '#settings-tab-video-posters', @@ -254,7 +259,7 @@ function(RegisterCacheOptionsEvent $event) { } ); } - if ($settings->queueVideosOnAssetUpload) { + if ($settings->queueVideosOnAssetUpload || $settings->queueGifsOnAssetUpload) { Event::on( Asset::class, Asset::EVENT_AFTER_SAVE, @@ -264,20 +269,32 @@ function(ModelEvent $event) use ($settings) { return; } - if (AssetsHelper::getFileKindByExtension($asset->filename) !== Asset::KIND_VIDEO) { + $isGif = strtolower(pathinfo($asset->filename, PATHINFO_EXTENSION)) === 'gif'; + $kind = AssetsHelper::getFileKindByExtension($asset->filename); + if ($isGif && $settings->queueGifsOnAssetUpload) { + $this->queueUploadedMedia( + new EncodeGif([ + 'assetId' => $asset->id, + 'gifOptions' => $settings->queuedGifOptions, + ]), + $settings->gifQueueDelaySeconds, + 'GIF', + (int)$asset->id + ); return; } - $queue = Craft::$app->getQueue(); - $queueDelay = max(0, (int)App::parseEnv((string)$settings->videoQueueDelaySeconds)); - if ($queueDelay > 0) { - $queue = $queue->delay($queueDelay); + if ($kind === Asset::KIND_VIDEO && $settings->queueVideosOnAssetUpload) { + $this->queueUploadedMedia( + new EncodeVideo([ + 'assetId' => $asset->id, + 'videoOptions' => $settings->queuedVideoOptions, + ]), + $settings->videoQueueDelaySeconds, + 'video', + (int)$asset->id + ); } - - $queue->push(new EncodeVideo([ - 'assetId' => $asset->id, - 'videoOptions' => $settings->queuedVideoOptions, - ])); } ); } @@ -324,6 +341,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 f66e67f..591b7b9 100644 --- a/src/config.php +++ b/src/config.php @@ -78,6 +78,15 @@ // 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' => [], + // How encoded video filenames are generated: options or source 'videoFilenameStrategy' => 'options', 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/models/Settings.php b/src/models/Settings.php index 65bf15a..809a3d4 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -116,6 +116,15 @@ class Settings extends Model /** @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 string How encoded video filenames are generated: options or source. */ public string $videoFilenameStrategy = 'options'; @@ -327,6 +336,9 @@ public function rules(): array ['queueVideosOnAssetUpload', 'boolean'], ['videoQueueDelaySeconds', 'validateIntegerSetting', 'params' => ['min' => 0]], ['queuedVideoOptions', ArrayValidator::class], + ['queueGifsOnAssetUpload', 'boolean'], + ['gifQueueDelaySeconds', 'validateIntegerSetting', 'params' => ['min' => 0]], + ['queuedGifOptions', ArrayValidator::class], ['videoFilenameStrategy', 'in', 'range' => ['options', 'source']], ['enableVideoWatermark', 'boolean'], ['videoWatermarkPath', 'string'], diff --git a/src/services/Transcode.php b/src/services/Transcode.php index 484412f..bab4528 100644 --- a/src/services/Transcode.php +++ b/src/services/Transcode.php @@ -514,15 +514,33 @@ public function performVideoAssetRefresh(Asset $asset): array * @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 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-video-asset-' . (int)$asset->id . '.lock'; + $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 video asset lock.'); + throw new RuntimeException("Unable to create the Transcoder $mediaType asset lock."); } if (!flock($handle, LOCK_EX | LOCK_NB)) { @@ -845,12 +863,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(); @@ -901,37 +920,51 @@ public function getGifUrl(Asset|string $filePath, array $gifOptions): string|fal // Assemble the destination path and final ffmpeg command $destVideoPath .= $destVideoFile; - $ffmpegCmd .= ' ' - . ' -y ' . escapeshellarg($destVideoPath) - . ' 1> ' . $progressFile . ' 2>&1 & echo $!'; + $ffmpegCmd .= ' -y ' . escapeshellarg($destVideoPath); + if (!$synchronous) { + $ffmpegCmd .= ' 1> ' . $progressFile . ' 2>&1 & echo $!'; + } - // Make sure there isn't a lockfile for this video already + // Make sure there isn't a lockfile for this GIF already $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $destVideoFile . '.lock'; - $oldPid = @file_get_contents($lockFile); - if ($oldPid !== false) { - // See if the process is running, and empty result means the process is still running - // ref: https://stackoverflow.com/questions/3043978/how-to-check-if-a-process-id-pid-exists - exec("kill -0 $oldPid 2>&1", $ProcessState); - if (count($ProcessState) === 0) { - return $result; + if (!$synchronous) { + $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; + } + // 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 the video file already exists and hasn't been modified, return it. Otherwise, start it transcoding + // If the GIF output already exists and hasn't been modified, return it. Otherwise, start transcoding. if (file_exists($destVideoPath) && (@filemtime($destVideoPath) >= @filemtime($filePath))) { $url = $settings['transcoderUrls']['gif'] ?? $settings['transcoderUrls']['default']; $url .= $subfolder; $result = App::parseEnv($url) . $destVideoFile; } else { // Kick off the transcoding - $pid = $this->executeShellCommand($ffmpegCmd); - Craft::info($ffmpegCmd . "\nffmpeg PID: " . $pid, __METHOD__); + $output = $this->executeShellCommand($ffmpegCmd); + if ($synchronous) { + if (file_exists($destVideoPath) && filesize($destVideoPath) > 0) { + $url = $settings['transcoderUrls']['gif'] ?? $settings['transcoderUrls']['default']; + $url .= $subfolder; + return App::parseEnv($url) . $destVideoFile; + } + + Craft::error("GIF encoding failed: $output", __METHOD__); + return ''; + } + + Craft::info($ffmpegCmd . "\nffmpeg PID: " . $output, __METHOD__); // Create a lockfile in tmp - file_put_contents($lockFile, $pid); + file_put_contents($lockFile, $output); } } diff --git a/src/templates/settings.twig b/src/templates/settings.twig index a6ddb0e..d4986bb 100644 --- a/src/templates/settings.twig +++ b/src/templates/settings.twig @@ -44,6 +44,27 @@ }) }} + + + +