page.tsx 23 KB

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