From 2065ebbee5e3d0f890ac90b55ca984f1d62a184c Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Fri, 24 Dec 2021 09:49:34 +0000
Subject: [PATCH] feat(video-clips): add route for scheduled video clips + list
 video clips with status

---
 .../2021-12-09-130000_add_clips.php           |  1 -
 app/Entities/Clip/BaseClip.php                | 26 +++++--
 app/Entities/Clip/VideoClip.php               | 62 +++++++++++++++-
 app/Entities/Episode.php                      |  8 +-
 app/Libraries/MediaClipper/VideoClipper.php   | 61 ++++++++++++++--
 app/Models/ClipModel.php                      | 62 +++++++---------
 app/Resources/icons/forbid.svg                |  6 ++
 app/Views/Components/Pill.php                 | 36 +++++++++
 modules/Admin/Config/Routes.php               |  5 ++
 .../Admin/Controllers/SchedulerController.php | 73 +++++++++++++++++++
 .../Controllers/VideoClipsController.php      | 60 +++++----------
 themes/cp_admin/episode/video_clips_list.php  | 43 ++++++++++-
 12 files changed, 344 insertions(+), 99 deletions(-)
 create mode 100644 app/Resources/icons/forbid.svg
 create mode 100644 app/Views/Components/Pill.php
 create mode 100644 modules/Admin/Controllers/SchedulerController.php

diff --git a/app/Database/Migrations/2021-12-09-130000_add_clips.php b/app/Database/Migrations/2021-12-09-130000_add_clips.php
index e3319fb99a..8e1f710404 100644
--- a/app/Database/Migrations/2021-12-09-130000_add_clips.php
+++ b/app/Database/Migrations/2021-12-09-130000_add_clips.php
@@ -85,7 +85,6 @@ class AddClips extends Migration
         ]);
 
         $this->forge->addKey('id', true);
-        $this->forge->addUniqueKey(['episode_id', 'start_time', 'duration', 'type']);
         $this->forge->addForeignKey('podcast_id', 'podcasts', 'id', '', 'CASCADE');
         $this->forge->addForeignKey('episode_id', 'episodes', 'id', '', 'CASCADE');
         $this->forge->addForeignKey('media_id', 'media', 'id', '', 'CASCADE');
diff --git a/app/Entities/Clip/BaseClip.php b/app/Entities/Clip/BaseClip.php
index c5d0e1a770..402bddd0d4 100644
--- a/app/Entities/Clip/BaseClip.php
+++ b/app/Entities/Clip/BaseClip.php
@@ -17,8 +17,10 @@ use App\Entities\Podcast;
 use App\Models\EpisodeModel;
 use App\Models\MediaModel;
 use App\Models\PodcastModel;
+use App\Models\UserModel;
 use CodeIgniter\Entity\Entity;
 use CodeIgniter\Files\File;
+use Modules\Auth\Entities\User;
 
 /**
  * @property int $id
@@ -33,8 +35,10 @@ use CodeIgniter\Files\File;
  * @property string $type
  * @property int $media_id
  * @property Video|Audio $media
+ * @property array|null $metadata
  * @property string $status
  * @property string $logs
+ * @property User $user
  * @property int $created_by
  * @property int $updated_by
  */
@@ -52,14 +56,17 @@ class BaseClip extends Entity
         'duration' => 'double',
         'type' => 'string',
         'media_id' => '?integer',
-        'metadata' => 'json-array',
+        'metadata' => '?json-array',
         'status' => 'string',
         'logs' => 'string',
         'created_by' => 'integer',
         'updated_by' => 'integer',
     ];
 
-    public function __construct($data)
+    /**
+     * @param array<string, mixed>|null $data
+     */
+    public function __construct(array $data = null)
     {
         parent::__construct($data);
 
@@ -80,13 +87,20 @@ class BaseClip extends Entity
         return (new EpisodeModel())->getEpisodeById($this->episode_id);
     }
 
