Files
muninn/tests/Feature/VideoWorkflow/StepHandlers/VideoEditingHandlerTest.php
T
m105andClaude Sonnet 5 c249a14d78 Add storyboard/video-gen/video-editing handlers and asset previews (#37-39, #45, #46)
StoryboardHandler (#37): breaks the project's script into a numbered
scene-by-scene breakdown (description + image prompt per scene) via
OpenAI in JSON mode (added json_mode support to OpenAiDriver for
reliable structured output), stored as a JSON asset.

VideoGenerationHandler (#38): generates one still image per storyboard
scene via StabilityAI — the scene *visuals*, not motion/video clips
(no text-to-video model integrated; see the task comment for why).

VideoEditingHandler (#39): the step that actually produces a video
file — assembles the scene stills into an MP4 via ffmpeg's concat
demuxer, timed to the voiceover audio's duration (probed via ffprobe)
or a fixed per-scene duration if there's no audio, then muxes the
audio track in. Verified against the real ffmpeg/ffprobe binaries on
this box, not just Process::fake().

Asset previews (#45, #46): a reusable partial renders each asset
inline by type — image/audio/video get real playback elements, text/
JSON gets a truncated <pre> block, everything else a download link.
Wired into both the awaiting_approval step view (so approving/
rejecting is an actual content review, not just a word count) and a
general project assets grid.

Also fixes a latent bug in AssetStorage::url(), found while wiring up
previews: it called Storage::url() on the default 'local' disk, which
has no configured URL driver and throws in production — only worked
before because Storage::fake() is more permissive than the real local
driver. Now serves assets through a new authenticated streaming route
instead (VideoProjectAssetController).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 21:15:31 +03:00

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