engine = new WorkflowEngine; $this->assets = new AssetStorage; $this->handler = new VideoEditingHandler($this->assets); // Simulates ffmpeg actually writing its output file, and ffprobe // reporting a duration, without invoking the real binaries. $this->fakeFfmpegSuccess = function () { Process::fake(function ($process) { $binary = basename($process->command[0] ?? ''); if ($binary === 'ffprobe') { return Process::result(output: '10.0'); } File::put(end($process->command), 'fake-video-bytes'); return Process::result(); }); }; // Starts a project with video_generation -> video_editing, completes // the video_generation step (which auto-activates video_editing via // the engine), and returns that now in_progress video_editing step. $this->setupPipelineReadyForEditing = function (VideoProject $project, int $sceneCount = 2) { $videoGenStep = $this->engine->start($project, [ ['type' => 'video_generation'], ['type' => 'video_editing'], ]); $assetIds = []; for ($i = 1; $i <= $sceneCount; $i++) { $asset = $this->assets->store($project, 'image', "scene {$i} bytes", 'png', mimeType: 'image/png', metadata: ['scene_number' => $i]); $assetIds[] = $asset->id; } $this->engine->completeStep($videoGenStep, ['asset_ids' => $assetIds, 'scene_count' => $sceneCount]); return $project->steps()->where('type', 'video_editing')->first(); }; }); test('it assembles scene images with the voiceover audio into a video asset', function () { ($this->fakeFfmpegSuccess)(); $project = VideoProject::factory()->create(); $step = ($this->setupPipelineReadyForEditing)($project, 2); $this->assets->store($project, 'audio', 'fake-mp3-bytes', 'mp3', mimeType: 'audio/mpeg'); $this->handler->handle($step, $this->engine); $fresh = $step->fresh(); expect($fresh->status)->toBe('completed'); $asset = Asset::find($fresh->output['asset_id']); expect($asset->type)->toBe('video'); expect($asset->mime_type)->toBe('video/mp4'); expect($asset->metadata['scene_count'])->toBe(2); expect($asset->metadata['duration_seconds'])->toEqual(10.0); expect(Storage::get($asset->disk_path))->toBe('fake-video-bytes'); Process::assertRan(fn ($process) => basename($process->command[0]) === 'ffprobe'); Process::assertRan(fn ($process) => in_array('concat', $process->command)); Process::assertRan(fn ($process) => in_array('-shortest', $process->command)); }); test('it assembles scene images with a default duration when there is no audio', function () { ($this->fakeFfmpegSuccess)(); $project = VideoProject::factory()->create(); $step = ($this->setupPipelineReadyForEditing)($project, 3); $this->handler->handle($step, $this->engine); $fresh = $step->fresh(); expect($fresh->status)->toBe('completed'); $asset = Asset::find($fresh->output['asset_id']); expect($asset->metadata['duration_seconds'])->toEqual(12.0); // 3 scenes * 4s default Process::assertNotRan(fn ($process) => basename($process->command[0]) === 'ffprobe'); Process::assertNotRan(fn ($process) => in_array('-shortest', $process->command)); }); test('a seconds_per_scene input overrides the default and the audio-derived duration', function () { ($this->fakeFfmpegSuccess)(); $project = VideoProject::factory()->create(); $step = ($this->setupPipelineReadyForEditing)($project, 2); $step->update(['input' => ['seconds_per_scene' => 7]]); $this->handler->handle($step->fresh(), $this->engine); $asset = Asset::find($step->fresh()->output['asset_id']); expect($asset->metadata['duration_seconds'])->toEqual(14.0); // 2 scenes * 7s }); test('it fails the step when there are no scene images', function () { $project = VideoProject::factory()->create(); $step = $this->engine->start($project, [['type' => 'video_editing']]); $this->handler->handle($step, $this->engine); expect($step->fresh()) ->status->toBe('failed') ->error->toContain('No scene images available'); }); test('it fails the step when ffmpeg fails to assemble the images', function () { Process::fake(function ($process) { $binary = basename($process->command[0] ?? ''); if ($binary === 'ffprobe') { return Process::result(output: '10.0'); } return Process::result(errorOutput: 'concat blew up', exitCode: 1); }); $project = VideoProject::factory()->create(); $step = ($this->setupPipelineReadyForEditing)($project, 2); $this->handler->handle($step, $this->engine); expect($step->fresh()) ->status->toBe('failed') ->error->toContain('concat blew up'); }); test('it fails the step when ffmpeg fails to mux the audio', function () { Process::fake(function ($process) { $binary = basename($process->command[0] ?? ''); if ($binary === 'ffprobe') { return Process::result(output: '10.0'); } if (in_array('concat', $process->command)) { File::put(end($process->command), 'silent-video-bytes'); return Process::result(); } return Process::result(errorOutput: 'mux blew up', exitCode: 1); }); $project = VideoProject::factory()->create(); $step = ($this->setupPipelineReadyForEditing)($project, 2); $this->assets->store($project, 'audio', 'fake-mp3-bytes', 'mp3'); $this->handler->handle($step, $this->engine); expect($step->fresh()) ->status->toBe('failed') ->error->toContain('mux blew up'); }); test('it fails the step when the audio duration cannot be read', function () { Process::fake(function ($process) { $binary = basename($process->command[0] ?? ''); return $binary === 'ffprobe' ? Process::result(errorOutput: 'no such file', exitCode: 1) : Process::result(); }); $project = VideoProject::factory()->create(); $step = ($this->setupPipelineReadyForEditing)($project, 2); $this->assets->store($project, 'audio', 'fake-mp3-bytes', 'mp3'); $this->handler->handle($step, $this->engine); expect($step->fresh()) ->status->toBe('failed') ->error->toContain('duration'); });