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