page.tsx 24 KB

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