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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ Pick an effect for the selected surface in the surface panel; its controls appea
| **Outline trace** | A glowing line around the surface's edge, optionally chasing round it | Color, Line width (1 to 40 px), Glow, Chase, Speed (0 to 2 laps/s), Segments (1 to 8), React to sound |
| **Noise flow** | Slowly drifting cloud-like texture between two colors | Color, Second color, Scale, Speed, Contrast, Brightness, React to sound |
| **Tint (scan)** | Recolors the real object while keeping its texture (wood grain, fabric) visible | Color, Texture, Scan gain |
| **Edge glow (scan)** | Glowing lines along the real edges the scan sees: panel grooves, grain, folds | Color, Sensitivity, Line width (1 to 8 px), Smoothing (0 to 2), Base light, Pulse |
| **Edge glow (scan)** | Glowing lines along the real edges the scan sees: panel grooves, grain, folds | Color, Edge threshold (higher: only strong edges), Line width (1 to 8 px), Smoothing (Off, Low, High: ignores speckle in the scan), Base light, Pulse |
| **Posterize (scan)** | Flattens the real surface into a few bands of color, like a screen print | Bands (2 to 8), Dark color, Light color, Scan gain |
| **Text** | Your text, as large as fits inside the surface (or corner-pinned to it), several lines allowed | Text, Color, Background, Font (Sans, Serif, Mono), Align, Fit (Fit inside, Map to corners), Motion (None, Scroll, Pulse), Speed, React to sound |
| **Image / video** | An uploaded image or video, clipped to the surface's outline | Image or video (file); Fit: Cover, Stretch, Map to corners, Contain, Original size, Tile; Zoom, Pan, Rotate, Flip, Background; Video start and speed, Video sound and Volume |
Expand Down
10 changes: 6 additions & 4 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,10 @@ <h2 id="surface-title">Surface</h2>
</details>
<details class="fold" data-fold="camera"><summary><h2>Camera</h2></summary>
<div class="row">
<button id="preview-toggle">Show preview</button>
<button id="calibrate">Calibrate exposure</button>
<button id="check-framing" title="Shows white, then black, and measures how much of the camera's view the projection fills: the more, the finer the scan. Outlined on the preview.">Check framing</button>
<button id="preview-toggle" aria-describedby="camera-reason">Show preview</button>
<button id="calibrate" aria-describedby="camera-reason">Calibrate exposure</button>
<button id="check-framing" title="Shows white, then black, and measures how much of the camera's view the projection fills: the more, the finer the scan. Outlined on the preview." aria-describedby="camera-reason">Check framing</button>
<span id="camera-reason" class="scan-reason" hidden></span>
</div>
<p id="framing-note" class="muted hint" role="status" hidden></p>
<p id="exposure-note" class="muted hint" role="status" hidden></p>
Expand Down Expand Up @@ -337,9 +338,10 @@ <h2 id="surface-title">Surface</h2>
<svg id="mask-overlay" viewBox="0 0 1 1" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"></svg>
</div>
<div class="row" title="Skip part of what the camera sees: draw around a window, a TV or shiny things, and the next scan leaves them out. Click corners on the preview, double-click or Enter to finish; add as many areas as you like. Click a shaded area to stop skipping it.">
<button id="mask-draw">Skip an area</button>
<button id="mask-draw" aria-describedby="mask-reason">Skip an area</button>
<button id="mask-clear" hidden>Skip nothing</button>
<span id="mask-note" class="muted"></span>
<span id="mask-reason" class="scan-reason" hidden></span>
</div>
</details>
<details class="fold" data-fold="test-frame"><summary><h2>Test frame</h2></summary>
Expand Down
52 changes: 49 additions & 3 deletions frontend/src/editor/cameraPanel.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,60 @@ describe("the Camera section", () => {
expect(document.querySelector("#framing-overlay polygon")!.getAttribute("points")).toBe("0.1,0.1 0.9,0.1 0.9,0.9");
});

