Files
muninn/app/Support/VideoWorkflow/StepHandlers/VideoEditingHandler.php
T

186 lines
5.9 KiB
PHP
Raw Normal View History

<?php
namespace App\Support\VideoWorkflow\StepHandlers;
use App\Models\Asset;
use App\Models\WorkflowStep;
use App\Support\AssetStorage\AssetStorage;
use App\Support\VideoWorkflow\WorkflowEngine;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
/**
* Assembles the scene images from a completed video_generation step and
* (optionally) the project's voiceover audio into an actual video file,
* via the ffmpeg/ffprobe CLIs. Each scene image is shown for an equal
* share of the audio's duration (or a fixed duration if there's no
* audio); no transitions/effects — that's future scope, not what this
* pass covers.
*/
class VideoEditingHandler implements StepHandler
{
private const DEFAULT_SECONDS_PER_SCENE = 4.0;
public function __construct(private readonly AssetStorage $assets) {}
public function type(): string
{
return 'video_editing';
}
public function handle(WorkflowStep $step, WorkflowEngine $engine): void
{
$images = $this->resolveSceneImages($step);
if (! $images || $images->isEmpty()) {
$engine->failStep($step, 'No scene images available — complete a video_generation step first.');
return;
}
$audioAsset = $this->resolveAudio($step);
$tempDir = storage_path('app/tmp/video-editing-'.$step->id.'-'.uniqid());
File::ensureDirectoryExists($tempDir);
try {
$imagePaths = $images->map(fn (Asset $a) => Storage::path($a->disk_path))->all();
$audioPath = $audioAsset ? Storage::path($audioAsset->disk_path) : null;
$duration = null;
if ($audioPath) {
$duration = $this->probeDuration($audioPath);
if ($duration === null) {
$engine->failStep($step, 'Could not read the voiceover audio\'s duration.');
return;
}
}
$input = $step->input ?? [];
$perScene = $input['seconds_per_scene'] ?? ($duration ? $duration / count($imagePaths) : self::DEFAULT_SECONDS_PER_SCENE);
$concatListPath = "{$tempDir}/concat.txt";
$this->writeConcatList($concatListPath, $imagePaths, $perScene);
$silentVideoPath = "{$tempDir}/silent.mp4";
$result = Process::timeout(300)->run([
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', $concatListPath,
'-vsync', 'vfr', '-pix_fmt', 'yuv420p', $silentVideoPath,
]);
if ($result->failed()) {
$engine->failStep($step, 'ffmpeg failed to assemble scene images: '.trim($result->errorOutput()));
return;
}
$finalVideoPath = "{$tempDir}/final.mp4";
if ($audioPath) {
$result = Process::timeout(300)->run([
'ffmpeg', '-y', '-i', $silentVideoPath, '-i', $audioPath,
'-c:v', 'copy', '-c:a', 'aac', '-shortest', $finalVideoPath,
]);
if ($result->failed()) {
$engine->failStep($step, 'ffmpeg failed to mux the voiceover audio: '.trim($result->errorOutput()));
return;
}
} else {
File::copy($silentVideoPath, $finalVideoPath);
}
$asset = $this->assets->store(
$step->videoProject,
'video',
File::get($finalVideoPath),
'mp4',
step: $step,
mimeType: 'video/mp4',
metadata: ['scene_count' => count($imagePaths), 'duration_seconds' => $duration ?? ($perScene * count($imagePaths))],
);
$engine->completeStep($step, ['asset_id' => $asset->id]);
} finally {
File::deleteDirectory($tempDir);
}
}
private function probeDuration(string $path): ?float
{
$result = Process::timeout(60)->run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', $path]);
if ($result->failed()) {
return null;
}
$value = trim($result->output());
return is_numeric($value) ? (float) $value : null;
}
private function writeConcatList(string $path, array $imagePaths, float $secondsPerImage): void
{
$lines = [];
foreach ($imagePaths as $imagePath) {
$lines[] = "file '".addslashes($imagePath)."'";
$lines[] = "duration {$secondsPerImage}";
}
// ffmpeg's concat demuxer ignores the last entry's duration, so the
// final image needs to be repeated once more without one.
$lines[] = "file '".addslashes(end($imagePaths))."'";
File::put($path, implode("\n", $lines));
}
/**
* @return Collection<int, Asset>|null
*/
private function resolveSceneImages(WorkflowStep $step): ?Collection
{
$input = $step->input ?? [];
$ids = $input['image_asset_ids'] ?? null;
if (! $ids) {
$videoGenStep = $step->videoProject->steps()
->where('type', 'video_generation')
->where('status', 'completed')
->orderByDesc('completed_at')
->first();
$ids = $videoGenStep?->output['asset_ids'] ?? null;
}
if (! $ids) {
return null;
}
$assetsById = Asset::whereIn('id', $ids)->get()->keyBy('id');
return collect($ids)->map(fn ($id) => $assetsById->get($id))->filter()->values();
}
private function resolveAudio(WorkflowStep $step): ?Asset
{
$input = $step->input ?? [];
if (! empty($input['audio_asset_id'])) {
return Asset::find($input['audio_asset_id']);
}
return $step->videoProject->assets()
->where('type', 'audio')
->orderByDesc('version')
->first();
}
}