teknolis / app /Services /OpenAiVideoService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
10.2 kB
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class OpenAiVideoService
{
public function __construct(
private OpenAiImageService $openAiImage,
private MotionVideoService $motionVideo,
private ?HuggingFaceMediaStorageService $hfMediaStorage = null,
) {
}
public function generate(?string $sourceImageUrl, string $prompt, array $options = []): array
{
$cleanPrompt = trim($prompt);
$imageUrl = trim((string) $sourceImageUrl);
$usesGeneratedFrame = false;
if ($cleanPrompt === '') {
throw new RuntimeException('Prompt video OpenAI wajib diisi.');
}
if ($this->isAvailable()) {
return $imageUrl !== ''
? $this->submit($imageUrl, $cleanPrompt, $options)
: $this->submit(null, $cleanPrompt, $options);
}
if ($imageUrl === '') {
$images = $this->openAiImage->generate($cleanPrompt, $this->resolveAspect($options), 1);
$imageUrl = trim((string) ($images[0] ?? ''));
$usesGeneratedFrame = true;
}
$video = $this->motionVideo->createFromImageUrl($imageUrl, $cleanPrompt, array_merge([
'video_quality' => '720p',
'video_duration' => '4s',
], $options));
return [
'url' => $video['url'] ?? null,
'provider' => 'openai-motion',
'aspect' => $video['aspect'] ?? ($options['aspect_ratio'] ?? '16:9'),
'prompt' => $cleanPrompt,
'duration_seconds' => $video['duration_seconds'] ?? 4,
'source_image_url' => $imageUrl,
'applied_settings' => array_merge((array) ($video['applied_settings'] ?? []), [
'video_provider' => 'openai',
'openai_video_model' => 'motion-render-fallback',
]),
'notice' => $usesGeneratedFrame
? 'Start frame dibuat lewat OpenAI image, lalu dianimasikan dengan motion renderer lokal karena OpenAI Video API belum tersedia.'
: 'Source frame dipakai langsung, lalu dirender ke video dengan fallback motion lokal.',
'async' => false,
];
}
public function submit(?string $sourceImageUrl, string $prompt, array $options = []): array
{
if (! $this->isAvailable()) {
throw new RuntimeException('OPENAI_API_KEY belum diisi di file .env');
}
$payload = $this->buildPayload($sourceImageUrl, $prompt, $options);
$response = Http::timeout($this->resolveTimeout())
->withToken($this->resolveApiKey())
->acceptJson()
->post($this->endpoint('/videos'), $payload['body']);
if (! $response->successful()) {
$errorBody = trim((string) data_get($response->json(), 'error.message', $response->body()));
throw new RuntimeException('OpenAI video submit gagal: '.$response->status().' - '.$errorBody);
}
$videoId = trim((string) $response->json('id', ''));
if ($videoId === '') {
throw new RuntimeException('OpenAI video tidak mengembalikan video id.');
}
Log::info('OpenAiVideoService: submitted', [
'video_id' => $videoId,
'model' => $payload['applied_settings']['openai_video_model'] ?? 'sora-2',
]);
return [
'provider' => 'openai-sora',
'prediction_id' => $videoId,
'source_image_url' => $sourceImageUrl,
'prompt' => trim($prompt),
'applied_settings' => $payload['applied_settings'],
'notice' => 'Render OpenAI Sora aktif memakai '.$payload['applied_settings']['openai_video_model'].' dengan output '.$payload['applied_settings']['video_quality'].' dan durasi '.$payload['applied_settings']['video_duration'].'.',
'async' => true,
];
}
public function checkStatus(string $videoId): array
{
if (! $this->isAvailable()) {
return ['status' => 'failed', 'error' => 'OPENAI_API_KEY belum diisi di file .env'];
}
$response = Http::timeout(30)
->withToken($this->resolveApiKey())
->acceptJson()
->get($this->endpoint('/videos/'.ltrim($videoId, '/')));
if (! $response->successful()) {
$message = trim((string) data_get($response->json(), 'error.message', $response->body()));
return ['status' => 'failed', 'error' => 'Gagal mengambil status video OpenAI: '.$message];
}
$data = $response->json();
$status = (string) ($data['status'] ?? '');
if (in_array($status, ['queued', 'in_progress'], true)) {
return ['status' => 'processing'];
}
if ($status === 'failed') {
$errorMessage = (string) data_get($data, 'error.message', 'OpenAI video gagal diproses.');
return ['status' => 'failed', 'error' => $errorMessage];
}
if ($status !== 'completed') {
return ['status' => 'failed', 'error' => 'Status video OpenAI tidak dikenali: '.$status];
}
try {
$download = $this->downloadVideo($videoId);
return [
'status' => 'succeeded',
'url' => $download['url'],
'remote_url' => $download['remote_url'],
'provider' => 'openai-sora',
];
} catch (\Throwable $e) {
return ['status' => 'failed', 'error' => $e->getMessage()];
}
}
public function isAvailable(): bool
{
return trim($this->resolveApiKey()) !== '';
}
private function buildPayload(?string $sourceImageUrl, string $prompt, array $options): array
{
$model = trim((string) ($options['openai_video_model'] ?? config('services.openai.video_model', 'sora-2')));
$seconds = $this->normalizeDuration((string) ($options['video_duration'] ?? '4s'));
$size = $this->normalizeSize((string) ($options['aspect_ratio'] ?? '16:9'), (string) ($options['video_quality'] ?? '720p'));
$body = [
'model' => $model !== '' ? $model : 'sora-2',
'prompt' => trim($prompt),
'seconds' => $seconds,
'size' => $size,
];
if (trim((string) $sourceImageUrl) !== '') {
$body['input_reference'] = [
[
'image_url' => trim((string) $sourceImageUrl),
],
];
}
return [
'body' => $body,
'applied_settings' => [
'video_provider' => 'openai',
'video_quality' => str_starts_with($size, '720x') || str_ends_with($size, 'x720') ? '720p' : '1024p',
'video_duration' => $seconds.'s',
'aspect_ratio' => trim((string) ($options['aspect_ratio'] ?? '16:9')) === '9:16' ? '9:16' : '16:9',
'openai_video_model' => $body['model'],
'openai_video_size' => $size,
],
];
}
private function normalizeDuration(string $duration): string
{
return match (trim($duration)) {
'5s', '4s' => '4',
'12s' => '12',
default => '8',
};
}
private function normalizeSize(string $aspectRatio, string $quality): string
{
$portrait = trim($aspectRatio) === '9:16';
$hd = trim($quality) !== '720p';
if ($hd) {
return $portrait ? '1024x1792' : '1792x1024';
}
return $portrait ? '720x1280' : '1280x720';
}
private function resolveAspect(array $options): string
{
return (($options['aspect_ratio'] ?? '16:9') === '9:16') ? 'portrait' : 'landscape';
}
private function resolveApiKey(): string
{
$primaryKey = trim((string) config('services.openai.api_key'));
$fallbackKey = trim((string) config('services.openai.chat_api_key'));
return $primaryKey !== '' ? $primaryKey : $fallbackKey;
}
private function resolveTimeout(): int
{
return max(30, (int) config('services.openai.timeout', 40));
}
private function endpoint(string $path): string
{
return rtrim((string) config('services.openai.base_url', 'https://api.openai.com/v1'), '/').$path;
}
private function downloadVideo(string $videoId): array
{
$response = Http::timeout(180)
->withToken($this->resolveApiKey())
->withOptions(['stream' => false])
->get($this->endpoint('/videos/'.ltrim($videoId, '/').'/content'));
if ($response->failed()) {
$message = trim((string) data_get($response->json(), 'error.message', $response->body()));
throw new RuntimeException('Gagal download video OpenAI: '.$message);
}
$relPath = 'videos/openai_sora_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.mp4';
$absPath = Storage::disk('public')->path($relPath);
$outputDir = dirname($absPath);
if (! is_dir($outputDir) && ! @mkdir($outputDir, 0775, true) && ! is_dir($outputDir)) {
throw new RuntimeException('Gagal menyiapkan folder output video OpenAI.');
}
file_put_contents($absPath, $response->body());
if (! is_file($absPath) || filesize($absPath) < 1024) {
throw new RuntimeException('File video OpenAI kosong atau rusak setelah download.');
}
$publicUrl = Storage::disk('public')->url($relPath);
if ($this->hfMediaStorage !== null) {
try {
$publicUrl = $this->hfMediaStorage->offloadPublicRelativePath($relPath);
} catch (\Throwable $e) {
Log::warning('OpenAiVideoService: HF offload failed', ['error' => $e->getMessage()]);
}
}
return [
'url' => $publicUrl,
'remote_url' => $this->endpoint('/videos/'.ltrim($videoId, '/').'/content'),
];
}
}