import { useState, useEffect } from 'react' // eslint-disable-next-line no-unused-vars import RequestPanel from '../components/RequestPanel' import { useParams, useOutletContext } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { CAP_IMAGE } from '../utils/capabilities' // eslint-disable-next-line no-unused-vars import Lightbox from '../components/Lightbox' import { imageApi, fileToBase64 } from '../utils/api' import { useMediaHistory } from '../hooks/useMediaHistory' import { useStudioHandoff, useHandoffSource, blobToImageInput } from '../hooks/useStudioHandoff' import { parseSize } from '../utils/studioWork' // eslint-disable-next-line no-unused-vars import HandoffNote from '../components/studio/HandoffNote' import { // eslint-disable-next-line no-unused-vars Workspace, ComposeCard, ChipSelect, ModelChip, Field, Fold, SourceChip, Starters, RunArea, JobCard, FailedCard, ResultCard, ResultsStrip, EmptyRun, } from '../components/studio/Workspace' import { foldSummary, useWorkspace } from '../hooks/useWorkspace' import Icon from '../components/Icon' const SIZES = ['256x256', '512x512', '768x768', '1024x1024'] export default function ImageGen() { const { model: urlModel } = useParams() const { addToast } = useOutletContext() const { t } = useTranslation('media') // Opened from the Studio front page, the form starts from what it sent. const handoff = useStudioHandoff() const [model, setModel] = useState(urlModel || handoff.model || '') const [prompt, setPrompt] = useState(handoff.prompt) const [negativePrompt, setNegativePrompt] = useState('') const [size, setSize] = useState(SIZES.includes(handoff.size) ? handoff.size : '512x512') const [count, setCount] = useState(Math.min(4, handoff.count || 1)) const [steps, setSteps] = useState('') const [seed, setSeed] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [images, setImages] = useState([]) const [showAdvanced, setShowAdvanced] = useState(false) const [sourceImage, setSourceImage] = useState(null) const [sourceName, setSourceName] = useState('') const [sourcePreview, setSourcePreview] = useState(null) const [refImages, setRefImages] = useState([]) const { addEntry, selectEntry, selectedEntry, historyProps } = useMediaHistory('image') const ws = useWorkspace({ type: 'images', entries: historyProps.entries }) const [lastId, setLastId] = useState(null) // A variation starts from the picture it was made from, as the source image. const wantsSource = handoff.edge === 'variation' const source = useHandoffSource(handoff, wantsSource) useEffect(() => { if (source.status !== 'ready') return blobToImageInput(source.blob).then((img) => { setSourceImage(img.base64); setSourcePreview(img.dataUrl); setSourceName(source.item?.title || '') }).catch(() => {}) }, [source]) const [lightboxIdx, setLightboxIdx] = useState(null) // The body of the last request, kept so the panel can show what was actually // sent rather than what the form currently holds. const [lastRequest, setLastRequest] = useState(null) // The images currently on screen (a picked history entry, else the latest run). const displayImages = selectedEntry ? selectedEntry.results.map(r => ({ url: r.url, alt: selectedEntry.prompt })) : images.map(img => ({ url: img.url || `data:image/png;base64,${img.b64_json}`, alt: prompt })) const activeEntry = selectedEntry || (lastId ? historyProps.entries.find(e => e.id === lastId) : null) || null const item = ws.itemById(activeEntry?.id) const run = async () => { if (!prompt.trim()) { addToast(t('image.toasts.noPrompt'), 'warning'); return } if (!model) { addToast(t('image.toasts.noModel'), 'warning'); return } setLoading(true) setImages([]) setError(null) setLastId(null) let combinedPrompt = prompt.trim() if (negativePrompt.trim()) combinedPrompt += '|' + negativePrompt.trim() const body = { model, prompt: combinedPrompt, n: count, size } if (steps) body.step = parseInt(steps) if (seed) body.seed = parseInt(seed) if (sourceImage) body.file = sourceImage if (refImages.length > 0) body.ref_images = refImages setLastRequest(body) try { const data = await imageApi.generate(body) const results = data?.data || [] setImages(results) if (!results.length) { addToast(t('image.toasts.noResults'), 'warning') } else { const urlResults = results.filter(r => r.url && !r.url.startsWith('data:')).map(r => ({ url: r.url })) if (urlResults.length) { setLastId(addEntry({ prompt: prompt.trim(), model, params: { size, count, steps, seed, negativePrompt: negativePrompt.trim() || undefined }, results: urlResults, parentId: handoff.from || undefined, edge: handoff.edge || undefined })) } selectEntry(null) ws.end() } } catch (err) { setError(err.message) } finally { setLoading(false) } } const handleRefImages = async (files) => { const arr = [] for (const f of files) arr.push(await fileToBase64(f)) setRefImages(prev => [...prev, ...arr]) } // Re-run with edits: this result's values go back into the form, and the form // remembers them so what changes can be listed. const rerun = (it) => { const entry = historyProps.entries.find(e => e.id === it.id) if (!entry) return const p = entry.params || {} const next = { prompt: entry.prompt || '', model: entry.model || model, size: SIZES.includes(p.size) ? p.size : size, count: String(Math.min(4, Number(p.count) || 1)), steps: p.steps ? String(p.steps) : '', seed: p.seed ? String(p.seed) : '', negative: p.negativePrompt || '', } setPrompt(next.prompt); setModel(next.model); setSize(next.size); setCount(Number(next.count)) setSteps(next.steps); setSeed(next.seed); setNegativePrompt(next.negative) if (next.steps || next.seed || next.negative) setShowAdvanced(true) selectEntry(null) ws.begin(next) } const labels = { prompt: t('studio.workspace.fields.prompt'), model: t('studio.workspace.fields.model'), size: t('studio.workspace.fields.size'), count: t('studio.workspace.fields.count'), steps: t('studio.workspace.fields.steps'), seed: t('studio.workspace.fields.seed'), negative: t('studio.workspace.fields.negative'), } const current = { prompt, model, size, count: String(count), steps, seed, negative: negativePrompt } const changes = ws.changes(current, labels) const changed = new Set(changes.map(c => c.field)) const installed = ws.installed.byType.images const noModel = !ws.installed.loading && !ws.installed.error && installed.length === 0 const why = noModel ? t('studio.composer.whyModel', { type: t('studio.tabs.images') }) : !prompt.trim() ? t('studio.composer.whyPrompt') : '' const dims = parseSize(size) return ( { e.preventDefault(); run() }} handoff={ { setSourceImage(null); setSourceName('') }} />} sources={<> { const img = await blobToImageInput(file); setSourceImage(img.base64); setSourcePreview(img.dataUrl); setSourceName(file.name) }} onClear={() => { setSourceImage(null); setSourceName('') }} /> setRefImages([])} /> } model={model} noModel={noModel ? { type: 'images', label: t('studio.tabs.images'), onChanged: ws.installed.refetch } : null} options={<> ({ value: s, label: s.replace('x', ' × ') }))} changed={changed.has('size')} testId="ws-size" /> setCount(parseInt(v) || 1)} options={[1, 2, 3, 4].map(n => ({ value: String(n), label: t('studio.composer.countOption', { count: n }) }))} mono={false} changed={changed.has('count')} testId="ws-count" /> } fold={ setShowAdvanced(v => !v)} id="image-advanced-options" >