diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ba2eaa0..f0767ef 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,10 @@ namespace App\Providers; +use App\Support\AiProviders\AiProviderManager; +use App\Support\AiProviders\ElevenLabsDriver; +use App\Support\AiProviders\OpenAiDriver; +use App\Support\AiProviders\StabilityAiDriver; use App\Support\Publishing\LinkedInPublisher; use App\Support\Publishing\PublisherManager; use App\Support\Publishing\RedditPublisher; @@ -22,6 +26,12 @@ class AppServiceProvider extends ServiceProvider $app->make(TwitterPublisher::class), $app->make(LinkedInPublisher::class), ])); + + $this->app->singleton(AiProviderManager::class, fn ($app) => new AiProviderManager([ + 'openai' => $app->make(OpenAiDriver::class), + 'stability_ai' => $app->make(StabilityAiDriver::class), + 'elevenlabs' => $app->make(ElevenLabsDriver::class), + ])); } /** diff --git a/app/Support/AiProviders/AiGenerationException.php b/app/Support/AiProviders/AiGenerationException.php new file mode 100644 index 0000000..88fbe49 --- /dev/null +++ b/app/Support/AiProviders/AiGenerationException.php @@ -0,0 +1,7 @@ + $raw The provider's decoded response, for debugging/metadata. + */ + public function __construct( + public readonly string $content, + public readonly string $contentType, + public readonly array $raw = [], + ) {} +} diff --git a/app/Support/AiProviders/AiProviderDriver.php b/app/Support/AiProviders/AiProviderDriver.php new file mode 100644 index 0000000..07e5b54 --- /dev/null +++ b/app/Support/AiProviders/AiProviderDriver.php @@ -0,0 +1,21 @@ +config for this call only (e.g. model, voice_id). + * + * @param array $options + * + * @throws AiGenerationException on any failure (bad credentials, + * unreachable API, rejected prompt, unexpected response shape). + */ + public function generate(AiProvider $provider, string $prompt, array $options = []): AiGenerationResult; +} diff --git a/app/Support/AiProviders/AiProviderManager.php b/app/Support/AiProviders/AiProviderManager.php new file mode 100644 index 0000000..846140c --- /dev/null +++ b/app/Support/AiProviders/AiProviderManager.php @@ -0,0 +1,34 @@ + $drivers Keyed by AiProvider::PROVIDERS value. + */ + public function __construct(private readonly array $drivers) {} + + public function driverFor(AiProvider $provider): AiProviderDriver + { + if (! isset($this->drivers[$provider->provider])) { + throw new AiGenerationException("No driver registered for AI provider '{$provider->provider}'."); + } + + return $this->drivers[$provider->provider]; + } + + /** + * @param array $options + */ + public function generate(AiProvider $provider, string $prompt, array $options = []): AiGenerationResult + { + if (! $provider->is_active) { + throw new AiGenerationException("AI provider '{$provider->name}' is not active."); + } + + return $this->driverFor($provider)->generate($provider, $prompt, $options); + } +} diff --git a/app/Support/AiProviders/ElevenLabsDriver.php b/app/Support/AiProviders/ElevenLabsDriver.php new file mode 100644 index 0000000..8e3aa88 --- /dev/null +++ b/app/Support/AiProviders/ElevenLabsDriver.php @@ -0,0 +1,44 @@ +config['voice_id'] ?? null; + + if (! $voiceId) { + throw new AiGenerationException("AI provider '{$provider->name}' has no voice_id configured for text-to-speech."); + } + + $modelId = $options['model_id'] ?? $provider->config['model_id'] ?? 'eleven_multilingual_v2'; + + try { + $response = Http::withHeaders(['xi-api-key' => $provider->api_key]) + ->post("https://api.elevenlabs.io/v1/text-to-speech/{$voiceId}", [ + 'text' => $prompt, + 'model_id' => $modelId, + ]); + } catch (ConnectionException $e) { + throw new AiGenerationException("could not connect to ElevenLabs: {$e->getMessage()}"); + } + + if ($response->failed()) { + $error = $response->json('detail.message') ?? $response->json('detail') ?? 'unexpected response from ElevenLabs'; + + throw new AiGenerationException(is_string($error) ? $error : 'unexpected response from ElevenLabs'); + } + + return new AiGenerationResult($response->body(), 'audio/mpeg'); + } +} diff --git a/app/Support/AiProviders/OpenAiDriver.php b/app/Support/AiProviders/OpenAiDriver.php new file mode 100644 index 0000000..170a60f --- /dev/null +++ b/app/Support/AiProviders/OpenAiDriver.php @@ -0,0 +1,43 @@ +config['model'] ?? 'gpt-4o-mini'; + + try { + $response = Http::withToken($provider->api_key) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ]); + } catch (ConnectionException $e) { + throw new AiGenerationException("could not connect to OpenAI: {$e->getMessage()}"); + } + + if ($response->failed()) { + throw new AiGenerationException($response->json('error.message') ?? 'unexpected response from OpenAI'); + } + + $content = $response->json('choices.0.message.content'); + + if (! $content) { + throw new AiGenerationException('unexpected response from OpenAI: missing message content'); + } + + return new AiGenerationResult($content, 'text/plain', $response->json()); + } +} diff --git a/app/Support/AiProviders/StabilityAiDriver.php b/app/Support/AiProviders/StabilityAiDriver.php new file mode 100644 index 0000000..332d809 --- /dev/null +++ b/app/Support/AiProviders/StabilityAiDriver.php @@ -0,0 +1,49 @@ +config['output_format'] ?? 'png'; + + try { + $response = Http::withToken($provider->api_key) + ->accept('application/json') + ->asMultipart() + ->attach('prompt', $prompt) + ->attach('output_format', $outputFormat) + ->post('https://api.stability.ai/v2beta/stable-image/generate/core'); + } catch (ConnectionException $e) { + throw new AiGenerationException("could not connect to StabilityAI: {$e->getMessage()}"); + } + + if ($response->failed()) { + $error = $response->json('errors.0') ?? $response->json('message') ?? 'unexpected response from StabilityAI'; + + throw new AiGenerationException($error); + } + + $base64 = $response->json('image'); + + if (! $base64) { + throw new AiGenerationException('unexpected response from StabilityAI: missing image data'); + } + + $bytes = base64_decode($base64); + + return new AiGenerationResult($bytes, "image/{$outputFormat}", [ + 'seed' => $response->json('seed'), + 'finish_reason' => $response->json('finish_reason'), + ]); + } +} diff --git a/app/Support/AssetStorage/AssetStorage.php b/app/Support/AssetStorage/AssetStorage.php new file mode 100644 index 0000000..64c91e1 --- /dev/null +++ b/app/Support/AssetStorage/AssetStorage.php @@ -0,0 +1,94 @@ + $metadata + */ + public function store( + VideoProject $project, + string $type, + string $contents, + string $extension, + ?WorkflowStep $step = null, + ?string $mimeType = null, + array $metadata = [], + ): Asset { + $version = $this->nextVersion($project, $type, $step); + $path = "video-projects/{$project->id}/{$type}/v{$version}.".ltrim($extension, '.'); + + Storage::put($path, $contents); + + return $project->assets()->create([ + 'workflow_step_id' => $step?->id, + 'type' => $type, + 'disk_path' => $path, + 'mime_type' => $mimeType, + 'size_bytes' => strlen($contents), + 'version' => $version, + 'metadata' => $metadata, + ]); + } + + public function nextVersion(VideoProject $project, string $type, ?WorkflowStep $step = null): int + { + $query = $project->assets()->where('type', $type); + + if ($step) { + $query->where('workflow_step_id', $step->id); + } + + return ((int) $query->max('version')) + 1; + } + + public function contents(Asset $asset): string + { + return Storage::get($asset->disk_path); + } + + public function url(Asset $asset): ?string + { + return Storage::exists($asset->disk_path) ? Storage::url($asset->disk_path) : null; + } + + public function delete(Asset $asset): void + { + Storage::delete($asset->disk_path); + $asset->delete(); + } + + /** + * Keep only the newest $keep versions for a (project, type[, step]) + * slot; delete the file + record for the rest. Returns how many were + * pruned. + */ + public function pruneOldVersions(VideoProject $project, string $type, ?WorkflowStep $step = null, int $keep = 3): int + { + $query = $project->assets()->where('type', $type); + + if ($step) { + $query->where('workflow_step_id', $step->id); + } + + $stale = $query->orderByDesc('version')->get()->slice($keep); + + foreach ($stale as $asset) { + $this->delete($asset); + } + + return $stale->count(); + } +} diff --git a/tests/Feature/AiProviders/AiProviderManagerTest.php b/tests/Feature/AiProviders/AiProviderManagerTest.php new file mode 100644 index 0000000..c070620 --- /dev/null +++ b/tests/Feature/AiProviders/AiProviderManagerTest.php @@ -0,0 +1,46 @@ + $driver]); + $provider = AiProvider::factory()->create(['provider' => 'openai']); + + $result = $manager->generate($provider, 'hello'); + + expect($result->content)->toBe('echo: hello'); +}); + +test('it throws when no driver is registered for the provider key', function () { + $manager = new AiProviderManager([]); + $provider = AiProvider::factory()->create(['provider' => 'openai']); + + $manager->generate($provider, 'hello'); +})->throws(AiGenerationException::class, "No driver registered for AI provider 'openai'"); + +test('it refuses to generate with an inactive provider', function () { + $driver = new class implements AiProviderDriver + { + public function generate(AiProvider $provider, string $prompt, array $options = []): AiGenerationResult + { + throw new RuntimeException('should not be called'); + } + }; + + $manager = new AiProviderManager(['openai' => $driver]); + $provider = AiProvider::factory()->create(['provider' => 'openai', 'is_active' => false]); + + $manager->generate($provider, 'hello'); +})->throws(AiGenerationException::class, 'is not active'); diff --git a/tests/Feature/AiProviders/ElevenLabsDriverTest.php b/tests/Feature/AiProviders/ElevenLabsDriverTest.php new file mode 100644 index 0000000..4a01227 --- /dev/null +++ b/tests/Feature/AiProviders/ElevenLabsDriverTest.php @@ -0,0 +1,55 @@ + Http::response('fake-mp3-bytes', 200, ['Content-Type' => 'audio/mpeg']), + ]); + + $provider = AiProvider::factory()->create([ + 'provider' => 'elevenlabs', + 'api_key' => 'el-test', + 'config' => ['voice_id' => 'voice123'], + ]); + + $result = (new ElevenLabsDriver)->generate($provider, 'Hello world'); + + expect($result->content)->toBe('fake-mp3-bytes'); + expect($result->contentType)->toBe('audio/mpeg'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.elevenlabs.io/v1/text-to-speech/voice123' + && $request->hasHeader('xi-api-key', 'el-test') + && $request['text'] === 'Hello world'; + }); +}); + +test('a voice_id passed in options overrides the provider config', function () { + Http::fake(['api.elevenlabs.io/*' => Http::response('bytes')]); + + $provider = AiProvider::factory()->create(['provider' => 'elevenlabs', 'config' => ['voice_id' => 'default-voice']]); + + (new ElevenLabsDriver)->generate($provider, 'hi', ['voice_id' => 'custom-voice']); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/text-to-speech/custom-voice')); +}); + +test('it throws when no voice_id is configured anywhere', function () { + $provider = AiProvider::factory()->create(['provider' => 'elevenlabs', 'config' => null]); + + (new ElevenLabsDriver)->generate($provider, 'hi'); +})->throws(AiGenerationException::class, 'no voice_id configured'); + +test('it throws when ElevenLabs returns an error', function () { + Http::fake([ + 'api.elevenlabs.io/*' => Http::response(['detail' => ['message' => 'quota exceeded']], 429), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'elevenlabs', 'config' => ['voice_id' => 'v1']]); + + (new ElevenLabsDriver)->generate($provider, 'hi'); +})->throws(AiGenerationException::class, 'quota exceeded'); diff --git a/tests/Feature/AiProviders/OpenAiDriverTest.php b/tests/Feature/AiProviders/OpenAiDriverTest.php new file mode 100644 index 0000000..ec65ede --- /dev/null +++ b/tests/Feature/AiProviders/OpenAiDriverTest.php @@ -0,0 +1,62 @@ + Http::response([ + 'choices' => [ + ['message' => ['content' => 'Once upon a time...']], + ], + ], 200), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'openai', 'api_key' => 'sk-test']); + + $result = (new OpenAiDriver)->generate($provider, 'Write a story opener'); + + expect($result->content)->toBe('Once upon a time...'); + expect($result->contentType)->toBe('text/plain'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.openai.com/v1/chat/completions' + && $request->hasHeader('Authorization', 'Bearer sk-test') + && $request['messages'][0]['content'] === 'Write a story opener' + && $request['model'] === 'gpt-4o-mini'; + }); +}); + +test('it uses a model from options over the provider config over the default', function () { + Http::fake([ + 'api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'hi']]]]), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'openai', 'config' => ['model' => 'gpt-4o']]); + + (new OpenAiDriver)->generate($provider, 'hi', ['model' => 'gpt-4-turbo']); + + Http::assertSent(fn ($request) => $request['model'] === 'gpt-4-turbo'); +}); + +test('it throws when OpenAI returns an error', function () { + Http::fake([ + 'api.openai.com/*' => Http::response(['error' => ['message' => 'Invalid API key']], 401), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'openai']); + + (new OpenAiDriver)->generate($provider, 'hi'); +})->throws(AiGenerationException::class, 'Invalid API key'); + +test('it throws when the response has no message content', function () { + Http::fake([ + 'api.openai.com/*' => Http::response(['choices' => []]), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'openai']); + + (new OpenAiDriver)->generate($provider, 'hi'); +})->throws(AiGenerationException::class); diff --git a/tests/Feature/AiProviders/StabilityAiDriverTest.php b/tests/Feature/AiProviders/StabilityAiDriverTest.php new file mode 100644 index 0000000..3273dc0 --- /dev/null +++ b/tests/Feature/AiProviders/StabilityAiDriverTest.php @@ -0,0 +1,63 @@ + Http::response([ + 'image' => base64_encode($bytes), + 'seed' => 42, + 'finish_reason' => 'SUCCESS', + ], 200), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'stability_ai', 'api_key' => 'sk-test']); + + $result = (new StabilityAiDriver)->generate($provider, 'a red bicycle'); + + expect($result->content)->toBe($bytes); + expect($result->contentType)->toBe('image/png'); + expect($result->raw['seed'])->toBe(42); + + Http::assertSent(function ($request) { + return $request->url() === 'https://api.stability.ai/v2beta/stable-image/generate/core' + && $request->hasHeader('Authorization', 'Bearer sk-test'); + }); +}); + +test('it respects an output_format override', function () { + Http::fake([ + 'api.stability.ai/*' => Http::response(['image' => base64_encode('bytes')]), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'stability_ai']); + + $result = (new StabilityAiDriver)->generate($provider, 'prompt', ['output_format' => 'webp']); + + expect($result->contentType)->toBe('image/webp'); +}); + +test('it throws when StabilityAI returns an error', function () { + Http::fake([ + 'api.stability.ai/*' => Http::response(['errors' => ['invalid prompt']], 400), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'stability_ai']); + + (new StabilityAiDriver)->generate($provider, 'prompt'); +})->throws(AiGenerationException::class, 'invalid prompt'); + +test('it throws when the response has no image data', function () { + Http::fake([ + 'api.stability.ai/*' => Http::response(['finish_reason' => 'CONTENT_FILTERED']), + ]); + + $provider = AiProvider::factory()->create(['provider' => 'stability_ai']); + + (new StabilityAiDriver)->generate($provider, 'prompt'); +})->throws(AiGenerationException::class); diff --git a/tests/Feature/AssetStorage/AssetStorageTest.php b/tests/Feature/AssetStorage/AssetStorageTest.php new file mode 100644 index 0000000..688c676 --- /dev/null +++ b/tests/Feature/AssetStorage/AssetStorageTest.php @@ -0,0 +1,113 @@ +storage = new AssetStorage; +}); + +test('storing content writes the file and creates an asset record', function () { + $project = VideoProject::factory()->create(); + + $asset = $this->storage->store($project, 'script', 'Once upon a time...', 'txt', mimeType: 'text/plain'); + + expect($asset->type)->toBe('script'); + expect($asset->version)->toBe(1); + expect($asset->size_bytes)->toBe(strlen('Once upon a time...')); + expect($asset->mime_type)->toBe('text/plain'); + expect($asset->video_project_id)->toBe($project->id); + + Storage::assertExists($asset->disk_path); + expect(Storage::get($asset->disk_path))->toBe('Once upon a time...'); +}); + +test('storing the same slot again increments the version instead of overwriting', function () { + $project = VideoProject::factory()->create(); + + $v1 = $this->storage->store($project, 'script', 'draft one', 'txt'); + $v2 = $this->storage->store($project, 'script', 'draft two', 'txt'); + + expect($v1->version)->toBe(1); + expect($v2->version)->toBe(2); + expect($v1->disk_path)->not->toBe($v2->disk_path); + + Storage::assertExists($v1->disk_path); + Storage::assertExists($v2->disk_path); + expect(Storage::get($v1->disk_path))->toBe('draft one'); + expect(Storage::get($v2->disk_path))->toBe('draft two'); +}); + +test('versioning is scoped per workflow step when one is given', function () { + $project = VideoProject::factory()->create(); + $stepA = WorkflowStep::factory()->for($project)->create(['order' => 0]); + $stepB = WorkflowStep::factory()->for($project)->create(['order' => 1]); + + $a = $this->storage->store($project, 'script', 'from step A', 'txt', step: $stepA); + $b = $this->storage->store($project, 'script', 'from step B', 'txt', step: $stepB); + + expect($a->version)->toBe(1); + expect($b->version)->toBe(1); +}); + +test('contents retrieves the stored file', function () { + $project = VideoProject::factory()->create(); + $asset = $this->storage->store($project, 'script', 'hello', 'txt'); + + expect($this->storage->contents($asset))->toBe('hello'); +}); + +test('url returns null for a missing file and a path for an existing one', function () { + $project = VideoProject::factory()->create(); + $asset = $this->storage->store($project, 'script', 'hello', 'txt'); + + expect($this->storage->url($asset))->not->toBeNull(); + + Storage::delete($asset->disk_path); + + expect($this->storage->url($asset))->toBeNull(); +}); + +test('delete removes the file and the asset record', function () { + $project = VideoProject::factory()->create(); + $asset = $this->storage->store($project, 'script', 'hello', 'txt'); + + $this->storage->delete($asset); + + Storage::assertMissing($asset->disk_path); + expect(\App\Models\Asset::find($asset->id))->toBeNull(); +}); + +test('pruneOldVersions keeps only the newest N versions', function () { + $project = VideoProject::factory()->create(); + + $versions = collect(range(1, 5))->map( + fn ($i) => $this->storage->store($project, 'script', "draft {$i}", 'txt') + ); + + $pruned = $this->storage->pruneOldVersions($project, 'script', keep: 2); + + expect($pruned)->toBe(3); + + $remaining = $project->assets()->where('type', 'script')->pluck('version')->sort()->values(); + expect($remaining->all())->toBe([4, 5]); + + Storage::assertMissing($versions[0]->disk_path); + Storage::assertMissing($versions[1]->disk_path); + Storage::assertMissing($versions[2]->disk_path); + Storage::assertExists($versions[3]->disk_path); + Storage::assertExists($versions[4]->disk_path); +}); + +test('pruneOldVersions does nothing when there are fewer versions than the keep count', function () { + $project = VideoProject::factory()->create(); + $this->storage->store($project, 'script', 'only draft', 'txt'); + + $pruned = $this->storage->pruneOldVersions($project, 'script', keep: 3); + + expect($pruned)->toBe(0); + expect($project->assets()->count())->toBe(1); +});