@rig-c/review 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 firejune (Joon Kyoung)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -51,7 +51,7 @@ For a curated handoff, [REVIEW_PACKAGE.md](docs/REVIEW_PACKAGE.md) documents opt
51
51
  bun run review -- /absolute/path/to/package/review.json --checkpoint silhouette
52
52
  ```
53
53
 
54
- The server prints the exact link, for example `http://127.0.0.1:4317/?checkpoint=silhouette`. `--port 0` selects a free port. It binds only `127.0.0.1` and never opens a browser automatically. A folder containing `review.json` uses that explicit manifest.
54
+ The server prints the exact link, for example `http://127.0.0.1:4317/?checkpoint=silhouette#cap=…`. The `#cap=` fragment is this server's write capability (see [Managed front and write capability](#managed-front-and-write-capability)). `--port 0` selects a free port. It binds only `127.0.0.1` and never opens a browser automatically. A folder containing `review.json` uses that explicit manifest.
55
55
 
56
56
  ## Compare two outputs
57
57
 
@@ -117,6 +117,10 @@ bun run smoke
117
117
 
118
118
  The server bundles `web/main.ts` at startup using the pinned installed runtime, with no CDN. `build` verifies that browser bundle in memory. CI checks types, focused path/state/API tests, bundling and the demo package. Follow AGENTS.md for contributor verification policy. [DESIGN.md](docs/DESIGN.md) distinguishes the runnable foundation from future modeling touch-up and preserves the illustrative screen concept.
119
119
 
120
+ ### Releasing
121
+
122
+ Releases are cut by `.github/workflows/release.yml`. On every push to main, release-please keeps a `release: vX.Y.Z` pull request whose version comes from the conventional-commit subjects since the last release. Merging it tags the version, creates the GitHub release, runs typecheck and the install smoke on the tagged tree, and publishes to npm through trusted publishing (OIDC) with provenance. No npm token is stored. Nothing is published until a release PR is merged.
123
+
120
124
  ## Frame strip export
121
125
 
122
126
  The strip panel exports the explicitly entered inclusive feedback interval, or all current in-memory feedback intervals, as a PNG or JSON download. Batch selections include unsaved annotations; they do not submit or approve a review. Choose a declared reference video in single-package mode and an explicit clock basis A/B in compare mode. Every selected feedback interval must identify the selected animation. Separate intervals remain separate strip groups, including overlaps.
@@ -133,14 +137,16 @@ Single `--reference` is a declared video **id**; compare uses the explicitly sel
133
137
 
134
138
  Strip times require the selected report's exact `frameTimes` and matching animation. Missing or uniform-only clocks refuse PNG with `Report has no frameTimes — regenerate with pose 0.30.0 or later`; JSON retains requested frames with `time:null`. The other compare side may lack a clock: it can still be posed at the common seconds, while its frame label stays unknown. No frame rate is inferred from frame count, duration or video metadata.
135
139
 
136
- PNG export uses the pinned published `@rig-c/play` canvas player and real Node Canvas2D, with `ffmpeg` and `ffprobe` available on PATH for reference decoding. Missing capabilities fail by name. Each requested sample chooses the nearest actual decoded reference PTS (ties earlier); both rigs seek that actual PTS using the viewer's fixed step. JSON distinguishes requested time, actual PTS, delta, decoded frame index, actual player times and independently known producer frames. Requests outside the common domain refuse instead of clamping. The PNG shows source rows, chronological frame columns, and full source hashes/revisions/tool and renderer versions. Batch groups stack vertically. Exports are bounded to64 intervals,256 total frames,64 megapixels and16384 pixels per dimension; larger selections must be split.
140
+ PNG export uses the pinned published `@rig-c/play` canvas player and real Node Canvas2D, with `ffmpeg` (5.1 or later, for `-fps_mode`) and `ffprobe` available on PATH for reference decoding. Missing capabilities fail by name. Each requested sample chooses the nearest actual decoded reference PTS (ties earlier); both rigs seek that actual PTS using the viewer's fixed step. JSON distinguishes requested time, actual PTS, delta, decoded frame index, actual player times and independently known producer frames. Requests outside the common domain refuse instead of clamping. The PNG shows source rows, chronological frame columns, and full source hashes/revisions/tool and renderer versions. Batch groups stack vertically. Exports are bounded to64 intervals,256 total frames,64 megapixels and16384 pixels per dimension; larger selections must be split.
137
141
 
138
142
  The footer fits its measured text while retaining full hashes. RGB/dark-tint features unsupported by the published Canvas2D path refuse with `STRIP_UNSUPPORTED_TINT`; they are not silently omitted.
139
143
 
140
144
  ## One-command owner handoff
141
145
 
142
146
  `open` validates and hashes an existing folder, starts a background loopback
143
- instance or reuses the exact matching one, prints its hash-bound URL, and exits:
147
+ instance or reuses the exact matching one, prints its hash-bound URL, and exits.
148
+ By default the URL goes through the managed front at `127.0.0.1:4318`; `--direct`
149
+ prints the instance's own port instead (see [Managed front and write capability](#managed-front-and-write-capability)):
144
150
 
145
151
  ```sh
146
152
  bun run open -- /absolute/output --reference /absolute/original.mp4
@@ -199,6 +205,40 @@ printed: every current instance binds only `127.0.0.1`.
199
205
  The printed feedback retrieval command uses POSIX shell quoting for literal path
200
206
  arguments. It is not a PowerShell or cmd.exe command string.
201
207
 
208
+ ### Managed front and write capability
209
+
210
+ `open` is **managed by default**. It ensures one detached loopback front at
211
+ `127.0.0.1:4318` (`RIG_REVIEW_FRONT_PORT` overrides) for the state directory and
212
+ prints `http://127.0.0.1:4318/instances/<key>/?input=…&handoff=…#cap=…`. Every
213
+ inspection therefore shares one stable browser origin, so Settings, panel sizes and
214
+ source-bound drafts survive a reopen. The front only routes: it takes the worker
215
+ address from that key's private open record, allows a closed per-method route
216
+ list, bounds bodies (16 KiB control, 1 MiB session/strip) and upstream time,
217
+ limits concurrent upstream streams, re-verifies the worker's nonce on every
218
+ request, and refuses an unknown, expired or replaced instance by name
219
+ (`FRONT_INSTANCE_UNKNOWN`, `FRONT_INSTANCE_EXPIRED`, `FRONT_INSTANCE_REPLACED`).
220
+ Worker error text is not passed through; failures become named `FRONT_*` codes.
221
+ Package assets keep their `sandbox` CSP through the front. It never reads packages.
222
+ Idle fronts exit after 30 minutes once no live instance remains.
223
+
224
+ If another listener holds the front port, `open` fails with
225
+ `FRONT_PORT_BUSY: 127.0.0.1:4318 is held by another listener; use --direct` and
226
+ does not fall back silently. `open --direct` keeps the per-instance port and
227
+ prints that worker's own URL.
228
+
229
+ One origin cannot isolate scripts that run on it, so writes are bound to a
230
+ per-instance capability rather than to the page's origin or the key in its path.
231
+ Each worker mints a 256-bit capability kept only in its private 0600 open record.
232
+ `open` (managed or direct) and the foreground `review`/`review compare` commands
233
+ print it in the `#cap=` fragment, which browsers never send to a server. The page
234
+ moves it into memory and that tab's `sessionStorage`, then removes the fragment
235
+ from the address bar. Every mutation sends it as `X-Review-Capability`; the front
236
+ checks it before forwarding and the worker compares it again in constant time
237
+ before its existing same-origin check. No endpoint returns it, so a page opened
238
+ for one instance cannot submit to another. A copied view link (without the
239
+ fragment) opens read-only and says *Read-only view: reopen with rig-review open to
240
+ edit*; run `open` again for a writable link.
241
+
202
242
  ## Control the current inspection page
