164 lines
5.9 KiB
PHP
164 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace App\Helpers\FirstParty\Render;
|
|
|
|
use App\Models\Video;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
/**
|
|
* Class FfmpegVideoRenderer
|
|
*
|
|
* Builds and optionally executes an ffmpeg command using VideoElement models and render settings.
|
|
*/
|
|
class FfmpegVideoRenderer
|
|
{
|
|
/**
|
|
* Render the given Video to a file via ffmpeg.
|
|
*
|
|
* @return object { string $name, string $path }
|
|
*
|
|
* @throws \InvalidArgumentException if required elements missing
|
|
* @throws \RuntimeException on ffmpeg failure
|
|
*/
|
|
public static function render(Video $video): object
|
|
{
|
|
$elements = self::get_video_elements($video);
|
|
$settings = self::get_render_settings($video);
|
|
|
|
// Gather elements by type and sort by time
|
|
$slide_images = $elements->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',
|
|
];
|
|
}
|
|
}
|