From c249a14d788f82bc3097b6ee5b11ebd94df55258 Mon Sep 17 00:00:00 2001 From: m105 Date: Thu, 6 Aug 2026 21:15:31 +0300 Subject: [PATCH] Add storyboard/video-gen/video-editing handlers and asset previews (#37-39, #45, #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StoryboardHandler (#37): breaks the project's script into a numbered scene-by-scene breakdown (description + image prompt per scene) via OpenAI in JSON mode (added json_mode support to OpenAiDriver for reliable structured output), stored as a JSON asset. VideoGenerationHandler (#38): generates one still image per storyboard scene via StabilityAI — the scene *visuals*, not motion/video clips (no text-to-video model integrated; see the task comment for why). VideoEditingHandler (#39): the step that actually produces a video file — assembles the scene stills into an MP4 via ffmpeg's concat demuxer, timed to the voiceover audio's duration (probed via ffprobe) or a fixed per-scene duration if there's no audio, then muxes the audio track in. Verified against the real ffmpeg/ffprobe binaries on this box, not just Process::fake(). Asset previews (#45, #46): a reusable partial renders each asset inline by type — image/audio/video get real playback elements, text/ JSON gets a truncated
 block, everything else a download link.
Wired into both the awaiting_approval step view (so approving/
rejecting is an actual content review, not just a word count) and a
general project assets grid.

Also fixes a latent bug in AssetStorage::url(), found while wiring up
previews: it called Storage::url() on the default 'local' disk, which
has no configured URL driver and throws in production — only worked
before because Storage::fake() is more permissive than the real local
driver. Now serves assets through a new authenticated streaming route
instead (VideoProjectAssetController).

Co-Authored-By: Claude Sonnet 5 
---
 .../VideoProjectAssetController.php           |  21 ++
 app/Providers/AppServiceProvider.php          |   6 +
 app/Support/AiProviders/OpenAiDriver.php      |  21 +-
 app/Support/AssetStorage/AssetStorage.php     |   9 +-
 .../StepHandlers/StoryboardHandler.php        | 116 +++++++++++
 .../StepHandlers/VideoEditingHandler.php      | 185 +++++++++++++++++
 .../StepHandlers/VideoGenerationHandler.php   | 123 ++++++++++++
 .../partials/asset-preview.blade.php          |  26 +++
 .../livewire/video-projects/show.blade.php    |  43 ++--
 routes/web.php                                |   1 +
 .../Feature/AiProviders/OpenAiDriverTest.php  |  20 ++
 .../Feature/AssetStorage/AssetStorageTest.php |   4 +-
 tests/Feature/Jobs/RunWorkflowStepJobTest.php |   2 +-
 .../Livewire/VideoProjectsShowTest.php        |  33 ++-
 .../VideoProjectAssetControllerTest.php       |  50 +++++
 .../StepHandlers/StoryboardHandlerTest.php    | 100 +++++++++
 .../StepHandlers/VideoEditingHandlerTest.php  | 189 ++++++++++++++++++
 .../VideoGenerationHandlerTest.php            | 111 ++++++++++
 18 files changed, 1026 insertions(+), 34 deletions(-)
 create mode 100644 app/Http/Controllers/VideoProjectAssetController.php
 create mode 100644 app/Support/VideoWorkflow/StepHandlers/StoryboardHandler.php
 create mode 100644 app/Support/VideoWorkflow/StepHandlers/VideoEditingHandler.php
 create mode 100644 app/Support/VideoWorkflow/StepHandlers/VideoGenerationHandler.php
 create mode 100644 resources/views/livewire/video-projects/partials/asset-preview.blade.php
 create mode 100644 tests/Feature/VideoProjectAssetControllerTest.php
 create mode 100644 tests/Feature/VideoWorkflow/StepHandlers/StoryboardHandlerTest.php
 create mode 100644 tests/Feature/VideoWorkflow/StepHandlers/VideoEditingHandlerTest.php
 create mode 100644 tests/Feature/VideoWorkflow/StepHandlers/VideoGenerationHandlerTest.php

diff --git a/app/Http/Controllers/VideoProjectAssetController.php b/app/Http/Controllers/VideoProjectAssetController.php
new file mode 100644
index 0000000..d3a09eb
--- /dev/null
+++ b/app/Http/Controllers/VideoProjectAssetController.php
@@ -0,0 +1,21 @@
+video_project_id !== $videoProject->id, 404);
+        abort_unless(Storage::exists($asset->disk_path), 404);
+
+        return Storage::response($asset->disk_path, null, [
+            'Content-Type' => $asset->mime_type ?: 'application/octet-stream',
+        ]);
+    }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 21e663d..722c92a 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -13,7 +13,10 @@ use App\Support\Publishing\TwitterPublisher;
 use App\Support\Publishing\WordPressPublisher;
 use App\Support\VideoWorkflow\StepHandlers\ScriptGenerationHandler;
 use App\Support\VideoWorkflow\StepHandlers\StepHandlerManager;
+use App\Support\VideoWorkflow\StepHandlers\StoryboardHandler;
 use App\Support\VideoWorkflow\StepHandlers\TtsVoiceoverHandler;
