Backend: - Added prompt_structure JSON column to clips DB (migration in init_db) - Updated GenerateRequest model with optional prompt_structure dict - build_prompt() method combines structured fields into single prompt: 'Generate a track with the following elements:\n\n**Style:** [style]\n...' - create_clip() accepts and stores prompt_structure JSON - get_clips()/get_clip() parse prompt_structure back to dict Frontend: - New StructuredPromptEditor component with 8 collapsible fields: Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional - Fields show filled/empty state with colored dots - Expand/collapse individual fields - 'Expand All' button - Counter: 'X of 8 fields filled' - Generator component updated with Basic/Advanced mode toggle (Pro only) - Basic: single textarea (works for both models) - Advanced: StructuredPromptEditor (Pro only) - Mode resets to Basic when switching to Clip Preview - DetailPanel shows structured prompt breakdown when present - Each filled field shown in its own labeled box - Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional - Clip interface updated with prompt_structure field across all components Architecture KB and Tasks KB updated.
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
import { useState } from 'react'
|
|
|
|
export interface PromptStructure {
|
|
style?: string
|
|
timbre?: string
|
|
structure?: string
|
|
mood?: string
|
|
tempo?: string
|
|
instrumentation?: string
|
|
effects?: string
|
|
additional?: string
|
|
}
|
|
|
|
interface Props {
|
|
value: PromptStructure
|
|
onChange: (value: PromptStructure) => void
|
|
}
|
|
|
|
const FIELDS = [
|
|
{ key: 'style', label: 'Style / Genre', placeholder: 'e.g., Gabber, Ambient, Orchestral, Phonk...' },
|
|
{ key: 'timbre', label: 'Timbre / Texture', placeholder: 'e.g., Warm analog synths, gritty distortion, ethereal pads...' },
|
|
{ key: 'structure', label: 'Structure', placeholder: 'e.g., Intro-Verse-Chorus-Verse-Chorus-Bridge-Chorus-Outro' },
|
|
{ key: 'mood', label: 'Mood / Energy', placeholder: 'e.g., Dark and aggressive, euphoric, melancholic, triumphant...' },
|
|
{ key: 'tempo', label: 'Tempo / BPM', placeholder: 'e.g., 180 BPM, slow build to 200 BPM, variable...' },
|
|
{ key: 'instrumentation', label: 'Instrumentation', placeholder: 'e.g., Heavy kick drums, distorted leads, vocal chops, sub-bass...' },
|
|
{ key: 'effects', label: 'Effects / Processing', placeholder: 'e.g., Heavy sidechain compression, reverb tails, bitcrush...' },
|
|
{ key: 'additional', label: 'Additional Notes', placeholder: 'Any other details, references, or specific instructions...' },
|
|
] as const
|
|
|
|
export default function StructuredPromptEditor({ value, onChange }: Props) {
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set(['style', 'structure', 'mood']))
|
|
|
|
const toggleField = (key: string) => {
|
|
setExpanded(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(key)) {
|
|
next.delete(key)
|
|
} else {
|
|
next.add(key)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const updateField = (key: string, text: string) => {
|
|
onChange({ ...value, [key]: text })
|
|
}
|
|
|
|
const filledCount = FIELDS.filter(f => value[f.key as keyof PromptStructure]?.trim()).length
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-lyria-muted">
|
|
{filledCount} of {FIELDS.length} fields filled
|
|
</span>
|
|
<button
|
|
onClick={() => setExpanded(new Set(FIELDS.map(f => f.key)))}
|
|
className="text-xs text-lyria-accent hover:text-lyria-accent/80 transition-colors"
|
|
>
|
|
Expand All
|
|
</button>
|
|
</div>
|
|
|
|
{FIELDS.map(({ key, label, placeholder }) => {
|
|
const isExpanded = expanded.has(key)
|
|
const hasValue = value[key as keyof PromptStructure]?.trim()
|
|
|
|
return (
|
|
<div key={key} className="border border-white/5 rounded-lg overflow-hidden">
|
|
<button
|
|
onClick={() => toggleField(key)}
|
|
className={`w-full flex items-center justify-between px-3 py-2 text-sm transition-colors ${
|
|
hasValue ? 'bg-white/5' : 'bg-transparent hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-1.5 h-1.5 rounded-full ${hasValue ? 'bg-lyria-accent' : 'bg-lyria-muted/30'}`} />
|
|
<span className={hasValue ? 'text-white' : 'text-lyria-muted'}>{label}</span>
|
|
</div>
|
|
<svg
|
|
className={`w-3.5 h-3.5 text-lyria-muted transition-transform ${isExpanded ? '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>
|
|
|
|
{isExpanded && (
|
|
<div className="px-3 pb-3">
|
|
<textarea
|
|
value={value[key as keyof PromptStructure] || ''}
|
|
onChange={(e) => updateField(key, e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full h-20 bg-black/20 border border-white/5 rounded-md px-3 py-2 text-sm text-white placeholder:text-lyria-muted/30 focus:outline-none focus:border-lyria-accent/50 resize-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|