diff --git a/Dockerfile b/Dockerfile index 21a0571b9069e0310f8ea2686e1cc8c3e86e37af..723d1bc2ece1a2310cda5bebaa06d4e14b142281 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ RUN apt-get update \ && apt-get update \ && apt-get install --yes --no-install-recommends nodejs \ # update npm - && npm install --global npm@7 \ + && npm install --global npm@8 \ && apt-get update \ && apt-get install --yes --no-install-recommends \ git \ @@ -39,13 +39,16 @@ RUN apt-get update \ libpng-dev \ libwebp-dev \ libjpeg-dev \ + libfreetype6-dev \ zlib1g-dev \ libzip-dev \ + # ffmpeg for video encoding + ffmpeg \ # intl for Internationalization && docker-php-ext-install intl \ && docker-php-ext-install zip \ # gd for image processing - && docker-php-ext-configure gd --with-webp --with-jpeg \ + && docker-php-ext-configure gd --with-webp --with-jpeg --with-freetype \ && docker-php-ext-install gd \ # redis extension for cache && pecl install -o -f redis \ diff --git a/app/Config/App.php b/app/Config/App.php index d547394793470dd190374e76341497f0d1996b2a..5d8d00d619749eeec553ff1fb24931c60a6ae19e 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -450,5 +450,5 @@ class App extends BaseConfig '512' => '/icon-512.png', ]; - public string $theme = 'crimson'; + public string $theme = 'pine'; } diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 6b313009d7afc6ade90473ab7bd393ea1f905564..b9de27b250337121e09b69f8552a84722d18ce83 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -52,6 +52,7 @@ class Autoload extends AutoloadConfig 'Config' => APPPATH . 'Config/', 'ViewComponents' => APPPATH . 'Libraries/ViewComponents/', 'ViewThemes' => APPPATH . 'Libraries/ViewThemes/', + 'MediaClipper' => APPPATH . 'Libraries/MediaClipper/', 'Themes' => ROOTPATH . 'themes', ]; diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php index 74c32b3f920e9e99214a7dbc9ac15106a0f9eb77..dbb96bf02f765c7b24f28e828595fe3872bf8b1c 100644 --- a/app/Language/en/Episode.php +++ b/app/Language/en/Episode.php @@ -14,7 +14,7 @@ return [ 'number' => 'Episode {episodeNumber}', 'number_abbr' => 'Ep. {episodeNumber}', 'season_episode' => 'Season {seasonNumber} episode {episodeNumber}', - 'season_episode_abbr' => 'S{seasonNumber}E{episodeNumber}', + 'season_episode_abbr' => 'S{seasonNumber}:E{episodeNumber}', 'persons' => '{personsCount, plural, one {# person} other {# persons} diff --git a/app/Language/fr/Episode.php b/app/Language/fr/Episode.php index fc848d9f3bcfc6a86a2a599446a672d218e863e6..7805ab93cd42e60aac5e0fe270934459f9dcc98a 100644 --- a/app/Language/fr/Episode.php +++ b/app/Language/fr/Episode.php @@ -14,7 +14,7 @@ return [ 'number' => 'Épisode {episodeNumber}', 'number_abbr' => 'Ep. {episodeNumber}', 'season_episode' => 'Saison {seasonNumber} épisode {episodeNumber}', - 'season_episode_abbr' => 'S{seasonNumber}E{episodeNumber}', + 'season_episode_abbr' => 'S{seasonNumber}:E{episodeNumber}', 'persons' => '{personsCount, plural, one {# intervenant·e} other {# intervenant·e·s} diff --git a/app/Libraries/MediaClipper/Config/MediaClipper.php b/app/Libraries/MediaClipper/Config/MediaClipper.php new file mode 100644 index 0000000000000000000000000000000000000000..536e4a5e4b3292fd3adc20b102c7232d79f66759 --- /dev/null +++ b/app/Libraries/MediaClipper/Config/MediaClipper.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +namespace MediaClipper\Config; + +use CodeIgniter\Config\BaseConfig; + +class MediaClipper extends BaseConfig +{ + public string $fontsFolder = APPPATH . 'Libraries/MediaClipper/fonts/'; + + public string $quotesImage = APPPATH . 'Libraries/MediaClipper/quotes.png'; + + public string $wavesMask = APPPATH . 'Libraries/MediaClipper/waves-mask.png'; + + /** + * @var array<string, array<string, int|array<string, int|string>>> + */ + public array $formats = [ + 'landscape' => [ + 'width' => 1920, + 'height' => 1080, + 'cover' => [ + 'width' => 480, + 'height' => 480, + 'radius' => 24, + 'x' => 150, + 'y' => 120, + ], + 'quotes' => [ + 'width' => 192, + 'height' => 192, + 'x' => 810, + 'y' => 210, + ], + 'episodeTitle' => [ + 'fontsize' => 32, + 'x' => 150, + 'y' => 690, + 'lines' => 3, + 'lineWidth' => 28, + 'leading' => 20, + ], + 'podcastTitle' => [ + 'fontsize' => 20, + 'x' => 150, + 'y' => 640, + ], + 'episodeNumbering' => [ + 'fontsize' => 18, + 'paddingX' => 10, + 'paddingY' => 5, + 'x' => 180 + 10, + 'y' => 540, + ], + 'timestamp' => [ + 'fontsize' => 32, + 'padding' => 10, + 'x' => 1678, + 'y' => 986, + ], + 'progressbar' => [ + 'height' => 10, + ], + 'soundwaves' => [ + 'width' => 192, + 'height' => 108, + 'rescaleWidth' => 1920, + 'rescaleHeight' => 540, + 'x' => 0, + 'y' => 810, + 'mask' => APPPATH . 'Libraries/MediaClipper/waves-mask.png', + ], + 'subtitles' => [ + 'fontsize' => 18, + 'marginL' => 180, + 'marginR' => 20, + 'marginV' => 85, + ], + ], + 'portrait' => [ + 'width' => 1080, + 'height' => 1920, + ], + 'squared' => [ + 'width' => 1200, + 'height' => 1200, + ], + ]; +} diff --git a/app/Libraries/MediaClipper/VideoClip.php b/app/Libraries/MediaClipper/VideoClip.php new file mode 100644 index 0000000000000000000000000000000000000000..257522df20a46d504c0af0e8832de30fd74e37fa --- /dev/null +++ b/app/Libraries/MediaClipper/VideoClip.php @@ -0,0 +1,509 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace MediaClipper; + +use App\Entities\Episode; +use GdImage; + +class VideoClip +{ + /** + * @var array<string, string> + */ + public const FONTS = [ + 'episodeTitle' => 'Rubik-Bold.ttf', + 'podcastTitle' => 'Inter-Regular.otf', + 'subtitles' => 'Inter-SemiBold', + 'episodeNumbering' => 'Inter-SemiBold.otf', + 'timestamp' => 'NotoSansMono-Regular.ttf', + ]; + + protected float $duration; + + protected string $soundbiteOutput; + + protected string $subtitlesClipOutput; + + protected string $videoClipBgOutput; + + protected string $videoClipOutput; + + protected ?string $episodeNumbering = null; + + /** + * @var array<string, int|array<string, int|string>> + */ + protected array $dimensions = []; + + /** + * @var 'landscape'|'portrait'|'squared' + */ + protected string $format = 'landscape'; + + /** + * @param 'landscape'|'portrait'|'squared' $format + */ + public function __construct( + protected Episode $episode, + protected float $start, + protected float $end, + string $format, + ) { + $this->duration = $end - $start; + $this->format = $format; + $this->episodeNumbering = $this->episodeNumbering($this->episode->number, $this->episode->season_number); + $this->dimensions = config('MediaClipper') + ->formats[$format]; + + helper('media'); + + $podcastFolder = media_path("podcasts/{$this->episode->podcast->handle}"); + + $this->soundbiteOutput = $podcastFolder . "/{$this->episode->slug}-soundbite-{$this->start}-to-{$this->end}.mp3"; + $this->subtitlesClipOutput = $podcastFolder . "/{$this->episode->slug}-subtitles-clip-{$this->start}-to-{$this->end}.srt"; + $this->videoClipBgOutput = $podcastFolder . "/{$this->episode->slug}-clip-bg-{$this->format}.png"; + $this->videoClipOutput = $podcastFolder . "/{$this->episode->slug}-clip-{$this->start}-to-{$this->end}.mp4"; + } + + public function soundbite(): void + { + $audioInput = media_path($this->episode->audio_file_path); + $soundbiteCmd = "ffmpeg -y -ss {$this->start} -t {$this->duration} -i {$audioInput} {$this->soundbiteOutput}"; + exec($soundbiteCmd); + } + + public function subtitlesClip(): void + { + if ($this->episode->transcript_file_path !== null) { + $srtFileInput = media_path($this->episode->transcript_file_path); + + $subtitleClipCmd = "ffmpeg -y -i {$srtFileInput} -ss {$this->start} -t {$this->duration} {$this->subtitlesClipOutput}"; + exec($subtitleClipCmd); + } + } + + public function generate(): void + { + $this->soundbite(); + $this->subtitlesClip(); + + // check if video clip bg already exists before generating it + if (! file_exists($this->videoClipBgOutput)) { + $this->generateVideoClipBg(); + } + + $generateCmd = $this->getCmd(); + + shell_exec($generateCmd); + } + + public function getCmd(): string + { + // @phpstan-ignore + $filters = [ + "[0:a]aformat=channel_layouts=mono,showwaves=s={$this->dimensions['soundwaves']['width']}x{$this->dimensions['soundwaves']['height']}:mode=cline:rate=10:colors=white,format=yuva420p[waves]", + "[waves]scale={$this->dimensions['width']}:{$this->dimensions['height']}:flags=neighbor[resizedwaves]", + '[resizedwaves][3:v][4:v][5:v]threshold[cleanwaves]', + '[cleanwaves][2:v]alphamerge[waves_t]', + '[4:v][waves_t]overlay=x=0:y=0:shortest=1[waves_t2]', + '[waves_t2]split[m][a]', + '[m][a]alphamerge[waves_t3]', + "[waves_t3]scale={$this->dimensions['soundwaves']['rescaleWidth']}:{$this->dimensions['soundwaves']['rescaleHeight']}[waves_final]", + "[1:v][waves_final]overlay=x={$this->dimensions['soundwaves']['x']}:y={$this->dimensions['soundwaves']['y']}:shortest=1,drawtext=fontfile=" . $this->getFont( + 'timestamp' + ) . ":text='%{pts\:gmtime\:{$this->start}\:%H\\\\\\\\\\:%M\\\\\\\\\\:%S\}':x={$this->dimensions['timestamp']['x']}:y={$this->dimensions['timestamp']['y']}:fontsize={$this->dimensions['timestamp']['fontsize']}:fontcolor=white:box=1:boxcolor=0x00564A:boxborderw={$this->dimensions['timestamp']['padding']}[v3]", + "color=c=0x009486:s={$this->dimensions['width']}x{$this->dimensions['progressbar']['height']}[progressbar]", + "[v3][progressbar]overlay=-w+(w/{$this->duration})*t:0:shortest=1:format=rgb,subtitles={$this->subtitlesClipOutput}:fontsdir=" . config( + 'MediaClipper' + )->fontsFolder . ":force_style='Fontname=" . self::FONTS['subtitles'] . ",Alignment=5,Fontsize={$this->dimensions['subtitles']['fontsize']},BorderStyle=1,Outline=0,Shadow=0,MarginL={$this->dimensions['subtitles']['marginL']},MarginR={$this->dimensions['subtitles']['marginR']},MarginV={$this->dimensions['subtitles']['marginV']}'[outv]", + ]; + + $videoClipCmd = [ + 'ffmpeg -y', + "-i {$this->soundbiteOutput}", + "-loop 1 -framerate 30 -i {$this->videoClipBgOutput}", + "-loop 1 -framerate 30 -i {$this->dimensions['soundwaves']['mask']}", + "-f lavfi -i color=gray:{$this->dimensions['width']}x{$this->dimensions['height']}", + "-f lavfi -i color=black:{$this->dimensions['width']}x{$this->dimensions['height']}", + "-f lavfi -i color=white:{$this->dimensions['width']}x{$this->dimensions['height']}", + '-filter_complex "' . implode(';', $filters) . '"', + '-map "[outv]"', + '-map 0:a', + '-acodec copy', + '-vcodec libx264', + "{$this->videoClipOutput}", + ]; + + // dd(implode(' ', $videoClipCmd)); + return implode(' ', $videoClipCmd); + } + + private function episodeNumbering(?int $episodeNumber = null, ?int $seasonNumber = null,): ?string + { + if (! $episodeNumber && ! $seasonNumber) { + return null; + } + + $transKey = ''; + $args = []; + if ($episodeNumber !== null) { + $args['episodeNumber'] = sprintf('%02d', $episodeNumber); + } + + if ($seasonNumber !== null) { + $args['seasonNumber'] = sprintf('%02d', $seasonNumber); + } + + if ($episodeNumber !== null && $seasonNumber !== null) { + $transKey = 'Episode.season_episode'; + } elseif ($episodeNumber !== null && $seasonNumber === null) { + $transKey = 'Episode.number'; + } elseif ($episodeNumber === null && $seasonNumber !== null) { + $transKey = 'Episode.season'; + } + + return lang($transKey . '_abbr', $args); + } + + private function generateVideoClipBg(): bool + { + $background = $this->generateColouredBg($this->dimensions['width'], $this->dimensions['height']); + + if ($background === null) { + return false; + } + + $episodeCover = imagecreatefromjpeg(media_path($this->episode->cover->path)); + if (! $episodeCover) { + return false; + } + + $scaledEpisodeCover = $this->scaleImage( + $episodeCover, + $this->dimensions['cover']['width'], + $this->dimensions['cover']['height'] + ); + + if (! $scaledEpisodeCover) { + return false; + } + + $roundedEpisodeCover = $this->roundCorners($scaledEpisodeCover, $this->dimensions['cover']['radius']); + + if (! $roundedEpisodeCover) { + return false; + } + + $isOverlaid = $this->overlayImages( + $background, + $roundedEpisodeCover, + $this->dimensions['cover']['x'], + $this->dimensions['cover']['y'], + $this->dimensions['cover']['width'], + $this->dimensions['cover']['height'] + ); + + if (! $isOverlaid) { + return false; + } + + $this->addTextToImage( + $background, + $this->dimensions['episodeTitle']['x'], + $this->dimensions['episodeTitle']['y'], + $this->episode->title, + $this->getFont('episodeTitle'), + $this->dimensions['episodeTitle']['fontsize'], + $this->dimensions['episodeTitle']['lines'], + $this->dimensions['episodeTitle']['lineWidth'], + $this->dimensions['episodeTitle']['leading'], + ); + $this->addTextToImage( + $background, + $this->dimensions['podcastTitle']['x'], + $this->dimensions['podcastTitle']['y'], + $this->episode->podcast->title, + $this->getFont('podcastTitle'), + $this->dimensions['podcastTitle']['fontsize'] + ); + if ($this->episodeNumbering) { + $this->addTextWithBox( + $background, + $this->dimensions['episodeNumbering']['x'], + $this->dimensions['episodeNumbering']['y'], + $this->episodeNumbering, + $this->getFont('episodeNumbering'), + $this->dimensions['episodeNumbering']['fontsize'], + $this->dimensions['episodeNumbering']['paddingX'], + $this->dimensions['episodeNumbering']['paddingY'], + ); + // dd($this->episodeNumbering); + } + + // Add quotes for subtitles + $quotes = imagecreatefrompng(config('MediaClipper')->quotesImage); + + if (! $quotes) { + return false; + } + + $scaledQuotes = $this->scaleImage( + $quotes, + $this->dimensions['quotes']['width'], + $this->dimensions['quotes']['height'] + ); + + if (! $scaledQuotes) { + return false; + } + + $this->overlayImages( + $background, + $scaledQuotes, + $this->dimensions['quotes']['x'], + $this->dimensions['quotes']['y'], + $this->dimensions['quotes']['width'], + $this->dimensions['quotes']['height'] + ); + + // Save Image + imagepng($background, $this->videoClipBgOutput); + + return true; + } + + private function getFont(string $name): string + { + return config('MediaClipper')->fontsFolder . self::FONTS[$name]; + } + + private function generateColouredBg(int $width, int $height): ?GdImage + { + $background = imagecreatetruecolor($width, $height); + + if ($background === false) { + return null; + } + + $coloredBackground = imagecolorallocate($background, 0, 86, 74); + + if ($coloredBackground === false) { + return null; + } + + imagefill($background, 0, 0, $coloredBackground); + + return $background; + } + + private function scaleImage(GdImage $image, int $width, int $height): GdImage | false + { + return imagescale($image, $width, $height); + } + + /** + * Copied and adapted from https://stackoverflow.com/a/52626818 + */ + private function roundCorners(GdImage $source, int $radius): GdImage | false + { + $ws = imagesx($source); + $hs = imagesy($source); + + $corner = $radius + 2; + $s = $corner * 2; + + $src = imagecreatetruecolor($s, $s); + if ($src === false) { + return false; + } + imagecopy($src, $source, 0, 0, 0, 0, $corner, $corner); + imagecopy($src, $source, $corner, 0, $ws - $corner, 0, $corner, $corner); + imagecopy($src, $source, $corner, $corner, $ws - $corner, $hs - $corner, $corner, $corner); + imagecopy($src, $source, 0, $corner, 0, $hs - $corner, $corner, $corner); + + $q = 8; # change this if you want + $radius *= $q; + + # find unique color + do { + $r = rand(0, 255); + $g = rand(0, 255); + $b = rand(0, 255); + } while (imagecolorexact($src, $r, $g, $b) < 0); + + $ns = $s * $q; + + $img = imagecreatetruecolor($ns, $ns); + if ($img === false) { + return false; + } + + $alphacolor = imagecolorallocatealpha($img, $r, $g, $b, 127); + + if ($alphacolor === false) { + return false; + } + + imagealphablending($img, false); + imagefilledrectangle($img, 0, 0, $ns, $ns, $alphacolor); + + imagefill($img, 0, 0, $alphacolor); + imagecopyresampled($img, $src, 0, 0, 0, 0, $ns, $ns, $s, $s); + imagedestroy($src); + + imagearc($img, $radius - 1, $radius - 1, $radius * 2, $radius * 2, 180, 270, $alphacolor); + imagefilltoborder($img, 0, 0, $alphacolor, $alphacolor); + imagearc($img, $ns - $radius, $radius - 1, $radius * 2, $radius * 2, 270, 0, $alphacolor); + imagefilltoborder($img, $ns - 1, 0, $alphacolor, $alphacolor); + imagearc($img, $radius - 1, $ns - $radius, $radius * 2, $radius * 2, 90, 180, $alphacolor); + imagefilltoborder($img, 0, $ns - 1, $alphacolor, $alphacolor); + imagearc($img, $ns - $radius, $ns - $radius, $radius * 2, $radius * 2, 0, 90, $alphacolor); + imagefilltoborder($img, $ns - 1, $ns - 1, $alphacolor, $alphacolor); + imagealphablending($img, true); + imagecolortransparent($img, $alphacolor); + + # resize image down + $dest = imagecreatetruecolor($s, $s); + if ($dest === false) { + return false; + } + imagealphablending($dest, false); + imagefilledrectangle($dest, 0, 0, $s, $s, $alphacolor); + imagecopyresampled($dest, $img, 0, 0, 0, 0, $s, $s, $ns, $ns); + imagedestroy($img); + + # output image + imagealphablending($source, false); + imagecopy($source, $dest, 0, 0, 0, 0, $corner, $corner); + imagecopy($source, $dest, $ws - $corner, 0, $corner, 0, $corner, $corner); + imagecopy($source, $dest, $ws - $corner, $hs - $corner, $corner, $corner, $corner, $corner); + imagecopy($source, $dest, 0, $hs - $corner, 0, $corner, $corner, $corner); + imagealphablending($source, true); + imagedestroy($dest); + + return $source; + } + + private function overlayImages( + GdImage $background, + GdImage $foreground, + int $x, + int $y, + int $width, + int $height + ): bool { + return imagecopy($background, $foreground, $x, $y, 0, 0, $width, $height); + } + + private function addTextToImage( + GdImage $image, + int $x, + int $y, + string $text, + string $fontPath, + int $fontsize, + int $numberOfLines = 1, + int $lineWidth = 32, + int $leading = 5, + ): bool { + // Allocate A Color For The Text + $white = imagecolorallocate($image, 255, 255, 255); + + if ($white === false) { + return false; + } + + if ($numberOfLines > 1) { + $text = wordwrap($text, $lineWidth, PHP_EOL); + preg_match_all('~' . PHP_EOL . '~', $text, $matches, PREG_OFFSET_CAPTURE); + if (array_key_exists($numberOfLines - 1, $matches[0])) { + $text = substr($text, 0, (int) $matches[0][$numberOfLines - 1][1]) . '…'; + } + + $lines = explode(PHP_EOL, $text); + foreach ($lines as $i => $line) { + // Print line On Image + imagettftext($image, $fontsize, 0, $x, $y + (($fontsize + $leading) * $i), $white, $fontPath, $line); + } + } else { + // Print Text On Image + imagettftext($image, $fontsize, 0, $x, $y, $white, $fontPath, $text); + } + + return true; + } + + private function addTextWithBox( + GdImage $image, + int $x, + int $y, + string $text, + string $fontPath, + int $fontsize, + int $paddingX = 0, + int $paddingY = 0, + ): bool { + // Create some colors + $white = imagecolorallocate($image, 255, 255, 255); + $bgColor = imagecolorallocate($image, 0, 86, 74); + + if ($white === false || $bgColor === false) { + return false; + } + + $bbox = $this->calculateTextBox($fontsize, 0, $fontPath, $text); + + if ($bbox === false) { + return false; + } + + $x1 = $x + $bbox['left']; + $y1 = $y + $bbox['top']; + $x2 = $x + $bbox['width'] + $paddingX; + $y2 = $y + $bbox['height'] + $paddingY; + + imagefilledrectangle($image, $x - $paddingX, $y - $paddingY, $x2, $y2, $bgColor); + imagettftext($image, $fontsize, 0, $x1, $y1, $white, $fontPath, $text); + + return true; + } + + /** + * Adapted from: https://www.php.net/manual/fr/function.imagettfbbox.php#105593 + * + * @return array<string, mixed>|false + */ + private function calculateTextBox(int $fontSize, int $fontAngle, string $fontFile, string $text): array | false + { + /************ + simple function that calculates the *exact* bounding box (single pixel precision). + The function returns an associative array with these keys: + left, top: coordinates you will pass to imagettftext + width, height: dimension of the image you have to create + *************/ + $bbox = imagettfbbox($fontSize, $fontAngle, $fontFile, $text); + if (! $bbox) { + return false; + } + $minX = min([$bbox[0], $bbox[2], $bbox[4], $bbox[6]]); + $maxX = max([$bbox[0], $bbox[2], $bbox[4], $bbox[6]]); + $minY = min([$bbox[1], $bbox[3], $bbox[5], $bbox[7]]); + $maxY = max([$bbox[1], $bbox[3], $bbox[5], $bbox[7]]); + + return [ + 'left' => abs($minX) - 1, + 'top' => abs($minY), + 'width' => $maxX - $minX, + 'height' => $maxY - $minY, + 'box' => $bbox, + ]; + } +} diff --git a/app/Libraries/MediaClipper/fonts/Inter-Regular.otf b/app/Libraries/MediaClipper/fonts/Inter-Regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..84e6a61c3c0f11fc8c88c9e7a8f6ed7f7bbc43b3 Binary files /dev/null and b/app/Libraries/MediaClipper/fonts/Inter-Regular.otf differ diff --git a/app/Libraries/MediaClipper/fonts/Inter-SemiBold.otf b/app/Libraries/MediaClipper/fonts/Inter-SemiBold.otf new file mode 100644 index 0000000000000000000000000000000000000000..daf4c4413f7b682ee421fbde5097c7963e2fcdc4 Binary files /dev/null and b/app/Libraries/MediaClipper/fonts/Inter-SemiBold.otf differ diff --git a/app/Libraries/MediaClipper/fonts/NotoSansMono-Regular.ttf b/app/Libraries/MediaClipper/fonts/NotoSansMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a850b21ca336cd144017e3b6e80df6a217fe2ffa Binary files /dev/null and b/app/Libraries/MediaClipper/fonts/NotoSansMono-Regular.ttf differ diff --git a/app/Libraries/MediaClipper/fonts/Rubik-Bold.ttf b/app/Libraries/MediaClipper/fonts/Rubik-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..dd50bbe4e8378a5917c3320a32ff8959b8650030 Binary files /dev/null and b/app/Libraries/MediaClipper/fonts/Rubik-Bold.ttf differ diff --git a/app/Libraries/MediaClipper/quotes.png b/app/Libraries/MediaClipper/quotes.png new file mode 100644 index 0000000000000000000000000000000000000000..2c65242817398e386b3c512453968dc34e2db5ec Binary files /dev/null and b/app/Libraries/MediaClipper/quotes.png differ diff --git a/app/Libraries/MediaClipper/waves-mask.png b/app/Libraries/MediaClipper/waves-mask.png new file mode 100644 index 0000000000000000000000000000000000000000..f705571f94183140142ea70cf5c06f13cccb778f Binary files /dev/null and b/app/Libraries/MediaClipper/waves-mask.png differ diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index 8f6e0e40c182f83aebb972f641e942ddf365bf79..469859db1acb1e05c10b6305f40e150df7a15422 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -350,6 +350,22 @@ $routes->group( 'filter' => 'permission:podcast_episodes-edit', ], ); + $routes->get( + 'video-clips', + 'ClipsController::videoClips/$1/$2', + [ + 'as' => 'video-clips', + 'filter' => 'permission:podcast_episodes-edit', + ], + ); + $routes->post( + 'video-clips', + 'ClipsController::generateVideoClip/$1/$2', + [ + 'as' => 'video-clips-generate', + 'filter' => 'permission:podcast_episodes-edit', + ], + ); $routes->get( 'embed', 'EpisodeController::embed/$1/$2', diff --git a/modules/Admin/Controllers/ClipsController.php b/modules/Admin/Controllers/ClipsController.php new file mode 100644 index 0000000000000000000000000000000000000000..f93ffead1290b0b3a58e303835ec5ace5e7eac4e --- /dev/null +++ b/modules/Admin/Controllers/ClipsController.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace Modules\Admin\Controllers; + +use App\Entities\Episode; +use App\Entities\Podcast; +use App\Models\EpisodeModel; +use App\Models\PodcastModel; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; +use MediaClipper\VideoClip; + +class ClipsController extends BaseController +{ + protected Podcast $podcast; + + protected Episode $episode; + + public function _remap(string $method, string ...$params): mixed + { + if ( + ($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->podcast = $podcast; + + if (count($params) > 1) { + if ( + ! ($episode = (new EpisodeModel()) + ->where([ + 'id' => $params[1], + 'podcast_id' => $params[0], + ]) + ->first()) + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->episode = $episode; + + unset($params[1]); + unset($params[0]); + } + + return $this->{$method}(...$params); + } + + public function videoClips(): string + { + helper('form'); + + $data = [ + 'podcast' => $this->podcast, + 'episode' => $this->episode, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => $this->episode->slug, + ]); + return view('episode/video_clips', $data); + } + + public function generateVideoClip(): RedirectResponse + { + $rules = [ + 'format' => 'required', + 'start_time' => 'required', + 'end_time' => 'required', + ]; + + if (! $this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + // TODO: start and end + + helper('media'); + + $clipper = new VideoClip( + $this->episode, + (float) $this->request->getPost('start_time'), + (float) $this->request->getPost('end_time',), + 'landscape' + ); + $clipper->generate(); + + return redirect()->route('video-clips', [$this->podcast->id, $this->episode->id])->with( + 'message', + lang('Settings.images.regenerationSuccess') + ); + } +} diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php index 2e8c52b67fd7ba9a1ecc48b64b407a7eaf88d86b..ebebdb0e9e1a20be59bd8b2ccc609ec95d61d609 100644 --- a/modules/Admin/Language/en/Breadcrumb.php +++ b/modules/Admin/Language/en/Breadcrumb.php @@ -43,5 +43,6 @@ return [ 'listening-time' => 'listening time', 'time-periods' => 'time periods', 'soundbites' => 'soundbites', + 'video-clips' => 'video clips', 'embed' => 'embeddable player', ]; diff --git a/modules/Admin/Language/en/EpisodeNavigation.php b/modules/Admin/Language/en/EpisodeNavigation.php index 1945e25b98abc68f4ce9cf8ef0be7e175d030a43..c7f7dba3eadb3690ff67e1c52efed755646b4504 100644 --- a/modules/Admin/Language/en/EpisodeNavigation.php +++ b/modules/Admin/Language/en/EpisodeNavigation.php @@ -16,4 +16,5 @@ return [ 'episode-persons-manage' => 'Manage persons', 'embed-add' => 'Embeddable player', 'soundbites-edit' => 'Soundbites', + 'video-clips' => 'Video clips', ]; diff --git a/themes/cp_admin/episode/_sidebar.php b/themes/cp_admin/episode/_sidebar.php index a22fbdd5bd57e2c6058417707c8ce3a99463b703..32332db025e6d1c54f88ca2ee5f00bc3d2ad3af1 100644 --- a/themes/cp_admin/episode/_sidebar.php +++ b/themes/cp_admin/episode/_sidebar.php @@ -3,7 +3,7 @@ $podcastNavigation = [ 'dashboard' => [ 'icon' => 'dashboard', - 'items' => ['episode-view', 'episode-edit', 'episode-persons-manage', 'embed-add', 'soundbites-edit'], + 'items' => ['episode-view', 'episode-edit', 'episode-persons-manage', 'embed-add', 'soundbites-edit', 'video-clips'], ], ]; ?> diff --git a/themes/cp_admin/episode/video_clips.php b/themes/cp_admin/episode/video_clips.php new file mode 100644 index 0000000000000000000000000000000000000000..e64144c9cd7131fbb9bb0767d90fcc13b42c4bdc --- /dev/null +++ b/themes/cp_admin/episode/video_clips.php @@ -0,0 +1,52 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Episode.video_clips.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Episode.video_clips.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<form action="<?= route_to('video-clips-generate', $podcast->id, $episode->id) ?>" method="POST" class="flex flex-col max-w-sm gap-y-4"> + +<fieldset> +<legend>Format</legend> +<div class="mx-auto"> + <input type="radio" name="format" value="16:9" id="landscape"/> + <label for="landscape">Landscape - 16:9</label> +</div> +<div class="mx-auto"> + <input type="radio" name="format" value="1:1" id="square" checked="checked"/> + <label for="square">Square - 1:1</label> +</div> +<div class="mx-auto"> + <input type="radio" name="format" value="9:16" id="portrait"/> + <label for="portrait">Portrait - 9:16</label> +</div> +</fieldset> + +<Forms.Field + type="number" + name="start_time" + label="START" + required="true" + value="0" +/> +<Forms.Field + type="number" + name="end_time" + label="END" + required="true" + value="15" +/> + +<audio></audio> + +<Button variant="primary" type="submit"><?= lang('Episode.video_clips.submit') ?></Button> + +</form> + +<?= $this->endSection() ?> diff --git a/themes/cp_install/_layout.php b/themes/cp_install/_layout.php index 8b0465dd427f33e2355425b230bef16f0b556990..a8563c046608905719412562d8fd529f0f8d68b1 100644 --- a/themes/cp_install/_layout.php +++ b/themes/cp_install/_layout.php @@ -8,9 +8,8 @@ <title>Castopod Install</title> <meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> - <link rel="icon" type="image/x-icon" href="<?= service('settings') - ->get('App.siteIcon')['ico'] ?>" /> - <link rel="apple-touch-icon" href="<?= service('settings')->get('App.siteIcon')['180'] ?>"> + <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" /> + <link rel="apple-touch-icon" href="/icon-180.png"> <?= service('vite') ->asset('styles/index.css', 'css') ?> <?= service('vite')