teknolis / app /Services /ReplicateVideoService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
17.2 kB
<?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;
/**
* Image-to-Video via Replicate API β€” ASYNC design.
*
* Step A (fast, <2s): submit() β€” POST /predictions β†’ returns prediction_id
* Step B (poll): checkStatus() β€” GET /predictions/{id} β†’ called by AJAX endpoint
*/
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 !== '';
}
/**
* 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);
}
}