feat(v2): add multiple extra features from the pdf slides

This commit is contained in:
2026-07-09 01:22:02 +03:30
parent ae240e7ac1
commit 20a43d5c9a
16 changed files with 1003 additions and 239 deletions

View File

@@ -1,22 +1,50 @@
import { useEffect, useMemo, useState } from "react";
import CanvasPane from "./components/CanvasPane.jsx";
import CanvasPane, { CanvasThumbnail } from "./components/CanvasPane.jsx";
import Controls from "./components/Controls.jsx";
import HistogramPanel from "./components/HistogramPanel.jsx";
import { createBatch, getJob, processImage, uploadImage } from "./lib/api.js";
import { useDebouncedEffect } from "./lib/debounce.js";
import { applyStateOperation, combineStates, getOperations, listStates, uploadImage } from "./lib/api.js";
function defaultCropParams(state) {
return {
x: 0,
y: 0,
width: state?.width || 256,
height: state?.height || 256
};
}
export default function App() {
const [session, setSession] = useState(null);
const [processed, setProcessed] = useState(null);
const [batchSessions, setBatchSessions] = useState([]);
const [operation, setOperation] = useState("gamma");
const [params, setParams] = useState({ gamma: 1 });
const [states, setStates] = useState([]);
const [activeState, setActiveState] = useState(null);
const [operations, setOperations] = useState([]);
const [selectedOperation, setSelectedOperation] = useState("");
const [params, setParams] = useState({});
const [selectedStateIds, setSelectedStateIds] = useState([]);
const [status, setStatus] = useState("Upload an image to begin.");
const [busy, setBusy] = useState(false);
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
const originalHistogram = session?.original_histogram;
const processedHistogram = processed?.processed_histogram || processed?.result_histogram;
useEffect(() => {
getOperations()
.then((payload) => {
setOperations(payload.operations || []);
const first = payload.operations?.find((operation) => operation.id === "crop") || payload.operations?.[0];
if (first) {
setSelectedOperation(first.id);
setParams(Object.fromEntries(Object.entries(first.params || {}).map(([key, schema]) => [key, schema.default])));
}
})
.catch((error) => setStatus(error.message));
}, []);
async function refreshStates(sessionId, nextActiveId = null) {
const payload = await listStates(sessionId);
setStates(payload.states || []);
const nextActive = payload.states?.find((state) => state.state_id === nextActiveId) || payload.states?.at(-1) || null;
setActiveState(nextActive);
return nextActive;
}
async function handleUpload(file) {
if (!file) return;
@@ -25,10 +53,13 @@ export default function App() {
try {
const payload = await uploadImage(file);
setSession(payload);
setProcessed(null);
setBatchSessions([payload.session_id]);
const initialStates = payload.states || [];
setStates(initialStates);
setActiveState(initialStates[0] || null);
setSelectedStateIds(initialStates[0] ? [initialStates[0].state_id] : []);
setTransform({ x: 0, y: 0, scale: 1 });
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded.`);
setStatus(`${payload.width} x ${payload.height} ${payload.color_mode} image loaded as S0.`);
if (selectedOperation === "crop") setParams(defaultCropParams(initialStates[0]));
} catch (error) {
setStatus(error.message);
} finally {
@@ -36,14 +67,24 @@ export default function App() {
}
}
async function handleBatchUpload(file) {
if (!file) return;
function handleSelectOperation(operationId, nextParams) {
setSelectedOperation(operationId);
setParams(operationId === "crop" ? { ...nextParams, ...defaultCropParams(activeState) } : nextParams);
}
function handleParamChange(key, value) {
setParams((current) => ({ ...current, [key]: value }));
}
async function handleApply() {
if (!activeState || !selectedOperation) return;
setBusy(true);
setStatus("Uploading batch image...");
setStatus(`Applying ${selectedOperation} to ${activeState.label}...`);
try {
const payload = await uploadImage(file);
setBatchSessions((current) => [...current, payload.session_id]);
setStatus("Batch image added.");
const state = await applyStateOperation(activeState.state_id, selectedOperation, params);
await refreshStates(state.session_id, state.state_id);
setSelectedStateIds([state.state_id]);
setStatus(`${state.label} created.`);
} catch (error) {
setStatus(error.message);
} finally {
@@ -51,14 +92,15 @@ export default function App() {
}
}
async function runBatch(kind) {
async function handleCombine(kind) {
if (selectedStateIds.length < 2) return;
setBusy(true);
setStatus(`Starting ${kind} job...`);
setStatus(`Combining ${selectedStateIds.length} states using ${kind}...`);
try {
const job = await createBatch(kind, batchSessions);
const result = await pollJob(job.job_id);
setProcessed(result);
setStatus(`${kind} complete.`);
const state = await combineStates(kind, selectedStateIds);
await refreshStates(state.session_id, state.state_id);
setSelectedStateIds([state.state_id]);
setStatus(`${state.label} created.`);
} catch (error) {
setStatus(error.message);
} finally {
@@ -66,86 +108,53 @@ export default function App() {
}
}
async function pollJob(jobId) {
for (let attempt = 0; attempt < 80; attempt += 1) {
const job = await getJob(jobId);
setStatus(`Job ${job.status}: ${job.progress}%`);
if (job.status === "complete") return job;
if (job.status === "failed") throw new Error(job.error || "Batch job failed");
await new Promise((resolve) => window.setTimeout(resolve, 1000));
}
throw new Error("Batch job timed out.");
function toggleCombineState(stateId) {
setSelectedStateIds((current) => current.includes(stateId) ? current.filter((id) => id !== stateId) : [...current, stateId]);
}
useDebouncedEffect(
() => {
if (!session?.session_id || !operation) return;
let cancelled = false;
async function run() {
setBusy(true);
setStatus(`Processing ${operation}...`);
try {
const payload = await processImage(session.session_id, operation, params);
if (!cancelled) {
setProcessed(payload);
setStatus(`${operation} complete in ${payload.elapsed_ms} ms.`);
}
} catch (error) {
if (!cancelled) setStatus(error.message);
} finally {
if (!cancelled) setBusy(false);
}
}
run();
return () => {
cancelled = true;
};
},
[session?.session_id, operation, JSON.stringify(params)],
300
);
const processedImage = processed?.image_data || session?.image_data;
const originalImage = session?.image_data;
const originalState = states[0] || null;
const viewportTitle = useMemo(() => {
if (!session) return "No image";
return `${session.width} x ${session.height} ${session.color_mode}`;
}, [session]);
if (!activeState) return "No active state";
return `${activeState.label} · ${activeState.width} x ${activeState.height} ${activeState.color_mode}`;
}, [activeState]);
return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:flex-row">
<Controls
selected={operation}
operations={operations}
selectedOperation={selectedOperation}
params={params}
onOperationChange={(nextOperation, nextParams) => {
setOperation(nextOperation);
setParams(nextParams);
}}
onParamChange={(key, value) => setParams((current) => ({ ...current, [key]: value }))}
onUpload={handleUpload}
onBatchUpload={handleBatchUpload}
batchCount={batchSessions.length}
onBatchRun={runBatch}
disabled={!session || busy}
states={states}
activeState={activeState}
selectedStateIds={selectedStateIds}
busy={busy}
onUpload={handleUpload}
onSelectOperation={handleSelectOperation}
onParamChange={handleParamChange}
onApply={handleApply}
onSelectState={(state) => {
setActiveState(state);
if (selectedOperation === "crop") setParams(defaultCropParams(state));
}}
onToggleCombineState={toggleCombineState}
onCombine={handleCombine}
/>
<main className="flex min-h-0 flex-1 flex-col">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-800 bg-zinc-950 px-5 py-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professional Dark Studio</p>
<p className="text-xs uppercase tracking-[0.18em] text-cyan-300">Professor Slide Workspace</p>
<h2 className="text-sm font-medium text-zinc-200">{viewportTitle}</h2>
</div>
<div className="text-sm text-zinc-400">{busy ? "Working..." : status}</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-px bg-zinc-800 lg:grid-cols-2">
<CanvasPane title="Original" imageData={originalImage} histogram={originalHistogram} transform={transform} onTransform={setTransform} />
<CanvasPane title="Processed" imageData={processedImage} histogram={processedHistogram} transform={transform} onTransform={setTransform} />
<div className="relative min-h-0 flex-1 bg-zinc-800">
<CanvasThumbnail title="S0 Original" imageData={originalState?.image_data} />
<CanvasPane title="Active State" imageData={activeState?.image_data} histogram={activeState?.histogram} transform={transform} onTransform={setTransform} />
</div>
<HistogramPanel original={originalHistogram} processed={processedHistogram} />
<HistogramPanel original={originalState?.histogram} processed={activeState?.histogram} />
</main>
</div>
);