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'); +});