mirror of
https://code.castopod.org/adaures/castopod
synced 2025-04-19 13:01:19 +00:00
feat(video-clips): generate a 16:9 video using ffmpeg
This commit is contained in:
parent
fee2c1c0d0
commit
35aa7ea5d9
@ -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 \
|
||||
|
@ -450,5 +450,5 @@ class App extends BaseConfig
|
||||
'512' => '/icon-512.png',
|
||||
];
|
||||
|
||||
public string $theme = 'crimson';
|
||||
public string $theme = 'pine';
|
||||
}
|
||||
|
@ -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',
|
||||
];
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
91
app/Libraries/MediaClipper/Config/MediaClipper.php
Normal file
91
app/Libraries/MediaClipper/Config/MediaClipper.php
Normal file
@ -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,
|
||||
],
|
||||
];
|
||||
}
|
509
app/Libraries/MediaClipper/VideoClip.php
Normal file
509
app/Libraries/MediaClipper/VideoClip.php
Normal file
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
BIN
app/Libraries/MediaClipper/fonts/Inter-Regular.otf
Normal file
BIN
app/Libraries/MediaClipper/fonts/Inter-Regular.otf
Normal file
Binary file not shown.
BIN
app/Libraries/MediaClipper/fonts/Inter-SemiBold.otf
Normal file
BIN
app/Libraries/MediaClipper/fonts/Inter-SemiBold.otf
Normal file
Binary file not shown.
BIN
app/Libraries/MediaClipper/fonts/NotoSansMono-Regular.ttf
Normal file
BIN
app/Libraries/MediaClipper/fonts/NotoSansMono-Regular.ttf
Normal file
Binary file not shown.
BIN
app/Libraries/MediaClipper/fonts/Rubik-Bold.ttf
Normal file
BIN
app/Libraries/MediaClipper/fonts/Rubik-Bold.ttf
Normal file
Binary file not shown.
BIN
app/Libraries/MediaClipper/quotes.png
Normal file
BIN
app/Libraries/MediaClipper/quotes.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.1 KiB |
BIN
app/Libraries/MediaClipper/waves-mask.png
Normal file
BIN
app/Libraries/MediaClipper/waves-mask.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
@ -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',
|
||||
|
106
modules/Admin/Controllers/ClipsController.php
Normal file
106
modules/Admin/Controllers/ClipsController.php
Normal file
@ -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')
|
||||
);
|
||||
}
|
||||
}
|
@ -43,5 +43,6 @@ return [
|
||||
'listening-time' => 'listening time',
|
||||
'time-periods' => 'time periods',
|
||||
'soundbites' => 'soundbites',
|
||||
'video-clips' => 'video clips',
|
||||
'embed' => 'embeddable player',
|
||||
];
|
||||
|
@ -16,4 +16,5 @@ return [
|
||||
'episode-persons-manage' => 'Manage persons',
|
||||
'embed-add' => 'Embeddable player',
|
||||
'soundbites-edit' => 'Soundbites',
|
||||
'video-clips' => 'Video clips',
|
||||
];
|
||||
|
@ -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'],
|
||||
],
|
||||
]; ?>
|
||||
|
||||
|
52
themes/cp_admin/episode/video_clips.php
Normal file
52
themes/cp_admin/episode/video_clips.php
Normal file
@ -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() ?>
|
@ -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')
|
||||
|
Loading…
x
Reference in New Issue
Block a user