203
243
 
204
244
  Opt in once when opening a fixed package, then keep that page open:
@@ -271,10 +311,14 @@ workspace selections are accepted without restoring a top-level workspace switch
271
311
  for a different source binding is refused.
272
312
 
273
313
  Ordinary form inputs, selected panels, view state and unsent feedback/checkpoint
274
- choices are kept in browser local storage for the same source. Changing the
275
- model/source clears the previous draft in that page's storage scope. The global
276
- appearance preference remains, while a source-bound appearance override does
277
- not carry over. URL view state takes precedence over the locally saved view.
314
+ choices are kept in browser local storage for the same source. The draft scope
315
+ includes the source hash (`single:<id>:<sourceHash>`; compare uses both sides'
316
+ id and hash), so two revisions of one package keep separate drafts. Opening a page
317
+ deletes no draft; saving evicts the least-recently-written drafts beyond 32, but
318
+ never one written within the last 24 hours. Browser
319
+ Settings (below) remain, while a source-bound appearance override does not
320
+ carry over. URL view state takes precedence over the locally saved view. Each
321
+ Inspector section's own scroll position is part of this source-bound draft.
278
322
 
279
323
  Local recovery compares the original submitted server-session fingerprint,
280
324
  including its update time. It cannot replace a newer server review: conflicting
@@ -284,3 +328,50 @@ errors never count as a successful save. Nothing is written to the server or
284
328
  input package merely by restoring a page. Browser storage is convenient local
285
329
  recovery, not a durable backup; export feedback separately when it must be kept.
286
330
  Control capabilities are excluded from links and browser persistence.
331
+
332
+ ## Workspace settings and panels
333
+
334
+ The left, right and bottom panels are resizable. Drag a separator, or focus it
335
+ and use the arrow keys (Shift for larger steps) and Home/End. Escape, a
336
+ cancelled pointer or window blur during a drag returns the previous size. The
337
+ stage keeps a usable minimum. When the window is too narrow, the left and then
338
+ the right panel, and when it is too short, the Inspector, become explicit
339
+ drawers opened from buttons above the stage instead of shrinking to nothing. Responsive clamping
340
+ never overwrites the desired size, so a wider window restores it.
341
+
342
+ Settings, in the lower left, holds browser defaults: Theme, Sync cameras by
343
+ default, Repeat idle by default, and Reset layout. Defaults seed a new
344
+ inspection only. A view link or source draft keeps its own appearance, camera
345
+ sync and idle choice, and restoring it neither changes the saved defaults nor
346
+ starts playback. Reset layout restores panel sizes and nothing else.
347
+
348
+ These defaults and desired panel sizes are one versioned, closed record in
349
+ browser `localStorage` (`rig-review-preferences/1`). It holds no source path,
350
+ hash, feedback, review decision or capability. The first load migrates the
351
+ earlier same-origin appearance preference. If storage is denied, unavailable
352
+ or the record is corrupt, the page uses defaults and every control keeps
353
+ working for that page; a corrupt record is refused rather than overwritten
354
+ until you change a setting. Several open tabs merge their writes per field, so
355
+ changing the theme in one tab does not undo a panel size changed in another.
356
+ Separate browser profiles keep separate preferences.
357
+
358
+ Browser storage is per origin. Managed opens share the front's one origin, so
359
+ Settings, panel sizes and drafts carry across reopens. `--direct` and foreground
360
+ serving use per-port origins and keep them separate.
361
+
362
+
363
+ ## Inspect a part
364
+
365
+ The right Parts explorer selects an actual model slot on a specific single/A/B side. All rows follow the public held snapshot's exact draw-order z, including hidden/null slots. Search matches the exact source slot names: a row is shown when its name contains the typed text, case-sensitively, with no case folding or normalization. Arrow keys/Home/End move between result buttons, Enter/Space selects, and Escape returns to search. Whole rig restores full drawing. Selecting a drawn row fits its conservative world bounds within the existing camera zoom range; Sync cameras applies the same normalized camera action to every visible pane, while independent mode changes only that selected pane. Hidden/empty geometry reports `PART_FIT_UNAVAILABLE` and keeps the camera.
366
+
367
+ Solo preserves the full held pose's resolved clips and filters only drawing. In context, the whole rig remains visible with an amber dashed conservative selection box; this box is geometry/clip bounds, not exact source-alpha coverage. Selection changes no track, skin, set, motion, source asset or review decision. The selected slot's side/mode survives source-bound links and local drafts. The Rig inspector shows actual posed mesh counts or region corners, page/region, current skin/set context and declared attachment candidates. **Use slot for feedback** explicitly prefills the target; selecting a row alone submits nothing.
368
+
369
+ The pinned published `@rig-c/play`1.12.0 supplies `slotIdentity()` and frame generation. The explorer atomically retains the public identity snapshot with the same-generation held frame: exact attachment key, owning skin, active set/skin, full z order and explicit hidden/no-attachment/timeline-null/set-hide reasons. Hidden/null slots keep null keys with their reported reason; declarations and texture/UV names never replace that public result. An identity-known attachment need not have a visible primitive or positive fit bounds. The current UI follows its selected slot as animation changes, updating the displayed identity to the actual held frame.
370
+
371
+ Source-bound links/drafts and explicit feedback preserve that side/slot plus exact attachment/owning skin/set/active skin/null reason. A restored explicit target is checked after restoring its animation/time/set and drawing; `PART_IDENTITY_MISMATCH` rolls back rather than silently substituting another key. Older slot-only links remain readable and resolve from the current authoritative snapshot. Runtime generation is current observation/feedback evidence, never a persistent identity. Named vertex weights and original source alpha remain unavailable in this inspection; packed pages are not original part images.
372
+
373
+ This explorer is inspection. Producer-backed optimization and candidate Apply/undo/export remain separate work; the explorer does not generate mesh, rig or motion bytes.
374
+
375
+ ## License
376
+
377
+ MIT. See [LICENSE](LICENSE).
package/docs/DESIGN.md CHANGED
@@ -41,3 +41,32 @@ An inclusive annotation interval can also become a derived frame strip. The sing
41
41
  Exact report `frameTimes` selects the requested seconds. Actual video decoding selects the nearest presented PTS (ties earlier), which drives all rig rows through the same published player/poser and fixed seek step as inspection. Requested frames, decoded PTS and each side's independently known frame remain distinct data. Missing clocks do not become a guessed rate or an empty rendered cell: PNG names the regeneration requirement and JSON can preserve an unmapped selection.
