teknolis / app /Services /ReplicateImageService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
6.74 kB
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Text-to-image generation via Replicate free-tier models.
*
* Supported models (Try-for-Free):
* - google/imagen-4 (high quality, ~10-25s)
* - black-forest-labs/flux-1.1-pro (fast & sharp, ~5-15s)
* - ideogram-ai/ideogram-v3-quality (highest-quality Ideogram v3 output)
*
* All use sync polling β€” predictions settle in <30s so we block within the
* HTTP request rather than adding an async flow.
*/
class ReplicateImageService
{
private string $apiKey;
private string $baseUrl;
// Replicate model slug β†’ aspect_ratio key name (some models differ)
private const MODELS = [
'replicate-imagen4' => 'google/imagen-4',
'replicate-flux' => 'black-forest-labs/flux-1.1-pro',
'replicate-ideogram' => 'ideogram-ai/ideogram-v3-quality',
'replicate-flux-flex' => 'black-forest-labs/flux-2-flex',
];
public function __construct()
{
$this->apiKey = (string) config('services.replicate.api_key', '');
$this->baseUrl = (string) config('services.replicate.base_url', 'https://api.replicate.com/v1');
}
public function isAvailable(): bool
{
return $this->apiKey !== '';
}
public static function providerToModel(string $provider): string
{
return self::MODELS[$provider] ?? 'google/imagen-4';
}
/**
* Generate image(s) β€” submits N predictions serially then polls all.
*
* @param string $prompt
* @param string $aspect 'landscape'|'square'|'portrait'
* @param int $count 1–4
* @param string $provider e.g. 'replicate-imagen4'
* @return array<string> Image URLs
*/
public function generate(
string $prompt,
string $aspect = 'landscape',
int $count = 1,
string $provider = 'replicate-imagen4',
array $referenceImages = []
): array
{
if (! $this->isAvailable()) {
throw new RuntimeException('REPLICATE_API_KEY belum diset.');
}
$model = self::providerToModel($provider);
$count = max(1, min(4, $count));
$aspectRatio = match ($aspect) {
'portrait' => '9:16',
'square' => '1:1',
default => '16:9',
};
$input = ['prompt' => $prompt, 'aspect_ratio' => $aspectRatio];
if ($provider === 'replicate-flux-flex') {
$input = [
'prompt' => $prompt,
'aspect_ratio' => $referenceImages !== [] ? 'match_input_image' : $aspectRatio,
'resolution' => $referenceImages !== [] ? 'match_input_image' : '1 MP',
'output_format' => 'png',
'input_images' => array_values(array_filter(array_map(
static fn ($url) => filled($url) ? trim((string) $url) : null,
$referenceImages
))),
];
}
// Ideogram uses aspect_ratio directly and can take extra style inputs later.
// Flux supports safety_tolerance; keep defaults
$predictionIds = [];
$endpoint = rtrim($this->baseUrl, '/') . '/models/' . $model . '/predictions';
for ($i = 0; $i < $count; $i++) {
// Delay between submissions to respect burst=1 on low-credit accounts
if ($i > 0) {
sleep(2);
}
$response = $this->post($endpoint, ['input' => $input]);
$id = $response->json('id');
if (blank($id)) {
throw new RuntimeException('Replicate: prediction ID tidak ada di response.');
}
$predictionIds[] = $id;
}
return $this->pollUntilDone($predictionIds, model: $model);
}
// ─── Internal ──────────────────────────────────────────────────────────
private function post(string $endpoint, array $payload)
{
$response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $payload);
if ($response->status() === 429) {
Log::info('ReplicateImageService: rate-limited, retrying in 3s…');
sleep(3);
$response = Http::withToken($this->apiKey)->timeout(30)->post($endpoint, $payload);
}
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 image gagal: ' . Str::limit((string) $detail, 200));
}
return $response;
}
private function pollUntilDone(array $ids, string $model = ''): array
{
$maxWait = 90; // seconds
$interval = 3;
$elapsed = 0;
$urls = [];
$pending = array_flip($ids); // id => index
while (! empty($pending) && $elapsed < $maxWait) {
sleep($interval);
$elapsed += $interval;
foreach (array_keys($pending) as $id) {
$r = Http::withToken($this->apiKey)
->timeout(15)
->get(rtrim($this->baseUrl, '/') . '/predictions/' . $id);
if ($r->failed()) {
continue;
}
$status = $r->json('status');
if ($status === 'succeeded') {
$output = $r->json('output');
if (is_array($output)) {
foreach ($output as $url) {
if (filled($url)) {
$urls[] = (string) $url;
}
}
} elseif (filled($output)) {
$urls[] = (string) $output;
}
unset($pending[$id]);
} elseif (in_array($status, ['failed', 'canceled'], true)) {
$err = $r->json('error') ?? 'Unknown error';
Log::warning('ReplicateImageService: prediction failed', ['id' => $id, 'model' => $model, 'error' => $err]);
unset($pending[$id]);
}
}
}
if (empty($urls)) {
throw new RuntimeException('Replicate: Gagal menghasilkan gambar. Coba lagi dalam beberapa detik.');
}
return $urls;
}
}