+    public function getUser(): ?User
+    {
+        return (new UserModel())->find($this->created_by);
+    }
+
     public function setMedia(string $filePath = null): static
     {
-        if ($filePath === null || ($file = new File($filePath)) === null) {
+        if ($filePath === null) {
             return $this;
         }
 
-        if ($this->media_id !== 0) {
+        $file = new File($filePath);
+
+        if ($this->media_id !== null) {
             $this->getMedia()
                 ->setFile($file);
             $this->getMedia()
@@ -97,8 +111,8 @@ class BaseClip extends Entity
                 'file_path' => $filePath,
                 'language_code' => $this->getPodcast()
                     ->language_code,
-                'uploaded_by' => user_id(),
-                'updated_by' => user_id(),
+                'uploaded_by' => $this->attributes['created_by'],
+                'updated_by' => $this->attributes['created_by'],
             ]);
             $media->setFile($file);
 
diff --git a/app/Entities/Clip/VideoClip.php b/app/Entities/Clip/VideoClip.php
index a39b759f64..0e5fa5a286 100644
--- a/app/Entities/Clip/VideoClip.php
+++ b/app/Entities/Clip/VideoClip.php
@@ -10,20 +10,78 @@ declare(strict_types=1);
 
 namespace App\Entities\Clip;
 
+use App\Entities\Media\Video;
+use App\Models\MediaModel;
+use CodeIgniter\Files\File;
+
 /**
- * @property string $theme
+ * @property array $theme
+ * @property string $format
  */
 class VideoClip extends BaseClip
 {
     protected string $type = 'video';
 
+    /**
+     * @param array<string, mixed>|null $data
+     */
     public function __construct(array $data = null)
     {
         parent::__construct($data);
 
-        if ($this->metadata !== null) {
+        if ($this->metadata !== null && $this->metadata !== []) {
             $this->theme = $this->metadata['theme'];
             $this->format = $this->metadata['format'];
         }
     }
+
+    /**
+     * @param array<string, string> $theme
+     */
+    public function setTheme(array $theme): self
+    {
+        // TODO: change?
+        $this->attributes['metadata'] = json_decode($this->attributes['metadata'] ?? '[]', true);
+
+        $this->attributes['theme'] = $theme;
+        $this->attributes['metadata']['theme'] = $theme;
+
+        $this->attributes['metadata'] = json_encode($this->attributes['metadata']);
+
+        return $this;
+    }
+
+    public function setFormat(string $format): self
+    {
+        $this->attributes['metadata'] = json_decode($this->attributes['metadata'], true);
+
+        $this->attributes['format'] = $format;
+        $this->attributes['metadata']['format'] = $format;
+
+        $this->attributes['metadata'] = json_encode($this->attributes['metadata']);
+
+        return $this;
+    }
+
+    public function setMedia(string $filePath = null): static
+    {
+        if ($filePath === null) {
+            return $this;
+        }
+
+        $file = new File($filePath);
+
+        $video = new Video([
+            'file_path' => $filePath,
+            'language_code' => $this->getPodcast()
+                ->language_code,
+            'uploaded_by' => $this->attributes['created_by'],
+            'updated_by' => $this->attributes['created_by'],
+        ]);
+        $video->setFile($file);
+
+        $this->attributes['media_id'] = (new MediaModel())->saveMedia($video);
+
+        return $this;
+    }
 }
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 2bc3fc784c..57c4e672b8 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -10,7 +10,7 @@ declare(strict_types=1);
 
 namespace App\Entities;
 
-use App\Entities\Clip\BaseClip;
+use App\Entities\Clip\Soundbite;
 use App\Entities\Media\Audio;
 use App\Entities\Media\Chapters;
 use App\Entities\Media\Image;
@@ -75,7 +75,7 @@ use RuntimeException;
  * @property Time|null $deleted_at;
  *
  * @property Person[] $persons;
- * @property Soundbites[] $soundbites;
+ * @property Soundbite[] $soundbites;
  * @property string $embed_url;
  */
 class Episode extends Entity
@@ -110,7 +110,7 @@ class Episode extends Entity
     protected ?array $persons = null;
 
     /**
-     * @var Soundbites[]|null
+     * @var Soundbite[]|null
      */
     protected ?array $soundbites = null;
 
@@ -407,7 +407,7 @@ class Episode extends Entity
     /**
      * Returns the episode’s clips
      *
-     * @return BaseClip[]|\App\Entities\Soundbites[]
+     * @return Soundbite[]
      */
     public function getSoundbites(): array
     {
diff --git a/app/Libraries/MediaClipper/VideoClipper.php b/app/Libraries/MediaClipper/VideoClipper.php
index 6e223de294..9614c6f1b4 100644
--- a/app/Libraries/MediaClipper/VideoClipper.php
+++ b/app/Libraries/MediaClipper/VideoClipper.php
@@ -31,6 +31,12 @@ class VideoClipper
         'timestamp' => 'NotoSansMono-Regular.ttf',
     ];
 
+    public ?string $logs = null;
+
+    public bool $error = false;
+
+    public string $videoClipOutput;
+
     protected float $duration;
 
     protected string $audioInput;
@@ -45,8 +51,6 @@ class VideoClipper
 
     protected string $videoClipBgOutput;
 
-    protected string $videoClipOutput;
-
     protected ?string $episodeNumbering = null;
 
     /**
@@ -107,7 +111,10 @@ class VideoClipper
         }
     }
 
-    public function generate(): string
+    /**
+     * @return int 0 for success, else error
+     */
+    public function generate(): int
     {
         $this->soundbite();
         $this->subtitlesClip();
@@ -119,7 +126,7 @@ class VideoClipper
 
         $generateCmd = $this->getCmd();
 
-        return shell_exec($generateCmd . ' 2>&1');
+        return $this->cmd_exec($generateCmd);
     }
 
     public function getCmd(): string
@@ -205,7 +212,7 @@ class VideoClipper
             return false;
         }
 
-        $episodeCover = imagecreatefromjpeg($this->episodeCoverPath);
+        $episodeCover = $this->createCoverImage();
         if (! $episodeCover) {
             return false;
         }
@@ -340,6 +347,41 @@ class VideoClipper
         return true;
     }
 
+    /**
+     * @return int 0 (success), 1 - 2 - 254 - 255 (error)
+     */
+    private function cmd_exec(string $cmd): int
+    {
+        $outFile = tempnam(WRITEPATH . 'logs', 'cmd-out-');
+
+        if (! $outFile) {
+            return 254;
+        }
+
+        $descriptorSpec = [
+            0 => ['pipe', 'r'],
+            1 => ['pipe', 'w'],
+            2 => ['file', $outFile, 'w'],
+            // FFmpeg outputs to stderr by default
+        ];
+        $proc = proc_open($cmd, $descriptorSpec, $pipes);
+
+        if (! is_resource($proc)) {
+            return 255;
+        }
+
+        fclose($pipes[0]);    //Don't really want to give any input
+
+        $exit = proc_close($proc);
+
+        $this->logs = (string) file_get_contents($outFile);
+
+        // remove temporary files
+        unlink($outFile);
+
+        return $exit;
+    }
+
     private function getFont(string $name): string
     {
         return config('MediaClipper')->fontsFolder . self::FONTS[$name];
@@ -364,6 +406,15 @@ class VideoClipper
         return $background;
     }
 
+    private function createCoverImage(): GdImage | false
+    {
+        return match ($this->episode->cover->file_mimetype) {
+            'image/jpeg' => imagecreatefromjpeg($this->episodeCoverPath),
+            'image/png' => imagecreatefrompng($this->episodeCoverPath),
+            default => imagecreate(1400, 1400),
+        };
+    }
+
     private function scaleImage(GdImage $image, int $width, int $height): GdImage | false
     {
         return imagescale($image, $width, $height);
diff --git a/app/Models/ClipModel.php b/app/Models/ClipModel.php
index 6ce42cc9cd..93cd2fb29a 100644
--- a/app/Models/ClipModel.php
+++ b/app/Models/ClipModel.php
@@ -44,6 +44,7 @@ class ClipModel extends Model
         'duration',
         'type',
         'media_id',
+        'metadata',
         'status',
         'logs',
         'created_by',
@@ -65,21 +66,6 @@ class ClipModel extends Model
      */
     protected $useTimestamps = true;
 
-    /**
-     * @var string[]
-     */
-    protected $afterInsert = ['clearCache'];
-
-    /**
-     * @var string[]
-     */
-    protected $afterUpdate = ['clearCache'];
-
-    /**
-     * @var string[]
-     */
-    protected $beforeDelete = ['clearCache'];
-
     public function __construct(
         protected string $type = 'audio',
         ConnectionInterface &$db = null,
@@ -104,7 +90,7 @@ class ClipModel extends Model
     /**
      * Gets all clips for an episode
      *
-     * @return BaseClip[]
+     * @return Soundbite[]
      */
     public function getEpisodeSoundbites(int $podcastId, int $episodeId): array
     {
@@ -155,6 +141,27 @@ class ClipModel extends Model
         return $found;
     }
 
+    /**
+     * Gets scheduled video clips for an episode
+     *
+     * @return VideoClip[]
+     */
+    public function getScheduledVideoClips(): array
+    {
+        $found = $this->where([
+            'type' => 'video',
+            'status' => 'queued',
+        ])
+            ->orderBy('created_at')
+            ->findAll();
+
+        foreach ($found as $key => $videoClip) {
+            $found[$key] = new VideoClip($videoClip->toArray());
+        }
+
+        return $found;
+    }
+
     public function deleteSoundbite(int $podcastId, int $episodeId, int $clipId): BaseResult | bool
     {
         cache()
@@ -167,25 +174,6 @@ class ClipModel extends Model
         ]);
     }
 
-    /**
-     * @param array<string, array<string|int, mixed>> $data
-     * @return array<string, array<string|int, mixed>>
-     */
-    public function clearCache(array $data): array
-    {
-        $episode = (new EpisodeModel())->find(
-            isset($data['data'])
-                ? $data['data']['episode_id']
-                : $data['id']['episode_id'],
-        );
-
-        // delete cache for rss feed
-        cache()
-            ->deleteMatching("podcast#{$episode->podcast_id}_feed*");
-
-        cache()
-            ->deleteMatching("page_podcast#{$episode->podcast_id}_episode#{$episode->id}_*");
-
-        return $data;
-    }
+    //     cache()
+    //         ->deleteMatching("page_podcast#{$clip->podcast_id}_episode#{$clip->episode_id}_*");
 }
