where('external_reference', 'slideshow')->sortBy('time')->values(); $overlay_images = $elements->where('type', 'image') ->where('external_reference', '!=', 'slideshow') ->sortBy('time') ->values(); $video_element = $elements->firstWhere('type', 'video'); $audio_tracks = $elements->where('type', 'audio')->sortBy('time')->values(); if ($slide_images->isEmpty() || ! $video_element) { throw new \InvalidArgumentException('At least one slideshow and one video element are required.'); } // Build ffmpeg input arguments $input_args = []; foreach ($slide_images as $slide) { $duration = number_format($slide->duration, 2); $input_args[] = "-loop 1 -t {$duration} -i \"{$slide->asset_url}\""; } foreach ($overlay_images as $overlay) { $duration = number_format($overlay->duration, 2); $input_args[] = "-loop 1 -t {$duration} -i \"{$overlay->asset_url}\""; } $input_args[] = "-i \"{$video_element->asset_url}\""; foreach ($audio_tracks as $audio) { $input_args[] = "-i \"{$audio->asset_url}\""; } // Build filter_complex chains $filters = []; $slide_count = $slide_images->count(); $concat_input = ''; for ($i = 0; $i < $slide_count; $i++) { $concat_input .= "[{$i}:v]"; } $filters[] = "{$concat_input}concat=n={$slide_count}:v=1:a=0[slideshow]"; $current_label = 'slideshow'; foreach ($overlay_images as $idx => $overlay) { $stream_idx = $slide_count + $idx; $start_time = number_format($overlay->time, 2); $end_time = number_format($overlay->time + $overlay->duration, 2); $next_label = "overlay{$idx}"; $filters[] = "[{$current_label}][{$stream_idx}:v]overlay=enable=between(t\,{$start_time}\,{$end_time})[{$next_label}]"; $current_label = $next_label; } $video_idx = $slide_count + $overlay_images->count(); $v_start = number_format($video_element->time, 2); $v_end = number_format($video_element->time + $video_element->duration, 2); $filters[] = "[{$current_label}][{$video_idx}:v]overlay=enable=between(t\,{$v_start}\,{$v_end})[v_out]"; $audio_labels = []; $first_audio = $video_idx + 1; foreach ($audio_tracks as $idx => $audio) { $stream_idx = $first_audio + $idx; $dur = number_format($audio->duration, 2); $start_time = number_format($audio->time, 2); $chain = "[{$stream_idx}:a]atrim=duration={$dur},asetpts=PTS-STARTPTS"; if ($audio->time > 0) { $delay_ms = (int) round($audio->time * 1000); $chain .= ",adelay={$delay_ms}|{$delay_ms}"; } $label = "a{$idx}"; $filters[] = "{$chain}[{$label}]"; $audio_labels[] = $label; } $mix_input = ''; foreach ($audio_labels as $lbl) { $mix_input .= "[{$lbl}]"; } $audio_count = count($audio_labels); $filters[] = "{$mix_input}amix=inputs={$audio_count}:duration=longest[a_out]"; $filter_complex = implode(';', $filters); // Prepare output $filename = Str::random(12).'.mp4'; $output_path = sys_get_temp_dir().DIRECTORY_SEPARATOR.$filename; // FFmpeg command, including render settings $video_bitrate = $settings['video_bitrate']; $audio_bitrate = $settings['audio_bitrate']; $fps = $settings['fps']; $command = 'ffmpeg '.implode(' ', $input_args) ." -r {$fps}" ." -filter_complex \"{$filter_complex}\"" .' -map "[v_out]" -map "[a_out]"' ." -c:v libx264 -b:v {$video_bitrate}" ." -c:a aac -b:a {$audio_bitrate}" ." -shortest \"{$output_path}\" -y"; $process = Process::fromShellCommandline($command, null, null, null, 300); $process->run(); if (! $process->isSuccessful()) { return (object) [ 'success' => false, 'exception' => new \RuntimeException('FFmpeg failed: '.$process->getErrorOutput()), ]; throw new \RuntimeException('FFmpeg failed: '.$process->getErrorOutput()); } return (object) [ 'success' => true, 'name' => $filename, 'path' => $output_path, ]; } /** * Fetch video elements for rendering. */ private static function get_video_elements(Video $video): Collection { return $video->video_elements; } /** * Extract render settings or defaults. */ private static function get_render_settings(Video $video): array { return [ 'video_bitrate' => $video->render_settings?->video_bitrate ?? '3M', 'audio_bitrate' => $video->render_settings?->audio_bitrate ?? '128k', 'fps' => $video->render_settings?->fps ?? '30', ]; } }