/* 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)}
/>
{Array.from({ length: 24 }).map((_, i) => (
))}
{dragOver ? "Déposez pour démarrer" : "Glissez vos fichiers ici"}
ou parcourir — images ou archive ZIP (jusqu'à 100 images)
JPEG
PNG
WebP
AVIF
ZIP
);
}
// ============================================================
// 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 (
{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) : "—"}
{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()}>
{
const r = e.currentTarget.getBoundingClientRect();
setPos(Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100)));
}}
>
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)}
ENTRÉE
{fmtBytes(totalIn)}
SORTIE
{fmtBytes(totalOut)}
FORMAT
.{fmt.ext.toUpperCase()}
{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 (
{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 (
);
}
function PhaseStep({ n, label, active, done, accent }) {
return (
{done ? : n.toString().padStart(2, "0")}
{label}
);
}
function PhaseConnector({ done, accent }) {
return (
);
}
// ============================================================
// ROOT APP
// ============================================================
function App() {
const accent = ACCENT;
// phase: upload | setup | processing | results
const [phase, setPhase] = useState("upload");
const [files, setFiles] = useState([]); // local File[] selected by user
const [format, setFormat] = useState("png");
const [jobId, setJobId] = useState(null);
const [jobState, setJobState] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
document.documentElement.dataset.density = "comfortable";
document.documentElement.dataset.ambient = "on";
}, []);
const handleFiles = (selected) => {
setFiles(selected);
setError(null);
setPhase("setup");
};
const handleStart = async () => {
setIsUploading(true);
setError(null);
try {
const fd = new FormData();
for (const f of files) fd.append("files", f);
fd.append("output_format", format);
const resp = await fetch("/upload", { method: "POST", body: fd });
if (!resp.ok) {
let msg = `Erreur HTTP ${resp.status}`;
try { const j = await resp.json(); if (j.detail) msg = j.detail; } catch {}
throw new Error(msg);
}
const data = await resp.json();
setJobId(data.job_id);
setPhase("processing");
} catch (e) {
setError(e.message || String(e));
} finally {
setIsUploading(false);
}
};
// Polling while processing
useEffect(() => {
if (!jobId || (phase !== "processing" && phase !== "results")) return;
let alive = true;
let timer;
const tick = async () => {
try {
const r = await fetch(`/jobs/${jobId}/status`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (!alive) return;
setJobState(data);
if (data.status === "done" || data.status === "failed") {
setPhase(data.status === "done" ? "results" : "processing");
if (data.status === "failed") setError(data.error || "Le traitement a échoué");
return;
}
} catch (e) {
if (!alive) return;
setError(e.message || String(e));
}
timer = setTimeout(tick, POLL_INTERVAL_MS);
};
tick();
return () => { alive = false; if (timer) clearTimeout(timer); };
}, [jobId, phase]);
const handleReset = () => {
setFiles([]);
setJobId(null);
setJobState(null);
setError(null);
setPhase("upload");
};
return (
{phase === "upload" && (
Agrandissez vos images en ×4 sans perte de qualité
Upscaling IA local via le modèle 4xRealWebPhoto_v4. Images individuelles ou archive ZIP — jusqu'à 100 images par lot.
01
Formats acceptés
JPEG · PNG · WebP · AVIF · ZIP
02
Sortie
×4 résolution — ex. 800×800 → 3200×3200
03
Limite
100 images max par lot
)}
{phase === "setup" && (
)}
{phase === "processing" && jobState && (
)}
{phase === "processing" && !jobState && (
)}
{phase === "results" && jobState && (
)}
);
}
// Mount
ReactDOM.createRoot(document.getElementById("root")).render();