@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 +21 -0
- package/README.md +98 -7
- package/docs/DESIGN.md +29 -0
- package/docs/REVIEW_PACKAGE.md +22 -2
- package/package.json +5 -3
- package/src/cli.ts +7 -5
- package/src/compare-server.ts +8 -5
- package/src/comparison-eligibility.ts +54 -0
- package/src/control.ts +32 -3
- package/src/front-worker.ts +2 -0
- package/src/front.ts +127 -0
- package/src/open-protocol.ts +17 -0
- package/src/open.ts +46 -30
- package/src/server.ts +8 -7
- package/src/strip-render.ts +1 -1
- package/web/camera-view.ts +10 -0
- package/web/compare.css +44 -0
- package/web/compare.html +3 -3
- package/web/compare.ts +78 -34
- package/web/control.ts +6 -4
- package/web/index.html +3 -3
- package/web/inspection-persistence.ts +30 -11
- package/web/instance-access.ts +23 -0
- package/web/main.ts +85 -41
- package/web/part-selection.ts +136 -0
- package/web/rig-overlays.ts +9 -6
- package/web/styles.css +41 -0
- package/web/view-controls.ts +7 -5
- package/web/workspace-layout.ts +84 -0
- package/web/workspace-preferences.ts +95 -0
- package/web/workspace-ui.ts +91 -0
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
|
|
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.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
package/docs/REVIEW_PACKAGE.md
CHANGED
|
@@ -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
|
|
591
|
-
source
|
|
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
|
|
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.
|
|
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}
|
|
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
|
-
|
|
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
|
}
|
package/src/compare-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
const url=new URL(req.url),origin=`http://127.0.0.1:${
|
|
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');
|