first commit
This commit is contained in:
19
app/Helpers/FirstParty/Jobs/JobTrigger.php
Normal file
19
app/Helpers/FirstParty/Jobs/JobTrigger.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\RunVideoRenderPipelineJob;
|
||||
use App\Models\Video;
|
||||
|
||||
class JobTrigger
|
||||
{
|
||||
public static function RunVideoRenderPipelineJob()
|
||||
{
|
||||
$video = Video::latest()->first();
|
||||
|
||||
if ($video) {
|
||||
$job = new RunVideoRenderPipelineJob($video->id);
|
||||
$job->handle();
|
||||
} else {
|
||||
echo 'NO VIDEO';
|
||||
}
|
||||
}
|
||||
}
|
||||
349
app/Helpers/FirstParty/MediaEngine/MediaEngine.php
Normal file
349
app/Helpers/FirstParty/MediaEngine/MediaEngine.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers\FirstParty\MediaEngine;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\MediaCollection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MediaEngine
|
||||
{
|
||||
const LOCAL_TEMP_DISK = 'local_temp';
|
||||
|
||||
const USER_UPLOADED = 'user_uploaded';
|
||||
|
||||
const USER_RENDERED = 'user_rendered';
|
||||
|
||||
const USER = 'user';
|
||||
|
||||
public static function getMediaCloudUrl($media)
|
||||
{
|
||||
return Storage::disk($media->disk)->url($media->file_path.$media->file_name);
|
||||
}
|
||||
|
||||
public static function loadMediaToLocalTemp($uuid)
|
||||
{
|
||||
$media = self::getMediaByUuid($uuid);
|
||||
|
||||
$result = [
|
||||
'media' => $media,
|
||||
'uuid' => $uuid,
|
||||
'temp_path' => null,
|
||||
'temp' => null,
|
||||
];
|
||||
|
||||
if (! $media) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (! Storage::disk($media->disk)->exists($media->file_path.$media->file_name)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$tempPath = 'temp_'.$media->file_name;
|
||||
$fileContent = Storage::disk($media->disk)->get($media->file_path.$media->file_name);
|
||||
|
||||
if (! Storage::disk(self::LOCAL_TEMP_DISK)->exists($tempPath)) {
|
||||
if (Storage::disk(self::LOCAL_TEMP_DISK)->put($tempPath, $fileContent)) {
|
||||
$result['temp_path'] = $tempPath;
|
||||
$result['temp'] = Storage::disk(self::LOCAL_TEMP_DISK)->path($tempPath);
|
||||
}
|
||||
} else {
|
||||
$result['temp_path'] = $tempPath;
|
||||
$result['temp'] = Storage::disk(self::LOCAL_TEMP_DISK)->path($tempPath);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function loadMediasToLocalTemp(array $uuids)
|
||||
{
|
||||
$medias = self::getMediaByUuids($uuids);
|
||||
$result = [];
|
||||
|
||||
foreach ($medias as $media) {
|
||||
if (! $media) {
|
||||
continue; // Skip invalid media instead of returning early
|
||||
}
|
||||
|
||||
$tempPath = 'temp_'.$media->file_name;
|
||||
$singleResult = [
|
||||
'media' => $media,
|
||||
'uuid' => $media->uuid,
|
||||
'temp_path' => null,
|
||||
'temp' => null,
|
||||
'cloud_url' => Storage::disk($media->disk)->url($media->file_path.$media->file_name),
|
||||
];
|
||||
|
||||
// Check if file exists in cloud storage
|
||||
if (! Storage::disk($media->disk)->exists($media->file_path.$media->file_name)) {
|
||||
$result[] = $singleResult; // Add to results but with null temp paths
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already loaded in local temp
|
||||
if (Storage::disk(self::LOCAL_TEMP_DISK)->exists($tempPath)) {
|
||||
// File already exists in temp, just set the paths
|
||||
$singleResult['temp_path'] = $tempPath;
|
||||
$singleResult['temp'] = Storage::disk(self::LOCAL_TEMP_DISK)->path($tempPath);
|
||||
} else {
|
||||
// File needs to be loaded to temp
|
||||
$fileContent = Storage::disk($media->disk)->get($media->file_path.$media->file_name);
|
||||
if (Storage::disk(self::LOCAL_TEMP_DISK)->put($tempPath, $fileContent)) {
|
||||
$singleResult['temp_path'] = $tempPath;
|
||||
$singleResult['temp'] = Storage::disk(self::LOCAL_TEMP_DISK)->path($tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = $singleResult;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function deleteMediaFromLocalTemp($uuid)
|
||||
{
|
||||
$result = self::loadMediaToLocalTemp($uuid);
|
||||
|
||||
if ($result['temp_path']) {
|
||||
Storage::disk(self::LOCAL_TEMP_DISK)->delete($result['temp_path']);
|
||||
}
|
||||
|
||||
return [
|
||||
'media' => $result['media'],
|
||||
'uuid' => $uuid,
|
||||
'temp_path' => null,
|
||||
'temp' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public static function deleteMediasFromLocalTemp(array $uuids)
|
||||
{
|
||||
$results = self::loadMediasToLocalTemp($uuids);
|
||||
$deletedResults = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
if ($result['temp_path']) {
|
||||
Storage::disk(self::LOCAL_TEMP_DISK)->delete($result['temp_path']);
|
||||
}
|
||||
|
||||
$deletedResults[] = [
|
||||
'media' => $result['media'],
|
||||
'uuid' => $result['uuid'],
|
||||
'temp_path' => null,
|
||||
'temp' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return $deletedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new media file to the specified media collection.
|
||||
*
|
||||
* Example: $newMedia = MediaEngine::addMedia(
|
||||
* 'bgm_collection',
|
||||
* 'bgm',
|
||||
* 'user_upload',
|
||||
* 'web_app',
|
||||
* 'background_music.mp3',
|
||||
* $fileContent,
|
||||
* 'r2',
|
||||
* 'name of file',
|
||||
* $user_id,
|
||||
* );
|
||||
*/
|
||||
public static function addMedia(
|
||||
string $mediaCollectionKey,
|
||||
string $mediaType,
|
||||
string $mediaSource,
|
||||
string $mediaProvider,
|
||||
string $fileName,
|
||||
string $fileContent,
|
||||
string $disk = 'r2',
|
||||
?string $name = null,
|
||||
?int $userId = null,
|
||||
|
||||
) {
|
||||
$mediaCollection = MediaCollection::where('key', $mediaCollectionKey)->first();
|
||||
|
||||
if (! $mediaCollection) {
|
||||
throw new \InvalidArgumentException("Media collection with key '{$mediaCollectionKey}' not found.");
|
||||
}
|
||||
|
||||
$config = config("platform.media.{$mediaCollectionKey}");
|
||||
|
||||
// Adjust fileName with prefix, postfix, and ensure extension
|
||||
$adjustedFileName = $config['prefix'].epoch_now_timestamp().'-'.$fileName;
|
||||
|
||||
// Construct file path
|
||||
$filePath = $config['location'];
|
||||
|
||||
// Store the file
|
||||
$stored = Storage::disk($disk)->put($filePath.$adjustedFileName, $fileContent);
|
||||
|
||||
if (! $stored) {
|
||||
throw new \RuntimeException("Failed to store file: {$filePath}");
|
||||
}
|
||||
|
||||
// Get filetype
|
||||
$mimeType = null;
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'mime_');
|
||||
try {
|
||||
file_put_contents($tempFile, $fileContent);
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $tempFile);
|
||||
finfo_close($finfo);
|
||||
} finally {
|
||||
// This ensures the file is deleted even if an exception occurs
|
||||
if (file_exists($tempFile)) {
|
||||
unlink($tempFile);
|
||||
}
|
||||
}
|
||||
if (is_empty($mimeType)) {
|
||||
$mimeType = $config['mime'];
|
||||
}
|
||||
|
||||
$media = new Media([
|
||||
'uuid' => Str::uuid(),
|
||||
'media_collection_id' => $mediaCollection->id,
|
||||
'user_id' => $userId,
|
||||
'media_type' => $mediaType,
|
||||
'media_source' => $mediaSource,
|
||||
'media_provider' => $mediaProvider,
|
||||
'mime_type' => $mimeType,
|
||||
'file_name' => $adjustedFileName,
|
||||
'file_path' => $filePath,
|
||||
'disk' => $disk,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
$media->save();
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
private static function ensureUniqueFileName($fileName, $disk, $location)
|
||||
{
|
||||
$uniqueFileName = $fileName;
|
||||
$counter = 1;
|
||||
|
||||
while (Storage::disk($disk)->exists($location.$uniqueFileName)) {
|
||||
$info = pathinfo($fileName);
|
||||
$uniqueFileName = $info['filename'].'-'.$counter.'.'.$info['extension'];
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $uniqueFileName;
|
||||
}
|
||||
|
||||
public static function getMediaByUuid($uuid)
|
||||
{
|
||||
return Media::where('uuid', $uuid)->first();
|
||||
}
|
||||
|
||||
public static function getMediaByUuids(array $uuids)
|
||||
{
|
||||
return Media::whereIn('uuid', $uuids)->get();
|
||||
}
|
||||
|
||||
public static function getMediasByCollectionKey($key)
|
||||
{
|
||||
$collection = MediaCollection::where('key', $key)->first();
|
||||
|
||||
if (! $collection) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $collection->media;
|
||||
}
|
||||
|
||||
public static function getCollectionKeyByOwnerMediaType($owner_type, $media_type)
|
||||
{
|
||||
$mediaConfig = config('platform.media');
|
||||
|
||||
foreach ($mediaConfig as $key => $item) {
|
||||
if ($item['owner_type'] == $owner_type && $item['media_type'] == $media_type) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file details from a URL including filename, extension and MIME type
|
||||
*
|
||||
* @param string $url The URL of the file
|
||||
* @return object Object containing filename, extension and MIME type
|
||||
*/
|
||||
public static function getFileDetailsbyUrl($url)
|
||||
{
|
||||
// Create an empty result object
|
||||
$result = new \stdClass;
|
||||
|
||||
// Parse the URL to extract the filename
|
||||
$pathInfo = pathinfo(parse_url($url, PHP_URL_PATH));
|
||||
|
||||
// Set the filename and extension
|
||||
$result->filename = $pathInfo['filename'] ?? '';
|
||||
$result->extension = $pathInfo['extension'] ?? '';
|
||||
|
||||
// Initialize the MIME type as unknown
|
||||
$result->mimetype = 'application/octet-stream';
|
||||
|
||||
// Try to get the real MIME type using fileinfo
|
||||
try {
|
||||
// Create a temporary file
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'file_');
|
||||
|
||||
// Get the file content from URL
|
||||
$fileContent = @file_get_contents($url);
|
||||
|
||||
if ($fileContent !== false) {
|
||||
// Write the content to the temporary file
|
||||
file_put_contents($tempFile, $fileContent);
|
||||
|
||||
// Create a finfo object
|
||||
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||
|
||||
// Get the MIME type
|
||||
$result->mimetype = $finfo->file($tempFile);
|
||||
|
||||
// Clean up the temporary file
|
||||
@unlink($tempFile);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If there's an error, try to determine MIME type from extension
|
||||
$commonMimeTypes = [
|
||||
// Images
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'svg' => 'image/svg+xml',
|
||||
|
||||
// Audio
|
||||
'mp3' => 'audio/mpeg',
|
||||
'wav' => 'audio/wav',
|
||||
'ogg' => 'audio/ogg',
|
||||
'm4a' => 'audio/mp4',
|
||||
'flac' => 'audio/flac',
|
||||
|
||||
// Video
|
||||
'mp4' => 'video/mp4',
|
||||
'webm' => 'video/webm',
|
||||
'avi' => 'video/x-msvideo',
|
||||
'mov' => 'video/quicktime',
|
||||
'mkv' => 'video/x-matroska',
|
||||
];
|
||||
|
||||
if (! empty($result->extension) && isset($commonMimeTypes[strtolower($result->extension)])) {
|
||||
$result->mimetype = $commonMimeTypes[strtolower($result->extension)];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
163
app/Helpers/FirstParty/Render/FfmpegVideoRenderer.php
Normal file
163
app/Helpers/FirstParty/Render/FfmpegVideoRenderer.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Helpers/FirstParty/Render/RenderConstants.php
Normal file
27
app/Helpers/FirstParty/Render/RenderConstants.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers\FirstParty\Render;
|
||||
|
||||
class RenderConstants
|
||||
{
|
||||
const STATUS_PLANNED = 'STATUS_PLANNED'; // the render is queued for rendering
|
||||
|
||||
const STATUS_WAITING = 'STATUS_WAITING'; // the render is waiting for a third-party service (e.g., OpenAI or ElevenLabs) to finish
|
||||
|
||||
const STATUS_TRANSCRIBING = 'STATUS_TRANSCRIBING'; // an input file is being transcribed
|
||||
|
||||
const STATUS_RENDERING = 'STATUS_RENDERING'; // the render is being processed
|
||||
|
||||
const STATUS_SUCCEEDED = 'STATUS_SUCCEEDED'; // the render has been completed successfully
|
||||
|
||||
const STATUS_FAILED = 'STATUS_FAILED'; // the render failed due to the reason specified in the error_message field
|
||||
|
||||
const STATUSES = [
|
||||
self::STATUS_PLANNED,
|
||||
self::STATUS_WAITING,
|
||||
self::STATUS_TRANSCRIBING,
|
||||
self::STATUS_RENDERING,
|
||||
self::STATUS_SUCCEEDED,
|
||||
self::STATUS_FAILED,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user