diff --git a/app/Jobs/RunWorkflowStepJob.php b/app/Jobs/RunWorkflowStepJob.php new file mode 100644 index 0000000..f71ba5b --- /dev/null +++ b/app/Jobs/RunWorkflowStepJob.php @@ -0,0 +1,54 @@ +stepId); + + // Stale/duplicate dispatch — the step moved on (or was retried and + // re-dispatched) before this run got picked up. + if (! $step || $step->status !== 'in_progress') { + return; + } + + $handler = $handlers->resolve($step->type); + + if (! $handler) { + $engine->failStep($step, "No handler registered for step type '{$step->type}'."); + + return; + } + + try { + $handler->handle($step, $engine); + } catch (Throwable $e) { + Log::error('workflow step handler threw', ['step_id' => $step->id, 'error' => $e->getMessage()]); + + $fresh = $step->fresh(); + + if ($fresh && $fresh->status === 'in_progress') { + $engine->failStep($fresh, $e->getMessage()); + } + } + } +} diff --git a/app/Livewire/VideoProjects/Index.php b/app/Livewire/VideoProjects/Index.php new file mode 100644 index 0000000..b342455 --- /dev/null +++ b/app/Livewire/VideoProjects/Index.php @@ -0,0 +1,56 @@ + VideoProject::withCount([ + 'steps', + 'steps as completed_steps_count' => fn ($q) => $q->whereIn('status', ['completed', 'skipped']), + ]) + ->with(['steps' => fn ($q) => $q->whereNotIn('status', ['completed', 'skipped'])->orderBy('order')->limit(1)]) + ->orderByDesc('created_at') + ->get(), + ]); + } + + public function create(): void + { + $this->title = ''; + $this->description = null; + $this->resetValidation(); + $this->showForm = true; + } + + public function save(): void + { + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + ]); + + $project = VideoProject::create($validated); + + $this->showForm = false; + $this->redirect(route('video-projects.show', $project), navigate: true); + } + + public function delete(int $id): void + { + VideoProject::findOrFail($id)->delete(); + } +} diff --git a/app/Livewire/VideoProjects/Show.php b/app/Livewire/VideoProjects/Show.php new file mode 100644 index 0000000..300a634 --- /dev/null +++ b/app/Livewire/VideoProjects/Show.php @@ -0,0 +1,109 @@ +videoProject = $videoProject; + } + + public function render() + { + $this->videoProject->refresh(); + + return view('livewire.video-projects.show', [ + 'steps' => $this->videoProject->steps()->with('assets')->get(), + 'assets' => $this->videoProject->assets()->orderByDesc('created_at')->get(), + 'templates' => WorkflowTemplate::orderBy('name')->get(), + 'hasActiveStep' => $this->videoProject->steps() + ->whereIn('status', ['pending', 'in_progress']) + ->exists(), + ]); + } + + public function startWithTemplate(WorkflowEngine $engine): void + { + if (! $this->workflowTemplateId) { + $this->notice = 'Pick a template first.'; + + return; + } + + $template = WorkflowTemplate::findOrFail($this->workflowTemplateId); + $engine->startFromTemplate($this->videoProject, $template); + + $this->runCurrentStepIfHandled($engine); + } + + public function startDefault(WorkflowEngine $engine): void + { + $engine->start($this->videoProject, [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover', 'requires_approval' => true], + ['type' => 'storyboard', 'requires_approval' => true], + ['type' => 'video_generation', 'requires_approval' => true], + ['type' => 'video_editing', 'requires_approval' => true], + ['type' => 'thumbnail_generation', 'requires_approval' => true], + ['type' => 'metadata_generation', 'requires_approval' => true], + ['type' => 'youtube_publish', 'requires_approval' => true], + ]); + + $this->runCurrentStepIfHandled($engine); + } + + public function runStep(int $stepId): void + { + RunWorkflowStepJob::dispatch($stepId); + $this->notice = 'Step queued to run.'; + } + + public function approveStep(int $stepId, WorkflowEngine $engine): void + { + $engine->approveStep(WorkflowStep::findOrFail($stepId)); + $this->runCurrentStepIfHandled($engine); + } + + public function rejectStep(int $stepId, WorkflowEngine $engine): void + { + $engine->rejectStep(WorkflowStep::findOrFail($stepId)); + } + + public function retryStep(int $stepId, WorkflowEngine $engine): void + { + $step = $engine->retryStep(WorkflowStep::findOrFail($stepId)); + RunWorkflowStepJob::dispatch($step->id); + } + + public function skipStep(int $stepId, WorkflowEngine $engine): void + { + $engine->skipStep(WorkflowStep::findOrFail($stepId)); + $this->runCurrentStepIfHandled($engine); + } + + private function runCurrentStepIfHandled(WorkflowEngine $engine): void + { + $current = $engine->currentStep($this->videoProject->fresh()); + + if ($current && $current->status === 'in_progress' && app(StepHandlerManager::class)->resolve($current->type)) { + RunWorkflowStepJob::dispatch($current->id); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f0767ef..21e663d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,9 @@ use App\Support\Publishing\PublisherManager; use App\Support\Publishing\RedditPublisher; 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\TtsVoiceoverHandler; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -32,6 +35,11 @@ class AppServiceProvider extends ServiceProvider 'stability_ai' => $app->make(StabilityAiDriver::class), 'elevenlabs' => $app->make(ElevenLabsDriver::class), ])); + + $this->app->singleton(StepHandlerManager::class, fn ($app) => new StepHandlerManager([ + $app->make(ScriptGenerationHandler::class), + $app->make(TtsVoiceoverHandler::class), + ])); } /** diff --git a/app/Support/VideoWorkflow/StepHandlers/ScriptGenerationHandler.php b/app/Support/VideoWorkflow/StepHandlers/ScriptGenerationHandler.php new file mode 100644 index 0000000..577431e --- /dev/null +++ b/app/Support/VideoWorkflow/StepHandlers/ScriptGenerationHandler.php @@ -0,0 +1,87 @@ +where('provider', 'openai')->where('is_active', true)->first(); + + if (! $provider) { + $engine->failStep($step, 'No active OpenAI provider configured.'); + + return; + } + + try { + $result = $this->aiProviders->generate($provider, $this->buildPrompt($step)); + } catch (AiGenerationException $e) { + $engine->failStep($step, $e->getMessage()); + + return; + } + + $asset = $this->assets->store( + $step->videoProject, + 'script', + $result->content, + 'txt', + step: $step, + mimeType: 'text/plain', + ); + + $engine->completeStep($step, [ + 'asset_id' => $asset->id, + 'word_count' => str_word_count($result->content), + ]); + } + + private function buildPrompt(WorkflowStep $step): string + { + $input = $step->input ?? []; + $project = $step->videoProject; + + $outline = $input['outline'] ?? trim(($project->title ?? '')."\n\n".($project->description ?? '')); + + $prompt = "Write a complete, engaging YouTube video script.\n\nTopic/Outline:\n{$outline}"; + + if (! empty($input['tone'])) { + $prompt .= "\n\nTone: {$input['tone']}"; + } + + if (! empty($input['seo_keywords'])) { + $prompt .= "\n\nNaturally include these SEO keywords where relevant: ".implode(', ', $input['seo_keywords']); + } + + if (! empty($input['target_words'])) { + $prompt .= "\n\nTarget length: approximately {$input['target_words']} words."; + } + + $prompt .= "\n\nWrite only the script content, no preamble or explanation."; + + return $prompt; + } +} diff --git a/app/Support/VideoWorkflow/StepHandlers/StepHandler.php b/app/Support/VideoWorkflow/StepHandlers/StepHandler.php new file mode 100644 index 0000000..40f40e3 --- /dev/null +++ b/app/Support/VideoWorkflow/StepHandlers/StepHandler.php @@ -0,0 +1,22 @@ + $handlers + */ + public function __construct(private readonly array $handlers) {} + + public function resolve(string $type): ?StepHandler + { + foreach ($this->handlers as $handler) { + if ($handler->type() === $type) { + return $handler; + } + } + + return null; + } +} diff --git a/app/Support/VideoWorkflow/StepHandlers/TtsVoiceoverHandler.php b/app/Support/VideoWorkflow/StepHandlers/TtsVoiceoverHandler.php new file mode 100644 index 0000000..5d0e3d9 --- /dev/null +++ b/app/Support/VideoWorkflow/StepHandlers/TtsVoiceoverHandler.php @@ -0,0 +1,95 @@ +input['text'] if given, otherwise the project's latest + * 'script' asset. Voice selection (input.voice_id / input.model_id) is + * per-step, falling back to the AiProvider's own config. + * + * Note: ElevenLabs' base TTS endpoint doesn't expose a direct "speed" + * parameter — only voice_id/model_id are wired through here for now. + */ +class TtsVoiceoverHandler implements StepHandler +{ + public function __construct( + private readonly AiProviderManager $aiProviders, + private readonly AssetStorage $assets, + ) {} + + public function type(): string + { + return 'tts_voiceover'; + } + + public function handle(WorkflowStep $step, WorkflowEngine $engine): void + { + $provider = AiProvider::query()->where('provider', 'elevenlabs')->where('is_active', true)->first(); + + if (! $provider) { + $engine->failStep($step, 'No active ElevenLabs provider configured.'); + + return; + } + + $text = $this->resolveText($step); + + if (! $text) { + $engine->failStep($step, 'No script text available to voice — set input.text or complete a script_generation step first.'); + + return; + } + + $input = $step->input ?? []; + $options = array_filter([ + 'voice_id' => $input['voice_id'] ?? null, + 'model_id' => $input['model_id'] ?? null, + ]); + + try { + $result = $this->aiProviders->generate($provider, $text, $options); + } catch (AiGenerationException $e) { + $engine->failStep($step, $e->getMessage()); + + return; + } + + $asset = $this->assets->store( + $step->videoProject, + 'audio', + $result->content, + 'mp3', + step: $step, + mimeType: $result->contentType, + ); + + $engine->completeStep($step, [ + 'asset_id' => $asset->id, + 'characters' => strlen($text), + ]); + } + + private function resolveText(WorkflowStep $step): ?string + { + $input = $step->input ?? []; + + if (! empty($input['text'])) { + return $input['text']; + } + + $scriptAsset = $step->videoProject->assets() + ->where('type', 'script') + ->orderByDesc('version') + ->first(); + + return $scriptAsset ? $this->assets->contents($scriptAsset) : null; + } +} diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 7c5e90b..6554567 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -16,6 +16,9 @@ Tasks Ideas + + Video Projects + @auth
@csrf diff --git a/resources/views/livewire/video-projects/index.blade.php b/resources/views/livewire/video-projects/index.blade.php new file mode 100644 index 0000000..6f0d43a --- /dev/null +++ b/resources/views/livewire/video-projects/index.blade.php @@ -0,0 +1,78 @@ +
+
+