42
42
 
43
43
  The export uses dedicated real canvas players and leaves the live playhead/session alone. PNG pixels, full source identity/footer and JSON are derived output outside the input packages. Exporting never approves a review or authors motion. Browser download controls share the bounded backend plan; a CLI can render and write new external files without providing a filesystem endpoint to the browser. Real CLI canvas/video tests do not establish browser WebGL or interactive presentation parity.
44
+
45
+
46
+ ## Implemented slot inspection and public identity snapshots
47
+
48
+ The right Parts explorer provides source-hash-bound slot selection, held-pose clipped Solo, Whole/context, conservative bounds fit, a diagnostic selection box, actual mesh/region counts and reproducible selection context. This is inspection rather than geometry editing. All rows use the public held identity snapshot's exact z order, including hidden/null slots. Current source-bound links/drafts and control observations retain side/slot/mode.
49
+
50
+ Published Play1.12.0 supplies a frozen `slotIdentity()` snapshot coherent with `posedFrame().generation`. Review retains copied frame/identity together only after equal-generation and source-slot/order checks, uses the player's default public filter, and never seeks/reposes/changes physics merely to read identity. Exact owning skin/key, active set/skin and null reasons come from that snapshot, not a unique-declaration or texture match. A resolved identity can remain known without visible geometry or fit bounds. Clip declarations do not invent keys when the API reports no drawable attachment.
51
+
52
+ Live selection follows a slot. A source-bound bookmark captures its exact identity tuple at that pose; restore validates it after applying time/animation/set and drawing, with transactional rollback on a named mismatch. Old slot-only bookmarks resolve through current public identity. Generation belongs to current readback/feedback, not a stable source target. Named weights and original part alpha remain unavailable without an exact original-source mapping. A conservative bounds box and counts are not source-faithfulness or physics validation.
53
+
54
+ A future optimization operation sends explicit source-bound selection/bounds to the owning producer. That producer returns a new package revision/hash and provenance including producer version/request identity; Review never regenerates rig, motion or mesh bytes. Candidate Apply/undo/export requires its own implemented contract and acceptance, rather than being inferred from the explorer.
55
+
56
+ ## Workspace geometry and browser preferences
57
+
58
+ Left, right and bottom panels have pointer and keyboard separators with separator semantics: orientation, the controlled panel, an accessible name and the actual reachable minimum/maximum/current pixels. Arrow keys act only when the separator itself has focus, so text-entry arrows are never intercepted. A drag holds pointer capture; cancel, lost capture, blur, Escape and disposal restore the previous desired size and release it.
59
+
60
+ The allocator measures the layout rectangle minus stage chrome and keeps a 240×180 CSS-pixel stage minimum. It shrinks side panels proportionally toward their own minima; when the width still cannot hold them it turns the left, then the right panel, and when the height cannot hold it the Inspector, into a native modal drawer opened from a trigger outside it. Effective sizes are computed from desired sizes and never written back; a separator inverts the allocator so a clamped pixel value is not saved as the desktop desire.
61
+
62
+ Browser defaults live in one closed, versioned record: theme, default camera sync, default idle repeat and the three desired panel sizes. Each field carries a logical stamp, so stale whole-record writes from other tabs converge per field without wall-clock ordering; an equal-stamp disagreement quarantines only that field until an explicit edit repairs it. Denied, unavailable or corrupt storage falls back to in-page defaults. The legacy same-origin appearance key migrates once, only when no new record exists. Reset layout changes geometry only.
63
+
64
+ Content state stays source-bound. Each Inspector section's x/y scroll offset is captured before its panel hides and restored after it shows, after two frames of layout, and again when its content extent changes. Restoring clamps to the current extent without replacing the remembered offset, and intentional user scrolling supersedes a pending restore. Offsets are kept in the source draft's UI state, never in the global record or a copied view link.
65
+
66
+ Browser storage is origin-scoped. Managed opens (the default) share one loopback front origin that routes `/instances/<key>/…` to each worker, so Settings, sizes and drafts carry across reopens; `--direct` keeps per-port origins. Because one origin cannot isolate its scripts, writes require a per-instance capability handed off only in the printed `#cap=` fragment and checked by both the front and the worker; a fragment-less copied link is read-only. Draft scopes carry the source hash, so revisions of one package never clobber each other's drafts.
67
+
68
+ ## Comparison eligibility
69
+
70
+ A layout is offered only for inputs that actually loaded and rendered. One pure decision (`src/comparison-eligibility.ts`) takes load outcomes, never declared paths: the rig counts after its first successful draw, an image after `decode()`, a pose after it parses, and a video after `seeked` at the held time with a decoded frame. A new video stays pending until then; a held time outside its duration is refused as `REFERENCE_OUT_OF_DOMAIN`, never clamped.
71
+
72
+ Single review keeps the requested layout in the source-bound draft and shows it when usable; otherwise the most useful loaded single pane is shown. Unavailable layout buttons stay visible, disabled, with the named reason as their description, and no placeholder pane stands in for a missing input. Two-build comparison keeps its identity and domain checks: a failed side leaves the survivor as a static single pane with the failed side named, feedback needs the settled pair, and a real zero-duration domain allows static inspection without playback. Modeling-candidate comparison is reported as unavailable (no producer handoff); no candidate UI exists. Remote observation reports the requested and effective layouts and each reason.
@@ -587,8 +587,11 @@ These optional fields preserve acceptance of older payloads by the current
587
587
  parser; strict older binaries may refuse new observations. This is not a
588
588
  forward-compatibility promise or a new source/review identity.
589
589
 
590
- Browser local storage uses `rig-review-draft/1:<scope>:<sourceHash>` and an active
591
- source marker `rig-review-active/1:<scope>`. A draft has schema
590
+ Browser local storage uses `rig-review-draft/1:<scope>:<sourceHash>`, where the
591
+ scope itself carries the source identity (`single:<id>:<sourceHash>`, or compare's
592
+ A and B id/hash), plus a written-time index `rig-review-draft-index/1`. Opening
593
+ deletes nothing; a write evicts the least-recently-written drafts beyond 32 except
594
+ any written within the last 24 hours. A draft has schema
592
595
  `rig-review-local-draft/1` and a maximum512KiB UTF-8 payload. It contains ordinary
