| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Http; |
| use Illuminate\Support\Facades\Log; |
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
|
|
| |
| |
| |
| |
| |
| |
| class ReplicateVideoService |
| { |
| private const WAVESPEED_WAN_MODEL = 'wavespeedai/wan-2.1-i2v-480p'; |
|
|
| private string $apiKey; |
| private string $baseUrl; |
| private string $i2vModel; |
|
|
| public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null) |
| { |
| $this->apiKey = (string) config('services.replicate.api_key', ''); |
| $this->baseUrl = (string) config('services.replicate.base_url', 'https://api.replicate.com/v1'); |
| $this->i2vModel = (string) config('services.replicate.i2v_model', self::WAVESPEED_WAN_MODEL); |
| } |
|
|
| public function isAvailable(): bool |
| { |
| return $this->apiKey !== ''; |
| } |
|
|
| |
| |
| |
| |
| |
| public function submit(string $imageUrl, string $prompt, array $options = []): array |
| { |
| if (! $this->isAvailable()) { |
| throw new RuntimeException('REPLICATE_API_KEY belum diset.'); |
| } |
|
|
| |
| if (! str_starts_with($imageUrl, 'http://') && ! str_starts_with($imageUrl, 'https://')) { |
| $imageUrl = rtrim((string) config('app.url', ''), '/') . '/' . ltrim($imageUrl, '/'); |
| } |
|
|
| $endpoint = rtrim($this->baseUrl, '/') . '/models/' . $this->i2vModel . '/predictions'; |
|
|
| $payload = $this->buildImageToVideoPayload($imageUrl, $prompt, $options); |
| $requestBody = [ |
| 'input' => $payload['input'] ?? [], |
| ]; |
|
|
| $response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $requestBody); |
|
|
| |
| if ($response->status() === 429) { |
| Log::info('ReplicateVideoService: rate-limited, retrying in 3sβ¦'); |
| sleep(3); |
| $response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $requestBody); |
| } |
|
|
| if ($response->failed()) { |
| $status = $response->status(); |
| $detail = $response->json('detail') ?? $response->body(); |
| if ($status === 429) { |
| throw new RuntimeException('Replicate sedang sibuk (rate limit). Tunggu beberapa detik lalu coba lagi.'); |
| } |
| throw new RuntimeException('Replicate submit gagal: ' . Str::limit((string) $detail, 200)); |
| } |
|
|
| $id = $response->json('id'); |
| if (blank($id)) { |
| throw new RuntimeException('Replicate: prediction ID tidak ada di response.'); |
| } |
|
|
| Log::info('ReplicateVideoService: submitted', ['id' => $id]); |
|
|
| return [ |
| 'prediction_id' => (string) $id, |
| 'applied_settings' => $payload['applied_settings'], |
| ]; |
| } |
|
|
| private function buildImageToVideoPayload(string $imageUrl, string $prompt, array $options): array |
| { |
| $model = $this->resolveRequestedModel($options); |
| $aspectRatio = $this->normalizeAspectRatio((string) ($options['aspect_ratio'] ?? '16:9')); |
| $audioEnabled = ((string) ($options['video_audio'] ?? 'off')) === 'on'; |
| $negativePrompt = trim((string) ($options['video_negative_prompt'] ?? '')); |
|
|
| if ($model === self::WAVESPEED_WAN_MODEL) { |
| [$numFrames, $fps, $durationLabel] = $this->resolveWanFrameSettings((string) ($options['video_duration'] ?? '4s')); |
|
|
| $input = [ |
| 'prompt' => $prompt, |
| 'image' => $imageUrl, |
| 'aspect_ratio' => $aspectRatio, |
| 'frames_per_second' => $fps, |
| 'num_frames' => $numFrames, |
| ]; |
|
|
| if ($negativePrompt !== '') { |
| $input['negative_prompt'] = $negativePrompt; |
| } |
|
|
| return [ |
| 'input' => $input, |
| 'applied_settings' => [ |
| 'aspect_ratio' => $aspectRatio, |
| 'video_duration' => $durationLabel, |
| 'video_quality' => '480p', |
| 'video_audio' => 'off', |
| 'veo_variant' => 'wan-2.1-i2v-480p', |
| 'replicate_i2v_model' => $model, |
| ], |
| ]; |
| } |
|
|
| if ($model === 'google/veo-3-fast' || $model === 'google/veo-3') { |
| $duration = $this->normalizeVeoDuration((string) ($options['video_duration'] ?? '4s')); |
| $resolution = ((string) ($options['video_quality'] ?? '1080p')) === '720p' ? '720p' : '1080p'; |
|
|
| $input = [ |
| 'prompt' => $prompt, |
| 'image' => $imageUrl, |
| 'duration' => $duration, |
| 'resolution' => $resolution, |
| 'aspect_ratio' => $aspectRatio, |
| 'generate_audio' => $audioEnabled, |
| ]; |
|
|
| if ($negativePrompt !== '') { |
| $input['negative_prompt'] = $negativePrompt; |
| } |
|
|
| return [ |
| 'input' => $input, |
| 'applied_settings' => [ |
| 'aspect_ratio' => $aspectRatio, |
| 'video_duration' => $duration.'s', |
| 'video_quality' => $resolution, |
| 'video_audio' => $audioEnabled ? 'on' : 'off', |
| 'veo_variant' => $model === 'google/veo-3' ? 'veo-3' : 'veo-3-fast', |
| 'replicate_i2v_model' => $model, |
| ], |
| ]; |
| } |
|
|
| if ($model === 'google/veo-2') { |
| $duration = ((string) ($options['video_duration'] ?? '8s')) === '5s' ? 5 : 8; |
|
|
| return [ |
| 'input' => [ |
| 'prompt' => $prompt, |
| 'image' => $imageUrl, |
| 'duration' => $duration, |
| 'aspect_ratio' => $aspectRatio, |
| ], |
| 'applied_settings' => [ |
| 'aspect_ratio' => $aspectRatio, |
| 'video_duration' => $duration.'s', |
| 'video_quality' => '720p', |
| 'video_audio' => 'off', |
| 'veo_variant' => 'veo-2', |
| 'replicate_i2v_model' => $model, |
| ], |
| ]; |
| } |
|
|
| if ($model === 'luma/ray-2-720p') { |
| $duration = ((string) ($options['video_duration'] ?? '8s')) === '5s' ? 5 : 9; |
|
|
| return [ |
| 'input' => [ |
| 'prompt' => $prompt, |
| 'start_image' => $imageUrl, |
| 'duration' => $duration, |
| 'aspect_ratio' => $aspectRatio, |
| ], |
| 'applied_settings' => [ |
| 'aspect_ratio' => $aspectRatio, |
| 'video_duration' => $duration.'s', |
| 'video_quality' => '720p', |
| 'video_audio' => 'off', |
| 'veo_variant' => 'luma-ray-2', |
| 'replicate_i2v_model' => $model, |
| ], |
| ]; |
| } |
|
|
| if ($model === 'kwaivgi/kling-v1.6-pro') { |
| $duration = ((string) ($options['video_duration'] ?? '8s')) === '5s' ? 5 : 10; |
|
|
| return [ |
| 'input' => [ |
| 'prompt' => $prompt, |
| 'start_image' => $imageUrl, |
| 'duration' => $duration, |
| 'aspect_ratio' => $aspectRatio, |
| ], |
| 'applied_settings' => [ |
| 'aspect_ratio' => $aspectRatio, |
| 'video_duration' => $duration.'s', |
| 'video_quality' => '1080p', |
| 'video_audio' => 'off', |
| 'veo_variant' => 'kling-v1.6-pro', |
| 'replicate_i2v_model' => $model, |
| ], |
| ]; |
| } |
|
|
| return [ |
| 'input' => [ |
| 'prompt' => $prompt, |
| 'first_frame_image' => $imageUrl, |
| 'prompt_optimizer' => true, |
| ], |
| 'applied_settings' => [ |
| 'aspect_ratio' => $aspectRatio, |
| 'video_duration' => '6s', |
| 'video_quality' => '720p', |
| 'video_audio' => 'off', |
| 'veo_variant' => 'legacy-replicate', |
| 'replicate_i2v_model' => $model, |
| ], |
| ]; |
| } |
|
|
| private function resolveRequestedModel(array $options): string |
| { |
| $requestedProvider = trim((string) ($options['video_provider'] ?? '')); |
| $requestedModel = trim((string) ($options['replicate_i2v_model'] ?? '')); |
|
|
| if ($requestedProvider === 'replicate') { |
| return $requestedModel !== '' ? $requestedModel : trim($this->i2vModel); |
| } |
|
|
| $requestedVariant = trim((string) ($options['veo_variant'] ?? '')); |
|
|
| return match ($requestedVariant) { |
| 'veo-3' => 'google/veo-3', |
| 'veo-3-fast' => 'google/veo-3-fast', |
| default => trim($this->i2vModel), |
| }; |
| } |
|
|
| private function normalizeAspectRatio(string $aspectRatio): string |
| { |
| return match (trim($aspectRatio)) { |
| '9:16', '16:9' => trim($aspectRatio), |
| default => '16:9', |
| }; |
| } |
|
|
| private function normalizeVeoDuration(string $duration): int |
| { |
| return match (trim($duration)) { |
| '4s', '5s' => 4, |
| '6s' => 6, |
| default => 8, |
| }; |
| } |
|
|
| private function resolveWanFrameSettings(string $duration): array |
| { |
| return match (trim($duration)) { |
| '6s' => [72, 12, '6s'], |
| '8s' => [96, 12, '8s'], |
| default => [48, 12, '4s'], |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| public function checkStatus(string $predictionId): array |
| { |
| $endpoint = rtrim($this->baseUrl, '/') . '/predictions/' . $predictionId; |
|
|
| $response = Http::withToken($this->apiKey) |
| ->timeout(15) |
| ->get($endpoint); |
|
|
| if ($response->failed()) { |
| return ['status' => 'failed', 'error' => 'Gagal mengambil status dari Replicate.']; |
| } |
|
|
| $status = (string) $response->json('status'); |
|
|
| if ($status === 'succeeded') { |
| $output = $response->json('output'); |
| $remoteUrl = is_array($output) ? ($output[0] ?? null) : $output; |
|
|
| if (blank($remoteUrl)) { |
| return ['status' => 'failed', 'error' => 'Output URL kosong.']; |
| } |
|
|
| try { |
| $localUrl = $this->downloadVideo((string) $remoteUrl); |
| return ['status' => 'succeeded', 'url' => $localUrl, 'remote_url' => (string) $remoteUrl]; |
| } catch (\Throwable $e) { |
| return ['status' => 'failed', 'error' => $e->getMessage()]; |
| } |
| } |
|
|
| if ($status === 'failed' || $status === 'canceled') { |
| $error = $response->json('error') ?? $status; |
| return ['status' => 'failed', 'error' => Str::limit((string) $error, 200)]; |
| } |
|
|
| |
| return ['status' => $status]; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| public function submitReframe(string $videoUrl, string $aspectRatio = '9:16'): string |
| { |
| if (! $this->isAvailable()) { |
| throw new RuntimeException('REPLICATE_API_KEY belum diset.'); |
| } |
|
|
| if (! str_starts_with($videoUrl, 'http')) { |
| $videoUrl = rtrim((string) config('app.url', ''), '/') . '/' . ltrim($videoUrl, '/'); |
| } |
|
|
| $endpoint = rtrim($this->baseUrl, '/') . '/models/luma/reframe-video/predictions'; |
|
|
| $payload = [ |
| 'input' => [ |
| 'video_url' => $videoUrl, |
| 'aspect_ratio' => $aspectRatio, |
| ], |
| ]; |
|
|
| $response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $payload); |
|
|
| if ($response->status() === 429) { |
| sleep(3); |
| $response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $payload); |
| } |
|
|
| if ($response->failed()) { |
| $detail = $response->json('detail') ?? $response->body(); |
| if ($response->status() === 429) { |
| throw new RuntimeException('Replicate sibuk (rate limit). Coba beberapa detik lagi.'); |
| } |
| throw new RuntimeException('Luma reframe submit gagal: ' . Str::limit((string) $detail, 200)); |
| } |
|
|
| $id = $response->json('id'); |
| if (blank($id)) { |
| throw new RuntimeException('Replicate: prediction ID tidak ada di response.'); |
| } |
|
|
| Log::info('ReplicateVideoService(luma/reframe): submitted', ['id' => $id]); |
| return (string) $id; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| public function submitVideoUpscale(string $videoUrl): string |
| { |
| if (! $this->isAvailable()) { |
| throw new RuntimeException('REPLICATE_API_KEY belum diset.'); |
| } |
|
|
| if (! str_starts_with($videoUrl, 'http')) { |
| $videoUrl = rtrim((string) config('app.url', ''), '/') . '/' . ltrim($videoUrl, '/'); |
| } |
|
|
| $endpoint = rtrim($this->baseUrl, '/') . '/models/topazlabs/video-upscale/predictions'; |
|
|
| $payload = [ |
| 'input' => [ |
| 'video' => $videoUrl, |
| ], |
| ]; |
|
|
| $response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $payload); |
|
|
| if ($response->status() === 429) { |
| sleep(3); |
| $response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $payload); |
| } |
|
|
| if ($response->failed()) { |
| $detail = $response->json('detail') ?? $response->body(); |
| if ($response->status() === 429) { |
| throw new RuntimeException('Replicate sibuk (rate limit). Coba beberapa detik lagi.'); |
| } |
| throw new RuntimeException('Topaz video-upscale submit gagal: ' . Str::limit((string) $detail, 200)); |
| } |
|
|
| $id = $response->json('id'); |
| if (blank($id)) { |
| throw new RuntimeException('Replicate: prediction ID tidak ada di response.'); |
| } |
|
|
| Log::info('ReplicateVideoService(topaz/video-upscale): submitted', ['id' => $id]); |
| return (string) $id; |
| } |
|
|
| |
|
|
| private function downloadVideo(string $remoteUrl): string |
| { |
| $response = Http::withOptions(['stream' => false]) |
| ->timeout(60) |
| ->get($remoteUrl); |
|
|
| if ($response->failed()) { |
| throw new RuntimeException('Gagal download video dari Replicate: ' . $remoteUrl); |
| } |
|
|
| $relPath = 'videos/replicate_' . now()->format('Ymd_His') . '_' . Str::lower(Str::random(8)) . '.mp4'; |
| $absPath = Storage::disk('public')->path($relPath); |
| $outputDir = dirname($absPath); |
|
|
| if (! is_dir($outputDir) && ! @mkdir($outputDir, 0775, true) && ! is_dir($outputDir)) { |
| throw new RuntimeException('Gagal menyiapkan folder output video Replicate.'); |
| } |
|
|
| file_put_contents($absPath, $response->body()); |
|
|
| if (! is_file($absPath) || filesize($absPath) < 1024) { |
| throw new RuntimeException('File video Replicate kosong atau rusak setelah download.'); |
| } |
|
|
| if ($this->hfMediaStorage !== null) { |
| try { |
| return $this->hfMediaStorage->offloadPublicRelativePath($relPath); |
| } catch (\Throwable $e) { |
| Log::warning('ReplicateVideoService: HF offload failed', ['error' => $e->getMessage()]); |
| } |
| } |
|
|
| return Storage::disk('public')->url($relPath); |
| } |
| } |
|
|