page.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import { Header } from '@/components/header';
  4. import { MainLayout } from '@/components/main-layout';
  5. import { Button } from '@/components/ui/button';
  6. import { Input } from '@/components/ui/input';
  7. import { Textarea } from '@/components/ui/textarea';
  8. import { PromptTextarea } from '@/components/prompt-textarea';
  9. import { Label } from '@/components/ui/label';
  10. import { Card, CardContent } from '@/components/ui/card';
  11. import { apiClient, type GenerationRequest, type JobInfo, type ModelInfo } from '@/lib/api';
  12. import { Loader2, Download, X } from 'lucide-react';
  13. import { downloadImage } from '@/lib/utils';
  14. import { useLocalStorage } from '@/lib/hooks';
  15. const defaultFormData: GenerationRequest = {
  16. prompt: '',
  17. negative_prompt: '',
  18. width: 512,
  19. height: 512,
  20. steps: 20,
  21. cfg_scale: 7.5,
  22. seed: '',
  23. sampling_method: 'euler_a',
  24. scheduler: 'default',
  25. batch_count: 1,
  26. };
  27. export default function Text2ImgPage() {
  28. const [formData, setFormData] = useLocalStorage<GenerationRequest>(
  29. 'text2img-form-data',
  30. defaultFormData
  31. );
  32. const [loading, setLoading] = useState(false);
  33. const [error, setError] = useState<string | null>(null);
  34. const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
  35. const [generatedImages, setGeneratedImages] = useState<string[]>([]);
  36. const [samplers, setSamplers] = useState<Array<{ name: string; description: string }>>([]);
  37. const [schedulers, setSchedulers] = useState<Array<{ name: string; description: string }>>([]);
  38. const [vaeModels, setVaeModels] = useState<ModelInfo[]>([]);
  39. const [selectedVae, setSelectedVae] = useState<string>('');
  40. const [loraModels, setLoraModels] = useState<string[]>([]);
  41. const [embeddings, setEmbeddings] = useState<string[]>([]);
  42. useEffect(() => {
  43. const loadOptions = async () => {
  44. try {
  45. const [samplersData, schedulersData, models, loras, embeds] = await Promise.all([
  46. apiClient.getSamplers(),
  47. apiClient.getSchedulers(),
  48. apiClient.getModels('vae'),
  49. apiClient.getModels('lora'),
  50. apiClient.getModels('embedding'),
  51. ]);
  52. setSamplers(samplersData);
  53. setSchedulers(schedulersData);
  54. setVaeModels(models);
  55. setLoraModels(loras.map(m => m.name));
  56. setEmbeddings(embeds.map(m => m.name));
  57. } catch (err) {
  58. console.error('Failed to load options:', err);
  59. }
  60. };
  61. loadOptions();
  62. }, []);
  63. const handleInputChange = (
  64. e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
  65. ) => {
  66. const { name, value } = e.target;
  67. setFormData((prev) => ({
  68. ...prev,
  69. [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method'
  70. ? value
  71. : Number(value),
  72. }));
  73. };
  74. const pollJobStatus = async (jobId: string) => {
  75. const maxAttempts = 300; // 5 minutes with 1 second interval
  76. let attempts = 0;
  77. const poll = async () => {
  78. try {
  79. const status = await apiClient.getJobStatus(jobId);
  80. setJobInfo(status);
  81. if (status.status === 'completed' && status.result?.images) {
  82. setGeneratedImages(status.result.images);
  83. setLoading(false);
  84. } else if (status.status === 'failed') {
  85. setError(status.error || 'Generation failed');
  86. setLoading(false);
  87. } else if (status.status === 'cancelled') {
  88. setError('Generation was cancelled');
  89. setLoading(false);
  90. } else if (attempts < maxAttempts) {
  91. attempts++;
  92. setTimeout(poll, 1000);
  93. } else {
  94. setError('Job polling timeout');
  95. setLoading(false);
  96. }
  97. } catch (err) {
  98. setError(err instanceof Error ? err.message : 'Failed to check job status');
  99. setLoading(false);
  100. }
  101. };
  102. poll();
  103. };
  104. const handleGenerate = async (e: React.FormEvent) => {
  105. e.preventDefault();
  106. setLoading(true);
  107. setError(null);
  108. setGeneratedImages([]);
  109. setJobInfo(null);
  110. try {
  111. const job = await apiClient.text2img(formData);
  112. setJobInfo(job);
  113. const jobId = job.request_id || job.id;
  114. if (jobId) {
  115. await pollJobStatus(jobId);
  116. } else {
  117. setError('No job ID returned from server');
  118. setLoading(false);
  119. }
  120. } catch (err) {
  121. setError(err instanceof Error ? err.message : 'Failed to generate image');
  122. setLoading(false);
  123. }
  124. };
  125. const handleCancel = async () => {
  126. const jobId = jobInfo?.request_id || jobInfo?.id;
  127. if (jobId) {
  128. try {
  129. await apiClient.cancelJob(jobId);
  130. setLoading(false);
  131. setError('Generation cancelled');
  132. } catch (err) {
  133. console.error('Failed to cancel job:', err);
  134. }
  135. }
  136. };
  137. return (
  138. <MainLayout>
  139. <Header title="Text to Image" description="Generate images from text prompts" />
  140. <div className="container mx-auto p-6">
  141. <div className="grid gap-6 lg:grid-cols-2">
  142. {/* Left Panel - Form */}
  143. <Card>
  144. <CardContent className="pt-6">
  145. <form onSubmit={handleGenerate} className="space-y-4">
  146. <div className="space-y-2">
  147. <Label htmlFor="prompt">Prompt *</Label>
  148. <PromptTextarea
  149. value={formData.prompt}
  150. onChange={(value) => setFormData({ ...formData, prompt: value })}
  151. placeholder="a beautiful landscape with mountains and a lake, sunset, highly detailed..."
  152. rows={4}
  153. loras={loraModels}
  154. embeddings={embeddings}
  155. />
  156. <p className="text-xs text-muted-foreground">
  157. Tip: Use &lt;lora:name:weight&gt; for LoRAs (e.g., &lt;lora:myLora:0.8&gt;) and embedding names directly
  158. </p>
  159. </div>
  160. <div className="space-y-2">
  161. <Label htmlFor="negative_prompt">Negative Prompt</Label>
  162. <PromptTextarea
  163. value={formData.negative_prompt || ''}
  164. onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
  165. placeholder="blurry, low quality, distorted..."
  166. rows={2}
  167. />
  168. </div>
  169. <div className="grid grid-cols-2 gap-4">
  170. <div className="space-y-2">
  171. <Label htmlFor="width">Width</Label>
  172. <Input
  173. id="width"
  174. name="width"
  175. type="number"
  176. value={formData.width}
  177. onChange={handleInputChange}
  178. step={64}
  179. min={256}
  180. max={2048}
  181. />
  182. </div>
  183. <div className="space-y-2">
  184. <Label htmlFor="height">Height</Label>
  185. <Input
  186. id="height"
  187. name="height"
  188. type="number"
  189. value={formData.height}
  190. onChange={handleInputChange}
  191. step={64}
  192. min={256}
  193. max={2048}
  194. />
  195. </div>
  196. </div>
  197. <div className="grid grid-cols-2 gap-4">
  198. <div className="space-y-2">
  199. <Label htmlFor="steps">Steps</Label>
  200. <Input
  201. id="steps"
  202. name="steps"
  203. type="number"
  204. value={formData.steps}
  205. onChange={handleInputChange}
  206. min={1}
  207. max={150}
  208. />
  209. </div>
  210. <div className="space-y-2">
  211. <Label htmlFor="cfg_scale">CFG Scale</Label>
  212. <Input
  213. id="cfg_scale"
  214. name="cfg_scale"
  215. type="number"
  216. value={formData.cfg_scale}
  217. onChange={handleInputChange}
  218. step={0.5}
  219. min={1}
  220. max={30}
  221. />
  222. </div>
  223. </div>
  224. <div className="space-y-2">
  225. <Label htmlFor="seed">Seed (optional)</Label>
  226. <Input
  227. id="seed"
  228. name="seed"
  229. value={formData.seed}
  230. onChange={handleInputChange}
  231. placeholder="Leave empty for random"
  232. />
  233. </div>
  234. <div className="space-y-2">
  235. <Label htmlFor="sampling_method">Sampling Method</Label>
  236. <select
  237. id="sampling_method"
  238. name="sampling_method"
  239. value={formData.sampling_method}
  240. onChange={handleInputChange}
  241. className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  242. >
  243. {samplers.length > 0 ? (
  244. samplers.map((sampler) => (
  245. <option key={sampler.name} value={sampler.name}>
  246. {sampler.name.toUpperCase()} - {sampler.description}
  247. </option>
  248. ))
  249. ) : (
  250. <option value="euler_a">Loading...</option>
  251. )}
  252. </select>
  253. </div>
  254. <div className="space-y-2">
  255. <Label htmlFor="scheduler">Scheduler</Label>
  256. <select
  257. id="scheduler"
  258. name="scheduler"
  259. value={formData.scheduler}
  260. onChange={handleInputChange}
  261. className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  262. >
  263. {schedulers.length > 0 ? (
  264. schedulers.map((scheduler) => (
  265. <option key={scheduler.name} value={scheduler.name}>
  266. {scheduler.name.toUpperCase()} - {scheduler.description}
  267. </option>
  268. ))
  269. ) : (
  270. <option value="default">Loading...</option>
  271. )}
  272. </select>
  273. </div>
  274. <div className="space-y-2">
  275. <Label htmlFor="vae">VAE (optional)</Label>
  276. <select
  277. id="vae"
  278. value={selectedVae}
  279. onChange={(e) => setSelectedVae(e.target.value)}
  280. className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  281. >
  282. <option value="">Default VAE</option>
  283. {vaeModels.map((vae) => (
  284. <option key={vae.id} value={vae.name}>
  285. {vae.name}
  286. </option>
  287. ))}
  288. </select>
  289. </div>
  290. <div className="space-y-2">
  291. <Label htmlFor="batch_count">Batch Count</Label>
  292. <Input
  293. id="batch_count"
  294. name="batch_count"
  295. type="number"
  296. value={formData.batch_count}
  297. onChange={handleInputChange}
  298. min={1}
  299. max={4}
  300. />
  301. </div>
  302. <div className="flex gap-2">
  303. <Button type="submit" disabled={loading} className="flex-1">
  304. {loading ? (
  305. <>
  306. <Loader2 className="h-4 w-4 animate-spin" />
  307. Generating...
  308. </>
  309. ) : (
  310. 'Generate'
  311. )}
  312. </Button>
  313. {loading && (
  314. <Button type="button" variant="destructive" onClick={handleCancel}>
  315. <X className="h-4 w-4" />
  316. Cancel
  317. </Button>
  318. )}
  319. </div>
  320. {error && (
  321. <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
  322. {error}
  323. </div>
  324. )}
  325. {loading && jobInfo && (
  326. <div className="rounded-md bg-muted p-3 text-sm">
  327. <p>Job ID: {jobInfo.id || jobInfo.request_id || 'N/A'}</p>
  328. <p>Status: {jobInfo.status}</p>
  329. {jobInfo.progress !== undefined && (
  330. <p>Progress: {Math.round(jobInfo.progress * 100)}%</p>
  331. )}
  332. </div>
  333. )}
  334. </form>
  335. </CardContent>
  336. </Card>
  337. {/* Right Panel - Generated Images */}
  338. <Card>
  339. <CardContent className="pt-6">
  340. <div className="space-y-4">
  341. <h3 className="text-lg font-semibold">Generated Images</h3>
  342. {generatedImages.length === 0 ? (
  343. <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
  344. <p className="text-muted-foreground">
  345. {loading ? 'Generating...' : 'Generated images will appear here'}
  346. </p>
  347. </div>
  348. ) : (
  349. <div className="grid gap-4">
  350. {generatedImages.map((image, index) => (
  351. <div key={index} className="relative group">
  352. <img
  353. src={image}
  354. alt={`Generated ${index + 1}`}
  355. className="w-full rounded-lg border border-border"
  356. />
  357. <Button
  358. size="icon"
  359. variant="secondary"
  360. className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
  361. onClick={() => downloadImage(image, `generated-${Date.now()}-${index}.png`)}
  362. >
  363. <Download className="h-4 w-4" />
  364. </Button>
  365. </div>
  366. ))}
  367. </div>
  368. )}
  369. </div>
  370. </CardContent>
  371. </Card>
  372. </div>
  373. </div>
  374. </MainLayout>
  375. );
  376. }