593
596
  ID/radio form inputs, UI selections, the inspection bookmark and the unsent
594
597
  feedback/checkpoint/status session. It records the SHA-256 fingerprint of the
@@ -605,3 +608,20 @@ selection, form and view overrides do not migrate. No restore writes the server,
605
608
  model, motion or source assets. Capability tokens, worker credentials and arbitrary
606
609
  paths/commands are not persistence fields. Local browser storage can be cleared
607
610
  or unavailable and is not a durable backup or an authoritative approval ledger.
611
+
612
+
613
+ ## Parts explorer and source-bound selection
614
+
615
+ Part selection is closed `{side:"single"|"a"|"b",slot:string,mode:"context"|"solo",identity?:{attachment:string|null,skin:string|null,reason:null|"hidden"|"no-attachment"|"timeline-null"|"set-hide",set:string|null,activeSkin:string|null}}` or null for Whole rig. Slot membership is checked against that side's actual model catalog, whose exact byte SHA-256 must equal `reviewedSource.rig.sha256`. The source model remains immutable. The catalog retains explicit slot/bone/setup and skin/attachment declarations; an atlas page does not identify a part. Malformed catalog/hash mismatch refuses rather than substituting a guessed catalog.
616
+
617
+ `selection.part` is optional in versioned inspection links/drafts; absent legacy values mean Whole rig. Unknown keys, invalid modes/sides and unavailable slot membership refuse before restoration mutation. Single cannot restore an A/B target and compare cannot restore a single target. Existing transactional rollback also restores selection. Restoring explicit view cameras does not auto-fit the selected part; a deliberate local selection or Fit action does. The optional `part` control observation reports this exact actual slot/identity selection, and optional `partGeneration:number|null` names its held runtime generation. These observations add no selection/source-writing/approval command. Generation is rejected inside the persisted target identity.
618
+
619
+ `context.part` in explicit feedback carries copied observed side/slot/mode, the exact identity tuple, held generation, null reason, drawn kind/page/region/sequence/z, actual mesh counts, active skin (null means no skin set), active set, declared skin/key candidates, conservative world-Y-up bounds and `activeAttachment:{skin,attachment}|null`. Play1.12.0's public `slotIdentity()` supplies attachment key and owning skin, including ambiguous/linked/set-selected cases. Hidden/non-drawn API reasons preserve attachment/owning skin null rather than guessing a catalog key. `reason:null` identifies a resolved attachment; it does not promise visible pixels, geometry or positive fit bounds. `identityUnavailable` is null after a valid public snapshot, including known null reasons; no held snapshot reports `PART_IDENTITY_UNAVAILABLE`. No UV/topology/texture-name match is identity evidence. Named vertex weights and original source alpha remain unavailable; the package supplies no inferred loose-art mapping.
620
+
621
+ Review copies `posedFrame()` with its explicit (nonenumerable upstream) generation and reads `slotIdentity()` under the same public generation. It respects the default public slot filter rather than requesting an unfiltered frame. Every actual frame row must have its coherent snapshot slot/z and resolved identity, while identity-only rows need not have a visible primitive. Unknown/repeated slots, invalid full z order, invalid key/null reason shape and generation mismatch refuse by name before replacing retained state. Identity checks do not call seek/repose or reset physics.
622
+
623
+ The live selected slot follows animation changes and each successful draw refreshes its actual identity. Bookmarks/drafts capture the tuple at their explicit inspection time; restoration checks that exact tuple only after restoring animation/time/set and drawing. `PART_IDENTITY_MISMATCH` causes the existing full inspection rollback. Legacy targets lacking identity resolve through a valid current public snapshot or refuse `PART_IDENTITY_UNAVAILABLE`; they do not infer keys. Runtime generation is observation/feedback only and is never persisted as a stable target ID.
624
+
625
+ The right list orders every slot by the public snapshot's exact held z, including hidden/null slots. Solo filters a copied full held posed frame after clip resolution; it never sets player skin, set, slots, clock or track. Context/Whole preserve full drawing. An amber diagnostic selection box uses conservative world bounds intersected with a normal clip's AABB; inverse clips remain conservative. It is not alpha coverage or an exact clip polygon. Selection/Fit centers those bounds within the established0.25–8× camera limit. SyncON sends the same fit-relative zoom/normalized pan signature to visible panes; OFF affects only the selected side. Missing/nonpositive bounds report `PART_FIT_UNAVAILABLE`. Fitting never reseeks or authors motion.
626
+
627
+ Selection, links, controls and export never approve a review. Producer-backed optimization must use a separately owned producer returning a new source-bound package revision/hash and provenance; Review does not generate rig, motion or mesh bytes.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
+ "license": "MIT",
6
7
  "scripts": {
7
8
  "review": "bun src/cli.ts",
8
9
  "check": "bun src/cli.ts check",
@@ -16,7 +17,7 @@
16
17
  },
17
18
  "dependencies": {
18
19
  "@napi-rs/canvas": "1.0.11",
19
- "@rig-c/play": "1.11.2"
20
+ "@rig-c/play": "1.12.0"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@types/bun": "^1.3.0",
@@ -34,7 +35,8 @@
34
35
  "examples/demo",
35
36
  "docs/REVIEW_PACKAGE.md",
36
37
  "docs/DESIGN.md",
37
- "README.md"
38
+ "README.md",
39
+ "LICENSE"
38
40
  ],
