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 !== ''; } /** * STEP A — submit prediction job, returns prediction_id immediately (<2s). * * @throws RuntimeException */ public function submit(string $imageUrl, string $prompt, array $options = []): array { if (! $this->isAvailable()) { throw new RuntimeException('REPLICATE_API_KEY belum diset.'); } // Replicate requires a fully-qualified https:// URI — convert relative paths. 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); // Retry once after 3s on rate-limit (burst=1 on low-credit accounts) 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'], }; } /** * STEP B — called from polling AJAX endpoint every ~5s. * Returns status array. When succeeded, downloads and stores the video. * * @return array{status: string, url?: string, error?: string} */ 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)]; } // starting | processing return ['status' => $status]; } // ───────────────────────────────────────────────────────────────────────── /** * Submit a luma/reframe-video prediction. * Reframes (reaspects) an existing video to the target aspect ratio. * * @param string $videoUrl Absolute URL to the source video * @param string $aspectRatio Target ratio: "9:16" | "16:9" | "1:1" | "4:5" * @return string prediction_id */ 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; } /** * Submit a topazlabs/video-upscale prediction. * Enhances and upscales a low-resolution video. * * @param string $videoUrl Absolute URL to the source video * @return string prediction_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); } }