+use App\Support\VideoWorkflow\StepHandlers\VideoEditingHandler;
+use App\Support\VideoWorkflow\StepHandlers\VideoGenerationHandler;
 use Illuminate\Support\ServiceProvider;
 
 class AppServiceProvider extends ServiceProvider
@@ -39,6 +42,9 @@ class AppServiceProvider extends ServiceProvider
         $this->app->singleton(StepHandlerManager::class, fn ($app) => new StepHandlerManager([
             $app->make(ScriptGenerationHandler::class),
             $app->make(TtsVoiceoverHandler::class),
+            $app->make(StoryboardHandler::class),
+            $app->make(VideoGenerationHandler::class),
+            $app->make(VideoEditingHandler::class),
         ]));
     }
 
diff --git a/app/Support/AiProviders/OpenAiDriver.php b/app/Support/AiProviders/OpenAiDriver.php
index 170a60f..8bd2d52 100644
--- a/app/Support/AiProviders/OpenAiDriver.php
+++ b/app/Support/AiProviders/OpenAiDriver.php
@@ -16,14 +16,23 @@ class OpenAiDriver implements AiProviderDriver
     {
         $model = $options['model'] ?? $provider->config['model'] ?? 'gpt-4o-mini';
 
+        $body = [
+            'model' => $model,
+            'messages' => [
+                ['role' => 'user', 'content' => $prompt],
+            ],
+        ];
+
+        // Ask the API to guarantee valid JSON back, for callers that need
+        // structured output (e.g. a storyboard's scene breakdown). The
+        // prompt itself must still ask for JSON — this only enforces it.
+        if ($options['json_mode'] ?? false) {
+            $body['response_format'] = ['type' => 'json_object'];
+        }
+
         try {
             $response = Http::withToken($provider->api_key)
-                ->post('https://api.openai.com/v1/chat/completions', [
-                    'model' => $model,
-                    'messages' => [
-                        ['role' => 'user', 'content' => $prompt],
-                    ],
-                ]);
+                ->post('https://api.openai.com/v1/chat/completions', $body);
         } catch (ConnectionException $e) {
             throw new AiGenerationException("could not connect to OpenAI: {$e->getMessage()}");
         }
diff --git a/app/Support/AssetStorage/AssetStorage.php b/app/Support/AssetStorage/AssetStorage.php
index 64c91e1..adca7db 100644
--- a/app/Support/AssetStorage/AssetStorage.php
+++ b/app/Support/AssetStorage/AssetStorage.php
@@ -59,9 +59,16 @@ class AssetStorage
         return Storage::get($asset->disk_path);
     }
 
+    /**
+     * A URL to view/download this asset. Not Storage::url() — the default
+     * 'local' disk has no configured URL driver and throws if asked for
+     * one, so this goes through the authenticated streaming route instead.
+     */
     public function url(Asset $asset): ?string
     {
-        return Storage::exists($asset->disk_path) ? Storage::url($asset->disk_path) : null;
+        return Storage::exists($asset->disk_path)
+            ? route('video-projects.assets.show', [$asset->video_project_id, $asset->id])
+            : null;
     }
 
     public function delete(Asset $asset): void