39
41
  "exports": {
40
42
  "./package.json": "./package.json"
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import {randomBytes} from 'node:crypto';
2
3
  import {parseControlCommand} from './control.ts';
3
4
  import { loadReview } from './manifest.ts';
4
5
  import { formatFeedbackIssues } from './feedback-export.ts';
@@ -14,7 +15,7 @@ import { readSession, type Feedback } from './session.ts';
14
15
  import { readCompareSession } from './compare-session.ts';
15
16
  import { exportReviewStrip, exportCompareStrip, stripJson, writeStripOutput, validateStripOutput } from './strip-export.ts';
16
17
  import type { StripInterval, StripRequest } from './strip.ts';
17
- const usage='Usage: rig-review open <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--control]\n rig-review control <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--page <id> --expected-revision <n> --command <JSON> --wait-ms <n>]\n rig-review feedback <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--format json|issue]\n Usage: bun run review -- <review.json|output-directory> [--port 4317] [--checkpoint ID]\n bun run check -- <review.json|output-directory>\n bun run feedback -- <review.json|output-directory>\n bun run review -- compare <a> <b> --reference <video> --animation <name> [--port 4317]\n bun run check -- compare <a> <b> --reference <video> --animation <name>\n bun run feedback -- compare <a> <b> --reference <video> --animation <name>';
18
+ const usage='Usage: rig-review open <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--control] [--direct]\n open is managed by default: one loopback front at 127.0.0.1:4318 (RIG_REVIEW_FRONT_PORT overrides) serves /instances/<key>/; --direct serves the worker on its own port.\n rig-review control <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--page <id> --expected-revision <n> --command <JSON> --wait-ms <n>]\n rig-review feedback <dir> [--reference <video>] [--compare <dir>] [--animation <name>] [--format json|issue]\n Usage: bun run review -- <review.json|output-directory> [--port 4317] [--checkpoint ID]\n bun run check -- <review.json|output-directory>\n bun run feedback -- <review.json|output-directory>\n bun run review -- compare <a> <b> --reference <video> --animation <name> [--port 4317]\n bun run check -- compare <a> <b> --reference <video> --animation <name>\n bun run feedback -- compare <a> <b> --reference <video> --animation <name>';
18
19
  async function stripCommand(args:string[],options:{stateDir?:string}){
19
20
  const compare=args[0]==='compare';if(compare)args.shift();const a=args.shift(),b=compare?args.shift():undefined;if(!a||compare&&!b)throw new Error('strip: expected package input(s)');
20
21
  let reference:string|undefined,referenceVideo:string|undefined,animation:string|undefined,basis:'a'|'b'|undefined,range:string|undefined,output:string|undefined,jsonOnly=false,all=false;
@@ -34,7 +35,7 @@ async function stripCommand(args:string[],options:{stateDir?:string}){
34
35
  }
35
36
  export function parseOpenInputs(args:string[]):OpenInputs {
36
37
  const input=args.shift();if(!input||input.startsWith('--'))throw new Error(usage);const result:OpenInputs={input},seen=new Set<string>();
37
- while(args.length){const flag=args.shift()!;if(seen.has(flag))throw new Error(`Duplicate open option ${flag}`);seen.add(flag);if(flag==='--control'){result.control=true;continue;}const value=args.shift();if(value===undefined||value==='')throw new Error(`Missing value for ${flag}`);if(flag==='--reference')result.reference=value;else if(flag==='--compare')result.compare=value;else if(flag==='--animation')result.animation=value;else throw new Error(`Unknown open option ${flag}`);}return result;
38
+ while(args.length){const flag=args.shift()!;if(seen.has(flag))throw new Error(`Duplicate open option ${flag}`);seen.add(flag);if(flag==='--control'){result.control=true;continue;}if(flag==='--direct'){result.direct=true;continue;}const value=args.shift();if(value===undefined||value==='')throw new Error(`Missing value for ${flag}`);if(flag==='--reference')result.reference=value;else if(flag==='--compare')result.compare=value;else if(flag==='--animation')result.animation=value;else throw new Error(`Unknown open option ${flag}`);}return result;
38
39
  }
39
40
  async function controlCommand(args:string[],stateDir?:string){
40
41
  const input=args.shift();if(!input||input==='--help'||input==='-h'){console.log(usage);return;}
@@ -75,7 +76,7 @@ function commandHelpRequested(args:readonly string[],command:'open'|'review'|'ch
75
76
  const flags=command==='open'?['--reference','--compare','--animation']:compare?['--reference','--animation','--port']:['--port','--checkpoint','--reference','--compare','--animation'];
76
77
  for(let i=compare?3:1;i<args.length;){
77
78
  const flag=args[i];if(flag==='--help'||flag==='-h')return true;
78
- if(command==='open'&&flag==='--control'){i++;continue;}if(!flags.includes(flag!))return false;i+=2;
79
+ if(command==='open'&&(flag==='--control'||flag==='--direct')){i++;continue;}if(!flags.includes(flag!))return false;i+=2;
79
80
  }
80
81
  return false;
81
82
  }
@@ -98,7 +99,7 @@ export async function main(args:string[],options:{stateDir?:string}={}){
98
99
  const p=await loadCompare({a,b,reference,animation});
99
100
  if(command==='feedback'){printFeedback(await readFeedbackCompareSession(p,options.stateDir));return;}
100
101
  if(command==='check'){console.log(`Comparison valid: ${p.response.title}\nSource SHA-256: ${p.response.sourceHash}\nAnimation: ${animation}\nReference SHA-256: ${p.response.reference.sha256}\nPlayback: two compiled models present`);return;}
101
- const server=await startCompareServer(p,{port,stateDir:options.stateDir});console.log(`Compare ready: http://127.0.0.1:${server.port}/\nComparison: ${p.response.title}\nSource SHA-256: ${p.response.sourceHash}\nPress Ctrl+C to stop.`);return;
102
+ const capability=randomBytes(32).toString('hex'),server=await startCompareServer(p,{port,stateDir:options.stateDir,capability});console.log(`Compare ready: http://127.0.0.1:${server.port}/#cap=${capability}\nComparison: ${p.response.title}\nSource SHA-256: ${p.response.sourceHash}\nPress Ctrl+C to stop.`);return;
102
103
  }
103
104
  const input=args.shift();if(!input)throw new Error(usage);let port=4317;let checkpoint:string|undefined,reference:string|undefined,compare:string|undefined,animation:string|undefined;
104
105
  while(args.length){const flag=args.shift();const value=args.shift();if(!value)throw new Error(`Missing value for ${flag}`);if(flag==='--port'){port=Number(value);if(!Number.isInteger(port)||port<0||port>65535)throw new Error('--port: expected integer 0…65535');}else if(flag==='--checkpoint')checkpoint=value;else if(flag==='--reference')reference=value;else if(flag==='--compare')compare=value;else if(flag==='--animation')animation=value;else throw new Error(`Unknown option ${flag}\n${usage}`);}
@@ -107,7 +108,8 @@ export async function main(args:string[],options:{stateDir?:string}={}){
107
108
  const fromReport=command==='review'&&animation===undefined&&p.report?.animation!==undefined;
108
109
  const selection=animation!==undefined||fromReport?selectAnimation([{label:'single',catalog:p.feedbackCatalog,report:p.report}],animation,true):null;const initialAnimation=selection?.name;if(command==='feedback'){const saved=await readFeedbackSession(p,options.stateDir);await assertReviewUnchanged(p);printFeedback(saved);return;}if(command==='check'){console.log(`Package valid: ${p.manifest.title}\nRevision: ${p.manifest.revision}\nSource SHA-256: ${p.sourceHash}\nDeclared files: ${p.files.size}\nPlayback: ${p.manifest.model?'compiled model present':'unavailable (no model)'}`);return;}
109
110
  if(checkpoint&&!p.manifest.checkpoints.some(v=>v.id===checkpoint))throw new Error(`Unknown checkpoint ${checkpoint}`);
110
- const server=await startServer(p,{port,stateDir:options.stateDir,initialAnimation});const url=new URL(`http://127.0.0.1:${server.port}/`);if(checkpoint)url.searchParams.set('checkpoint',checkpoint);
111
+ // Foreground serving uses the same `#cap=` fragment handoff as open: the printed link alone can submit.
112
+ const capability=randomBytes(32).toString('hex'),server=await startServer(p,{port,stateDir:options.stateDir,initialAnimation,capability});const url=new URL(`http://127.0.0.1:${server.port}/`);if(checkpoint)url.searchParams.set('checkpoint',checkpoint);url.hash='cap='+capability;
111
113
  if(fromReport&&selection)console.error(animationDiagnostic(selection));
112
114
  console.log(`Review ready: ${url}\nPackage: ${p.manifest.title} (${p.manifest.revision})\nSource SHA-256: ${p.sourceHash}\nPress Ctrl+C to stop.`);
113
115
  }
@@ -1,5 +1,5 @@
1
1
  import {createControlServer,type ControlIdentity} from './control-server.ts';
2
- import {openGate,staleOpenStatus,type OpenIdentity} from './open-protocol.ts';
2
+ import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { appRoot, buildWeb } from './build.ts';
5
5
  import { packagePath, safeFile } from './manifest.ts';
@@ -28,13 +28,15 @@ function serveAsset(req:Request,path:string):Response{
28
28
  }
29
29
  return new Response(req.method==='HEAD'?null:file,{headers:{...assetHeaders,'Content-Length':String(size)}});
30
30
  }
31
- export async function startCompareServer(p:ComparePackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;activity?:()=>void;control?:ControlIdentity}={}){
31
+ /** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it. */
32
+ export async function startCompareServer(p:ComparePackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;activity?:()=>void;control?:ControlIdentity;capability?:string}={}){
32
33
  const bundle=await buildWeb('compare');
33
34
  const frames=referenceFrameIndex(p.referencePath,p.response.binding.reference.sha256,p.response.sourceHash);
34
35
  const control=options.control?createControlServer({identity:options.control,sourceHash:p.response.sourceHash,mode:'compare',unchanged:()=>assertCompareUnchanged(p),activity:options.activity}):null;
35
- return Bun.serve({hostname:'127.0.0.1',port:options.port??4317,async fetch(req,server){try{
36
- const url=new URL(req.url),origin=`http://127.0.0.1:${server.port}`;
36
+ const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try{
37
+ const url=new URL(req.url),origin=`http://127.0.0.1:${port}`;
37
38
  if(url.origin!==origin)return json({error:'Loopback host required'},403);
39
+ const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
38
40
  const controlled=await control?.(req,origin);if(controlled)return controlled;
39
41
  options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await assertCompareUnchanged(p);}catch{return staleOpenStatus(options.open!);}}return gate;}
40
42
  if(req.method==='PUT'&&url.pathname==='/api/compare/session'){
@@ -56,5 +58,6 @@ export async function startCompareServer(p:ComparePackage,options:{port?:number;
56
58
  if(url.pathname==='/'||url.pathname==='/index.html')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/compare.html'),{headers:{...headers,'Content-Type':'text/html; charset=utf-8'}});
57
59
  if(url.pathname==='/styles.css')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/compare.css'),{headers:{...headers,'Content-Type':'text/css'}});
58
60
  return json({error:'Not found'},404);
59
- }catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}}});
61
+ }catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}};
62
+ return Bun.serve({hostname:'127.0.0.1',port:options.port??4317,async fetch(req,server){return stampOpen(req,await handle(req,server.port),options.open);}});
60
63
  }
