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')