diff --git a/backend/src/database.py b/backend/src/database.py index 4ea1d9d..210d51d 100644 --- a/backend/src/database.py +++ b/backend/src/database.py @@ -78,15 +78,20 @@ async def init_db(): await db.execute("ALTER TABLE clips ADD COLUMN key TEXT") except Exception: pass # Column already exists + # Migration: add prompt_structure column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN prompt_structure TEXT") + except Exception: + pass # Column already exists await db.commit() -async def create_clip(prompt: str, model: str, duration: int) -> str: +async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str: clip_id = str(uuid.uuid4()) filename = f"{clip_id}.mp3" async with aiosqlite.connect(DB_PATH) as db: await db.execute( - "INSERT INTO clips (id, prompt, model, duration_seconds, filename, status) VALUES (?, ?, ?, ?, ?, ?)", - (clip_id, prompt, model, duration, filename, "generating") + "INSERT INTO clips (id, prompt, model, duration_seconds, filename, status, prompt_structure) VALUES (?, ?, ?, ?, ?, ?, ?)", + (clip_id, prompt, model, duration, filename, "generating", prompt_structure) ) await db.commit() return clip_id @@ -121,6 +126,13 @@ async def get_clips(limit: int = 50, offset: int = 0): clips = [dict(row) for row in rows] for clip in clips: clip["created_at"] = format_timestamp(clip.get("created_at")) + # Parse prompt_structure JSON + if clip.get("prompt_structure"): + try: + import json + clip["prompt_structure"] = json.loads(clip["prompt_structure"]) + except Exception: + clip["prompt_structure"] = None return clips async def get_clip(clip_id: str): @@ -131,6 +143,13 @@ async def get_clip(clip_id: str): if row: clip = dict(row) clip["created_at"] = format_timestamp(clip.get("created_at")) + # Parse prompt_structure JSON + if clip.get("prompt_structure"): + try: + import json + clip["prompt_structure"] = json.loads(clip["prompt_structure"]) + except Exception: + clip["prompt_structure"] = None return clip return None diff --git a/backend/src/main.py b/backend/src/main.py index 9fe2a94..1114e1a 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -77,13 +77,18 @@ async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: in @app.post("/api/generate", response_model=GenerateResponse) async def generate(request: GenerateRequest, background_tasks: BackgroundTasks): duration = request.get_effective_duration() - clip_id = await create_clip(request.prompt, request.model, duration) + # Build prompt from structured fields if present + final_prompt = request.build_prompt() + # Serialize structured prompt for DB storage + import json + prompt_structure_json = json.dumps(request.prompt_structure) if request.prompt_structure else None + clip_id = await create_clip(final_prompt, request.model, duration, prompt_structure_json) # Kick off generation in background background_tasks.add_task( _generate_and_save, clip_id, - request.prompt, + final_prompt, request.model, duration ) diff --git a/backend/src/models.py b/backend/src/models.py index fb0ec61..3286a65 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -24,6 +24,7 @@ class GenerateRequest(BaseModel): prompt: str model: str = DEFAULT_MODEL duration: Optional[int] = None # defaults to model default + prompt_structure: Optional[dict] = None # structured prompt fields for Pro mode @field_validator('model') @classmethod @@ -40,6 +41,26 @@ class GenerateRequest(BaseModel): dur = self.duration if self.duration is not None else info["default_duration"] return min(dur, info["max_duration"]) + def build_prompt(self) -> str: + """Build the final prompt from structured fields or return the simple prompt.""" + if self.prompt_structure: + parts = ["Generate a track with the following elements:"] + field_order = [ + ("style", "Style"), + ("timbre", "Timbre"), + ("structure", "Structure"), + ("mood", "Mood"), + ("tempo", "Tempo"), + ("instrumentation", "Instrumentation"), + ("effects", "Effects"), + ("additional", "Additional Notes"), + ] + for key, label in field_order: + if key in self.prompt_structure and self.prompt_structure[key]: + parts.append(f"**{label}:** {self.prompt_structure[key]}") + return "\n\n".join(parts) + return self.prompt + class GenerateResponse(BaseModel): id: str status: str @@ -57,6 +78,9 @@ class Clip(BaseModel): status: str error_message: Optional[str] = None created_at: datetime + prompt_structure: Optional[dict] = None + bpm: Optional[float] = None + key: Optional[str] = None class RenameRequest(BaseModel): title: str diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 29f9064..88d7967 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ interface Clip { artwork_filename?: string | null bpm?: number | null key?: string | null + prompt_structure?: Record | null created_at: string } diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index ebaa202..221dea4 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -13,6 +13,7 @@ interface Clip { artwork_filename?: string | null bpm?: number | null key?: string | null + prompt_structure?: Record | null created_at: string } @@ -280,6 +281,34 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o