@@ -0,0 +1,54 @@
1
+ /** Layout offers follow actual load events. A declared path, a stored request or a placeholder never establishes an input. */
2
+ export type InputState={state:'loaded'}|{state:'pending'}|{state:'absent'}|{state:'failed';reason:string}|{state:'out-of-domain';reason:string};
3
+ export const singleLayouts=['compare','reference','rig'] as const;
4
+ export const pairLayouts=['compare','a','b','reference'] as const;
5
+ export type SingleLayout=typeof singleLayouts[number];
6
+ export type PairLayout=typeof pairLayouts[number];
7
+ export type ComparisonLayout=SingleLayout|PairLayout;
8
+ /** Review has no producer candidate handoff yet; this is stated, not drawn. */
9
+ export const candidateUnavailable={state:'unavailable',reason:'no producer handoff'} as const;
10
+ export interface Eligibility<L extends ComparisonLayout> {
11
+ mode:'single'|'compare';requested:L;effective:L|null;usable:L[];reasons:Partial<Record<L,string>>;candidate:typeof candidateUnavailable;
12
+ }
13
+ export interface PairEligibility extends Eligibility<PairLayout> {feedback:boolean;playback:boolean;static:boolean}
14
+ export interface SingleInputs {rig:InputState;reference:InputState;requested:SingleLayout}
15
+ export type PairDomain={end:number}|{refused:string}|null;
16
+ export interface PairInputs {a:InputState;b:InputState;reference:InputState;domain:PairDomain;requested?:PairLayout}
17
+
18
+ const prose:Record<InputState['state'],string>={loaded:'',pending:'not rendered yet',absent:'not supplied',failed:'',['out-of-domain']:''};
19
+ /** Named reason for an input that cannot be drawn. Codes are stable; detail is display text. */
20
+ export function inputReason(name:'RIG'|'REFERENCE'|'A'|'B',input:InputState):string|null {
21
+ if(input.state==='loaded')return null;
22
+ if(input.state==='failed')return `${name}_FAILED: ${input.reason}`;
23
+ if(input.state==='out-of-domain')return `${name}_OUT_OF_DOMAIN: ${input.reason}`;
24
+ return `${name}_${input.state.toUpperCase()}: ${name==='REFERENCE'&&input.state==='pending'?'held time not decoded yet':prose[input.state]}`;
25
+ }
26
+ function settle<L extends ComparisonLayout>(mode:'single'|'compare',requested:L,order:readonly L[],reasons:Partial<Record<L,string>>,fallback:readonly L[]):Eligibility<L> {
27
+ const usable=order.filter(layout=>reasons[layout]===undefined);
28
+ const effective=usable.includes(requested)?requested:fallback.find(layout=>usable.includes(layout))??null;
29
+ return {mode,requested,effective,usable,reasons,candidate:candidateUnavailable};
30
+ }
31
+ /** Single review: side by side needs an actually drawn rig AND a decoded reference. */
32
+ export function singleEligibility(value:SingleInputs):Eligibility<SingleLayout> {
33
+ const rig=inputReason('RIG',value.rig),reference=inputReason('REFERENCE',value.reference),reasons:Partial<Record<SingleLayout,string>>={};
34
+ if(rig)reasons.rig=rig;if(reference)reasons.reference=reference;
35
+ if(rig||reference)reasons.compare=[rig,reference].filter(Boolean).join(' · ');
36
+ return settle('single',value.requested,singleLayouts,reasons,value.requested==='reference'?['reference','rig']:['rig','reference']);
37
+ }
38
+ /** A held time outside a video's real domain is refused, never clamped. */
39
+ export function videoReferenceAt(held:number,duration:number):InputState {
40
+ if(!Number.isFinite(duration)||duration<0)return {state:'failed',reason:'video duration is not finite'};
41
+ if(!Number.isFinite(held)||held<0||held>duration)return {state:'out-of-domain',reason:`held time ${held} s is outside the video domain 0–${duration} s`};
42
+ return {state:'pending'};
43
+ }
44
+ /** Two builds: the pair needs both sides, the shared video and a valid common domain. A failed side leaves the survivor usable alone. */
45
+ export function pairEligibility(value:PairInputs):PairEligibility {
46
+ const a=inputReason('A',value.a),b=inputReason('B',value.b),reference=inputReason('REFERENCE',value.reference),reasons:Partial<Record<PairLayout,string>>={};
47
+ if(a)reasons.a=a;if(b)reasons.b=b;if(reference)reasons.reference=reference;
48
+ const domain=value.domain,end=domain&&'end' in domain&&Number.isFinite(domain.end)&&domain.end>=0?domain.end:null;
49
+ const domainReason=a||b||reference?null:domain===null?'DOMAIN_PENDING: shared domain not established':'refused' in domain?`DOMAIN_REFUSED: ${domain.refused}`:end===null?'DOMAIN_REFUSED: shared end is not finite':null;
50
+ if(a||b||reference||domainReason)reasons.compare=[a,b,reference,domainReason].filter(Boolean).join(' · ');
51
+ const result=settle('compare',value.requested??'compare',pairLayouts,reasons,['a','b','reference']);
52
+ const pair=result.usable.includes('compare');
53
+ return {...result,feedback:pair,playback:pair&&end!==null&&end>0,static:pair&&end===0};
54
+ }
package/src/control.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Inspection-only protocol. Source identity and human review decisions are not commands. */
2
+ import {singleLayouts,pairLayouts,type ComparisonLayout,type Eligibility} from './comparison-eligibility.ts';
2
3
  export type ControlMode='single'|'compare';
