generateImageFallback($cleanPrompt, []); } private function generateImageFallback(string $prompt, array $fallbackErrors): array { try { $urls = $this->huggingFaceImage->generate($prompt, 'portrait', 4); $variants = $this->storyNormalizer->normalizeMany($urls); return [ 'url' => $variants[0]['url'] ?? null, 'thumbnail_url' => $variants[0]['thumbnail_url'] ?? null, 'variants' => $variants, 'provider' => 'huggingface', 'aspect' => '9:16', 'prompt' => $prompt, 'width' => 1080, 'height' => 1920, 'notice' => 'Image story-size diproses lewat Hugging Face.', ]; } catch (\Throwable $hfError) { try { $urls = $this->pollinations->generate($prompt, 'portrait', 4); $variants = $this->storyNormalizer->normalizeMany($urls); return [ 'url' => $variants[0]['url'] ?? null, 'thumbnail_url' => $variants[0]['thumbnail_url'] ?? null, 'variants' => $variants, 'provider' => 'pollinations', 'aspect' => '9:16', 'prompt' => $prompt, 'width' => 1080, 'height' => 1920, 'notice' => 'Image story-size diproses lewat Pollinations.', ]; } catch (\Throwable $pollinationsError) { $urls = $this->posterImage->generate($prompt, 'portrait', 4); $variants = $this->storyNormalizer->normalizeMany($urls); return [ 'url' => $variants[0]['url'] ?? null, 'thumbnail_url' => $variants[0]['thumbnail_url'] ?? null, 'variants' => $variants, 'provider' => 'poster', 'aspect' => '9:16', 'prompt' => $prompt, 'width' => 1080, 'height' => 1920, 'notice' => 'Provider eksternal tidak tersedia, menggunakan poster story-size lokal.', 'fallback_errors' => array_merge($fallbackErrors, [ 'huggingface' => $hfError->getMessage(), 'pollinations' => $pollinationsError->getMessage(), ]), ]; } } } public function generateVideo(string $prompt, array $options = []): array { $cleanPrompt = trim($prompt); return $this->generateVideoFallback($cleanPrompt, [], $options); } private function generateVideoFallback(string $prompt, array $fallbackErrors, array $options = []): array { try { $requestedProvider = $this->resolveRequestedVideoProvider($options); if ($requestedProvider === 'gemini') { if (! $this->geminiVideo->isAvailable()) { throw new \RuntimeException('Provider Gemini belum siap karena GEMINI_API_KEY belum diset.'); } $submission = $this->geminiVideo->submit(null, $prompt, $options); $appliedSettings = (array) ($submission['applied_settings'] ?? []); return [ 'provider' => 'gemini-veo', 'prediction_id' => $submission['prediction_id'] ?? null, 'prompt' => $prompt, 'applied_settings' => $appliedSettings, 'notice' => 'Render Google Veo native aktif dengan '.$this->describeAppliedVideoSettings($appliedSettings).'.', 'async' => true, ]; } if ($requestedProvider === 'huggingface') { return $this->huggingFaceVideo->generate(null, $prompt, $options); } if ($requestedProvider === 'runway') { try { $duration = (int) preg_replace('/[^0-9]/', '', (string) ($options['video_duration'] ?? '4s')); $video = $this->freepikVideo->generate($prompt, $duration); return [ ...$video, 'prompt' => $prompt, 'notice' => 'Render Runway aktif lewat Freepik dengan output vertikal siap pakai.', 'async' => false, ]; } catch (\Throwable $runwayError) { $fallbackErrors['runway'] = $runwayError->getMessage(); if ($this->replicateVideo->isAvailable()) { $image = $this->generateSingleStoryImage($prompt); $fallbackOptions = array_merge($options, ['video_provider' => 'replicate']); $video = $this->generateVideoFromImageUrl((string) $image['url'], $prompt, $fallbackOptions); return [ ...$video, 'source_image_provider' => $image['provider'] ?? 'huggingface', 'notice' => 'Runway tidak tersedia saat ini, otomatis dialihkan ke Replicate dari source frame story terbaru.', 'fallback_errors' => array_merge($fallbackErrors, (array) ($image['fallback_errors'] ?? [])), ]; } } } if ($requestedProvider === 'openai') { return $this->openAiVideo->generate(null, $prompt, $options); } if ($requestedProvider === 'replicate') { if (! $this->replicateVideo->isAvailable()) { throw new \RuntimeException('Provider Replicate belum siap karena REPLICATE_API_KEY belum diset.'); } $image = $this->generateSingleStoryImage($prompt); $video = $this->generateVideoFromImageUrl((string) $image['url'], $prompt, $options); return [ ...$video, 'source_image_provider' => $image['provider'] ?? 'huggingface', 'notice' => 'Video diproses lewat Replicate dari source frame story terbaru.', 'fallback_errors' => array_merge($fallbackErrors, (array) ($image['fallback_errors'] ?? [])), ]; } // Buat frame visual dulu $image = $this->generateSingleStoryImage($prompt); if ($this->replicateVideo->isAvailable()) { $video = $this->generateVideoFromImageUrl((string) $image['url'], $prompt, $options); return [ ...$video, 'source_image_provider' => $image['provider'] ?? 'huggingface', 'notice' => 'Video diproses lewat Replicate dari source frame story terbaru.', 'fallback_errors' => array_merge($fallbackErrors, (array) ($image['fallback_errors'] ?? [])), ]; } // Render jadi motion video lokal via ffmpeg $video = $this->motionVideo->createFromImageUrl((string) $image['url'], $prompt, $options); return [ 'url' => $video['url'], 'provider' => $video['provider'], 'aspect' => '9:16', 'prompt' => $prompt, 'duration_seconds' => $video['duration_seconds'] ?? 6, 'source_image_url' => $image['url'], 'source_image_provider' => $image['provider'], 'notice' => 'Video vertikal dirender lokal dari visual story terbaru.', 'fallback_errors' => array_merge($fallbackErrors, (array) ($image['fallback_errors'] ?? [])), ]; } catch (\Throwable $fallbackVideoError) { throw new \RuntimeException( 'Gagal membuat video: ' . $fallbackVideoError->getMessage() ); } } public function generateVideoFromImageUrl(string $imageUrl, string $prompt, array $options = []): array { $requestedProvider = $this->resolveRequestedVideoProvider($options); if ($requestedProvider === 'gemini') { if (! $this->geminiVideo->isAvailable()) { throw new \RuntimeException('Provider Gemini belum siap karena GEMINI_API_KEY belum diset.'); } $submission = $this->geminiVideo->submit($imageUrl, trim($prompt), $options); $appliedSettings = (array) ($submission['applied_settings'] ?? []); return [ 'provider' => 'gemini-veo', 'prediction_id' => $submission['prediction_id'] ?? null, 'source_image_url' => $imageUrl, 'prompt' => trim($prompt), 'requested_quality' => $options['video_quality'] ?? '720p', 'requested_duration' => $options['video_duration'] ?? '4s', 'applied_settings' => $appliedSettings, 'notice' => 'Render Google Veo native aktif dengan '.$this->describeAppliedVideoSettings($appliedSettings).'.', 'async' => true, ]; } if ($requestedProvider === 'huggingface') { return $this->huggingFaceVideo->generate($imageUrl, trim($prompt), $options); } if ($requestedProvider === 'runway') { try { $duration = (int) preg_replace('/[^0-9]/', '', (string) ($options['video_duration'] ?? '4s')); $result = $this->freepikAssetRemix->submitRemixVideo([ 'type' => 'image', 'url' => $imageUrl, 'thumbnail_url' => $imageUrl, ], trim($prompt), 'image_to_video', $duration); return [ ...$result, 'source_image_url' => $imageUrl, 'prompt' => trim($prompt), 'notice' => 'Render Runway aktif dari start frame yang dipilih. Job diproses async lalu hasil final akan masuk ke gallery.', 'async' => true, ]; } catch (\Throwable $runwayError) { if ($this->replicateVideo->isAvailable()) { $fallbackOptions = array_merge($options, ['video_provider' => 'replicate']); $fallbackVideo = $this->generateVideoFromImageUrl($imageUrl, trim($prompt), $fallbackOptions); return [ ...$fallbackVideo, 'notice' => 'Runway tidak tersedia saat ini, otomatis dialihkan ke Replicate dari start frame yang dipilih.', ]; } } } if ($requestedProvider === 'openai') { return $this->openAiVideo->generate($imageUrl, trim($prompt), $options); } if ($requestedProvider === 'replicate' && ! $this->replicateVideo->isAvailable()) { throw new \RuntimeException('Provider Replicate belum siap karena REPLICATE_API_KEY belum diset.'); } // If Replicate is available, submit async and return prediction_id immediately. // The controller redirects right away; browser polls /tools/video/poll for result. if ($this->replicateVideo->isAvailable()) { $submission = $this->replicateVideo->submit($imageUrl, trim($prompt), $options); $appliedSettings = (array) ($submission['applied_settings'] ?? []); $appliedModel = (string) ($appliedSettings['replicate_i2v_model'] ?? 'replicate-i2v'); $appliedQuality = (string) ($appliedSettings['video_quality'] ?? ($options['video_quality'] ?? '720p')); $appliedDuration = (string) ($appliedSettings['video_duration'] ?? ($options['video_duration'] ?? '4s')); $appliedAudio = (string) ($appliedSettings['video_audio'] ?? ($options['video_audio'] ?? 'off')); return [ 'provider' => 'replicate-i2v', 'prediction_id' => $submission['prediction_id'] ?? null, 'source_image_url' => $imageUrl, 'prompt' => trim($prompt), 'requested_quality' => $options['video_quality'] ?? '720p', 'requested_duration' => $options['video_duration'] ?? '4s', 'applied_settings' => $appliedSettings, 'notice' => 'Render Replicate aktif memakai '.$appliedModel.' dengan output '.$appliedQuality.', durasi '.$appliedDuration.', dan audio '.($appliedAudio === 'on' ? 'aktif' : 'nonaktif').'.', 'async' => true, ]; } // Fallback: local FFmpeg Ken Burns zoompan (sync, fast enough on small res) $video = $this->motionVideo->createFromImageUrl($imageUrl, trim($prompt), $options); return [ 'url' => $video['url'], 'provider' => $video['provider'], 'aspect' => '9:16', 'prompt' => trim($prompt), 'duration_seconds' => $video['duration_seconds'] ?? 5, 'source_image_url' => $imageUrl, 'source_image_provider' => 'upload', 'notice' => 'Video dirender dari gambar (FFmpeg zoompan).', 'async' => false, ]; } private function shouldUseGeminiVeo(array $options): bool { return $this->resolveRequestedVideoProvider($options) === 'gemini' && (string) ($options['video_model'] ?? '') === 'veo'; } private function resolveRequestedVideoProvider(array $options): string { $provider = trim((string) ($options['video_provider'] ?? 'replicate')); return match ($provider) { 'runway', 'huggingface', 'openai', 'replicate' => $provider, default => 'gemini', }; } private function describeAppliedVideoSettings(array $appliedSettings): string { $model = (string) ($appliedSettings['gemini_model'] ?? 'veo-3.1-fast-generate-preview'); $quality = (string) ($appliedSettings['video_quality'] ?? '720p'); $duration = (string) ($appliedSettings['video_duration'] ?? '4s'); $audio = (string) ($appliedSettings['video_audio'] ?? 'on'); return $model.' dengan output '.$quality.', durasi '.$duration.', dan audio '.($audio === 'on' ? 'aktif' : 'nonaktif'); } public function remixFromAsset(array $source, string $prompt, string $mode, string $outputType): array { $cleanPrompt = trim($prompt); if ($cleanPrompt === '') { throw new \RuntimeException('Prompt remix tidak boleh kosong.'); } $mode = trim($mode); $outputType = trim($outputType); $remixPrompt = $this->composeRemixPrompt($source, $cleanPrompt, $mode, $outputType); if ($outputType === 'video') { $sourceImageUrl = $this->resolveReferenceImageUrl($source); $video = $this->generateVideoFromImageUrl($sourceImageUrl, $remixPrompt); return [ ...$video, 'prompt' => $cleanPrompt, 'source_image_url' => $sourceImageUrl, 'notice' => 'AI Remix video diproses lewat jalur Hugging Face-compatible image-to-video.', ]; } $image = $this->generateRemixImageFallback($remixPrompt, $mode); return [ ...$image, 'prompt' => $cleanPrompt, 'notice' => $image['notice'] ?? 'AI Remix image diproses lewat Replicate/Hugging Face fallback. Hasil mengikuti prompt dan referensi secara aproksimatif.', ]; } private function generateSingleStoryImage(string $prompt): array { $cleanPrompt = trim($prompt); try { $urls = $this->huggingFaceImage->generate($cleanPrompt, 'portrait', 1); $variant = $this->storyNormalizer->normalizeMany($urls)[0]; return [ ...$variant, 'provider' => 'huggingface', 'notice' => 'Source frame diproses lewat Hugging Face.', ]; } catch (\Throwable $hfError) { try { $urls = $this->pollinations->generate($cleanPrompt, 'portrait', 1); $variant = $this->storyNormalizer->normalizeMany($urls)[0]; return [ ...$variant, 'provider' => 'pollinations', 'notice' => 'Source frame diproses lewat Pollinations.', ]; } catch (\Throwable $fallbackError) { $urls = $this->posterImage->generate($cleanPrompt, 'portrait', 1); $variant = $this->storyNormalizer->normalizeMany($urls)[0]; return [ ...$variant, 'provider' => 'poster', 'notice' => 'Menggunakan poster story-size lokal.', ]; } } } private function composeRemixPrompt(array $source, string $prompt, string $mode, string $outputType): string { $sourcePrompt = trim((string) ($source['prompt'] ?? '')); $modeInstruction = match ($mode) { 'mockup' => 'Transform the reference into a polished premium mockup while preserving the main subject and overall composition cues.', 'face_swap', 'strict_face_swap' => 'Preserve the primary identity, facial structure, and key likeness cues from the reference as much as possible while following the new styling request.', 'restyle' => 'Restyle the reference into a fresh variation while keeping the same subject, framing intent, and recognizable visual anchors.', default => 'Create a refined variation based on the reference asset while preserving the main subject and core composition cues.', }; $typeInstruction = ($source['type'] ?? 'image') === 'video' ? 'The reference comes from a video thumbnail, so preserve the hero subject and cinematic framing from that source frame.' : 'The reference comes from a still image, so preserve the hero subject and key visual balance from that image.'; $outputInstruction = $outputType === 'video' ? 'Write the prompt as a motion-ready direction with clear cinematic movement and continuity from the reference.' : 'Write the prompt as an image-generation direction for a single strong keyframe.'; $sourceInstruction = $sourcePrompt !== '' ? 'Reference description: '.$sourcePrompt.'.' : 'Reference description: preserve the selected source image subject, identity cues, and overall framing.'; return trim($modeInstruction.' '.$typeInstruction.' '.$sourceInstruction.' '.$outputInstruction.' User request: '.$prompt); } private function resolveReferenceImageUrl(array $source): string { $sourceType = (string) ($source['type'] ?? 'image'); if ($sourceType === 'video') { $thumbnailUrl = trim((string) ($source['thumbnail_url'] ?? '')); if ($thumbnailUrl === '') { throw new \RuntimeException('Source video belum punya thumbnail/reference image untuk diproses jadi video baru.'); } return $thumbnailUrl; } $imageUrl = trim((string) ($source['url'] ?? '')); if ($imageUrl === '') { throw new \RuntimeException('Source image tidak punya URL referensi yang bisa dipakai untuk AI Remix.'); } return $imageUrl; } private function generateRemixImageFallback(string $prompt, string $mode): array { $fallbackErrors = []; if ($this->replicateImage->isAvailable()) { try { $provider = $this->replicateProviderForRemixMode($mode); $urls = $this->replicateImage->generate($prompt, 'portrait', 1, $provider); $variants = $this->storyNormalizer->normalizeMany($urls); return [ 'url' => $variants[0]['url'] ?? null, 'thumbnail_url' => $variants[0]['thumbnail_url'] ?? null, 'variants' => $variants, 'provider' => $provider, 'aspect' => '9:16', 'prompt' => $prompt, 'width' => 1080, 'height' => 1920, 'notice' => 'AI Remix image diproses lewat Replicate.', ]; } catch (\Throwable $replicateError) { $fallbackErrors['replicate'] = $replicateError->getMessage(); } } return $this->generateImageFallback($prompt, $fallbackErrors); } private function replicateProviderForRemixMode(string $mode): string { return match ($mode) { 'mockup' => 'replicate-ideogram', 'restyle', 'face_swap', 'strict_face_swap' => 'replicate-flux', default => 'replicate-imagen4', }; } }