it("can't check framing or calibrate without the output window, or check framing during a scan", () => {
it("says beside them why calibrate and the framing check can't run without the output window (#173)", () => {
const { engine } = fakeEngine();
const panel = mountCameraPanel({ engine, notice: () => {} });
panel.update({ ...status(AC410), output_connected: false }, false);
expect($<HTMLButtonElement>("calibrate").disabled).toBe(true);
panel.update(status(AC410), true);
for (const id of ["calibrate", "check-framing"]) {
expect($<HTMLButtonElement>(id).disabled).toBe(true);
expect($(id).title).toBe("Open the output window first");
}
expect($("camera-reason").hidden).toBe(false);
expect($("camera-reason").textContent).toBe("Open the output window first");
panel.update(status(AC410), false);
expect($<HTMLButtonElement>("calibrate").disabled).toBe(false);
expect($("camera-reason").hidden).toBe(true);
expect($("check-framing").title).toMatch(/^Shows white, then black/); // its description is back
});

it("says to choose a camera when none is, and to start the engine when it's down (#173)", () => {
const { engine } = fakeEngine();
const panel = mountCameraPanel({ engine, notice: () => {} });
const none = status(AC410);
panel.update({ ...none, camera: { ...none.camera, selected: null } }, false);
expect($<HTMLButtonElement>("calibrate").disabled).toBe(true);
expect($("camera-reason").textContent).toBe("Choose a camera first");
panel.update(null, false);
expect($<HTMLButtonElement>("check-framing").disabled).toBe(true);
expect($("camera-reason").textContent).toBe("The engine isn't running: start it with make dev");
});

it("disables what needs the camera while a scan runs, and says why (#173)", () => {
const { engine } = fakeEngine();
const panel = mountCameraPanel({ engine, notice: () => {} });
panel.update(status(AC410), true);
for (const id of ["preview-toggle", "calibrate", "check-framing", "mask-draw"]) {
expect($<HTMLButtonElement>(id).disabled, id).toBe(true);
expect($(id).title, id).toBe("The scan is using the camera");
}
expect($("camera-reason").textContent).toBe("The scan is using the camera");
expect($("mask-reason").textContent).toBe("The scan is using the camera"); // beside Skip an area, further down
expect($<HTMLButtonElement>("mask-clear").disabled).toBe(false); // doesn't need the camera
panel.update(status(AC410), false);
for (const id of ["preview-toggle", "calibrate", "check-framing", "mask-draw"]) expect($<HTMLButtonElement>(id).disabled, id).toBe(false);
expect($("mask-reason").hidden).toBe(true);
});

it("keeps a busy button disabled when status arrives while it runs", async () => {
let finish!: (r: Response) => void;
const { engine } = fakeEngine({ calibrate: () => new Promise<Response>((r) => { finish = r; }) });
const panel = mountCameraPanel({ engine, notice: () => {} });
panel.update(status(AC410), false);
$<HTMLButtonElement>("calibrate").click();
panel.update(status(AC410), false); // e.g. the output window's stats, every 2 s
expect($<HTMLButtonElement>("calibrate").disabled).toBe(true);
finish(json({ exposure: 1200, gain: 3, p99: 201.4 }));
await vi.waitFor(() => expect($<HTMLButtonElement>("calibrate").disabled).toBe(false));
});

it("saves a scan setting as it's changed", () => {
Expand Down
26 changes: 24 additions & 2 deletions frontend/src/editor/cameraPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
const checkFraming = $<HTMLButtonElement>("check-framing");
const framingNote = $("framing-note");
const exposureNote = $("exposure-note");
const framingTip = checkFraming.title; // what it does; its reason replaces it while disabled
async function usingCamera(button: HTMLButtonElement, busyLabel: string, run: () => Promise<void>) {
const label = button.textContent;
button.disabled = true;
Expand Down Expand Up @@ -202,13 +203,34 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
const calibration = $("calibration");
const battery = $("battery");
const batteryLabel = $("battery-label");
const cameraReason = $("camera-reason");
const maskReason = $("mask-reason");
// A reason as the tooltip; none leaves the row's own tooltip showing (an empty title would hide it).
const tip = (el: HTMLElement, text: string | null) => (text ? (el.title = text) : el.removeAttribute("title"));
function render() {
const view = describeStatus(status);
calibration.textContent = view.calibration;
battery.textContent = view.battery?.replace("Battery ", "") ?? "";
battery.hidden = batteryLabel.hidden = view.battery === null;
calibrate.disabled = !status?.output_connected || !status.camera.selected;
checkFraming.disabled = calibrate.disabled || scanning;
// Why calibrate and the framing check can't run, beside them and in their tooltips, as Scan does (#173).
const scanReason = scanning ? "The scan is using the camera" : null;
const reason = scanning ? scanReason
: !status ? view.scanReason // the engine is down
: !status.output_connected ? "Open the output window first"
: !status.camera.selected ? "Choose a camera first" : null;
calibrate.disabled = reason !== null || !!calibrate.dataset.busy;
checkFraming.disabled = reason !== null || !!checkFraming.dataset.busy;
tip(calibrate, reason);
checkFraming.title = reason ?? framingTip;
cameraReason.textContent = reason ?? "";
cameraReason.hidden = !reason;
// The preview and drawing a skipped area (which starts it) need the camera too, but not the output.
const maskDrawButton = $<HTMLButtonElement>("mask-draw");
previewToggle.disabled = maskDrawButton.disabled = scanning;
tip(previewToggle, scanReason);
tip(maskDrawButton, scanReason);
maskReason.textContent = scanReason ?? "";
maskReason.hidden = !scanning;
const sel = status?.hardware.cameras.find((c) => c.unique_id === status?.camera.selected);
const still = sel?.device_type === "still";
apertureRow.hidden = !still; // a webcam's lens has none to set
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/editor/controls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,23 @@ describe("a setting's readout", () => {
it("is the bare value for settings without a unit", () => {
expect(readout(control("fill", "brightness"), 0.5)).toBe("0.5");
});

it("names Edge glow's Smoothing steps instead of 0, 1, 2", () => {
const smoothing = control("edgeglow", "smoothing");
expect([0, 1, 2].map((v) => readout(smoothing, v))).toEqual(["Off", "Low", "High"]);
expect(readout(smoothing, "2")).toBe("High"); // the slider's own string value
});
});

describe("Edge glow's settings (#173)", () => {
const controls = controlsFor(effectById("edgeglow"), {});
const byName = (name: string) => controls.find((c) => c.name === name)!;

it("calls its threshold what it is, says which way it goes, and keeps the saved key", () => {
expect(byName("threshold")).toMatchObject({ label: "Edge threshold", hint: "Higher: only strong edges", min: 0.01, max: 0.5 });
});

it("says what Smoothing does", () => {
expect(byName("smoothing")).toMatchObject({ hint: "Ignores speckle in the scan. Off: every edge, High: only clean lines" });
});
});
3 changes: 2 additions & 1 deletion frontend/src/editor/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ export function parseControlValue(kind: Control["kind"], raw: string): string |
return kind === "range" ? Number(raw) : raw;
}

/** A range setting's value as its readout shows it, with its unit: "6 px", "90°". */
/** A range setting's value as its readout shows it, with its unit or step name: "6 px", "90°", "Low". */
export function readout(c: Control, value: number | string): string {
if (c.kind === "range" && c.steps) return c.steps[Math.round((Number(value) - c.min) / c.step)] ?? String(value);
if (c.kind !== "range" || !c.unit) return String(value);
return c.unit === "°" ? `${value}°` : `${value} ${c.unit}`;
}
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/editor/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,10 @@ function renderPanel() {
patchSurface(surface.id, { params: { [c.name]: parseControlValue(c.kind, input.value) } });
});
row.append(Object.assign(document.createElement("span"), { textContent: c.label }), readout, input);
if (c.kind === "range" && c.hint) {
row.title = c.hint;
input.setAttribute("aria-description", c.hint); // a screen reader may skip the row's title
}
// Shown below while sound is off (see the end of renderPanel); after the slider, so the label names it.
if (c.name === "react") row.append(Object.assign(soundOffNote(), { hidden: true }));
return row;
Expand Down Expand Up @@ -1091,6 +1095,7 @@ function renderScanButton() {
scan.title = reason ?? (scanState.running ? "Stop the scan" : "Project the scan patterns and find the surfaces");
scanReason.textContent = reason ?? "";
scanReason.hidden = !reason;
cameraPanel.update(status, scanState.running); // on scan start and end too, not only on the next status (#173)
}

function render() {
Expand All @@ -1111,7 +1116,6 @@ function render() {
projectSaveName.value = status.project.name;
}
syncOptions(cameraSelect, cameraOptions(status));
cameraPanel.update(status, scanState.running);
}

// Sound: the output window listens; the editor switches it and shows its meter.
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/effects/edgeglow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ export const edgeglow: Effect = {
name: "Edge glow (scan)",
params: [
{ name: "glowColor", label: "Color", type: "color", default: "#22d3ee" },
{ name: "threshold", label: "Sensitivity", type: "number", default: 0.08, min: 0.01, max: 0.5, step: 0.01 },
{ name: "threshold", label: "Edge threshold", type: "number", default: 0.08, min: 0.01, max: 0.5, step: 0.01, hint: "Higher: only strong edges" },
{ name: "spread", label: "Line width", type: "number", default: 2, min: 1, max: 8, step: 1, unit: "px" },
// Ignores stray pixels and fine speckle in the scan (0: every last edge, as it was).
{ name: "smoothing", label: "Smoothing", type: "number", default: 1, min: 0, max: 2, step: 1 },
{ name: "smoothing", label: "Smoothing", type: "number", default: 1, min: 0, max: 2, step: 1, steps: ["Off", "Low", "High"],
hint: "Ignores speckle in the scan. Off: every edge, High: only clean lines" },
{ name: "base", label: "Base light", type: "number", default: 0.1, min: 0, max: 1, step: 0.01 },
{ name: "pulse", label: "Pulse", type: "number", default: 0, min: 0, max: 1, step: 0.01 },
],
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/effects/settingTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { ParamSchema, UniformValue } from "./types";
/** An editor control for one setting, showing the surface's saved value or the default. */
export type Control =
| { name: string; label: string; kind: "color"; value: string }
| { name: string; label: string; kind: "range"; value: number; min: number; max: number; step: number; unit?: string }
| { name: string; label: string; kind: "range"; value: number; min: number; max: number; step: number; unit?: string; steps?: string[]; hint?: string }
| { name: string; label: string; kind: "select"; value: string; options: { value: string; label: string }[] }
| { name: string; label: string; kind: "media"; value: string }
| { name: string; label: string; kind: "text"; value: string };
Expand Down Expand Up @@ -61,6 +61,7 @@ export const SETTING_TYPES: { [K in Kind]: SettingType<K> } = {
control: (p, v) => ({
name: p.name, label: p.label, kind: "range", value: typeof v === "number" ? v : p.default,
min: p.min ?? 0, max: p.max ?? 1, step: p.step ?? 0.01, ...(p.unit ? { unit: p.unit } : {}),
...(p.steps ? { steps: p.steps } : {}), ...(p.hint ? { hint: p.hint } : {}),
}),
validate: (p) =>
p.default < (p.min ?? -Infinity) || p.default > (p.max ?? Infinity)
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/effects/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { textKey, type TextStyle } from "./textTexture";

export type ParamSchema =
| { name: string; label: string; type: "color"; default: string }
// `unit` is shown after the value in the editor's readout ("6 px", "90°").
| { name: string; label: string; type: "number"; default: number; min?: number; max?: number; step?: number; unit?: string }
// `unit` is shown after the value in the editor's readout ("6 px", "90°"); `steps` names each step
// instead ("Off", "Low", "High"); `hint` is the control's one-line tooltip.
| { name: string; label: string; type: "number"; default: number; min?: number; max?: number; step?: number; unit?: string; steps?: string[]; hint?: string }
// One of a few named options; the shader gets the option's index as a float.
| { name: string; label: string; type: "choice"; default: string; options: { value: string; label: string }[] }
// An uploaded image or video (its URL). The shader gets `sampler2D u_<name>` and its pixel size `vec2 u_<name>Size`.
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/output/scanEdges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { linkProgram } from "../effects/compile";
// The scan image's edge strength (#158), computed once per scan, line width and size instead of by
// every Edge glow surface on every frame (8 scan reads per pixel, 11 surfaces: it slowed the rig).
// Strength is the Sobel magnitude of the scan's brightness, 0..~5.7; it's stored as sqrt(m / MAX) in
// 16 bits over two 8-bit channels: 8 bits alone left steps that Sensitivity's sharp threshold made
// 16 bits over two 8-bit channels: 8 bits alone left steps that Edge threshold's sharp cutoff made
// visible, and the square root keeps low strengths (where those thresholds sit) precise.
// `scanEdgeAt()` in the effect preamble decodes it (keep the two in step).

Expand Down
Loading