| <?php |
|
|
| namespace App\Services; |
|
|
| class SocialMediaStudioService |
| { |
| public function __construct( |
| private HuggingFaceImageService $huggingFaceImage, |
| private HuggingFaceVideoService $huggingFaceVideo, |
| private FreepikVideoService $freepikVideo, |
| private FreepikAssetRemixService $freepikAssetRemix, |
| private OpenAiVideoService $openAiVideo, |
| private ReplicateImageService $replicateImage, |
| private PollinationsImageService $pollinations, |
| private PosterImageService $posterImage, |
| private StoryImageNormalizerService $storyNormalizer, |
| private MotionVideoService $motionVideo, |
| private GeminiVideoService $geminiVideo, |
| private ReplicateVideoService $replicateVideo, |
| ) { |
| } |
|
|
| public function generateImage(string $prompt): array |
| { |
| $cleanPrompt = trim($prompt); |
|
|
| return $this->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'] ?? [])), |
| ]; |
| } |
|
|
| |
| $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'] ?? [])), |
| ]; |
| } |
|
|
| |
| $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 ($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, |
| ]; |
| } |
|
|
| |
| $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', |
| }; |
| } |
|
|
| } |
|
|