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
@@ -87,37 +87,36 @@
{{ $step->output['word_count'] }} words generated
@elseif (isset($step->output['characters']))
{{ $step->output['characters'] }} characters synthesized
@elseif (isset($step->output['scene_count']))
{{ $step->output['scene_count'] }} scenes
@endif
</p>
@endif
@if ($step->status === 'awaiting_approval' && $step->assets->isNotEmpty())
<div class="mt-3 grid grid-cols-2 sm:grid-cols-3 gap-3">
@foreach ($step->assets->sortBy('version') as $asset)
<div wire:key="approval-asset-{{ $asset->id }}">
@include('livewire.video-projects.partials.asset-preview', ['asset' => $asset])
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
@if ($assets->isNotEmpty())
<div class="bg-white rounded border border-gray-200">
<h2 class="text-lg font-semibold p-4 border-b border-gray-100">Assets</h2>
<table class="w-full">
<thead>
<tr class="text-left border-b border-gray-100 text-sm text-gray-500">
<th class="p-3">Type</th>
<th class="p-3">Version</th>
<th class="p-3">Size</th>
<th class="p-3">Created</th>
</tr>
</thead>
<tbody>
@foreach ($assets as $asset)
<tr class="border-b border-gray-50">
<td class="p-3">{{ ucfirst($asset->type) }}</td>
<td class="p-3">v{{ $asset->version }}</td>
<td class="p-3">{{ $asset->size_bytes ? number_format($asset->size_bytes / 1024, 1).' KB' : '—' }}</td>
<td class="p-3">{{ $asset->created_at->format('Y-m-d H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="bg-white rounded border border-gray-200 p-4">
<h2 class="text-lg font-semibold mb-4">Assets</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
@foreach ($assets as $asset)
<div wire:key="asset-{{ $asset->id }}">
@include('livewire.video-projects.partials.asset-preview', ['asset' => $asset])
</div>
@endforeach
</div>
</div>
@endif
</div>