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:
2026-09-23 15:59:05 +03:00
commit 79320950e7
78 changed files with 14452 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
@extends('layouts.app')
@section('title', 'Generate — dreamstudio')
@section('content')
<div class="mb-8 rounded-md border {{ $warnThresholdReached ? 'border-amber-300 bg-amber-50' : 'border-gray-200 bg-white' }} p-4">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Monthly Runware budget</span>
<span>{{ number_format($spentEur, 2) }}€ / {{ number_format($capEur, 2) }}€ spent · {{ number_format($remainingEur, 2) }}€ remaining</span>
</div>
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-100">
@php $pct = $capEur > 0 ? min(100, ($spentEur / $capEur) * 100) : 0; @endphp
<div class="h-full {{ $warnThresholdReached ? 'bg-amber-500' : 'bg-gray-800' }}" style="width: {{ $pct }}%"></div>
</div>
@if ($warnThresholdReached)
<p class="mt-2 text-sm text-amber-800">Approaching the monthly cap — new generations will be blocked once it's reached.</p>
@endif
</div>
<div class="mb-8 rounded-md border border-gray-200 bg-white p-6">
<h2 class="mb-4 text-base font-semibold">New generation</h2>
<form method="POST" action="{{ route('generations.store') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium">Type</label>
<select name="type" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm" onchange="document.querySelectorAll('[data-type-fields]').forEach(el => el.classList.toggle('hidden', el.dataset.typeFields !== this.value))">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="text">Text</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">Model (optional — leave blank to use the default cheapest model)</label>
<select name="model" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
<option value="">Use default</option>
@foreach ($models->flatten() as $model)
<option value="{{ $model->air_id }}">[{{ $model->modality }}] {{ $model->label }} ({{ $model->air_id }}) — ${{ $model->price_usd }}/{{ $model->unit }}{{ $model->is_default ? ' · default' : '' }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium">Prompt</label>
<textarea name="prompt" rows="3" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">{{ old('prompt') }}</textarea>
</div>
<div class="grid grid-cols-3 gap-4">
<div data-type-fields="image">
<label class="block text-sm font-medium">Width</label>
<input type="number" name="width" value="1024" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
<div data-type-fields="image">
<label class="block text-sm font-medium">Height</label>
<input type="number" name="height" value="1024" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
<div data-type-fields="video" class="hidden">
<label class="block text-sm font-medium">Duration (seconds)</label>
<input type="number" name="duration" value="5" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
</div>
<button type="submit" class="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">
Generate
</button>
</form>
</div>
<div class="rounded-md border border-gray-200 bg-white p-6">
<h2 class="mb-4 text-base font-semibold">History</h2>
<div class="divide-y divide-gray-100">
@forelse ($generations as $generation)
<div class="flex items-start justify-between gap-4 py-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm">
<span class="rounded bg-gray-100 px-2 py-0.5 font-medium uppercase">{{ $generation->type }}</span>
<span class="rounded px-2 py-0.5 {{ match($generation->status) {
'completed' => 'bg-green-100 text-green-800',
'failed' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-700',
} }}">{{ $generation->status }}</span>
<span class="text-gray-400">{{ $generation->model }}</span>
</div>
<p class="mt-1 truncate text-sm text-gray-700">{{ $generation->prompt }}</p>
@if ($generation->type === 'image' && $generation->output_path)
<img src="{{ Storage::url($generation->output_path) }}" class="mt-2 h-32 w-32 rounded object-cover">
@elseif ($generation->type === 'video' && $generation->output_path)
<video src="{{ Storage::url($generation->output_path) }}" controls class="mt-2 h-32 rounded"></video>
@elseif ($generation->type === 'text' && $generation->output_text)
<p class="mt-2 rounded bg-gray-50 p-2 text-sm text-gray-800">{{ Str::limit($generation->output_text, 300) }}</p>
@elseif ($generation->error)
<p class="mt-2 text-sm text-red-700">{{ $generation->error }}</p>
@endif
</div>
<div class="shrink-0 text-right text-sm text-gray-500">
@if ($generation->cost_eur !== null)
<div>{{ number_format($generation->cost_eur, 4) }}€{{ $generation->cost_is_estimated ? ' (est.)' : '' }}</div>
@endif
<div class="text-xs">{{ $generation->created_at->diffForHumans() }}</div>
</div>
</div>
@empty
<p class="py-6 text-center text-sm text-gray-500">No generations yet.</p>
@endforelse
</div>
{{ $generations->links() }}
</div>
@endsection
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', 'dreamstudio')</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-gray-50 text-gray-900">
<nav class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<a href="{{ route('generations.index') }}" class="text-lg font-semibold">dreamstudio</a>
<div class="flex gap-4 text-sm">
<a href="{{ route('generations.index') }}" class="text-gray-600 hover:text-gray-900">Generate</a>
<a href="{{ route('runware-models.index') }}" class="text-gray-600 hover:text-gray-900">Models</a>
</div>
</div>
</nav>
<main class="mx-auto max-w-5xl px-6 py-8">
@if (session('status'))
<div class="mb-6 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
<ul class="list-inside list-disc">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@yield('content')
</main>
</body>
</html>
@@ -0,0 +1,95 @@
@extends('layouts.app')
@section('title', 'Runware Models — dreamstudio')
@section('content')
<div class="mb-8 rounded-md border border-gray-200 bg-white p-6">
<h2 class="mb-4 text-base font-semibold">Add a model</h2>
<p class="mb-4 text-sm text-gray-500">
Runware doesn't expose pricing through its API, so add models here with the price from
<a href="https://runware.ai/pricing" class="underline" target="_blank" rel="noopener">runware.ai/pricing</a>.
The cheapest active model per type is automatically used as the default.
</p>
<form method="POST" action="{{ route('runware-models.store') }}" class="grid grid-cols-2 gap-4 sm:grid-cols-3">
@csrf
<div>
<label class="block text-sm font-medium">Type</label>
<select name="modality" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="text">Text</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">Model ID (AIR)</label>
<input type="text" name="air_id" required placeholder="e.g. bfl:flux@3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
<div>
<label class="block text-sm font-medium">Label</label>
<input type="text" name="label" required placeholder="e.g. Flux 3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
<div>
<label class="block text-sm font-medium">Billed per</label>
<select name="unit" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
<option value="per_image">Image</option>
<option value="per_second">Second (video)</option>
<option value="per_million_tokens">1M tokens (text)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">Price (USD)</label>
<input type="number" step="0.000001" min="0" name="price_usd" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
</div>
<div class="flex items-end">
<button type="submit" class="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">Add model</button>
</div>
</form>
</div>
<div class="rounded-md border border-gray-200 bg-white p-6">
<h2 class="mb-4 text-base font-semibold">Models</h2>
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-gray-200 text-gray-500">
<th class="py-2">Type</th>
<th class="py-2">Label</th>
<th class="py-2">AIR ID</th>
<th class="py-2">Price</th>
<th class="py-2">Default</th>
<th class="py-2">Active</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($models as $model)
<tr>
<td class="py-2 uppercase">{{ $model->modality }}</td>
<td class="py-2">{{ $model->label }}</td>
<td class="py-2 font-mono text-xs">{{ $model->air_id }}</td>
<td class="py-2">${{ $model->price_usd }} / {{ $model->unit }}</td>
<td class="py-2">{{ $model->is_default ? '✓' : '' }}</td>
<td class="py-2">
<form method="POST" action="{{ route('runware-models.update', $model) }}">
@csrf
@method('PATCH')
<input type="hidden" name="active" value="{{ $model->active ? 0 : 1 }}">
<button type="submit" class="underline">{{ $model->active ? 'Deactivate' : 'Activate' }}</button>
</form>
</td>
<td class="py-2">
<form method="POST" action="{{ route('runware-models.destroy', $model) }}" onsubmit="return confirm('Delete this model?')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-700 underline">Delete</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="py-6 text-center text-gray-500">No models yet — add one above.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endsection
File diff suppressed because one or more lines are too long