Video Projects

+ +
+ + + + + + + + + + + + + + @forelse ($projects as $project) + + + + + + + + + @empty + + + + @endforelse + +
TitleStatusCurrent StepProgressCreated
+ {{ $project->title }} + {{ ucfirst($project->status) }} + @if ($project->steps->isNotEmpty()) + {{ str($project->steps->first()->type)->headline() }} + ({{ str($project->steps->first()->status)->headline() }}) + @else + — + @endif + + @if ($project->steps_count > 0) + {{ $project->completed_steps_count }} / {{ $project->steps_count }} + @else + not started + @endif + {{ $project->created_at->format('Y-m-d') }} + Open + +
No video projects yet.
+ + @if ($showForm) +
+
+

New Video Project

+ +
+ + + @error('title') {{ $message }} @enderror +
+ +
+ + + @error('description') {{ $message }} @enderror +
+ +
+ + +
+
+
+ @endif +
diff --git a/resources/views/livewire/video-projects/show.blade.php b/resources/views/livewire/video-projects/show.blade.php new file mode 100644 index 0000000..31358d0 --- /dev/null +++ b/resources/views/livewire/video-projects/show.blade.php @@ -0,0 +1,123 @@ +
+ + +
+
+
+

{{ $videoProject->title }}

+ @if ($videoProject->description) +

