page.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. 'use client';
  2. import { useState, useRef, useEffect } from 'react';
  3. import { Header } from '@/components/header';
  4. import { AppLayout } from '@/components/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 JobInfo } from '@/lib/api';
  12. import { Loader2, Download, X, Upload } from 'lucide-react';
  13. import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils';
  14. import { useLocalStorage } from '@/lib/hooks';
  15. type Img2ImgFormData = {
  16. prompt: string;
  17. negative_prompt: string;
  18. image: string;
  19. strength: number;
  20. steps: number;
  21. cfg_scale: number;
  22. seed: string;
  23. sampling_method: string;
  24. };
  25. const defaultFormData: Img2ImgFormData = {
  26. prompt: '',
  27. negative_prompt: '',
  28. image: '',
  29. strength: 0.75,
  30. steps: 20,
  31. cfg_scale: 7.5,
  32. seed: '',
  33. sampling_method: 'euler_a',
  34. };
  35. export default function Img2ImgPage() {
  36. const [formData, setFormData] = useLocalStorage<Img2ImgFormData>(
  37. 'img2img-form-data',
  38. defaultFormData
  39. );
  40. const [loading, setLoading] = useState(false);
  41. const [error, setError] = useState<string | null>(null);
  42. const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
  43. const [generatedImages, setGeneratedImages] = useState<string[]>([]);
  44. const [previewImage, setPreviewImage] = useState<string | null>(null);
  45. const [loraModels, setLoraModels] = useState<string[]>([]);
  46. const [embeddings, setEmbeddings] = useState<string[]>([]);
  47. const fileInputRef = useRef<HTMLInputElement>(null);
  48. useEffect(() => {
  49. const loadModels = async () => {
  50. try {
  51. const [loras, embeds] = await Promise.all([
  52. apiClient.getModels('lora'),
  53. apiClient.getModels('embedding'),
  54. ]);
  55. setLoraModels(loras.models.map(m => m.name));
  56. setEmbeddings(embeds.models.map(m => m.name));
  57. } catch (err) {
  58. console.error('Failed to load models:', err);
  59. }
  60. };
  61. loadModels();
  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 handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  75. const file = e.target.files?.[0];
  76. if (!file) return;
  77. try {
  78. const base64 = await fileToBase64(file);
  79. setFormData((prev) => ({ ...prev, image: base64 }));
  80. setPreviewImage(base64);
  81. setError(null);
  82. } catch (err) {
  83. setError('Failed to load image');
  84. }
  85. };
  86. const pollJobStatus = async (jobId: string) => {
  87. const maxAttempts = 300;
  88. let attempts = 0;
  89. const poll = async () => {
  90. try {
  91. const status = await apiClient.getJobStatus(jobId);
  92. setJobInfo(status);
  93. if (status.status === 'completed') {
  94. let imageUrls: string[] = [];
  95. // Handle both old format (result.images) and new format (outputs)
  96. if (status.outputs && status.outputs.length > 0) {
  97. // New format: convert output URLs to authenticated image URLs with cache-busting
  98. imageUrls = status.outputs.map((output: any) => {
  99. const filename = output.filename;
  100. return apiClient.getImageUrl(jobId, filename);
  101. });
  102. } else if (status.result?.images && status.result.images.length > 0) {
  103. // Old format: convert image URLs to authenticated URLs
  104. imageUrls = status.result.images.map((imageUrl: string) => {
  105. // Extract filename from URL if it's already a full URL
  106. if (imageUrl.includes('/output/')) {
  107. const parts = imageUrl.split('/output/');
  108. if (parts.length === 2) {
  109. const filename = parts[1].split('?')[0]; // Remove query params
  110. return apiClient.getImageUrl(jobId, filename);
  111. }
  112. }
  113. // If it's just a filename, convert it directly
  114. return apiClient.getImageUrl(jobId, imageUrl);
  115. });
  116. }
  117. // Create a new array to trigger React re-render
  118. setGeneratedImages([...imageUrls]);
  119. setLoading(false);
  120. } else if (status.status === 'failed') {
  121. setError(status.error || 'Generation failed');
  122. setLoading(false);
  123. } else if (status.status === 'cancelled') {
  124. setError('Generation was cancelled');
  125. setLoading(false);
  126. } else if (attempts < maxAttempts) {
  127. attempts++;
  128. setTimeout(poll, 2000);
  129. } else {
  130. setError('Job polling timeout');
  131. setLoading(false);
  132. }
  133. } catch (err) {
  134. setError(err instanceof Error ? err.message : 'Failed to check job status');
  135. setLoading(false);
  136. }
  137. };
  138. poll();
  139. };
  140. const handleGenerate = async (e: React.FormEvent) => {
  141. e.preventDefault();
  142. if (!formData.image) {
  143. setError('Please upload an image first');
  144. return;
  145. }
  146. setLoading(true);
  147. setError(null);
  148. setGeneratedImages([]);
  149. setJobInfo(null);
  150. try {
  151. const job = await apiClient.img2img(formData);
  152. setJobInfo(job);
  153. const jobId = job.request_id || job.id;
  154. if (jobId) {
  155. await pollJobStatus(jobId);
  156. } else {
  157. setError('No job ID returned from server');
  158. setLoading(false);
  159. }
  160. } catch (err) {
  161. setError(err instanceof Error ? err.message : 'Failed to generate image');
  162. setLoading(false);
  163. }
  164. };
  165. const handleCancel = async () => {
  166. const jobId = jobInfo?.request_id || jobInfo?.id;
  167. if (jobId) {
  168. try {
  169. await apiClient.cancelJob(jobId);
  170. setLoading(false);
  171. setError('Generation cancelled');
  172. } catch (err) {
  173. console.error('Failed to cancel job:', err);
  174. }
  175. }
  176. };
  177. return (
  178. <AppLayout>
  179. <Header title="Image to Image" description="Transform images with AI using text prompts" />
  180. <div className="container mx-auto p-6">
  181. <div className="grid gap-6 lg:grid-cols-2">
  182. {/* Left Panel - Form */}
  183. <Card>
  184. <CardContent className="pt-6">
  185. <form onSubmit={handleGenerate} className="space-y-4">
  186. <div className="space-y-2">
  187. <Label>Source Image *</Label>
  188. <div className="space-y-4">
  189. {previewImage && (
  190. <div className="relative">
  191. <img
  192. src={previewImage}
  193. alt="Source"
  194. className="w-full rounded-lg border border-border"
  195. />
  196. </div>
  197. )}
  198. <Button
  199. type="button"
  200. variant="outline"
  201. onClick={() => fileInputRef.current?.click()}
  202. className="w-full"
  203. >
  204. <Upload className="h-4 w-4" />
  205. {previewImage ? 'Change Image' : 'Upload Image'}
  206. </Button>
  207. <input
  208. ref={fileInputRef}
  209. type="file"
  210. accept="image/*"
  211. onChange={handleImageUpload}
  212. className="hidden"
  213. />
  214. </div>
  215. </div>
  216. <div className="space-y-2">
  217. <Label htmlFor="prompt">Prompt *</Label>
  218. <PromptTextarea
  219. value={formData.prompt}
  220. onChange={(value) => setFormData({ ...formData, prompt: value })}
  221. placeholder="Describe the transformation you want..."
  222. rows={3}
  223. loras={loraModels}
  224. embeddings={embeddings}
  225. />
  226. <p className="text-xs text-muted-foreground">
  227. Tip: Use &lt;lora:name:weight&gt; for LoRAs and embedding names directly
  228. </p>
  229. </div>
  230. <div className="space-y-2">
  231. <Label htmlFor="negative_prompt">Negative Prompt</Label>
  232. <PromptTextarea
  233. value={formData.negative_prompt || ''}
  234. onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
  235. placeholder="What to avoid..."
  236. rows={2}
  237. loras={loraModels}
  238. embeddings={embeddings}
  239. />
  240. </div>
  241. <div className="space-y-2">
  242. <Label htmlFor="strength">
  243. Strength: {formData.strength.toFixed(2)}
  244. </Label>
  245. <Input
  246. id="strength"
  247. name="strength"
  248. type="range"
  249. value={formData.strength}
  250. onChange={handleInputChange}
  251. min={0}
  252. max={1}
  253. step={0.05}
  254. />
  255. <p className="text-xs text-muted-foreground">
  256. Lower values preserve more of the original image
  257. </p>
  258. </div>
  259. <div className="grid grid-cols-2 gap-4">
  260. <div className="space-y-2">
  261. <Label htmlFor="steps">Steps</Label>
  262. <Input
  263. id="steps"
  264. name="steps"
  265. type="number"
  266. value={formData.steps}
  267. onChange={handleInputChange}
  268. min={1}
  269. max={150}
  270. />
  271. </div>
  272. <div className="space-y-2">
  273. <Label htmlFor="cfg_scale">CFG Scale</Label>
  274. <Input
  275. id="cfg_scale"
  276. name="cfg_scale"
  277. type="number"
  278. value={formData.cfg_scale}
  279. onChange={handleInputChange}
  280. step={0.5}
  281. min={1}
  282. max={30}
  283. />
  284. </div>
  285. </div>
  286. <div className="space-y-2">
  287. <Label htmlFor="seed">Seed (optional)</Label>
  288. <Input
  289. id="seed"
  290. name="seed"
  291. value={formData.seed}
  292. onChange={handleInputChange}
  293. placeholder="Leave empty for random"
  294. />
  295. </div>
  296. <div className="space-y-2">
  297. <Label htmlFor="sampling_method">Sampling Method</Label>
  298. <select
  299. id="sampling_method"
  300. name="sampling_method"
  301. value={formData.sampling_method}
  302. onChange={handleInputChange}
  303. className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  304. >
  305. <option value="euler">Euler</option>
  306. <option value="euler_a">Euler A</option>
  307. <option value="heun">Heun</option>
  308. <option value="dpm2">DPM2</option>
  309. <option value="dpm++2s_a">DPM++ 2S A</option>
  310. <option value="dpm++2m">DPM++ 2M</option>
  311. <option value="dpm++2mv2">DPM++ 2M V2</option>
  312. <option value="lcm">LCM</option>
  313. </select>
  314. </div>
  315. <div className="flex gap-2">
  316. <Button type="submit" disabled={loading || !formData.image} className="flex-1">
  317. {loading ? (
  318. <>
  319. <Loader2 className="h-4 w-4 animate-spin" />
  320. Generating...
  321. </>
  322. ) : (
  323. 'Generate'
  324. )}
  325. </Button>
  326. {loading && (
  327. <Button type="button" variant="destructive" onClick={handleCancel}>
  328. <X className="h-4 w-4" />
  329. Cancel
  330. </Button>
  331. )}
  332. </div>
  333. {error && (
  334. <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
  335. {error}
  336. </div>
  337. )}
  338. {loading && jobInfo && (
  339. <div className="rounded-md bg-muted p-3 text-sm">
  340. <p>Job ID: {jobInfo.id || jobInfo.request_id || 'N/A'}</p>
  341. <p>Status: {jobInfo.status}</p>
  342. {jobInfo.progress !== undefined && (
  343. <p>Progress: {Math.round(jobInfo.progress * 100)}%</p>
  344. )}
  345. </div>
  346. )}
  347. </form>
  348. </CardContent>
  349. </Card>
  350. {/* Right Panel - Generated Images */}
  351. <Card>
  352. <CardContent className="pt-6">
  353. <div className="space-y-4">
  354. <h3 className="text-lg font-semibold">Generated Images</h3>
  355. {generatedImages.length === 0 ? (
  356. <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
  357. <p className="text-muted-foreground">
  358. {loading ? 'Generating...' : 'Generated images will appear here'}
  359. </p>
  360. </div>
  361. ) : (
  362. <div className="grid gap-4">
  363. {generatedImages.map((image, index) => (
  364. <div key={index} className="relative group">
  365. <img
  366. src={image}
  367. alt={`Generated ${index + 1}`}
  368. className="w-full rounded-lg border border-border"
  369. />
  370. <Button
  371. size="icon"
  372. variant="secondary"
  373. className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
  374. onClick={() => {
  375. const authToken = localStorage.getItem('auth_token');
  376. const unixUser = localStorage.getItem('unix_user');
  377. downloadAuthenticatedImage(image, `img2img-${Date.now()}-${index}.png`, authToken || undefined, unixUser || undefined)
  378. .catch(err => {
  379. console.error('Failed to download image:', err);
  380. // Fallback to regular download if authenticated download fails
  381. downloadImage(image, `img2img-${Date.now()}-${index}.png`);
  382. });
  383. }}
  384. >
  385. <Download className="h-4 w-4" />
  386. </Button>
  387. </div>
  388. ))}
  389. </div>
  390. )}
  391. </div>
  392. </CardContent>
  393. </Card>
  394. </div>
  395. </div>
  396. </AppLayout>
  397. );
  398. }