feat: Album artwork generation via Gemini Image models

Backend:
- New image_gen.py module with Gemini image generation client
  - generate_artwork(): generates PNG from text prompt using Google GenAI
  - get_image_gen_config(): loads config from .env (model, API key, safety)
  - get_available_models(): returns list of Gemini image models
  - Safety settings: harassment, hate_speech, sexually_explicit, dangerous
    Configurable via IMAGE_GEN_SAFETY_* env vars (default: BLOCK_ONLY_HIGH)
- New endpoints:
  * POST /api/clips/{id}/generate-artwork — generates and saves artwork
  * GET /api/image-gen/models — lists available models
  * GET /api/image-gen/config — returns safe config (API key masked)
- Default model: gemini-3.1-flash-image-preview
- Fallback API key: LYRIA_API_KEY if IMAGE_GEN_API_KEY not set
- Added google-genai==1.0.0 to requirements.txt

Frontend:
- New ArtworkGenerationModal component for prompt input
  - Textarea with placeholder, loading spinner during generation
  - Styled consistent with SettingsModal (backdrop blur, rounded, dark)
- SettingsModal expanded with Image Generation section (collapsible)
  - Shows current model, API key status, safety filter levels
  - Read-only display — values configured via .env
- DetailPanel: 'Generate' button added next to Upload/Change
  - Passes onGenerateArtwork prop to App.tsx
- App.tsx: handles artwork generation flow, updates state on success

Architecture KB and Tasks KB updated.
This commit is contained in:
rin.kagamine committed 2026-05-29 22:05:47 -04:00
1 parent fa3515acc9
commit 934d7775a8
8 files changed
+477 -6

No files matched your search

+147 -4
View File
@@ -1,4 +1,20 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
interface ImageGenConfig {
model: string
has_api_key: boolean
safety_harassment: string
safety_hate_speech: string
safety_sexually_explicit: string
safety_dangerous: string
}
interface ImageGenModel {
id: string
name: string
description: string
status: string
}
interface Props {
isOpen: boolean
@@ -8,9 +24,51 @@ interface Props {
clipCount: number
}
const SAFETY_OPTIONS = [
{ value: 'OFF', label: 'Off' },
{ value: 'BLOCK_NONE', label: 'Block None' },
{ value: 'BLOCK_ONLY_HIGH', label: 'Block Few (High only)' },
{ value: 'BLOCK_MEDIUM_AND_ABOVE', label: 'Block Some (Medium+)' },
{ value: 'BLOCK_LOW_AND_ABOVE', label: 'Block Most (Low+)' },
]
export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDeleteAll, clipCount }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false)
const [loading, setLoading] = useState(false)
const [imageGenModels, setImageGenModels] = useState<ImageGenModel[]>([])
const [imageGenConfig, setImageGenConfig] = useState<ImageGenConfig | null>(null)
const [showImageGenSettings, setShowImageGenSettings] = useState(false)
useEffect(() => {
if (isOpen) {
fetchImageGenModels()
fetchImageGenConfig()
}
}, [isOpen])
const fetchImageGenModels = async () => {
try {
const res = await fetch('/api/image-gen/models')
if (res.ok) {
const data = await res.json()
setImageGenModels(data.models)
}
} catch (e) {
console.error('Failed to fetch image gen models:', e)
}
}
const fetchImageGenConfig = async () => {
try {
const res = await fetch('/api/image-gen/config')
if (res.ok) {
const data = await res.json()
setImageGenConfig(data)
}
} catch (e) {
console.error('Failed to fetch image gen config:', e)
}
}
if (!isOpen) return null
@@ -46,9 +104,9 @@ export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDel
/>
{/* Modal */}
<div className="relative bg-lyria-surface border border-white/10 rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
<div className="relative bg-lyria-surface border border-white/10 rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
<h2 className="text-lg font-semibold text-white">Settings</h2>
<button
onClick={onClose}
@@ -62,7 +120,92 @@ export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDel
</div>
{/* Content */}
<div className="px-6 py-5 space-y-6">
<div className="px-6 py-5 space-y-6 overflow-y-auto">
{/* Image Generation Settings */}
<div className="border border-white/10 rounded-lg overflow-hidden">
<button
onClick={() => setShowImageGenSettings(!showImageGenSettings)}
className="w-full flex items-center justify-between px-4 py-3 bg-white/5 hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-lyria-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium text-white">Image Generation</span>
</div>
<svg
className={`w-4 h-4 text-lyria-muted transition-transform ${showImageGenSettings ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showImageGenSettings && (
<div className="p-4 space-y-4">
{/* Model Selection */}
<div>
<label className="block text-xs text-lyria-muted mb-1.5">Model</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-lyria-accent"
value={imageGenConfig?.model || 'gemini-3.1-flash-image-preview'}
onChange={(e) => {
// This would need a backend endpoint to update config
// For now, it's read-only from .env
console.log('Model selection changed:', e.target.value)
}}
>
{imageGenModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.status})
</option>
))}
</select>
<p className="text-xs text-lyria-muted/60 mt-1">
Configure via IMAGE_GEN_MODEL in .env
</p>
</div>
{/* API Key Status */}
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${imageGenConfig?.has_api_key ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-xs text-lyria-muted">
{imageGenConfig?.has_api_key ? 'API key configured' : 'No API key — set IMAGE_GEN_API_KEY in .env'}
</span>
</div>
{/* Safety Settings */}
<div className="space-y-3">
<p className="text-xs font-medium text-white">Safety Filters</p>
{[
{ key: 'safety_harassment', label: 'Harassment' },
{ key: 'safety_hate_speech', label: 'Hate Speech' },
{ key: 'safety_sexually_explicit', label: 'Sexually Explicit' },
{ key: 'safety_dangerous', label: 'Dangerous Content' },
].map(({ key, label }) => (
<div key={key}>
<label className="block text-xs text-lyria-muted mb-1">{label}</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-1.5 text-sm text-white focus:outline-none focus:border-lyria-accent"
value={imageGenConfig?.[key as keyof ImageGenConfig] as string || 'BLOCK_ONLY_HIGH'}
disabled
>
{SAFETY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
<p className="text-xs text-lyria-muted/60">
Configure via IMAGE_GEN_SAFETY_* in .env
</p>
</div>
</div>
)}
</div>
{/* Danger Zone */}
<div className="border border-red-500/30 rounded-lg overflow-hidden">
<div className="bg-red-500/10 px-4 py-2 border-b border-red-500/20">