teknolis / app /Services /FreepikVideoService.php
adwandw's picture
Config: Ensure HF-only mode robustness
eeaf011
Raw
History Blame Contribute Delete
10.1 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 FreepikVideoService
{
public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null)
{
}
public function generate(string $prompt, int $duration = 5): array
{
$apiKey = trim((string) config('services.freepik.api_key', ''));
$timeout = max(20, (int) config('services.freepik.timeout', 90));
$cleanPrompt = trim($prompt);
$duration = in_array($duration, [5, 8, 10], true) ? $duration : 5;
if ($apiKey === '') {
throw new RuntimeException($this->missingApiKeyMessage('Video generator'));
}
if ($cleanPrompt === '') {
throw new RuntimeException('Prompt video Freepik tidak boleh kosong.');
}
$response = $this->client($apiKey, $timeout)
->post($this->videoEndpoint(), [
'prompt' => $cleanPrompt,
'ratio' => '720:1280',
'duration' => $duration,
]);
$data = $this->extractData($response, 'Freepik video request gagal');
$videoUrl = $this->extractGeneratedUrl($data);
if ($videoUrl === null) {
$taskId = trim((string) data_get($data, 'task_id', ''));
if ($taskId === '') {
throw new RuntimeException('Freepik video tidak mengembalikan task_id.');
}
$videoUrl = $this->awaitGeneratedUrl($apiKey, $timeout, $taskId);
}
$relativePath = $this->persistVideo($videoUrl, $timeout);
return [
'url' => $this->offloadPublicPath($relativePath),
'provider' => 'freepik-runway',
'aspect' => '9:16',
'duration_seconds' => $duration,
];
}
private function awaitGeneratedUrl(string $apiKey, int $timeout, string $taskId): string
{
$deadline = microtime(true) + max(35, min(240, $timeout * 2));
$lastError = null;
do {
try {
$response = $this->client($apiKey, min(25, $timeout))
->get($this->videoEndpoint().'/'.$taskId);
$data = $this->extractData($response, 'Gagal membaca status task Freepik video');
$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 video task berakhir dengan status '.$status.'.');
}
} catch (\Throwable $pollError) {
$lastError = $pollError;
}
usleep(1500000);
} while (microtime(true) < $deadline);
if ($lastError !== null) {
throw new RuntimeException('Timeout saat menunggu hasil video dari Freepik. Detail terakhir: '.$lastError->getMessage());
}
throw new RuntimeException('Timeout saat menunggu hasil video dari Freepik.');
}
private function persistVideo(string $videoUrl, int $timeout): string
{
$response = Http::connectTimeout(20)
->timeout(max(45, min(120, $timeout * 2)))
->withHeaders(['Accept' => 'video/*'])
->get($videoUrl);
if (! $response->successful()) {
throw new RuntimeException('Gagal mengambil hasil video dari Freepik: '.$response->status());
}
$binary = $response->body();
if (! is_string($binary) || $binary === '') {
throw new RuntimeException('Binary video dari Freepik kosong.');
}
$relativePath = 'videos/freepik_'.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 Freepik 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 client(string $apiKey, int $timeout)
{
return Http::connectTimeout(20)
->timeout($timeout)
->acceptJson()
->withHeaders([
'x-freepik-api-key' => $apiKey,
]);
}
/**
* Build the Freepik video generation endpoint.
*
* The original implementation concatenated the configured `services.freepik.base_url`
* (which defaults to the *text‑to‑image* endpoint) with the video path, resulting in
* an invalid URL such as `https://api.freepik.com/v1/ai/text-to-image/v1/ai/text-to-video/...`.
* This caused the remote API to return a 500 error which propagated to the Space UI.
*
* The logic now:
* 1. If the user explicitly sets a `FREEPIK_VIDEO_BASE_URL` env variable (via the config
* key `services.freepik.video_base_url` – fallback to the generic `base_url`), use it
* directly.
* 2. If the configured base URL already contains the segment `text-to-video`, assume it
* is a proper video endpoint and return it unchanged.
* 3. Otherwise fall back to the official default video endpoint.
*/
private function videoEndpoint(): string
{
// Allow an explicit video base URL; otherwise reuse the generic base_url.
$configured = trim((string) config('services.freepik.video_base_url', config('services.freepik.base_url', '')));
if ($configured === '' || Str::contains($configured, 'text-to-video')) {
// Empty config or already a video endpoint – use the default video URL.
return $configured !== '' && Str::contains($configured, 'text-to-video')
? rtrim($configured, '/').'/runway-4-5'
: 'https://api.freepik.com/v1/ai/text-to-video/runway-4-5';
}
// If a non‑video base URL is provided (e.g., the default text‑to‑image URL), ignore it
// and return the canonical video endpoint.
return 'https://api.freepik.com/v1/ai/text-to-video/runway-4-5';
}
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 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);
}
/**
* Return a helpful message when the Freepik API key is missing.
*
* In a Hugging Face Space the environment variables are defined in the Space
* settings. If the key is absent we guide the user to add
* `FREEPIK_API_KEY` there. When the space runs in HF‑only mode we explain
* that the video feature is disabled.
*/
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.';
}
return 'FREEPIK_API_KEY belum diisi di file .env. Tambahkan variabel `FREEPIK_API_KEY` di Settings → Secrets of the Hugging Face Space dan redeploy.';
}
private function extractGeneratedUrl(array $data): ?string
{
$candidates = data_get($data, 'generated', []);
if (! is_array($candidates) || $candidates === []) {
return null;
}
foreach ($candidates as $candidate) {
if (is_string($candidate) && Str::startsWith($candidate, ['http://', 'https://'])) {
return trim($candidate);
}
if (is_array($candidate)) {
$url = data_get($candidate, 'url', data_get($candidate, 'video_url'));
if (is_string($url) && trim($url) !== '') {
return trim($url);
}
}
}
return null;
}
}