diff --git a/app/Support/VideoWorkflow/StepHandlers/StoryboardHandler.php b/app/Support/VideoWorkflow/StepHandlers/StoryboardHandler.php
new file mode 100644
index 0000000..09821d4
--- /dev/null
+++ b/app/Support/VideoWorkflow/StepHandlers/StoryboardHandler.php
@@ -0,0 +1,116 @@
+where('provider', 'openai')->where('is_active', true)->first();
+
+        if (! $provider) {
+            $engine->failStep($step, 'No active OpenAI provider configured.');
+
+            return;
+        }
+
+        $script = $this->resolveScript($step);
+
+        if (! $script) {
+            $engine->failStep($step, 'No script available to storyboard — complete a script_generation step first.');
+
+            return;
+        }
+
+        try {
+            $result = $this->aiProviders->generate($provider, $this->buildPrompt($step, $script), ['json_mode' => true]);
+        } catch (AiGenerationException $e) {
+            $engine->failStep($step, $e->getMessage());
+
+            return;
+        }
+
+        $decoded = json_decode($result->content, true);
+        $scenes = $decoded['scenes'] ?? null;
+
+        if (! is_array($scenes) || empty($scenes)) {
+            $engine->failStep($step, 'AI provider returned an unusable storyboard: no scenes found.');
+
+            return;
+        }
+
+        $asset = $this->assets->store(
+            $step->videoProject,
+            'other',
+            json_encode(['scenes' => $scenes], JSON_PRETTY_PRINT),
+            'json',
+            step: $step,
+            mimeType: 'application/json',
+            metadata: ['kind' => 'storyboard', 'scene_count' => count($scenes)],
+        );
+
+        $engine->completeStep($step, [
+            'asset_id' => $asset->id,
+            'scene_count' => count($scenes),
+        ]);
+    }
+
+    private function resolveScript(WorkflowStep $step): ?string
+    {
+        $input = $step->input ?? [];
+
+        if (! empty($input['script'])) {
+            return $input['script'];
+        }
+
+        $scriptAsset = $step->videoProject->assets()
+            ->where('type', 'script')
+            ->orderByDesc('version')
+            ->first();
+
+        return $scriptAsset ? $this->assets->contents($scriptAsset) : null;
+    }
+
+    private function buildPrompt(WorkflowStep $step, string $script): string
+    {
+        $input = $step->input ?? [];
+
+        $prompt = "Break the following video script into a numbered scene-by-scene storyboard.\n\n"
+            ."Script:\n{$script}\n\n"
+            .'For each scene provide: scene_number (integer), description (what happens on screen), '
+            ."and visual_prompt (a concise, self-contained image-generation prompt describing this scene's visual).";
+
+        if (! empty($input['style'])) {
+            $prompt .= " Every visual_prompt must consistently reflect this visual style: {$input['style']}.";
+        }
+
+        $prompt .= "\n\nRespond with JSON only, in exactly this shape: "
+            .'{"scenes": [{"scene_number": 1, "description": "...", "visual_prompt": "..."}]}';
+
+        return $prompt;
+    }
+}
diff --git a/app/Support/VideoWorkflow/StepHandlers/VideoEditingHandler.php b/app/Support/VideoWorkflow/StepHandlers/VideoEditingHandler.php
new file mode 100644
index 0000000..e943f78
--- /dev/null
+++ b/app/Support/VideoWorkflow/StepHandlers/VideoEditingHandler.php
@@ -0,0 +1,185 @@
+resolveSceneImages($step);
+
+        if (! $images || $images->isEmpty()) {
+            $engine->failStep($step, 'No scene images available — complete a video_generation step first.');
+
+            return;
+        }
+
+        $audioAsset = $this->resolveAudio($step);
+
+        $tempDir = storage_path('app/tmp/video-editing-'.$step->id.'-'.uniqid());
+        File::ensureDirectoryExists($tempDir);
+
+        try {
+            $imagePaths = $images->map(fn (Asset $a) => Storage::path($a->disk_path))->all();
+            $audioPath = $audioAsset ? Storage::path($audioAsset->disk_path) : null;
+
+            $duration = null;
+
+            if ($audioPath) {
+                $duration = $this->probeDuration($audioPath);
+
+                if ($duration === null) {
+                    $engine->failStep($step, 'Could not read the voiceover audio\'s duration.');
+
+                    return;
+                }
+            }
+
+            $input = $step->input ?? [];
+            $perScene = $input['seconds_per_scene'] ?? ($duration ? $duration / count($imagePaths) : self::DEFAULT_SECONDS_PER_SCENE);
+
+            $concatListPath = "{$tempDir}/concat.txt";
+            $this->writeConcatList($concatListPath, $imagePaths, $perScene);
+
+            $silentVideoPath = "{$tempDir}/silent.mp4";
+            $result = Process::timeout(300)->run([
+                'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', $concatListPath,
+                '-vsync', 'vfr', '-pix_fmt', 'yuv420p', $silentVideoPath,
+            ]);
+
+            if ($result->failed()) {
+                $engine->failStep($step, 'ffmpeg failed to assemble scene images: '.trim($result->errorOutput()));
+
+                return;
+            }
+
+            $finalVideoPath = "{$tempDir}/final.mp4";
+
+            if ($audioPath) {
+                $result = Process::timeout(300)->run([
+                    'ffmpeg', '-y', '-i', $silentVideoPath, '-i', $audioPath,
+                    '-c:v', 'copy', '-c:a', 'aac', '-shortest', $finalVideoPath,
+                ]);
+
+                if ($result->failed()) {
+                    $engine->failStep($step, 'ffmpeg failed to mux the voiceover audio: '.trim($result->errorOutput()));
+
+                    return;
+                }
+            } else {
+                File::copy($silentVideoPath, $finalVideoPath);
+            }
+
+            $asset = $this->assets->store(
+                $step->videoProject,
+                'video',
+                File::get($finalVideoPath),
+                'mp4',
+                step: $step,
+                mimeType: 'video/mp4',
+                metadata: ['scene_count' => count($imagePaths), 'duration_seconds' => $duration ?? ($perScene * count($imagePaths))],
+            );
+
+            $engine->completeStep($step, ['asset_id' => $asset->id]);
+        } finally {
+            File::deleteDirectory($tempDir);
+        }
+    }
+
+    private function probeDuration(string $path): ?float
+    {
+        $result = Process::timeout(60)->run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', $path]);
+
+        if ($result->failed()) {
+            return null;
+        }
+
+        $value = trim($result->output());
+
+        return is_numeric($value) ? (float) $value : null;
+    }
+
+    private function writeConcatList(string $path, array $imagePaths, float $secondsPerImage): void
+    {
+        $lines = [];
+
+        foreach ($imagePaths as $imagePath) {
+            $lines[] = "file '".addslashes($imagePath)."'";
+            $lines[] = "duration {$secondsPerImage}";
+        }
+
+        // ffmpeg's concat demuxer ignores the last entry's duration, so the
+        // final image needs to be repeated once more without one.
+        $lines[] = "file '".addslashes(end($imagePaths))."'";
+
+        File::put($path, implode("\n", $lines));
+    }
+
+    /**
+     * @return Collection|null
+     */
+    private function resolveSceneImages(WorkflowStep $step): ?Collection
+    {
+        $input = $step->input ?? [];
+
+        $ids = $input['image_asset_ids'] ?? null;
+
+        if (! $ids) {
+            $videoGenStep = $step->videoProject->steps()
+                ->where('type', 'video_generation')
+                ->where('status', 'completed')
+                ->orderByDesc('completed_at')
+                ->first();
+
+            $ids = $videoGenStep?->output['asset_ids'] ?? null;
+        }
+
+        if (! $ids) {
+            return null;
+        }
+
+        $assetsById = Asset::whereIn('id', $ids)->get()->keyBy('id');
+
+        return collect($ids)->map(fn ($id) => $assetsById->get($id))->filter()->values();
+    }
+
+    private function resolveAudio(WorkflowStep $step): ?Asset
+    {
+        $input = $step->input ?? [];
+
+        if (! empty($input['audio_asset_id'])) {
+            return Asset::find($input['audio_asset_id']);
+        }
+
+        return $step->videoProject->assets()
+            ->where('type', 'audio')
+            ->orderByDesc('version')
+            ->first();
+    }
+}
diff --git a/app/Support/VideoWorkflow/StepHandlers/VideoGenerationHandler.php b/app/Support/VideoWorkflow/StepHandlers/VideoGenerationHandler.php
new file mode 100644
index 0000000..a001490
--- /dev/null
+++ b/app/Support/VideoWorkflow/StepHandlers/VideoGenerationHandler.php
@@ -0,0 +1,123 @@
+where('provider', 'stability_ai')->where('is_active', true)->first();
+
+        if (! $provider) {
+            $engine->failStep($step, 'No active StabilityAI provider configured.');
+
+            return;
+        }
+
+        $scenes = $this->resolveScenes($step);
+
+        if (! $scenes) {
+            $engine->failStep($step, 'No storyboard available — complete a storyboard step first.');
+
+            return;
+        }
+
+        $style = ($step->input ?? [])['style'] ?? null;
+        $assetIds = [];
+
+        foreach ($scenes as $scene) {
+            $prompt = $scene['visual_prompt'] ?? $scene['description'] ?? null;
+
+            if (! $prompt) {
+                continue;
+            }
+
+            if ($style) {
+                $prompt .= ", {$style}";
+            }
+
+            try {
+                $result = $this->aiProviders->generate($provider, $prompt);
+            } catch (AiGenerationException $e) {
+                $engine->failStep($step, "scene {$scene['scene_number']}: {$e->getMessage()}");
+
+                return;
+            }
+
+            $extension = str($result->contentType)->after('image/')->toString() ?: 'png';
+
+            $asset = $this->assets->store(
+                $step->videoProject,
+                'image',
+                $result->content,
+                $extension,
+                step: $step,
+                mimeType: $result->contentType,
+                metadata: ['scene_number' => $scene['scene_number'] ?? null, 'prompt' => $prompt],
+            );
+
+            $assetIds[] = $asset->id;
+        }
+
+        if (empty($assetIds)) {
+            $engine->failStep($step, 'No scenes had a usable visual_prompt.');
+
+            return;
+        }
+
+        $engine->completeStep($step, [
+            'asset_ids' => $assetIds,
+            'scene_count' => count($assetIds),
+        ]);
+    }
+
+    /**
+     * @return array>|null
+     */
+    private function resolveScenes(WorkflowStep $step): ?array
+    {
+        $input = $step->input ?? [];
+
+        if (! empty($input['scenes'])) {
+            return $input['scenes'];
+        }
+
+        $storyboardAsset = $step->videoProject->assets()
+            ->where('type', 'other')
+            ->whereJsonContains('metadata->kind', 'storyboard')
+            ->orderByDesc('version')
+            ->first();
+
+        if (! $storyboardAsset) {
+            return null;
+        }
+
+        $decoded = json_decode($this->assets->contents($storyboardAsset), true);
+
+        return $decoded['scenes'] ?? null;
+    }
+}
diff --git a/resources/views/livewire/video-projects/partials/asset-preview.blade.php b/resources/views/livewire/video-projects/partials/asset-preview.blade.php
new file mode 100644
index 0000000..d63bc4b
--- /dev/null
+++ b/resources/views/livewire/video-projects/partials/asset-preview.blade.php
@@ -0,0 +1,26 @@
+@php
+    $assetStorage = app(\App\Support\AssetStorage\AssetStorage::class);
+    $url = $assetStorage->url($asset);
+    $isText = $asset->type === 'script' || in_array($asset->mime_type, ['application/json', 'text/plain']);
+@endphp
+
+
+ @if ($asset->type === 'image') + + @elseif ($asset->type === 'audio') + + @elseif ($asset->type === 'video') + + @elseif ($isText) +
{{ \Illuminate\Support\Str::limit($assetStorage->contents($asset), 1000) }}
+ @else + Download {{ $asset->type }} + @endif + +
+ {{ ucfirst($asset->type) }} · v{{ $asset->version }} + @if ($asset->size_bytes) + · {{ number_format($asset->size_bytes / 1024, 1) }} KB + @endif +
+
diff --git a/resources/views/livewire/video-projects/show.blade.php b/resources/views/livewire/video-projects/show.blade.php index 31358d0..7af64b5 100644 --- a/resources/views/livewire/video-projects/show.blade.php +++ b/resources/views/livewire/video-projects/show.blade.php @@ -87,37 +87,36 @@ {{ $step->output['word_count'] }} words generated @elseif (isset($step->output['characters'])) {{ $step->output['characters'] }} characters synthesized + @elseif (isset($step->output['scene_count'])) + {{ $step->output['scene_count'] }} scenes @endif

