teknolis / app /Services /FreepikImageService.php
adwandw's picture
Enable HF-only mode: add hf_only config, always show Remix Lab, hide Remix Stock in HF-only, improve error message for stock remix
da7bbba
Raw
History Blame Contribute Delete
18.7 kB
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class FreepikImageService
{
public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null)
{
}
public function generateWithHuggingFace(string $prompt, string $aspect = 'landscape', int $count = 1): array
{
$huggingFaceToken = $this->huggingFaceToken();
$timeout = max(20, (int) (
config('services.huggingface.timeout')
?: $this->readEnv('HF_TIMEOUT')
?: config('services.freepik.timeout', 90)
));
$cleanPrompt = trim($prompt);
$count = max(1, min(4, $count));
if ($huggingFaceToken === '') {
throw new RuntimeException('HF_TOKEN belum diisi di file .env');
}
if ($cleanPrompt === '') {
throw new RuntimeException('Prompt image tidak boleh kosong.');
}
return $this->generateViaHuggingFace($huggingFaceToken, $timeout, $cleanPrompt, $aspect, $count);
}
public function generate(string $prompt, string $aspect = 'landscape', int $count = 1): array
{
$apiKey = trim((string) config('services.freepik.api_key', ''));
$huggingFaceToken = $this->huggingFaceToken();
$timeout = max(20, (int) (
config('services.freepik.timeout', 90)
?: config('services.huggingface.timeout', 60)
));
$cleanPrompt = trim($prompt);
$count = max(1, min(4, $count));
if ($cleanPrompt === '') {
throw new RuntimeException('Prompt image tidak boleh kosong.');
}
if ($this->isHfOnlyMode() || $apiKey === '') {
if ($huggingFaceToken !== '') {
return $this->generateWithHuggingFace($cleanPrompt, $aspect, $count);
}
throw new RuntimeException('HF_TOKEN belum diisi. Workspace ini sekarang pakai Hugging Face untuk generate image.');
}
$legacyError = null;
if ($this->shouldUseLegacyEndpoint()) {
try {
return $this->generateViaLegacyEndpoint($apiKey, $timeout, $cleanPrompt, $aspect, $count);
} catch (\Throwable $legacyFailure) {
$legacyError = $legacyFailure;
}
}
$urls = [];
for ($index = 0; $index < $count; $index++) {
$seed = random_int(1, 4294967295);
try {
$ticket = $this->createTask($apiKey, $timeout, [
'prompt' => $cleanPrompt,
'aspect_ratio' => $this->aspectRatio($aspect),
'resolution' => '1k',
'output_format' => 'jpeg',
'safety_tolerance' => 2,
'seed' => $seed,
]);
} catch (\Throwable $asyncError) {
if ($legacyError !== null) {
if ($huggingFaceToken !== '') {
return $this->generateWithHuggingFace($cleanPrompt, $aspect, $count);
}
throw new RuntimeException($legacyError->getMessage().' Fallback async Freepik juga gagal: '.$asyncError->getMessage());
}
throw $asyncError;
}
if ($ticket['direct_url'] !== null) {
$urls[] = $ticket['direct_url'];
continue;
}
$urls[] = $this->awaitGeneratedUrl($apiKey, $timeout, $ticket['task_id']);
}
if ($urls === []) {
if ($huggingFaceToken !== '') {
return $this->generateWithHuggingFace($cleanPrompt, $aspect, $count);
}
throw new RuntimeException('Freepik image response kosong atau belum menghasilkan URL gambar.');
}
return $urls;
}
private function generateViaHuggingFace(string $token, int $timeout, string $prompt, string $aspect, int $count): array
{
$model = trim((string) (
config('services.huggingface.image_model')
?: $this->readEnv('HUGGINGFACE_IMAGE_MODEL')
?: $this->readEnv('HF_MODEL')
?: 'black-forest-labs/FLUX.1-schnell'
));
$endpoint = trim((string) (
config('services.huggingface.image_endpoint')
?: $this->readEnv('HUGGINGFACE_IMAGE_ENDPOINT')
?: ''
));
$baseUrl = trim((string) ($this->readEnv('HF_BASE_URL') ?: ''));
if ($endpoint === '' && $baseUrl !== '') {
$endpoint = rtrim($baseUrl, '/');
if (Str::endsWith($endpoint, '/models')) {
$endpoint .= '/'.$model;
}
}
if ($endpoint === '') {
$endpoint = 'https://router.huggingface.co/hf-inference/models/'.$model;
}
[$width, $height] = $this->legacySize($aspect);
$urls = [];
for ($index = 0; $index < $count; $index++) {
$seed = random_int(1, 999999999);
$payload = [
'inputs' => $count > 1 ? $prompt.' [variation '.($index + 1).']' : $prompt,
'parameters' => [
'width' => $width,
'height' => $height,
'seed' => $seed,
],
'options' => [
'wait_for_model' => true,
'use_cache' => false,
],
];
$response = $this->runHuggingFaceRequest($endpoint, $token, $timeout, $payload);
$contentType = strtolower((string) $response->header('content-type', ''));
if (Str::startsWith($contentType, 'image/')) {
$urls[] = $this->persistBinaryImage($response->body(), $contentType);
continue;
}
$json = $response->json();
$base64 = data_get($json, 'image') ?? data_get($json, 'images.0');
if (is_string($base64) && trim($base64) !== '') {
$urls[] = $this->persistBase64Image($base64);
continue;
}
throw new RuntimeException('Hugging Face image response tidak dikenali.');
}
if ($urls === []) {
throw new RuntimeException('Hugging Face tidak mengembalikan output image.');
}
return $urls;
}
private function runHuggingFaceRequest(string $endpoint, string $token, int $timeout, array $payload): Response
{
$maxAttempts = 6;
$attempt = 0;
$lastResponse = null;
$acceptTypes = ['image/png', 'application/json'];
while ($attempt < $maxAttempts) {
$attempt++;
$response = null;
foreach ($acceptTypes as $acceptType) {
$probe = Http::timeout(max(30, $timeout))
->withToken($token)
->withHeaders(['Accept' => $acceptType])
->post($endpoint, $payload);
if ($probe->successful()) {
$response = $probe;
break;
}
$errorBody = strtolower((string) $probe->body());
$isAcceptHeaderError = $probe->status() === 400 && Str::contains($errorBody, 'accept type');
if ($isAcceptHeaderError) {
$lastResponse = $probe;
continue;
}
$response = $probe;
break;
}
if (! $response instanceof Response) {
throw new RuntimeException('Hugging Face image request gagal: response kosong.');
}
$lastResponse = $response;
if ($response->successful()) {
return $response;
}
if (in_array($response->status(), [503, 524], true)) {
$wait = (float) (data_get($response->json(), 'estimated_time') ?? 2.0);
usleep((int) (max(1, min(8, $wait)) * 1000000));
continue;
}
throw new RuntimeException(
'Hugging Face image request gagal: '.$response->status().' - '.$this->responseError($response)
);
}
if ($lastResponse instanceof Response) {
throw new RuntimeException(
'Hugging Face image timeout: '.$lastResponse->status().' - '.$this->responseError($lastResponse)
);
}
throw new RuntimeException('Hugging Face image timeout: request gagal diulang.');
}
private function persistBinaryImage(string $binary, string $contentType): string
{
if ($binary === '') {
throw new RuntimeException('Hugging Face mengembalikan binary image kosong.');
}
$ext = 'jpg';
if (Str::contains($contentType, 'png')) {
$ext = 'png';
} elseif (Str::contains($contentType, 'webp')) {
$ext = 'webp';
}
$relativePath = 'images/hf_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.'.$ext;
Storage::disk('public')->put($relativePath, $binary);
return $this->offloadPublicPath($relativePath);
}
private function huggingFaceToken(): string
{
return trim((string) (
config('services.huggingface.api_token')
?: $this->readEnv('HUGGINGFACE_API_TOKEN')
?: $this->readEnv('HF_TOKEN')
?: $this->readEnv('HF_API_TOKEN')
?: $this->readEnv('HUGGINGFACEHUB_API_TOKEN')
?: ''
));
}
private function readEnv(string $key): ?string
{
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function isHfOnlyMode(): bool
{
$value = strtolower(trim((string) (
$this->readEnv('TOOLS_HF_ONLY')
?? config('services.huggingface.hf_only')
?? 'false'
)));
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
private function generateViaLegacyEndpoint(string $apiKey, int $timeout, string $prompt, string $aspect, int $count): array
{
[$width, $height] = $this->legacySize($aspect);
$response = $this->client($apiKey, $timeout)
->withHeaders([
'content-type' => 'application/json',
])
->post($this->legacyEndpoint(), [
'prompt' => $prompt,
'num_images' => $count,
'width' => $width,
'height' => $height,
]);
$data = $this->extractData($response, 'Freepik image request gagal');
$urls = $this->extractGeneratedUrls($data);
if ($urls === []) {
throw new RuntimeException('Freepik image legacy endpoint tidak mengembalikan output gambar.');
}
return array_values(array_slice($urls, 0, $count));
}
private function createTask(string $apiKey, int $timeout, array $payload): array
{
$response = $this->client($apiKey, $timeout)
->post($this->imageEndpoint(), $payload);
$data = $this->extractData($response, 'Freepik image request gagal');
$directUrl = $this->extractGeneratedUrl($data);
if ($directUrl !== null) {
return [
'task_id' => null,
'direct_url' => $directUrl,
];
}
$taskId = trim((string) data_get($data, 'task_id', ''));
if ($taskId === '') {
throw new RuntimeException('Freepik image tidak mengembalikan task_id.');
}
return [
'task_id' => $taskId,
'direct_url' => null,
];
}
private function awaitGeneratedUrl(string $apiKey, int $timeout, string $taskId): string
{
$deadline = microtime(true) + max(25, min(120, $timeout));
do {
$response = $this->client($apiKey, min(30, $timeout))
->get($this->imageEndpoint().'/'.$taskId);
$data = $this->extractData($response, 'Gagal membaca status task Freepik image');
$generatedUrl = $this->extractGeneratedUrl($data);
if ($generatedUrl !== null) {
return $generatedUrl;
}
$status = strtoupper(trim((string) data_get($data, 'status', '')));
if (in_array($status, ['FAILED', 'ERROR', 'REJECTED', 'CANCELLED'], true)) {
throw new RuntimeException('Freepik image task berakhir dengan status '.$status.'.');
}
usleep(800000);
} while (microtime(true) < $deadline);
throw new RuntimeException('Timeout saat menunggu hasil image dari Freepik.');
}
private function client(string $apiKey, int $timeout)
{
return Http::timeout($timeout)
->acceptJson()
->withHeaders([
'x-freepik-api-key' => $apiKey,
]);
}
private function imageEndpoint(): string
{
$configured = trim((string) config('services.freepik.base_url', ''));
if ($configured === '') {
return 'https://api.freepik.com/v1/ai/text-to-image/flux-2-klein';
}
$configured = rtrim($configured, '/');
if (Str::contains($configured, '/text-to-image/') && ! Str::endsWith($configured, '/text-to-image')) {
return $configured;
}
if (Str::endsWith($configured, '/text-to-image')) {
return $configured.'/flux-2-klein';
}
if (preg_match('#/v1/ai$#', $configured) === 1) {
return $configured.'/text-to-image/flux-2-klein';
}
return rtrim($this->apiOrigin($configured), '/').'/v1/ai/text-to-image/flux-2-klein';
}
private function legacyEndpoint(): string
{
$configured = trim((string) config('services.freepik.base_url', 'https://api.freepik.com/v1/ai/text-to-image'));
return rtrim($configured, '/');
}
private function shouldUseLegacyEndpoint(): bool
{
$configured = trim((string) config('services.freepik.base_url', ''));
if ($configured === '') {
return true;
}
return Str::endsWith(rtrim($configured, '/'), '/text-to-image');
}
private function apiOrigin(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https';
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($host) || $host === '') {
return 'https://api.freepik.com';
}
return $scheme.'://'.$host.($port ? ':'.$port : '');
}
private function aspectRatio(string $aspect): string
{
return match ($aspect) {
'portrait' => 'social_story_9_16',
'square' => 'square_1_1',
default => 'widescreen_16_9',
};
}
private function legacySize(string $aspect): array
{
return match ($aspect) {
'portrait' => [768, 1344],
'square' => [1024, 1024],
default => [1344, 768],
};
}
private function extractData(Response $response, string $prefix): array
{
if (! $response->successful()) {
throw new RuntimeException($prefix.': '.$response->status().' - '.$this->responseError($response));
}
$json = $response->json();
$data = data_get($json, 'data', $json);
if (! is_array($data)) {
throw new RuntimeException($prefix.': format response Freepik tidak dikenali.');
}
return $data;
}
private function responseError(Response $response): string
{
$message = data_get($response->json(), 'message')
?? data_get($response->json(), 'error.message')
?? data_get($response->json(), 'detail')
?? $response->body();
return Str::limit(trim((string) $message), 240);
}
private function extractGeneratedUrl(array $data): ?string
{
return $this->extractGeneratedUrls($data)[0] ?? null;
}
private function extractGeneratedUrls(array $data): array
{
$candidates = data_get($data, 'generated', data_get($data, 'images', $data));
if (! is_array($candidates) || $candidates === []) {
return [];
}
$urls = [];
foreach ($candidates as $candidate) {
if (is_string($candidate) && Str::startsWith($candidate, ['http://', 'https://'])) {
$urls[] = trim($candidate);
continue;
}
if (is_array($candidate)) {
$url = data_get($candidate, 'url', data_get($candidate, 'image_url'));
if (is_string($url) && trim($url) !== '') {
$urls[] = trim($url);
continue;
}
$base64 = data_get($candidate, 'base64', data_get($candidate, 'b64_json'));
if (is_string($base64) && trim($base64) !== '') {
$urls[] = $this->persistBase64Image($base64);
}
}
}
return $urls;
}
private function persistBase64Image(string $base64): string
{
$binary = base64_decode($base64, true);
if ($binary === false) {
throw new RuntimeException('Freepik image mengirim base64 yang tidak valid.');
}
$relativePath = 'images/freepik_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.jpg';
Storage::disk('public')->put($relativePath, $binary);
return $this->offloadPublicPath($relativePath);
}
private function offloadPublicPath(string $relativePath): string
{
try {
return $this->hfMedia()->offloadPublicRelativePath($relativePath);
} catch (\Throwable) {
return Storage::url($relativePath);
}
}
private function hfMedia(): HuggingFaceMediaStorageService
{
if ($this->hfMediaStorage instanceof HuggingFaceMediaStorageService) {
return $this->hfMediaStorage;
}
$service = app(HuggingFaceMediaStorageService::class);
if (! $service instanceof HuggingFaceMediaStorageService) {
throw new RuntimeException('Service HuggingFaceMediaStorageService tidak tersedia.');
}
$this->hfMediaStorage = $service;
return $this->hfMediaStorage;
}
}