diff --git a/app/Resources/icons/forbid.svg b/app/Resources/icons/forbid.svg
new file mode 100644
index 0000000000..dbc2632cd2
--- /dev/null
+++ b/app/Resources/icons/forbid.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g>
+        <path fill="none" d="M0 0h24v24H0z"/>
+        <path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zM8.523 7.109A6.04 6.04 0 0 0 7.11 8.523l8.368 8.368a6.04 6.04 0 0 0 1.414-1.414L8.523 7.109z"/>
+    </g>
+</svg>
diff --git a/app/Views/Components/Pill.php b/app/Views/Components/Pill.php
new file mode 100644
index 0000000000..ead3922535
--- /dev/null
+++ b/app/Views/Components/Pill.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Views\Components;
+
+use ViewComponents\Component;
+
+class Pill extends Component
+{
+    /**
+     * @var 'small'|'base'
+     */
+    public string $size = 'base';
+
+    public string $variant = 'default';
+
+    public ?string $icon = null;
+
+    public function render(): string
+    {
+        $variantClasses = [
+            'default' => 'text-gray-800 bg-gray-100 border-gray-300',
+            'primary' => 'text-accent-contrast bg-accent-base border-accent-base',
+            'success' => 'text-pine-900 bg-pine-100 border-castopod-300',
+            'danger' => 'text-red-900 bg-red-100 border-red-300',
+            'warning' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
+        ];
+
+        $icon = $this->icon ? icon($this->icon) : '';
+
+        return <<<HTML
+            <span class="inline-flex items-center gap-x-1 px-1 font-semibold border rounded {$variantClasses[$this->variant]}">{$icon}{$this->slot}</span>
+        HTML;
+    }
+}
diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
index 59233f6198..66bb739b80 100644
--- a/modules/Admin/Config/Routes.php
+++ b/modules/Admin/Config/Routes.php
@@ -6,6 +6,11 @@ namespace Modules\Admin\Config;
 
 $routes = service('routes');
 