3
4
  export type ControlCameraTarget='rig'|'a'|'b'|'reference';
4
5
  export interface ControlCamera {zoom:number;pan:{x:number;y:number}}
@@ -13,11 +14,39 @@ export interface ControlView {
13
14
  cameraSync?:boolean;activeCamera?:ControlCameraTarget;
14
15
  cameras:{rig:ControlNumericCamera|null;a:ControlNumericCamera|null;b:ControlNumericCamera|null;reference:ControlNumericCamera|null};
15
16
  }
17
+ export type PartIdentityReason='hidden'|'no-attachment'|'timeline-null'|'set-hide';
18
+ /** Persistable exact target at the bookmarked pose. Generation is observation only. */
19
+ export interface ControlPartIdentity {attachment:string|null;skin:string|null;reason:PartIdentityReason|null;set:string|null;activeSkin:string|null}
20
+ export function parsePartIdentity(value:unknown):ControlPartIdentity {
21
+ const o=controlObject(value,['attachment','skin','reason','set','activeSkin'],'control.part.identity');
22
+ const nullable=(value:unknown,name:string)=>value===null?null:controlString(value,name,512);
23
+ const result={attachment:nullable(o.attachment,'part.attachment'),skin:nullable(o.skin,'part.skin'),reason:o.reason===null?null:choice(o.reason,['hidden','no-attachment','timeline-null','set-hide'] as const,'part.reason'),set:nullable(o.set,'part.set'),activeSkin:nullable(o.activeSkin,'part.activeSkin')};
24
+ if(result.reason===null?(result.attachment===null||result.skin===null):(result.attachment!==null||result.skin!==null))throw new Error('PART_IDENTITY_SHAPE: resolved key/owning skin or explicit null reason required');return result;
25
+ }
26
+ export interface ControlPart {side:'single'|'a'|'b';slot:string;mode:'context'|'solo';identity?:ControlPartIdentity}
27
+ export function parseControlPart(value:unknown):ControlPart|null {if(value===null)return null;const o=controlObject(value,['side','slot','mode','identity'],'control.part');return {side:choice(o.side,['single','a','b'] as const,'control.part.side'),slot:controlString(o.slot,'control.part.slot',512),mode:choice(o.mode,['context','solo'] as const,'control.part.mode'),...(o.identity===undefined?{}:{identity:parsePartIdentity(o.identity)})};}
28
+ /** Observed layout offer: requested (source-bound draft), effective (actually visible) and a named reason per unavailable layout. */
29
+ export interface ControlComparison {mode:'single'|'compare';requestedLayout:ComparisonLayout;effectiveLayout:ComparisonLayout|null;usableLayouts:ComparisonLayout[];reasons:Partial<Record<ComparisonLayout,string>>;candidate:{state:'unavailable';reason:'no producer handoff'}}
30
+ export function controlComparison(value:Eligibility<ComparisonLayout>):ControlComparison {return {mode:value.mode,requestedLayout:value.requested,effectiveLayout:value.effective,usableLayouts:[...value.usable],reasons:{...value.reasons},candidate:{...value.candidate}};}
31
+ export function parseControlComparison(value:unknown):ControlComparison {
32
+ const o=controlObject(value,['mode','requestedLayout','effectiveLayout','usableLayouts','reasons','candidate'],'control.comparison');
33
+ const mode=choice(o.mode,['single','compare'] as const,'control.comparison.mode'),layouts:readonly ComparisonLayout[]=mode==='single'?singleLayouts:pairLayouts;
34
+ const requested=choice(o.requestedLayout,layouts,'control.comparison.requestedLayout');
35
+ if(!Array.isArray(o.usableLayouts)||o.usableLayouts.length>layouts.length)throw new Error('control.comparison.usableLayouts: expected layout list');
36
+ const usable=o.usableLayouts.map(item=>choice(item,layouts,'control.comparison.usableLayouts'));if(new Set(usable).size!==usable.length)throw new Error('control.comparison.usableLayouts: duplicate layout');
37
+ const effective=o.effectiveLayout===null?null:choice(o.effectiveLayout,layouts,'control.comparison.effectiveLayout');
38
+ if(effective===null?usable.length>0:!usable.includes(effective))throw new Error('control.comparison.effectiveLayout: must be a usable layout, null only when none is usable');
39
+ if(usable.includes(requested)&&effective!==requested)throw new Error('control.comparison.effectiveLayout: a usable request is effective');
40
+ const r=controlObject(o.reasons,layouts,'control.comparison.reasons'),reasons:Partial<Record<ComparisonLayout,string>>={};
41
+ for(const layout of layouts){if(usable.includes(layout)){if(r[layout]!==undefined)throw new Error(`control.comparison.reasons.${layout}: usable layout has no refusal`);}else reasons[layout]=controlString(r[layout],`control.comparison.reasons.${layout}`,1024);}
42
+ const c=controlObject(o.candidate,['state','reason'],'control.comparison.candidate');if(c.state!=='unavailable'||c.reason!=='no producer handoff')throw new Error('control.comparison.candidate: no candidate source exists');
43
+ return {mode,requestedLayout:requested,effectiveLayout:effective,usableLayouts:usable,reasons,candidate:{state:'unavailable',reason:'no producer handoff'}};
44
+ }
16
45
  export interface ControlInspection {
17
46
  theme:'system'|'dark'|'light';layout:'auto';effectiveLayout:'row'|'column'|null;
18
47
  cameras:{rig:ControlCamera;reference:ControlCamera;a?:ControlCamera;b?:ControlCamera};animation:string|null;time:number|null;
19
48
  playing:boolean;loop:'idle'|'off';layers:ControlLayers;ready:boolean;seeking:boolean;
20
- dirty:boolean;busy:boolean;feedbackEligible:boolean;view?:ControlView;
49
+ dirty:boolean;busy:boolean;feedbackEligible:boolean;view?:ControlView;comparison?:ControlComparison;part?:ControlPart|null;partGeneration?:number|null;
21
50
  capabilities:{camera:boolean;referenceCamera:boolean;animation:boolean;time:boolean;playback:boolean;layers:ControlLayers};
22
51
  }
