{
  "schema": "leumas.docs.page/1",
  "id": "how-to:editors-and-captures",
  "slug": "how-to/editors-and-captures",
  "kind": "pages",
  "bucket": "how-to",
  "title": "Build-knowledge — Editors + Captures",
  "name": "Editors + Captures",
  "eyebrow": "build knowledge",
  "chip": null,
  "summary": "The rule that decides everything here: the UI is never the source of truth. Every useful thing in this layer is a shared operation first, and a React surface second — so the same crop, trim or sensor...",
  "keywords": [
    "editors-and-captures",
    "sections",
    "capture",
    "device",
    "editor",
    "leumas editors and captures",
    "how to editors and captures",
    "turns"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# Build-knowledge — Editors + Captures\n\n**The rule that decides everything here: the UI is never the source of truth.** Every useful thing in\nthis layer is a shared operation first, and a React surface second — so the same crop, trim or sensor\nread is reachable from a Studio panel, an Action, a Workflow node, the Function Index, a toolbank and\nMCP without being written twice.\n\nIf you are about to build an editor, a capture, or anything that turns a device into a file, read the\nfirst two sections before you write code.\n\n---\n\n## The one seam\n\n```\n                    ONE OPERATION (an adapter function)\n                                  │\n    ┌──────────┬──────────┬───────┴────────┬──────────────┬──────────┐\n  React      Action    Workflow node   Function Index   MCP tool  Chatbot fn\n  editor   (curated)  {executor:...}   (harvestAdapters) (free)   (toolRegistry)\n```\n\n**An operation is an adapter function.** That single choice is why nothing else needed registering:\nan adapter function is *already* an MCP tool (`adapterToServer`), a Function Index row\n(`harvestAdapters` reads its `metadata.json` inputs as `from:'metadata'`, which is trusted and\ntherefore enforced), a workflow node (`{executor:{kind:'adapter', ref:'image.crop'}}`) and a chatbot\nfunctioncall.\n\n**It is NOT one Action per operation.** `app.js` already wrote that doctrine down: registering all\n~1,700 adapter functions as actions \"would bury the ~40 curated ones in a picker nobody could scan.\"\nSo the catalog stays adapter-side and exactly **one** curated action fronts it —\n`run-artifact-op`, whose `operationId` is declared as `options: OPERATION_IDS`. Because that\ndescriptor is trusted, `validateInputs` enforces the list and a workflow node renders a `<select>`.\n\n| Want to… | Do this |\n|---|---|\n| add an editing operation | add a function to a `@leumas/adapter-*` pack, then a row in `@leumas/editors`' `OPERATIONS` |\n| add an editor | `defineEditor({id, accepts, operations, load})` in `@leumas/editors/editors` |\n| add a capture source | a row in `CAPTURE_MANIFESTS`, then `defineCapture({...manifest, open, close, grab, load})` |\n| add a sensor type | one row in `@leumas/devices/grobotics`' `CHANNEL_TYPES` — nothing else |\n\n---\n\n## Artifacts\n\nAn artifact **is a datacenter asset row plus one namespaced `artifact` key**. No new store, no\nmigration — `store.updateAsset` merges shallowly and `readMetadata` spreads `...a` last, so unknown\nfields already round-trip.\n\n```js\nartifact: { v, kind, scope, expiresAt, sessionId, source, parents, op, media, filters, checksum }\n```\n\n- **`scope: 'scratch'`** writes to `.scratch/<sessionId>` with `bundleOf: 'scratch:<id>'` — which is\n  the store's *existing* \"do not list me on my own\" flag, so scratch is invisible in the file\n  explorer with zero store changes. TTL 24h (5 min for an abandoned session).\n- **`parents` is a DAG, and derive NEVER mutates.** Undo is re-opening `parents[0]`; there is nothing\n  in memory to lose on a reload, and no way for an undo stack to disagree with what is stored.\n- `artifactId === assetId`. `artifactId` resolves through `inferEntity`, so every such prop gets a\n  picker for free.\n\n**The ingestion policy is `marketplaceMedia.js`'s**: size capped *before* decode, type sniffed from\nthe **decoded bytes** (never the filename), images re-encoded through sharp (which is what makes an\nSVG structurally impossible to store), content-addressed names, quota checked before the write.\n\n---\n\n## Captures\n\n| id | produces | permission |\n|---|---|---|\n| `camera.photo` | `image/webp`, `image/png` | `video` |\n| `camera.video` | `video/webm` | `video+audio` |\n| `mic.audio` | `audio/webm` | `audio` |\n| `screen.video` | `video/webm` | `display` |\n\n**No capture opens a device itself.** Everything goes through `@leumas/devices/media`, which owns the\npermission latch, the ref-counted stream and the hung-prompt timeout.\n\n### Four things that will bite you\n\n1. **The latch keys are `audio`/`video`, not `microphone`/`camera`.** The latch's own `@param` docs\n   are wrong about this and say so.\n2. **Screen share must NOT use the latch.** Chrome shows the picker every call, and *cancelling*\n   throws `NotAllowedError` — the same name a real denial uses. Latching it means the first cancelled\n   picker kills screen capture for the session, behind a Retry pointing at a setting that does not\n   exist. `screen.js` calls `getDisplayMedia` directly and keeps no latch.\n3. **Photo and video share ONE camera** by calling `startBrowserCamera` with the same `source` key.\n   Toggling modes never re-prompts, never blinks the capture light, and cannot hit `NotReadableError`.\n4. **Always pass the WHOLE handle to `stopBrowserCamera`/`stopBrowserMic`.** A bare stream bypasses\n   the refcount and takes every other surface on that device dark.\n\n### A server-side \"take a photo\" action\n\n`getUserMedia` has no server half, so `capture-image` pushes an **addressed SSE directive** and waits.\nThree constraints, all mechanics rather than politeness:\n\n- **Addressed, never broadcast.** `push({})` reaches *every* operator.\n- **The browser never captures without a click.** `getUserMedia` outside a user gesture is refused by\n  every browser, and that refusal is latched *sticky* — so a silent server-driven capture is not\n  merely disallowed, it is impossible to implement correctly.\n- **Bounded.** A workflow `wait` node caps at 30s, so a longer capture timeout buys nothing.\n\n---\n\n## Editors\n\n`image` · `audio` · `video` · `text` · `code` · `json`, resolved by **specificity → priority →\nregistration order**. `text/plain` is legitimately claimed by both the prose and code editors;\npriority decides and the operator can flip it once.\n\nEvery editor `load` is a **thunk**, always. `defineEditor` throws on a component, and `check:editors`\nenforces it — passing one works, looks fine in review, and quietly puts that editor's whole\ndependency tree on the registry's chunk.\n\n**The browser never re-implements a pixel operation.** Crop, resize, rotate and the filters all run on\nthe server through `@leumas/adapter-image`; preview is CSS. `code.format` is declared\n**unavailable** — there is no formatter in this repo, and a fake one returning the input unchanged is\nindistinguishable from a file that was already tidy.\n\n---\n\n## Grobotics — DEVICE → CAPABILITY → CAPTURE\n\n**Grobotics is not the capture. It is the provider; each sensor channel is a capability.**\n\n```\nGrobotics board → 80 channels → capability `grobotics-01/m1.c6` (`sensor.co2`) → read/record/stream\n```\n\nA capability is addressed by **channel position** with an operator label stored in Leumas. The\n*semantic type* is the contract (`sensor.temperature`), the letter code is the wire — which is what\nwill let an Arduino or a remote probe answer the same `read-sensor` action later.\n\n### [critical] Four traps that produce a plausible WRONG number, not an error\n\n| Type | Trap |\n|---|---|\n| `sensor.temperature` | `mc` is **milli**-degrees (`raw×500000/1023 − 50000`). Read as a percent, 22 °C reports as 22000. |\n| `sensor.co2` | `i` is an **index 0–100, not ppm**. A rule comparing it to 400 can never fire. |\n| `sensor.soil`, `sensor.rain` | **Inverted** (`{1023,300}`, `{800,200}`) — a wet probe pulls the ADC *down*. Backwards, a downpour reads as a drought. |\n| `sensor.button` | `s = raw <= high`, a boolean the firmware already computed — not a threshold map. |\n\nAnd one in the wire format: **`t` is the timestamp on a frame and the TYPE on a reading**, whose own\ntimestamp is `ts`.\n\n### Bounds are not optional\n\n`STREAM` **cannot be turned off** — `streamEnabled = (v >= 0)` with `v` clamped non-negative, so the\ndefault is hundreds of frames a second forever. Therefore:\n\n- the recorder **decimates at ingest** (five numbers per capability, constant size at any rate);\n- buckets close on the **wall clock** — `millis()` wraps at 49.7 days and resets on reboot;\n- the line rate is capped and excess is **dropped and counted**, never queued;\n- deadband + stability + minimum-post-interval run **in the session**, so the SSE feed, the recorder\n  and the trigger lane inherit one answer. A TMP36 dithers ±1 °C, and an edge-triggered rule on a\n  value sitting at its limit fires hundreds of times a second without this.\n\n### Recording is fire-and-forget\n\nA workflow `wait` node caps at 30s, so **start → wait → stop silently produces a 30-second dataset**\nfrom a ten-minute request. The recorder finalises *itself* and fires `sensor-recording-finished`; a\nsecond workflow reacts. Rows go to a dataset **asset**; metadata goes to a queryable **row** —\n12,000 samples through `/db` is a denial of service on our own database.\n\n### Thresholds are DERIVED triggers\n\nOne per semantic type, generated from the channel-type table over `device-telemetry` — which is\nalready edge-triggered with per-sink memory. **The session posts the CAPABILITY id as `deviceId`**,\nbecause that trigger keys its edge on that field: the board's id would make all 80 channels share one\nedge, and a channel that stopped firing would look like a broken sensor.\n\n### The firmware\n\nLeumas owns it now (`shared/engines/adapters/hardware/grobotics-firmware`). The shipped V3 image had\na **buffer overflow** — `appendReading` returned snprintf's would-be length, so `off` ran past a\n192-byte array and the next call got an underflowed 16-bit `cap - off` (~65535) and wrote past it,\nputting adjacent SRAM on the wire. Patched, buffer raised to 384, and **not compiled or flashed** —\n`arduino-cli` is not installed; the arithmetic is proven by simulation.\n\n---\n\n## Guards\n\n```sh\npnpm check:editors      # barrels isomorphic · subpaths resolve · no component as `load` · not on a boot path\npnpm check:devices      # the browser/Node split, the tab strip, driver honesty\npnpm check:props        # every routed verb declares its props\npnpm check:boot         # nothing new is eager\n```\n\n`check:editors --self-test` re-runs every matcher against deliberately broken input. Its own\nself-test caught a regex that would have let a `./react` re-export through — a guard that passes\nbecause its matcher is wrong is worse than no guard.\n",
  "source": {
    "path": "shared/services/knowledge/build-knowledge/editors-and-captures.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 10501,
    "hash": "617edcc5f17086eee9c7b759273b7f9ccf22f51a"
  },
  "urls": {
    "html": "/p/how-to/editors-and-captures",
    "json": "/docs/how-to/editors-and-captures.json",
    "md": "/docs/how-to/editors-and-captures.md"
  },
  "links": {
    "composes": [],
    "usedBy": [],
    "product": [],
    "howTo": [],
    "skills": []
  }
}
