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