feat(v2): add multiple extra features from the pdf slides
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user