diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php index 402bddd0d48d87f5c4e18dc15973421107c855cb..77d4c0dae2454a52175b2be3e8f13b9989f58348 100644 --- a/app/Entities/Clip/BaseClip.php +++ b/app/Entities/Clip/BaseClip.php @@ -12,6 +12,7 @@ namespace App\Entities\Clip; use App\Entities\Episode; use App\Entities\Media\Audio; +use App\Entities\Media\BaseMedia; use App\Entities\Media\Video; use App\Entities\Podcast; use App\Models\EpisodeModel; @@ -44,6 +45,11 @@ use Modules\Auth\Entities\User; */ class BaseClip extends Entity { + /** + * @var BaseMedia + */ + protected $media = null; + /** * @var array<string, string> */ @@ -122,12 +128,16 @@ class BaseClip extends Entity return $this; } - public function getMedia(): Audio | Video + /** + * @noRector ReturnTypeDeclarationRector + */ + public function getMedia(): Audio | Video | null { if ($this->media_id !== null && $this->media === null) { $this->media = (new MediaModel($this->type))->getMediaById($this->media_id); } + // @phpstan-ignore-next-line return $this->media; } } diff --git a/app/Entities/Clip/VideoClip.php b/app/Entities/Clip/VideoClip.php index 0e5fa5a2861e9d760c70a31bfa9f9c0d1e0cfc98..0cbf9b9d27df4e371b28bb61db75b989e55f2de1 100644 --- a/app/Entities/Clip/VideoClip.php +++ b/app/Entities/Clip/VideoClip.php @@ -69,7 +69,8 @@ class VideoClip extends BaseClip return $this; } - $file = new File($filePath); + helper('media'); + $file = new File(media_path($filePath)); $video = new Video([ 'file_path' => $filePath, diff --git a/app/Libraries/MediaClipper/VideoClipper.php b/app/Libraries/MediaClipper/VideoClipper.php index 9614c6f1b4e5e1ee74b910e41e5911d8f7e89203..6fd9b541771b639ee16e7714e8eb35c36ef7c354 100644 --- a/app/Libraries/MediaClipper/VideoClipper.php +++ b/app/Libraries/MediaClipper/VideoClipper.php @@ -35,7 +35,9 @@ class VideoClipper public bool $error = false; - public string $videoClipOutput; + public string $videoClipFilePath; + + protected string $videoClipOutput; protected float $duration; @@ -95,6 +97,7 @@ class VideoClipper $this->subtitlesClipOutput = $podcastFolder . "/{$this->episode->slug}-subtitles-clip-{$this->start}-to-{$this->end}.srt"; $this->videoClipBgOutput = $podcastFolder . "/{$this->episode->slug}-clip-bg-{$this->format}-{$this->theme}.png"; $this->videoClipOutput = $podcastFolder . "/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}-{$this->format}-{$this->theme}.mp4"; + $this->videoClipFilePath = "podcasts/{$this->episode->podcast->handle}/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}-{$this->format}-{$this->theme}.mp4"; } public function soundbite(): void @@ -152,6 +155,7 @@ class VideoClipper "color=0x{$this->colors['watermarkBg']}:{$this->dimensions['watermark']['width']}x{$this->dimensions['watermark']['height']}[over]", '[over][watermark]overlay=x=0:y=0:shortest=1[watermark_box]', "[outv][watermark_box]overlay=x={$this->dimensions['watermark']['x']}:y={$this->dimensions['watermark']['y']}:shortest=1[watermarked]", + '[watermarked]scale=w=-1:h=-1:out_color_matrix=bt709[outfinal]', ]; $watermark = config('MediaClipper') @@ -167,10 +171,10 @@ class VideoClipper "-f lavfi -i color=white:{$this->dimensions['width']}x{$this->dimensions['height']}", "-loop 1 -framerate 1 -i {$watermark}", '-filter_complex "' . implode(';', $filters) . '"', - '-map "[watermarked]"', + '-map "[outfinal]"', '-map 0:a', '-acodec copy', - '-vcodec libx264rgb', + '-vcodec libx264 -pix_fmt yuv420p', "{$this->videoClipOutput}", ]; diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php index 93cd2fb29a629a9805d403739210121a5a8cd2be..e61e6fc5f4dbd380d86b0c4d9a677243f2476517 100644 --- a/app/Models/ClipModel.php +++ b/app/Models/ClipModel.php @@ -114,6 +114,26 @@ class ClipModel extends Model return $found; } + public function getVideoClipById(int $videoClipId): ?VideoClip + { + $cacheName = "video-clip#{$videoClipId}"; + if (! ($found = cache($cacheName))) { + $clip = $this->find($videoClipId); + + if ($clip === null) { + return null; + } + + // @phpstan-ignore-next-line + $found = new VideoClip($clip->toArray()); + + cache() + ->save($cacheName, $found, DECADE); + } + + return $found; + } + /** * Gets all video clips for an episode * diff --git a/app/Views/Components/Alert.php b/app/Views/Components/Alert.php index 7f6b9cbfad519a3947983f3de23b761026570959..41cf3bc51faf9122637914fa9e8bc9f651d0a5f2 100644 --- a/app/Views/Components/Alert.php +++ b/app/Views/Components/Alert.php @@ -21,7 +21,7 @@ class Alert extends Component { $variantClasses = [ 'default' => 'text-gray-800 bg-gray-100 border-gray-300', - 'success' => 'text-pine-900 bg-pine-100 border-castopod-300', + 'success' => 'text-pine-900 bg-pine-100 border-pine-300', 'danger' => 'text-red-900 bg-red-100 border-red-300', 'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300', ]; diff --git a/app/Views/Components/Pill.php b/app/Views/Components/Pill.php index ead392253538a70685a0935254cda64354d19b24..b5927e658e62f6f9c5e07984eaad36375b5eeaec 100644 --- a/app/Views/Components/Pill.php +++ b/app/Views/Components/Pill.php @@ -22,7 +22,7 @@ class Pill extends Component $variantClasses = [ 'default' => 'text-gray-800 bg-gray-100 border-gray-300', 'primary' => 'text-accent-contrast bg-accent-base border-accent-base', - 'success' => 'text-pine-900 bg-pine-100 border-castopod-300', + 'success' => 'text-pine-900 bg-pine-100 border-pine-300', 'danger' => 'text-red-900 bg-red-100 border-red-300', 'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300', ]; @@ -30,7 +30,7 @@ class Pill extends Component $icon = $this->icon ? icon($this->icon) : ''; return <<<HTML - <span class="inline-flex items-center gap-x-1 px-1 font-semibold border rounded {$variantClasses[$this->variant]}">{$icon}{$this->slot}</span> + <span class="inline-flex items-center gap-x-1 px-1 font-semibold text-sm border rounded {$variantClasses[$this->variant]}">{$icon}{$this->slot}</span> HTML; } } diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index 66bb739b8071be924a2583181ac82fd773124489..b32a81748d399143c6f7869ac71013e62e62d292 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -379,6 +379,14 @@ $routes->group( 'filter' => 'permission:podcast_episodes-edit', ], ); + $routes->get( + 'video-clips/(:num)', + 'VideoClipsController::view/$1/$2/$3', + [ + 'as' => 'video-clip', + 'filter' => 'permission:podcast_episodes-edit', + ], + ); $routes->get( 'embed', 'EpisodeController::embed/$1/$2', diff --git a/modules/Admin/Controllers/SchedulerController.php b/modules/Admin/Controllers/SchedulerController.php index 7ff4d0d3a90419976ad29d27a1bb89cff07daf9a..043451798558493c0ac2e4e59b80615c9702cb2b 100644 --- a/modules/Admin/Controllers/SchedulerController.php +++ b/modules/Admin/Controllers/SchedulerController.php @@ -53,7 +53,7 @@ class SchedulerController extends Controller if ($exitCode === 0) { // success, video was generated - $scheduledClip->setMedia($clipper->videoClipOutput); + $scheduledClip->setMedia($clipper->videoClipFilePath); (new ClipModel())->update($scheduledClip->id, [ 'media_id' => $scheduledClip->media_id, 'status' => 'passed', diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php index 4c70f72cfe2c4b2b68a22a0762eb73f1950ea160..f75849bdea7a232a20e4aac9703df99ffcd4cbe2 100644 --- a/modules/Admin/Controllers/VideoClipsController.php +++ b/modules/Admin/Controllers/VideoClipsController.php @@ -87,6 +87,24 @@ class VideoClipsController extends BaseController return view('episode/video_clips_list', $data); } + public function view($videoClipId): string + { + $videoClip = (new ClipModel())->getVideoClipById((int) $videoClipId); + + $data = [ + 'podcast' => $this->podcast, + 'episode' => $this->episode, + 'videoClip' => $videoClip, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => $this->episode->title, + 2 => $videoClip->label, + ]); + return view('episode/video_clip', $data); + } + public function generate(): string { helper('form'); diff --git a/themes/cp_admin/episode/video_clip.php b/themes/cp_admin/episode/video_clip.php new file mode 100644 index 0000000000000000000000000000000000000000..dad7a8ce7569cd9cd32cec79c94aaab85b004f1c --- /dev/null +++ b/themes/cp_admin/episode/video_clip.php @@ -0,0 +1,31 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Episode.video_clips.title', [ + 'videoClipLabel' => $videoClip->label, +]) ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Episode.video_clips.title', [ + 'videoClipLabel' => $videoClip->label, +]) ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<?php if ($videoClip->media): ?> + <video controls class="bg-black h-80 aspect-video"> + <source src="<?= $videoClip->media->file_url ?>" type="<?= $videoClip->media->file_mimetype ?>"> + Your browser does not support the video tag. + </video> +<?php endif; ?> + +<?php if ($videoClip->logs): ?> +<details class="w-full mt-8 overflow-hidden text-white bg-black border rounded shadow-sm"> + <summary class="px-4 py-2 font-semibold text-black bg-white"><?= lang('VideoClip.logs') ?></summary> + <pre class="p-4 text-sm whitespace-pre-wrap"><?= $videoClip->logs ?></pre> +</details> +<?php endif; ?> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php index 75a98bb513bd1777d7f2da1ad66fc5748ff665cd..71a5d0f65d782678c34059b6959a4b4b8d280e1f 100644 --- a/themes/cp_admin/episode/video_clips_list.php +++ b/themes/cp_admin/episode/video_clips_list.php @@ -39,17 +39,17 @@ 'header' => lang('VideoClip.list.label'), 'cell' => function ($videoClip): string { $formatClass = [ - 'landscape' => 'aspect-video h-4', - 'portrait' => 'aspect-[9/16] w-4', - 'squared' => 'aspect-square h-6', + 'landscape' => 'aspect-video', + 'portrait' => 'aspect-[9/16]', + 'squared' => 'aspect-square', ]; - return '<a href="#" class="inline-flex items-center w-full hover:underline gap-x-2"><span class="block w-3 h-3 rounded-full" data-tooltip="bottom" title="' . $videoClip->theme['name'] . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><span class="flex items-center justify-center text-white bg-gray-400 rounded-sm ' . $formatClass[$videoClip->format] . '" data-tooltip="bottom" title="' . $videoClip->format . '"><Icon glyph="play"/></span>' . $videoClip->label . '</a>'; + return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="inline-flex items-center font-semibold hover:underline gap-x-2 focus:ring-accent"><div class="relative"><span class="absolute block w-3 h-3 rounded-full -bottom-1 -left-1" data-tooltip="bottom" title="' . $videoClip->theme['name'] . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><div class="flex items-center justify-center h-6 overflow-hidden bg-black rounded-sm aspect-video" data-tooltip="bottom" title="' . $videoClip->format . '"><span class="flex items-center justify-center h-full text-white bg-gray-400 ' . $formatClass[$videoClip->format] . '"><Icon glyph="play"/></span></div></div>' . $videoClip->label . '</a>'; }, ], [ 'header' => lang('VideoClip.list.clip_id'), 'cell' => function ($videoClip): string { - return '#' . $videoClip->id . ' by ' . $videoClip->user->username; + return '<a href="' . route_to('video-clip', $videoClip->podcast_id, $videoClip->episode_id, $videoClip->id) . '" class="font-semibold hover:underline focus:ring-accent">#' . $videoClip->id . '</a><span class="ml-1 text-sm">by ' . $videoClip->user->username . '</span>'; }, ], [