| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Http; |
| use RuntimeException; |
|
|
| class FalTryOnService |
| { |
| public function isAvailable(): bool |
| { |
| return trim((string) config('services.fal.api_key', '')) !== ''; |
| } |
|
|
| public function generate(string $personImageUrl, string $clothingImageUrl, array $options = []): array |
| { |
| $apiKey = trim((string) config('services.fal.api_key', '')); |
| $baseUrl = rtrim((string) config('services.fal.base_url', 'https://queue.fal.run'), '/'); |
| $model = trim((string) config('services.fal.tryon_model', 'fal-ai/image-apps-v2/virtual-try-on')); |
| $pollIntervalMs = max(500, (int) config('services.fal.poll_interval_ms', 1500)); |
| $timeoutSec = max(20, (int) config('services.fal.timeout', 180)); |
|
|
| if ($apiKey === '') { |
| throw new RuntimeException('FAL_KEY belum diisi di server, jadi Try On Outfit belum bisa diproses.'); |
| } |
|
|
| $payload = [ |
| 'person_image_url' => trim($personImageUrl), |
| 'clothing_image_url' => trim($clothingImageUrl), |
| 'preserve_pose' => (bool) ($options['preserve_pose'] ?? true), |
| 'aspect_ratio' => [ |
| 'ratio' => $this->normalizeAspectRatio((string) ($options['aspect_ratio'] ?? '3:4')), |
| ], |
| ]; |
|
|
| $start = microtime(true); |
|
|
| $submit = Http::timeout($timeoutSec) |
| ->withHeaders([ |
| 'Authorization' => 'Key '.$apiKey, |
| 'Content-Type' => 'application/json', |
| ]) |
| ->post($baseUrl.'/'.$model, $payload); |
|
|
| if (! $submit->successful()) { |
| $error = trim((string) data_get($submit->json(), 'detail', data_get($submit->json(), 'error', $submit->body()))); |
| throw new RuntimeException('fal.ai Try On gagal: '.$submit->status().' - '.$error); |
| } |
|
|
| $submitJson = $submit->json(); |
| $directImage = $this->extractImage($submitJson); |
|
|
| if ($directImage !== null) { |
| return $this->normalizeResponse($directImage, $options, $submitJson); |
| } |
|
|
| $statusUrl = trim((string) data_get($submitJson, 'status_url', '')); |
| if ($statusUrl === '') { |
| throw new RuntimeException('fal.ai tidak mengembalikan status_url untuk Try On.'); |
| } |
|
|
| while ((microtime(true) - $start) < $timeoutSec) { |
| usleep($pollIntervalMs * 1000); |
|
|
| $status = Http::timeout(30) |
| ->withHeaders([ |
| 'Authorization' => 'Key '.$apiKey, |
| 'Content-Type' => 'application/json', |
| ]) |
| ->get($statusUrl); |
|
|
| if (! $status->successful()) { |
| continue; |
| } |
|
|
| $statusJson = $status->json(); |
| $image = $this->extractImage($statusJson); |
| if ($image !== null) { |
| return $this->normalizeResponse($image, $options, $statusJson); |
| } |
|
|
| $state = strtoupper((string) data_get($statusJson, 'status', '')); |
| if (in_array($state, ['FAILED', 'ERROR', 'CANCELLED'], true)) { |
| $error = (string) data_get($statusJson, 'error', 'unknown error'); |
| throw new RuntimeException('fal.ai Try On gagal: '.$error); |
| } |
| } |
|
|
| throw new RuntimeException('fal.ai Try On timeout. Coba ulangi dengan foto dan outfit yang lebih jelas.'); |
| } |
|
|
| private function normalizeAspectRatio(string $value): string |
| { |
| $ratio = trim($value); |
|
|
| return in_array($ratio, ['1:1', '16:9', '9:16', '4:3', '3:4'], true) |
| ? $ratio |
| : '3:4'; |
| } |
|
|
| private function extractImage(array $payload): ?array |
| { |
| $candidates = [ |
| data_get($payload, 'images.0'), |
| data_get($payload, 'data.images.0'), |
| data_get($payload, 'response.images.0'), |
| data_get($payload, 'output.images.0'), |
| ]; |
|
|
| foreach ($candidates as $candidate) { |
| if (is_array($candidate) && trim((string) ($candidate['url'] ?? '')) !== '') { |
| return $candidate; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| private function normalizeResponse(array $image, array $options, array $raw): array |
| { |
| $url = trim((string) ($image['url'] ?? '')); |
| if ($url === '') { |
| throw new RuntimeException('fal.ai Try On selesai, tapi URL hasilnya kosong.'); |
| } |
|
|
| return [ |
| 'url' => $url, |
| 'thumbnail_url' => $url, |
| 'width' => (int) ($image['width'] ?? 0), |
| 'height' => (int) ($image['height'] ?? 0), |
| 'provider' => 'fal-tryon', |
| 'prompt' => (string) ($options['prompt'] ?? 'Virtual try on result'), |
| 'aspect' => (string) ($options['aspect_ratio'] ?? '3:4'), |
| 'meta' => array_merge([ |
| 'source' => 'tools_tryon', |
| 'request_id' => data_get($raw, 'request_id'), |
| 'model' => config('services.fal.tryon_model', 'fal-ai/image-apps-v2/virtual-try-on'), |
| 'person_image_url' => (string) ($options['person_image_url'] ?? ''), |
| 'clothing_image_url' => (string) ($options['clothing_image_url'] ?? ''), |
| 'preserve_pose' => (bool) ($options['preserve_pose'] ?? true), |
| ], is_array($options['meta'] ?? null) ? $options['meta'] : []), |
| ]; |
| } |
| } |
|
|