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