Files
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

117 lines
3.7 KiB
PHP

<?php
namespace App\Support\VideoWorkflow\StepHandlers;
use App\Models\AiProvider;
use App\Models\WorkflowStep;
use App\Support\AiProviders\AiGenerationException;
use App\Support\AiProviders\AiProviderManager;
use App\Support\AssetStorage\AssetStorage;
use App\Support\VideoWorkflow\WorkflowEngine;
/**
* Breaks the project's script into a numbered scene-by-scene storyboard —
* each scene gets a description and an image-generation prompt — via an
* OpenAI-backed AiProvider in JSON mode. Produces the text/description
* layer only; VideoGenerationHandler turns each scene's visual_prompt into
* an actual image.
*/
class StoryboardHandler implements StepHandler
{
public function __construct(
private readonly AiProviderManager $aiProviders,
private readonly AssetStorage $assets,
) {}
public function type(): string
{
return 'storyboard';
}
public function handle(WorkflowStep $step, WorkflowEngine $engine): void
{
$provider = AiProvider::query()->where('provider', 'openai')->where('is_active', true)->first();
if (! $provider) {
$engine->failStep($step, 'No active OpenAI provider configured.');
return;
}
$script = $this->resolveScript($step);
if (! $script) {
$engine->failStep($step, 'No script available to storyboard — complete a script_generation step first.');
return;
}
try {
$result = $this->aiProviders->generate($provider, $this->buildPrompt($step, $script), ['json_mode' => true]);
} catch (AiGenerationException $e) {
$engine->failStep($step, $e->getMessage());
return;
}
$decoded = json_decode($result->content, true);
$scenes = $decoded['scenes'] ?? null;
if (! is_array($scenes) || empty($scenes)) {
$engine->failStep($step, 'AI provider returned an unusable storyboard: no scenes found.');
return;
}
$asset = $this->assets->store(
$step->videoProject,
'other',
json_encode(['scenes' => $scenes], JSON_PRETTY_PRINT),
'json',
step: $step,
mimeType: 'application/json',
metadata: ['kind' => 'storyboard', 'scene_count' => count($scenes)],
);
$engine->completeStep($step, [
'asset_id' => $asset->id,
'scene_count' => count($scenes),
]);
}
private function resolveScript(WorkflowStep $step): ?string
{
$input = $step->input ?? [];
if (! empty($input['script'])) {
return $input['script'];
}
$scriptAsset = $step->videoProject->assets()
->where('type', 'script')
->orderByDesc('version')
->first();
return $scriptAsset ? $this->assets->contents($scriptAsset) : null;
}
private function buildPrompt(WorkflowStep $step, string $script): string
{
$input = $step->input ?? [];
$prompt = "Break the following video script into a numbered scene-by-scene storyboard.\n\n"
."Script:\n{$script}\n\n"
.'For each scene provide: scene_number (integer), description (what happens on screen), '
."and visual_prompt (a concise, self-contained image-generation prompt describing this scene's visual).";
if (! empty($input['style'])) {
$prompt .= " Every visual_prompt must consistently reflect this visual style: {$input['style']}.";
}
$prompt .= "\n\nRespond with JSON only, in exactly this shape: "
.'{"scenes": [{"scene_number": 1, "description": "...", "visual_prompt": "..."}]}';
return $prompt;
}
}