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); // ── Choose motion preset based on prompt keywords (Ken Burns style) ────── $lowerPrompt = strtolower($prompt); $zoompanPresets = [ // 0 — slow zoom in toward center "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}", // 1 — start zoomed in, slowly pull out "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}", // 2 — pan left to right (fixed zoom 1.15) "zoompan=z=1.15:x='(iw-iw/zoom)*on/{$frameCount}':y='ih/2-(ih/zoom/2)':d={$frameCount}:s={$outputWidth}x{$outputHeight}:fps={$fps}", // 3 — pan right to left "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}", // 4 — tilt up (pan bottom to top) "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; // Bump PHP execution limit for this request only (video encoding can take 60-90s) @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); // Fallback A: zoompan unavailable — try simple static fade (no motion but at least a video) 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); // Detect SVG — GD cannot rasterize vector formats $sniff = \ltrim(\substr($binary, 0, 100)); if ( \str_starts_with($sniff, 'format('Ymd_His').'_'.Str::lower(Str::random(8)); // Try JPEG first, then WebP, then PNG 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/')) { // SVG from API may come as image/svg+xml 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; } }