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>
This commit is contained in:
m105
2026-08-06 21:15:31 +03:00
co-authored by Claude Sonnet 5
parent 55fa2ebfda
commit c249a14d78
18 changed files with 1026 additions and 34 deletions
@@ -0,0 +1,100 @@
<?php
use App\Models\AiProvider;
use App\Models\VideoProject;
use App\Support\AiProviders\AiProviderManager;
use App\Support\AssetStorage\AssetStorage;
use App\Support\VideoWorkflow\StepHandlers\StoryboardHandler;
use App\Support\VideoWorkflow\WorkflowEngine;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
$this->engine = new WorkflowEngine;
$this->assets = new AssetStorage;
$this->handler = new StoryboardHandler(app(AiProviderManager::class), $this->assets);
});
test('it breaks the script into scenes, stores them, and completes the step', function () {
$scenesJson = json_encode(['scenes' => [
['scene_number' => 1, 'description' => 'Intro shot', 'visual_prompt' => 'a bright kitchen'],
['scene_number' => 2, 'description' => 'Demo', 'visual_prompt' => 'a chef cooking'],
]]);
Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => $scenesJson]]]])]);
AiProvider::factory()->create(['provider' => 'openai']);
$project = VideoProject::factory()->create();
$this->assets->store($project, 'script', 'A script about cooking.', 'txt');
$step = $this->engine->start($project, [['type' => 'storyboard']]);
$this->handler->handle($step, $this->engine);
$fresh = $step->fresh();
expect($fresh->status)->toBe('completed');
expect($fresh->output['scene_count'])->toBe(2);
$asset = \App\Models\Asset::find($fresh->output['asset_id']);
expect($asset->type)->toBe('other');
expect($asset->metadata['kind'])->toBe('storyboard');
$stored = json_decode(Storage::get($asset->disk_path), true);
expect($stored['scenes'])->toHaveCount(2);
Http::assertSent(function ($request) {
return str_contains($request['messages'][0]['content'], 'A script about cooking.')
&& ($request['response_format']['type'] ?? null) === 'json_object';
});
});
test('a style hint is included in the prompt when given', function () {
Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => json_encode(['scenes' => [['scene_number' => 1, 'description' => 'x', 'visual_prompt' => 'y']]])]]]])]);
AiProvider::factory()->create(['provider' => 'openai']);
$project = VideoProject::factory()->create();
$step = $this->engine->start($project, [['type' => 'storyboard']]);
$step->update(['input' => ['script' => 'a script', 'style' => 'noir black and white']]);
$this->handler->handle($step->fresh(), $this->engine);
Http::assertSent(fn ($request) => str_contains($request['messages'][0]['content'], 'noir black and white'));
});
test('it fails when there is no script to storyboard', function () {
AiProvider::factory()->create(['provider' => 'openai']);
$project = VideoProject::factory()->create();
$step = $this->engine->start($project, [['type' => 'storyboard']]);
$this->handler->handle($step, $this->engine);
expect($step->fresh())
->status->toBe('failed')
->error->toContain('No script available');
});
test('it fails when no active OpenAI provider is configured', function () {
$project = VideoProject::factory()->create();
$this->assets->store($project, 'script', 'a script', 'txt');
$step = $this->engine->start($project, [['type' => 'storyboard']]);
$this->handler->handle($step, $this->engine);
expect($step->fresh())
->status->toBe('failed')
->error->toContain('No active OpenAI provider');
});
test('it fails when the AI response has no usable scenes', function () {
Http::fake(['api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => '{"not_scenes": []}']]]])]);
AiProvider::factory()->create(['provider' => 'openai']);
$project = VideoProject::factory()->create();
$this->assets->store($project, 'script', 'a script', 'txt');
$step = $this->engine->start($project, [['type' => 'storyboard']]);
$this->handler->handle($step, $this->engine);
expect($step->fresh())
->status->toBe('failed')
->error->toContain('unusable storyboard');
});
@@ -0,0 +1,189 @@
<?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');
});
@@ -0,0 +1,111 @@
<?php
use App\Models\AiProvider;
use App\Models\Asset;
use App\Models\VideoProject;
use App\Support\AiProviders\AiProviderManager;
use App\Support\AssetStorage\AssetStorage;
use App\Support\VideoWorkflow\StepHandlers\VideoGenerationHandler;
use App\Support\VideoWorkflow\WorkflowEngine;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake();
$this->engine = new WorkflowEngine;
$this->assets = new AssetStorage;
$this->handler = new VideoGenerationHandler(app(AiProviderManager::class), $this->assets);
$this->storeStoryboard = function (VideoProject $project, array $scenes) {
return $this->assets->store(
$project,
'other',
json_encode(['scenes' => $scenes]),
'json',
mimeType: 'application/json',
metadata: ['kind' => 'storyboard'],
);
};
});
test('it generates one image per scene and completes the step', function () {
Http::fake(['api.stability.ai/*' => Http::response(['image' => base64_encode('fake-bytes')])]);
AiProvider::factory()->create(['provider' => 'stability_ai']);
$project = VideoProject::factory()->create();
($this->storeStoryboard)($project, [
['scene_number' => 1, 'description' => 'a', 'visual_prompt' => 'a red bicycle'],
['scene_number' => 2, 'description' => 'b', 'visual_prompt' => 'a blue car'],
]);
$step = $this->engine->start($project, [['type' => 'video_generation']]);
$this->handler->handle($step, $this->engine);
$fresh = $step->fresh();
expect($fresh->status)->toBe('completed');
expect($fresh->output['scene_count'])->toBe(2);
expect($fresh->output['asset_ids'])->toHaveCount(2);
$assets = Asset::whereIn('id', $fresh->output['asset_ids'])->get();
expect($assets)->toHaveCount(2);
$assets->each(fn ($a) => expect($a->type)->toBe('image'));
Http::assertSentCount(2);
});
test('a style hint is appended to every scene prompt', function () {
Http::fake(['api.stability.ai/*' => Http::response(['image' => base64_encode('bytes')])]);
AiProvider::factory()->create(['provider' => 'stability_ai']);
$project = VideoProject::factory()->create();
($this->storeStoryboard)($project, [
['scene_number' => 1, 'description' => 'a', 'visual_prompt' => 'a red bicycle'],
]);
$step = $this->engine->start($project, [['type' => 'video_generation']]);
$step->update(['input' => ['style' => 'watercolor']]);
$this->handler->handle($step->fresh(), $this->engine);
// Multipart fields aren't queryable via $request['field'] — check the raw body instead.
Http::assertSent(fn ($request) => str_contains($request->body(), 'watercolor'));
});
test('it fails the step when there is no storyboard', function () {
AiProvider::factory()->create(['provider' => 'stability_ai']);
$project = VideoProject::factory()->create();
$step = $this->engine->start($project, [['type' => 'video_generation']]);
$this->handler->handle($step, $this->engine);
expect($step->fresh())
->status->toBe('failed')
->error->toContain('No storyboard available');
});
test('it fails the step when no active StabilityAI provider is configured', function () {
$project = VideoProject::factory()->create();
($this->storeStoryboard)($project, [['scene_number' => 1, 'description' => 'a', 'visual_prompt' => 'x']]);
$step = $this->engine->start($project, [['type' => 'video_generation']]);
$this->handler->handle($step, $this->engine);
expect($step->fresh())
->status->toBe('failed')
->error->toContain('No active StabilityAI provider');
});
test('it fails the step and reports which scene failed when generation errors', function () {
Http::fake(['api.stability.ai/*' => Http::response(['errors' => ['bad prompt']], 400)]);
AiProvider::factory()->create(['provider' => 'stability_ai']);
$project = VideoProject::factory()->create();
($this->storeStoryboard)($project, [['scene_number' => 3, 'description' => 'a', 'visual_prompt' => 'x']]);
$step = $this->engine->start($project, [['type' => 'video_generation']]);
$this->handler->handle($step, $this->engine);
expect($step->fresh())
->status->toBe('failed')
->error->toContain('scene 3');
});