page.tsx 24 KB

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