where('modality', $modality) ->where('air_id', $airIdOverride) ->where('active', true) ->first(); if ($model) { return $model; } } return RunwareModel::defaultFor($modality) ?? throw new RuntimeException("No default {$modality} model configured. Add one under Runware Models."); } public function createImage(string $prompt, array $params = [], ?string $modelOverride = null): Generation { $model = $this->resolveModel('image', $modelOverride); $params = array_merge(['width' => 1024, 'height' => 1024, 'numberResults' => 1], $params); $generation = Generation::create([ 'type' => 'image', 'model' => $model->air_id, 'prompt' => $prompt, 'params' => $params, 'status' => 'pending', ]); $this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $params)); try { $taskUUID = (string) Str::uuid(); $results = $this->client->runTasks([array_merge([ 'taskType' => 'imageInference', 'taskUUID' => $taskUUID, 'model' => $model->air_id, 'positivePrompt' => $prompt, 'includeCost' => true, ], $params)]); $result = $results[0]; $path = $this->downloadTo("generations/images/{$taskUUID}", $result['imageURL']); $generation->update([ 'status' => 'completed', 'runware_task_uuid' => $taskUUID, 'output_path' => $path, ...$this->costFields($result['cost'] ?? null), ]); } catch (Throwable $e) { $generation->update(['status' => 'failed', 'error' => $e->getMessage()]); throw $e; } return $generation->fresh(); } public function createText(string $prompt, array $params = [], ?string $modelOverride = null): Generation { $model = $this->resolveModel('text', $modelOverride); $params = array_merge(['max_completion_tokens' => 1024], $params); $generation = Generation::create([ 'type' => 'text', 'model' => $model->air_id, 'prompt' => $prompt, 'params' => $params, 'status' => 'pending', ]); $this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $params)); try { $response = $this->client->chatCompletion([ 'model' => $model->air_id, 'messages' => [['role' => 'user', 'content' => $prompt]], ...$params, ]); $content = $response['choices'][0]['message']['content'] ?? throw new RuntimeException( 'Unexpected Runware chat response: '.json_encode($response) ); $costUsd = $response['usage']['cost'] ?? null; $estimated = false; if ($costUsd === null) { // Runware doesn't always return usage.cost for non-streaming // chat completions; fall back to a token-based estimate and // flag it so it's visible it isn't Runware's own figure. $tokens = $response['usage']['total_tokens'] ?? $params['max_completion_tokens']; $costUsd = (float) $model->price_usd * ($tokens / 1_000_000); $estimated = true; } $generation->update([ 'status' => 'completed', 'output_text' => $content, ...$this->costFields($costUsd, $estimated), ]); } catch (Throwable $e) { $generation->update(['status' => 'failed', 'error' => $e->getMessage()]); throw $e; } return $generation->fresh(); } public function queueVideo(string $prompt, array $params = [], ?string $modelOverride = null): Generation { $model = $this->resolveModel('video', $modelOverride); $params = array_merge(['width' => 1280, 'height' => 720, 'duration' => 5], $params); $this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $params)); return Generation::create([ 'type' => 'video', 'model' => $model->air_id, 'prompt' => $prompt, 'params' => $params, 'status' => 'pending', ]); } /** * Runs the actual video submit+poll+download flow. Called from * App\Jobs\GenerateVideoJob, not directly from the controller, since * video generation can take minutes. */ public function processVideo(Generation $generation): void { $model = RunwareModel::query() ->where('modality', 'video') ->where('air_id', $generation->model) ->firstOrFail(); // Re-check right before spending: time has passed since this job was // queued and other generations may have consumed the budget since. $this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $generation->params ?? [])); $generation->update(['status' => 'processing']); $taskUUID = (string) Str::uuid(); $this->client->runTasks([array_merge([ 'taskType' => 'videoInference', 'taskUUID' => $taskUUID, 'model' => $model->air_id, 'positivePrompt' => $generation->prompt, 'deliveryMethod' => 'async', 'includeCost' => true, ], $generation->params ?? [])]); $result = $this->pollUntilDone($taskUUID); $path = $this->downloadTo("generations/videos/{$taskUUID}", $result['videoURL']); $generation->update([ 'status' => 'completed', 'runware_task_uuid' => $taskUUID, 'output_path' => $path, ...$this->costFields($result['cost'] ?? null), ]); } protected function pollUntilDone(string $taskUUID): array { $delaySeconds = 2; $elapsed = 0; $maxSeconds = 600; while ($elapsed < $maxSeconds) { $result = $this->client->pollTask($taskUUID); $status = $result['status'] ?? null; if ($status === 'success') { return $result; } if ($status === 'error') { throw new RuntimeException('Runware video task failed: '.json_encode($result)); } sleep($delaySeconds); $elapsed += $delaySeconds; $delaySeconds = min(10, $delaySeconds + 2); } throw new RuntimeException("Runware video task {$taskUUID} timed out after {$maxSeconds}s"); } protected function downloadTo(string $relativePath, string $url): string { $response = Http::timeout(120)->get($url); if ($response->failed()) { throw new RuntimeException("Failed to download Runware output from {$url}"); } $extension = pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'bin'; $path = "{$relativePath}.{$extension}"; Storage::disk('public')->put($path, $response->body()); return $path; } protected function costFields(?float $costUsd, bool $estimated = false): array { if ($costUsd === null) { return ['cost_usd' => null, 'cost_eur' => null, 'cost_is_estimated' => false]; } return [ 'cost_usd' => $costUsd, 'cost_eur' => $costUsd * $this->rates->usdToEur(), 'cost_is_estimated' => $estimated, ]; } }