teknolis / app /Services /FreepikAssetRemixService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
19.9 kB
<?php
namespace App\Services;
use App\Exceptions\TaskStillProcessingException;
use App\Traits\Retryable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class FreepikAssetRemixService
{
use Retryable;
public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null)
{
}
public function remixImage(array $source, string $prompt, string $mode): array
{
$data = $this->submitTask(
'/v1/ai/text-to-image/seedream-v4-5-edit',
[
'prompt' => $this->composePrompt($prompt, $mode, 'image'),
'aspect_ratio' => 'social_story_9_16',
'reference_images' => [$this->referenceImageBase64($source)],
'seed' => random_int(1, 2147483647),
'enable_safety_checker' => true,
],
'Freepik asset image edit gagal'
);
return [
'urls' => $this->awaitGeneratedUrls('/v1/ai/text-to-image/seedream-v4-5-edit', $data, 'Freepik asset image edit gagal'),
'provider' => 'freepik-seedream-v45-edit',
];
}
public function remixVideo(array $source, string $prompt, string $mode, int $duration = 5): array
{
$duration = in_array($duration, [5, 10], true) ? $duration : 5;
$submission = $this->submitRemixVideo($source, $prompt, $mode, $duration);
$status = $this->checkRemixVideoStatus((string) ($submission['prediction_id'] ?? ''));
if (($status['status'] ?? '') !== 'succeeded' || ! is_string($status['url'] ?? null) || trim((string) $status['url']) === '') {
throw new RuntimeException((string) ($status['error'] ?? 'Freepik asset video remix tidak mengembalikan URL video.'));
}
return [
'url' => (string) $status['url'],
'provider' => 'freepik-runway-i2v',
'aspect' => '9:16',
'duration_seconds' => $duration,
];
}
public function submitRemixVideo(array $source, string $prompt, string $mode, int $duration = 5): array
{
$duration = in_array($duration, [5, 10], true) ? $duration : 5;
$data = $this->submitTask(
'/v1/ai/image-to-video/runway-4-5',
[
'prompt' => $this->composePrompt($prompt, $mode, 'video'),
'image' => $this->referenceImagePayload($source),
'ratio' => '720:1280',
'duration' => $duration,
],
'Freepik asset video remix gagal'
);
$taskId = trim((string) data_get($data, 'task_id', ''));
if ($taskId === '') {
throw new RuntimeException('Freepik asset video remix tidak mengembalikan task_id.');
}
return [
'provider' => 'freepik-runway-i2v',
'prediction_id' => $taskId,
'source_image_url' => $this->referenceImagePayload($source),
'async' => true,
];
}
public function checkRemixVideoStatus(string $taskId): array
{
$taskId = trim($taskId);
if ($taskId === '') {
return ['status' => 'failed', 'error' => 'task_id kosong untuk Freepik Runway.'];
}
try {
$response = $this->client(60)->get($this->endpoint('/v1/ai/image-to-video/runway-4-5/'.$taskId));
$task = $this->extractData($response, 'Freepik asset video remix gagal membaca status');
$generated = $this->extractGeneratedUrls($task);
if ($generated !== []) {
$videoUrl = trim((string) ($generated[0] ?? ''));
if ($videoUrl === '') {
return ['status' => 'failed', 'error' => 'Freepik asset video remix tidak mengembalikan URL video.'];
}
$relativePath = $this->persistVideo($videoUrl);
return [
'status' => 'succeeded',
'url' => $this->offloadPublicPath($relativePath),
'remote_url' => $videoUrl,
'provider' => 'freepik-runway-i2v',
];
}
$status = strtoupper(trim((string) data_get($task, 'status', '')));
if (in_array($status, ['FAILED', 'ERROR', 'REJECTED', 'CANCELLED'], true)) {
return ['status' => 'failed', 'error' => 'Freepik asset video remix berakhir dengan status '.$status.'.'];
}
return ['status' => 'processing'];
} catch (Throwable $error) {
return ['status' => 'failed', 'error' => $error->getMessage()];
}
}
private function submitTask(string $path, array $payload, string $prefix): array
{
$response = $this->client()
->withHeaders(['Content-Type' => 'application/json'])
->post($this->endpoint($path), $payload);
return $this->extractData($response, $prefix);
}
private function awaitGeneratedUrls(string $path, array $initialData, string $prefix): array
{
$taskId = trim((string) data_get($initialData, 'task_id', ''));
if ($taskId === '') {
$generated = $this->extractGeneratedUrls($initialData);
if ($generated !== []) {
return $generated;
}
throw new RuntimeException($prefix.': task_id tidak ditemukan pada respons awal.');
}
$callable = function () use ($path, $taskId, $prefix) {
$response = $this->client(60)->get($this->endpoint(rtrim($path, '/').'/'.$taskId));
$task = $this->extractData($response, $prefix.' status gagal');
$generated = $this->extractGeneratedUrls($task);
if ($generated !== []) {
return $generated;
}
$status = strtoupper(trim((string) data_get($task, 'status', '')));
if (in_array($status, ['FAILED', 'ERROR', 'REJECTED', 'CANCELLED'], true)) {
throw new RuntimeException($prefix.': task berakhir dengan status '.$status.'.');
}
throw new TaskStillProcessingException('Tugas '.$taskId.' masih dalam proses (status: '.$status.').');
};
try {
return $this->retry(
$callable,
15,
1500,
0.2,
[ConnectionException::class, TaskStillProcessingException::class]
);
} catch (Throwable $e) {
if ($e instanceof TaskStillProcessingException) {
throw new RuntimeException($prefix.': timeout saat menunggu hasil dari Freepik.');
}
throw $e;
}
}
private function referenceImageBase64(array $source): string
{
return base64_encode($this->referenceImageBinary($source));
}
private function referenceImagePayload(array $source): string
{
$candidate = $this->referenceImageLocation($source);
if (Str::startsWith($candidate, ['https://', 'http://'])) {
return $candidate;
}
$appUrl = rtrim((string) config('app.url', ''), '/');
if ($appUrl !== '' && Str::startsWith($candidate, '/storage/')) {
return $appUrl.$candidate;
}
$binary = $this->referenceImageBinary($source);
return base64_encode($binary);
}
private function referenceImageBinary(array $source): string
{
$candidate = $this->referenceImageLocation($source);
$binary = $this->readBinary($candidate);
$binary = $this->optimizeReferenceImage($binary);
if ($binary === '') {
throw new RuntimeException('Gagal membaca binary source asset untuk AI edit.');
}
return $binary;
}
private function referenceImageLocation(array $source): string
{
$type = (string) ($source['type'] ?? 'image');
$candidate = '';
if ($type === 'video') {
$candidate = trim((string) ($source['thumbnail_url'] ?? ''));
if ($candidate === '') {
throw new RuntimeException('Source video belum punya thumbnail/reference image untuk AI edit.');
}
} else {
$candidate = trim((string) ($source['url'] ?? ''));
}
if ($candidate === '') {
throw new RuntimeException('Source asset tidak punya file referensi yang bisa diproses.');
}
return $candidate;
}
private function readBinary(string $location): string
{
if (Str::startsWith($location, 'data:')) {
$parts = explode(',', $location, 2);
$binary = isset($parts[1]) ? base64_decode($parts[1], true) : false;
return $binary === false ? '' : $binary;
}
$localPath = $this->publicStoragePath($location);
if ($localPath !== null && is_file($localPath)) {
return (string) file_get_contents($localPath);
}
if (is_file($location)) {
return (string) file_get_contents($location);
}
if (Str::startsWith($location, ['http://', 'https://'])) {
$response = Http::connectTimeout(20)
->timeout(180)
->get($location);
if (! $response->successful()) {
throw new RuntimeException('Gagal mengambil reference asset: '.$response->status());
}
return (string) $response->body();
}
throw new RuntimeException('Reference asset tidak bisa dibaca dari lokasi '.$location);
}
private function publicStoragePath(string $url): ?string
{
$path = null;
if (Str::startsWith($url, '/storage/')) {
$path = $url;
} else {
$appUrl = rtrim((string) config('app.url', ''), '/');
if ($appUrl !== '' && Str::startsWith($url, $appUrl.'/storage/')) {
$path = Str::replaceFirst($appUrl, '', $url);
}
}
if (! is_string($path) || $path === '') {
return null;
}
$relative = ltrim(Str::replaceFirst('/storage/', '', $path), '/');
return $relative === '' ? null : Storage::disk('public')->path($relative);
}
private function persistVideo(string $videoUrl): string
{
$response = Http::connectTimeout(20)
->timeout(240)
->retry(3, 1500)
->withHeaders(['Accept' => 'video/*'])
->get($videoUrl);
if (! $response->successful()) {
throw new RuntimeException('Gagal mengambil hasil video remix Freepik: '.$response->status());
}
$binary = $response->body();
if (! is_string($binary) || $binary === '') {
throw new RuntimeException('Binary video remix Freepik kosong.');
}
$relativePath = 'videos/freepik_remix_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.mp4';
Storage::disk('public')->makeDirectory('videos');
if (! Storage::disk('public')->put($relativePath, $binary)) {
throw new RuntimeException('Gagal menyimpan hasil video remix ke storage public.');
}
return $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;
}
private function composePrompt(string $prompt, string $mode, string $outputType): string
{
$modeInstruction = match ($mode) {
'mockup' => 'Transform the reference into a polished premium mockup while preserving the main subject and core composition cues.',
'face_swap' => 'Keep the main identity strongly aligned with the reference while adapting the styling and scene to the new prompt.',
'strict_face_swap' => 'Preserve the primary identity and facial structure from the reference as strictly as possible while following the prompt.',
default => 'Restyle the reference while preserving the main subject and overall visual intent.',
};
$outputInstruction = $outputType === 'video'
? 'Create a cinematic vertical 9:16 motion result that feels ready for social story posting.'
: 'Create a premium vertical 9:16 still image ready for social story posting.';
return trim($modeInstruction.' '.$outputInstruction.' '.$prompt);
}
private function optimizeReferenceImage(string $binary): string
{
$maxBytes = 9_500_000;
if (strlen($binary) <= $maxBytes) {
return $binary;
}
if (! function_exists('\imagecreatefromstring')) {
throw new RuntimeException('Reference image lebih besar dari 10MB dan GD tidak tersedia untuk kompres otomatis.');
}
$image = @\imagecreatefromstring($binary);
if (! $image) {
throw new RuntimeException('Reference image terlalu besar dan gagal dikompres untuk Freepik edit.');
}
try {
$width = \imagesx($image);
$height = \imagesy($image);
$longestEdge = max($width, $height, 1);
$scale = min(1, 1800 / $longestEdge);
$targetWidth = max(1, (int) round($width * $scale));
$targetHeight = max(1, (int) round($height * $scale));
$canvas = \imagecreatetruecolor($targetWidth, $targetHeight);
if (! $canvas) {
throw new RuntimeException('Gagal membuat canvas kompresi untuk reference image.');
}
try {
$background = \imagecolorallocate($canvas, 255, 255, 255);
\imagefill($canvas, 0, 0, $background);
\imagecopyresampled($canvas, $image, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
$quality = 88;
$encoded = '';
do {
ob_start();
\imagejpeg($canvas, null, $quality);
$encoded = (string) ob_get_clean();
$quality -= 8;
} while ($encoded !== '' && strlen($encoded) > $maxBytes && $quality >= 56);
if ($encoded === '' || strlen($encoded) > $maxBytes) {
throw new RuntimeException('Reference image masih terlalu besar setelah kompresi otomatis.');
}
return $encoded;
} finally {
\imagedestroy($canvas);
}
} finally {
\imagedestroy($image);
}
}
private function detectImageMime(string $binary): string
{
if (function_exists('finfo_buffer')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo !== false) {
try {
$mime = finfo_buffer($finfo, $binary);
if (is_string($mime) && Str::startsWith($mime, 'image/')) {
return $mime;
}
} finally {
finfo_close($finfo);
}
}
}
return 'image/jpeg';
}
private function extractGeneratedUrls(array $data): array
{
$candidates = data_get($data, 'generated', []);
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, 'video_url', data_get($candidate, 'image_url')));
if (is_string($url) && trim($url) !== '') {
$urls[] = trim($url);
}
}
}
return array_values(array_filter($urls));
}
private function client(int $timeout = 120)
{
$apiKey = trim((string) config('services.freepik.api_key', ''));
if ($apiKey === '') {
throw new RuntimeException($this->missingApiKeyMessage('Remix lab'));
}
return Http::connectTimeout(20)
->timeout($timeout)
->retry(3, 1500)
->acceptJson()
->withHeaders([
'x-freepik-api-key' => $apiKey,
]);
}
private function endpoint(string $path): string
{
return rtrim($this->apiOrigin(), '/').$path;
}
private function apiOrigin(): string
{
$configured = trim((string) config('services.freepik.base_url', 'https://api.freepik.com/v1/ai/text-to-image'));
$scheme = parse_url($configured, PHP_URL_SCHEME) ?: 'https';
$host = parse_url($configured, PHP_URL_HOST);
$port = parse_url($configured, PHP_URL_PORT);
if (! is_string($host) || $host === '') {
return 'https://api.freepik.com';
}
return $scheme.'://'.$host.($port ? ':'.$port : '');
}
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), 260);
}
/**
* Generate a helpful error message when the Freepik API key is missing.
*
* The service can operate in two modes: normal (Freepik API enabled) or
* "Hugging Face‑only" mode, which disables Freepik integration. In the
* latter case we return a clear indication that the feature is unavailable.
* Otherwise we point the developer to the required environment variable.
*/
private function missingApiKeyMessage(string $feature): string
{
$hfOnly = strtolower(trim((string) (
config('services.huggingface.hf_only')
?? $_ENV['TOOLS_HF_ONLY']
?? $_SERVER['TOOLS_HF_ONLY']
?? getenv('TOOLS_HF_ONLY')
?? 'false'
)));
if (in_array($hfOnly, ['1', 'true', 'yes', 'on'], true)) {
return $feature.' belum tersedia di mode Hugging Face‑only. Gunakan image generator atau upload asset biasa.';
}
// Provide explicit guidance on where to set the key.
return 'FREEPIK_API_KEY belum diisi di file .env. Tambahkan baris `FREEPIK_API_KEY=your_key` ke .env dan pastikan konfigurasi layanan Freepik di config/services.php mengarah ke variabel ini.';
}
}