@endif + + @if ($step->status === 'awaiting_approval' && $step->assets->isNotEmpty()) +
+ @foreach ($step->assets->sortBy('version') as $asset) +
+ @include('livewire.video-projects.partials.asset-preview', ['asset' => $asset]) +
+ @endforeach +
+ @endif @endforeach @endif @if ($assets->isNotEmpty()) -
-

Assets

- - - - - - - - - - - @foreach ($assets as $asset) - - - - - - - @endforeach - -
TypeVersionSizeCreated
{{ ucfirst($asset->type) }}v{{ $asset->version }}{{ $asset->size_bytes ? number_format($asset->size_bytes / 1024, 1).' KB' : '—' }}{{ $asset->created_at->format('Y-m-d H:i') }}
+
+

Assets

+
+ @foreach ($assets as $asset) +
+ @include('livewire.video-projects.partials.asset-preview', ['asset' => $asset]) +
+ @endforeach +
@endif
diff --git a/routes/web.php b/routes/web.php index d3841e3..5f903a0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,4 +36,5 @@ Route::middleware('auth')->group(function () { // see task #47 on the "YouTube Content Creation System" project. Route::get('/video-projects', \App\Livewire\VideoProjects\Index::class)->name('video-projects.index'); Route::get('/video-projects/{videoProject}', \App\Livewire\VideoProjects\Show::class)->name('video-projects.show'); + Route::get('/video-projects/{videoProject}/assets/{asset}', [\App\Http\Controllers\VideoProjectAssetController::class, 'show'])->name('video-projects.assets.show'); }); diff --git a/tests/Feature/AiProviders/OpenAiDriverTest.php b/tests/Feature/AiProviders/OpenAiDriverTest.php index ec65ede..9c05db0 100644 --- a/tests/Feature/AiProviders/OpenAiDriverTest.php +++ b/tests/Feature/AiProviders/OpenAiDriverTest.php @@ -51,6 +51,26 @@ test('it throws when OpenAI returns an error', function () { (new OpenAiDriver)->generate($provider, 'hi'); })->throws(AiGenerationException::class, 'Invalid API key'); +test('json_mode requests a JSON-guaranteed response format', function () { + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => '{}']]]])]); + + $provider = AiProvider::factory()->create(['provider' => 'openai']); + + (new OpenAiDriver)->generate($provider, 'hi', ['json_mode' => true]); + + Http::assertSent(fn ($request) => ($request['response_format']['type'] ?? null) === 'json_object'); +}); + +test('it omits response_format when json_mode is not set', function () { + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'hi']]]])]); + + $provider = AiProvider::factory()->create(['provider' => 'openai']); + + (new OpenAiDriver)->generate($provider, 'hi'); + + Http::assertSent(fn ($request) => ! isset($request['response_format'])); +}); + test('it throws when the response has no message content', function () { Http::fake([ 'api.openai.com/*' => Http::response(['choices' => []]), diff --git a/tests/Feature/AssetStorage/AssetStorageTest.php b/tests/Feature/AssetStorage/AssetStorageTest.php index 688c676..802a45c 100644 --- a/tests/Feature/AssetStorage/AssetStorageTest.php +++ b/tests/Feature/AssetStorage/AssetStorageTest.php @@ -60,11 +60,11 @@ test('contents retrieves the stored file', function () { expect($this->storage->contents($asset))->toBe('hello'); }); -test('url returns null for a missing file and a path for an existing one', function () { +test('url returns the authenticated streaming route for an existing file, null for a missing one', function () { $project = VideoProject::factory()->create(); $asset = $this->storage->store($project, 'script', 'hello', 'txt'); - expect($this->storage->url($asset))->not->toBeNull(); + expect($this->storage->url($asset))->toBe(route('video-projects.assets.show', [$project->id, $asset->id])); Storage::delete($asset->disk_path); diff --git a/tests/Feature/Jobs/RunWorkflowStepJobTest.php b/tests/Feature/Jobs/RunWorkflowStepJobTest.php index 24bb0de..9fdf6c2 100644 --- a/tests/Feature/Jobs/RunWorkflowStepJobTest.php +++ b/tests/Feature/Jobs/RunWorkflowStepJobTest.php @@ -28,7 +28,7 @@ test('it runs the matching handler and completes the step', function () { test('it fails the step when no handler is registered for its type', function () { $project = VideoProject::factory()->create(); - $step = $this->engine->start($project, [['type' => 'video_generation']]); + $step = $this->engine->start($project, [['type' => 'thumbnail_generation']]); (new RunWorkflowStepJob($step->id))->handle(app(StepHandlerManager::class), $this->engine); diff --git a/tests/Feature/Livewire/VideoProjectsShowTest.php b/tests/Feature/Livewire/VideoProjectsShowTest.php index 8c1e213..e731094 100644 --- a/tests/Feature/Livewire/VideoProjectsShowTest.php +++ b/tests/Feature/Livewire/VideoProjectsShowTest.php @@ -2,10 +2,13 @@ use App\Jobs\RunWorkflowStepJob; use App\Livewire\VideoProjects\Show; +use App\Models\User; use App\Models\VideoProject; use App\Models\WorkflowTemplate; +use App\Support\AssetStorage\AssetStorage; use App\Support\VideoWorkflow\WorkflowEngine; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; test('starting the default pipeline creates steps and queues the first handled one', function () { @@ -29,7 +32,7 @@ test('starting the default pipeline creates steps and queues the first handled o test('starting from a template uses its steps and does not queue unhandled types', function () { Queue::fake(); $template = WorkflowTemplate::factory()->create(['steps' => [ - ['type' => 'video_generation'], + ['type' => 'thumbnail_generation'], ['type' => 'youtube_publish'], ]]); $project = VideoProject::factory()->create(); @@ -41,7 +44,7 @@ test('starting from a template uses its steps and does not queue unhandled types expect($project->fresh()->workflow_template_id)->toBe($template->id); expect($project->steps()->count())->toBe(2); - // video_generation has no handler yet, so nothing should be queued + // thumbnail_generation has no handler yet, so nothing should be queued Queue::assertNotPushed(RunWorkflowStepJob::class); }); @@ -127,3 +130,29 @@ test('skipping a step advances and queues the next handled step', function () { expect($next->status)->toBe('in_progress'); Queue::assertPushed(RunWorkflowStepJob::class, fn ($job) => $job->stepId === $next->id); }); + +test('an awaiting_approval step shows a preview of its generated asset', function () { + Storage::fake(); + $project = VideoProject::factory()->create(); + $engine = new WorkflowEngine; + $step = $engine->start($project, [['type' => 'script_generation', 'requires_approval' => true]]); + $asset = (new AssetStorage)->store($project, 'script', 'Once upon a time, a script.', 'txt', step: $step, mimeType: 'text/plain'); + $engine->completeStep($step, ['asset_id' => $asset->id]); + + $this->actingAs(User::factory()->create()) + ->get(route('video-projects.show', $project)) + ->assertOk() + ->assertSee('Once upon a time, a script.'); +}); + +test('the assets section renders a preview for an image asset', function () { + Storage::fake(); + $project = VideoProject::factory()->create(); + $asset = (new AssetStorage)->store($project, 'image', 'fake-png-bytes', 'png', mimeType: 'image/png'); + + $response = $this->actingAs(User::factory()->create()) + ->get(route('video-projects.show', $project)); + + $response->assertOk(); + $response->assertSee(route('video-projects.assets.show', [$project, $asset]), false); +}); diff --git a/tests/Feature/VideoProjectAssetControllerTest.php b/tests/Feature/VideoProjectAssetControllerTest.php new file mode 100644 index 0000000..6b055ed --- /dev/null +++ b/tests/Feature/VideoProjectAssetControllerTest.php @@ -0,0 +1,50 @@ +create(); + $asset = (new AssetStorage)->store($project, 'script', 'hello world', 'txt', mimeType: 'text/plain'); + + $response = $this->actingAs(User::factory()->create()) + ->get(route('video-projects.assets.show', [$project, $asset])); + + $response->assertOk(); + expect($response->headers->get('Content-Type'))->toStartWith('text/plain'); + expect($response->streamedContent())->toBe('hello world'); +}); + +test('it 404s when the asset does not belong to the given project', function () { + $project = VideoProject::factory()->create(); + $otherProject = VideoProject::factory()->create(); + $asset = (new AssetStorage)->store($otherProject, 'script', 'hello', 'txt'); + + $this->actingAs(User::factory()->create()) + ->get(route('video-projects.assets.show', [$project, $asset])) + ->assertNotFound(); +}); + +test('it 404s when the underlying file is missing', function () { + $project = VideoProject::factory()->create(); + $asset = (new AssetStorage)->store($project, 'script', 'hello', 'txt'); + Storage::delete($asset->disk_path); + + $this->actingAs(User::factory()->create()) + ->get(route('video-projects.assets.show', [$project, $asset])) + ->assertNotFound(); +}); + +test('guests cannot view an asset', function () { + $project = VideoProject::factory()->create(); + $asset = (new AssetStorage)->store($project, 'script', 'hello', 'txt'); + + $this->get(route('video-projects.assets.show', [$project, $asset])) + ->assertRedirect(route('login')); +}); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/StoryboardHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/StoryboardHandlerTest.php new file mode 100644 index 0000000..e6aada8 --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/StoryboardHandlerTest.php @@ -0,0 +1,100 @@ +engine = new WorkflowEngine; + $this->assets = new AssetStorage; + $this->handler = new StoryboardHandler(app(AiProviderManager::class), $this->assets); +}); + +test('it breaks the script into scenes, stores them, and completes the step', function () { + $scenesJson = json_encode(['scenes' => [ + ['scene_number' => 1, 'description' => 'Intro shot', 'visual_prompt' => 'a bright kitchen'], + ['scene_number' => 2, 'description' => 'Demo', 'visual_prompt' => 'a chef cooking'], + ]]); + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => $scenesJson]]]])]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'script', 'A script about cooking.', 'txt'); + $step = $this->engine->start($project, [['type' => 'storyboard']]); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + expect($fresh->output['scene_count'])->toBe(2); + + $asset = \App\Models\Asset::find($fresh->output['asset_id']); + expect($asset->type)->toBe('other'); + expect($asset->metadata['kind'])->toBe('storyboard'); + $stored = json_decode(Storage::get($asset->disk_path), true); + expect($stored['scenes'])->toHaveCount(2); + + Http::assertSent(function ($request) { + return str_contains($request['messages'][0]['content'], 'A script about cooking.') + && ($request['response_format']['type'] ?? null) === 'json_object'; + }); +}); + +test('a style hint is included in the prompt when given', function () { + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => json_encode(['scenes' => [['scene_number' => 1, 'description' => 'x', 'visual_prompt' => 'y']]])]]]])]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'storyboard']]); + $step->update(['input' => ['script' => 'a script', 'style' => 'noir black and white']]); + + $this->handler->handle($step->fresh(), $this->engine); + + Http::assertSent(fn ($request) => str_contains($request['messages'][0]['content'], 'noir black and white')); +}); + +test('it fails when there is no script to storyboard', function () { + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'storyboard']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No script available'); +}); + +test('it fails when no active OpenAI provider is configured', function () { + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'script', 'a script', 'txt'); + $step = $this->engine->start($project, [['type' => 'storyboard']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No active OpenAI provider'); +}); + +test('it fails when the AI response has no usable scenes', function () { + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => '{"not_scenes": []}']]]])]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'script', 'a script', 'txt'); + $step = $this->engine->start($project, [['type' => 'storyboard']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('unusable storyboard'); +}); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/VideoEditingHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/VideoEditingHandlerTest.php new file mode 100644 index 0000000..0ff9938 --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/VideoEditingHandlerTest.php @@ -0,0 +1,189 @@ +engine = new WorkflowEngine; + $this->assets = new AssetStorage; + $this->handler = new VideoEditingHandler($this->assets); + + // Simulates ffmpeg actually writing its output file, and ffprobe + // reporting a duration, without invoking the real binaries. + $this->fakeFfmpegSuccess = function () { + Process::fake(function ($process) { + $binary = basename($process->command[0] ?? ''); + + if ($binary === 'ffprobe') { + return Process::result(output: '10.0'); + } + + File::put(end($process->command), 'fake-video-bytes'); + + return Process::result(); + }); + }; + + // Starts a project with video_generation -> video_editing, completes + // the video_generation step (which auto-activates video_editing via + // the engine), and returns that now in_progress video_editing step. + $this->setupPipelineReadyForEditing = function (VideoProject $project, int $sceneCount = 2) { + $videoGenStep = $this->engine->start($project, [ + ['type' => 'video_generation'], + ['type' => 'video_editing'], + ]); + + $assetIds = []; + + for ($i = 1; $i <= $sceneCount; $i++) { + $asset = $this->assets->store($project, 'image', "scene {$i} bytes", 'png', mimeType: 'image/png', metadata: ['scene_number' => $i]); + $assetIds[] = $asset->id; + } + + $this->engine->completeStep($videoGenStep, ['asset_ids' => $assetIds, 'scene_count' => $sceneCount]); + + return $project->steps()->where('type', 'video_editing')->first(); + }; +}); + +test('it assembles scene images with the voiceover audio into a video asset', function () { + ($this->fakeFfmpegSuccess)(); + + $project = VideoProject::factory()->create(); + $step = ($this->setupPipelineReadyForEditing)($project, 2); + $this->assets->store($project, 'audio', 'fake-mp3-bytes', 'mp3', mimeType: 'audio/mpeg'); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + + $asset = Asset::find($fresh->output['asset_id']); + expect($asset->type)->toBe('video'); + expect($asset->mime_type)->toBe('video/mp4'); + expect($asset->metadata['scene_count'])->toBe(2); + expect($asset->metadata['duration_seconds'])->toEqual(10.0); + expect(Storage::get($asset->disk_path))->toBe('fake-video-bytes'); + + Process::assertRan(fn ($process) => basename($process->command[0]) === 'ffprobe'); + Process::assertRan(fn ($process) => in_array('concat', $process->command)); + Process::assertRan(fn ($process) => in_array('-shortest', $process->command)); +}); + +test('it assembles scene images with a default duration when there is no audio', function () { + ($this->fakeFfmpegSuccess)(); + + $project = VideoProject::factory()->create(); + $step = ($this->setupPipelineReadyForEditing)($project, 3); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + + $asset = Asset::find($fresh->output['asset_id']); + expect($asset->metadata['duration_seconds'])->toEqual(12.0); // 3 scenes * 4s default + + Process::assertNotRan(fn ($process) => basename($process->command[0]) === 'ffprobe'); + Process::assertNotRan(fn ($process) => in_array('-shortest', $process->command)); +}); + +test('a seconds_per_scene input overrides the default and the audio-derived duration', function () { + ($this->fakeFfmpegSuccess)(); + + $project = VideoProject::factory()->create(); + $step = ($this->setupPipelineReadyForEditing)($project, 2); + $step->update(['input' => ['seconds_per_scene' => 7]]); + + $this->handler->handle($step->fresh(), $this->engine); + + $asset = Asset::find($step->fresh()->output['asset_id']); + expect($asset->metadata['duration_seconds'])->toEqual(14.0); // 2 scenes * 7s +}); + +test('it fails the step when there are no scene images', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'video_editing']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No scene images available'); +}); + +test('it fails the step when ffmpeg fails to assemble the images', function () { + Process::fake(function ($process) { + $binary = basename($process->command[0] ?? ''); + + if ($binary === 'ffprobe') { + return Process::result(output: '10.0'); + } + + return Process::result(errorOutput: 'concat blew up', exitCode: 1); + }); + + $project = VideoProject::factory()->create(); + $step = ($this->setupPipelineReadyForEditing)($project, 2); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('concat blew up'); +}); + +test('it fails the step when ffmpeg fails to mux the audio', function () { + Process::fake(function ($process) { + $binary = basename($process->command[0] ?? ''); + + if ($binary === 'ffprobe') { + return Process::result(output: '10.0'); + } + + if (in_array('concat', $process->command)) { + File::put(end($process->command), 'silent-video-bytes'); + + return Process::result(); + } + + return Process::result(errorOutput: 'mux blew up', exitCode: 1); + }); + + $project = VideoProject::factory()->create(); + $step = ($this->setupPipelineReadyForEditing)($project, 2); + $this->assets->store($project, 'audio', 'fake-mp3-bytes', 'mp3'); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('mux blew up'); +}); + +test('it fails the step when the audio duration cannot be read', function () { + Process::fake(function ($process) { + $binary = basename($process->command[0] ?? ''); + + return $binary === 'ffprobe' + ? Process::result(errorOutput: 'no such file', exitCode: 1) + : Process::result(); + }); + + $project = VideoProject::factory()->create(); + $step = ($this->setupPipelineReadyForEditing)($project, 2); + $this->assets->store($project, 'audio', 'fake-mp3-bytes', 'mp3'); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('duration'); +}); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/VideoGenerationHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/VideoGenerationHandlerTest.php new file mode 100644 index 0000000..22d6a12 --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/VideoGenerationHandlerTest.php @@ -0,0 +1,111 @@ +engine = new WorkflowEngine; + $this->assets = new AssetStorage; + $this->handler = new VideoGenerationHandler(app(AiProviderManager::class), $this->assets); + + $this->storeStoryboard = function (VideoProject $project, array $scenes) { + return $this->assets->store( + $project, + 'other', + json_encode(['scenes' => $scenes]), + 'json', + mimeType: 'application/json', + metadata: ['kind' => 'storyboard'], + ); + }; +}); + +test('it generates one image per scene and completes the step', function () { + Http::fake(['api.stability.ai/*' => Http::response(['image' => base64_encode('fake-bytes')])]); + AiProvider::factory()->create(['provider' => 'stability_ai']); + + $project = VideoProject::factory()->create(); + ($this->storeStoryboard)($project, [ + ['scene_number' => 1, 'description' => 'a', 'visual_prompt' => 'a red bicycle'], + ['scene_number' => 2, 'description' => 'b', 'visual_prompt' => 'a blue car'], + ]); + $step = $this->engine->start($project, [['type' => 'video_generation']]); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + expect($fresh->output['scene_count'])->toBe(2); + expect($fresh->output['asset_ids'])->toHaveCount(2); + + $assets = Asset::whereIn('id', $fresh->output['asset_ids'])->get(); + expect($assets)->toHaveCount(2); + $assets->each(fn ($a) => expect($a->type)->toBe('image')); + + Http::assertSentCount(2); +}); + +test('a style hint is appended to every scene prompt', function () { + Http::fake(['api.stability.ai/*' => Http::response(['image' => base64_encode('bytes')])]); + AiProvider::factory()->create(['provider' => 'stability_ai']); + + $project = VideoProject::factory()->create(); + ($this->storeStoryboard)($project, [ + ['scene_number' => 1, 'description' => 'a', 'visual_prompt' => 'a red bicycle'], + ]); + $step = $this->engine->start($project, [['type' => 'video_generation']]); + $step->update(['input' => ['style' => 'watercolor']]); + + $this->handler->handle($step->fresh(), $this->engine); + + // Multipart fields aren't queryable via $request['field'] — check the raw body instead. + Http::assertSent(fn ($request) => str_contains($request->body(), 'watercolor')); +}); + +test('it fails the step when there is no storyboard', function () { + AiProvider::factory()->create(['provider' => 'stability_ai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'video_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No storyboard available'); +}); + +test('it fails the step when no active StabilityAI provider is configured', function () { + $project = VideoProject::factory()->create(); + ($this->storeStoryboard)($project, [['scene_number' => 1, 'description' => 'a', 'visual_prompt' => 'x']]); + $step = $this->engine->start($project, [['type' => 'video_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No active StabilityAI provider'); +}); + +test('it fails the step and reports which scene failed when generation errors', function () { + Http::fake(['api.stability.ai/*' => Http::response(['errors' => ['bad prompt']], 400)]); + AiProvider::factory()->create(['provider' => 'stability_ai']); + + $project = VideoProject::factory()->create(); + ($this->storeStoryboard)($project, [['scene_number' => 3, 'description' => 'a', 'visual_prompt' => 'x']]); + $step = $this->engine->start($project, [['type' => 'video_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('scene 3'); +});