diff --git a/electron/src/renderer/src/components/DriftWizard.tsx b/electron/src/renderer/src/components/DriftWizard.tsx new file mode 100644 index 00000000..d76ecb07 --- /dev/null +++ b/electron/src/renderer/src/components/DriftWizard.tsx @@ -0,0 +1,395 @@ +/** + * DriftWizard.tsx — the Drift Correction caret (`drift_` staged actions, + * backend: spyde/actions/drift_action.py; plan §A8 + §0.9a). + * + * **Two toggles and a button.** The first version of this caret had thirteen + * controls on its face — three model tabs, four numeric fields, three + * checkboxes, Solve/Apply/Cancel — and the review was "way too complicated. + * Too many options. Information overload." Plan §0.9a is the rule that came out + * of it: the default face carries the TASK, not the algorithm. Reference mode, + * sub-pixel factor, max shift, interpolation order and the model tabs all still + * exist, all still reach the backend, and all still land in provenance — they + * live behind the collapsed `Advanced` disclosure, because drift's parameters + * have one right answer we already know. + * + * **What the caret does NOT show.** The dy/dx curve used to be a 40 px inline + * SVG here; it is now its own figure window (`Drift dy/dx`), opened by + * `drift_run` and filled progressively from the solver's `on_shift` stream. A + * sparkline could show that the stage crept 30 px; only a real plot shows WHICH + * frame jumped. The before/after sums stay in the `Drift Check` window, whose + * bottom row is the discovery pair. + * + * **Discovery, not configuration.** The backend puts a draggable box on the + * movie the moment this mounts, aligns ~20 frames sampled across the whole + * movie on that box alone, and reports how much sharper the sum got. That + * number (`drift_preview.gain`) is what the readout under the toggles shows — + * drag the box onto a landmark and watch it rise, drag it onto empty film and + * watch it fall below 1. `Use ROI for alignment` is then the commitment: the + * full solve correlates on that same rectangle. It is OFF by default because + * a guessed box is not automatically better than the whole frame (measured: + * 1.03 px vs 0.25 px against ground truth on the test movie) — the preview is + * how you find out whether yours is. + * + * Only `rigid` has a solver. `rigid+affine` is shown LOCKED inside Advanced + * with the backend's own reason rather than silently falling back — a rigid + * solve under a caret claiming "rigid+affine" puts a wrong `kind` into the + * model's provenance, which is worse than the missing feature. + */ +import React from 'react' +import { WizardShell, TabRow, Field, NumInput, Select, Check, S } from './WizardShell' +import { useWizardLifecycle, useDebouncedAction, useWizardEvent, CommitButton } from './wizardHooks' +import type { SendAction } from './wizardHooks' + +interface Props { + caretPos: React.CSSProperties + windowId: number + sendAction: SendAction + onClose: () => void +} + +/** `drift_action.METHODS`. */ +type Method = 'rigid' | 'rigid_affine' +type TabLabel = 'Rigid' | 'Rigid+Affine' +const TABS: readonly TabLabel[] = ['Rigid', 'Rigid+Affine'] +const METHOD_OF: Record = { + 'Rigid': 'rigid', 'Rigid+Affine': 'rigid_affine', +} +const TAB_OF: Record = { + rigid: 'Rigid', rigid_affine: 'Rigid+Affine', +} +/** Verbatim from `drift_action._UNAVAILABLE` — the reason the backend gives. + * + * This list is DUPLICATED from the backend, which is a trap worth naming: + * implementing a model there while leaving its tab locked here makes the + * finished feature unreachable — with every headless test still green, + * because none of them can see a disabled tab. If a model is added or + * implemented, both ends move. */ +const UNAVAILABLE: Partial> = { + rigid_affine: 'the affine drift search (plan A4) is not implemented in spyde.drift yet', +} + +type Reference = 'running' | 'sequential' | 'first' +const REFERENCES: readonly { value: Reference; label: string }[] = [ + { value: 'running', label: 'Running average' }, + { value: 'sequential', label: 'Previous frame' }, + { value: 'first', label: 'First frame' }, +] + +/** Mirrors `drift_action.DEFAULTS`. */ +interface DriftSaved { + useRoi: boolean + rejectOutliers: boolean + method: Method + reference: Reference + upsample: number + maxShift: number + apodize: boolean + normalize: boolean + order: number + previewFrames: number +} +const DEFAULTS: DriftSaved = { + useRoi: false, rejectOutliers: true, method: 'rigid', reference: 'running', + upsample: 8, maxShift: 32, apodize: true, normalize: true, order: 1, + previewFrames: 20, +} +const _driftStore = new Map() + +interface Preview { roi: number[] | null; frames: number; gain: number } +interface Result { maxShift: number; gain: number; rejected: number; cancelled: boolean } + +export function DriftWizard({ caretPos, windowId, sendAction, onClose }: Props) { + const saved = _driftStore.get(windowId) ?? DEFAULTS + const [useRoi, setUseRoi] = React.useState(saved.useRoi) + const [rejectOutliers, setRejectOutliers] = React.useState(saved.rejectOutliers) + const [method, setMethod] = React.useState(saved.method) + const [reference, setReference] = React.useState(saved.reference) + const [upsample, setUpsample] = React.useState(saved.upsample) + const [maxShift, setMaxShift] = React.useState(saved.maxShift) + const [apodize, setApodize] = React.useState(saved.apodize) + const [normalize, setNormalize] = React.useState(saved.normalize) + const [order, setOrder] = React.useState(saved.order) + const [previewFrames, setPreviewFrames] = React.useState(saved.previewFrames) + + const [advanced, setAdvanced] = React.useState(false) + const [nFrames, setNFrames] = React.useState(0) + const [solved, setSolved] = React.useState(false) + const [running, setRunning] = React.useState(false) + const [progress, setProgress] = React.useState<{ done: number; total: number } | null>(null) + const [preview, setPreview] = React.useState(null) + const [result, setResult] = React.useState(null) + const [status, setStatus] = React.useState('Drag the box onto a landmark to test it.') + + const vals = React.useRef(saved) + vals.current = { + useRoi, rejectOutliers, method, reference, + upsample, maxShift, apodize, normalize, order, previewFrames, + } + React.useEffect(() => { _driftStore.set(windowId, vals.current) }) + + /** The backend's parameter names (`drift_action.DEFAULTS` keys). */ + const params = (): Record => { + const v = vals.current + return { + use_roi: v.useRoi, reject_outliers: v.rejectOutliers, method: v.method, + reference: v.reference, upsample: v.upsample, max_shift: v.maxShift, + apodize: v.apodize, normalize: v.normalize, order: v.order, + preview_frames: v.previewFrames, + } + } + + // Mount → drift_open (Drift Check window + the alignment box + the first + // discovery preview; nothing SOLVES — drift + // correction never runs on load). Unmount → drift_close. StrictMode-safe. + useWizardLifecycle({ + windowId, sendAction, + openAction: 'drift_open', openPayload: params, closeAction: 'drift_close', + }) + + // A toggle/parameter change re-runs the ~20-frame discovery preview. Only + // debounced HERE — the backend deliberately doesn't debounce drift_tune + // again (it debounces the ROI DRAG, whose events arrive at frame rate). + const sendTune = useDebouncedAction(sendAction, 'drift_tune', windowId) + const tune = () => sendTune(params) + const live = (set: (v: T) => void) => (v: T) => { set(v); tune() } + + useWizardEvent('spyde:drift_state', windowId, (d) => { + if (typeof d.n_frames === 'number') setNFrames(d.n_frames) + if (typeof d.solved === 'boolean') { + setSolved(d.solved) + if (!d.solved) setResult(null) + } + // The backend refuses an unimplemented model and stays on rigid, so the + // tab follows what it actually selected — never what was clicked. + const m = String(d.method ?? '') as Method + if (m in TAB_OF) setMethod(m) + }) + + useWizardEvent('spyde:drift_preview', windowId, (d) => { + const gain = Number(d.gain) + setPreview({ + roi: Array.isArray(d.roi) ? (d.roi as number[]).map(Number) : null, + frames: Number(d.frames ?? 0), + gain: Number.isFinite(gain) ? gain : NaN, + }) + }) + + useWizardEvent('spyde:drift_progress', windowId, (d) => { + const done = Number(d.done ?? 0), total = Number(d.total ?? 0) + const live = total > 0 && done < total + setProgress(live ? { done, total } : null) + if (live) setRunning(true) + }) + + useWizardEvent('spyde:drift_result', windowId, (d) => { + const gain = Number(d.gain) + setResult({ + maxShift: Number(d.max_abs_shift ?? 0), + gain: Number.isFinite(gain) ? gain : NaN, + rejected: Number(d.rejected ?? 0), + cancelled: Boolean(d.cancelled), + }) + setProgress(null) + setRunning(false) + setSolved(true) + setStatus(d.cancelled ? 'Stopped — partial model' : 'Solved.') + }) + + const onMethod = (t: TabLabel) => { + const m = METHOD_OF[t] + setMethod(m) + vals.current = { ...vals.current, method: m } + sendAction('drift_set_method', { method: m }, windowId) + } + + const solve = () => { + setResult(null) + setRunning(true) + setStatus(`Correcting drift over ${nFrames || '…'} frames`) + sendAction('drift_run', params(), windowId) + } + + const discard = () => { + setRunning(false) + setProgress(null) + setResult(null) + setSolved(false) + setStatus('Discarded.') + sendAction('drift_discard', {}, windowId) + } + + const locked = UNAVAILABLE[method] + const pct = progress ? Math.round((progress.done / progress.total) * 100) : 0 + + return ( + + {/* The whole default face: two toggles, one number, one button. */} + + + + + + + + {progress && ( +
+
+ {progress.done}/{progress.total} +
+ )} + + {result && ( + <> +
+ {result.cancelled ? '◐' : '✓'}{' '} + {Number.isFinite(result.gain) ? `${result.gain.toFixed(1)}x sharper · ` : ''} + {result.maxShift.toFixed(1)} px drift + {result.rejected ? ` · ${result.rejected} bad frames` : ''} +
+
+ {/* Apply adds the LAZY corrected node (map_blocks over the source's + own chunking) — nothing is copied, so this is cheap even on a + multi-GB movie. */} + + +
+ + )} + + setAdvanced(v => !v)}> + Boolean(UNAVAILABLE[METHOD_OF[t]])} + testid={(t) => `drift-tab-${METHOD_OF[t]}`} + /> + {/* The stub is locked, so this names it rather than waiting for a + click that cannot happen. Text is the backend's own wording. */} +
+ {locked ?? 'Rigid+Affine is not implemented in spyde.drift yet.'} +
+ +