23
52
  export type ControlCommand=
@@ -73,9 +102,9 @@ export function parseControlCommand(v:unknown):ControlCommand{
73
102
  return {type,images:bool(base.images,'control.layers.images'),meshes:bool(base.meshes,'control.layers.meshes'),bones:bool(base.bones,'control.layers.bones')};
74
103
  }
75
104
  export function parseControlInspection(v:unknown):ControlInspection{
76
- const o=controlObject(v,['theme','layout','effectiveLayout','cameras','animation','time','playing','loop','layers','ready','seeking','dirty','busy','feedbackEligible','capabilities','view'],'control.inspection');
105
+ const o=controlObject(v,['theme','layout','effectiveLayout','cameras','animation','time','playing','loop','layers','ready','seeking','dirty','busy','feedbackEligible','capabilities','view','part','partGeneration','comparison'],'control.inspection');
77
106
  const c=controlObject(o.cameras,['rig','reference','a','b'],'control.cameras'),a=controlObject(o.capabilities,['camera','referenceCamera','animation','time','playback','layers'],'control.capabilities');
78
- return {...(o.view===undefined?{}:{view:parseControlView(o.view)}),theme:choice(o.theme,['system','dark','light'] as const,'control.theme'),layout:choice(o.layout,['auto'] as const,'control.layout'),effectiveLayout:o.effectiveLayout===null?null:choice(o.effectiveLayout,['row','column'] as const,'control.effectiveLayout'),cameras:{rig:camera(c.rig),reference:camera(c.reference),...(c.a===undefined?{}:{a:camera(c.a)}),...(c.b===undefined?{}:{b:camera(c.b)})},animation:o.animation===null?null:controlString(o.animation,'control.animation'),time:o.time===null?null:num(o.time,'control.time',0,Number.MAX_SAFE_INTEGER),playing:bool(o.playing,'control.playing'),loop:choice(o.loop,['idle','off'] as const,'control.loop'),layers:layers(o.layers),ready:bool(o.ready,'control.ready'),seeking:bool(o.seeking,'control.seeking'),dirty:bool(o.dirty,'control.dirty'),busy:bool(o.busy,'control.busy'),feedbackEligible:bool(o.feedbackEligible,'control.feedbackEligible'),capabilities:{camera:bool(a.camera,'control.capabilities.camera'),referenceCamera:bool(a.referenceCamera,'control.capabilities.referenceCamera'),animation:bool(a.animation,'control.capabilities.animation'),time:bool(a.time,'control.capabilities.time'),playback:bool(a.playback,'control.capabilities.playback'),layers:layers(a.layers)}};
107
+ return {...(o.view===undefined?{}:{view:parseControlView(o.view)}),...(o.comparison===undefined?{}:{comparison:parseControlComparison(o.comparison)}),...(o.part===undefined?{}:{part:parseControlPart(o.part)}),...(o.partGeneration===undefined?{}:{partGeneration:o.partGeneration===null?null:controlInteger(o.partGeneration,'control.partGeneration')}),theme:choice(o.theme,['system','dark','light'] as const,'control.theme'),layout:choice(o.layout,['auto'] as const,'control.layout'),effectiveLayout:o.effectiveLayout===null?null:choice(o.effectiveLayout,['row','column'] as const,'control.effectiveLayout'),cameras:{rig:camera(c.rig),reference:camera(c.reference),...(c.a===undefined?{}:{a:camera(c.a)}),...(c.b===undefined?{}:{b:camera(c.b)})},animation:o.animation===null?null:controlString(o.animation,'control.animation'),time:o.time===null?null:num(o.time,'control.time',0,Number.MAX_SAFE_INTEGER),playing:bool(o.playing,'control.playing'),loop:choice(o.loop,['idle','off'] as const,'control.loop'),layers:layers(o.layers),ready:bool(o.ready,'control.ready'),seeking:bool(o.seeking,'control.seeking'),dirty:bool(o.dirty,'control.dirty'),busy:bool(o.busy,'control.busy'),feedbackEligible:bool(o.feedbackEligible,'control.feedbackEligible'),capabilities:{camera:bool(a.camera,'control.capabilities.camera'),referenceCamera:bool(a.referenceCamera,'control.capabilities.referenceCamera'),animation:bool(a.animation,'control.capabilities.animation'),time:bool(a.time,'control.capabilities.time'),playback:bool(a.playback,'control.capabilities.playback'),layers:layers(a.layers)}};
79
108
  }
80
109
  export function parseControlRequest(v:unknown):ControlRequest{
81
110
  const o=controlObject(v,['schema','requestId','pageId','instanceKey','sourceHash','expectedRevision','command'],'control.request');
@@ -0,0 +1,2 @@
1
+ import {runFront} from './front.ts';
2
+ if(import.meta.main)runFront(Bun.argv[2]??'').catch(error=>{console.error(error instanceof Error?error.message:String(error));process.exit(1);});