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'] : []), ]; } }