Commit 42538dd7 authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat(video-clip): add video-clip page with video preview + logs

parent 2065ebbe
......@@ -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;
}
}
......@@ -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,
......
......@@ -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}",
];
......
......@@ -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
*
......
......@@ -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',
];
......
......@@ -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;
}
}
......@@ -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',
......
......@@ -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',
......
......@@ -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');
......
<?= $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() ?>
......@@ -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>';
},
],
[
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment