diff --git a/README.md b/README.md index bab6fdc..f1a1192 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ For long sessions, power the camera from a dummy battery and AC adapter (NP-FZ10 **Connect in this order.** 1. Plug in the USB cable with the camera **off**, and start `make dev`. -2. Turn the camera on, click **Refresh hardware**, and pick it in the **Camera** dropdown. +2. Turn the camera on, click **Refresh hardware**, and pick it in the **Camera** dropdown (in the Camera section). 3. Click **Show preview** (or Check exposure, Check framing or Scan). The first of these connects. macOS's camera service (`ptpcamerad`) grabs any camera like this the moment it appears; while connecting, the app stops it and keeps it stopped until the camera is the app's. You don't need to quit anything yourself. The app then holds the connection between scans. If the camera shows **Connecting** and the app can't reach it, turn the camera off and on. @@ -145,19 +145,21 @@ Everything is plain files under `~/.auto-mapper/`: ## Using it +Until the first scan, the **Setup** list at the top of the sidebar walks through these steps in order: projector, output window, camera, framing, exposure, then Scan. Each step shows its state and a button that does it. Framing and exposure are recommended rather than required: Scan stays enabled without them, but with a still camera and no exposure check, the editor says so beside Scan. After a scan the list folds away, and it opens again by itself only if the projector, output window or camera needs doing again. + ### 1. Open the output window Click **Open output window** in the editor. Drag the new window onto the projector and click inside it to go fullscreen (browsers only allow fullscreen from a click, so the app can't do this for you). The setup hint ("Drag this window onto the projector, then click to go fullscreen.") disappears once the window fills the screen, so it is never projected over the patterns. Keep only one output window open. The newest one owns the projector: if another opens (say, one left on the laptop), the old one goes dark and says "Another output window is in use. Click here to use this window instead." Click it to make that window the projector's output again; and when the newer window closes, the old one takes over again by itself. If the output window isn't on the projector, the banner at the top says so and names the display it seems to be on. -The editor's **Hardware** panel shows the projector (a dropdown: with more than one external monitor, pick the one that is the projector; the choice is remembered, and if that display is unplugged the editor says so and falls back to the first non-main display), the camera (a dropdown too: see the next step), whether the output is connected and at what size, and the output's frame rate. Scan stays disabled until the output window exactly matches the projector's resolution. Use **Refresh hardware** after plugging something in. +The editor's **Hardware** panel shows the projector (a dropdown: with more than one external monitor, pick the one that is the projector; the choice is remembered, and if that display is unplugged the editor says so and falls back to the first non-main display), whether the output is connected and at what size, and the output's frame rate. Scan stays disabled until the output window exactly matches the projector's resolution. Use **Refresh hardware** after plugging something in. The **Test frame** buttons (Grid, White, Black) put a test image on the projector, which helps with aiming and focusing. ### 2. Pick the camera -The **Camera** dropdown in the Hardware panel lists every camera, labeled `(USB)`, `(photos over USB)` for a still camera, `(built-in)`, `(external)`, `(phone)` or `(other)`. The first USB webcam is selected by default, else a still camera; your choice is remembered in `settings.json`. +The **Camera** dropdown, at the top of the **Camera** section with the camera's preview, checks and scan settings, lists every camera, labeled `(USB)`, `(photos over USB)` for a still camera, `(built-in)`, `(external)`, `(phone)` or `(other)`. The first USB webcam is selected by default, else a still camera; your choice is remembered in `settings.json`. **Show preview**, in the **Camera** section, is off by default. While it's on, the editor polls a camera frame twice a second; turning it off releases the webcam. A still camera takes a photo per preview (a few seconds each) and reuses it for 5 seconds. Starting a scan turns the preview off, because the scan needs the camera to itself. Use the preview to check that the camera sees the whole projected area. @@ -208,7 +210,7 @@ Detected surfaces appear as numbered outlines over the scan. In the editor: | Remove a corner | Alt-click a handle. A surface always keeps at least three corners. | | Skip part of the room when scanning | Camera section: **Skip an area**, then click corners on the camera preview around a window, a TV or shiny things, double-click to finish; add more areas as needed. Skipped areas are shaded on the preview and left out of the next scan; click one to stop skipping it, or **Skip nothing** to clear them all. **Hole fill** sets how much small gaps in the scan are filled (more: fuller surfaces; less: truer edges). **HDR** captures each pattern at 2 or 3 exposures and keeps the best-lit reading per pixel, for scenes with dark and bright surfaces (slower). A still camera uses its own bracketing for HDR instead, at either setting: the app switches its drive mode to Bracketing C 2.0 Steps 3 Pictures for the scan, so one press per pattern takes 3 photos 2 stops apart. All are kept per camera. | | Zoom in to place points precisely | Pinch on the trackpad (or Cmd/Ctrl + scroll) over the scan to zoom, up to 16×; two-finger scroll moves around. **−**, **+** and **Fit** sit under the scan. Handles and labels keep their size, so you can place points much more precisely. | -| Less on screen | Click a sidebar heading to fold or open that section; the Editor remembers it. Set-up-once sections (Schedule, MIDI, Hardware, Camera, Test frame) start folded, and a section with a problem (no projector, sound blocked) opens by itself. | +| Less on screen | Click a sidebar heading to fold or open that section; the Editor remembers it. Set-up-once sections (Schedule, MIDI, Hardware, Camera, Test frame) start folded, and a section with a problem (no projector, sound blocked) opens by itself. Camera opens by default until the first scan. | | Delete surfaces | Click a surface and press **Delete** or **Backspace** (or the panel's Delete). Shift-click more surfaces to delete them all at once; one Undo brings them back. | | Start a new project | **New** under Project, then click again to confirm: the scan and show are cleared and the output goes dark, ready for a new scan. Saved projects are kept, so **Save** the current one first if you want it back. | | Several scenes | **Scenes** in the sidebar: **Add** starts a dark scene, **Duplicate** copies the open one. Click a scene to show and edit it; every scene uses the same surfaces. Name it, set its seconds for the playlist, and move it with ↑ ↓. In **Play** mode the scenes play in that order with a **Crossfade** between them (**Loop** to start over); the Right and Left arrow keys step through them by hand. | diff --git a/engine/hub.py b/engine/hub.py index ac2f8a9..30b5024 100644 --- a/engine/hub.py +++ b/engine/hub.py @@ -109,7 +109,7 @@ def scan_blocker(self) -> str | None: size = f"{projector['width']}×{projector['height']}" return f"Output window must be {size}: make it fullscreen on the projector" if self.settings.selected(hw.cameras) is None: - return "Choose the camera that scans (Hardware)" + return "Choose the camera that scans (Camera)" if self.still_battery is not None and self.still_battery <= FLAT_BATTERY: return f"The camera battery is at {self.still_battery}%: charge or swap it before scanning" return None diff --git a/frontend/index.html b/frontend/index.html index c9e095a..8ae74c1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -121,6 +121,13 @@ dt { color: var(--muted); } dd { margin: 0; min-width: 0; } .ok { color: var(--ok); } .bad { color: var(--bad); } + /* Setup (#168): a tick per step; ! for a recommended one not done yet. */ + .setup-steps { list-style: none; padding: 0; margin: 0 0 8px; } + .setup-steps li { display: grid; grid-template-columns: 1.2em 1fr auto; column-gap: 6px; align-items: center; margin: 6px 0; font-size: 13px; } + .setup-steps li::before { content: "○"; color: var(--muted); } + .setup-steps li.done::before { content: "✓"; color: var(--ok); } + .setup-steps li.warn::before { content: "!"; color: var(--warn); font-weight: 700; text-align: center; } + .setup-state { grid-column: 2 / -1; color: var(--muted); font-size: 12px; line-height: 1.3; } .scan-reason { align-self: center; max-width: 22em; font-size: 12px; line-height: 1.3; color: var(--warn); } #hardware-notes .note { margin: 0 0 12px; padding: 6px 8px; font-size: 12px; color: var(--text); border-left: 3px solid var(--muted); background: #222; } .banner { background: #3a2a05; color: var(--warn); border-left: 3px solid var(--warn); padding: 8px 16px; } @@ -230,6 +237,9 @@

Surface

+

Setup

+
    +

    Project

    @@ -274,7 +284,6 @@

    Surface

    Projector
    -
    Camera
    Output
    …
    Frame rate
    …
    @@ -303,6 +312,7 @@

    Surface

    Off

    Camera

    +
    Camera

    Before scanning

    Check framing and Check exposure each show white on the projector. Check framing measures how much of the camera's view the projection fills (the more, the finer the scan) and outlines it on the preview. Check exposure makes sure the white is neither too dark nor clipping.

    diff --git a/frontend/src/editor/cameraPanel.browser.test.ts b/frontend/src/editor/cameraPanel.browser.test.ts index 0007a35..3fefb24 100644 --- a/frontend/src/editor/cameraPanel.browser.test.ts +++ b/frontend/src/editor/cameraPanel.browser.test.ts @@ -271,5 +271,20 @@ describe("the Camera section", () => { expect($("framing-note").hidden).toBe(true); expect(document.querySelector("#framing-overlay polygon")!.getAttribute("points")).toBe(""); }); -}); + it("keeps the last framing result for the Setup checklist, and says when a check starts and ends (#168)", async () => { + const { engine } = fakeEngine(); + const onChange = vi.fn(); + const panel = mountCameraPanel({ engine, notice: () => {}, onChange }); + panel.update(status(AC410), false); + expect(panel.framing()).toBeNull(); + $("check-framing").click(); + expect(onChange).toHaveBeenCalledTimes(1); // busy: the checklist's button follows it + await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(2)); + expect(panel.framing()).toMatchObject({ span: 0.8, advice: null }); + $("calibrate").click(); + await vi.waitFor(() => expect(onChange).toHaveBeenCalledTimes(4)); + panel.update(status(A6600), false); // another camera: its framing is unknown + expect(panel.framing()).toBeNull(); + }); +}); diff --git a/frontend/src/editor/cameraPanel.ts b/frontend/src/editor/cameraPanel.ts index 928d66e..b4dc361 100644 --- a/frontend/src/editor/cameraPanel.ts +++ b/frontend/src/editor/cameraPanel.ts @@ -16,6 +16,7 @@ export interface CameraPanelDeps { engine: EngineClient; notice: (text: string, failure?: boolean) => void; fetchPreview?: () => Promise; + onChange?: () => void; // a framing or exposure check started or finished (the Setup checklist follows both, #168) } export interface CameraPanel { @@ -23,6 +24,7 @@ export interface CameraPanel { loadScanSettings(): Promise; stopPreview(): void; // e.g. before a scan: it needs the camera to itself previewRunning(): boolean; + framing(): FramingResult | null; // the selected camera's last framing check, or null } export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { @@ -160,11 +162,13 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { const framingNote = $("framing-note"); const exposureNote = $("exposure-note"); const framingTip = checkFraming.title; // what it does; its reason replaces it while disabled + let framing: FramingResult | null = null; async function usingCamera(button: HTMLButtonElement, busyLabel: string, run: () => Promise) { const label = button.textContent; button.disabled = true; button.dataset.busy = "1"; // render leaves its label alone meanwhile button.textContent = busyLabel; + deps.onChange?.(); try { await run(); } finally { @@ -172,6 +176,7 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { delete button.dataset.busy; button.textContent = label; render(); + deps.onChange?.(); } } // One name for both kinds of camera (#172): the result leads with the engine's verdict, then says whether @@ -191,7 +196,8 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { const res = await engine.checkFraming(); const body = await res.json(); if (!res.ok) return notice(`Framing check failed: ${body.detail}`, true); - const view = describeFraming(body as FramingResult); + framing = body as FramingResult; + const view = describeFraming(framing); framingNote.textContent = view.text; framingNote.classList.toggle("warn", view.warn); framingNote.hidden = false; @@ -241,6 +247,7 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { update(next, isScanning) { if (next?.camera.selected !== status?.camera.selected) { // another camera: its framing is unknown framingNote.hidden = exposureNote.hidden = true; + framing = null; document.querySelector("#framing-overlay polygon")!.setAttribute("points", ""); } status = next; @@ -250,5 +257,6 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { loadScanSettings, stopPreview: () => { if (previewRunning()) togglePreview(); }, previewRunning, + framing: () => framing, }; } diff --git a/frontend/src/editor/folds.test.ts b/frontend/src/editor/folds.test.ts index 7d38a57..df31608 100644 --- a/frontend/src/editor/folds.test.ts +++ b/frontend/src/editor/folds.test.ts @@ -17,6 +17,16 @@ describe("which sidebar sections are open", () => { it("opens a section with a problem, whatever you chose, so collapsing never hides one", () => { expect(foldOpen("hardware", { hardware: false }, new Set(["hardware"]))).toBe(true); }); + + it("folds the Setup checklist when nothing needs it: it opens as a problem does (#168)", () => { + expect(foldOpen("setup", {}, new Set())).toBe(false); + expect(foldOpen("setup", { setup: false }, new Set(["setup"]))).toBe(true); + }); + + it("opens Camera by default while there's no scan yet; your choice still wins (#168)", () => { + expect(foldOpen("camera", {}, new Set(), new Set(["camera"]))).toBe(true); + expect(foldOpen("camera", { camera: false }, new Set(), new Set(["camera"]))).toBe(false); + }); }); describe("sections with a problem", () => { diff --git a/frontend/src/editor/folds.ts b/frontend/src/editor/folds.ts index f0239d0..7f59ec6 100644 --- a/frontend/src/editor/folds.ts +++ b/frontend/src/editor/folds.ts @@ -1,12 +1,13 @@ // The Editor's sidebar sections fold open or shut (#110): everyday ones start open, set-up-once ones // folded, your choice is remembered, and a section with a problem always opens so nothing is hidden. -const FOLDED_AT_FIRST = new Set(["schedule", "midi", "hardware", "camera", "test-frame"]); +const FOLDED_AT_FIRST = new Set(["setup", "schedule", "midi", "hardware", "camera", "test-frame"]); -/** Whether a section is open: a problem opens it; otherwise your last choice, else its default. */ -export function foldOpen(name: string, saved: Record, problems: Set): boolean { +/** Whether a section is open: a problem opens it; otherwise your last choice, else its default, which + * `openFirst` turns to open (Camera while there's no scan yet, #168). */ +export function foldOpen(name: string, saved: Record, problems: Set, openFirst = new Set()): boolean { if (problems.has(name)) return true; - return saved[name] ?? !FOLDED_AT_FIRST.has(name); + return saved[name] ?? (openFirst.has(name) || !FOLDED_AT_FIRST.has(name)); } interface StatusLike { diff --git a/frontend/src/editor/main.ts b/frontend/src/editor/main.ts index 7ba9bf2..dd9a6fc 100644 --- a/frontend/src/editor/main.ts +++ b/frontend/src/editor/main.ts @@ -20,6 +20,8 @@ import { movePin, pinHandles } from "./pin"; import { ACTIONS, MidiRouter, parseMidi, settingValue, targetMenu, type MidiTarget, type TargetItem } from "./midi"; import { deleteKeyTargets } from "./deleteKey"; import { foldOpen, problemFolds } from "./folds"; +import { mountSetup } from "./setupPanel"; +import { scanAnswered, scanWarning, setupNeeded, setupSteps } from "./setupView"; import { labelPoint } from "./labelPoint"; import { modePill } from "./modePill"; import { moveScene } from "./sceneList"; @@ -110,13 +112,13 @@ async function refreshProjects() { ); } +let scanKnown = false; // whether there's a scan is known once the engine answers (no Setup flash meanwhile) async function reloadScan() { if (view !== FIT) setView(FIT); // a different scan: start from the whole of it - const r = await engine.latestScan(); - if (r.ok) { - scanState = scanReducer(initialScan, { type: "scan_result", ...(await r.json()) }); - renderScan(); - } + const r = await engine.latestScan().catch(() => null); + scanKnown ||= scanAnswered(r); // no answer: asked again when the engine reconnects + if (r?.ok) scanState = scanReducer(initialScan, { type: "scan_result", ...(await r.json()) }); + renderScan(); } let scanState: ScanState = initialScan; const scanImage = $("scan-image"); @@ -140,7 +142,7 @@ effectSelect.replaceChildren(...EFFECTS.map((e) => Object.assign(document.create // save at most once per frame; show updates that arrive during a drag wait until it ends. const engine = createEngineClient(); // The Camera section: preview, calibration, framing check and the camera's scan settings. -const cameraPanel = mountCameraPanel({ engine, notice }); +const cameraPanel = mountCameraPanel({ engine, notice, onChange: () => renderScanButton() }); void cameraPanel.loadScanSettings(); const session = new EditSession({ patch: (id, body, gesture) => void engine.patchSurface(id, body, gesture) }); @@ -879,9 +881,14 @@ try { const foldShown = new Map(); // what applyFolds last set, to tell its toggles from yours function applyFolds() { const problems = problemFolds(status ?? null); + // Setup shows itself until there's a scan, then only when a step needs doing again; Camera opens by + // default while there's no scan yet (#168). + const hasScan = !!scanState.image; + if (scanKnown && setupNeeded(status ?? null, hasScan)) problems.add("setup"); + const openFirst = new Set(scanKnown && !hasScan ? ["camera"] : []); for (const d of folds) { const name = d.dataset.fold!; - const open = foldOpen(name, savedFolds, problems); + const open = foldOpen(name, savedFolds, problems, openFirst); foldShown.set(name, open); if (d.open !== open) d.open = open; } @@ -1081,22 +1088,27 @@ function renderScan() { renderSurfaces(); renderScanButton(); + applyFolds(); // a scan collapses Setup scanWarnings.replaceChildren( ...scanState.warnings.map((w) => Object.assign(document.createElement("div"), { className: "banner", textContent: w })), ); renderMissed(); } -/** Scan, or Cancel scan while one runs; when it can't run, why (in its tooltip and beside it). */ +/** Scan, or Cancel scan while one runs; when it can't run, why (in its tooltip and beside it), and when + * it can, what to check first (#168). Then the camera controls and the Setup steps, which mirror them. */ function renderScanButton() { const reason = scanState.running ? null : describeStatus(status).scanReason; + const note = reason ?? (scanState.running ? null : scanWarning(status)); scan.textContent = scanState.running ? "Cancel scan" : "Scan"; scan.disabled = reason !== null; scan.title = reason ?? (scanState.running ? "Stop the scan" : "Project the scan patterns and find the surfaces"); - scanReason.textContent = reason ?? ""; - scanReason.hidden = !reason; + scanReason.textContent = note ?? ""; + scanReason.hidden = !note; cameraPanel.update(status, scanState.running); // on scan start and end too, not only on the next status (#173) + renderSetup(setupSteps(status, cameraPanel.framing(), !!scanState.image)); } +const renderSetup = mountSetup(); function render() { const view = describeStatus(status); @@ -1227,7 +1239,7 @@ connect({ if (!open) { status = null; render(); - } + } else if (!scanKnown) void reloadScan(); // the engine wasn't up at load: Setup waits for its answer }, }); diff --git a/frontend/src/editor/setupPanel.browser.test.ts b/frontend/src/editor/setupPanel.browser.test.ts new file mode 100644 index 0000000..4157538 --- /dev/null +++ b/frontend/src/editor/setupPanel.browser.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import html from "../../index.html?raw"; +import { mountSetup } from "./setupPanel"; +import type { SetupStep } from "./setupView"; + +// The Setup checklist (#168), on the Editor's real markup. +const $ = (id: string) => document.getElementById(id) as T; + +beforeEach(() => { + document.body.innerHTML = html.replace(//g, "").replace(/^[\s\S]*|<\/body>[\s\S]*$/g, ""); +}); + +describe("the Setup checklist's page", () => { + it("is the sidebar's first section", () => { + expect(document.querySelector("aside details.fold")!.getAttribute("data-fold")).toBe("setup"); + expect($("setup-steps").closest("[data-fold]")!.getAttribute("data-fold")).toBe("setup"); + }); + + it("puts the camera picker with the camera's preview, checks and scan settings", () => { + const camera = document.querySelector('[data-fold="camera"]')!; + for (const id of ["camera-select", "preview-toggle", "check-framing", "calibrate", "hole-fill"]) { + expect(camera.contains($(id)), id).toBe(true); + } + }); +}); + +const steps: SetupStep[] = [ + { key: "projector", label: "Projector", state: "AML TV", level: "done", target: "projector-select", press: false }, + { key: "output", label: "Output window", state: "Open the output window", level: "todo", target: "open-output", press: true }, + { key: "camera", label: "Camera", state: "Choose a camera", level: "todo", target: "camera-select", press: false }, + { key: "framing", label: "Framing", state: "Not checked", level: "warn", target: "check-framing", press: true }, +]; + +describe("the Setup checklist", () => { + it("shows each step's state, in order", () => { + mountSetup()(steps); + const items = [...document.querySelectorAll("#setup-steps li")]; + expect(items.map((li) => li.querySelector(".setup-label")!.textContent)).toEqual(["Projector", "Output window", "Camera", "Framing"]); + expect(items[1].querySelector(".setup-state")!.textContent).toBe("Open the output window"); + expect(items.map((li) => li.className)).toEqual(["done", "todo", "todo", "warn"]); + }); + + it("does a step with the real control: its label, and its disabled reason", () => { + const open = vi.fn(); + $("open-output").addEventListener("click", open); + $("check-framing").disabled = true; + $("check-framing").title = "Open the output window first"; + mountSetup()(steps); + const button = (key: string) => document.querySelector(`#setup-steps li[data-step="${key}"] button`)!; + expect(button("output").textContent).toBe("Open output window"); + button("output").click(); + expect(open).toHaveBeenCalledTimes(1); + expect(button("framing").disabled).toBe(true); + expect(button("framing").title).toBe("Open the output window first"); + }); + + it("reveals a choice: opens its section and puts the focus on it", () => { + mountSetup()(steps); + const camera = document.querySelector('[data-fold="camera"]')!; + camera.open = false; + document.querySelector('#setup-steps li[data-step="camera"] button')!.click(); + expect(camera.open).toBe(true); + expect(document.activeElement).toBe($("camera-select")); + expect(document.querySelector('#setup-steps li[data-step="camera"] button')!.getAttribute("aria-label")).toBe("Choose camera"); + }); + + it("updates in place, so a click isn't lost to a redraw", () => { + const render = mountSetup(); + render(steps); + const first = document.querySelector("#setup-steps button"); + render([{ ...steps[0], state: "Other TV" }, ...steps.slice(1)]); + expect(document.querySelector("#setup-steps button")).toBe(first); + expect(document.querySelector("#setup-steps .setup-state")!.textContent).toBe("Other TV"); + }); +}); diff --git a/frontend/src/editor/setupPanel.ts b/frontend/src/editor/setupPanel.ts new file mode 100644 index 0000000..1d0fcfa --- /dev/null +++ b/frontend/src/editor/setupPanel.ts @@ -0,0 +1,43 @@ +// The Setup checklist's list (#168): one row per step, updated in place (a status arrives every 2 s, +// and a rebuilt button would lose a click). A step's button is its real control's: pressed, with +// that control's label and disabled reason, or, for a picker, its section opened and the focus put on it. + +import type { SetupStep } from "./setupView"; + +export function mountSetup(): (steps: SetupStep[]) => void { + const list = document.getElementById("setup-steps")!; + const rows = new Map(); + return (steps) => { + for (const step of steps) { + let row = rows.get(step.key); + if (!row) { + const li = document.createElement("li"); + li.dataset.step = step.key; + const label = Object.assign(document.createElement("span"), { className: "setup-label", textContent: step.label }); + const state = Object.assign(document.createElement("span"), { className: "setup-state" }); + const button = document.createElement("button"); + button.addEventListener("click", () => act(step.target, step.press)); + li.append(label, button, state); // the state on its own line under them (index.html) + list.append(li); + row = { li, state, button }; + rows.set(step.key, row); + } + const target = document.getElementById(step.target) as HTMLButtonElement; + row.li.className = step.level; + row.state.textContent = step.state; + row.button.textContent = step.press ? target.textContent : "Choose"; + if (!step.press) row.button.setAttribute("aria-label", `Choose ${step.label.toLowerCase()}`); + row.button.disabled = step.press && target.disabled; + row.button.title = step.press ? target.title : ""; + } + }; +} + +function act(id: string, press: boolean) { + const target = document.getElementById(id)!; + if (press) return target.click(); + const fold = target.closest("details"); + if (fold) fold.open = true; + target.scrollIntoView({ block: "nearest" }); + target.focus(); +} diff --git a/frontend/src/editor/setupView.test.ts b/frontend/src/editor/setupView.test.ts new file mode 100644 index 0000000..4e0916e --- /dev/null +++ b/frontend/src/editor/setupView.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { CameraInfo, StatusMessage } from "../shared/messages"; +import { scanAnswered, scanWarning, setupNeeded, setupSteps } from "./setupView"; + +const AC410: CameraInfo = { name: "Webcam AC410", unique_id: "0x2110000f1311306", device_type: "external" }; +const A6600: CameraInfo = { name: "Sony Alpha-A6600", unique_id: "gphoto2:Sony", device_type: "still" }; + +function status(over: Partial = {}, camera: Partial = {}): StatusMessage { + return { + type: "status", + hardware: { + projector: { name: "AML TV", key: "aml", width: 1920, height: 1080 }, + projector_missing: null, displays: [], cameras: [AC410, A6600], issues: [], + }, + output_connected: true, + output_resolution: { width: 1920, height: 1080 }, + camera: { selected: AC410.unique_id, calibration: null, at_light_limit: false, exposure: null, battery: null, battery_state: null, ...camera }, + project: null, can_scan: true, scan_blocker: null, + ...over, + }; +} + +const step = (key: string, s: StatusMessage | null, hasScan = false) => setupSteps(s, null, hasScan).find((x) => x.key === key)!; + +describe("the setup checklist (#168)", () => { + it("lists the steps in setup order", () => { + expect(setupSteps(status(), null, false).map((s) => s.label)).toEqual( + ["Projector", "Output window", "Camera", "Framing", "Exposure", "Scan"], + ); + }); + + it("names the projector, or says to connect one", () => { + expect(step("projector", status())).toMatchObject({ level: "done", state: "AML TV" }); + const none = status(); + none.hardware = { ...none.hardware, projector: null, issues: ["no_projector"] }; + expect(step("projector", none)).toMatchObject({ level: "todo", state: "Connect the projector as an extended display" }); + }); + + it("says to open the output window, then to put it fullscreen on the projector", () => { + expect(step("output", status({ output_connected: false, output_resolution: null }))).toMatchObject({ level: "todo", state: "Open the output window" }); + const off = status({ output_resolution: { width: 800, height: 600 } }); + expect(step("output", off)).toMatchObject({ level: "todo", state: "Make it fullscreen on AML TV" }); + expect(step("output", { ...off, scan_blocker: "It's on Color LCD: move it to AML TV" }).state).toBe("It's on Color LCD: move it to AML TV"); + expect(step("output", status())).toMatchObject({ level: "done", state: "Fullscreen on AML TV" }); + }); + + it("names the chosen camera, or says to choose one", () => { + expect(step("camera", status())).toMatchObject({ level: "done", state: "Webcam AC410" }); + expect(step("camera", status({}, { selected: null }))).toMatchObject({ level: "todo", state: "Choose a camera" }); + }); + + it("shows the last framing check, which is recommended, not required", () => { + expect(step("framing", status())).toMatchObject({ level: "warn", state: "Not checked" }); + const framed = (f: Parameters[1]) => setupSteps(status(), f, false).find((x) => x.key === "framing")!; + expect(framed({ span: 0.82, cut_off: false, outline: [], advice: null })) + .toMatchObject({ level: "done", state: "Framed well: the projection fills 82% of the camera's view." }); + expect(framed({ span: 0.3, cut_off: false, outline: [], advice: "Move the camera closer" })) + .toMatchObject({ level: "warn", state: "Move the camera closer" }); + }); + + it("shows the engine's exposure verdict, and what an unchecked one means for this kind of camera", () => { + const range = { low: null, high: 250, target: 200 }; + expect(step("exposure", status())).toMatchObject({ level: "warn", state: "Not checked (the scan will calibrate a webcam itself)" }); + expect(step("exposure", status({}, { selected: A6600.unique_id }))) + .toMatchObject({ level: "warn", state: "Not checked: the scan checks a still camera's exposure first, and stops if it's wrong" }); + expect(step("exposure", status({}, { exposure: { verdict: "good", ...range } }))).toMatchObject({ level: "done", state: "Good" }); + expect(step("exposure", status({}, { exposure: { verdict: "light_limit", ...range } }))) + .toMatchObject({ level: "warn", state: "At the camera's light limit" }); + }); + + it("ends with Scan: why it can't run yet, ready, or done", () => { + expect(step("scan", status({ can_scan: false, scan_blocker: "Choose a camera first" }))).toMatchObject({ level: "todo", state: "Choose a camera first" }); + expect(step("scan", status())).toMatchObject({ level: "todo", state: "Ready" }); + expect(step("scan", status(), true)).toMatchObject({ level: "done", state: "Scanned" }); + expect(step("scan", null).state).toBe("The engine isn't running: start it with make dev"); + }); +}); + +describe("when the checklist is needed (#168)", () => { + it("while there's no scan yet; after one, only when a required step stops being met", () => { + expect(setupNeeded(status(), false)).toBe(true); + expect(setupNeeded(status(), true)).toBe(false); // framing and exposure unchecked: recommended only + expect(setupNeeded(status({ output_connected: false, output_resolution: null }), true)).toBe(true); + expect(setupNeeded(status({}, { selected: null }), true)).toBe(true); + expect(setupNeeded(null, true)).toBe(false); // the engine is down: its banner says so, not the checklist + }); +}); + +describe("whether the engine has said if there's a scan (#168)", () => { + it("is a scan (200) or none (404); a proxy's 502 or a failed request is no answer yet", () => { + expect(scanAnswered(new Response("{}", { status: 200 }))).toBe(true); + expect(scanAnswered(new Response("", { status: 404 }))).toBe(true); + expect(scanAnswered(new Response("", { status: 502 }))).toBe(false); // the Vite proxy, engine down + expect(scanAnswered(null)).toBe(false); // the request itself failed + }); +}); + +describe("beside Scan, while it can run (#168)", () => { + it("warns that a still camera's exposure isn't checked, and nothing otherwise", () => { + const still = status({}, { selected: A6600.unique_id }); + expect(scanWarning(still)).toBe("Exposure not checked: the scan checks it first, and stops if it's wrong"); + expect(scanWarning(status({}, { selected: A6600.unique_id, exposure: { verdict: "good", low: 140, high: 250, target: null } }))).toBeNull(); + expect(scanWarning(status())).toBeNull(); // a webcam calibrates itself + expect(scanWarning(null)).toBeNull(); + }); +}); diff --git a/frontend/src/editor/setupView.ts b/frontend/src/editor/setupView.ts new file mode 100644 index 0000000..8d23a88 --- /dev/null +++ b/frontend/src/editor/setupView.ts @@ -0,0 +1,70 @@ +// The Setup checklist (#168): the rig's setup order, each step's state from what the editor already +// knows (engine status, the last framing check, whether there's a scan), and the control that does it. + +import type { StatusMessage } from "../shared/messages"; +import { describeFraming, type FramingResult } from "./framingView"; +import { describeStatus, outputOffProjector } from "./statusView"; + +export interface SetupStep { + key: "projector" | "output" | "camera" | "framing" | "exposure" | "scan"; + label: string; + state: string; + /** done; todo: needed before a scan; warn: recommended, the scan runs without it. */ + level: "done" | "todo" | "warn"; + /** The control that does the step: pressed for a button, else shown and focused (a picker). */ + target: string; + press: boolean; +} + +type StepState = Omit; +const TARGETS: Record = { + projector: ["projector-select", false], output: ["open-output", true], camera: ["camera-select", false], + framing: ["check-framing", true], exposure: ["calibrate", true], scan: ["scan", true], +}; + +export function setupSteps(status: StatusMessage | null, framing: FramingResult | null, hasScan: boolean): SetupStep[] { + const projector = status?.hardware.projector ?? null; + const output: StepState = { key: "output", label: "Output window", state: "Open the output window", level: "todo" }; + if (status?.output_connected && projector) { + if (outputOffProjector(status)) output.state = status.scan_blocker ?? `Make it fullscreen on ${projector.name}`; + else Object.assign(output, { state: `Fullscreen on ${projector.name}`, level: "done" }); + } + const camera = status?.hardware.cameras.find((c) => c.unique_id === status.camera.selected); + const framed = framing && describeFraming(framing); + const verdict = status?.camera.exposure?.verdict ?? null; // the engine's; no levels of our own + const unchecked = camera?.device_type === "still" + ? "Not checked: the scan checks a still camera's exposure first, and stops if it's wrong" + : "Not checked (the scan will calibrate a webcam itself)"; + const steps: StepState[] = [ + projector ? { key: "projector", label: "Projector", state: projector.name, level: "done" } + : { key: "projector", label: "Projector", state: "Connect the projector as an extended display", level: "todo" }, + output, + camera ? { key: "camera", label: "Camera", state: camera.name, level: "done" } + : { key: "camera", label: "Camera", state: "Choose a camera", level: "todo" }, + { key: "framing", label: "Framing", state: framed?.text ?? "Not checked", level: framed && !framed.warn ? "done" : "warn" }, + verdict === "good" ? { key: "exposure", label: "Exposure", state: "Good", level: "done" } + : { key: "exposure", label: "Exposure", state: verdict === "light_limit" ? "At the camera's light limit" : unchecked, level: "warn" }, + hasScan ? { key: "scan", label: "Scan", state: "Scanned", level: "done" } + : { key: "scan", label: "Scan", state: describeStatus(status).scanReason ?? "Ready", level: "todo" }, + ]; + return steps.map((s) => ({ ...s, target: TARGETS[s.key][0], press: TARGETS[s.key][1] })); +} + +/** Said beside Scan while it can run: a still camera's unchecked exposure fails a scan two minutes in. */ +export function scanWarning(status: StatusMessage | null): string | null { + const camera = status?.hardware.cameras.find((c) => c.unique_id === status.camera.selected); + return camera?.device_type === "still" && !status!.camera.exposure + ? "Exposure not checked: the scan checks it first, and stops if it's wrong" : null; +} + +/** Shown until there's a scan; after one, only when the projector, output window or camera needs doing again. */ +export function setupNeeded(status: StatusMessage | null, hasScan: boolean): boolean { + if (!hasScan) return true; + return !!status && setupSteps(status, null, hasScan).some((s) => s.level === "todo" && s.key !== "scan"); +} + +/** Whether a latest-scan answer says if there's a scan: 200 there is, 404 there isn't. Anything else + * (the dev proxy's 502 while the engine is down) or a failed request (null) is no answer yet. */ +export function scanAnswered(r: Response | null): boolean { + return !!r && (r.ok || r.status === 404); +} diff --git a/frontend/src/editor/statusView.ts b/frontend/src/editor/statusView.ts index 57c15a8..c8fddb0 100644 --- a/frontend/src/editor/statusView.ts +++ b/frontend/src/editor/statusView.ts @@ -65,8 +65,7 @@ export function describeStatus(status: StatusMessage | null): StatusView { if (status.output_video_sound_blocked) { banners.push("Video sound is waiting: click the output window once to allow it."); } - const offProjector = !!(status.output_connected && projector && out - && (out.width !== projector.width || out.height !== projector.height)); + const offProjector = outputOffProjector(status); if (!status.output_connected) { banners.push("Output window not connected. Open it and move it fullscreen onto the projector."); } else if (offProjector) { @@ -89,6 +88,13 @@ export function describeStatus(status: StatusMessage | null): StatusView { }; } +/** The output window is open but not fullscreen on the projector (its size isn't the projector's). */ +export function outputOffProjector(status: StatusMessage): boolean { + const { projector } = status.hardware; + const out = status.output_resolution; + return !!(status.output_connected && projector && out && (out.width !== projector.width || out.height !== projector.height)); +} + // The verdict first, then the white level against the engine's range (#172). function describeCalibration(c: Calibration | null, e: Exposure | null): string { if (!c || !e) return "Not checked yet: the scan checks it first, or check exposure now"; diff --git a/tests/test_camera.py b/tests/test_camera.py index 7e87858..f2aa40b 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -93,7 +93,9 @@ def test_releasing_the_camera_closes_it(rig, tmp_path): def test_scan_needs_a_selected_camera(tmp_path): hw = FakeHardware(displays=[LAPTOP, PROJECTOR], cameras=[FACETIME]) with engine(hw, data_dir=tmp_path) as client, output(client): - assert client.get("/api/status").json()["can_scan"] is False + s = client.get("/api/status").json() + assert s["can_scan"] is False + assert s["scan_blocker"] == "Choose the camera that scans (Camera)" # where its picker is (#168) client.post("/api/camera", json={"unique_id": FACETIME["unique_id"]})