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,9 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user