190 lines
6.6 KiB
PHP
190 lines
6.6 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
use App\Models\Asset;
|
||
|
|
use App\Models\VideoProject;
|
||
|
|
use App\Support\AssetStorage\AssetStorage;
|
||
|
|
use App\Support\VideoWorkflow\StepHandlers\VideoEditingHandler;
|
||
|
|
use App\Support\VideoWorkflow\WorkflowEngine;
|
||
|
|
use Illuminate\Support\Facades\File;
|
||
|
|
use Illuminate\Support\Facades\Process;
|
||
|
|
use Illuminate\Support\Facades\Storage;
|
||
|
|
|
||
|
|
beforeEach(function () {
|
||
|
|
Storage::fake();
|
||
|
|
$this->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');
|
||
|
|
});
|