page.tsx 14 KB

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