| <?php |
|
|
| namespace App\Services; |
|
|
| use Illuminate\Support\Facades\Http; |
| use Illuminate\Support\Facades\Storage; |
| use Illuminate\Support\Str; |
| use RuntimeException; |
|
|
| class VideoMusicComposerService |
| { |
| public function compose(string $videoUrl, string $musicUrl, float $musicVolume = 0.24): array |
| { |
| $videoPath = $this->resolveToLocalPath($videoUrl, 'video'); |
| $musicPath = $this->resolveToLocalPath($musicUrl, 'audio'); |
|
|
| $musicVolume = max(0.05, min(1.0, $musicVolume)); |
|
|
| $outputRelative = 'videos/social_mix_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.mp4'; |
| $outputPath = Storage::disk('public')->path($outputRelative); |
|
|
| $this->ensureDirectory(dirname($outputPath)); |
|
|
| $mixCommand = sprintf( |
| 'ffmpeg -y -i %s -stream_loop -1 -i %s -filter_complex %s -map 0:v:0 -map [aout] -c:v copy -c:a aac -shortest %s 2>&1', |
| escapeshellarg($videoPath), |
| escapeshellarg($musicPath), |
| escapeshellarg('[0:a]volume=1.0[a0];[1:a]volume='.$musicVolume.'[a1];[a0][a1]amix=inputs=2:duration=first:dropout_transition=2[aout]'), |
| escapeshellarg($outputPath) |
| ); |
|
|
| exec($mixCommand, $mixOutput, $mixCode); |
|
|
| if ($mixCode !== 0) { |
| $replaceCommand = sprintf( |
| 'ffmpeg -y -i %s -stream_loop -1 -i %s -filter_complex %s -map 0:v:0 -map [bgm] -c:v copy -c:a aac -shortest %s 2>&1', |
| escapeshellarg($videoPath), |
| escapeshellarg($musicPath), |
| escapeshellarg('[1:a]volume='.$musicVolume.'[bgm]'), |
| escapeshellarg($outputPath) |
| ); |
|
|
| exec($replaceCommand, $replaceOutput, $replaceCode); |
|
|
| if ($replaceCode !== 0) { |
| throw new RuntimeException('Gagal merge musik ke video via ffmpeg: '.trim(implode("\n", array_slice($replaceOutput, -8)))); |
| } |
| } |
|
|
| if (! is_file($outputPath) || filesize($outputPath) < 1024) { |
| throw new RuntimeException('Output video hasil merge tidak valid.'); |
| } |
|
|
| return [ |
| 'url' => Storage::url($outputRelative), |
| 'relative_path' => $outputRelative, |
| 'music_volume' => $musicVolume, |
| ]; |
| } |
|
|
| private function resolveToLocalPath(string $source, string $kind): string |
| { |
| $source = trim($source); |
| if ($source === '') { |
| throw new RuntimeException('URL '.$kind.' kosong.'); |
| } |
|
|
| if (Str::startsWith($source, '/storage/')) { |
| $relative = ltrim(Str::replaceFirst('/storage/', '', $source), '/'); |
| $path = Storage::disk('public')->path($relative); |
| if (! is_file($path)) { |
| throw new RuntimeException('File '.$kind.' lokal tidak ditemukan: '.$source); |
| } |
|
|
| return $path; |
| } |
|
|
| if (! Str::startsWith($source, ['http://', 'https://'])) { |
| throw new RuntimeException('URL '.$kind.' harus http/https atau /storage/...'); |
| } |
|
|
| $ext = $kind === 'audio' ? 'mp3' : 'mp4'; |
| $relativePath = 'tmp/social_media/'.$kind.'_'.now()->format('Ymd_His').'_'.Str::lower(Str::random(8)).'.'.$ext; |
| $fullPath = Storage::disk('local')->path($relativePath); |
| $this->ensureDirectory(dirname($fullPath)); |
|
|
| $response = Http::connectTimeout(20) |
| ->timeout(180) |
| ->get($source); |
|
|
| if (! $response->successful()) { |
| throw new RuntimeException('Gagal download '.$kind.': '.$response->status()); |
| } |
|
|
| file_put_contents($fullPath, $response->body()); |
|
|
| if (! is_file($fullPath) || filesize($fullPath) < 512) { |
| throw new RuntimeException('File '.$kind.' hasil download tidak valid.'); |
| } |
|
|
| return $fullPath; |
| } |
|
|
| private function ensureDirectory(string $path): void |
| { |
| if (! is_dir($path)) { |
| mkdir($path, 0775, true); |
| } |
| } |
| } |
|
|