page.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. 'use client';
  2. import { useState, useRef, useEffect } from 'react';
  3. import { Header, AppLayout } from '@/components/layout';
  4. import { Button } from '@/components/ui/button';
  5. import { Input } from '@/components/ui/input';
  6. import { Textarea } from '@/components/ui/textarea';
  7. import { PromptTextarea } from '@/components/forms';
  8. import { Label } from '@/components/ui/label';
  9. import { Card, CardContent } from '@/components/ui/card';
  10. import { ImageInput } from '@/components/ui/image-input';
  11. import { apiClient, type JobInfo } from '@/lib/api';
  12. import { Loader2, Download, X } from 'lucide-react';
  13. import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils';
  14. import { useLocalStorage, useMemoryStorage } from '@/lib/storage';
  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. width?: number;
  25. height?: number;
  26. };
  27. const defaultFormData: Img2ImgFormData = {
  28. prompt: '',
  29. negative_prompt: '',
  30. image: '',
  31. strength: 0.75,
  32. steps: 20,
  33. cfg_scale: 7.5,
  34. seed: '',
  35. sampling_method: 'euler_a',
  36. width: 512,
  37. height: 512,
  38. };
  39. function Img2ImgForm() {
  40. // Store form data without the image to avoid localStorage quota issues
  41. const { image: _, ...formDataWithoutImage } = defaultFormData;
  42. const [formData, setFormData] = useLocalStorage<Omit<Img2ImgFormData, 'image'>>(
  43. 'img2img-form-data',
  44. formDataWithoutImage
  45. );
  46. // Store image separately in memory
  47. const [imageData, setImageData] = useMemoryStorage<string>('');
  48. // Combined form data with image
  49. const fullFormData = { ...formData, image: imageData };
  50. const [loading, setLoading] = useState(false);
  51. const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
  52. const [generatedImages, setGeneratedImages] = useState<string[]>([]);
  53. const [previewImage, setPreviewImage] = useState<string | null>(null);
  54. const [loraModels, setLoraModels] = useState<string[]>([]);
  55. const [embeddings, setEmbeddings] = useState<string[]>([]);
  56. const [selectedImage, setSelectedImage] = useState<File | string | null>(null);
  57. const [imageValidation, setImageValidation] = useState<any>(null);
  58. const [originalImage, setOriginalImage] = useState<string | null>(null);
  59. const [isResizing, setIsResizing] = useState(false);
  60. const [error, setError] = useState<string | null>(null);
  61. const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
  62. // Cleanup polling on unmount
  63. useEffect(() => {
  64. return () => {
  65. if (pollCleanup) {
  66. pollCleanup();
  67. }
  68. };
  69. }, [pollCleanup]);
  70. useEffect(() => {
  71. const loadModels = async () => {
  72. try {
  73. const [loras, embeds] = await Promise.all([
  74. apiClient.getModels('lora'),
  75. apiClient.getModels('embedding'),
  76. ]);
  77. setLoraModels(loras.models.map(m => m.name));
  78. setEmbeddings(embeds.models.map(m => m.name));
  79. } catch (err) {
  80. console.error('Failed to load models:', err);
  81. }
  82. };
  83. loadModels();
  84. }, []);
  85. const handleInputChange = (
  86. e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
  87. ) => {
  88. const { name, value } = e.target;
  89. setFormData((prev) => ({
  90. ...prev,
  91. [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method'
  92. ? value
  93. : Number(value),
  94. }));
  95. };
  96. const handleImageChange = async (image: File | string | null) => {
  97. setSelectedImage(image);
  98. setError(null);
  99. if (!image) {
  100. setImageData('');
  101. setFormData(prev => ({ ...prev, image: '' }));
  102. setPreviewImage(null);
  103. setImageValidation(null);
  104. setOriginalImage(null);
  105. return;
  106. }
  107. try {
  108. let imageBase64: string;
  109. let previewUrl: string;
  110. if (image instanceof File) {
  111. // Convert File to base64
  112. imageBase64 = await fileToBase64(image);
  113. previewUrl = imageBase64;
  114. } else {
  115. // Use URL directly
  116. imageBase64 = image;
  117. previewUrl = image;
  118. }
  119. // Store original image for resizing
  120. setOriginalImage(imageBase64);
  121. setImageData(imageBase64);
  122. setFormData(prev => ({ ...prev })); // Don't store image in localStorage
  123. setPreviewImage(previewUrl);
  124. } catch (err) {
  125. setError('Failed to process image');
  126. console.error('Image processing error:', err);
  127. }
  128. };
  129. // Auto-resize image when width or height changes
  130. useEffect(() => {
  131. const resizeImage = async () => {
  132. if (!originalImage || !formData.width || !formData.height) {
  133. return;
  134. }
  135. // Don't resize if we're already resizing
  136. if (isResizing) {
  137. return;
  138. }
  139. try {
  140. setIsResizing(true);
  141. // Validate image data before sending
  142. if (!originalImage.startsWith('data:image/')) {
  143. console.warn('Invalid image data for resizing');
  144. return;
  145. }
  146. const result = await apiClient.resizeImage(originalImage, formData.width, formData.height);
  147. setImageData(result.image);
  148. setFormData(prev => ({ ...prev })); // Don't store image in localStorage
  149. setPreviewImage(result.image);
  150. } catch (err) {
  151. console.error('Failed to resize image:', err);
  152. setError('Failed to resize image - you may need to resize manually');
  153. } finally {
  154. setIsResizing(false);
  155. }
  156. };
  157. resizeImage();
  158. }, [formData.width, formData.height, originalImage]);
  159. const handleImageValidation = (result: any) => {
  160. setImageValidation(result);
  161. if (!result.isValid) {
  162. setError(result.error || 'Invalid image');
  163. } else {
  164. setError(null);
  165. // If we have base64 data from URL download, use it for preview
  166. if (result.base64Data && selectedImage && typeof selectedImage === 'string') {
  167. setPreviewImage(result.base64Data);
  168. setFormData(prev => ({ ...prev, image: result.base64Data }));
  169. setOriginalImage(result.base64Data);
  170. }
  171. }
  172. };
  173. const pollJobStatus = async (jobId: string) => {
  174. const maxAttempts = 300;
  175. let attempts = 0;
  176. let isPolling = true;
  177. const poll = async () => {
  178. if (!isPolling) return;
  179. try {
  180. const status = await apiClient.getJobStatus(jobId);
  181. setJobInfo(status);
  182. if (status.status === 'completed') {
  183. let imageUrls: string[] = [];
  184. // Handle both old format (result.images) and new format (outputs)
  185. if (status.outputs && status.outputs.length > 0) {
  186. // New format: convert output URLs to authenticated image URLs with cache-busting
  187. imageUrls = status.outputs.map((output: any) => {
  188. const filename = output.filename;
  189. return apiClient.getImageUrl(jobId, filename);
  190. });
  191. } else if (status.result?.images && status.result.images.length > 0) {
  192. // Old format: convert image URLs to authenticated URLs
  193. imageUrls = status.result.images.map((imageUrl: string) => {
  194. // Extract filename from URL if it's already a full URL
  195. if (imageUrl.includes('/output/')) {
  196. const parts = imageUrl.split('/output/');
  197. if (parts.length === 2) {
  198. const filename = parts[1].split('?')[0]; // Remove query params
  199. return apiClient.getImageUrl(jobId, filename);
  200. }
  201. }
  202. // If it's just a filename, convert it directly
  203. return apiClient.getImageUrl(jobId, imageUrl);
  204. });
  205. }
  206. // Create a new array to trigger React re-render
  207. setGeneratedImages([...imageUrls]);
  208. setLoading(false);
  209. isPolling = false;
  210. } else if (status.status === 'failed') {
  211. setError(status.error || 'Generation failed');
  212. setLoading(false);
  213. isPolling = false;
  214. } else if (status.status === 'cancelled') {
  215. setError('Generation was cancelled');
  216. setLoading(false);
  217. isPolling = false;
  218. } else if (attempts < maxAttempts) {
  219. attempts++;
  220. setTimeout(poll, 2000);
  221. } else {
  222. setError('Job polling timeout');
  223. setLoading(false);
  224. isPolling = false;
  225. }
  226. } catch (err) {
  227. if (isPolling) {
  228. setError(err instanceof Error ? err.message : 'Failed to check job status');
  229. setLoading(false);
  230. isPolling = false;
  231. }
  232. }
  233. };
  234. poll();
  235. // Return cleanup function
  236. return () => {
  237. isPolling = false;
  238. };
  239. };
  240. const handleGenerate = async (e: React.FormEvent) => {
  241. e.preventDefault();
  242. if (!fullFormData.image) {
  243. setError('Please upload or select an image first');
  244. return;
  245. }
  246. // Check if image validation passed
  247. if (imageValidation && !imageValidation.isValid) {
  248. setError('Please fix the image validation errors before generating');
  249. return;
  250. }
  251. setLoading(true);
  252. setError(null);
  253. setGeneratedImages([]);
  254. setJobInfo(null);
  255. try {
  256. const requestData = {
  257. ...fullFormData,
  258. };
  259. const job = await apiClient.img2img(requestData);
  260. setJobInfo(job);
  261. const jobId = job.request_id || job.id;
  262. if (jobId) {
  263. const cleanup = pollJobStatus(jobId);
  264. setPollCleanup(() => cleanup);
  265. } else {
  266. setError('No job ID returned from server');
  267. setLoading(false);
  268. }
  269. } catch (err) {
  270. setError(err instanceof Error ? err.message : 'Failed to generate image');
  271. setLoading(false);
  272. }
  273. };
  274. const handleCancel = async () => {
  275. const jobId = jobInfo?.request_id || jobInfo?.id;
  276. if (jobId) {
  277. try {
  278. await apiClient.cancelJob(jobId);
  279. setLoading(false);
  280. setError('Generation cancelled');
  281. // Cleanup polling
  282. if (pollCleanup) {
  283. pollCleanup();
  284. setPollCleanup(null);
  285. }
  286. } catch (err) {
  287. console.error('Failed to cancel job:', err);
  288. }
  289. }
  290. };
  291. return (
  292. <AppLayout>
  293. <Header title="Image to Image" description="Transform images with AI using text prompts" />
  294. <div className="container mx-auto p-6">
  295. <div className="grid gap-6 lg:grid-cols-2">
  296. {/* Left Panel - Form */}
  297. <Card>
  298. <CardContent className="pt-6">
  299. <form onSubmit={handleGenerate} className="space-y-4">
  300. <div className="space-y-2">
  301. <Label>Source Image *</Label>
  302. <ImageInput
  303. value={selectedImage}
  304. onChange={handleImageChange}
  305. onValidation={handleImageValidation}
  306. disabled={loading}
  307. maxSize={10 * 1024 * 1024} // 10MB
  308. accept="image/*"
  309. placeholder="Enter image URL or select a file"
  310. showPreview={true}
  311. />
  312. </div>
  313. <div className="space-y-2">
  314. <Label htmlFor="prompt">Prompt *</Label>
  315. <PromptTextarea
  316. value={formData.prompt}
  317. onChange={(value) => setFormData({ ...formData, prompt: value })}
  318. placeholder="Describe the transformation you want..."
  319. rows={3}
  320. loras={loraModels}
  321. embeddings={embeddings}
  322. />
  323. <p className="text-xs text-muted-foreground">
  324. Tip: Use &lt;lora:name:weight&gt; for LoRAs and embedding names directly
  325. </p>
  326. </div>
  327. <div className="space-y-2">
  328. <Label htmlFor="negative_prompt">Negative Prompt</Label>
  329. <PromptTextarea
  330. value={formData.negative_prompt || ''}
  331. onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
  332. placeholder="What to avoid..."
  333. rows={2}
  334. loras={loraModels}
  335. embeddings={embeddings}
  336. />
  337. </div>
  338. <div className="space-y-2">
  339. <Label htmlFor="strength">
  340. Strength: {formData.strength.toFixed(2)}
  341. </Label>
  342. <Input
  343. id="strength"
  344. name="strength"
  345. type="range"
  346. value={formData.strength}
  347. onChange={handleInputChange}
  348. min={0}
  349. max={1}
  350. step={0.05}
  351. />
  352. <p className="text-xs text-muted-foreground">
  353. Lower values preserve more of the original image
  354. </p>
  355. </div>
  356. <div className="grid grid-cols-2 gap-4">
  357. <div className="space-y-2">
  358. <Label htmlFor="width">Width</Label>
  359. <Input
  360. id="width"
  361. name="width"
  362. type="number"
  363. value={formData.width}
  364. onChange={handleInputChange}
  365. step={64}
  366. min={256}
  367. max={2048}
  368. disabled={isResizing}
  369. />
  370. </div>
  371. <div className="space-y-2">
  372. <Label htmlFor="height">Height</Label>
  373. <Input
  374. id="height"
  375. name="height"
  376. type="number"
  377. value={formData.height}
  378. onChange={handleInputChange}
  379. step={64}
  380. min={256}
  381. max={2048}
  382. disabled={isResizing}
  383. />
  384. </div>
  385. </div>
  386. {isResizing && (
  387. <div className="text-sm text-muted-foreground flex items-center gap-2">
  388. <Loader2 className="h-4 w-4 animate-spin" />
  389. Resizing image...
  390. </div>
  391. )}
  392. <div className="grid grid-cols-2 gap-4">
  393. <div className="space-y-2">
  394. <Label htmlFor="steps">Steps</Label>
  395. <Input
  396. id="steps"
  397. name="steps"
  398. type="number"
  399. value={formData.steps}
  400. onChange={handleInputChange}
  401. min={1}
  402. max={150}
  403. />
  404. </div>
  405. <div className="space-y-2">
  406. <Label htmlFor="cfg_scale">CFG Scale</Label>
  407. <Input
  408. id="cfg_scale"
  409. name="cfg_scale"
  410. type="number"
  411. value={formData.cfg_scale}
  412. onChange={handleInputChange}
  413. step={0.5}
  414. min={1}
  415. max={30}
  416. />
  417. </div>
  418. </div>
  419. <div className="space-y-2">
  420. <Label htmlFor="seed">Seed (optional)</Label>
  421. <Input
  422. id="seed"
  423. name="seed"
  424. value={formData.seed}
  425. onChange={handleInputChange}
  426. placeholder="Leave empty for random"
  427. />
  428. </div>
  429. <div className="space-y-2">
  430. <Label htmlFor="sampling_method">Sampling Method</Label>
  431. <select
  432. id="sampling_method"
  433. name="sampling_method"
  434. value={formData.sampling_method}
  435. onChange={handleInputChange}
  436. className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
  437. >
  438. <option value="euler">Euler</option>
  439. <option value="euler_a">Euler A</option>
  440. <option value="heun">Heun</option>
  441. <option value="dpm2">DPM2</option>
  442. <option value="dpm++2s_a">DPM++ 2S A</option>
  443. <option value="dpm++2m">DPM++ 2M</option>
  444. <option value="dpm++2mv2">DPM++ 2M V2</option>
  445. <option value="lcm">LCM</option>
  446. </select>
  447. </div>
  448. <div className="flex gap-2">
  449. <Button type="submit" disabled={loading || !imageData || (imageValidation && !imageValidation.isValid)} className="flex-1">
  450. {loading ? (
  451. <>
  452. <Loader2 className="h-4 w-4 animate-spin" />
  453. Generating...
  454. </>
  455. ) : (
  456. 'Generate'
  457. )}
  458. </Button>
  459. {loading && (
  460. <Button type="button" variant="destructive" onClick={handleCancel}>
  461. <X className="h-4 w-4" />
  462. Cancel
  463. </Button>
  464. )}
  465. </div>
  466. {error && (
  467. <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
  468. {error}
  469. </div>
  470. )}
  471. </form>
  472. </CardContent>
  473. </Card>
  474. {/* Right Panel - Generated Images */}
  475. <Card>
  476. <CardContent className="pt-6">
  477. <div className="space-y-4">
  478. <h3 className="text-lg font-semibold">Generated Images</h3>
  479. {generatedImages.length === 0 ? (
  480. <div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
  481. <p className="text-muted-foreground">
  482. {loading ? 'Generating...' : 'Generated images will appear here'}
  483. </p>
  484. </div>
  485. ) : (
  486. <div className="grid gap-4">
  487. {generatedImages.map((image, index) => (
  488. <div key={index} className="relative group">
  489. <img
  490. src={image}
  491. alt={`Generated ${index + 1}`}
  492. className="w-full rounded-lg border border-border"
  493. />
  494. <Button
  495. size="icon"
  496. variant="secondary"
  497. className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
  498. onClick={() => {
  499. const authToken = localStorage.getItem('auth_token');
  500. const unixUser = localStorage.getItem('unix_user');
  501. downloadAuthenticatedImage(image, `img2img-${Date.now()}-${index}.png`, authToken || undefined, unixUser || undefined)
  502. .catch(err => {
  503. console.error('Failed to download image:', err);
  504. // Fallback to regular download if authenticated download fails
  505. downloadImage(image, `img2img-${Date.now()}-${index}.png`);
  506. });
  507. }}
  508. >
  509. <Download className="h-4 w-4" />
  510. </Button>
  511. </div>
  512. ))}
  513. </div>
  514. )}
  515. </div>
  516. </CardContent>
  517. </Card>
  518. </div>
  519. </div>
  520. </AppLayout>
  521. );
  522. }
  523. export default function Img2ImgPage() {
  524. return <Img2ImgForm />;
  525. }