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>
117 lines
3.7 KiB
PHP
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;
|
|
}
|
|
}
|