File size: 5,434 Bytes
74acf19 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | <?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'] : []),
];
}
}
|