{{ $videoProject->description }}

+ @endif +
+ {{ ucfirst($videoProject->status) }} +
+
+ + @if ($notice) +
{{ $notice }}
+ @endif + + @if ($steps->isEmpty()) +
+

Start Workflow

+ + @if ($templates->isNotEmpty()) +
+
+ + +
+ +
+ @endif + +
+ +

Script → Voiceover → Storyboard → Video → Editing → Thumbnail → Metadata → Publish, each requiring approval.

+
+
+ @else +
+ @foreach ($steps as $step) +
+
+
+ {{ $loop->iteration }}. {{ str($step->type)->headline() }} + in_array($step->status, ['pending', 'skipped']), + 'bg-blue-100 text-blue-700' => $step->status === 'in_progress', + 'bg-amber-100 text-amber-700' => $step->status === 'awaiting_approval', + 'bg-green-100 text-green-700' => $step->status === 'completed', + 'bg-red-100 text-red-700' => in_array($step->status, ['failed', 'rejected']), + ])>{{ str($step->status)->headline() }} +
+ +
+ @if ($step->status === 'in_progress') + + + @elseif ($step->status === 'awaiting_approval') + + + @elseif (in_array($step->status, ['failed', 'rejected'])) + + @elseif ($step->status === 'pending') + + @endif +
+
+ + @if ($step->error) +

