2021-12-02 16:45:41 +00:00
|
|
|
<?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;
|
|
|
|
|
2021-12-08 17:05:34 +00:00
|
|
|
/**
|
|
|
|
* TODO: refactor this by splitting image manipulations + video generation
|
|
|
|
*
|
|
|
|
* @phpstan-ignore-next-line
|
|
|
|
*/
|
2021-12-02 16:45:41 +00:00
|
|
|
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;
|
|
|
|
|
2021-12-03 17:00:15 +00:00
|
|
|
protected string $audioInput;
|
|
|
|
|
|
|
|
protected string $episodeCoverPath;
|
|
|
|
|
|
|
|
protected ?string $subtitlesInput = null;
|
|
|
|
|
2021-12-02 16:45:41 +00:00
|
|
|
protected string $soundbiteOutput;
|
|
|
|
|
|
|
|
protected string $subtitlesClipOutput;
|
|
|
|
|
|
|
|
protected string $videoClipBgOutput;
|
|
|
|
|
|
|
|
protected string $videoClipOutput;
|
|
|
|
|
|
|
|
protected ?string $episodeNumbering = null;
|
|
|
|
|
|
|
|
/**
|
2021-12-06 16:27:00 +00:00
|
|
|
* @var array<string, mixed>
|
2021-12-02 16:45:41 +00:00
|
|
|
*/
|
|
|
|
protected array $dimensions = [];
|
|
|
|
|
2021-12-07 16:58:12 +00:00
|
|
|
/**
|
|
|
|
* @var array<string, mixed>
|
|
|
|
*/
|
|
|
|
protected array $colors = [];
|
2021-12-02 16:45:41 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @param 'landscape'|'portrait'|'squared' $format
|
2021-12-07 16:58:12 +00:00
|
|
|
* @param 'pine'|'crimson'|'lake'|'amber'|'jacaranda'|'onyx' $theme
|
2021-12-02 16:45:41 +00:00
|
|
|
*/
|
|
|
|
public function __construct(
|
|
|
|
protected Episode $episode,
|
|
|
|
protected float $start,
|
|
|
|
protected float $end,
|
2021-12-08 17:05:34 +00:00
|
|
|
protected string $format = 'landscape',
|
|
|
|
protected string $theme = 'pine',
|
2021-12-02 16:45:41 +00:00
|
|
|
) {
|
|
|
|
$this->duration = $end - $start;
|
|
|
|
$this->episodeNumbering = $this->episodeNumbering($this->episode->number, $this->episode->season_number);
|
|
|
|
$this->dimensions = config('MediaClipper')
|
|
|
|
->formats[$format];
|
2021-12-07 16:58:12 +00:00
|
|
|
$this->colors = config('MediaClipper')
|
|
|
|
->themes[$theme];
|
2021-12-02 16:45:41 +00:00
|
|
|
|
2021-12-07 16:58:12 +00:00
|
|
|
helper(['media']);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
2021-12-14 16:41:10 +00:00
|
|
|
$this->audioInput = media_path($this->episode->audio->file_path);
|
2021-12-03 17:00:15 +00:00
|
|
|
$this->episodeCoverPath = media_path($this->episode->cover->path);
|
2021-12-14 16:41:10 +00:00
|
|
|
if ($this->episode->transcript !== null) {
|
|
|
|
$this->subtitlesInput = media_path($this->episode->transcript->file_path);
|
2021-12-03 17:00:15 +00:00
|
|
|
}
|
|
|
|
|
2021-12-02 16:45:41 +00:00
|
|
|
$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";
|
2021-12-08 17:05:34 +00:00
|
|
|
$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";
|
2021-12-02 16:45:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public function soundbite(): void
|
|
|
|
{
|
2021-12-03 17:00:15 +00:00
|
|
|
$soundbiteCmd = "ffmpeg -y -ss {$this->start} -t {$this->duration} -i {$this->audioInput} {$this->soundbiteOutput}";
|
2021-12-02 16:45:41 +00:00
|
|
|
exec($soundbiteCmd);
|
|
|
|
}
|
|
|
|
|
|
|
|
public function subtitlesClip(): void
|
|
|
|
{
|
2021-12-03 17:00:15 +00:00
|
|
|
if ($this->subtitlesInput) {
|
|
|
|
$subtitleClipCmd = "ffmpeg -y -i {$this->subtitlesInput} -ss {$this->start} -t {$this->duration} {$this->subtitlesClipOutput}";
|
2021-12-02 16:45:41 +00:00
|
|
|
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 = [
|
2021-12-08 17:05:34 +00:00
|
|
|
"[0:a]aformat=channel_layouts=mono,showwaves=s={$this->dimensions['soundwaves']['width']}x{$this->dimensions['soundwaves']['height']}:mode=cline:rate=10:colors=white,format=rgb24[waves]",
|
2021-12-02 16:45:41 +00:00
|
|
|
"[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]',
|
2021-12-08 17:05:34 +00:00
|
|
|
"[waves_t3]scale={$this->dimensions['soundwaves']['rescaleWidth']}:{$this->dimensions['soundwaves']['rescaleHeight']},lutrgb=r='if(gt(val,100),{$this->colors['soundwaves'][0]},val)':g='if(gt(val,100),{$this->colors['soundwaves'][1]},val)':b='if(gt(val,100),{$this->colors['soundwaves'][2]},val)'[waves_final]",
|
2021-12-02 16:45:41 +00:00
|
|
|
"[1:v][waves_final]overlay=x={$this->dimensions['soundwaves']['x']}:y={$this->dimensions['soundwaves']['y']}:shortest=1,drawtext=fontfile=" . $this->getFont(
|
|
|
|
'timestamp'
|
2021-12-08 17:05:34 +00:00
|
|
|
) . ":text='%{pts\:gmtime\:{$this->start}\:%H\\\\\\\\\\:%M\\\\\\\\\\:%S\}':x={$this->dimensions['timestamp']['x']}:y={$this->dimensions['timestamp']['y']}:fontsize={$this->dimensions['timestamp']['fontsize']}:fontcolor=0x{$this->colors['timestampText']}:box=1:boxcolor=0x{$this->colors['timestampBg']}:boxborderw={$this->dimensions['timestamp']['padding']}[v3]",
|
2021-12-07 16:58:12 +00:00
|
|
|
"color=c=0x{$this->colors['progressbar']}:s={$this->dimensions['width']}x{$this->dimensions['progressbar']['height']}[progressbar]",
|
2021-12-02 16:45:41 +00:00
|
|
|
"[v3][progressbar]overlay=-w+(w/{$this->duration})*t:0:shortest=1:format=rgb,subtitles={$this->subtitlesClipOutput}:fontsdir=" . config(
|
|
|
|
'MediaClipper'
|
2021-12-08 17:05:34 +00:00
|
|
|
)->fontsFolder . ":force_style='Fontname=" . self::FONTS['subtitles'] . ",Alignment=5,Fontsize={$this->dimensions['subtitles']['fontsize']},PrimaryColour=&H{$this->colors['subtitles']}&,BorderStyle=1,Outline=0,Shadow=0,MarginL={$this->dimensions['subtitles']['marginL']},MarginR={$this->dimensions['subtitles']['marginR']},MarginV={$this->dimensions['subtitles']['marginV']}'[outv]",
|
|
|
|
"[6:v]scale={$this->dimensions['watermark']['width']}:{$this->dimensions['watermark']['height']}[watermark]",
|
|
|
|
"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]",
|
2021-12-02 16:45:41 +00:00
|
|
|
];
|
|
|
|
|
2021-12-08 17:05:34 +00:00
|
|
|
$watermark = config('MediaClipper')
|
|
|
|
->watermark;
|
|
|
|
|
2021-12-02 16:45:41 +00:00
|
|
|
$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']}",
|
2021-12-08 17:05:34 +00:00
|
|
|
"-f lavfi -i color=white:{$this->dimensions['width']}x{$this->dimensions['height']}",
|
|
|
|
"-loop 1 -framerate 1 -i {$watermark}",
|
2021-12-02 16:45:41 +00:00
|
|
|
'-filter_complex "' . implode(';', $filters) . '"',
|
2021-12-08 17:05:34 +00:00
|
|
|
'-map "[watermarked]"',
|
2021-12-02 16:45:41 +00:00
|
|
|
'-map 0:a',
|
|
|
|
'-acodec copy',
|
2021-12-08 17:05:34 +00:00
|
|
|
'-vcodec libx264rgb',
|
2021-12-02 16:45:41 +00:00
|
|
|
"{$this->videoClipOutput}",
|
|
|
|
];
|
|
|
|
|
|
|
|
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
|
|
|
|
{
|
2021-12-07 16:58:12 +00:00
|
|
|
$background = $this->generateBackground($this->dimensions['width'], $this->dimensions['height']);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
|
|
|
if ($background === null) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-12-03 17:00:15 +00:00
|
|
|
$episodeCover = imagecreatefromjpeg($this->episodeCoverPath);
|
2021-12-02 16:45:41 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2021-12-06 16:27:00 +00:00
|
|
|
$this->addParagraphToImage(
|
2021-12-02 16:45:41 +00:00
|
|
|
$background,
|
|
|
|
$this->dimensions['podcastTitle']['x'],
|
|
|
|
$this->dimensions['podcastTitle']['y'],
|
|
|
|
$this->episode->podcast->title,
|
|
|
|
$this->getFont('podcastTitle'),
|
2021-12-06 16:27:00 +00:00
|
|
|
$this->dimensions['podcastTitle']['fontsize'],
|
|
|
|
$this->dimensions['podcastTitle']['lineWidth'],
|
|
|
|
$this->dimensions['podcastTitle']['lines'] ?? 1,
|
|
|
|
$this->dimensions['podcastTitle']['lineHeight'] ?? 1,
|
2021-12-02 16:45:41 +00:00
|
|
|
);
|
2021-12-06 16:27:00 +00:00
|
|
|
|
|
|
|
$episodeNumberingWidth = 0;
|
2021-12-02 16:45:41 +00:00
|
|
|
if ($this->episodeNumbering) {
|
2021-12-06 16:27:00 +00:00
|
|
|
$episodeTitleBox = $this->calculateTextBox(
|
|
|
|
$this->dimensions['episodeTitle']['fontsize'],
|
|
|
|
0,
|
|
|
|
$this->getFont('episodeTitle'),
|
|
|
|
$this->episode->title
|
|
|
|
);
|
|
|
|
$episodeNumberingBox = $this->calculateTextBox(
|
|
|
|
$this->dimensions['episodeNumbering']['fontsize'],
|
|
|
|
0,
|
|
|
|
$this->getFont('episodeNumbering'),
|
|
|
|
$this->episodeNumbering
|
|
|
|
);
|
|
|
|
if (! $episodeTitleBox || ! $episodeNumberingBox) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
$episodeTitleCenter = (int) ($episodeTitleBox['height'] / 2);
|
|
|
|
$episodeNumberingCenter = (int) (($episodeNumberingBox['height'] + ($this->dimensions['episodeNumbering']['paddingY'] * 2)) / 2);
|
|
|
|
$episodeNumberingWidth = $episodeNumberingBox['width'] + ($this->dimensions['episodeNumbering']['paddingX'] * 2);
|
|
|
|
|
2021-12-02 16:45:41 +00:00
|
|
|
$this->addTextWithBox(
|
|
|
|
$background,
|
2021-12-06 16:27:00 +00:00
|
|
|
$this->dimensions['episodeTitle']['x'],
|
|
|
|
$this->dimensions['episodeTitle']['y'] + $episodeTitleCenter - $episodeNumberingCenter,
|
2021-12-02 16:45:41 +00:00
|
|
|
$this->episodeNumbering,
|
|
|
|
$this->getFont('episodeNumbering'),
|
|
|
|
$this->dimensions['episodeNumbering']['fontsize'],
|
2021-12-07 16:58:12 +00:00
|
|
|
$this->colors['episodeNumberingText'],
|
|
|
|
$this->colors['episodeNumberingBg'],
|
2021-12-02 16:45:41 +00:00
|
|
|
$this->dimensions['episodeNumbering']['paddingX'],
|
|
|
|
$this->dimensions['episodeNumbering']['paddingY'],
|
|
|
|
);
|
|
|
|
}
|
2021-12-06 16:27:00 +00:00
|
|
|
$this->addParagraphToImage(
|
|
|
|
$background,
|
|
|
|
$this->dimensions['episodeTitle']['x'],
|
|
|
|
$this->dimensions['episodeTitle']['y'],
|
|
|
|
$this->episode->title,
|
|
|
|
$this->getFont('episodeTitle'),
|
|
|
|
$this->dimensions['episodeTitle']['fontsize'],
|
|
|
|
$this->dimensions['episodeTitle']['lineWidth'],
|
|
|
|
$this->dimensions['episodeTitle']['lines'],
|
|
|
|
$this->dimensions['episodeTitle']['lineHeight'] ?? 1,
|
|
|
|
$episodeNumberingWidth + ($episodeNumberingWidth === 0 ? 0 : $this->dimensions['episodeNumbering']['marginRight']),
|
|
|
|
);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
|
|
|
// Add quotes for subtitles
|
|
|
|
$quotes = imagecreatefrompng(config('MediaClipper')->quotesImage);
|
|
|
|
|
|
|
|
if (! $quotes) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-12-08 17:05:34 +00:00
|
|
|
$cleanedQuotes = $this->cleanTransparency($quotes);
|
|
|
|
|
|
|
|
if (! $cleanedQuotes) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
imagefilter($cleanedQuotes, IMG_FILTER_CONTRAST, 255);
|
|
|
|
imagefilter($cleanedQuotes, IMG_FILTER_COLORIZE, ...$this->colors['quotes']);
|
2021-12-07 16:58:12 +00:00
|
|
|
|
2021-12-02 16:45:41 +00:00
|
|
|
$scaledQuotes = $this->scaleImage(
|
2021-12-08 17:05:34 +00:00
|
|
|
$cleanedQuotes,
|
2021-12-02 16:45:41 +00:00
|
|
|
$this->dimensions['quotes']['width'],
|
|
|
|
$this->dimensions['quotes']['height']
|
|
|
|
);
|
|
|
|
|
|
|
|
if (! $scaledQuotes) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-12-08 17:05:34 +00:00
|
|
|
imagesavealpha($scaledQuotes, true);
|
2021-12-02 16:45:41 +00:00
|
|
|
$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];
|
|
|
|
}
|
|
|
|
|
2021-12-07 16:58:12 +00:00
|
|
|
private function generateBackground(int $width, int $height): ?GdImage
|
2021-12-02 16:45:41 +00:00
|
|
|
{
|
|
|
|
$background = imagecreatetruecolor($width, $height);
|
|
|
|
|
|
|
|
if ($background === false) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2021-12-07 16:58:12 +00:00
|
|
|
$coloredBackground = imagecolorallocate($background, ...$this->colors['background']);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2021-12-06 16:27:00 +00:00
|
|
|
private function addParagraphToImage(
|
2021-12-02 16:45:41 +00:00
|
|
|
GdImage $image,
|
|
|
|
int $x,
|
|
|
|
int $y,
|
|
|
|
string $text,
|
|
|
|
string $fontPath,
|
|
|
|
int $fontsize,
|
2021-12-06 16:27:00 +00:00
|
|
|
int $lineWidth,
|
2021-12-02 16:45:41 +00:00
|
|
|
int $numberOfLines = 1,
|
2021-12-06 16:27:00 +00:00
|
|
|
float $lineHeight = 1,
|
|
|
|
int $paragraphIndent = 0,
|
2021-12-02 16:45:41 +00:00
|
|
|
): bool {
|
|
|
|
// Allocate A Color For The Text
|
2021-12-07 16:58:12 +00:00
|
|
|
$textColor = imagecolorallocate($image, ...$this->colors['text']);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
2021-12-07 16:58:12 +00:00
|
|
|
if ($textColor === false) {
|
2021-12-02 16:45:41 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-12-06 16:27:00 +00:00
|
|
|
$lines = $this->textToParagraph($text, $fontPath, $fontsize, $lineWidth, $numberOfLines, $paragraphIndent);
|
|
|
|
if (! $lines) {
|
|
|
|
return false;
|
|
|
|
}
|
2021-12-02 16:45:41 +00:00
|
|
|
|
2021-12-06 16:27:00 +00:00
|
|
|
$leading = (int) ($fontsize * $lineHeight);
|
|
|
|
foreach ($lines as $i => $line) {
|
|
|
|
// Print line On Image
|
|
|
|
imagettftext(
|
|
|
|
$image,
|
|
|
|
$fontsize,
|
|
|
|
0,
|
|
|
|
$x + ($paragraphIndent * ($i === 0 ? 1 : 0)),
|
|
|
|
$y + $fontsize + ($leading * $i),
|
2021-12-07 16:58:12 +00:00
|
|
|
$textColor,
|
2021-12-06 16:27:00 +00:00
|
|
|
$fontPath,
|
|
|
|
$line
|
|
|
|
);
|
2021-12-02 16:45:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2021-12-06 16:27:00 +00:00
|
|
|
/**
|
|
|
|
* 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,
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2021-12-07 16:58:12 +00:00
|
|
|
/**
|
|
|
|
* @param int[] $boxTextColor
|
|
|
|
* @param int[] $boxBgColor
|
|
|
|
*/
|
2021-12-02 16:45:41 +00:00
|
|
|
private function addTextWithBox(
|
|
|
|
GdImage $image,
|
|
|
|
int $x,
|
|
|
|
int $y,
|
|
|
|
string $text,
|
|
|
|
string $fontPath,
|
|
|
|
int $fontsize,
|
2021-12-07 16:58:12 +00:00
|
|
|
array $boxTextColor,
|
|
|
|
array $boxBgColor,
|
2021-12-02 16:45:41 +00:00
|
|
|
int $paddingX = 0,
|
|
|
|
int $paddingY = 0,
|
|
|
|
): bool {
|
|
|
|
// Create some colors
|
2021-12-07 16:58:12 +00:00
|
|
|
$textColor = imagecolorallocate($image, ...$boxTextColor);
|
|
|
|
$bgColor = imagecolorallocate($image, ...$boxBgColor);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
2021-12-07 16:58:12 +00:00
|
|
|
if ($textColor === false || $bgColor === false) {
|
2021-12-02 16:45:41 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
$bbox = $this->calculateTextBox($fontsize, 0, $fontPath, $text);
|
|
|
|
|
|
|
|
if ($bbox === false) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-12-03 17:00:15 +00:00
|
|
|
$x1 = $x + $bbox['left'] + $paddingX;
|
|
|
|
$y1 = $y + $bbox['top'] + $paddingY;
|
|
|
|
$x2 = $x + $bbox['width'] + ($paddingX * 2);
|
|
|
|
$y2 = $y + $bbox['height'] + ($paddingY * 2);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
2021-12-03 17:00:15 +00:00
|
|
|
imagefilledrectangle($image, $x, $y, $x2, $y2, $bgColor);
|
2021-12-07 16:58:12 +00:00
|
|
|
imagettftext($image, $fontsize, 0, $x1, $y1, $textColor, $fontPath, $text);
|
2021-12-02 16:45:41 +00:00
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2021-12-08 17:05:34 +00:00
|
|
|
/**
|
|
|
|
* This helps getting a truly transparent background for images with transparency:
|
|
|
|
* https://stackoverflow.com/a/2611911
|
|
|
|
*/
|
|
|
|
private function cleanTransparency(GdImage $image): GdImage | false
|
|
|
|
{
|
|
|
|
$imageBg = imagecolorallocate($image, 0, 0, 0);
|
|
|
|
if ($imageBg === false) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
// removing the black from the image
|
|
|
|
imagecolortransparent($image, $imageBg);
|
|
|
|
|
|
|
|
// turning off alpha blending (to ensure alpha channel information
|
|
|
|
// is preserved, rather than removed (blending with the rest of the
|
|
|
|
// image in the form of black))
|
|
|
|
imagealphablending($image, false);
|
|
|
|
|
|
|
|
// turning on alpha channel information saving (to ensure the full range
|
|
|
|
// of transparency is preserved)
|
|
|
|
imagesavealpha($image, true);
|
|
|
|
|
|
|
|
return $image;
|
|
|
|
}
|
|
|
|
|
2021-12-02 16:45:41 +00:00
|
|
|
/**
|
2021-12-06 16:27:00 +00:00
|
|
|
* @return array<int, string>|false
|
2021-12-02 16:45:41 +00:00
|
|
|
*/
|
2021-12-06 16:27:00 +00:00
|
|
|
private function textToParagraph(
|
|
|
|
string $text,
|
|
|
|
string $fontPath,
|
|
|
|
int $fontsize,
|
|
|
|
int $lineWidth,
|
|
|
|
int $numberOfLines,
|
|
|
|
int $paragraphIndent = 0,
|
|
|
|
): array | false {
|
|
|
|
// check length of text
|
|
|
|
$bbox = $this->calculateTextBox($fontsize, 0, $fontPath, $text);
|
2021-12-02 16:45:41 +00:00
|
|
|
if (! $bbox) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2021-12-06 16:27:00 +00:00
|
|
|
// return early if text width is less than line width
|
|
|
|
if ($bbox['width'] <= $lineWidth) {
|
|
|
|
return [$text];
|
|
|
|
}
|
|
|
|
|
|
|
|
// cut text in multiple lines based on the lineWidth property
|
|
|
|
$lines = [''];
|
|
|
|
$length = $paragraphIndent;
|
|
|
|
$words = preg_split('~\b(?=\S)|(?=\s)~', $text);
|
|
|
|
if (! $words) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
$wordCount = count($words);
|
|
|
|
$lineNumber = 0;
|
|
|
|
for ($i = 0; $i < $wordCount; ++$i) {
|
|
|
|
$word = $words[$i];
|
|
|
|
$wordBox = $this->calculateTextBox($fontsize, 0, $fontPath, $word);
|
|
|
|
if (! $wordBox) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
$wordWidth = $wordBox['width'];
|
|
|
|
|
|
|
|
if (($wordWidth + $length) > $lineWidth) {
|
|
|
|
++$lineNumber;
|
|
|
|
if ($lineNumber > $numberOfLines - 1) {
|
|
|
|
$lines[$numberOfLines - 1] .= '…';
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
$lines[$lineNumber] = '';
|
|
|
|
$length = 0;
|
|
|
|
|
|
|
|
// If the current word is just a space, don't bother. Skip (saves a weird-looking gap in the text).
|
|
|
|
if ($word === ' ') {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
$lines[$lineNumber] .= $word;
|
|
|
|
$length += $wordWidth;
|
|
|
|
}
|
|
|
|
|
|
|
|
return $lines;
|
2021-12-02 16:45:41 +00:00
|
|
|
}
|
|
|
|
}
|