page.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  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. const base64 = await fileToBase64(file);
  141. setUploadedImage(base64);
  142. setPreviewImage(base64);
  143. setError(null);
  144. } catch {
  145. setError("Failed to load image");
  146. }
  147. };
  148. const pollJobStatus = async (jobId: string) => {
  149. const maxAttempts = 300;
  150. let attempts = 0;
  151. let isPolling = true;
  152. let timeoutId: NodeJS.Timeout | null = null;
  153. const poll = async () => {
  154. if (!isPolling) return;
  155. try {
  156. const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
  157. setJobInfo(status.job);
  158. if (status.job.status === "completed") {
  159. let imageUrls: string[] = [];
  160. // Handle both old format (result.images) and new format (outputs)
  161. if (status.job.outputs && status.job.outputs.length > 0) {
  162. // New format: convert output URLs to authenticated image URLs with cache-busting
  163. imageUrls = status.job.outputs.map((output: { filename: string }) => {
  164. const filename = output.filename;
  165. return apiClient.getImageUrl(jobId, filename);
  166. });
  167. } else if (
  168. status.job.result?.images &&
  169. status.job.result.images.length > 0
  170. ) {
  171. // Old format: convert image URLs to authenticated URLs
  172. imageUrls = status.job.result.images.map((imageUrl: string) => {
  173. // Extract filename from URL if it's already a full URL
  174. if (imageUrl.includes("/output/")) {
  175. const parts = imageUrl.split("/output/");
  176. if (parts.length === 2) {
  177. const filename = parts[1].split("?")[0]; // Remove query params
  178. return apiClient.getImageUrl(jobId, filename);
  179. }
  180. }
  181. // If it's just a filename, convert it directly
  182. return apiClient.getImageUrl(jobId, imageUrl);
  183. });
  184. }
  185. // Create a new array to trigger React re-render
  186. setGeneratedImages([...imageUrls]);
  187. setLoading(false);
  188. isPolling = false;
  189. } else if (status.job.status === "failed") {
  190. setError(status.job.error_message || status.job.error || "Upscaling failed");
  191. setLoading(false);
  192. isPolling = false;
  193. } else if (status.job.status === "cancelled") {
  194. setError("Upscaling was cancelled");
  195. setLoading(false);
  196. isPolling = false;
  197. } else if (attempts < maxAttempts) {
  198. attempts++;
  199. timeoutId = setTimeout(poll, 2000);
  200. } else {
  201. setError("Job polling timeout");
  202. setLoading(false);
  203. isPolling = false;
  204. }
  205. } catch {
  206. if (isPolling) {
  207. setError("Failed to check job status");
  208. setLoading(false);
  209. isPolling = false;
  210. }
  211. }
  212. };
  213. poll();
  214. // Return cleanup function
  215. return () => {
  216. isPolling = false;
  217. if (timeoutId) {
  218. clearTimeout(timeoutId);
  219. }
  220. };
  221. };
  222. const handleUpscale = async (e: React.FormEvent) => {
  223. e.preventDefault();
  224. if (!uploadedImage) {
  225. setError("Please upload an image first");
  226. return;
  227. }
  228. setLoading(true);
  229. setError(null);
  230. setGeneratedImages([]);
  231. setJobInfo(null);
  232. try {
  233. // Validate model selection
  234. if (!formData.model) {
  235. setError("Please select an upscaler model");
  236. setLoading(false);
  237. return;
  238. }
  239. // Unload all currently loaded models and load the selected upscaler model
  240. const selectedModel = upscalerModels.find(m =>
  241. m.sha256_short === formData.model ||
  242. m.sha256 === formData.model ||
  243. m.id === formData.model ||
  244. m.name === formData.model
  245. );
  246. const modelId = selectedModel?.sha256_short || selectedModel?.sha256 || selectedModel?.id;
  247. if (!selectedModel) {
  248. setError("Selected upscaler model not found.");
  249. setLoading(false);
  250. return;
  251. }
  252. if (!modelId) {
  253. setError("Selected upscaler model does not have a hash. Please compute the hash on the models page.");
  254. setLoading(false);
  255. return;
  256. }
  257. try {
  258. // Get all loaded models
  259. const loadedModels = await apiClient.getAllModels(undefined, true);
  260. // Unload all loaded models
  261. for (const model of loadedModels) {
  262. const unloadId = model.sha256_short || model.sha256 || model.id;
  263. if (unloadId) {
  264. try {
  265. await apiClient.unloadModel(unloadId);
  266. } catch (unloadErr) {
  267. console.warn(`Failed to unload model ${model.name}:`, unloadErr);
  268. // Continue with others
  269. }
  270. }
  271. }
  272. // Load the selected upscaler model
  273. await apiClient.loadModel(modelId);
  274. } catch (modelErr) {
  275. console.error("Failed to prepare upscaler model:", modelErr);
  276. setError("Failed to prepare upscaler model. Please try again.");
  277. setLoading(false);
  278. return;
  279. }
  280. const job = await apiClient.upscale({
  281. image: uploadedImage,
  282. model: modelId, // Use the hash ID instead of name
  283. upscale_factor: formData.upscale_factor,
  284. });
  285. setJobInfo(job);
  286. const jobId = job.request_id || job.id;
  287. if (jobId) {
  288. const cleanup = pollJobStatus(jobId);
  289. setPollCleanup(() => cleanup);
  290. } else {
  291. setError("No job ID returned from server");
  292. setLoading(false);
  293. }
  294. } catch {
  295. setError("Failed to upscale image");
  296. setLoading(false);
  297. }
  298. };
  299. const handleCancel = async () => {
  300. const jobId = jobInfo?.request_id || jobInfo?.id;
  301. if (jobId) {
  302. try {
  303. await apiClient.cancelJob(jobId);
  304. setLoading(false);
  305. setError("Upscaling cancelled");
  306. // Cleanup polling
  307. if (pollCleanup) {
  308. pollCleanup();
  309. setPollCleanup(null);
  310. }
  311. } catch (err) {
  312. console.error("Failed to cancel job:", err);
  313. }
  314. }
  315. };
  316. return (
  317. <AppLayout>
  318. <Header
  319. title="Upscaler"
  320. description="Enhance and upscale your images with AI"
  321. />
  322. <div className="container mx-auto p-6">
  323. <div className="grid gap-6 lg:grid-cols-2">
  324. {/* Left Panel - Form Parameters */}
  325. <div className="space-y-6">
  326. <Card>
  327. <CardContent className="pt-6">
  328. <form onSubmit={handleUpscale} className="space-y-4">
  329. {/* Image Upload Section */}
  330. <div className="space-y-2">
  331. <Label htmlFor="image-upload">Image *</Label>
  332. <div className="space-y-2">
  333. <input
  334. id="image-upload"
  335. type="file"
  336. accept="image/*"
  337. onChange={handleImageUpload}
  338. ref={fileInputRef}
  339. className="hidden"
  340. />
  341. <Button
  342. type="button"
  343. variant="outline"
  344. onClick={() => fileInputRef.current?.click()}
  345. className="w-full"
  346. >
  347. <Upload className="mr-2 h-4 w-4" />
  348. Choose Image File
  349. </Button>
  350. <div className="flex gap-2">
  351. <Input
  352. type="url"
  353. placeholder="Or paste image URL"
  354. value={urlInput}
  355. onChange={(e) => setUrlInput(e.target.value)}
  356. className="flex-1"
  357. />
  358. <Button
  359. type="button"
  360. variant="outline"
  361. onClick={() => loadImageFromUrl(urlInput)}
  362. disabled={!urlInput}
  363. >
  364. Load
  365. </Button>
  366. </div>
  367. </div>
  368. </div>
  369. {/* Model Selection */}
  370. <div className="space-y-2">
  371. <Label htmlFor="model">Upscaler Model</Label>
  372. <Select
  373. value={formData.model}
  374. onValueChange={(value) =>
  375. setFormData((prev) => ({ ...prev, model: value }))
  376. }
  377. >
  378. <SelectTrigger>
  379. <SelectValue placeholder="Select upscaler model" />
  380. </SelectTrigger>
  381. <SelectContent>
  382. {upscalerModels.map((model) => {
  383. const modelId = model.sha256_short || model.sha256 || model.id || model.name;
  384. const displayName = model.sha256_short
  385. ? `${model.name} (${model.sha256_short})`
  386. : model.name;
  387. return (
  388. <SelectItem key={modelId} value={modelId}>
  389. {displayName}
  390. </SelectItem>
  391. );
  392. })}
  393. </SelectContent>
  394. </Select>
  395. {modelsLoading && (
  396. <p className="text-sm text-muted-foreground">Loading models...</p>
  397. )}
  398. </div>
  399. {/* Upscale Factor */}
  400. <div className="space-y-2">
  401. <Label htmlFor="upscale_factor">
  402. Upscale Factor *
  403. <span className="text-xs text-muted-foreground ml-1">
  404. ({formData.upscale_factor}x)
  405. </span>
  406. </Label>
  407. <input
  408. id="upscale_factor"
  409. name="upscale_factor"
  410. type="range"
  411. min="2"
  412. max="8"
  413. step="1"
  414. value={formData.upscale_factor}
  415. onChange={(e) =>
  416. setFormData((prev) => ({
  417. ...prev,
  418. upscale_factor: Number(e.target.value),
  419. }))
  420. }
  421. className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
  422. />
  423. <div className="flex justify-between text-xs text-muted-foreground">
  424. <span>2x</span>
  425. <span>8x</span>
  426. </div>
  427. </div>
  428. {/* Generate Button */}
  429. <Button type="submit" disabled={loading || !uploadedImage} className="w-full">
  430. {loading ? (
  431. <>
  432. <Loader2 className="mr-2 h-4 w-4 animate-spin" />
  433. Upscaling...
  434. </>
  435. ) : (
  436. "Upscale Image"
  437. )}
  438. </Button>
  439. {/* Cancel Button */}
  440. {jobInfo && (
  441. <Button
  442. type="button"
  443. variant="outline"
  444. onClick={handleCancel}
  445. disabled={!loading}
  446. className="w-full"
  447. >
  448. <X className="h-4 w-4 mr-2" />
  449. Cancel
  450. </Button>
  451. )}
  452. {/* Error Display */}
  453. {error && (
  454. <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
  455. {error}
  456. </div>
  457. )}
  458. </form>
  459. </CardContent>
  460. </Card>
  461. </div>
  462. {/* Right Panel - Image Preview and Results */}
  463. <div className="space-y-6">
  464. {/* Image Preview */}
  465. <Card>
  466. <CardContent className="pt-6">
  467. <div className="space-y-4">
  468. <h3 className="text-lg font-semibold">Image Preview</h3>
  469. {previewImage ? (
  470. <div className="relative">
  471. <img
  472. src={previewImage}
  473. alt="Preview"
  474. className="w-full rounded-lg border border-border"
  475. />
  476. </div>
  477. ) : (
  478. <div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-border">
  479. <p className="text-muted-foreground">
  480. Upload an image to see preview
  481. </p>
  482. </div>
  483. )}
  484. </div>
  485. </CardContent>
  486. </Card>
  487. {/* Results */}
  488. <Card>
  489. <CardContent className="pt-6">
  490. <div className="space-y-4">
  491. <h3 className="text-lg font-semibold">Upscaled Images</h3>
  492. {generatedImages.length === 0 ? (
  493. <div className="flex h-64 items-center justify-center rounded-lg border-2 border-dashed border-border">
  494. <p className="text-muted-foreground">
  495. {loading
  496. ? "Upscaling in progress..."
  497. : "Upscaled images will appear here"}
  498. </p>
  499. </div>
  500. ) : (
  501. <div className="grid gap-4">
  502. {generatedImages.map((image, index) => (
  503. <div key={index} className="relative group">
  504. <img
  505. src={image}
  506. alt={`Upscaled ${index + 1}`}
  507. className="w-full rounded-lg border border-border"
  508. />
  509. <Button
  510. size="icon"
  511. variant="secondary"
  512. className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
  513. onClick={() => {
  514. const authToken =
  515. localStorage.getItem("auth_token");
  516. const unixUser = localStorage.getItem("unix_user");
  517. downloadAuthenticatedImage(
  518. image,
  519. `upscaled-${Date.now()}-${index}.png`,
  520. authToken || undefined,
  521. unixUser || undefined,
  522. ).catch((err) => {
  523. console.error("Failed to download image:", err);
  524. });
  525. }}
  526. >
  527. <Download className="h-4 w-4" />
  528. </Button>
  529. </div>
  530. ))}
  531. </div>
  532. )}
  533. </div>
  534. </CardContent>
  535. </Card>
  536. </div>
  537. </div>
  538. </div>
  539. </AppLayout>
  540. );
  541. }
  542. export default function UpscalerPage() {
  543. return (
  544. <Suspense fallback={<div>Loading...</div>}>
  545. <UpscalerForm />
  546. </Suspense>
  547. );
  548. }