+// video-clips scheduler
+$routes->add('scheduled-video-clips', 'SchedulerController::generateVideoClips', [
+    'namespace' => 'Modules\Admin\Controllers',
+]);
+
 // Admin area routes
 $routes->group(
     config('Admin')
diff --git a/modules/Admin/Controllers/SchedulerController.php b/modules/Admin/Controllers/SchedulerController.php
new file mode 100644
index 0000000000..7ff4d0d3a9
--- /dev/null
+++ b/modules/Admin/Controllers/SchedulerController.php
@@ -0,0 +1,73 @@
+<?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 Modules\Admin\Controllers;
+
+use App\Models\ClipModel;
+use CodeIgniter\Controller;
+use MediaClipper\VideoClipper;
+
+class SchedulerController extends Controller
+{
+    public function generateVideoClips(): bool
+    {
+        // get all clips that haven't been processed yet
+        $scheduledClips = (new ClipModel())->getScheduledVideoClips();
+
+        if ($scheduledClips === []) {
+            return true;
+        }
+
+        $data = [];
+        foreach ($scheduledClips as $scheduledClip) {
+            $data[] = [
+                'id' => $scheduledClip->id,
+                'status' => 'pending',
+            ];
+        }
+
+        (new ClipModel())->updateBatch($data, 'id');
+
+        // Loop through clips to generate them
+        foreach ($scheduledClips as $scheduledClip) {
+            // set clip to pending
+            (new ClipModel())
+                ->update($scheduledClip->id, [
+                    'status' => 'running',
+                ]);
+            $clipper = new VideoClipper(
+                $scheduledClip->episode,
+                $scheduledClip->start_time,
+                $scheduledClip->end_time,
+                $scheduledClip->format,
+                $scheduledClip->theme['name'],
+            );
+            $exitCode = $clipper->generate();
+
+            if ($exitCode === 0) {
+                // success, video was generated
+                $scheduledClip->setMedia($clipper->videoClipOutput);
+                (new ClipModel())->update($scheduledClip->id, [
+                    'media_id' => $scheduledClip->media_id,
+                    'status' => 'passed',
+                    'logs' => $clipper->logs,
+                ]);
+            } else {
+                // error
+                (new ClipModel())->update($scheduledClip->id, [
+                    'status' => 'failed',
+                    'logs' => $clipper->logs,
+                ]);
+            }
+        }
+
+        return true;
+    }
+}
diff --git a/modules/Admin/Controllers/VideoClipsController.php b/modules/Admin/Controllers/VideoClipsController.php
index 82be57b06d..4c70f72cfe 100644
--- a/modules/Admin/Controllers/VideoClipsController.php
+++ b/modules/Admin/Controllers/VideoClipsController.php
@@ -10,7 +10,6 @@ declare(strict_types=1);
 
 namespace Modules\Admin\Controllers;
 
-use App\Entities\Clip;
 use App\Entities\Clip\VideoClip;
 use App\Entities\Episode;
 use App\Entities\Podcast;
@@ -19,7 +18,6 @@ use App\Models\EpisodeModel;
 use App\Models\PodcastModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
-use MediaClipper\VideoClipper;
 
 class VideoClipsController extends BaseController
 {
@@ -60,7 +58,7 @@ class VideoClipsController extends BaseController
 
     public function list(): string
     {
-        $videoClips = (new ClipModel('video'))
+        $videoClipsBuilder = (new ClipModel('video'))
             ->where([
                 'podcast_id' => $this->podcast->id,
                 'episode_id' => $this->episode->id,
@@ -68,11 +66,18 @@ class VideoClipsController extends BaseController
             ])
             ->orderBy('created_at', 'desc');
 
+        $clips = $videoClipsBuilder->paginate(10);
+
+        $videoClips = [];
+        foreach ($clips as $clip) {
+            $videoClips[] = new VideoClip($clip->toArray());
+        }
+
         $data = [
             'podcast' => $this->podcast,
             'episode' => $this->episode,
-            'videoClips' => $videoClips->paginate(10),
-            'pager' => $videoClips->pager,
+            'videoClips' => $videoClips,
+            'pager' => $videoClipsBuilder->pager,
         ];
 
         replace_breadcrumb_params([
@@ -115,10 +120,20 @@ class VideoClipsController extends BaseController
                 ->with('errors', $this->validator->getErrors());
         }
 
+        $themeName = $this->request->getPost('theme');
+        $themeColors = config('MediaClipper')
+            ->themes[$themeName];
+        $theme = [
+            'name' => $themeName,
+            'preview' => $themeColors['preview'],
+        ];
+
         $videoClip = new VideoClip([
             'label' => 'NEW CLIP',
             'start_time' => (float) $this->request->getPost('start_time'),
             'end_time' => (float) $this->request->getPost('end_time',),
+            'theme' => $theme,
+            'format' => $this->request->getPost('format'),
             'type' => 'video',
             'status' => 'queued',
             'podcast_id' => $this->podcast->id,
@@ -134,39 +149,4 @@ class VideoClipsController extends BaseController
             lang('Settings.images.regenerationSuccess')
         );
     }
-
-    public function scheduleClips(): void
-    {
-        // get all clips that haven't been generated
-        $scheduledClips = (new ClipModel())->getScheduledVideoClips();
-
-        foreach ($scheduledClips as $scheduledClip) {
-            $scheduledClip->status = 'pending';
-        }
-
-        (new ClipModel())->updateBatch($scheduledClips);
-
-        // Loop through clips to generate them
-        foreach ($scheduledClips as $scheduledClip) {
-            // set clip to pending
-            (new ClipModel())
-                ->update($scheduledClip->id, [
-                    'status' => 'running',
-                ]);
-            $clipper = new VideoClipper(
-                $scheduledClip->episode,
-                $scheduledClip->start_time,
-                $scheduledClip->end_time,
-                $scheduledClip->format,
-                $scheduledClip->theme,
-            );
-            $output = $clipper->generate();
-            $scheduledClip->setMedia($clipper->videoClipOutput);
-
-            (new ClipModel())->update($scheduledClip->id, [
-                'status' => 'passed',
-                'logs' => $output,
-            ]);
-        }
-    }
 }
diff --git a/themes/cp_admin/episode/video_clips_list.php b/themes/cp_admin/episode/video_clips_list.php
index ee3ac24cdd..75a98bb513 100644
--- a/themes/cp_admin/episode/video_clips_list.php
+++ b/themes/cp_admin/episode/video_clips_list.php
@@ -12,15 +12,50 @@
 <?= data_table(
     [
         [
-            'header' => lang('Episode.list.episode'),
+            'header' => lang('VideoClip.list.status'),
             'cell' => function ($videoClip): string {
-                return $videoClip->label;
+                $pillVariantMap = [
+                    'queued' => 'default',
+                    'pending' => 'warning',
+                    'running' => 'primary',
+                    'canceled' => 'default',
+                    'failed' => 'danger',
+                    'passed' => 'success',
+                ];
+
+                $pillIconMap = [
+                    'queued' => 'timer',
+                    'pending' => 'pause',
+                    'running' => 'play',
+                    'canceled' => 'forbid',
+                    'failed' => 'close',
+                    'passed' => 'check',
+                ];
+
+                return '<Pill variant="' . $pillVariantMap[$videoClip->status] . '" icon="' . $pillIconMap[$videoClip->status] . '">' . $videoClip->status . '</Pill>';
+            },
+        ],
+        [
+            'header' => lang('VideoClip.list.label'),
+            'cell' => function ($videoClip): string {
+                $formatClass = [
+                    'landscape' => 'aspect-video h-4',
+                    'portrait' => 'aspect-[9/16] w-4',
+                    'squared' => 'aspect-square h-6',
+                ];
+                return '<a href="#" class="inline-flex items-center w-full hover:underline gap-x-2"><span class="block w-3 h-3 rounded-full" data-tooltip="bottom" title="' . $videoClip->theme['name'] . '" style="background-color:hsl(' . $videoClip->theme['preview'] . ')"></span><span class="flex items-center justify-center text-white bg-gray-400 rounded-sm ' . $formatClass[$videoClip->format] . '" data-tooltip="bottom" title="' . $videoClip->format . '"><Icon glyph="play"/></span>' . $videoClip->label . '</a>';
+            },
+        ],
+        [
+            'header' => lang('VideoClip.list.clip_id'),
+            'cell' => function ($videoClip): string {
+                return '#' . $videoClip->id . ' by ' . $videoClip->user->username;
             },
         ],
         [
-            'header' => lang('Episode.list.visibility'),
+            'header' => lang('Common.actions'),
             'cell' => function ($videoClip): string {
-                return $videoClip->status;
+                return '…';
             },
         ],
     ],
-- 
GitLab