page.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. "use client";
  2. import { useState, useRef, useEffect, Suspense } from "react";
  3. import { useSearchParams } from "next/navigation";
  4. import { Button } from "@/components/ui/button";
  5. import { Input } from "@/components/ui/input";
  6. import { Label } from "@/components/ui/label";
  7. import {
  8. Card,
  9. CardContent,
  10. } from "@/components/ui/card";
  11. import {
  12. apiClient,
  13. type JobInfo,
  14. type JobDetailsResponse,
  15. type ModelInfo,
  16. type EnhancedModelsResponse,
  17. } from "@/lib/api";
  18. import { Loader2, Download, X, Upload } from "lucide-react";
  19. import {
  20. downloadAuthenticatedImage,
  21. fileToBase64,
  22. } from "@/lib/utils";
  23. import {
  24. Select,
  25. SelectContent,
  26. SelectItem,
  27. SelectTrigger,
  28. SelectValue,
  29. } from "@/components/ui/select";
  30. import { AppLayout, Header } from "@/components/layout";
  31. type UpscalerFormData = {
  32. upscale_factor: number;
  33. model: string;
  34. };
  35. const defaultFormData: UpscalerFormData = {
  36. upscale_factor: 2,
  37. model: "",
  38. };
  39. function UpscalerForm() {
  40. const searchParams = useSearchParams();
  41. // Simple state management - no complex hooks initially
  42. const [formData, setFormData] = useState<UpscalerFormData>(defaultFormData);
  43. // Separate state for image data (not stored in localStorage)
  44. const [uploadedImage, setUploadedImage] = useState<string>("");
  45. const [previewImage, setPreviewImage] = useState<string | null>(null);
  46. const [loading, setLoading] = useState(false);
  47. const [error, setError] = useState<string | null>(null);
  48. const [jobInfo, setJobInfo] = useState<JobInfo | null>(null);
  49. const [generatedImages, setGeneratedImages] = useState<string[]>([]);
  50. const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
  51. const fileInputRef = useRef<HTMLInputElement>(null);
  52. // URL input state
  53. const [urlInput, setUrlInput] = useState('');
  54. // Local state for upscaler models - no global context to avoid performance issues
  55. const [upscalerModels, setUpscalerModels] = useState<ModelInfo[]>([]);
  56. const [modelsLoading, setModelsLoading] = useState(false);
  57. // Cleanup polling on unmount
  58. useEffect(() => {
  59. return () => {
  60. if (pollCleanup) {
  61. pollCleanup();
  62. }
  63. };
  64. }, [pollCleanup]);
  65. // Load image from URL parameter on mount
  66. useEffect(() => {
  67. const imageUrl = searchParams.get('imageUrl');
  68. if (imageUrl) {
  69. loadImageFromUrl(imageUrl);
  70. }
  71. }, [searchParams]);
  72. // Load upscaler models on mount
  73. useEffect(() => {
  74. let isComponentMounted = true;
  75. const loadModels = async () => {
  76. try {
  77. setModelsLoading(true);
  78. setError(null);
  79. // Set up timeout for API call
  80. const timeoutPromise = new Promise((_, reject) =>
  81. setTimeout(() => reject(new Error('API call timeout')), 5000)
  82. );
  83. const apiPromise = apiClient.getModels("esrgan");
  84. const modelsData = await Promise.race([apiPromise, timeoutPromise]) as EnhancedModelsResponse;
  85. console.log("API call completed, models:", modelsData.models?.length || 0);
  86. if (!isComponentMounted) return;
  87. // Set models locally - no global state updates
  88. setUpscalerModels(modelsData.models || []);
  89. // Set first model as default if none selected
  90. if (modelsData.models?.length > 0 && !formData.model) {
  91. const firstModel = modelsData.models[0];
  92. const modelId = firstModel.sha256_short || firstModel.sha256 || firstModel.id || firstModel.name;
  93. setFormData((prev) => ({
  94. ...prev,
  95. model: modelId,
  96. }));
  97. }
  98. } catch (err) {
  99. console.error("Failed to load upscaler models:", err);
  100. if (isComponentMounted) {
  101. setError(`Failed to load upscaler models: ${err instanceof Error ? err.message : 'Unknown error'}`);
  102. }
  103. } finally {
  104. if (isComponentMounted) {
  105. setModelsLoading(false);
  106. }
  107. }
  108. };
  109. loadModels();
  110. return () => {
  111. isComponentMounted = false;
  112. };
  113. }, []); // eslint-disable-line react-hooks/exhaustive-deps
  114. const loadImageFromUrl = async (url: string) => {
  115. try {
  116. setError(null);
  117. // Fetch the image and convert to base64
  118. const response = await fetch(url);
  119. if (!response.ok) {
  120. throw new Error('Failed to fetch image');
  121. }
  122. const blob = await response.blob();
  123. const base64 = await new Promise<string>((resolve, reject) => {
  124. const reader = new FileReader();
  125. reader.onload = () => resolve(reader.result as string);
  126. reader.onerror = reject;
  127. reader.readAsDataURL(blob);
  128. });
  129. setUploadedImage(base64);
  130. setPreviewImage(base64);
  131. } catch (err) {
  132. console.error('Failed to load image from URL:', err);
  133. setError('Failed to load image from gallery');
  134. }
  135. };
  136. const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  137. const file = e.target.files?.[0];
  138. if (!file) return;
  139. try {
  140. console.log("Processing file:", file.name, file.size, file.type);
  141. const base64 = await fileToBase64(file);
  142. console.log("Base64 result length:", base64.length);
  143. console.log("Base64 prefix:", base64.substring(0, 100));
  144. setUploadedImage(base64);
  145. setPreviewImage(base64);
  146. setError(null);
  147. } catch (error) {
  148. console.error("Image upload error:", error);
  149. setError("Failed to load image");
  150. }
  151. };
  152. const pollJobStatus = async (jobId: string) => {
  153. const maxAttempts = 300;
  154. let attempts = 0;
  155. let isPolling = true;
  156. let timeoutId: NodeJS.Timeout | null = null;
  157. const poll = async () => {
  158. if (!isPolling) return;
  159. try {
  160. const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
  161. setJobInfo(status.job);
  162. if (status.job.status === "completed") {
  163. let imageUrls: string[] = [];
  164. // Handle both old format (result.images) and new format (outputs)
  165. if (status.job.outputs && status.job.outputs.length > 0) {
  166. // New format: convert output URLs to authenticated image URLs with cache-busting
  167. imageUrls = status.job.outputs.map((output: { filename: string }) => {
  168. const filename = output.filename;
  169. return apiClient.getImageUrl(jobId, filename);
  170. });
  171. } else if (
  172. status.job.result?.images &&
  173. status.job.result.images.length > 0
  174. ) {
  175. // Old format: convert image URLs to authenticated URLs
  176. imageUrls = status.job.result.images.map((imageUrl: string) => {
  177. // Extract filename from URL if it's already a full URL
  178. if (imageUrl.includes("/output/")) {
  179. const parts = imageUrl.split("/output/");
  180. if (parts.length === 2) {
  181. const filename = parts[1].split("?")[0]; // Remove query params
  182. return apiClient.getImageUrl(jobId, filename);
  183. }
  184. }
  185. // If it's just a filename, convert it directly
  186. return apiClient.getImageUrl(jobId, imageUrl);
  187. });
  188. }
  189. // Create a new array to trigger React re-render
  190. setGeneratedImages([...imageUrls]);
  191. setLoading(false);
  192. isPolling = false;
  193. } else if (status.job.status === "failed") {
  194. setError(status.job.error_message || status.job.error || "Upscaling failed");
  195. setLoading(false);
  196. isPolling = false;
  197. } else if (status.job.status === "cancelled") {
  198. setError("Upscaling was cancelled");
  199. setLoading(false);
  200. isPolling = false;
  201. } else if (attempts < maxAttempts) {
  202. attempts++;
  203. timeoutId = setTimeout(poll, 2000);
  204. } else {
  205. setError("Job polling timeout");
  206. setLoading(false);
  207. isPolling = false;
  208. }
  209. } catch {
  210. if (isPolling) {
  211. setError("Failed to check job status");
  212. setLoading(false);
  213. isPolling = false;
  214. }
  215. }
  216. };
  217. poll();
  218. // Return cleanup function
  219. return () => {
  220. isPolling = false;
  221. if (timeoutId) {
  222. clearTimeout(timeoutId);
  223. }
  224. };
  225. };
  226. const handleUpscale = async (e: React.FormEvent) => {
  227. e.preventDefault();
  228. if (!uploadedImage) {
  229. setError("Please upload an image first");
  230. return;
  231. }
  232. setLoading(true);
  233. setError(null);
  234. setGeneratedImages([]);
  235. setJobInfo(null);
  236. try {
  237. // Validate model selection
  238. if (!formData.model) {
  239. setError("Please select an upscaler model");
  240. setLoading(false);
  241. return;
  242. }
  243. // Unload all currently loaded models and load the selected upscaler model
  244. const selectedModel = upscalerModels.find(m =>
  245. m.sha256_short === formData.model ||
  246. m.sha256 === formData.model ||
  247. m.id === formData.model ||
  248. m.name === formData.model
  249. );
  250. const modelId = selectedModel?.sha256_short || selectedModel?.sha256 || selectedModel?.id;
  251. if (!selectedModel) {
  252. setError("Selected upscaler model not found.");
  253. setLoading(false);
  254. return;
  255. }
  256. if (!modelId) {
  257. setError("Selected upscaler model does not have a hash. Please compute the hash on the models page.");
  258. setLoading(false);
  259. return;
  260. }
  261. try {
  262. // Get all loaded models
  263. const loadedModels = await apiClient.getAllModels(undefined, true);
  264. // Unload all loaded models
  265. for (const model of loadedModels) {
  266. const unloadId = model.sha256_short || model.sha256 || model.id;
  267. if (unloadId) {
  268. try {
  269. await apiClient.unloadModel(unloadId);
  270. } catch (unloadErr) {
  271. console.warn(`Failed to unload model ${model.name}:`, unloadErr);
  272. // Continue with others
  273. }
  274. }
  275. }
  276. // Load the selected upscaler model
  277. await apiClient.loadModel(modelId);
  278. } catch (modelErr) {
  279. console.error("Failed to prepare upscaler model:", modelErr);
  280. setError("Failed to prepare upscaler model. Please try again.");
  281. setLoading(false);
  282. return;
  283. }
  284. console.log("About to send upscale request:", {
  285. imageLength: uploadedImage.length,
  286. imagePrefix: uploadedImage.substring(0, 100),
  287. model: modelId,
  288. upscale_factor: formData.upscale_factor,
  289. hasImage: !!uploadedImage,
  290. imageStartsWith: uploadedImage.startsWith('data:image') ? 'data-uri' : 'other'
  291. });
  292. if (!uploadedImage || uploadedImage.length === 0) {
  293. setError("No image data available. Please re-upload the image.");
  294. setLoading(false);
  295. return;
  296. }
  297. const job = await apiClient.upscale({
  298. image: uploadedImage,
  299. model: modelId, // Use hash ID instead of name
  300. upscale_factor: formData.upscale_factor,
  301. });
  302. setJobInfo(job);
  303. const jobId = job.request_id || job.id;
  304. if (jobId) {
  305. const cleanup = pollJobStatus(jobId);
  306. setPollCleanup(() => cleanup);
  307. } else {
  308. setError("No job ID returned from server");
  309. setLoading(false);
  310. }
  311. } catch {
  312. setError("Failed to upscale image");
  313. setLoading(false);
  314. }
  315. };
  316. const handleCancel = async () => {
  317. const jobId = jobInfo?.request_id || jobInfo?.id;
  318. if (jobId) {
  319. try {
  320. await apiClient.cancelJob(jobId);
  321. setLoading(false);
  322. setError("Upscaling cancelled");
  323. // Cleanup polling
  324. if (pollCleanup) {
  325. pollCleanup();
  326. setPollCleanup(null);
  327. }
  328. } catch (err) {
  329. console.error("Failed to cancel job:", err);
  330. }
  331. }
  332. };
  333. return (
  334. <AppLayout>
  335. <Header
  336. title="Upscaler"
  337. description="Enhance and upscale your images with AI"
  338. />
  339. <div className="container mx-auto p-6">
  340. <div className="grid gap-6 lg:grid-cols-2">
  341. {/* Left Panel - Form Parameters */}
  342. <div className="space-y-6">
  343. <Card>
  344. <CardContent className="pt-6">
  345. <form onSubmit={handleUpscale} className="space-y-4">
  346. {/* Image Upload Section */}
  347. <div className="space-y-2">
  348. <Label htmlFor="image-upload">Image *</Label>
  349. <div className="space-y-2">
  350. <input
  351. id="image-upload"
  352. type="file"
  353. accept="image/*"
  354. onChange={handleImageUpload}
  355. ref={fileInputRef}
  356. className="hidden"
  357. />
  358. <Button
  359. type="button"
  360. variant="outline"
  361. onClick={() => fileInputRef.current?.click()}
  362. className="w-full"
  363. >
  364. <Upload className="mr-2 h-4 w-4" />
  365. Choose Image File
  366. </Button>
  367. <div className="flex gap-2">
  368. <Input
  369. type="url"
  370. placeholder="Or paste image URL"
  371. value={urlInput}
  372. onChange={(e) => setUrlInput(e.target.value)}
  373. className="flex-1"
  374. />
  375. <Button
  376. type="button"
  377. variant="outline"
  378. onClick={() => loadImageFromUrl(urlInput)}
  379. disabled={!urlInput}
  380. >
  381. Load
  382. </Button>
  383. </div>
  384. </div>
  385. </div>
  386. {/* Model Selection */}
  387. <div className="space-y-2">
  388. <Label htmlFor="model">Upscaler Model</Label>
  389. <Select
  390. value={formData.model}
  391. onValueChange={(value) =>
  392. setFormData((prev) => ({ ...prev, model: value }))
  393. }
  394. >
  395. <SelectTrigger>
  396. <SelectValue placeholder="Select upscaler model" />
  397. </SelectTrigger>
  398. <SelectContent>
  399. {upscalerModels.map((model) => {
  400. const modelId = model.sha256_short || model.sha256 || model.id || model.name;
  401. const displayName = model.sha256_short
  402. ? `${model.name} (${model.sha256_short})`
  403. : model.name;
  404. return (
  405. <SelectItem key={modelId} value={modelId}>
  406. {displayName}
  407. </SelectItem>
  408. );
  409. })}
  410. </SelectContent>
  411. </Select>
  412. {modelsLoading && (
  413. <p className="text-sm text-muted-foreground">Loading models...</p>
  414. )}
  415. </div>
  416. {/* Upscale Factor */}
  417. <div className="space-y-2">
  418. <Label htmlFor="upscale_factor">
  419. Upscale Factor *
  420. <span className="text-xs text-muted-foreground ml-1">
  421. ({formData.upscale_factor}x)
  422. </span>
  423. </Label>
  424. <input
  425. id="upscale_factor"
  426. name="upscale_factor"
  427. type="range"
  428. min="2"
  429. max="8"
  430. step="1"
  431. value={formData.upscale_factor}
  432. onChange={(e) =>
  433. setFormData((prev) => ({
  434. ...prev,
  435. upscale_factor: Number(e.target.value),
  436. }))
  437. }
  438. className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
  439. />
  440. <div className="flex justify-between text-xs text-muted-foreground">
  441. <span>2x</span>
  442. <span>8x</span>
  443. </div>
  444. </div>
  445. {/* Generate Button */}
  446. <Button type="submit" disabled={loading || !uploadedImage} className="w-full">
  447. {loading ? (
  448. <>
  449. <Loader2 className="mr-2 h-4 w-4 animate-spin" />
  450. Upscaling...
  451. </>
  452. ) : (
  453. "Upscale Image"
  454. )}
  455. </Button>
  456. {/* Cancel Button */}
  457. {jobInfo && (
  458. <Button
  459. type="button"
  460. variant="outline"
  461. onClick={handleCancel}
  462. disabled={!loading}
  463. className="w-full"
  464. >
  465. <X className="h-4 w-4 mr-2" />
  466. Cancel
  467. </Button>
  468. )}
  469. {/* Error Display */}
  470. {error && (
  471. <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
  472. {error}
  473. </div>
  474. )}
  475. </form>
  476. </CardContent>
  477. </Card>
  478. </div>
  479. {/* Right Panel - Image Preview and Results */}
  480. <div className="space-y-6">
  481. {/* Image Preview */}
  482. <Card>
  483. <CardContent className="pt-6">
  484. <div className="space-y-4">
  485. <h3 className="text-lg font-semibold">Image Preview</h3>
  486. {previewImage ? (
  487. <div className="relative">
  488. <img
  489. src={previewImage}
  490. alt="Preview"
  491. className="w-full rounded-lg border border-border"
  492. />
  493. </div>
  494. ) : (
  495. <div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-border">
  496. <p className="text-muted-foreground">
  497. Upload an image to see preview
  498. </p>
  499. </div>
  500. )}
  501. </div>
  502. </CardContent>
  503. </Card>
  504. {/* Results */}
  505. <Card>
  506. <CardContent className="pt-6">
  507. <div className="space-y-4">
  508. <h3 className="text-lg font-semibold">Upscaled Images</h3>
  509. {generatedImages.length === 0 ? (
  510. <div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-border">
  511. <p className="text-muted-foreground">
  512. {loading
  513. ? "Upscaling in progress..."
  514. : "Upscaled images will appear here"}
  515. </p>
  516. </div>
  517. ) : (
  518. <div className="grid gap-4">
  519. {generatedImages.map((image, index) => (
  520. <div key={index} className="relative group">
  521. <img
  522. src={image}
  523. alt={`Upscaled ${index + 1}`}
  524. className="w-full rounded-lg border border-border"
  525. />
  526. <Button
  527. size="icon"
  528. variant="secondary"
  529. className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
  530. onClick={() => {
  531. const authToken =
  532. localStorage.getItem("auth_token");
  533. const unixUser = localStorage.getItem("unix_user");
  534. downloadAuthenticatedImage(
  535. image,
  536. `upscaled-${Date.now()}-${index}.png`,
  537. authToken || undefined,
  538. unixUser || undefined,
  539. ).catch((err) => {
  540. console.error("Failed to download image:", err);
  541. });
  542. }}
  543. >
  544. <Download className="h-4 w-4" />
  545. </Button>
  546. </div>
  547. ))}
  548. </div>
  549. )}
  550. </div>
  551. </CardContent>
  552. </Card>
  553. </div>
  554. </div>
  555. </div>
  556. </AppLayout>
  557. );
  558. }
  559. export default function UpscalerPage() {
  560. return (
  561. <Suspense fallback={<div>Loading...</div>}>
  562. <UpscalerForm />
  563. </Suspense>
  564. );
  565. }