teknolis / app /Services /SocialMediaStudioService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
22.1 kB
<?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'] ?? [])),
];
}
// 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',
};
}
}