Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion engine/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -230,6 +237,9 @@ <h2 id="surface-title">Surface</h2>
<button id="merge-surfaces" hidden>Merge selected</button>
</div>
</section>
<details class="fold" data-fold="setup"><summary><h2>Setup</h2></summary>
<ol id="setup-steps" class="setup-steps"></ol>
</details>
<details class="fold" data-fold="project"><summary><h2>Project</h2></summary>
<div class="row">
<input id="project-save-name" type="text" placeholder="Project name" aria-label="Project name" />
Expand Down Expand Up @@ -274,7 +284,6 @@ <h2 id="surface-title">Surface</h2>
<div id="hardware-notes"></div>
<dl>
<dt id="projector-label">Projector</dt><dd><select id="projector-select" title="Which display is the projector" aria-labelledby="projector-label"></select></dd>
<dt id="camera-label">Camera</dt><dd><select id="camera-select" title="Which camera scans" aria-labelledby="camera-label"></select></dd>
<dt>Output</dt><dd id="output">…</dd>
<dt>Frame rate</dt><dd id="fps">…</dd>
</dl>
Expand Down Expand Up @@ -303,6 +312,7 @@ <h2 id="surface-title">Surface</h2>
<div class="row"><meter id="sound-meter" min="0" max="100" value="0" aria-label="Sound level"></meter> <span id="sound-note" class="muted">Off</span></div>
</details>
<details class="fold" data-fold="camera"><summary><h2>Camera</h2></summary>
<dl><dt id="camera-label">Camera</dt><dd><select id="camera-select" title="Which camera scans" aria-labelledby="camera-label"></select></dd></dl>
<h3>Before scanning</h3>
<p class="muted hint">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.</p>
<div class="row">
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/editor/cameraPanel.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
10 changes: 9 additions & 1 deletion frontend/src/editor/cameraPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export interface CameraPanelDeps {
engine: EngineClient;
notice: (text: string, failure?: boolean) => void;
fetchPreview?: () => Promise<Response>;
onChange?: () => void; // a framing or exposure check started or finished (the Setup checklist follows both, #168)
}

export interface CameraPanel {
update(status: StatusMessage | null, scanning: boolean): void;
loadScanSettings(): Promise<void>;
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 {
Expand Down Expand Up @@ -160,18 +162,21 @@ 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<void>) {
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 {
if (!previewRunning()) void engine.releaseCamera();
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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -250,5 +257,6 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
loadScanSettings,
stopPreview: () => { if (previewRunning()) togglePreview(); },
previewRunning,
framing: () => framing,
};
}
10 changes: 10 additions & 0 deletions frontend/src/editor/folds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/editor/folds.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>, problems: Set<string>): 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<string, boolean>, problems: Set<string>, openFirst = new Set<string>()): 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 {
Expand Down
Loading
Loading