+ {/* Structured Prompt Breakdown */} + {clip.prompt_structure && ( +
+ +
+ {[ + { key: 'style', label: 'Style' }, + { key: 'timbre', label: 'Timbre' }, + { key: 'structure', label: 'Structure' }, + { key: 'mood', label: 'Mood' }, + { key: 'tempo', label: 'Tempo' }, + { key: 'instrumentation', label: 'Instrumentation' }, + { key: 'effects', label: 'Effects' }, + { key: 'additional', label: 'Additional' }, + ].map(({ key, label }) => { + const val = clip.prompt_structure?.[key] + if (!val) return null + return ( +
+ {label}: + {val} +
+ ) + })} +
+
+ )} + {/* Status */}
diff --git a/frontend/src/components/Gallery.tsx b/frontend/src/components/Gallery.tsx index a2de56f..5a8f756 100644 --- a/frontend/src/components/Gallery.tsx +++ b/frontend/src/components/Gallery.tsx @@ -15,6 +15,7 @@ interface Clip { artwork_filename?: string | null bpm?: number | null key?: string | null + prompt_structure?: Record | null created_at: string } diff --git a/frontend/src/components/Generator.tsx b/frontend/src/components/Generator.tsx index 4028917..de1b95e 100644 --- a/frontend/src/components/Generator.tsx +++ b/frontend/src/components/Generator.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import StructuredPromptEditor, { PromptStructure } from './StructuredPromptEditor' interface ModelInfo { name: string @@ -11,6 +12,8 @@ interface Props { onGenerate: () => void } +type PromptMode = 'basic' | 'advanced' + export default function Generator({ onGenerate }: Props) { const [prompt, setPrompt] = useState('') const [model, setModel] = useState('lyria-3-clip-preview') @@ -18,6 +21,8 @@ export default function Generator({ onGenerate }: Props) { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const [models, setModels] = useState>({}) + const [promptMode, setPromptMode] = useState('basic') + const [structuredPrompt, setStructuredPrompt] = useState({}) useEffect(() => { fetch('/api/models') @@ -37,28 +42,40 @@ export default function Generator({ onGenerate }: Props) { if (info) { setDuration(info.default_duration) } + // Reset to basic mode when switching to Clip Preview + if (newModel === 'lyria-3-clip-preview') { + setPromptMode('basic') + } } const currentModelInfo = models[model] const maxDuration = currentModelInfo?.max_duration || 30 const minDuration = 5 + const isPro = model === 'lyria-3-pro-preview' const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!prompt.trim()) return + if (promptMode === 'basic' && !prompt.trim()) return + if (promptMode === 'advanced' && Object.values(structuredPrompt).every(v => !v?.trim())) return setIsLoading(true) setError('') try { + const body: Record = { + prompt: prompt.trim(), + model, + duration, + } + + if (promptMode === 'advanced' && isPro) { + body.prompt_structure = structuredPrompt + } + const res = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - prompt: prompt.trim(), - model, - duration - }) + body: JSON.stringify(body) }) if (!res.ok) { @@ -78,6 +95,7 @@ export default function Generator({ onGenerate }: Props) { } setPrompt('') + setStructuredPrompt({}) onGenerate() } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong') @@ -93,18 +111,7 @@ export default function Generator({ onGenerate }: Props) {
-
- -