teknolis / app /Services /MotionVideoService.php
Codex
Deploy latest assistant, DeepSeek defaults, and automation updates
74acf19
Raw
History Blame Contribute Delete
14.2 kB
<?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);
// ── 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, '<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".'
);
}
// Detect HTML error page
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) {
// Try format-specific fallbacks
$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));
// 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;
}
}