{{ $step->error }}

+ @endif + + @if ($step->output) +

+ @if (isset($step->output['word_count'])) + {{ $step->output['word_count'] }} words generated + @elseif (isset($step->output['characters'])) + {{ $step->output['characters'] }} characters synthesized + @endif +

+ @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') }}
+
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 93d8911..d3841e3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -31,4 +31,9 @@ Route::middleware('auth')->group(function () { Route::get('/tasks', \App\Livewire\Tasks\Index::class)->name('tasks.index'); Route::get('/task-comments', \App\Livewire\TaskComments\Index::class)->name('task-comments.index'); Route::get('/ideas', \App\Livewire\Ideas\Index::class)->name('ideas.index'); + + // Video Projects — a separate domain from Projects/Tasks/Ideas above, + // 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'); }); diff --git a/tests/Feature/Jobs/RunWorkflowStepJobTest.php b/tests/Feature/Jobs/RunWorkflowStepJobTest.php new file mode 100644 index 0000000..24bb0de --- /dev/null +++ b/tests/Feature/Jobs/RunWorkflowStepJobTest.php @@ -0,0 +1,77 @@ +engine = new WorkflowEngine; +}); + +test('it runs the matching handler and completes the step', function () { + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'a script']]]])]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + (new RunWorkflowStepJob($step->id))->handle(app(StepHandlerManager::class), $this->engine); + + expect($step->fresh()->status)->toBe('completed'); +}); + +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']]); + + (new RunWorkflowStepJob($step->id))->handle(app(StepHandlerManager::class), $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No handler registered'); +}); + +test('it does nothing for a step that is no longer in_progress', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + $this->engine->skipStep($step); + + (new RunWorkflowStepJob($step->id))->handle(app(StepHandlerManager::class), $this->engine); + + expect($step->fresh()->status)->toBe('skipped'); +}); + +test('it does nothing for a missing step', function () { + (new RunWorkflowStepJob(999999))->handle(app(StepHandlerManager::class), $this->engine); +})->throwsNoExceptions(); + +test('it fails the step when the handler throws unexpectedly', function () { + $badHandler = new class implements \App\Support\VideoWorkflow\StepHandlers\StepHandler + { + public function type(): string + { + return 'script_generation'; + } + + public function handle(\App\Models\WorkflowStep $step, WorkflowEngine $engine): void + { + throw new \RuntimeException('boom'); + } + }; + $manager = new StepHandlerManager([$badHandler]); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + (new RunWorkflowStepJob($step->id))->handle($manager, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toBe('boom'); +}); diff --git a/tests/Feature/Livewire/VideoProjectsIndexTest.php b/tests/Feature/Livewire/VideoProjectsIndexTest.php new file mode 100644 index 0000000..b1e6b8f --- /dev/null +++ b/tests/Feature/Livewire/VideoProjectsIndexTest.php @@ -0,0 +1,54 @@ +create(['title' => 'My Awesome Video']); + + $this->actingAs(User::factory()->create()) + ->get(route('video-projects.index')) + ->assertOk() + ->assertSee('My Awesome Video'); +}); + +test('a new video project can be created', function () { + Livewire::test(Index::class) + ->set('title', 'New Video') + ->set('description', 'A description') + ->call('save'); + + expect(VideoProject::where('title', 'New Video')->exists())->toBeTrue(); +}); + +test('title is required to create a video project', function () { + Livewire::test(Index::class) + ->set('title', '') + ->call('save') + ->assertHasErrors(['title']); +}); + +test('a video project can be deleted', function () { + $project = VideoProject::factory()->create(); + + Livewire::test(Index::class)->call('delete', $project->id); + + expect(VideoProject::find($project->id))->toBeNull(); +}); + +test('it shows step progress counts for a started project', function () { + $project = VideoProject::factory()->create(['title' => 'Progress Test']); + $engine = new WorkflowEngine; + $step = $engine->start($project, [ + ['type' => 'script_generation'], + ['type' => 'tts_voiceover'], + ]); + $engine->completeStep($step); + + $this->actingAs(User::factory()->create()) + ->get(route('video-projects.index')) + ->assertSee('1 / 2'); +}); diff --git a/tests/Feature/Livewire/VideoProjectsShowTest.php b/tests/Feature/Livewire/VideoProjectsShowTest.php new file mode 100644 index 0000000..8c1e213 --- /dev/null +++ b/tests/Feature/Livewire/VideoProjectsShowTest.php @@ -0,0 +1,129 @@ +create(); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->call('startDefault'); + + expect($project->fresh()->status)->toBe('in_progress'); + expect($project->steps()->count())->toBe(8); + expect($project->steps()->orderBy('order')->first()->type)->toBe('script_generation'); + + Queue::assertPushed(RunWorkflowStepJob::class, function ($job) use ($project) { + $step = $project->steps()->orderBy('order')->first(); + + return $job->stepId === $step->id; + }); +}); + +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' => 'youtube_publish'], + ]]); + $project = VideoProject::factory()->create(); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->set('workflowTemplateId', $template->id) + ->call('startWithTemplate'); + + 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 + Queue::assertNotPushed(RunWorkflowStepJob::class); +}); + +test('startWithTemplate shows a notice when no template is selected', function () { + $project = VideoProject::factory()->create(); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->call('startWithTemplate') + ->assertSee('Pick a template first.'); + + expect($project->fresh()->status)->toBe('draft'); +}); + +test('runStep dispatches the job for the given step', function () { + Queue::fake(); + $project = VideoProject::factory()->create(); + $step = (new WorkflowEngine)->start($project, [['type' => 'script_generation']]); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->call('runStep', $step->id); + + Queue::assertPushed(RunWorkflowStepJob::class, fn ($job) => $job->stepId === $step->id); +}); + +test('approving a step advances and queues the next handled step', function () { + Queue::fake(); + $project = VideoProject::factory()->create(); + $engine = new WorkflowEngine; + $step = $engine->start($project, [ + ['type' => 'script_generation', 'requires_approval' => true], + ['type' => 'tts_voiceover'], + ]); + $engine->completeStep($step, ['script' => 'draft']); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->call('approveStep', $step->id); + + $next = $project->steps()->where('order', 1)->first(); + expect($next->status)->toBe('in_progress'); + Queue::assertPushed(RunWorkflowStepJob::class, fn ($job) => $job->stepId === $next->id); +}); + +test('rejecting a step blocks the pipeline', function () { + $project = VideoProject::factory()->create(); + $engine = new WorkflowEngine; + $step = $engine->start($project, [['type' => 'script_generation', 'requires_approval' => true]]); + $engine->completeStep($step, ['script' => 'bad']); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->call('rejectStep', $step->id); + + expect($step->fresh()->status)->toBe('rejected'); +}); + +test('retrying a failed step re-queues it', function () { + Queue::fake(); + $project = VideoProject::factory()->create(); + $engine = new WorkflowEngine; + $step = $engine->start($project, [['type' => 'script_generation']]); + $engine->failStep($step, 'boom'); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->call('retryStep', $step->id); + + expect($step->fresh()->status)->toBe('in_progress'); + Queue::assertPushed(RunWorkflowStepJob::class, fn ($job) => $job->stepId === $step->id); +}); + +test('skipping a step advances and queues the next handled step', function () { + Queue::fake(); + $project = VideoProject::factory()->create(); + $engine = new WorkflowEngine; + $step = $engine->start($project, [ + ['type' => 'video_generation'], + ['type' => 'script_generation'], + ]); + + Livewire::test(Show::class, ['videoProject' => $project]) + ->call('skipStep', $step->id); + + $next = $project->steps()->where('order', 1)->first(); + expect($step->fresh()->status)->toBe('skipped'); + expect($next->status)->toBe('in_progress'); + Queue::assertPushed(RunWorkflowStepJob::class, fn ($job) => $job->stepId === $next->id); +}); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/ScriptGenerationHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/ScriptGenerationHandlerTest.php new file mode 100644 index 0000000..c181311 --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/ScriptGenerationHandlerTest.php @@ -0,0 +1,91 @@ +engine = new WorkflowEngine; + $this->handler = new ScriptGenerationHandler(app(AiProviderManager::class), new AssetStorage); +}); + +test('it generates a script, stores it as an asset, and completes the step', function () { + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'choices' => [['message' => ['content' => 'Welcome back to the channel today']]], + ]), + ]); + AiProvider::factory()->create(['provider' => 'openai', 'is_active' => true]); + + $project = VideoProject::factory()->create(['title' => 'Best Coffee Gadgets 2026']); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + $this->handler->handle($step, $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + expect($fresh->output['word_count'])->toBe(6); + + $asset = \App\Models\Asset::find($fresh->output['asset_id']); + expect($asset->type)->toBe('script'); + expect($asset->video_project_id)->toBe($project->id); + expect(Storage::get($asset->disk_path))->toBe('Welcome back to the channel today'); + + Http::assertSent(fn ($request) => str_contains($request['messages'][0]['content'], 'Best Coffee Gadgets 2026')); +}); + +test('it uses an explicit outline, tone, keywords, and target length when given', function () { + Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'script']]]])]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + $step->update(['input' => [ + 'outline' => 'A history of espresso machines', + 'tone' => 'casual and funny', + 'seo_keywords' => ['espresso', 'coffee history'], + 'target_words' => 800, + ]]); + + $this->handler->handle($step->fresh(), $this->engine); + + Http::assertSent(function ($request) { + $prompt = $request['messages'][0]['content']; + + return str_contains($prompt, 'A history of espresso machines') + && str_contains($prompt, 'casual and funny') + && str_contains($prompt, 'espresso, coffee history') + && str_contains($prompt, 'approximately 800 words'); + }); +}); + +test('it fails the step when no active OpenAI provider is configured', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No active OpenAI provider'); +}); + +test('it fails the step when the AI provider call fails', function () { + Http::fake(['api.openai.com/*' => Http::response(['error' => ['message' => 'rate limited']], 429)]); + AiProvider::factory()->create(['provider' => 'openai']); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'script_generation']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toBe('rate limited'); +}); diff --git a/tests/Feature/VideoWorkflow/StepHandlers/TtsVoiceoverHandlerTest.php b/tests/Feature/VideoWorkflow/StepHandlers/TtsVoiceoverHandlerTest.php new file mode 100644 index 0000000..4ea8bf4 --- /dev/null +++ b/tests/Feature/VideoWorkflow/StepHandlers/TtsVoiceoverHandlerTest.php @@ -0,0 +1,92 @@ +engine = new WorkflowEngine; + $this->assets = new AssetStorage; + $this->handler = new TtsVoiceoverHandler(app(AiProviderManager::class), $this->assets); +}); + +test('it voices explicit input text and completes the step', function () { + Http::fake(['api.elevenlabs.io/*' => Http::response('fake-mp3-bytes', 200, ['Content-Type' => 'audio/mpeg'])]); + AiProvider::factory()->create(['provider' => 'elevenlabs', 'config' => ['voice_id' => 'v1']]); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'tts_voiceover']]); + $step->update(['input' => ['text' => 'Hello and welcome']]); + + $this->handler->handle($step->fresh(), $this->engine); + + $fresh = $step->fresh(); + expect($fresh->status)->toBe('completed'); + expect($fresh->output['characters'])->toBe(strlen('Hello and welcome')); + + $asset = \App\Models\Asset::find($fresh->output['asset_id']); + expect($asset->type)->toBe('audio'); + expect($asset->mime_type)->toBe('audio/mpeg'); + expect(Storage::get($asset->disk_path))->toBe('fake-mp3-bytes'); + + Http::assertSent(fn ($request) => $request['text'] === 'Hello and welcome'); +}); + +test('it falls back to the latest script asset when no input text is given', function () { + Http::fake(['api.elevenlabs.io/*' => Http::response('bytes')]); + AiProvider::factory()->create(['provider' => 'elevenlabs', 'config' => ['voice_id' => 'v1']]); + + $project = VideoProject::factory()->create(); + $this->assets->store($project, 'script', 'Old draft', 'txt'); + $this->assets->store($project, 'script', 'Latest draft', 'txt'); + + $step = $this->engine->start($project, [['type' => 'tts_voiceover']]); + + $this->handler->handle($step, $this->engine); + + Http::assertSent(fn ($request) => $request['text'] === 'Latest draft'); +}); + +test('a voice_id in step input overrides the provider default', function () { + Http::fake(['api.elevenlabs.io/*' => Http::response('bytes')]); + AiProvider::factory()->create(['provider' => 'elevenlabs', 'config' => ['voice_id' => 'default-voice']]); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'tts_voiceover']]); + $step->update(['input' => ['text' => 'hi', 'voice_id' => 'custom-voice']]); + + $this->handler->handle($step->fresh(), $this->engine); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/text-to-speech/custom-voice')); +}); + +test('it fails the step when there is no text to voice', function () { + AiProvider::factory()->create(['provider' => 'elevenlabs', 'config' => ['voice_id' => 'v1']]); + + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'tts_voiceover']]); + + $this->handler->handle($step, $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No script text available'); +}); + +test('it fails the step when no active ElevenLabs provider is configured', function () { + $project = VideoProject::factory()->create(); + $step = $this->engine->start($project, [['type' => 'tts_voiceover']]); + $step->update(['input' => ['text' => 'hi']]); + + $this->handler->handle($step->fresh(), $this->engine); + + expect($step->fresh()) + ->status->toBe('failed') + ->error->toContain('No active ElevenLabs provider'); +});