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|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(); } }