/* global React, ReactDOM */ const { useState, useEffect, useRef, useCallback, useMemo } = React; // ============================================================ // CONFIG // ============================================================ const ACCENT = "#3b82f6"; // blue const POLL_INTERVAL_MS = 1500; const FORMAT_OPTIONS = [ { value: "png", label: "PNG — sans perte (gros fichiers)", ext: "png", desc: "Sans perte (gros fichiers)" }, { value: "webp", label: "WebP — qualité 95 (compact)", ext: "webp", desc: "Qualité 95 (compact)" }, { value: "jpg", label: "JPEG — qualité 92 (compact)", ext: "jpg", desc: "Qualité 92 (compact)" }, { value: "avif", label: "AVIF — qualité 80 (très compact)", ext: "avif", desc: "Qualité 80 (très compact)" }, ]; const FALLBACK_GRADIENTS = [ "linear-gradient(135deg, #1e3a8a 0%, #7c3aed 50%, #ec4899 100%)", "linear-gradient(135deg, #064e3b 0%, #0891b2 50%, #67e8f9 100%)", "linear-gradient(135deg, #7c2d12 0%, #ea580c 50%, #fbbf24 100%)", "linear-gradient(135deg, #581c87 0%, #be185d 50%, #fb7185 100%)", "linear-gradient(135deg, #134e4a 0%, #14b8a6 50%, #a7f3d0 100%)", "linear-gradient(135deg, #1e1b4b 0%, #3730a3 50%, #818cf8 100%)", "linear-gradient(135deg, #831843 0%, #db2777 50%, #fbcfe8 100%)", "linear-gradient(135deg, #14532d 0%, #16a34a 50%, #bbf7d0 100%)", "linear-gradient(135deg, #422006 0%, #92400e 50%, #fde68a 100%)", "linear-gradient(135deg, #0c4a6e 0%, #0284c7 50%, #7dd3fc 100%)", "linear-gradient(135deg, #4c1d95 0%, #7e22ce 50%, #c4b5fd 100%)", "linear-gradient(135deg, #18181b 0%, #404040 50%, #a3a3a3 100%)", ]; // ============================================================ // HELPERS // ============================================================ function fmtBytes(b) { if (!b) return "—"; if (b < 1024) return b + " B"; if (b < 1024 * 1024) return (b / 1024).toFixed(1) + " KB"; if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + " MB"; return (b / (1024 * 1024 * 1024)).toFixed(2) + " GB"; } function fmtTime(s) { if (s == null || isNaN(s)) return "—"; if (s < 60) return Math.round(s) + "s"; const m = Math.floor(s / 60); const r = Math.round(s - m * 60); return `${m}m ${r.toString().padStart(2, "0")}s`; } function gradientFor(seed) { let h = 0; for (let i = 0; i < (seed || "").length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; return FALLBACK_GRADIENTS[h % FALLBACK_GRADIENTS.length]; } // ============================================================ // ICONS // ============================================================ const Icon = { Upload: (p) => ( ), Image: (p) => ( ), Archive: (p) => ( ), Download: (p) => ( ), Check: (p) => ( ), X: (p) => ( ), Sparkles: (p) => ( ), Refresh: (p) => ( ), Cpu: (p) => ( ), Trash: (p) => ( ), Eye: (p) => ( ), }; const IMAGE_EXTS = /\.(jpe?g|png|webp|avif)$/i; const ARCHIVE_EXTS = /\.zip$/i; // ============================================================ // DROP ZONE // ============================================================ function DropZone({ onFiles, accent }) { const [dragOver, setDragOver] = useState(false); const inputRef = useRef(null); const handle = (filesList) => { const files = Array.from(filesList || []).filter( (f) => IMAGE_EXTS.test(f.name) || ARCHIVE_EXTS.test(f.name) ); if (!files.length) return; onFiles(files); }; return (
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); handle(e.dataTransfer.files); }} onClick={() => inputRef.current?.click()} role="button" tabIndex={0} > handle(e.target.files)} /> ); } // ============================================================ // SETUP PANEL // ============================================================ function SetupPanel({ files, accent, format, setFormat, onStart, onReset, isUploading, error }) { const isArchive = files.length === 1 && ARCHIVE_EXTS.test(files[0].name); const totalSize = files.reduce((s, f) => s + f.size, 0); // Object URLs for thumbnails (image files only) const thumbs = useMemo(() => { return files.map((f) => (IMAGE_EXTS.test(f.name) ? URL.createObjectURL(f) : null)); }, [files]); useEffect(() => () => thumbs.forEach((u) => u && URL.revokeObjectURL(u)), [thumbs]); return (
{isArchive ? : }
{isArchive ? `Archive ZIP — ${files[0].name}` : `${files.length} image${files.length > 1 ? "s" : ""} prête${files.length > 1 ? "s" : ""}`}
{fmtBytes(totalSize)}
{!isArchive && (
{files.slice(0, 8).map((f, i) => (
))} {files.length > 8 && (
+{files.length - 8}
)}
)}
{FORMAT_OPTIONS.map((opt) => ( ))}
4xRealWebPhoto_v4 (DAT-2)
Optimisé photographie produit & web · ×4 par défaut
×4
{error && (
{error}
)}
); } // ============================================================ // QUEUE ROW (during processing) — driven by API state // ============================================================ function QueueRow({ item, scale, accent, files, jobId }) { const isProcessing = item.status === "processing"; const isDone = item.status === "done"; const isFailed = item.status === "failed"; // Local file thumbnail (URL.createObjectURL) — only for direct file uploads, not ZIP-extracted const localThumb = useMemo(() => { if (!files || item.idx >= files.length) return null; const f = files[item.idx]; if (!f || !IMAGE_EXTS.test(f.name)) return null; return URL.createObjectURL(f); }, [files, item.idx]); useEffect(() => () => localThumb && URL.revokeObjectURL(localThumb), [localThumb]); const thumbBg = localThumb ? `center/cover no-repeat url(${localThumb})` : gradientFor(item.filename); const inW = item.input_w, inH = item.input_h; const outW = inW ? inW * scale : null, outH = inH ? inH * scale : null; const duration = item.duration_ms != null ? item.duration_ms / 1000 : null; // Indeterminate progress for "processing" — we don't have sub-step granularity from PyTorch, // so we show an animated stripe. For done, full bar. For pending/failed, empty. const barWidth = isDone ? "100%" : isProcessing ? "100%" : "0%"; return (
{isDone && (
)}
{item.filename}
{inW && ( <> {inW}×{inH} {outW}×{outH} )} {isDone && ( <> · {fmtTime(duration)} {item.output_size && ( <> · {fmtBytes(item.output_size)} )} )} {isFailed && item.error && ( <> · {item.error} )}
{item.status === "pending" && "EN ATTENTE"} {isProcessing && "EN COURS"} {isFailed && "ÉCHEC"} {isDone && }
); } // ============================================================ // PROCESSING VIEW (driven by API polling) // ============================================================ function ProcessingView({ jobState, files, accent, onCancel }) { const [startTime] = useState(Date.now()); const [elapsed, setElapsed] = useState(0); useEffect(() => { const t = setInterval(() => setElapsed((Date.now() - startTime) / 1000), 250); return () => clearInterval(t); }, [startTime]); const items = jobState.items || []; const total = items.length || jobState.n_images || 1; const doneCount = items.filter((it) => it.status === "done").length; const remaining = total - doneCount; const overallPct = total > 0 ? (doneCount / total) * 100 : 0; const avgPerFile = doneCount > 0 ? elapsed / doneCount : 12; const eta = remaining > 0 ? remaining * avgPerFile : 0; return (
EN COURS
Upscaling x4 · {doneCount}/{total}
PROGRESSION
{Math.round(overallPct)}%
ÉCOULÉ
{fmtTime(elapsed)}
ETA
{eta > 0 ? fmtTime(eta) : "—"}
MODÈLE
4xRealWebPhoto
{items.map((it) => ( ))}
); } // ============================================================ // COMPARE PANEL — real input vs upscaled output // ============================================================ function ComparePanel({ jobId, item, accent, onClose }) { const [pos, setPos] = useState(50); const inputUrl = `/jobs/${jobId}/items/${item.idx}/input`; const outputUrl = `/jobs/${jobId}/items/${item.idx}/download`; return (
e.stopPropagation()}>
{item.filename}
{ const r = e.currentTarget.getBoundingClientRect(); setPos(Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100))); }} > avant après
AVANT · {item.input_w}×{item.input_h}
APRÈS · {item.input_w * 4}×{item.input_h * 4}
‹›
); } // ============================================================ // RESULTS VIEW // ============================================================ function ResultsView({ jobState, files, accent, onReset }) { const [compareIdx, setCompareIdx] = useState(null); const items = jobState.items || []; const totalIn = items.reduce((s, it) => s + (it.input_size || 0), 0); const totalOut = items.reduce((s, it) => s + (it.output_size || 0), 0); const totalDuration = items.reduce((s, it) => s + (it.duration_ms || 0), 0) / 1000; const fmt = FORMAT_OPTIONS.find((o) => o.value === jobState.output_format) || FORMAT_OPTIONS[0]; const bundleUrl = `/jobs/${jobState.id}/download`; return (
Traitement terminé
{items.length} image{items.length > 1 ? "s" : ""} upscalée{items.length > 1 ? "s" : ""} en {fmtTime(totalDuration)}
FICHIERS
{items.length}
ENTRÉE
{fmtBytes(totalIn)}
SORTIE
{fmtBytes(totalOut)}
FORMAT
.{fmt.ext.toUpperCase()}
FACTEUR
×4
{items.map((it) => ( setCompareIdx(it.idx)} /> ))}
{compareIdx !== null && ( it.idx === compareIdx)} accent={accent} onClose={() => setCompareIdx(null)} /> )}
); } function ResultCard({ jobId, item, files, accent, onCompare }) { const outputUrl = `/jobs/${jobId}/items/${item.idx}/download`; const localThumb = useMemo(() => { if (!files || item.idx >= files.length) return null; const f = files[item.idx]; if (!f || !IMAGE_EXTS.test(f.name)) return null; return URL.createObjectURL(f); }, [files, item.idx]); useEffect(() => () => localThumb && URL.revokeObjectURL(localThumb), [localThumb]); // Use the upscaled output as thumbnail (browser scales it down) const thumbBg = item.status === "done" ? `center/cover no-repeat url(${outputUrl})` : (localThumb ? `center/cover no-repeat url(${localThumb})` : gradientFor(item.filename)); return (
×4
{item.filename}
{item.input_w && `${item.input_w * 4}×${item.input_h * 4}`} {item.output_size && ` · ${fmtBytes(item.output_size)}`}
); } // ============================================================ // AMBIENT BG // ============================================================ function AmbientBg({ accent }) { return (