Initial commit: Runware-powered content generator foundation
Laravel app with a cost-tracked Runware.ai integration (image, video, text generation), a hard monthly EUR budget cap with live USD/EUR FX conversion, and a locally-maintained model/pricing table since Runware's model-search API doesn't expose pricing.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class BudgetExceededException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly float $spentEur,
|
||||
public readonly float $capEur,
|
||||
public readonly float $estimatedEur,
|
||||
) {
|
||||
parent::__construct(sprintf(
|
||||
'Runware monthly budget would be exceeded: %.2f€ spent + %.2f€ estimated > %.2f€ cap.',
|
||||
$spentEur,
|
||||
$estimatedEur,
|
||||
$capEur,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\BudgetExceededException;
|
||||
use App\Models\Generation;
|
||||
use App\Models\RunwareModel;
|
||||
use App\Services\Runware\BudgetGuard;
|
||||
use App\Services\Runware\GenerationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GenerationController extends Controller
|
||||
{
|
||||
public function index(BudgetGuard $budget): View
|
||||
{
|
||||
return view('generations.index', [
|
||||
'generations' => Generation::query()->latest()->paginate(20),
|
||||
'models' => RunwareModel::query()->where('active', true)->orderBy('modality')->orderBy('price_usd')->get()->groupBy('modality'),
|
||||
'capEur' => $budget->capEur(),
|
||||
'spentEur' => $budget->spentEurThisMonth(),
|
||||
'remainingEur' => $budget->remainingEur(),
|
||||
'warnThresholdReached' => $budget->warnThresholdReached(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, GenerationService $generations): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'type' => ['required', 'in:image,video,text'],
|
||||
'prompt' => ['required', 'string', 'max:4000'],
|
||||
'model' => ['nullable', 'string'],
|
||||
'width' => ['nullable', 'integer', 'min:64', 'max:2048'],
|
||||
'height' => ['nullable', 'integer', 'min:64', 'max:2048'],
|
||||
'duration' => ['nullable', 'integer', 'min:1', 'max:30'],
|
||||
]);
|
||||
|
||||
$params = array_filter([
|
||||
'width' => $data['width'] ?? null,
|
||||
'height' => $data['height'] ?? null,
|
||||
'duration' => $data['duration'] ?? null,
|
||||
], fn ($v) => $v !== null);
|
||||
|
||||
try {
|
||||
match ($data['type']) {
|
||||
'image' => $generations->createImage($data['prompt'], $params, $data['model'] ?? null),
|
||||
'text' => $generations->createText($data['prompt'], $params, $data['model'] ?? null),
|
||||
'video' => $generations->queueVideo($data['prompt'], $params, $data['model'] ?? null),
|
||||
};
|
||||
} catch (BudgetExceededException $e) {
|
||||
return back()->withErrors(['budget' => $e->getMessage()])->withInput();
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['generation' => 'Generation failed: '.$e->getMessage()])->withInput();
|
||||
}
|
||||
|
||||
return redirect()->route('generations.index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\RunwareModel;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RunwareModelController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('runware-models.index', [
|
||||
'models' => RunwareModel::query()->orderBy('modality')->orderBy('price_usd')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'modality' => ['required', 'in:image,video,text'],
|
||||
'air_id' => ['required', 'string', 'max:255'],
|
||||
'label' => ['required', 'string', 'max:255'],
|
||||
'unit' => ['required', 'in:per_image,per_second,per_million_tokens'],
|
||||
'price_usd' => ['required', 'numeric', 'min:0'],
|
||||
]);
|
||||
|
||||
RunwareModel::create($data);
|
||||
RunwareModel::recomputeDefault($data['modality']);
|
||||
|
||||
return redirect()->route('runware-models.index');
|
||||
}
|
||||
|
||||
public function update(Request $request, RunwareModel $runwareModel): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'active' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$runwareModel->update($data);
|
||||
RunwareModel::recomputeDefault($runwareModel->modality);
|
||||
|
||||
return redirect()->route('runware-models.index');
|
||||
}
|
||||
|
||||
public function destroy(RunwareModel $runwareModel): RedirectResponse
|
||||
{
|
||||
$modality = $runwareModel->modality;
|
||||
$runwareModel->delete();
|
||||
RunwareModel::recomputeDefault($modality);
|
||||
|
||||
return redirect()->route('runware-models.index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Generation;
|
||||
use App\Services\Runware\GenerationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
class GenerateVideoJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
// Video submission + polling can legitimately take minutes.
|
||||
public int $timeout = 900;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(public Generation $generation) {}
|
||||
|
||||
public function handle(GenerationService $generations): void
|
||||
{
|
||||
try {
|
||||
$generations->processVideo($this->generation);
|
||||
} catch (Throwable $e) {
|
||||
$this->generation->update(['status' => 'failed', 'error' => $e->getMessage()]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Generation extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'batch_id',
|
||||
'type',
|
||||
'model',
|
||||
'prompt',
|
||||
'params',
|
||||
'status',
|
||||
'runware_task_uuid',
|
||||
'cost_usd',
|
||||
'cost_eur',
|
||||
'cost_is_estimated',
|
||||
'output_path',
|
||||
'output_text',
|
||||
'error',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'params' => 'array',
|
||||
'cost_usd' => 'decimal:6',
|
||||
'cost_eur' => 'decimal:6',
|
||||
'cost_is_estimated' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RunwareModel extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'modality',
|
||||
'air_id',
|
||||
'label',
|
||||
'unit',
|
||||
'price_usd',
|
||||
'is_default',
|
||||
'active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price_usd' => 'decimal:6',
|
||||
'is_default' => 'boolean',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public static function defaultFor(string $modality): ?self
|
||||
{
|
||||
return static::query()
|
||||
->where('modality', $modality)
|
||||
->where('is_default', true)
|
||||
->where('active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheapest active model always wins the default flag, since Runware's
|
||||
* own model-search API doesn't expose pricing — this locally-entered
|
||||
* table is the only place "cheapest" can be computed from.
|
||||
*/
|
||||
public static function recomputeDefault(string $modality): void
|
||||
{
|
||||
static::query()->where('modality', $modality)->update(['is_default' => false]);
|
||||
|
||||
$cheapest = static::query()
|
||||
->where('modality', $modality)
|
||||
->where('active', true)
|
||||
->orderBy('price_usd')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
$cheapest?->update(['is_default' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Runware\RunwareClient;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(RunwareClient::class, fn () => new RunwareClient(
|
||||
apiKey: (string) config('runware.api_key'),
|
||||
baseUrl: (string) config('runware.base_url'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Runware;
|
||||
|
||||
use App\Exceptions\BudgetExceededException;
|
||||
use App\Models\Generation;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Enforces the hard monthly EUR spend cap on Runware usage. Every generation
|
||||
* must go through ensureCanSpend() before the API is called, and the actual
|
||||
* cost must be reconciled afterward via recordActualUsd() so the running
|
||||
* total reflects reality (Runware only bills successful generations).
|
||||
*/
|
||||
class BudgetGuard
|
||||
{
|
||||
protected const LOCK_KEY = 'runware:budget-guard-lock';
|
||||
|
||||
protected const LOCK_TIMEOUT_SECONDS = 10;
|
||||
|
||||
public function __construct(protected CurrencyRateService $rates) {}
|
||||
|
||||
public function capEur(): float
|
||||
{
|
||||
return (float) config('runware.monthly_cap_eur');
|
||||
}
|
||||
|
||||
public function spentEurThisMonth(): float
|
||||
{
|
||||
return (float) Generation::query()
|
||||
->where('created_at', '>=', now()->startOfMonth())
|
||||
->whereNotNull('cost_eur')
|
||||
->sum('cost_eur');
|
||||
}
|
||||
|
||||
public function remainingEur(): float
|
||||
{
|
||||
return max(0.0, $this->capEur() - $this->spentEurThisMonth());
|
||||
}
|
||||
|
||||
public function warnThresholdReached(): bool
|
||||
{
|
||||
$cap = $this->capEur();
|
||||
|
||||
if ($cap <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($this->spentEurThisMonth() / $cap) >= (float) config('runware.warn_threshold');
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws BudgetExceededException if spending $estimatedUsd would push
|
||||
* this month's total (plus a safety margin) past the cap. Guards the
|
||||
* check with an atomic lock so concurrent requests can't both slip
|
||||
* through right at the boundary.
|
||||
*/
|
||||
public function ensureCanSpend(float $estimatedUsd): void
|
||||
{
|
||||
Cache::lock(self::LOCK_KEY, self::LOCK_TIMEOUT_SECONDS)->block(5, function () use ($estimatedUsd) {
|
||||
$rate = $this->rates->usdToEur();
|
||||
$estimatedEur = $estimatedUsd * $rate;
|
||||
$spent = $this->spentEurThisMonth();
|
||||
$cap = $this->capEur();
|
||||
$margin = $cap * (float) config('runware.safety_margin');
|
||||
|
||||
if ($spent + $estimatedEur + $margin > $cap) {
|
||||
throw new BudgetExceededException($spent, $cap, $estimatedEur);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Runware;
|
||||
|
||||
use App\Models\RunwareModel;
|
||||
|
||||
/**
|
||||
* Pre-flight cost estimation used by BudgetGuard before a Runware call is
|
||||
* made. Deliberately conservative — the real cost is always reconciled
|
||||
* afterward from Runware's response `cost` field (or, for text, `usage.cost`).
|
||||
*/
|
||||
class CostEstimator
|
||||
{
|
||||
protected const DEFAULT_VIDEO_DURATION_SECONDS = 5;
|
||||
|
||||
protected const DEFAULT_TEXT_TOKEN_BUDGET = 2048;
|
||||
|
||||
public function estimateUsd(RunwareModel $model, array $params): float
|
||||
{
|
||||
return match ($model->unit) {
|
||||
'per_image' => (float) $model->price_usd * (int) ($params['numberResults'] ?? 1),
|
||||
'per_second' => (float) $model->price_usd * (int) ($params['duration'] ?? self::DEFAULT_VIDEO_DURATION_SECONDS),
|
||||
'per_million_tokens' => (float) $model->price_usd
|
||||
* (((int) ($params['max_completion_tokens'] ?? self::DEFAULT_TEXT_TOKEN_BUDGET)) / 1_000_000),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Runware;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Keeps a live-ish USD->EUR rate cached for BudgetGuard to convert Runware's
|
||||
* USD costs against the EUR monthly cap. Refreshed daily by the scheduler
|
||||
* (see routes/console.php); falls back to a static configured rate if no
|
||||
* live fetch has ever succeeded, so the cap never stops enforcing.
|
||||
*/
|
||||
class CurrencyRateService
|
||||
{
|
||||
protected const CACHE_KEY = 'runware:usd_eur_rate';
|
||||
|
||||
// Refresh runs daily; keep the cached value alive well past that so a
|
||||
// single failed fetch doesn't drop back to the fallback rate.
|
||||
protected const CACHE_TTL_SECONDS = 60 * 60 * 30;
|
||||
|
||||
public function usdToEur(): float
|
||||
{
|
||||
return Cache::get(self::CACHE_KEY) ?? (float) config('runware.usd_eur_fallback_rate');
|
||||
}
|
||||
|
||||
public function refresh(): ?float
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(10)->get('https://api.frankfurter.app/latest', [
|
||||
'from' => 'USD',
|
||||
'to' => 'EUR',
|
||||
]);
|
||||
|
||||
$rate = $response->json('rates.EUR');
|
||||
|
||||
if (! $response->successful() || ! is_numeric($rate)) {
|
||||
throw new \RuntimeException('Unexpected FX response: '.$response->body());
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY, (float) $rate, self::CACHE_TTL_SECONDS);
|
||||
|
||||
return (float) $rate;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to refresh USD/EUR rate, keeping previous cached/fallback value.', [
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Runware;
|
||||
|
||||
use App\Models\Generation;
|
||||
use App\Models\RunwareModel;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Orchestrates a single Runware generation end to end: resolve the model,
|
||||
* pre-flight budget check, call the API, persist the result, reconcile the
|
||||
* real cost. Image and text run synchronously; video is long-running and is
|
||||
* driven by App\Jobs\GenerateVideoJob via queueVideo()/processVideo().
|
||||
*/
|
||||
class GenerationService
|
||||
{
|
||||
public function __construct(
|
||||
protected RunwareClient $client,
|
||||
protected BudgetGuard $budget,
|
||||
protected CostEstimator $estimator,
|
||||
protected CurrencyRateService $rates,
|
||||
) {}
|
||||
|
||||
public function resolveModel(string $modality, ?string $airIdOverride = null): RunwareModel
|
||||
{
|
||||
if ($airIdOverride) {
|
||||
$model = RunwareModel::query()
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Runware;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thin wrapper around Runware's HTTP API. Image and video generation use the
|
||||
* batched task-array endpoint (POST /v1, body is a JSON array of tasks);
|
||||
* text/LLM generation uses Runware's separate OpenAI-compatible endpoint
|
||||
* (POST /v1/chat/completions). See https://runware.ai/docs/platform/introduction
|
||||
* and https://runware.ai/docs/platform/openai.
|
||||
*/
|
||||
class RunwareClient
|
||||
{
|
||||
public function __construct(
|
||||
protected string $apiKey,
|
||||
protected string $baseUrl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tasks
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function runTasks(array $tasks): array
|
||||
{
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(120)
|
||||
->post($this->baseUrl, $tasks);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('Runware API request failed: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
if (! is_array($data)) {
|
||||
throw new RuntimeException('Unexpected Runware response shape: '.$response->body());
|
||||
}
|
||||
|
||||
foreach ($data as $result) {
|
||||
if (isset($result['error']) || ($result['status'] ?? null) === 'error') {
|
||||
throw new RuntimeException('Runware task failed: '.json_encode($result));
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function pollTask(string $taskUUID): array
|
||||
{
|
||||
$results = $this->runTasks([[
|
||||
'taskType' => 'getResponse',
|
||||
'taskUUID' => $taskUUID,
|
||||
]]);
|
||||
|
||||
return $results[0] ?? throw new RuntimeException("No poll result for task {$taskUUID}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function chatCompletion(array $payload): array
|
||||
{
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(120)
|
||||
->post(rtrim($this->baseUrl, '/').'/chat/completions', $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('Runware chat completion failed: '.$response->body());
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user