| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Http; |
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
| use function extension_loaded; |
| use function escapeshellarg; |
| use function escapeshellcmd; |
| use function shell_exec; |
| use function imagecreatefromstring; |
| use function imagecreatefrompng; |
| use function imagecreatefromjpeg; |
| use function imagecreatefromwebp; |
| use function imagejpeg; |
| use function imagedestroy; |
| use function tempnam; |
| use function sys_get_temp_dir; |
| use function file_put_contents; |
| use function unlink; |
|
|
| class MotionVideoService |
| { |
| public function __construct(private ?HuggingFaceMediaStorageService $hfMediaStorage = null) |
| { |
| } |
|
|
| public function createFromImageUrl(string $imageUrl, string $prompt, array $options = []): array |
| { |
| if (! $this->canUseShellExec()) { |
| throw new RuntimeException('Server memblokir shell_exec, jadi render video lokal belum bisa dijalankan. Gunakan provider video remote seperti Replicate atau aktifkan shell_exec + ffmpeg di server.'); |
| } |
|
|
| $ffmpeg = trim((string) $this->runShellCommand('command -v ffmpeg 2>/dev/null')); |
| if ($ffmpeg === '') { |
| throw new RuntimeException('FFmpeg belum tersedia di server, jadi generate video 9:16 belum bisa dijalankan.'); |
| } |
|
|
| $workingDir = Storage::disk('public')->path('media-lab'); |
| if (! is_dir($workingDir) && ! @mkdir($workingDir, 0775, true) && ! is_dir($workingDir)) { |
| throw new RuntimeException('Gagal menyiapkan folder kerja media lab.'); |
| } |
|
|
| $sourceFrame = $this->normalizeImageFrame($imageUrl, $workingDir); |
| $relativeOutput = 'videos/story_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.mp4'; |
| $outputPath = Storage::disk('public')->path($relativeOutput); |
| $outputDirectory = dirname($outputPath); |
|
|
| if (! is_dir($outputDirectory) && ! @mkdir($outputDirectory, 0775, true) && ! is_dir($outputDirectory)) { |
| throw new RuntimeException('Gagal menyiapkan folder output video.'); |
| } |
|
|
| if (! is_writable($outputDirectory)) { |
| throw new RuntimeException('Folder output video belum writable untuk proses web.'); |
| } |
|
|
| $aspectRatio = (string) ($options['aspect_ratio'] ?? '9:16'); |
| $videoQuality = (string) ($options['video_quality'] ?? '1080p'); |
| $videoDuration = (string) ($options['video_duration'] ?? '4s'); |
| $durationSeconds = match ($videoDuration) { |
| '4s', '5s' => 4, |
| '6s' => 6, |
| default => 8, |
| }; |
| $fps = 24; |
| $frameCount = $durationSeconds * $fps; |
|
|
| $dimensions = $this->resolveVideoDimensions($aspectRatio, $videoQuality); |
| $outputWidth = $dimensions['output_width']; |
| $outputHeight = $dimensions['output_height']; |
| $sourceWidth = $dimensions['source_width']; |
| $sourceHeight = $dimensions['source_height']; |
| $fadeOutStart = max(0.3, $durationSeconds - 0.7); |
|
|
| |
| $lowerPrompt = strtolower($prompt); |
|
|
| $zoompanPresets = [ |
| |
| "zoompan=z='zoom+0.001':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={$frameCount}:s={$outputWidth}x{$outputHeight}:fps={$fps}", |
| |
| "zoompan=z='1.25-on*0.002':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={$frameCount}:s={$outputWidth}x{$outputHeight}:fps={$fps}", |
| |
| "zoompan=z=1.15:x='(iw-iw/zoom)*on/{$frameCount}':y='ih/2-(ih/zoom/2)':d={$frameCount}:s={$outputWidth}x{$outputHeight}:fps={$fps}", |
| |
| "zoompan=z=1.15:x='(iw-iw/zoom)*(1-on/{$frameCount})':y='ih/2-(ih/zoom/2)':d={$frameCount}:s={$outputWidth}x{$outputHeight}:fps={$fps}", |
| |
| "zoompan=z=1.15:x='iw/2-(iw/zoom/2)':y='(ih-ih/zoom)*(1-on/{$frameCount})':d={$frameCount}:s={$outputWidth}x{$outputHeight}:fps={$fps}", |
| ]; |
|
|
| if (str_contains($lowerPrompt, 'aerial') || str_contains($lowerPrompt, 'drone') || str_contains($lowerPrompt, 'zoom out') || str_contains($lowerPrompt, 'pull back')) { |
| $motionIdx = 1; |
| } elseif (str_contains($lowerPrompt, 'pan') || str_contains($lowerPrompt, 'tracking shot') || str_contains($lowerPrompt, 'follow')) { |
| $motionIdx = 2; |
| } elseif (str_contains($lowerPrompt, 'tilt') || str_contains($lowerPrompt, 'reveal') || str_contains($lowerPrompt, 'rise')) { |
| $motionIdx = 4; |
| } elseif (str_contains($lowerPrompt, 'push in') || str_contains($lowerPrompt, 'close up') || str_contains($lowerPrompt, 'zoom in')) { |
| $motionIdx = 0; |
| } else { |
| $motionIdx = abs((int) crc32($prompt)) % 5; |
| } |
|
|
| $zoompanFilter = $zoompanPresets[$motionIdx]; |
| $baseScale = "scale={$sourceWidth}:{$sourceHeight}:force_original_aspect_ratio=increase,crop={$sourceWidth}:{$sourceHeight}"; |
| $fades = "fade=t=in:st=0:d=0.3,fade=t=out:st={$fadeOutStart}:d=0.7,format=yuv420p"; |
| $motionFilter = $baseScale . ',' . $zoompanFilter . ',' . $fades; |
|
|
| |
| @set_time_limit(180); |
|
|
| $command = sprintf( |
| '%s -y -loop 1 -i %s -vf %s -t %d -r %d -c:v libx264 -preset ultrafast -crf %d -pix_fmt yuv420p -movflags +faststart %s 2>&1', |
| escapeshellcmd($ffmpeg), |
| escapeshellarg($sourceFrame), |
| escapeshellarg($motionFilter), |
| $durationSeconds, |
| $fps, |
| $videoQuality === '720p' ? 28 : 23, |
| escapeshellarg($outputPath) |
| ); |
|
|
| $output = $this->runShellCommand($command); |
|
|
| |
| if (! is_file($outputPath) || filesize($outputPath) < 1024) { |
| $staticFilter = "scale={$outputWidth}:{$outputHeight}:force_original_aspect_ratio=increase,crop={$outputWidth}:{$outputHeight},fade=t=in:st=0:d=0.3,fade=t=out:st={$fadeOutStart}:d=0.7,format=yuv420p"; |
| $fallbackCmd = sprintf( |
| '%s -y -loop 1 -i %s -vf %s -t %d -r %d -c:v libx264 -preset ultrafast -crf %d -pix_fmt yuv420p -movflags +faststart %s 2>&1', |
| escapeshellcmd($ffmpeg), |
| escapeshellarg($sourceFrame), |
| escapeshellarg($staticFilter), |
| $durationSeconds, |
| $fps, |
| $videoQuality === '720p' ? 30 : 25, |
| escapeshellarg($outputPath) |
| ); |
| $output = $this->runShellCommand($fallbackCmd); |
| } |
|
|
| if (! is_file($outputPath) || filesize($outputPath) < 1024) { |
| throw new RuntimeException('Generate video gagal dijalankan. Detail: '.Str::limit(trim((string) $output), 240)); |
| } |
|
|
| return [ |
| 'url' => $this->offloadPublicPath($relativeOutput), |
| 'provider' => 'motion-video', |
| 'aspect' => $aspectRatio, |
| 'prompt' => $prompt, |
| 'duration_seconds' => $durationSeconds, |
| 'applied_settings' => [ |
| 'aspect_ratio' => $aspectRatio, |
| 'video_quality' => $videoQuality, |
| 'video_duration' => $durationSeconds.'s', |
| 'veo_variant' => 'local-fallback', |
| 'video_audio' => 'off', |
| ], |
| ]; |
| } |
|
|
| private function resolveVideoDimensions(string $aspectRatio, string $videoQuality): array |
| { |
| $quality = $videoQuality === '720p' ? '720p' : '1080p'; |
|
|
| return match ($aspectRatio) { |
| '16:9' => $quality === '720p' |
| ? ['output_width' => 1280, 'output_height' => 720, 'source_width' => 2560, 'source_height' => 1440] |
| : ['output_width' => 1920, 'output_height' => 1080, 'source_width' => 2880, 'source_height' => 1620], |
| default => $quality === '720p' |
| ? ['output_width' => 720, 'output_height' => 1280, 'source_width' => 1440, 'source_height' => 2560] |
| : ['output_width' => 1080, 'output_height' => 1920, 'source_width' => 1620, 'source_height' => 2880], |
| }; |
| } |
|
|
| private function normalizeImageFrame(string $imageUrl, string $workingDir): string |
| { |
| if (! \extension_loaded('gd')) { |
| throw new RuntimeException('GD extension belum aktif, jadi gambar sumber video belum bisa diproses.'); |
| } |
|
|
| $binary = $this->loadImageBinary($imageUrl); |
|
|
| |
| $sniff = \ltrim(\substr($binary, 0, 100)); |
| if ( |
| \str_starts_with($sniff, '<svg') || |
| \str_starts_with($sniff, '<?xml') || |
| \str_contains(\strtolower(\substr($binary, 0, 256)), '<svg') |
| ) { |
| throw new RuntimeException( |
| 'Format SVG tidak bisa digunakan sebagai sumber video. ' |
| .'Generate gambar terlebih dahulu menggunakan provider OpenAI, HuggingFace, atau Pollinations, lalu klik "Buat Video".' |
| ); |
| } |
|
|
| |
| if (\str_starts_with($sniff, '<!DOCTYPE') || \str_starts_with($sniff, '<html')) { |
| throw new RuntimeException('URL gambar mengembalikan halaman HTML bukan file gambar. Coba generate ulang gambar sumbernya.'); |
| } |
|
|
| $resource = @\imagecreatefromstring($binary); |
|
|
| if ($resource === false) { |
| |
| $tmpFile = \tempnam(\sys_get_temp_dir(), 'mvframe_'); |
| \file_put_contents($tmpFile, $binary); |
| |
| if (\function_exists('\imagecreatefrompng')) { |
| $resource = @\imagecreatefrompng($tmpFile); |
| } |
| |
| if ($resource === false && \function_exists('\imagecreatefromjpeg')) { |
| $resource = @\imagecreatefromjpeg($tmpFile); |
| } |
| |
| if ($resource === false && \function_exists('\imagecreatefromwebp')) { |
| $resource = @\imagecreatefromwebp($tmpFile); |
| } |
| |
| @\unlink($tmpFile); |
| } |
|
|
| if ($resource === false) { |
| throw new RuntimeException( |
| 'Gambar sumber video tidak bisa diproses (format tidak support atau data korup). ' |
| .'Coba generate ulang gambar dengan provider yang berbeda.' |
| ); |
| } |
|
|
| $framePath = $workingDir.'/frame_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)); |
| |
| |
| if (\function_exists('\imagejpeg')) { |
| $framePath .= '.jpg'; |
| $saved = @\imagejpeg($resource, $framePath, 92); |
| } elseif (\function_exists('\imagewebp')) { |
| $framePath .= '.webp'; |
| $saved = @\imagewebp($resource, $framePath, 85); |
| } else { |
| $framePath .= '.png'; |
| $saved = @\imagepng($resource, $framePath, 6); |
| } |
|
|
| if (! $saved) { |
| \imagedestroy($resource); |
| throw new RuntimeException('Gagal menyimpan frame sementara untuk video. GD JPEG/WebP/PNG might be broken.'); |
| } |
|
|
| \imagedestroy($resource); |
|
|
| return $framePath; |
| } |
|
|
| private function loadImageBinary(string $imageUrl): string |
| { |
| $path = parse_url($imageUrl, PHP_URL_PATH) ?: $imageUrl; |
|
|
| if (is_string($path) && str_starts_with($path, '/storage/')) { |
| $relative = ltrim(substr($path, strlen('/storage/')), '/'); |
| if (Storage::disk('public')->exists($relative)) { |
| $binary = Storage::disk('public')->get($relative); |
| if ($binary !== '') { |
| return $binary; |
| } |
| } |
| } |
|
|
| $response = Http::timeout(60)->get($imageUrl); |
| if (! $response->successful()) { |
| throw new RuntimeException('Gagal mengambil gambar sumber untuk video dari URL publik (HTTP '.$response->status().').'); |
| } |
|
|
| $contentType = strtolower((string) $response->header('Content-Type')); |
| if ($contentType !== '' && ! str_starts_with($contentType, 'image/')) { |
| |
| if (str_contains($contentType, 'svg')) { |
| throw new RuntimeException( |
| 'Format SVG tidak bisa digunakan sebagai sumber video. ' |
| .'Generate gambar terlebih dahulu menggunakan provider OpenAI, HuggingFace, atau Pollinations, lalu klik "Buat Video".' |
| ); |
| } |
| throw new RuntimeException( |
| 'URL gambar mengembalikan konten bukan gambar (Content-Type: '.$contentType.'). Coba generate ulang gambar sumbernya.' |
| ); |
| } |
|
|
| return (string) $response->body(); |
| } |
|
|
| private function offloadPublicPath(string $relativePath): string |
| { |
| try { |
| return $this->hfMedia()->offloadPublicRelativePath($relativePath); |
| } catch (\Throwable) { |
| return Storage::url($relativePath); |
| } |
| } |
|
|
| private function canUseShellExec(): bool |
| { |
| return \function_exists('shell_exec'); |
| } |
|
|
| private function runShellCommand(string $command): string |
| { |
| if (! $this->canUseShellExec()) { |
| return ''; |
| } |
|
|
| return (string) @shell_exec($command); |
| } |
|
|
| 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; |
| } |
| } |
|
|