Compare commits
357
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a9738d6aa | ||
|
|
30227b661f | ||
|
|
4bcc35c1a0 | ||
|
|
a65ae9c285 | ||
|
|
5e97fa293f | ||
|
|
aba06086ac | ||
|
|
424374263e | ||
|
|
906a6ce37b | ||
|
|
1496184167 | ||
|
|
9561a068ab | ||
|
|
9e686b3311 | ||
|
|
936ee479ed | ||
|
|
b82a97fe8b | ||
|
|
90bdc27785 | ||
|
|
0b01bb7775 | ||
|
|
b688347778 | ||
|
|
252cbc8495 | ||
|
|
048716b3b3 | ||
|
|
343ddeac2d | ||
|
|
5983883ffe | ||
|
|
6290c75210 | ||
|
|
3c81c39a1c | ||
|
|
932c5216dc | ||
|
|
f9a76fed0e | ||
|
|
c05db1fbe0 | ||
|
|
385082c8ca | ||
|
|
cb1e1494af | ||
|
|
d943ec853c | ||
|
|
b111406cf8 | ||
|
|
28effdde23 | ||
|
|
2920fc7579 | ||
|
|
147a623df9 | ||
|
|
62c8ed421a | ||
|
|
be079bf18f | ||
|
|
73fbfddf6c | ||
|
|
3ff3b9b587 | ||
|
|
9a956c318a | ||
|
|
209d0bfc26 | ||
|
|
f50e0077c5 | ||
|
|
fb5bf943c9 | ||
|
|
bab0bb2df2 | ||
|
|
e21332b9e1 | ||
|
|
078b8421a6 | ||
|
|
84624ae3ea | ||
|
|
477886d100 | ||
|
|
1d706dd169 | ||
|
|
d1e6e42cf4 | ||
|
|
a0152f371e | ||
|
|
5b07372791 | ||
|
|
b5080671c9 | ||
|
|
15be698097 | ||
|
|
3679e43fe3 | ||
|
|
05590867b7 | ||
|
|
33cef2fdea | ||
|
|
6276bbf324 | ||
|
|
1084984da2 | ||
|
|
669bb64423 | ||
|
|
eb416ff80a | ||
|
|
4fa1669ab7 | ||
|
|
4fa6597b18 | ||
|
|
992c5a8b74 | ||
|
|
7400fe2fd7 | ||
|
|
8877ee947f | ||
|
|
a7caed70c1 | ||
|
|
a55ef475d9 | ||
|
|
34ed8a0f5f | ||
|
|
64860be4ae | ||
|
|
4ac07f7671 | ||
|
|
bcda5d001a | ||
|
|
cbbaea9148 | ||
|
|
3d3c0eced2 | ||
|
|
d50d3bb3d3 | ||
|
|
51bb1369eb | ||
|
|
a3fc13ad63 | ||
|
|
c041a569f6 | ||
|
|
5ebc9fc27c | ||
|
|
99eafaf02d | ||
|
|
03830cd15e | ||
|
|
6e7428a116 | ||
|
|
3e5aedd74d | ||
|
|
687c050c28 | ||
|
|
4e00e804f3 | ||
|
|
456141a3f3 | ||
|
|
7d911b4b1f | ||
|
|
71c8b043c9 | ||
|
|
0752e5c6bf | ||
|
|
1001a31638 | ||
|
|
85035fa07b | ||
|
|
eaad9deda1 | ||
|
|
7217244886 | ||
|
|
c7843a3c7e | ||
|
|
a3138f3d71 | ||
|
|
d01be34cac | ||
|
|
ac4a00c67d | ||
|
|
aee60f12b9 | ||
|
|
321cde70e8 | ||
|
|
986ea69610 | ||
|
|
72a2f37c03 | ||
|
|
4a24275ddc | ||
|
|
17a07355cf | ||
|
|
cd8a1045c4 | ||
|
|
3235ba7b1c | ||
|
|
9e6b5b403a | ||
|
|
9ef115d3b4 | ||
|
|
606cbbfae2 | ||
|
|
0ca7316a24 | ||
|
|
aff331082f | ||
|
|
52da9b75b7 | ||
|
|
be50144ca8 | ||
|
|
81ae4425cb | ||
|
|
8eaa3ab497 | ||
|
|
b7a51e26e6 | ||
|
|
b8fb4ebcba | ||
|
|
e38f2fb1da | ||
|
|
9df9f58ab8 | ||
|
|
aacc6dc43b | ||
|
|
13ed096f80 | ||
|
|
31313c3c96 | ||
|
|
7aa3ce55c0 | ||
|
|
c70ad345ea | ||
|
|
de19229895 | ||
|
|
b0d0bc8d7f | ||
|
|
6b810952a4 | ||
|
|
998775c3af | ||
|
|
438e0ca263 | ||
|
|
3ecbe3fb21 | ||
|
|
b1f9957317 | ||
|
|
8bd632509e | ||
|
|
8e161a197f | ||
|
|
7756310bdf | ||
|
|
b38cd51338 | ||
|
|
40794f13aa | ||
|
|
720487379c | ||
|
|
c9242480c2 | ||
|
|
95ac235122 | ||
|
|
a680debfaf | ||
|
|
029b486c67 | ||
|
|
ae1e41f7fe | ||
|
|
8ef215e98f | ||
|
|
78a3dc2162 | ||
|
|
1b3e0b3406 | ||
|
|
6d0abbc569 | ||
|
|
de12e96297 | ||
|
|
4c763bd8aa | ||
|
|
67bd96868f | ||
|
|
7a72a206f1 | ||
|
|
12c8a2d74c | ||
|
|
3982256f08 | ||
|
|
c1b0f6f8a3 | ||
|
|
55b7821a5c | ||
|
|
b44c4b3265 | ||
|
|
a2cb50d1ad | ||
|
|
8fe6184c52 | ||
|
|
a9a5ca5a26 | ||
|
|
29fee5c0ac | ||
|
|
dc6f9b66da | ||
|
|
dee6d57133 | ||
|
|
8c91d40e2e | ||
|
|
81d7ad1a77 | ||
|
|
e4e5bf6ea1 | ||
|
|
fdcaf57ae7 | ||
|
|
e9bbfb9a41 | ||
|
|
7d1a70d8e0 | ||
|
|
e56fa0c074 | ||
|
|
2ab9e45548 | ||
|
|
091ab2671e | ||
|
|
cc6838ed24 | ||
|
|
8e4891d608 | ||
|
|
2678295ca7 | ||
|
|
0c3855eac5 | ||
|
|
b91d3b913e | ||
|
|
5a06194962 | ||
|
|
f2a4dfe863 | ||
|
|
8e3710b493 | ||
|
|
66f8d7a1fa | ||
|
|
d95a13059c | ||
|
|
6180c3b8b7 | ||
|
|
32c5d0dda5 | ||
|
|
611853d3af | ||
|
|
9db39347bd | ||
|
|
1e1b7fe43d | ||
|
|
82a44478fb | ||
|
|
8b1f109b09 | ||
|
|
c455301764 | ||
|
|
636db089ae | ||
|
|
d8ccc9345a | ||
|
|
1eba7c82ba | ||
|
|
2a97cf28c0 | ||
|
|
37b8930527 | ||
|
|
79eb4b46f7 | ||
|
|
783aa444ac | ||
|
|
bfd95c7fee | ||
|
|
d729abab31 | ||
|
|
1f20e0d7d9 | ||
|
|
6e5124bd9c | ||
|
|
c018f2b074 | ||
|
|
e35f81ac34 | ||
|
|
6db5ac05ef | ||
|
|
a2fcaa7b39 | ||
|
|
65a72d40a8 | ||
|
|
d2964a0ddc | ||
|
|
a63d2fe15b | ||
|
|
2ffbb51a7e | ||
|
|
4a65b52f6d | ||
|
|
1c0181297d | ||
|
|
1f4c960cc4 | ||
|
|
b8a008b50e | ||
|
|
9fc002d86f | ||
|
|
667db00b52 | ||
|
|
67e12a47a4 | ||
|
|
438196cd6d | ||
|
|
fa20079c9e | ||
|
|
b894945344 | ||
|
|
621084fcd6 | ||
|
|
95c6540691 | ||
|
|
2c016b117d | ||
|
|
fa2d66ab0e | ||
|
|
9bbd5845b7 | ||
|
|
32257fc2b4 | ||
|
|
10c306f162 | ||
|
|
091d560ac8 | ||
|
|
001d597a89 | ||
|
|
a44d7627fd | ||
|
|
2ffaf94052 | ||
|
|
f50405d907 | ||
|
|
4db00b53f7 | ||
|
|
e9ff29297b | ||
|
|
360ec39ff3 | ||
|
|
dc55ff16c9 | ||
|
|
453d760be4 | ||
|
|
42041b37cd | ||
|
|
05413e3f5c | ||
|
|
677dbdb776 | ||
|
|
f791b63890 | ||
|
|
36ea7ea3d8 | ||
|
|
a1e2cb523f | ||
|
|
e6d5411bdd | ||
|
|
4c83e8a4e7 | ||
|
|
796b9306f9 | ||
|
|
c6c48fbc38 | ||
|
|
3449b2bc3e | ||
|
|
04a658b218 | ||
|
|
cfe17e49bb | ||
|
|
d8b7ed4526 | ||
|
|
21c7a5507c | ||
|
|
15821a1c98 | ||
|
|
41e347d20a | ||
|
|
8446eb4492 | ||
|
|
f2f044080f | ||
|
|
054feec0d2 | ||
|
|
13c35ff446 | ||
|
|
e97573e221 | ||
|
|
2e5c4ba1c5 | ||
|
|
b037076506 | ||
|
|
cea63f9a1e | ||
|
|
d6decbe05c | ||
|
|
60ba64004b | ||
|
|
951b40c870 | ||
|
|
881e97312b | ||
|
|
c01712c30d | ||
|
|
f57d64bf52 | ||
|
|
3333e9ac0f | ||
|
|
2dfb34ef21 | ||
|
|
75a3e669d9 | ||
|
|
e4fdd9fa20 | ||
|
|
3f549f91f1 | ||
|
|
37c24b5fa8 | ||
|
|
5abd07d2bf | ||
|
|
bc61331604 | ||
|
|
49b0f47fea | ||
|
|
0fb2782f2d | ||
|
|
6ad1803aaa | ||
|
|
8ace0cd102 | ||
|
|
b9ce5f53a0 | ||
|
|
8eecb8ed69 | ||
|
|
a0e9597cb5 | ||
|
|
97a3775acf | ||
|
|
f63ed36913 | ||
|
|
101707d54c | ||
|
|
53fd1e3bfc | ||
|
|
60e79160c9 | ||
|
|
17a0e7a7f5 | ||
|
|
ee7d1dd26c | ||
|
|
6cc40282e0 | ||
|
|
1995dfdf68 | ||
|
|
b7ccca3481 | ||
|
|
359f0d1d39 | ||
|
|
b790f29d59 | ||
|
|
a78c8c83f5 | ||
|
|
6cb14954d4 | ||
|
|
b4e3b3b735 | ||
|
|
01ec12263a | ||
|
|
e1809ac9e1 | ||
|
|
630d1ae819 | ||
|
|
12fba7126d | ||
|
|
fc8c81dbc4 | ||
|
|
a19053dfd4 | ||
|
|
2548bb5740 | ||
|
|
fc5cbdff8a | ||
|
|
c9b1009259 | ||
|
|
6290fcb2ce | ||
|
|
81761067e9 | ||
|
|
7fba39a629 | ||
|
|
230cba4b21 | ||
|
|
e6dd8c2cf1 | ||
|
|
23181c867b | ||
|
|
cfc7b062da | ||
|
|
5a29a536a2 | ||
|
|
082b7057da | ||
|
|
10019a454a | ||
|
|
f1f8e21b78 | ||
|
|
ae2ce055c8 | ||
|
|
a7babf60db | ||
|
|
90450ae320 | ||
|
|
ecd95a22f0 | ||
|
|
9f712bf353 | ||
|
|
ee15160862 | ||
|
|
3a64f54dea | ||
|
|
a0706fd5d8 | ||
|
|
b53d6d5a45 | ||
|
|
ada2a55ee6 | ||
|
|
2b53168149 | ||
|
|
31fc4f6567 | ||
|
|
648d5bced4 | ||
|
|
75d2e5da4b | ||
|
|
57b2208cae | ||
|
|
fecb5885d0 | ||
|
|
71c85e9894 | ||
|
|
ffffee1879 | ||
|
|
d7a2c22faf | ||
|
|
7b7b5d6cad | ||
|
|
36f0c93d2a | ||
|
|
a29b38a0d7 | ||
|
|
eddc09008e | ||
|
|
a4accf0fb6 | ||
|
|
ea811ff370 | ||
|
|
d7749208a7 | ||
|
|
af9319a33b | ||
|
|
0ac6424c46 | ||
|
|
574a494759 | ||
|
|
aa3680948f | ||
|
|
9d51080d2e | ||
|
|
24a47318f2 | ||
|
|
f9ffb7bd1c | ||
|
|
e94c64eebd | ||
|
|
007ff9fff2 | ||
|
|
656f0c524d | ||
|
|
aa2df560b7 | ||
|
|
2bda1986bd | ||
|
|
a281faf923 | ||
|
|
05bdab24a5 | ||
|
|
e6f7648b84 | ||
|
|
19ab973110 | ||
|
|
63dd8c8790 | ||
|
|
27bf7527df | ||
|
|
9225227421 | ||
|
|
8459bb03fb |
@@ -0,0 +1,260 @@
|
||||
---
|
||||
name: mission-core-product-ui
|
||||
description: Design and enforce Mission Core product surfaces, information architecture, product UI, and laboratory presentation. Use for every new or relocated workspace, product root, navigation category, panel, viewer, Control Station surface, LAB catalog/summary, visualization control, fullscreen behavior, product-facing status, or UI refactor in NODEDC_MISSION_CORE, including work that could introduce a new component, page pattern, or task-specific composition.
|
||||
---
|
||||
|
||||
# Mission Core product UI
|
||||
|
||||
## Required context
|
||||
|
||||
Before changing UI:
|
||||
|
||||
1. Read the repository `AGENTS.md`.
|
||||
2. Read `docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md`.
|
||||
3. Read `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
|
||||
4. For a new, relocated, or materially changed non-LAB surface, read
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
|
||||
5. For LAB work, also read `docs/15_LABORATORY_RUN_CANON.md`.
|
||||
6. Read the sibling Design Guideline:
|
||||
- `registry/registry.json`
|
||||
- `registry/components.json`
|
||||
- `registry/icons.json`
|
||||
- `docs/COMPONENTS.md`
|
||||
- `docs/GOVERNANCE.md`
|
||||
- the documentation for the affected page pattern or component.
|
||||
|
||||
Do not start implementation before resolving the required controls and icons in
|
||||
the registries.
|
||||
|
||||
## Canonical local endpoint
|
||||
|
||||
- Keep the integrated Mission Core Control Station and API exclusively on
|
||||
`http://127.0.0.1:8000`.
|
||||
- Treat the canonical `8000` process as durable operator state, not as a
|
||||
disposable QA process. Finishing a test, build, browser check, Codex turn, or
|
||||
implementation increment never authorizes stopping it.
|
||||
- At the start of every Control Station task, inspect `8000`. If it is not
|
||||
serving Mission Core, start the canonical integrated service before UI work.
|
||||
At handoff, verify it again and leave it running.
|
||||
- A rebuild may replace the process on `8000`, but the replacement must be the
|
||||
same canonical service and must be listening successfully before handoff.
|
||||
Never leave the operator with a stopped Control Station after a restart.
|
||||
- Never start a second integrated backend on an alternate port to bypass a
|
||||
stale or occupied `8000`. In particular, TCP `8765` is a legacy Foxglove
|
||||
regression port and is not an operator path.
|
||||
- Before browser QA or product handoff, inspect listeners on `8000` and `8765`.
|
||||
If the Mission Core process on `8000` is stale, restart that exact canonical
|
||||
service instead of launching another instance.
|
||||
- Use `5173` only for an explicitly requested Vite development session and
|
||||
`4173` only for an explicitly requested production preview. Both must proxy
|
||||
the API to `8000`; neither changes the canonical integrated endpoint.
|
||||
- Finish with exactly one integrated Mission Core backend on `8000` and no
|
||||
Mission Core backend on `8765`. Open or hand off `8000`, never an alternate
|
||||
backend URL.
|
||||
|
||||
## Local workstation resource gate
|
||||
|
||||
- The local operator machine is a 14-inch 2023 MacBook Pro with only 18 GB of
|
||||
physical memory. Its memory, swap, CPU, GPU, and thermal headroom are part of
|
||||
the product-test safety boundary.
|
||||
- Run memory-intensive work sequentially. Never overlap a full frontend test
|
||||
pass, production build, Docker/Compose build or startup, synthetic replay,
|
||||
and browser visual QA.
|
||||
- Before starting a memory-intensive operation, inspect host memory pressure and
|
||||
the active Docker/process workload. If memory pressure is elevated or swap is
|
||||
growing, stop temporary work and recover headroom before continuing.
|
||||
- Prefer focused tests and an existing valid build. Run the full suite and
|
||||
production build only when they are required acceptance gates, one at a time.
|
||||
- Do not start Docker Desktop, Compose stacks, duplicate application servers,
|
||||
duplicate browser automation sessions, or load/stress tests for convenience.
|
||||
Bounded load work belongs on Worker 006, not on the Mac.
|
||||
- After each test, build, replay, or visual-QA stage, stop temporary watchers,
|
||||
publishers, viewers, workers, and containers before starting the next heavy
|
||||
stage. Preserve only the canonical integrated service on `8000` and other
|
||||
explicitly required durable operator services.
|
||||
- Do not change Docker Desktop CPU or memory limits without explicit product
|
||||
owner approval. A configured Docker VM ceiling is not safe available host
|
||||
memory.
|
||||
|
||||
## Component gate
|
||||
|
||||
- Reuse package exports from `@nodedc/ui-react`, `@nodedc/ui-core`,
|
||||
`@nodedc/tokens`, and `@nodedc/page-patterns`.
|
||||
- Do not create a local substitute for a button, icon button, segmented switch,
|
||||
select, dropdown, glass surface, status, window, focus state, hover state, or
|
||||
page pattern.
|
||||
- Treat an absent generic visual entity as a product decision. Stop, describe
|
||||
the user need and proposed generic API, and obtain explicit approval.
|
||||
- After approval, implement the generic entity in the Design Guideline first,
|
||||
including registry, documentation, interaction states, catalog example, and
|
||||
validation. Consume it from Mission Core only afterward.
|
||||
- Allow Mission Core domain renderers, data adapters, and domain layouts. Build
|
||||
their controls from canonical exports.
|
||||
|
||||
Explicit approval for one entity does not authorize unrelated visual entities.
|
||||
|
||||
## Product surface design
|
||||
|
||||
Keep product grammar stable without forcing one layout:
|
||||
|
||||
- Reuse the canonical shell, controls, states, icons, focus, motion, and
|
||||
responsive behavior.
|
||||
- Never add decorative perimeter borders, outlines, inset rings, or persistent
|
||||
edge strokes to Mission Core cards, panels, topology nodes, telemetry blocks,
|
||||
task stages, or LAB content surfaces. Separate product hierarchy with
|
||||
approved surface tones, spacing, typography, and elevation.
|
||||
- Preserve canonical keyboard focus indicators and accessibility outlines from
|
||||
the Design Guideline. The no-outline rule applies to decoration, never to
|
||||
focus visibility, data-visualization grids, or a necessary structural
|
||||
divider.
|
||||
- Change composition when the operator job, primary entity, lifecycle, or
|
||||
action model changes materially.
|
||||
- Do not treat the LAB template as the default for non-LAB work.
|
||||
- Choose the smallest complete surface: view mode, inspector/panel, floating
|
||||
window, existing section, dedicated workspace, product root, plugin slot, or
|
||||
LAB result.
|
||||
|
||||
Before adding or relocating a workspace or changing primary navigation:
|
||||
|
||||
1. State the operator job, trigger/frequency, primary entity/lifecycle,
|
||||
evidence, authority, actions, and complete state grammar.
|
||||
2. Compare at least two credible placements or compositions.
|
||||
3. Resolve the Design Guideline primitives and page patterns.
|
||||
4. Classify the novelty:
|
||||
- domain content in an admitted composition;
|
||||
- new Mission Core-specific composition;
|
||||
- new shared Design Guideline entity/pattern;
|
||||
- new product root or platform meaning.
|
||||
5. Present the selected placement and rejected alternative with UX reasoning.
|
||||
6. Obtain product-owner agreement for a new/relocated workspace, primary
|
||||
navigation change, new root, or shared visual pattern.
|
||||
7. Implement only after the decision; do not add placeholder product routes or
|
||||
temporary navigation.
|
||||
|
||||
Valid task-specific compositions include scene-first, map-first,
|
||||
timeline-first, list/detail queue, table/catalog, form/editor, graph/topology,
|
||||
dashboard, and immutable LAB review.
|
||||
|
||||
## Application architecture
|
||||
|
||||
- Preserve `core → components/renderers → workspaces → composition/App`.
|
||||
- Core owns contracts, adapters, state machines, and domain hooks. It must not
|
||||
import visual adapters, workspaces, or App.
|
||||
- Reusable components may consume Core and canonical UI exports. They must not
|
||||
import workspaces or own product navigation.
|
||||
- Add every new domain or LAB run as a bounded feature module. Do not append its
|
||||
API adapter, renderer, selectors, or styles to `App.tsx`,
|
||||
`Workspaces.tsx`, or `styles/workspaces.css`.
|
||||
- Keep LAB composition under `workspaces/laboratory` and pass generic host
|
||||
viewers through typed contracts.
|
||||
- Treat `productModel.ts` as the application registry. Do not duplicate root,
|
||||
workspace, icon, or capability lists in the shell.
|
||||
- Do not introduce a runtime ontology merely to organize code. Use the typed
|
||||
registry, executable contracts, and experimental vocabulary until the
|
||||
multi-consumer admission gate in the architecture canon is met.
|
||||
- Never raise an architecture line-count ratchet to fit a new feature. Split
|
||||
the feature and retain the boundary test.
|
||||
|
||||
## Laboratory product contract
|
||||
|
||||
Use the executable template
|
||||
`missioncore.laboratory-report/v1`:
|
||||
|
||||
`selectors → compact summary → evidence → result → optional reusable details`
|
||||
|
||||
For every new bounded `workspaces/laboratory/ENNResult.tsx`:
|
||||
|
||||
1. Pass typed values into `LaboratorySummary`; always supply `question`,
|
||||
`approach`, `principalResult`, and `limitation`.
|
||||
2. Put visual evidence in `LaboratoryEvidence`.
|
||||
3. Pass result metrics and `proved`, `notProved`, and `decision` into
|
||||
`LaboratoryResultSummary`.
|
||||
4. Do not render the `laboratory-summary`, `laboratory-result-summary`,
|
||||
`laboratory-result-metrics`, or `laboratory-result-conclusion` classes in the
|
||||
LAB module.
|
||||
5. Do not change slot order or shared report geometry for one run.
|
||||
|
||||
Treat a missing field as missing product evidence, not as permission to omit
|
||||
the section. An admitted diagnostic viewer may own the result interaction only
|
||||
when the existing canon explicitly identifies that viewer as the result
|
||||
instrument.
|
||||
|
||||
Changing the v1 anatomy requires product-owner agreement, a new template
|
||||
version, migration of every consumer, updated canon, and an updated
|
||||
architecture test. Never drift v1 incrementally from a single LAB.
|
||||
|
||||
The compact summary must cover:
|
||||
|
||||
- decision question and purpose;
|
||||
- immutable source and tested bounds;
|
||||
- pipeline, execution class, models, algorithms, tools, and worker/runtime;
|
||||
- experimental feature or configuration;
|
||||
- principal result and limitation;
|
||||
- retained authority.
|
||||
|
||||
Keep manifests, long hashes, implementation files, validation logs, regressions,
|
||||
rejected approaches, and next-stage planning in the Mission Core Ops report.
|
||||
|
||||
Do not expose roadmap steps, next-gate checklists, internal reason taxonomies,
|
||||
debug controls, placeholder blocks, or implementation scaffolding in product
|
||||
UI.
|
||||
|
||||
## Evidence viewers
|
||||
|
||||
- Use one reusable viewer frame for related representations.
|
||||
- Provide canonical expand/restore through `IconButton` and `Icon`.
|
||||
- Provide mode switching through `SegmentedControl`.
|
||||
- Preserve selected evidence and mode across resize.
|
||||
- Use 3D for spatial shape, range, height, topology, support, and occupied
|
||||
volume.
|
||||
- Use 2D for camera-plane reprojection, bbox/mask agreement, calibration,
|
||||
field-of-view, occlusion, and pixel correspondence.
|
||||
- When both are useful, keep them as modes of the same viewer and share source
|
||||
indices and selected case.
|
||||
- Keep renderer colors semantic and token-derived. Never use them to invent
|
||||
control states or persistent colored outlines.
|
||||
|
||||
## Ops report boundary
|
||||
|
||||
When the task includes reporting a completed LAB or architecture milestone, use
|
||||
the direct `nodedc-ops-agent` tools and write titled structured blocks in this
|
||||
order:
|
||||
|
||||
1. Objective and architecture stage.
|
||||
2. Decision question and hypothesis.
|
||||
3. Immutable source evidence and bounds.
|
||||
4. Method, preprocessing, models, algorithms, tools, and identities.
|
||||
5. Worker/runtime topology and resource policy.
|
||||
6. Experimental implementation.
|
||||
7. Validation and reproduced evidence.
|
||||
8. Results.
|
||||
9. Regressions, rejected approaches, and limitations.
|
||||
10. Decision.
|
||||
11. Next stage and forbidden authority.
|
||||
12. Acceptance checker.
|
||||
|
||||
Do not turn the product UI into a duplicate Ops report.
|
||||
|
||||
## Validation
|
||||
|
||||
Before handoff:
|
||||
|
||||
1. Search the changed product UI for raw local controls, hard-coded product
|
||||
colors, decorative perimeter borders/outlines, temporary copy,
|
||||
gate/checklist UI, and per-LAB page branches. Justify every remaining
|
||||
`border`, `outline`, or inset `box-shadow` as a canonical focus state,
|
||||
data-visualization grid, or necessary structural divider.
|
||||
2. Verify every changed or new bounded `ENNResult.tsx` uses
|
||||
`LaboratorySummary`, `LaboratoryEvidence`, and `LaboratoryResultSummary`
|
||||
without raw canonical report markup.
|
||||
3. For a new surface, verify the product-surface brief names the user job,
|
||||
selected placement, rejected alternative, state grammar, and real acceptance
|
||||
evidence.
|
||||
4. Under the local workstation resource gate, run
|
||||
`test/applicationArchitecture.test.mjs`, then the full frontend typecheck,
|
||||
unit tests, and production build sequentially. Never overlap these jobs.
|
||||
5. After the build/test processes have exited and memory headroom is confirmed,
|
||||
use the in-app browser to verify normal and expanded modes, every new view
|
||||
mode, keyboard Escape, and the canonical control states.
|
||||
6. Verify the default composition answers the primary operator question.
|
||||
7. Leave a real evidence case open for product-owner review.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Mission Core Product UI"
|
||||
short_description: "Mission Core surfaces and LAB product architecture"
|
||||
default_prompt: "Use $mission-core-product-ui to design or review a Mission Core product surface and its placement."
|
||||
+1
-1
@@ -9,4 +9,4 @@
|
||||
*.xbin binary
|
||||
*.las binary
|
||||
*.lcc binary
|
||||
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -12,6 +12,7 @@ __pycache__/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
apps/control-station/dist-shadow/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
@@ -21,10 +22,14 @@ build/
|
||||
!.env.example
|
||||
config/local.toml
|
||||
private/
|
||||
.runtime/
|
||||
|
||||
# Real laboratory captures and decoded artifacts are sensitive and large.
|
||||
captures/
|
||||
sessions/
|
||||
# The host runtime package shares the domain name but is source, not evidence.
|
||||
!src/k1link/sessions/
|
||||
!src/k1link/sessions/**/*.py
|
||||
artifacts/raw/
|
||||
artifacts/decoded/
|
||||
*.pcap
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Repository operating rules
|
||||
|
||||
This repository investigates an owner-controlled XGRIDS/LixelKity K1 as a
|
||||
black-box sensor. Preserve the device, the Mac, the working LAN, and raw
|
||||
evidence.
|
||||
NODEDC MISSION CORE is a vendor-neutral mission-control monorepo. Its first
|
||||
real device plugin investigates an owner-controlled XGRIDS/LixelKity K1 as a
|
||||
black-box sensor. Preserve the device, the host, the working LAN, raw evidence,
|
||||
and the boundary between Mission Core and vendor-specific integration code.
|
||||
|
||||
## Non-negotiable safety boundaries
|
||||
|
||||
@@ -23,6 +24,33 @@ evidence.
|
||||
- Do not install or alter Homebrew/system components unless the user explicitly
|
||||
authorizes that concrete change.
|
||||
|
||||
## Local operator resource envelope
|
||||
|
||||
- The local operator machine is a 14-inch 2023 MacBook Pro with only 18 GB of
|
||||
physical memory. Treat local memory and swap as a hard shared operational
|
||||
limit, not as disposable build capacity.
|
||||
- Run frontend tests, production builds, Docker builds, runtime startup, and
|
||||
browser QA sequentially. Do not launch parallel heavy local jobs.
|
||||
- Do not start Docker Desktop, a Compose stack, a second browser automation
|
||||
session, or a full test/build pass merely for convenience. First prove that
|
||||
the operation is necessary, prefer the narrowest focused check, and keep no
|
||||
more than one memory-intensive validation job active at a time.
|
||||
- Do not run load or stress tests on the Mac. Run bounded synthetic load only on
|
||||
Worker 006, and remove every temporary process after the measurement.
|
||||
- Use only the canonical Mission Core endpoint on port `8000`; do not start
|
||||
duplicate application servers to work around stale state.
|
||||
- Before a memory-intensive local operation, inspect current memory pressure and
|
||||
active Docker workload. If memory pressure is elevated or swap is growing,
|
||||
stop and remove temporary jobs before continuing. Prefer focused tests and
|
||||
existing build artifacts when they are sufficient for acceptance.
|
||||
- After every local test, build, replay, browser-QA, or Docker operation, stop
|
||||
temporary workers, watchers, replay publishers, duplicate viewers, and
|
||||
containers that are not durable operator state. Keep only the canonical
|
||||
Mission Core service on `8000` and explicitly required operator services.
|
||||
- Docker Desktop's configured VM ceiling is not evidence that the host can
|
||||
safely supply that memory. Do not change Docker Desktop CPU or memory limits
|
||||
without explicit owner approval.
|
||||
|
||||
## Evidence rules
|
||||
|
||||
- Reference inputs under `docs/reference/` are immutable; write corrections in
|
||||
@@ -38,3 +66,103 @@ evidence.
|
||||
|
||||
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
||||
decoders before BLE/Wi-Fi/data-session evidence exists.
|
||||
|
||||
## Docker runtime governance
|
||||
|
||||
- Every new or migrated durable NODE.DC-owned Docker container, Compose project,
|
||||
network, named volume, and first-party image uses the lowercase `ndc-`
|
||||
namespace. The prefix is mandatory for anything that may leave a laboratory
|
||||
workstation; a legacy project/network name may survive only behind a bounded,
|
||||
documented migration barrier.
|
||||
- Do not retag or rename unrelated third-party runtime owned by another product.
|
||||
Express NODE.DC ownership through the managed object name and
|
||||
`com.nodedc.product`, `com.nodedc.stack`, `com.nodedc.role`, and
|
||||
`com.nodedc.managed-by` labels.
|
||||
- One operator-managed contour may be one Compose project while still using
|
||||
separate single-purpose containers. Do not merge a broker, normalizer,
|
||||
database, or application process into a multi-process container merely to
|
||||
make Docker Desktop show one row.
|
||||
- Treat Compose and worker orchestration declarations as the source of truth.
|
||||
A live `docker rename` is allowed only as a bounded migration with exact
|
||||
predecessor IDs, a persisted declaration change, rollback, and post-change
|
||||
health acceptance.
|
||||
- Keep bounded legacy-name fallback only for an explicit migration window.
|
||||
New defaults, UI probes, runbooks, and durable runtime names must use `ndc-`.
|
||||
|
||||
## Product UI governance
|
||||
|
||||
- For every Control Station, LAB, viewer, or product-presentation change, use
|
||||
`.codex/skills/mission-core-product-ui/SKILL.md` and follow
|
||||
`docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md` and
|
||||
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
|
||||
- The integrated Control Station and API have one canonical local endpoint:
|
||||
`http://127.0.0.1:8000`. Never start a second Mission Core backend on another
|
||||
port to bypass a stale process. TCP `8765` is legacy Foxglove regression
|
||||
only, not an operator path. Restart the canonical `8000` process and finish
|
||||
with no Mission Core backend listening on `8765`.
|
||||
- The canonical `8000` process is durable operator state. A completed test,
|
||||
build, browser QA pass, Codex turn, or implementation increment never
|
||||
authorizes stopping it. Every Control Station task starts by checking and, if
|
||||
necessary, starting the integrated service; every handoff verifies and leaves
|
||||
that exact service running on `8000`. A rebuild may replace it only when the
|
||||
replacement is confirmed listening before handoff.
|
||||
- `NODEDC_DESIGN_GUIDELINE` is the only visual-design source of truth. Before
|
||||
editing UI, read its `registry/registry.json`, `registry/components.json`,
|
||||
`registry/icons.json`, and the relevant component documentation.
|
||||
- Reuse `@nodedc/ui-react`, `@nodedc/ui-core`, tokens, icons, and page patterns.
|
||||
Do not create an application-local visual control, interaction state,
|
||||
geometry, color language, or copy of a design-system component.
|
||||
- If the required visual entity is absent from the Design Guideline, stop and
|
||||
obtain explicit product-owner approval. After approval, add it to the Design
|
||||
Guideline first with registry, documentation, states, and validation; only
|
||||
then consume it here.
|
||||
- Domain renderers may remain Mission Core code when they visualize Mission
|
||||
Core data. Their controls and containing surfaces must still be composed from
|
||||
canonical Design Guideline exports.
|
||||
- Product UI must not expose implementation steps, roadmap gates, internal
|
||||
reason taxonomies, debug controls, placeholder status blocks, or temporary
|
||||
experiment scaffolding. Keep these in Ops, engineering reports, or developer
|
||||
tooling.
|
||||
- Extend one reusable application pattern instead of adding per-LAB layouts.
|
||||
A new LAB supplies data and renderer configuration; it does not invent a new
|
||||
page hierarchy or visual language.
|
||||
- Do not generalize the LAB hierarchy into a universal application template.
|
||||
For a new non-LAB interface, follow
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`: identify the operator job,
|
||||
compare placement/composition alternatives, classify the novelty, and obtain
|
||||
product-owner agreement before adding a workspace, changing primary
|
||||
navigation, or introducing a new product root.
|
||||
- Preserve one product grammar while allowing task-specific composition. A
|
||||
scene, map, timeline, queue, editor, topology, dashboard, and LAB review may
|
||||
have different layouts when their entity, lifecycle, or action model differs.
|
||||
- Preserve the internal dependency direction:
|
||||
`core → components/renderers → workspaces → composition/App`. Core never
|
||||
imports workspaces or visual adapters; reusable components never import
|
||||
workspaces. New domains and LAB runs get their own feature module instead of
|
||||
growing `App.tsx`, `Workspaces.tsx`, or generic CSS buckets.
|
||||
- Treat `productModel.ts`, executable versioned contracts, and the experimental
|
||||
vocabulary as the current local semantic sources. Do not add a parallel
|
||||
runtime ontology unless the admission conditions in
|
||||
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md` are met.
|
||||
|
||||
## Laboratory presentation and reporting
|
||||
|
||||
- Use one fixed laboratory presentation contract: selectors, one compact
|
||||
canonical summary, admitted evidence viewer(s), result, and optional reusable
|
||||
technical details.
|
||||
- Keep `missioncore.laboratory-report/v1` executable: bounded `ENNResult.tsx`
|
||||
modules supply all four `LaboratorySummary` brief fields and use
|
||||
`LaboratoryResultSummary` for metrics plus proved/not-proved/decision. They
|
||||
never own canonical summary/result markup. Anatomy changes require a new
|
||||
version and product-owner agreement.
|
||||
- The compact summary must explain the decision question, immutable source,
|
||||
tested method/models/algorithms, experimental mode, principal result,
|
||||
limitations, and retained authority. It is not the engineering report.
|
||||
- The complete engineering report belongs in the Mission Core Ops card as
|
||||
titled structured blocks: objective and architecture stage, source evidence,
|
||||
method/models/algorithms, worker/runtime, implementation, validation,
|
||||
results, regressions and limitations, decision, next stage, and acceptance
|
||||
checker.
|
||||
- Spatial evidence uses 3D by default. Image-space reprojection may be offered
|
||||
as a 2D diagnostic mode inside the same reusable viewer. Primary evidence
|
||||
viewers must provide the canonical expand/restore action.
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
# NDC XGRIDS K1 Connector
|
||||
# NODEDC MISSION CORE
|
||||
|
||||
Pre-production research project for connecting an owner-controlled
|
||||
XGRIDS/LixelKity K1 to a Mac without LixelGO, firmware changes, device opening,
|
||||
or speculative writes.
|
||||
NODEDC MISSION CORE is the vendor-neutral control, observation, mission-planning,
|
||||
recording, and integration platform for NODEDC autonomous systems. This
|
||||
repository is the canonical early-stage monorepo: it contains the Mission Core
|
||||
Control Station, the current local control plane, shared contracts as they are
|
||||
extracted, and device plugins.
|
||||
|
||||
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
|
||||
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
|
||||
the scan-correlated point-cloud and pose streams, and decoded both successfully.
|
||||
The repository also contains a local React control console, an automatic Rerun
|
||||
gRPC bridge for the verified point-cloud and trajectory streams, and a
|
||||
self-hosted Rerun Web Viewer embedded in that console. The former Foxglove
|
||||
bridge remains only as a legacy regression module.
|
||||
The first proven hardware vertical is the XGRIDS/LixelKity K1 plugin. On firmware
|
||||
3.0.2 the host provisions the scanner onto an existing LAN without LixelGO,
|
||||
connects to its MQTT broker, persists each raw frame before preview work, decodes
|
||||
point cloud and pose, and renders the real cloud plus trajectory through an
|
||||
embedded self-hosted Rerun Web Viewer. Native MQTT persistence uses bounded
|
||||
group commit (at most 0.5 seconds, 4 MiB or 32 messages), and camera archives
|
||||
commit complete fMP4 segments before their index rows. These are explicit
|
||||
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
|
||||
Foxglove bridge remains only as a legacy regression module.
|
||||
|
||||
The backend vendor implementation is physically isolated below
|
||||
`src/k1link/device_plugins/xgrids_k1/`; its reviewed manifest and compatibility
|
||||
profile remain below `plugins/xgrids-k1/`. Mission Core host code discovers
|
||||
archive sources, recovery hooks and recording exporters only through the plugin
|
||||
runtime contribution. The verified wire protocol and raw evidence format are
|
||||
unchanged.
|
||||
|
||||
The K1 acquisition now also requires an operator project name. Frontend and
|
||||
backend normalize it with NFKC plus surrounding-whitespace trimming, reject
|
||||
control/surrogate characters and values above 96 Unicode characters, and
|
||||
preserve it as local session/catalog display metadata. It is never used as a
|
||||
filesystem path. The physically accepted interactive control path carries the
|
||||
same validated value in its single canonical START; there is no separate
|
||||
project-name write.
|
||||
|
||||
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
|
||||
operation, runtime-action, stream, evidence and compatibility contracts. Every
|
||||
backend runtime must now pass a versioned descriptor/handshake against its
|
||||
manifest before actions are admitted. Every action then crosses immutable SDK
|
||||
`RuntimeActionInvocation` and `RuntimeActionResult` validation through a
|
||||
replaceable runtime transport seam. The meanings remain a local experimental
|
||||
vocabulary rather than a mutation of NODE.DC Platform Ontology. The current
|
||||
transport is deliberately in process: it uses an explicitly injected K1
|
||||
normalizer to produce transport-neutral local consumer views; portable SDK
|
||||
stream envelopes, process isolation, durable operations, multi-device routing
|
||||
and the remote Edge split remain later gates.
|
||||
|
||||
The application-control bootstrap reads and correlates live `DeviceInfo` before
|
||||
START. The exact profile requires model `LixelKity K1`, observed platform type
|
||||
`A4`, activated state, firmware `3.0.2`, and direct-LAN topology. The UI selects
|
||||
that profile but does not ask the operator to attest firmware manually.
|
||||
|
||||
The repository now contains one narrowly gated state-changing command:
|
||||
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
|
||||
@@ -18,12 +54,21 @@ profile and requires explicit `--confirm-write`; the Wi-Fi password is collected
|
||||
through a hidden local macOS dialog. MQTT capture and decoding are read-only.
|
||||
Nothing changes router settings, firmware or global Python packages.
|
||||
|
||||
## Available stand
|
||||
## Verified XGRIDS K1 stand
|
||||
|
||||
- one XGRIDS/LixelKity K1;
|
||||
- one Apple Silicon MacBook running macOS;
|
||||
- one ordinary TP-Link Deco/mesh network used by other devices;
|
||||
- no LixelGO, phone, Linux host, dedicated AP, OpenWrt, or vendor SDK.
|
||||
- the proven baseline used no LixelGO, phone, Linux host, dedicated AP,
|
||||
OpenWrt, or vendor SDK.
|
||||
|
||||
An owner-controlled iPhone with LixelGO is now available for a separate,
|
||||
evidence-only observation stage. It does not invalidate the no-phone baseline
|
||||
and is not a runtime dependency. The decision and gated runbook are
|
||||
[`ADR 0005`](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md) and
|
||||
[`docs/08_LIXELGO_IPHONE_OBSERVATION.md`](docs/08_LIXELGO_IPHONE_OBSERVATION.md).
|
||||
The Mac capture environment is isolated under
|
||||
`plugins/xgrids-k1/lab/iphone-capture/` and does not require full Xcode.
|
||||
|
||||
The ordinary router is sufficient for the first gates. We first observe the
|
||||
existing LAN without changing it. A Guest/IoT SSID is optional and may be
|
||||
@@ -39,10 +84,11 @@ The project has three independent gates:
|
||||
3. Without LixelGO, K1 can be associated with Wi-Fi and a proprietary data
|
||||
session can be opened.
|
||||
|
||||
All three gates are now proven on the tested unit. The external stream is plain
|
||||
All three gates are now proven on the tested unit. The spatial stream is plain
|
||||
MQTT 3.1.1 on TCP 1883. Firmware-3 `lio_pcl` is protobuf wrapped in a raw LZ4
|
||||
block, and `lio_pose` is an uncompressed protobuf. Raw panoramic camera access is
|
||||
still unproven and is not implied by point-cloud success.
|
||||
block, and `lio_pose` is an uncompressed protobuf. Owner-operated LixelGO capture
|
||||
also proved separate left/right RTSP/H.264 camera previews on TCP 8554. This is a
|
||||
compressed preview contract, not proof of full-resolution raw camera access.
|
||||
|
||||
## Local environment
|
||||
|
||||
@@ -51,26 +97,274 @@ This does not install Python packages globally and does not modify neighboring
|
||||
repositories.
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NDC_xgrids-k1-connector
|
||||
uv sync --group dev
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
|
||||
uv sync --frozen --group dev
|
||||
uv run k1link doctor
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## NODE.DC Control Station and visualization adapters
|
||||
## External perception worker
|
||||
|
||||
The browser application is now a universal NODE.DC Control Station rather than
|
||||
a K1-specific Foxglove launcher. Its fixed shell contains six architectural
|
||||
sections — Center, Fleet, Observation, Missions, Data and System — while the K1
|
||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter.
|
||||
Mission Core can now package one sealed camera epoch as an immutable,
|
||||
content-addressed `missioncore.compute-job/v1` without changing the source
|
||||
evidence. The first recorded TEST007 job was digest-verified and decoded on a
|
||||
separate Windows RTX 4090 worker, executed through pinned Triton/YOLOX-S and
|
||||
returned a content-addressed `missioncore.compute-result/v1` containing 56
|
||||
session-timestamped generic COCO detections. The worker never connects to K1 and
|
||||
does not own the raw archive.
|
||||
|
||||
Mission Core now validates and projects that optional result into the same saved
|
||||
Rerun recording. Open TEST007 and use **Распознавание** to see the recorded
|
||||
left-camera frames and boxes; **Облако точек** returns to 3D. This remains a
|
||||
recorded camera-only acceptance, not a navigation stack: large-epoch
|
||||
performance, direct worker transport and bounded live fan-out remain open. See
|
||||
[ADR 0014](docs/adr/0014-bounded-external-perception-worker.md)
|
||||
and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md).
|
||||
|
||||
## LiDAR Dataset Gateway
|
||||
|
||||
Mission Core now distinguishes a single native LiDAR scan, a normalized
|
||||
sensor-frame scan and a pose-registered rolling local map. The first Dataset
|
||||
Gateway adapter reads GOOSE/SemanticKITTI XYZI plus point-wise semantic and
|
||||
instance labels without silently accumulating or transforming them. Current
|
||||
K1 `lio_pcl` remains a post-LIO vendor-map increment and is not promoted to a
|
||||
raw scan. Large dataset bytes are admitted only on the Windows/WSL worker under
|
||||
`D:\NDC_MISSIONCORE\datasets`; no archive is downloaded automatically.
|
||||
|
||||
That representation boundary does not reject K1 as the production starting
|
||||
sensor. The passively received real-time `lio_pcl` plus `lio_pose` stream is
|
||||
the primary K1 input for two separate derivatives: a short-TTL local world
|
||||
model and a persistent territory reconstruction. `RAVNOVES00` is the immutable
|
||||
private replay dataset for this work. No K1 firmware change, onboard exporter
|
||||
or new device command is part of the LiDAR roadmap, and the historical
|
||||
`1.27 m` handheld-height experiment is not a runtime constant.
|
||||
|
||||
The first passive replay derivative is now implemented as
|
||||
`missioncore.k1-local-surface/v1`. It binds the immutable `RAVNOVES00` pack,
|
||||
estimates a rolling local surface from map points plus compatible pose, and
|
||||
publishes height, slope, roughness, confidence and conservative observed
|
||||
surface/occupied/unknown evidence. The complete pack contains `4,489` timeline
|
||||
frames, `3,928` available/valid LiDAR frames and `9,207,270` points; there were
|
||||
no stale-pose, insufficient-surface or fit-failed frames. Prior-only
|
||||
next-frame qualification produced `3,927` samples with `0.04065 m` p50 median
|
||||
residual and `205` strict temporal jumps. The p95 residual tail remains too
|
||||
large for planner use. The current complete derivative is the first item in
|
||||
**Парк → Диагностика LiDAR**.
|
||||
|
||||
The earlier content-bound `526`-frame review slice remains available as
|
||||
historical laboratory evidence. Its deterministic triage contains `37`
|
||||
attention frames in `21` episodes and four high-priority frames. The selected
|
||||
frame can switch to the prior-plane residual view; source frame `1254` has
|
||||
`36/98` evaluated cells above the `0.16 m` band and none below it. This
|
||||
localizes a positive residual structure without naming an object or granting
|
||||
safety authority.
|
||||
|
||||
The same estimator now runs behind
|
||||
`missioncore.k1-local-surface-shadow-runtime/v1`, a capacity-two
|
||||
latest-wins queue with a bounded diagnostic result ring. LAB E27 passed a
|
||||
`14.995 s` RAVNOVES00 slice at recorded 1× pace: `143/143` available frames
|
||||
were consumed, maximum queue depth was `1/2`, no frame was replaced, p95
|
||||
processing latency was `14.777 ms`, and state, point classes, step candidates
|
||||
and scalar results matched the immutable replay derivative exactly. This is a
|
||||
recorded-source-paced execution gate only; physical K1 acquisition,
|
||||
free-space, commands, navigation and safety authority remain unavailable.
|
||||
|
||||
The authenticated external-worker seam is now implemented and replay-qualified.
|
||||
It binds decoded map-frame `lio_pcl` and `lio_pose` in either arrival order
|
||||
through
|
||||
`missioncore.k1-local-surface-pose-binder/v1`, then feeds the accepted estimator
|
||||
without blocking camera cadence. Point input and estimator work are
|
||||
bounded separately: the authenticated ingress has LiDAR depth eight, while the
|
||||
freshness-first estimator remains capacity two. LAB E28 replayed the complete
|
||||
recording at 1× through the actual NVIDIA worker: all `4,570` LiDAR and `4,598`
|
||||
pose events arrived with zero gaps, all point frames bound, and `4,206`
|
||||
results were persisted at `9.1945 Hz`. Explicitly superseded work was
|
||||
`364/4,570` (`7.965%`), p95 result age was `89.530 ms`, and all acceptance
|
||||
checks passed. Physical K1 acquisition remains a separate commissioning test;
|
||||
it is not required for replay algorithm development.
|
||||
|
||||
The complete RELLIS-3D v1.1 release is now admitted there and its full
|
||||
`2,413`-frame validation split is available in **Полигон → Датасеты**. The
|
||||
sealed Current/Patchwork++ comparison rejected Patchwork++ for navigation:
|
||||
despite much lower latency and a small Ground-IoU gain, obstacle non-ground
|
||||
recall regressed from `80.46%` to `69.70%`.
|
||||
|
||||
See [the Dataset Gateway plan](docs/14_LIDAR_DATASET_GATEWAY.md),
|
||||
[the laboratory run canon](docs/15_LABORATORY_RUN_CANON.md) and
|
||||
[ADR 0021](docs/adr/0021-dataset-gateway-representation-boundary.md).
|
||||
|
||||
## Simulation Polygon
|
||||
|
||||
Mission Core now has a parallel Polygon product branch for reproducible
|
||||
autonomy qualification. Mission Core owns scenarios, qualification-run
|
||||
lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4
|
||||
SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay,
|
||||
digital twin, HIL and physical shadow keep distinct causal run kinds.
|
||||
|
||||
The provider boundary is now executable through
|
||||
`missioncore.simulation-provider-profile/v1`: worker status declares provider
|
||||
roles/capabilities, clock, ENU/FLU frames and supported control profiles.
|
||||
Gazebo/PX4 stays the accepted regression profile. Unreal is evaluated as a
|
||||
separate high-fidelity provider family; native Unreal, CARLA and Project AirSim
|
||||
must pass ADR 0017 U0 before a production adapter is selected. Unreal runs on a
|
||||
worker, never inside React; the existing browser scene, lifecycle, evidence and
|
||||
safety contracts are retained.
|
||||
|
||||
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
|
||||
loopback-only process isolation, exact provider pins, a 2 ms physics step,
|
||||
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
|
||||
accepted-profile stock-rover runs and nine digest-bound evidence claims passed;
|
||||
the target doctor returned `GO`. The doctor remains read-only and a development
|
||||
host without the private evidence pack still returns `INCOMPLETE`:
|
||||
|
||||
```bash
|
||||
uv run missioncore-sim s0 doctor \
|
||||
--evidence /path/to/private/evidence.yaml \
|
||||
--json
|
||||
```
|
||||
|
||||
S1A now adds the first server-owned domain boundary: typed immutable
|
||||
`QualificationRun` identities, legal lifecycle transitions, canonical
|
||||
Ackermann/Differential commands, authority generation and TTL validation, and
|
||||
an append-only crash-safe run/event/command/artifact repository. Active runs
|
||||
recover as failed/interrupted rather than silently resuming, and reset creates
|
||||
a new linked run/episode. This is repository-level lifecycle acceptance only;
|
||||
PX4 command delivery, watchdog/failsafe behavior, navigation behavior and real
|
||||
actuator control remain unaccepted.
|
||||
|
||||
The S1B repository increment adds the transport-neutral
|
||||
`SimulationApplicationService`, an exact-S0/D-only worker admission guard and a
|
||||
POSIX process supervisor. Start is idempotent, a second active owner is
|
||||
rejected, provider processes receive dedicated PGIDs and run-scoped logs, and
|
||||
shutdown follows the S0 dependency order with residue reported as failure.
|
||||
Pause/resume/step/reset/stop are owned by the application service through a
|
||||
worker port.
|
||||
|
||||
Commit `6cb1495` has now passed the real D-only `MissionCore-Sim` S1B lifecycle:
|
||||
the exact immutable source generation started Micro XRCE-DDS Agent and the PX4
|
||||
v1.17.0/Gazebo Harmonic stock Ackermann rover in a fresh loopback-only
|
||||
namespace, admitted the run only after world/startup/DDS-writer health markers,
|
||||
and stopped both owned PGIDs with zero residue. The persisted run reached
|
||||
`completed`, contains eight lifecycle events and zero control commands. This
|
||||
run is now visible through UI-0: a direct internal read-only Control Station
|
||||
surface backed by the same append-only qualification repository. It exposes
|
||||
identity, terminal reason, provider pins, events, command count, artifact
|
||||
metadata and the virtual-only authority boundary. Live pause/resume/step/reset,
|
||||
canonical telemetry, PX4 command mapping, watchdog/failsafe evidence and the
|
||||
accepted command path remain gated.
|
||||
|
||||
S1C commit `b7ccca3` adds the first registered live-worker vertical without
|
||||
collapsing that boundary. An unprivileged worker agent owns the existing
|
||||
application service and providers inside a fresh loopback-only network
|
||||
namespace. Mission Core reaches it through a mode-`0600` Unix socket; React
|
||||
reaches only same-origin Polygon API routes. The live browser panel can
|
||||
start/stop the stock rover and show worker/run/provider status. UI-2 commit
|
||||
`60e7916` adds a worker-gated `Полигон` header root and a browser-native
|
||||
Three.js Ackermann Rover scene with orbit, zoom, pan, follow and reset. The
|
||||
deep-link remains for exact-run review; it is no longer the primary operator
|
||||
entry. Gazebo dynamic-pose ground truth remains explicitly `diagnostic`, not
|
||||
accepted PX4/ROS 2 telemetry.
|
||||
|
||||
The exact D-only UI-2 generation passed 35 target tests and a real UI-driven run
|
||||
`s1c-60e7916-20260724t182006z-2f164e`: both providers reached readiness, the
|
||||
browser received live `VehicleState`, the run reached `completed` after
|
||||
27.74 seconds of Gazebo time, and shutdown left no PX4/Gazebo/XRCE process
|
||||
residue. The run contained zero commands, so this result does not yet prove
|
||||
rover motion, PX4 command delivery, watchdog/failsafe behavior, navigation or
|
||||
safety acceptance.
|
||||
|
||||
S1D exact code generation `49b0f47` closes the first virtual Ackermann command
|
||||
and motion slice. React sends bounded speed/steering intents through the
|
||||
same-origin Mission Core API and private worker socket. The worker journals each
|
||||
accepted command before mapping it to PX4 rover-level Offboard setpoints:
|
||||
`RoverThrottleSetpoint` is calibrated against the stock model's
|
||||
`RO_MAX_THR_SPEED=3.1`, `RoverSteeringSetpoint` carries normalized steering,
|
||||
and PX4 retains arming, Offboard loss handling and control allocation. No
|
||||
`ActuatorMotors` or `ActuatorServos` topic is exposed or published by Mission
|
||||
Core.
|
||||
|
||||
The immutable D-only generation passed 40 focused target tests. Its first cold
|
||||
start completed in one request after 32.08 seconds. Run
|
||||
`s1c-49b0f47-20260724t195259z-62edd2` proved 3.74 m straight motion, a curved
|
||||
turn, reverse motion, 44.6 ms sampled command admission, a 250 ms TTL transition
|
||||
(`ttl_expired_count` 4 -> 5), stable stopped pose and clean provider shutdown
|
||||
with no PX4/Gazebo/XRCE residue. This does not accept calibrated closed-loop
|
||||
speed tracking, Differential Rover, canonical PX4/ROS 2 telemetry,
|
||||
pause/step/reset, heartbeat/offboard/link-loss cases, Nav2, perception or safety
|
||||
behavior. Real actuator authority remains false.
|
||||
|
||||
Start the worker from the exact immutable D source generation:
|
||||
|
||||
```bash
|
||||
sudo simulation/s1/worker-agent.sh <exact-40-character-mission-core-commit>
|
||||
```
|
||||
|
||||
Then configure the Mission Core backend. When the worker status is available,
|
||||
open `Полигон` from the Control Station header:
|
||||
|
||||
```bash
|
||||
export MISSIONCORE_POLYGON_RUNS_ROOT=/mnt/d/NDC_MISSIONCORE/simulation/artifacts/s1/runs
|
||||
export MISSIONCORE_POLYGON_WORKER_SOCKET=/run/missioncore-sim/worker.sock
|
||||
export MISSIONCORE_POLYGON_WORKER_CONTROL=internal-virtual-only
|
||||
export MISSIONCORE_COMMIT=<exact-40-character-mission-core-commit>
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8000/
|
||||
```
|
||||
|
||||
The direct `?workspace=polygon-run&run=<qualification-run-id>` URL remains a
|
||||
compatible evidence deep-link. The archive API remains GET-only. The worker API
|
||||
adds status/live GETs and explicit start, command and stop POSTs; POSTs remain
|
||||
`403` unless the backend has the `internal-virtual-only` gate and an exact
|
||||
commit. Missing configuration, transport drift and corrupt evidence fail
|
||||
closed. The browser receives no Unix socket, D root, artifact bytes, PX4
|
||||
endpoint or direct provider command.
|
||||
|
||||
Do not launch a second integrated backend on another port when `8000` is
|
||||
already occupied. Stop or restart the stale Mission Core process on `8000`.
|
||||
TCP `8765` remains reserved for the legacy Foxglove regression module and is
|
||||
not a Control Station operator path.
|
||||
|
||||
See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md),
|
||||
[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md),
|
||||
[ADR 0016](docs/adr/0016-distributed-product-edge-and-worker-topology.md),
|
||||
[ADR 0017](docs/adr/0017-provider-neutral-simulation-and-unreal-evaluation.md)
|
||||
and the
|
||||
[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md). The live-worker
|
||||
launch and acceptance procedure is in
|
||||
[SIM S1C live-worker runbook](docs/runbooks/SIM_S1C_LIVE_WORKER.md).
|
||||
|
||||
Mission Core remains a distributed browser product rather than a
|
||||
platform-specific desktop executable. React runs in the operator browser;
|
||||
Gateway/API and Control Plane run as long-lived services; Gazebo/PX4/ROS 2/Nav2
|
||||
run headless on a registered simulation worker; and a future field vehicle runs
|
||||
a headless onboard Linux Edge Agent with local-first evidence and
|
||||
store-and-forward synchronization. Native Gazebo GUI is worker-local
|
||||
diagnostics. The accepted live rover scene uses canonical state in Three.js;
|
||||
Rerun remains the recorded spatial-evidence path. Field execution never depends
|
||||
on streaming a simulator desktop.
|
||||
|
||||
## Mission Core Control Station and visualization adapters
|
||||
|
||||
The browser application is the universal Mission Core Control Station rather than
|
||||
a K1-specific Foxglove launcher. Its fixed shell contains Center, Fleet,
|
||||
Observation, Missions, Data and System; a seventh Polygon section is inserted
|
||||
only while an admitted Simulation Worker is available. The K1
|
||||
BLE/Wi-Fi/live workflow remains isolated as the first real device adapter. Its
|
||||
React provisioning, acquisition/replay and diagnostic blocks live beside the
|
||||
plugin manifest under `plugins/xgrids-k1/frontend`; the generic application
|
||||
mounts them through one reviewed composition import.
|
||||
|
||||
Install, type-check, build and serve the complete local application from the
|
||||
repository root:
|
||||
|
||||
```bash
|
||||
uv sync --group dev
|
||||
cd apps/k1-viewer
|
||||
npm install
|
||||
uv sync --frozen --group dev
|
||||
cd apps/control-station
|
||||
npm ci
|
||||
npm run test:unit
|
||||
npm run typecheck
|
||||
npm run build
|
||||
cd ../..
|
||||
@@ -80,45 +374,149 @@ uv run k1link serve
|
||||
Open `http://127.0.0.1:8000`. The static application, REST/WebSocket control
|
||||
plane and credential endpoint bind to loopback only. The current K1 adapter
|
||||
still provides real CoreBluetooth discovery, one operator-triggered reviewed
|
||||
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
|
||||
reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
|
||||
Physical K1 scanning is still started and stopped by the verified double-click;
|
||||
the connector publishes no modeling command.
|
||||
BLE Wi-Fi provisioning write, a one-shot CoreWLAN K1-AP association path,
|
||||
read-only MQTT live capture, native `.k1mqtt` and
|
||||
reviewed-TSV replay, raw-first evidence storage, device-reported scan
|
||||
time/distance/speed and measured preview metrics. Its frontend contribution now
|
||||
also owns an optional spatial-scene control block, including acquisition phase,
|
||||
telemetry and the stop action. Operator-manual acquisition still finalizes only
|
||||
local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
||||
canonical K1 START/STOP dialogue.
|
||||
|
||||
Plugin v0.7.0 supervises the v0.6.0 local connection matrix with separate
|
||||
desired, configured and active modes plus exact DeviceInfo-backed Ready.
|
||||
Bridge remains the default and accepted product path; Direct Connect sends the reviewed station
|
||||
provisioning frame for an already-running controller hotspot. Quick Connect
|
||||
sends one separately reviewed AP-enable frame and can associate a prepared Mac
|
||||
through CoreWLAN. Its device activation and prepared-host association were
|
||||
physically accepted, but credential bootstrap is not portable: a clean host
|
||||
cannot acquire the firmware-defined AP material from the reviewed BLE protocol.
|
||||
Quick Connect therefore remains laboratory functionality, with no automatic
|
||||
firmware download, iPhone extraction or hard-coded fallback. Each mode has a
|
||||
distinct topology attestation and no automatic retry; see
|
||||
[ADR 0013](docs/adr/0013-k1-local-connection-matrix.md).
|
||||
|
||||
The exact recovered `ModelingRequest` start/stop encoder, response correlator
|
||||
and device-status state machine are installed in the operator-gated interactive
|
||||
control path.
|
||||
The plugin also contains the exact ten-request pre-START bootstrap recovered
|
||||
from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID;
|
||||
the response supplies live vendor identity, serial, model, activation and
|
||||
version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI
|
||||
as one private application-level value in the observed client, not a manual
|
||||
per-scanner profile. The whole pre-START order and START/STOP payloads are
|
||||
byte-matched offline. A fixed macOS Keychain loader now supplies the exact
|
||||
application authority without env/file/browser fallback, and a five-batch
|
||||
orchestrator passes all retained response barriers. The legacy shadow publisher
|
||||
boundary remains structurally write-disabled and cannot call its sink. Its
|
||||
dormant coordinator can hold authority
|
||||
for at most 300 seconds and expiry/disarm/reprovision/acquisition/shutdown revoke
|
||||
it together with the orchestrator. Plugin v0.5.0 installs a separate
|
||||
physical-acceptance transport reproducing MQTT 3.1.1 `clean_session=false`,
|
||||
keepalive 60, exact
|
||||
response subscriptions, QoS2 completion and the five response-gated batches.
|
||||
It consumes operation keys before publish and poisons unknown outcomes without
|
||||
retry. One background owner services that socket through response-gated
|
||||
connection, workspace, project, START, STOP and device-standby stages. The normal
|
||||
UI collapses only the pre-START controls into one explicit operator intent;
|
||||
navigation cannot emit a command. The first operator-present physical attempt on 2026-07-18 emitted only
|
||||
the first six bootstrap requests, then failed closed while correlating the third
|
||||
response batch; START was not emitted. A second one-shot attempt correlated all
|
||||
ten pre-START requests and the START acknowledgement, but incorrectly collapsed
|
||||
the whole exchange into 365 ms and ended the control lifecycle before the
|
||||
retained post-START reads. K1 stayed in `SCAN_STARTING` for about 24 seconds,
|
||||
reported `ALGORITHM_ERROR` twice, emitted no point/pose data and showed steady
|
||||
red until an operator power cycle restored steady green. The collapsed executor
|
||||
is now disabled. The offline acceptance executor maps connection requests 1–6,
|
||||
workspace-entry request 7, project-prompt reads 8–10, user-confirmed START 11,
|
||||
immediate status read 12 and state-gated reads 13–14 onto one continuously owned
|
||||
MQTT session. No captured operator delay is a protocol timer: the final reads
|
||||
wait for live `SCANNING`, project binding and `init_ready`, while STOP/save waits
|
||||
for live unbound `READY`. Standalone START and STOP
|
||||
are both rejected. The same socket is serviced throughout launch and STOP
|
||||
confirmation, and no captured human delay is replayed as a protocol timer.
|
||||
Plugin v0.5.0 wires this executor to the facade and plugin UI. The full staged
|
||||
START/live/STOP/save cycle is physically accepted on the reviewed unit. START
|
||||
and STOP each retain an explicit operator action; final local sealing now follows
|
||||
the scanner's protocol-reported unbound READY without a redundant second click.
|
||||
|
||||
This locked bootstrap is repeatable in the current workspace, not yet a
|
||||
standalone release install. The frontend consumes sibling `file:` packages from
|
||||
`NODEDC_DESIGN_GUIDELINE`; `package-lock.json` does not pin that checkout's Git
|
||||
revision or content hash. Publishing/vendoring those packages or enforcing an
|
||||
immutable donor revision remains a packaging and CI prerequisite.
|
||||
|
||||
Polygon is implemented as a capability-gated seventh section. When Mission Core
|
||||
confirms an admitted Simulation Worker, its header button opens one direct
|
||||
workspace with live 3D inspection and the server-owned qualification
|
||||
repository. `?workspace=polygon-run[&run=<id>]` remains a compatibility
|
||||
deep-link. Command and safety acceptance remain separate gates.
|
||||
|
||||
The Observation spatial workspace embeds the open-source Rerun Web Viewer
|
||||
inside the NODE.DC shell. It can open a compatible RRD file over HTTP(S) or a
|
||||
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
|
||||
use an external hosted viewer UI.
|
||||
inside the Mission Core shell. It can open a compatible RRD over same-origin
|
||||
HTTP or a Rerun gRPC/proxy source such as
|
||||
`rerun+http://127.0.0.1:9876/proxy`; no external hosted viewer UI is used.
|
||||
Dynamic point-cloud/camera composition and the live source contract are fixed in
|
||||
[`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
|
||||
The durable session catalog, recorded `session_time` scrubber and versioned
|
||||
workspace-layout profile are fixed in
|
||||
[`ADR 0008`](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md).
|
||||
|
||||
The first K1 live session or replay in a `k1link serve` process creates one local
|
||||
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
|
||||
the resulting URL through control-plane state. Later sessions reset their
|
||||
session-local scene and metrics and reuse that process-wide stream; this avoids
|
||||
restarting the native listener while the embedded browser remains connected.
|
||||
The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) in a
|
||||
`k1link serve` process creates one local Rerun `RecordingStream`, starts its
|
||||
gRPC/proxy server on TCP 9876 and publishes the resulting URL through
|
||||
control-plane state. Later live/file-replay sessions reset their session-local
|
||||
scene and metrics and reuse that process-wide stream. Saved observation sessions
|
||||
do not reuse this listener: they open a private digest-bound cache-v9 RRD
|
||||
generation over same-origin HTTP through Rerun's native incremental receiver.
|
||||
Unless an operator has entered a manual source, the React application assigns
|
||||
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence
|
||||
capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders →
|
||||
Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer.
|
||||
the applicable source to the embedded viewer. A generic host action clears a
|
||||
previous manual/archive selection only after a plugin-owned live or file-replay
|
||||
start succeeds; a failed start leaves the current scene mounted. A fail-closed
|
||||
guard blocks operator source switches for every nonterminal acquisition. The
|
||||
complete visual live runtime path is K1 MQTT → raw-first evidence capture →
|
||||
bounded four-message latest-wins preview queue → explicitly injected K1
|
||||
protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
|
||||
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
|
||||
not inspect K1 topics or raw payloads. These local decoded views are not yet the
|
||||
portable Plugin SDK wire envelopes. K1 `ModelingReport` status is consumed by an
|
||||
explicitly injected pre-preview observer, so scan telemetry cannot evict point
|
||||
or pose frames from that four-message queue.
|
||||
|
||||
The default Rerun blueprint shows a 12-second sliding accumulation of real point
|
||||
frames. Product controls are connected for point size, intensity/height/distance
|
||||
or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
|
||||
point and trajectory visibility, and the scene grid. Projection, custom
|
||||
timeline transport and saved layout remain later product work. No synthetic
|
||||
point cloud, trajectory, camera frame or latency value is generated.
|
||||
point and trajectory visibility, and the scene grid. The first disk action saves
|
||||
and restores the versioned spatial layout without mutating sensor evidence.
|
||||
Saved native point/pose sessions are materialized losslessly into private,
|
||||
digest-bound RRD recordings and can be played, paused and scrubbed on a
|
||||
zero-based `session_time` timeline. New captures durably publish a clock origin
|
||||
before camera production, retain a transport-scoped provisional envelope and
|
||||
atomically point their summary to a content-addressed session envelope after all
|
||||
producers stop. That session envelope becomes the real RRD origin/end range;
|
||||
archived `ModelingReport` distance, speed and scan time are logged as scalar
|
||||
time series. No synthetic point cloud, trajectory, camera frame or latency
|
||||
value is generated.
|
||||
|
||||
A powered-device checkpoint passed 80 real MQTT messages through the current
|
||||
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
|
||||
cloud and zero decode errors. Raw panoramic camera frames remain absent. Rerun
|
||||
`capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or
|
||||
photon-to-screen measurement.
|
||||
cloud and zero decode errors. The later RTSP camera preview is available through
|
||||
the generic floating observation windows and new acquisitions archive its fMP4
|
||||
segments independently of browser delivery. Historical sessions recorded before
|
||||
that archive contract contain no video. A real archived session containing one
|
||||
camera plus point cloud has not yet passed physical shared-timeline playback;
|
||||
the implementation and test contract do not close that hardware gate. Rerun
|
||||
`capture_time` and the camera index use Mac receive/arrival timestamps, not
|
||||
proven K1 sensor timestamps or a photon-to-screen measurement.
|
||||
|
||||
The old Foxglove implementation is retained only in
|
||||
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
|
||||
`src/k1link/device_plugins/xgrids_k1/viewer/foxglove_bridge.py` and its regression tests. The current
|
||||
live/replay runtime does not start it or use TCP 8765. The
|
||||
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
|
||||
its timing/security boundaries; the frontend contract is documented in
|
||||
[`apps/k1-viewer/README.md`](apps/k1-viewer/README.md).
|
||||
[`apps/control-station/README.md`](apps/control-station/README.md).
|
||||
The host storage layout, recovery rules, replay API and operator path are in
|
||||
[`docs/09_OBSERVATION_SESSIONS.md`](docs/09_OBSERVATION_SESSIONS.md).
|
||||
|
||||
The FastAPI application and credential endpoint bind to loopback, but the Rerun
|
||||
gRPC server currently binds TCP 9876 on all network interfaces even though its
|
||||
@@ -165,12 +563,36 @@ present.
|
||||
|
||||
- [Technical audit](docs/00_TECHNICAL_AUDIT.md)
|
||||
- [Implementation gates](docs/01_IMPLEMENTATION_PLAN.md)
|
||||
- [Architecture audit execution roadmap](docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md)
|
||||
- [Laboratory run canon](docs/15_LABORATORY_RUN_CANON.md)
|
||||
- [Product UI and laboratory presentation canon](docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md)
|
||||
- [Application component architecture](docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md)
|
||||
- [Product surface extension protocol](docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md)
|
||||
- [First lab runbook](docs/02_FIRST_LAB_RUNBOOK.md)
|
||||
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
||||
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
||||
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
|
||||
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
|
||||
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
|
||||
- [K1 application-control physical acceptance](docs/lab/003_K1_CONTROL_ACCEPTANCE_20260718.redacted.md)
|
||||
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
|
||||
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
|
||||
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
|
||||
- [Plugin SDK v0alpha2 and experimental device lifecycle](docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md)
|
||||
- [Owner-controlled LixelGO observation decision](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md)
|
||||
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
|
||||
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
|
||||
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
|
||||
- [Device-plugin observation runtime and K1 extraction](docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md)
|
||||
- [Plugin-owned frontend device workflows](docs/adr/0010-plugin-owned-frontend-device-workflows.md)
|
||||
- [Laboratory plugin runtime handshake and transport seam](docs/adr/0011-laboratory-plugin-runtime-handshake-and-transport-seam.md)
|
||||
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
|
||||
- [Simulation Polygon qualification boundary](docs/adr/0015-simulation-polygon-qualification-boundary.md)
|
||||
- [Distributed product, edge and worker topology](docs/adr/0016-distributed-product-edge-and-worker-topology.md)
|
||||
- [Sensor representation capabilities and free-space admission](docs/adr/0023-sensor-representation-capabilities-and-free-space-admission.md)
|
||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
- [Reference input provenance](docs/reference/README.md)
|
||||
|
||||
@@ -186,13 +608,15 @@ capture of traffic to or from the confirmed K1 address, and offline analysis of
|
||||
owned artifacts.
|
||||
|
||||
The reviewed provisioning write requires its named profile and explicit operator
|
||||
confirmation. Application command publishing remains disabled: physical
|
||||
double-click is the verified start/stop mechanism. Any future MQTT publisher,
|
||||
router configuration change or new BLE write requires its own evidence and
|
||||
confirmation. The interactive application-control publisher is enabled only for
|
||||
the exact K1/A4/FW 3.0.2 direct-LAN profile and only inside one operator-opened,
|
||||
response-gated session; physical double-click remains the independent fallback.
|
||||
The legacy shadow publisher remains structurally disabled. Any broader firmware,
|
||||
topology, scanner-family or command surface requires its own evidence and
|
||||
reviewed step. Random writes, fuzzing, brute force, firmware operations,
|
||||
destructive file access and credential guessing remain out of scope.
|
||||
|
||||
Real captures, projects, router metadata, serials, credentials, maps, images,
|
||||
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
|
||||
are committed; encrypted artifact storage will be selected only when real data
|
||||
exists.
|
||||
and logs under `.runtime/`, canonical evidence roots and legacy `sessions/` are
|
||||
ignored by normal Git. Redacted manifests and SHA-256 inventories are committed;
|
||||
encrypted/replicated artifact storage remains a deployment decision.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Mission Core applications
|
||||
|
||||
- `control-station/` — the current browser operator application.
|
||||
|
||||
Applications consume Mission Core contracts and capabilities. Device-specific
|
||||
transport and codecs belong under `plugins/`, not inside application routing
|
||||
or navigation.
|
||||
@@ -0,0 +1,356 @@
|
||||
# NODEDC MISSION CORE · Control Station
|
||||
|
||||
Универсальный браузерный пункт управления Mission Core на React 19, TypeScript и
|
||||
Vite. Приложение задаёт общую операторскую оболочку для аппаратов, сенсоров,
|
||||
наблюдения, миссий и записей. XGRIDS/LixelKity K1 является первым реальным
|
||||
device adapter, но структура интерфейса от него не зависит.
|
||||
|
||||
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
|
||||
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
|
||||
переход во внешний облачный интерфейс. Для K1 live и adapter file-replay backend
|
||||
сам создаёт Rerun gRPC/proxy source. Saved observation sessions открывают
|
||||
проверенную digest-bound RRD generation через same-origin HTTP; ручной адрес
|
||||
нужен только для другого совместимого Rerun source.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
| Контур | Состояние | Что это означает |
|
||||
| --- | --- | --- |
|
||||
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
||||
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
|
||||
| Plugin-owned device UI | Реализован | XGRIDS provisioning, project/acquisition/replay, diagnostics, metrics, optional spatial controls и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет frontend SDK, model catalog и host slots. |
|
||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и device-reported scan time/distance/speed. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
|
||||
| Сохранённые observation sessions | Реализованы для point/pose/device metrics | Три последние сессии, background preparation, capture-clock-bound cache v9, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
|
||||
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
|
||||
|
||||
Приложение не генерирует демонстрационное облако, траекторию, кадры или
|
||||
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
|
||||
показывают `—`.
|
||||
|
||||
## Архитектура данных
|
||||
|
||||
```text
|
||||
K1 MQTT :1883, read-only
|
||||
└── raw .k1mqtt + metadata + durable clock origin сохраняются первыми
|
||||
├── injected ModelingReport observer -> device scan time/distance/speed
|
||||
└── bounded latest-wins visual preview queue (4 сообщения)
|
||||
└── явно внедрённый K1 protobuf/LZ4 point/pose normalizer
|
||||
└── transport-neutral DecodedPointCloudView / DecodedPoseView
|
||||
└── Rerun Points3D + Transform3D + LineStrips3D
|
||||
└── gRPC/proxy TCP 9876
|
||||
└── rerun_grpc_url в REST/WebSocket state
|
||||
└── встроенный @rerun-io/web-viewer
|
||||
└── пространственная сцена Mission Core
|
||||
|
||||
Mission Core Control Station ←→ REST /api/v1/device-plugins/*
|
||||
+ plugin-scoped WebSocket events
|
||||
FastAPI на 127.0.0.1:8000
|
||||
CoreBluetooth + live/replay runtime
|
||||
|
||||
sealed/recovered observation session
|
||||
└── SQLite catalog + bounded background preparation
|
||||
└── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
|
||||
└── generation-bound same-origin HTTP
|
||||
└── aggregate admission
|
||||
└── Rerun native receiver + recorded fMP4 player
|
||||
```
|
||||
|
||||
В момент готовности live/file-replay `RerunBridge` backend публикует адрес вида
|
||||
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
|
||||
если оператор не указал ручной source. После остановки приёма URL и встроенный
|
||||
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
|
||||
траекторию и метрики и использует тот же listener. Он закрывается вместе с
|
||||
процессом `k1link serve`.
|
||||
|
||||
Поля `foxglove_ws_url` и `foxglove_viewer_url` пока остаются в API как
|
||||
совместимость со старым контрактом, но текущий runtime держит их пустыми.
|
||||
|
||||
## Фиксированная оболочка
|
||||
|
||||
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
|
||||
всех функциональных модулей:
|
||||
|
||||
1. `AppHeader` — марка NODE DC, выбор архитектурного раздела и состояние
|
||||
локального backend.
|
||||
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
|
||||
выбранного раздела.
|
||||
3. `LandingStage` — стартовая ситуационная поверхность и быстрые переходы.
|
||||
4. `ApplicationPanel` — единый контейнер активной рабочей поверхности.
|
||||
5. `Window` и `Inspector` — источник, отображение, слои и компоновка без
|
||||
раскрытия внутренних панелей визуального движка.
|
||||
|
||||
Встроенный Rerun Viewer работает как canvas внутри этой оболочки. Его верхняя,
|
||||
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
|
||||
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
|
||||
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
|
||||
Rerun Blueprint. Host timeline управляет сохранённым `session_time`, а spatial
|
||||
layout сохраняет display settings, tool windows и source-window geometry через
|
||||
revisioned API. Смена 2D/3D/карты и семантические слои пока остаются
|
||||
интерфейсным контрактом.
|
||||
|
||||
## Архитектурные разделы
|
||||
|
||||
Навигация описана данными в `src/productModel.ts`, а не зашита в разметку каждой
|
||||
страницы.
|
||||
|
||||
| Раздел | Назначение |
|
||||
| --- | --- |
|
||||
| Центр | Живое состояние вычислительных узлов, процессов, сети и подключённых устройств. |
|
||||
| Парк | Аппараты, текущее устройство, сенсоры и конфигурации борта. |
|
||||
| Наблюдение | Только live-пространственная сцена, камеры, карта, объекты, телеметрия и время. |
|
||||
| Миссии | Планировщик, маршруты, сценарии и исполнение. Командный backend отключён. |
|
||||
| Данные | Сохранённые сессии, replay, публичные датасеты, потоки, сущности и экспорт. |
|
||||
| Система | Модули, интеграции, сеть, аудит и настройки платформы. |
|
||||
| Тестировочный контур | Архив лабораторных работ, конфигурации экспериментов и визуальные результаты. |
|
||||
|
||||
Карточки возможностей имеют четыре честных уровня: работает сейчас, готово к
|
||||
источнику, интерфейсный контракт и последующий этап. Каталог не следует читать
|
||||
как утверждение, что для каждой карточки уже существует backend.
|
||||
|
||||
## Установка, проверка, сборка и запуск
|
||||
|
||||
Требуются Node.js 20.19+ либо 22.12+, `uv` и соседний checkout
|
||||
`NODEDC_DESIGN_GUIDELINE`: зависимости `@nodedc/*` подключены к нему через
|
||||
локальные `file:` пути. Python устанавливается только в `.venv` репозитория.
|
||||
|
||||
Канонический путь от корня репозитория:
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
|
||||
uv sync --frozen --group dev
|
||||
|
||||
cd apps/control-station
|
||||
npm ci
|
||||
npm run test:unit
|
||||
npm run typecheck
|
||||
npm run build
|
||||
|
||||
cd ../..
|
||||
uv run k1link serve
|
||||
```
|
||||
|
||||
Открыть `http://127.0.0.1:8000`. Команда `serve` отдаёт собранный `dist/` и
|
||||
локальный API. Она намеренно привязана только к `127.0.0.1`; LAN bind не
|
||||
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
|
||||
Wi-Fi.
|
||||
|
||||
Этот locked bootstrap повторяем в текущем workspace, но он ещё не является
|
||||
standalone release install. Локальные `file:` зависимости берутся из соседнего
|
||||
`NODEDC_DESIGN_GUIDELINE`, а lockfile не фиксирует Git revision или content hash
|
||||
этого checkout. До CI/portable packaging эти пакеты нужно опубликовать,
|
||||
завендорить либо проверять по неизменяемой donor revision.
|
||||
|
||||
Для разработки интерфейса после запуска backend:
|
||||
|
||||
```bash
|
||||
cd apps/control-station
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Для полного live-viewer контура Vite должен открыться на
|
||||
`http://127.0.0.1:5173`: только 5173, production preview 4173 и backend 8000
|
||||
входят в текущий Rerun CORS allowlist. Если Vite сообщает fallback на 5174 или
|
||||
выше, освободите 5173 и перезапустите `npm run dev`; REST/WebSocket proxy на
|
||||
fallback-порту может работать, но прямой browser → Rerun 9876 будет отклонён.
|
||||
Vite проксирует весь `/api` (включая WebSocket) на `http://127.0.0.1:8000`.
|
||||
Другой локальный backend можно указать переменной `VITE_API_TARGET`. Preview
|
||||
production-сборки запускается командой `npm run preview` на
|
||||
`http://127.0.0.1:4173`.
|
||||
|
||||
## Операторский путь для текущего K1 adapter
|
||||
|
||||
1. Запустить `uv run k1link serve` и открыть Mission Core Control Station.
|
||||
2. Выбрать **Парк → Локальное устройство**.
|
||||
3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
|
||||
активируется runtime и монтируется custom UI XGRIDS-плагина.
|
||||
4. Включить K1, дождаться стабильного индикатора, вручную сверить firmware
|
||||
`3.0.2` и direct-LAN топологию, затем подтвердить это в форме. Mission Core
|
||||
не читает firmware с устройства; такое подтверждение остаётся
|
||||
`operator-attested`, а не device-derived evidence.
|
||||
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
|
||||
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
|
||||
оператор.
|
||||
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||
reviewed provisioning-запись без автоматических повторов.
|
||||
7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится,
|
||||
проверяется на control/surrogate characters и лимит 96 Unicode characters,
|
||||
сохраняется как display metadata и не используется в filesystem path.
|
||||
8. Запустить локальный live-приём по определённому адресу K1 либо replay
|
||||
локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается
|
||||
и останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block
|
||||
показывает local acquisition phase, stop local reception и полученные от K1
|
||||
scan time/distance/speed; облако, траектория и preview metrics появляются
|
||||
только после реальных сообщений.
|
||||
11. Физически остановить K1, дождаться steady green, затем остановить локальный
|
||||
приём из device workflow или spatial block, чтобы запечатать evidence.
|
||||
12. После нормального stop или recovery открыть **Данные → Сессии и записи**. Дождаться
|
||||
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
|
||||
сохраняется автоматически; disk action сохраняет только workspace layout.
|
||||
|
||||
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
|
||||
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
|
||||
0 ошибок декодирования.
|
||||
|
||||
## Источники Rerun
|
||||
|
||||
Live/replay runtime автоматически назначает первый URL из примера. В
|
||||
**Наблюдение → Пространственная сцена → Источник** можно оставить ручное поле
|
||||
пустым либо указать другой адрес, который понимает `@rerun-io/web-viewer`
|
||||
зафиксированной в `package.json` версии:
|
||||
|
||||
```text
|
||||
rerun+http://127.0.0.1:9876/proxy
|
||||
http://127.0.0.1:8080/recording.rrd
|
||||
https://example.internal/recording.rrd
|
||||
```
|
||||
|
||||
- `rerun+http://…/proxy` — живой Rerun gRPC source через доступный браузеру
|
||||
proxy;
|
||||
- `http(s)://…/recording.rrd` — RRD-запись по HTTP(S);
|
||||
- версия RRD должна быть совместима с версией Web Viewer;
|
||||
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
|
||||
автоматическим source;
|
||||
- nonterminal acquisition fail-closed блокирует manual source apply/reset,
|
||||
saved session switch и persisted replay reattach до завершения текущего
|
||||
приёма; automatic-source action очищает старый source и открывает сцену только
|
||||
после successful start, failed start сохраняет текущую сцену, а guard не
|
||||
останавливает устройство;
|
||||
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||
|
||||
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||
оболочку. Backend принимает и применяет к Rerun размер точки `0.5–12.0`, режимы
|
||||
цвета `intensity`, `height`, `distance`, `rgb`, `class`, палитры Turbo, Viridis,
|
||||
Plasma, grayscale и custom, накопление `0–120` секунд, а также видимость облака,
|
||||
траектории и сетки. Значение по умолчанию — 12 секунд истории реальных кадров.
|
||||
Для текущего firmware-3 `lio_pcl` доказана только интенсивность из младшего
|
||||
байта `rgbi`; RGB используется лишь когда он действительно присутствует в
|
||||
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
|
||||
а не готовую семантическую классификацию.
|
||||
|
||||
Для saved session host timeline вызывает Rerun play/pause/seek только после
|
||||
полного admission. Accumulation и display settings применяются через отдельный
|
||||
маленький blueprint RRD и не заменяют основную запись. Versioned
|
||||
`observation.spatial` layout сохраняется на host с optimistic revision; он не
|
||||
содержит sensor evidence. Переключатель 2D/3D/карты и семантические слои пока не
|
||||
вызывают готовый presentation backend.
|
||||
|
||||
## Контракт локального API
|
||||
|
||||
| Метод | Route | Тело / назначение |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Проверка локального сервиса. |
|
||||
| `GET` | `/api/v1/device-plugins` | Валидированные manifests установленных device plugins. |
|
||||
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
|
||||
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
|
||||
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
|
||||
| `GET` | `/api/v1/observation-sessions` | Последние cataloged sessions и authoritative preparation state. |
|
||||
| `POST` | `/api/v1/observation-sessions/{id}/replay` | Verified replay launch либо HTTP 202 preparation handle. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/recording-preparation` | Exact-job polling с `If-Match`. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/recording.rrd` | Immutable generation-bound RRD; arbitrary chunked `send_rrd` не используется. |
|
||||
| `GET` | `/api/v1/observation-sessions/{id}/media/{artifact}/...` | Manifest/init/segments записанных камер по opaque identifiers. |
|
||||
| `GET`, `PUT` | `/api/v1/workspace-layouts/observation.spatial` | Versioned host layout с optimistic revision. |
|
||||
|
||||
Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
|
||||
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
|
||||
`/api/viewer/settings` сохранены как deprecated compatibility shims внутри
|
||||
XGRIDS backend contribution.
|
||||
|
||||
В v1alpha1/v1alpha2 backend composition загружается из manifests, а frontend plugins
|
||||
статически включаются в сборку через `src/composition/devicePlugins.ts`.
|
||||
Автоматической runtime-сверки двух installed sets пока нет: их соответствие —
|
||||
проверяемое требование сборки до появления подписанных plugin bundles и startup
|
||||
compatibility handshake. Поля manifest `permissions`, `mutating` и
|
||||
`secretFields` пока являются декларативными метаданными; generic host валидирует
|
||||
их форму и action allowlist, но ещё не реализует на их основе RBAC, подтверждения
|
||||
оператора или secret-vault substitution.
|
||||
Точный K1 compatibility profile остаётся неактивным, пока оператор явно не
|
||||
подтвердит firmware `3.0.2` и direct-LAN топологию. Runtime не получает версию
|
||||
firmware с устройства и явно помечает основание как `operator-attested`.
|
||||
Ошибки валидации namespaced action endpoint и deprecated `/api/connect`
|
||||
возвращаются как общий `422` без исходных значений запроса, поэтому отклонённое
|
||||
значение credential-поля не отражается клиенту.
|
||||
|
||||
Legacy `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
|
||||
как `{ "state": { ... } }`. v1alpha2 snapshot отдельно содержит `device_ref`,
|
||||
`device_session`, `acquisition`, `operations`, compatibility/calibration/sensor
|
||||
facts и старые presentation-поля `phase`, `message`, `devices`,
|
||||
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
|
||||
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
|
||||
пустыми. OpenAPI доступен по `/api/docs`.
|
||||
|
||||
## Карта исходников frontend
|
||||
|
||||
| Файл | Ответственность |
|
||||
| --- | --- |
|
||||
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
||||
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
|
||||
| `src/core/runtime/` | Нормализованное состояние активного устройства, spatial source и fail-closed source-switch guard. |
|
||||
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
|
||||
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
|
||||
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, project/acquisition/replay UI, optional spatial controls, API client, runtime mapper и scoped styles. |
|
||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
|
||||
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
|
||||
| `src/components/RecordedFmp4Player.tsx` | Проверенный generation-bound MSE playback архивных камер. |
|
||||
| `src/core/observation/sessionArchive.ts` | Строгий wire contract catalog/preparation/replay/media API. |
|
||||
| `src/core/observation/recordedSessionAdmission.ts` | Общий RRD/camera gate до публикации recorded workspace. |
|
||||
| `src/core/observation/workspaceLayout.ts` | Versioned spatial layout и optimistic revision. |
|
||||
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
|
||||
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
|
||||
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
|
||||
|
||||
## Safety и чувствительные данные
|
||||
|
||||
- Mission Core Control Station и credential endpoint доступны только на loopback.
|
||||
- Исключение: Rerun gRPC/proxy на TCP 9876 сейчас слушает все сетевые интерфейсы,
|
||||
хотя автоматически возвращаемый URL содержит `127.0.0.1`. На нём нет
|
||||
connector-level authentication или TLS. Этот порт допустим только в доверенной
|
||||
лабораторной LAN; его нельзя пробрасывать в публичный Интернет или cellular
|
||||
WAN без аутентифицированного TLS reverse proxy. Listener намеренно остаётся
|
||||
активным между сессиями; для его закрытия нужно остановить `k1link serve`.
|
||||
- Пароль Wi-Fi находится только в React memory, передаётся в JSON POST body,
|
||||
очищается после успешного ответа и не сохраняется в URL/local storage.
|
||||
- Bridge и Direct Connect выполняют только отдельно рассмотренную 99-байтную
|
||||
provisioning-запись. Quick Connect выполняет отдельную рассмотренную
|
||||
100-байтную AP-enable запись и после AP-ready допускает ровно одну системную
|
||||
ассоциацию с заранее материализованным exact-firmware профилем. Пароль не
|
||||
попадает в браузер/API/argv; системный Wi-Fi Keychain, post-write prompt и
|
||||
автоматические повторы запрещены.
|
||||
- MQTT data live/replay остаётся subscribe-only. Команды изолированы в
|
||||
plugin-owned canonical control session и доступны только через отдельные
|
||||
operator-present UI checkpoints.
|
||||
- Exact start/stop codec, response correlator и device-status state machine не
|
||||
владеют transport. Plugin v0.5.0 связывает их отдельным single-owner MQTT
|
||||
контуром без wall-clock переходов, reconnect или automatic retry; после STOP
|
||||
обязательны live READY и подтверждённый постоянный зелёный индикатор.
|
||||
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
|
||||
формируют disposable preview. При перегрузке preview может быть отброшен,
|
||||
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
|
||||
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
|
||||
- Новая session summary hash-bind-ит durable clock origin и атомарно переключает
|
||||
pointer с provisional `mqtt.timeline.json` на content-addressed
|
||||
`mqtt.timeline.session-<sha256>.json`: только owner-sealed `session` scope
|
||||
может рекламировать combined media replay. Cache v9 создаёт реальные
|
||||
origin/end RRD rows; provisional `transport` scope fails closed.
|
||||
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
|
||||
накопления viewer использует session-local `stream_time`. `capture_time` — не
|
||||
доказанный timestamp сенсора K1 и не photon-to-screen latency.
|
||||
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
|
||||
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
|
||||
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
|
||||
Физический archived playback point cloud + одна выбранная камера ещё не принят.
|
||||
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
|
||||
могут содержать адреса, изображения, идентификаторы, траекторию и карту
|
||||
помещения. В репозиторий попадают только код, тесты, redacted manifests и
|
||||
безопасная документация.
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Пункт управления NODE.DC — наблюдение, данные и планирование миссий беспилотных аппаратов."
|
||||
content="NODEDC MISSION CORE — наблюдение, данные и планирование миссий беспилотных аппаратов."
|
||||
/>
|
||||
<meta name="theme-color" content="#08090b" />
|
||||
<title>NODE.DC · Пункт управления</title>
|
||||
<title>NODEDC MISSION CORE</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
+133
-7
@@ -1,52 +1,77 @@
|
||||
{
|
||||
"name": "@nodedc/k1-viewer",
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@nodedc/k1-viewer",
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
"@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@types/three": "0.185.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react": {
|
||||
"name": "@nodedc/map-cesium-react",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"cesium": "1.143.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"react": "^19.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns": {
|
||||
"name": "@nodedc/page-patterns",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
||||
"name": "@nodedc/tokens",
|
||||
"version": "0.6.0"
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core": {
|
||||
"name": "@nodedc/ui-core",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@nodedc/tokens": "0.6.0"
|
||||
}
|
||||
},
|
||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react": {
|
||||
"name": "@nodedc/ui-react",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@nodedc/ui-core": "0.6.0",
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -342,6 +367,13 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
@@ -834,6 +866,26 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/map-cesium-react": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/page-patterns": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/tokens": {
|
||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"link": true
|
||||
@@ -1209,6 +1261,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1291,6 +1350,34 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1312,6 +1399,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.72",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
|
||||
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.43",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
|
||||
@@ -1489,6 +1582,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1557,6 +1657,13 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1613,6 +1720,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playcanvas": {
|
||||
"version": "2.21.4",
|
||||
"resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-2.21.4.tgz",
|
||||
"integrity": "sha512-L4UGy3z/YT8AaNBEY4ITsZup548UFMabF7loh0YQ0DRwQLdjIW8grmZPoQ1+B83+N6sejRS0/XlXV2Vfb11+fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/webxr": "^0.5.24",
|
||||
"@webgpu/types": "^0.1.70"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
@@ -1744,6 +1864,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -1,32 +1,40 @@
|
||||
{
|
||||
"name": "@nodedc/k1-viewer",
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test test/*.test.mjs",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
"@nodedc/page-patterns": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/page-patterns",
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@types/three": "0.185.1",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-wasm": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 322 B |
@@ -0,0 +1,8 @@
|
||||
# Draco glTF decoder 1.5.7
|
||||
|
||||
Vendored from Google's immutable versioned Draco decoder distribution for the
|
||||
PlayCanvas `DracoDecoderModule`. Runtime loading remains local to Mission Core.
|
||||
|
||||
- `draco_wasm_wrapper_gltf.js`: `8bb2952d2ba7d67e1414f8df819410cb0434a666be53f671fff75f68843d76f6`
|
||||
- `draco_decoder_gltf.wasm`: `712db3449ae2041d6e8a224c395bda6cedb49e51322fae38b7db9beb8b381889`
|
||||
- Upstream base: `https://www.gstatic.com/draco/versioned/decoders/1.5.7/`
|
||||
BIN
Binary file not shown.
+116
@@ -0,0 +1,116 @@
|
||||
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(h){var n=0;return function(){return n<h.length?{done:!1,value:h[n++]}:{done:!0}}};$jscomp.arrayIterator=function(h){return{next:$jscomp.arrayIteratorImpl(h)}};$jscomp.makeIterator=function(h){var n="undefined"!=typeof Symbol&&Symbol.iterator&&h[Symbol.iterator];return n?n.call(h):$jscomp.arrayIterator(h)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
|
||||
$jscomp.ISOLATE_POLYFILLS=!1;$jscomp.FORCE_POLYFILL_PROMISE=!1;$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION=!1;$jscomp.getGlobal=function(h){h=["object"==typeof globalThis&&globalThis,h,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var n=0;n<h.length;++n){var k=h[n];if(k&&k.Math==Math)return k}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
|
||||
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(h,n,k){if(h==Array.prototype||h==Object.prototype)return h;h[n]=k.value;return h};$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";
|
||||
var $jscomp$lookupPolyfilledValue=function(h,n){var k=$jscomp.propertyToPolyfillSymbol[n];if(null==k)return h[n];k=h[k];return void 0!==k?k:h[n]};$jscomp.polyfill=function(h,n,k,p){n&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(h,n,k,p):$jscomp.polyfillUnisolated(h,n,k,p))};
|
||||
$jscomp.polyfillUnisolated=function(h,n,k,p){k=$jscomp.global;h=h.split(".");for(p=0;p<h.length-1;p++){var l=h[p];if(!(l in k))return;k=k[l]}h=h[h.length-1];p=k[h];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(k,h,{configurable:!0,writable:!0,value:n})};
|
||||
$jscomp.polyfillIsolated=function(h,n,k,p){var l=h.split(".");h=1===l.length;p=l[0];p=!h&&p in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var y=0;y<l.length-1;y++){var f=l[y];if(!(f in p))return;p=p[f]}l=l[l.length-1];k=$jscomp.IS_SYMBOL_NATIVE&&"es6"===k?p[l]:null;n=n(k);null!=n&&(h?$jscomp.defineProperty($jscomp.polyfills,l,{configurable:!0,writable:!0,value:n}):n!==k&&(void 0===$jscomp.propertyToPolyfillSymbol[l]&&(k=1E9*Math.random()>>>0,$jscomp.propertyToPolyfillSymbol[l]=$jscomp.IS_SYMBOL_NATIVE?
|
||||
$jscomp.global.Symbol(l):$jscomp.POLYFILL_PREFIX+k+"$"+l),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[l],{configurable:!0,writable:!0,value:n})))};
|
||||
$jscomp.polyfill("Promise",function(h){function n(){this.batch_=null}function k(f){return f instanceof l?f:new l(function(q,u){q(f)})}if(h&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return h;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)};
|
||||
var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q<f.length;++q){var u=f[q];f[q]=null;try{u()}catch(A){this.asyncThrow_(A)}}}this.batch_=null};n.prototype.asyncThrow_=function(f){this.asyncExecuteFunction(function(){throw f;})};var l=function(f){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];this.isRejectionHandled_=!1;var q=this.createResolveAndReject_();
|
||||
try{f(q.resolve,q.reject)}catch(u){q.reject(u)}};l.prototype.createResolveAndReject_=function(){function f(A){return function(F){u||(u=!0,A.call(q,F))}}var q=this,u=!1;return{resolve:f(this.resolveTo_),reject:f(this.reject_)}};l.prototype.resolveTo_=function(f){if(f===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof l)this.settleSameAsPromise_(f);else{a:switch(typeof f){case "object":var q=null!=f;break a;case "function":q=!0;break a;default:q=!1}q?this.resolveToNonPromiseObj_(f):
|
||||
this.fulfill_(f)}};l.prototype.resolveToNonPromiseObj_=function(f){var q=void 0;try{q=f.then}catch(u){this.reject_(u);return}"function"==typeof q?this.settleSameAsThenable_(q,f):this.fulfill_(f)};l.prototype.reject_=function(f){this.settle_(2,f)};l.prototype.fulfill_=function(f){this.settle_(1,f)};l.prototype.settle_=function(f,q){if(0!=this.state_)throw Error("Cannot settle("+f+", "+q+"): Promise already settled in state"+this.state_);this.state_=f;this.result_=q;2===this.state_&&this.scheduleUnhandledRejectionCheck_();
|
||||
this.executeOnSettledCallbacks_()};l.prototype.scheduleUnhandledRejectionCheck_=function(){var f=this;p(function(){if(f.notifyUnhandledRejection_()){var q=$jscomp.global.console;"undefined"!==typeof q&&q.error(f.result_)}},1)};l.prototype.notifyUnhandledRejection_=function(){if(this.isRejectionHandled_)return!1;var f=$jscomp.global.CustomEvent,q=$jscomp.global.Event,u=$jscomp.global.dispatchEvent;if("undefined"===typeof u)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):
|
||||
"function"===typeof q?f=new q("unhandledrejection",{cancelable:!0}):(f=$jscomp.global.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.result_;return u(f)};l.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var f=0;f<this.onSettledCallbacks_.length;++f)y.asyncExecute(this.onSettledCallbacks_[f]);this.onSettledCallbacks_=null}};var y=new n;l.prototype.settleSameAsPromise_=function(f){var q=this.createResolveAndReject_();
|
||||
f.callWhenSettled_(q.resolve,q.reject)};l.prototype.settleSameAsThenable_=function(f,q){var u=this.createResolveAndReject_();try{f.call(q,u.resolve,u.reject)}catch(A){u.reject(A)}};l.prototype.then=function(f,q){function u(w,B){return"function"==typeof w?function(R){try{A(w(R))}catch(Z){F(Z)}}:B}var A,F,v=new l(function(w,B){A=w;F=B});this.callWhenSettled_(u(f,A),u(q,F));return v};l.prototype.catch=function(f){return this.then(void 0,f)};l.prototype.callWhenSettled_=function(f,q){function u(){switch(A.state_){case 1:f(A.result_);
|
||||
break;case 2:q(A.result_);break;default:throw Error("Unexpected state: "+A.state_);}}var A=this;null==this.onSettledCallbacks_?y.asyncExecute(u):this.onSettledCallbacks_.push(u);this.isRejectionHandled_=!0};l.resolve=k;l.reject=function(f){return new l(function(q,u){u(f)})};l.race=function(f){return new l(function(q,u){for(var A=$jscomp.makeIterator(f),F=A.next();!F.done;F=A.next())k(F.value).callWhenSettled_(q,u)})};l.all=function(f){var q=$jscomp.makeIterator(f),u=q.next();return u.done?k([]):new l(function(A,
|
||||
F){function v(R){return function(Z){w[R]=Z;B--;0==B&&A(w)}}var w=[],B=0;do w.push(void 0),B++,k(u.value).callWhenSettled_(v(w.length-1),F),u=q.next();while(!u.done)})};return l},"es6","es3");$jscomp.owns=function(h,n){return Object.prototype.hasOwnProperty.call(h,n)};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(h,n){for(var k=1;k<arguments.length;k++){var p=arguments[k];if(p)for(var l in p)$jscomp.owns(p,l)&&(h[l]=p[l])}return h};
|
||||
$jscomp.polyfill("Object.assign",function(h){return h||$jscomp.assign},"es6","es3");$jscomp.checkStringArgs=function(h,n,k){if(null==h)throw new TypeError("The 'this' value for String.prototype."+k+" must not be null or undefined");if(n instanceof RegExp)throw new TypeError("First argument to String.prototype."+k+" must not be a regular expression");return h+""};
|
||||
$jscomp.polyfill("String.prototype.startsWith",function(h){return h?h:function(n,k){var p=$jscomp.checkStringArgs(this,n,"startsWith");n+="";var l=p.length,y=n.length;k=Math.max(0,Math.min(k|0,p.length));for(var f=0;f<y&&k<l;)if(p[k++]!=n[f++])return!1;return f>=y}},"es6","es3");
|
||||
$jscomp.polyfill("Array.prototype.copyWithin",function(h){function n(k){k=Number(k);return Infinity===k||-Infinity===k?k:k|0}return h?h:function(k,p,l){var y=this.length;k=n(k);p=n(p);l=void 0===l?y:n(l);k=0>k?Math.max(y+k,0):Math.min(k,y);p=0>p?Math.max(y+p,0):Math.min(p,y);l=0>l?Math.max(y+l,0):Math.min(l,y);if(k<p)for(;p<l;)p in this?this[k++]=this[p++]:(delete this[k++],p++);else for(l=Math.min(l,y+p-k),k+=l-p;l>p;)--l in this?this[--k]=this[l]:delete this[--k];return this}},"es6","es3");
|
||||
$jscomp.typedArrayCopyWithin=function(h){return h?h:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
|
||||
$jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
|
||||
var DracoDecoderModule=function(){var h="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(h=h||__filename);return function(n){function k(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b){if(e){var c=ia;var d=e+b;for(b=e;c[b]&&!(b>=d);)++b;if(16<b-e&&c.buffer&&ra)c=ra.decode(c.subarray(e,b));else{for(d="";e<b;){var g=c[e++];if(g&128){var t=c[e++]&63;if(192==(g&224))d+=String.fromCharCode((g&31)<<6|t);else{var aa=c[e++]&
|
||||
63;g=224==(g&240)?(g&15)<<12|t<<6|aa:(g&7)<<18|t<<12|aa<<6|c[e++]&63;65536>g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}c=d}}else c="";return c}function l(){var e=ja.buffer;a.HEAP8=W=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ia=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=Y=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function y(e){if(a.onAbort)a.onAbort(e);
|
||||
e="Aborted("+e+")";da(e);sa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function f(e){try{if(e==P&&ea)return new Uint8Array(ea);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){y(b)}}function q(){if(!ea&&(ta||fa)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return f(P)});
|
||||
if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return f(P)})}function u(e){for(;0<e.length;)e.shift()(a)}function A(e){this.excPtr=e;this.ptr=e-24;this.set_type=function(b){Y[this.ptr+4>>2]=b};this.get_type=function(){return Y[this.ptr+4>>2]};this.set_destructor=function(b){Y[this.ptr+8>>2]=b};this.get_destructor=function(){return Y[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){W[this.ptr+
|
||||
12>>0]=b?1:0};this.get_caught=function(){return 0!=W[this.ptr+12>>0]};this.set_rethrown=function(b){W[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=W[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){Y[this.ptr+
|
||||
16>>2]=b};this.get_adjusted_ptr=function(){return Y[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ua(this.get_type()))return Y[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function F(){function e(){if(!la&&(la=!0,a.calledRun=!0,!sa)){va=!0;u(oa);wa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)xa.unshift(a.postRun.shift());u(xa)}}if(!(0<ba)){if(a.preRun)for("function"==
|
||||
typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)ya.unshift(a.preRun.shift());u(ya);0<ba||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);e()},1)):e())}}function v(){}function w(e){return(e||v).__cache__}function B(e,b){var c=w(b),d=c[e];if(d)return d;d=Object.create((b||v).prototype);d.ptr=e;return c[e]=d}function R(e){if("string"===typeof e){for(var b=0,c=0;c<e.length;++c){var d=e.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=
|
||||
d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0<d){d=c+d-1;for(var g=0;g<e.length;++g){var t=e.charCodeAt(g);if(55296<=t&&57343>=t){var aa=e.charCodeAt(++g);t=65536+((t&1023)<<10)|aa&1023}if(127>=t){if(c>=d)break;b[c++]=t}else{if(2047>=t){if(c+1>=d)break;b[c++]=192|t>>6}else{if(65535>=t){if(c+2>=d)break;b[c++]=224|t>>12}else{if(c+3>=d)break;b[c++]=240|t>>18;b[c++]=128|t>>12&63}b[c++]=128|t>>6&63}b[c++]=128|t&63}}b[c]=0}e=r.alloc(b,W);r.copy(b,W,e);return e}return e}function Z(e){if("object"===
|
||||
typeof e){var b=r.alloc(e,W);r.copy(e,W,b);return b}return e}function X(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=za();w(S)[this.ptr]=this}function Q(){this.ptr=Aa();w(Q)[this.ptr]=this}function V(){this.ptr=Ba();w(V)[this.ptr]=this}function x(){this.ptr=Ca();w(x)[this.ptr]=this}function D(){this.ptr=Da();w(D)[this.ptr]=this}function G(){this.ptr=Ea();w(G)[this.ptr]=this}function H(){this.ptr=Fa();w(H)[this.ptr]=this}function E(){this.ptr=Ga();w(E)[this.ptr]=
|
||||
this}function T(){this.ptr=Ha();w(T)[this.ptr]=this}function C(){throw"cannot construct a Status, no constructor in IDL";}function I(){this.ptr=Ia();w(I)[this.ptr]=this}function J(){this.ptr=Ja();w(J)[this.ptr]=this}function K(){this.ptr=Ka();w(K)[this.ptr]=this}function L(){this.ptr=La();w(L)[this.ptr]=this}function M(){this.ptr=Ma();w(M)[this.ptr]=this}function N(){this.ptr=Na();w(N)[this.ptr]=this}function O(){this.ptr=Oa();w(O)[this.ptr]=this}function z(){this.ptr=Pa();w(z)[this.ptr]=this}function m(){this.ptr=
|
||||
Qa();w(m)[this.ptr]=this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},wa,ka;a.ready=new Promise(function(e,b){wa=e;ka=b});var Ra=!1,Sa=!1;a.onRuntimeInitialized=function(){Ra=!0;if(Sa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Sa=!0;if(Ra&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3<e.length?!1:1==e[0]&&0<=e[1]&&5>=e[1]?!0:0!=e[0]||10<
|
||||
e[1]?!1:!0};var Ta=Object.assign({},a),ta="object"==typeof window,fa="function"==typeof importScripts,Ua="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ua){var Va=require("fs"),pa=require("path");U=fa?pa.dirname(U)+"/":__dirname+"/";var Wa=function(e,b){e=e.startsWith("file://")?new URL(e):pa.normalize(e);return Va.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=Wa(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e,
|
||||
b,c){e=e.startsWith("file://")?new URL(e):pa.normalize(e);Va.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1<process.argv.length&&process.argv[1].replace(/\\/g,"/");process.argv.slice(2);a.inspect=function(){return"[Emscripten Module object]"}}else if(ta||fa)fa?U=self.location.href:"undefined"!=typeof document&&document.currentScript&&(U=document.currentScript.src),h&&(U=h),U=0!==U.indexOf("blob:")?U.substr(0,U.replace(/[?#].*/,"").lastIndexOf("/")+1):"",Wa=function(e){var b=new XMLHttpRequest;b.open("GET",
|
||||
e,!1);b.send(null);return b.responseText},fa&&(ma=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=function(e,b,c){var d=new XMLHttpRequest;d.open("GET",e,!0);d.responseType="arraybuffer";d.onload=function(){200==d.status||0==d.status&&d.response?b(d.response):c()};d.onerror=c;d.send(null)};a.print||console.log.bind(console);var da=a.printErr||console.warn.bind(console);Object.assign(a,Ta);Ta=null;var ea;a.wasmBinary&&
|
||||
(ea=a.wasmBinary);"object"!=typeof WebAssembly&&y("no native wasm support detected");var ja,sa=!1,ra="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,W,ia,ca,Y,ya=[],oa=[],xa=[],va=!1,ba=0,qa=null,ha=null;var P="draco_decoder_gltf.wasm";P.startsWith("data:application/octet-stream;base64,")||(P=k(P));var pd=0,qd={b:function(e,b,c){(new A(e)).init(b,c);pd++;throw e;},a:function(){y("")},d:function(e,b,c){ia.copyWithin(e,b,b+c)},c:function(e){var b=ia.length;e>>>=0;if(2147483648<e)return!1;
|
||||
for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);l();var t=1;break a}catch(aa){}t=void 0}if(t)return!0}return!1}};(function(){function e(g,t){a.asm=g.exports;ja=a.asm.e;l();oa.unshift(a.asm.f);ba--;a.monitorRunDependencies&&a.monitorRunDependencies(ba);0==ba&&(null!==qa&&(clearInterval(qa),qa=null),ha&&(g=ha,ha=null,g()))}function b(g){e(g.instance)}
|
||||
function c(g){return q().then(function(t){return WebAssembly.instantiate(t,d)}).then(function(t){return t}).then(g,function(t){da("failed to asynchronously prepare wasm: "+t);y(t)})}var d={a:qd};ba++;a.monitorRunDependencies&&a.monitorRunDependencies(ba);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return ea||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")||
|
||||
P.startsWith("file://")||Ua||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(t){da("wasm streaming compile failed: "+t);da("falling back to ArrayBuffer instantiation");return c(b)})})})().catch(ka);return{}})();var Xa=a._emscripten_bind_VoidPtr___destroy___0=function(){return(Xa=a._emscripten_bind_VoidPtr___destroy___0=a.asm.h).apply(null,arguments)},za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=
|
||||
function(){return(za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.i).apply(null,arguments)},Ya=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(Ya=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.j).apply(null,arguments)},Za=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return(Za=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.k).apply(null,arguments)},Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=
|
||||
a.asm.l).apply(null,arguments)},$a=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return($a=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.m).apply(null,arguments)},ab=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return(ab=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.n).apply(null,arguments)},Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=
|
||||
a.asm.o).apply(null,arguments)},bb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(bb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.p).apply(null,arguments)},Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.q).apply(null,arguments)},cb=a._emscripten_bind_PointAttribute_size_0=function(){return(cb=a._emscripten_bind_PointAttribute_size_0=a.asm.r).apply(null,arguments)},db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=
|
||||
function(){return(db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.s).apply(null,arguments)},eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(eb=a._emscripten_bind_PointAttribute_attribute_type_0=a.asm.t).apply(null,arguments)},fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(fb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.u).apply(null,arguments)},gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(gb=a._emscripten_bind_PointAttribute_num_components_0=
|
||||
a.asm.v).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(hb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.w).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return(ib=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.x).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(jb=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.y).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_unique_id_0=
|
||||
function(){return(kb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.z).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(lb=a._emscripten_bind_PointAttribute___destroy___0=a.asm.A).apply(null,arguments)},Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.B).apply(null,arguments)},mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=
|
||||
function(){return(mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.C).apply(null,arguments)},nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return(nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.D).apply(null,arguments)},ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.E).apply(null,arguments)},pb=
|
||||
a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(pb=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.F).apply(null,arguments)},qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return(qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.G).apply(null,arguments)},Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=
|
||||
a.asm.H).apply(null,arguments)},rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.I).apply(null,arguments)},sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.J).apply(null,arguments)},tb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(tb=
|
||||
a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.K).apply(null,arguments)},Fa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Fa=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.L).apply(null,arguments)},ub=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(ub=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.M).apply(null,arguments)},vb=a._emscripten_bind_PointCloud_num_points_0=function(){return(vb=a._emscripten_bind_PointCloud_num_points_0=a.asm.N).apply(null,
|
||||
arguments)},wb=a._emscripten_bind_PointCloud___destroy___0=function(){return(wb=a._emscripten_bind_PointCloud___destroy___0=a.asm.O).apply(null,arguments)},Ga=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ga=a._emscripten_bind_Mesh_Mesh_0=a.asm.P).apply(null,arguments)},xb=a._emscripten_bind_Mesh_num_faces_0=function(){return(xb=a._emscripten_bind_Mesh_num_faces_0=a.asm.Q).apply(null,arguments)},yb=a._emscripten_bind_Mesh_num_attributes_0=function(){return(yb=a._emscripten_bind_Mesh_num_attributes_0=
|
||||
a.asm.R).apply(null,arguments)},zb=a._emscripten_bind_Mesh_num_points_0=function(){return(zb=a._emscripten_bind_Mesh_num_points_0=a.asm.S).apply(null,arguments)},Ab=a._emscripten_bind_Mesh___destroy___0=function(){return(Ab=a._emscripten_bind_Mesh___destroy___0=a.asm.T).apply(null,arguments)},Ha=a._emscripten_bind_Metadata_Metadata_0=function(){return(Ha=a._emscripten_bind_Metadata_Metadata_0=a.asm.U).apply(null,arguments)},Bb=a._emscripten_bind_Metadata___destroy___0=function(){return(Bb=a._emscripten_bind_Metadata___destroy___0=
|
||||
a.asm.V).apply(null,arguments)},Cb=a._emscripten_bind_Status_code_0=function(){return(Cb=a._emscripten_bind_Status_code_0=a.asm.W).apply(null,arguments)},Db=a._emscripten_bind_Status_ok_0=function(){return(Db=a._emscripten_bind_Status_ok_0=a.asm.X).apply(null,arguments)},Eb=a._emscripten_bind_Status_error_msg_0=function(){return(Eb=a._emscripten_bind_Status_error_msg_0=a.asm.Y).apply(null,arguments)},Fb=a._emscripten_bind_Status___destroy___0=function(){return(Fb=a._emscripten_bind_Status___destroy___0=
|
||||
a.asm.Z).apply(null,arguments)},Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm._).apply(null,arguments)},Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.$).apply(null,arguments)},Hb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Hb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.aa).apply(null,arguments)},Ib=
|
||||
a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Ib=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ba).apply(null,arguments)},Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return(Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.ca).apply(null,arguments)},Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.da).apply(null,arguments)},Kb=a._emscripten_bind_DracoInt8Array_size_0=
|
||||
function(){return(Kb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ea).apply(null,arguments)},Lb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Lb=a._emscripten_bind_DracoInt8Array___destroy___0=a.asm.fa).apply(null,arguments)},Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ga).apply(null,arguments)},Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=
|
||||
a.asm.ha).apply(null,arguments)},Nb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Nb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.ia).apply(null,arguments)},Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return(Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ja).apply(null,arguments)},La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.ka).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=
|
||||
function(){return(Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.la).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Qb=a._emscripten_bind_DracoInt16Array_size_0=a.asm.ma).apply(null,arguments)},Rb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Rb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.na).apply(null,arguments)},Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=
|
||||
a.asm.oa).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.pa).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Tb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.qa).apply(null,arguments)},Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ra).apply(null,arguments)},Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=
|
||||
function(){return(Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.sa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return(Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.ta).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt32Array_size_0=function(){return(Wb=a._emscripten_bind_DracoInt32Array_size_0=a.asm.ua).apply(null,arguments)},Xb=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(Xb=a._emscripten_bind_DracoInt32Array___destroy___0=
|
||||
a.asm.va).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=a.asm.wa).apply(null,arguments)},Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.xa).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(Zb=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.ya).apply(null,arguments)},$b=a._emscripten_bind_DracoUInt32Array___destroy___0=
|
||||
function(){return($b=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.za).apply(null,arguments)},Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return(Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Aa).apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ba).apply(null,arguments)},bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=
|
||||
a.asm.Ca).apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=a.asm.Da).apply(null,arguments)},dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ea).apply(null,arguments)},ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Fa).apply(null,
|
||||
arguments)},fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ga).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ha).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(hc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.Ia).apply(null,arguments)},Qa=a._emscripten_bind_Decoder_Decoder_0=
|
||||
function(){return(Qa=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ja).apply(null,arguments)},ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Ka).apply(null,arguments)},jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.La).apply(null,arguments)},kc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(kc=a._emscripten_bind_Decoder_GetAttributeId_2=
|
||||
a.asm.Ma).apply(null,arguments)},lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=a.asm.Na).apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Oa).apply(null,arguments)},nc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(nc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Pa).apply(null,arguments)},
|
||||
oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Qa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(pc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ra).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Sa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=
|
||||
function(){return(rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Ta).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return(sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Ua).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Va).apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(uc=
|
||||
a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Wa).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm.Xa).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.Ya).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=
|
||||
a.asm.Za).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm._a).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.$a).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=
|
||||
a.asm.ab).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.bb).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.cb).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=
|
||||
a.asm.db).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.eb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.fb).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=
|
||||
a.asm.gb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return(Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.hb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.ib).apply(null,arguments)},Jc=a._emscripten_bind_Decoder___destroy___0=function(){return(Jc=a._emscripten_bind_Decoder___destroy___0=a.asm.jb).apply(null,arguments)},Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=
|
||||
function(){return(Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=a.asm.kb).apply(null,arguments)},Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.lb).apply(null,arguments)},Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=
|
||||
a.asm.mb).apply(null,arguments)},Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return(Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.nb).apply(null,arguments)},Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.ob).apply(null,arguments)},Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=
|
||||
a.asm.pb).apply(null,arguments)},Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return(Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.qb).apply(null,arguments)},Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.rb).apply(null,arguments)},Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=
|
||||
a.asm.sb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.tb).apply(null,arguments)},Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.ub).apply(null,arguments)},Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return(Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=
|
||||
a.asm.vb).apply(null,arguments)},Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return(Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.wb).apply(null,arguments)},Xc=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(Xc=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.xb).apply(null,arguments)},Yc=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(Yc=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.yb).apply(null,arguments)},Zc=
|
||||
a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(Zc=a._emscripten_enum_draco_DataType_DT_UINT8=a.asm.zb).apply(null,arguments)},$c=a._emscripten_enum_draco_DataType_DT_INT16=function(){return($c=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Ab).apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(ad=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Bb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INT32=
|
||||
a.asm.Cb).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return(cd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Db).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(dd=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Eb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(ed=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Fb).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=
|
||||
function(){return(fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Gb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Hb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(hd=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Ib).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=
|
||||
a.asm.Jb).apply(null,arguments)},jd=a._emscripten_enum_draco_StatusCode_OK=function(){return(jd=a._emscripten_enum_draco_StatusCode_OK=a.asm.Kb).apply(null,arguments)},kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Lb).apply(null,arguments)},ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Mb).apply(null,arguments)},md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=
|
||||
function(){return(md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=a.asm.Nb).apply(null,arguments)},nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Ob).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Pb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Qb).apply(null,arguments)};
|
||||
a._free=function(){return(a._free=a.asm.Rb).apply(null,arguments)};var ua=function(){return(ua=a.asm.Sb).apply(null,arguments)};a.___start_em_js=11660;a.___stop_em_js=11758;var la;ha=function b(){la||F();la||(ha=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();F();v.prototype=Object.create(v.prototype);v.prototype.constructor=v;v.prototype.__class__=v;v.__cache__={};a.WrapperObject=v;a.getCache=w;a.wrapPointer=B;a.castObject=function(b,
|
||||
c){return B(b.ptr,c)};a.NULL=B(0);a.destroy=function(b){if(!b.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";b.__destroy__();delete w(b.__class__)[b.ptr]};a.compare=function(b,c){return b.ptr===c.ptr};a.getPointer=function(b){return b.ptr};a.getClass=function(b){return b.__class__};var r={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(r.needed){for(var b=0;b<r.temps.length;b++)a._free(r.temps[b]);r.temps.length=0;a._free(r.buffer);r.buffer=0;r.size+=
|
||||
r.needed;r.needed=0}r.buffer||(r.size+=128,r.buffer=a._malloc(r.size),r.buffer||y(void 0));r.pos=0},alloc:function(b,c){r.buffer||y(void 0);b=b.length*c.BYTES_PER_ELEMENT;b=b+7&-8;r.pos+b>=r.size?(0<b||y(void 0),r.needed+=b,c=a._malloc(b),r.temps.push(c)):(c=r.buffer+r.pos,r.pos+=b);return c},copy:function(b,c,d){d>>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;g<b.length;g++)c[d+g]=b[g]}};X.prototype=Object.create(v.prototype);X.prototype.constructor=
|
||||
X;X.prototype.__class__=X;X.__cache__={};a.VoidPtr=X;X.prototype.__destroy__=X.prototype.__destroy__=function(){Xa(this.ptr)};S.prototype=Object.create(v.prototype);S.prototype.constructor=S;S.prototype.__class__=S;S.__cache__={};a.DecoderBuffer=S;S.prototype.Init=S.prototype.Init=function(b,c){var d=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);Ya(d,b,c)};S.prototype.__destroy__=S.prototype.__destroy__=function(){Za(this.ptr)};Q.prototype=Object.create(v.prototype);
|
||||
Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.AttributeTransformData=Q;Q.prototype.transform_type=Q.prototype.transform_type=function(){return $a(this.ptr)};Q.prototype.__destroy__=Q.prototype.__destroy__=function(){ab(this.ptr)};V.prototype=Object.create(v.prototype);V.prototype.constructor=V;V.prototype.__class__=V;V.__cache__={};a.GeometryAttribute=V;V.prototype.__destroy__=V.prototype.__destroy__=function(){bb(this.ptr)};x.prototype=Object.create(v.prototype);x.prototype.constructor=
|
||||
x;x.prototype.__class__=x;x.__cache__={};a.PointAttribute=x;x.prototype.size=x.prototype.size=function(){return cb(this.ptr)};x.prototype.GetAttributeTransformData=x.prototype.GetAttributeTransformData=function(){return B(db(this.ptr),Q)};x.prototype.attribute_type=x.prototype.attribute_type=function(){return eb(this.ptr)};x.prototype.data_type=x.prototype.data_type=function(){return fb(this.ptr)};x.prototype.num_components=x.prototype.num_components=function(){return gb(this.ptr)};x.prototype.normalized=
|
||||
x.prototype.normalized=function(){return!!hb(this.ptr)};x.prototype.byte_stride=x.prototype.byte_stride=function(){return ib(this.ptr)};x.prototype.byte_offset=x.prototype.byte_offset=function(){return jb(this.ptr)};x.prototype.unique_id=x.prototype.unique_id=function(){return kb(this.ptr)};x.prototype.__destroy__=x.prototype.__destroy__=function(){lb(this.ptr)};D.prototype=Object.create(v.prototype);D.prototype.constructor=D;D.prototype.__class__=D;D.__cache__={};a.AttributeQuantizationTransform=
|
||||
D;D.prototype.InitFromAttribute=D.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!mb(c,b)};D.prototype.quantization_bits=D.prototype.quantization_bits=function(){return nb(this.ptr)};D.prototype.min_value=D.prototype.min_value=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return ob(c,b)};D.prototype.range=D.prototype.range=function(){return pb(this.ptr)};D.prototype.__destroy__=D.prototype.__destroy__=function(){qb(this.ptr)};G.prototype=
|
||||
Object.create(v.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.AttributeOctahedronTransform=G;G.prototype.InitFromAttribute=G.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!rb(c,b)};G.prototype.quantization_bits=G.prototype.quantization_bits=function(){return sb(this.ptr)};G.prototype.__destroy__=G.prototype.__destroy__=function(){tb(this.ptr)};H.prototype=Object.create(v.prototype);H.prototype.constructor=H;H.prototype.__class__=
|
||||
H;H.__cache__={};a.PointCloud=H;H.prototype.num_attributes=H.prototype.num_attributes=function(){return ub(this.ptr)};H.prototype.num_points=H.prototype.num_points=function(){return vb(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){wb(this.ptr)};E.prototype=Object.create(v.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.Mesh=E;E.prototype.num_faces=E.prototype.num_faces=function(){return xb(this.ptr)};E.prototype.num_attributes=E.prototype.num_attributes=
|
||||
function(){return yb(this.ptr)};E.prototype.num_points=E.prototype.num_points=function(){return zb(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=function(){Ab(this.ptr)};T.prototype=Object.create(v.prototype);T.prototype.constructor=T;T.prototype.__class__=T;T.__cache__={};a.Metadata=T;T.prototype.__destroy__=T.prototype.__destroy__=function(){Bb(this.ptr)};C.prototype=Object.create(v.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.Status=C;C.prototype.code=
|
||||
C.prototype.code=function(){return Cb(this.ptr)};C.prototype.ok=C.prototype.ok=function(){return!!Db(this.ptr)};C.prototype.error_msg=C.prototype.error_msg=function(){return p(Eb(this.ptr))};C.prototype.__destroy__=C.prototype.__destroy__=function(){Fb(this.ptr)};I.prototype=Object.create(v.prototype);I.prototype.constructor=I;I.prototype.__class__=I;I.__cache__={};a.DracoFloat32Array=I;I.prototype.GetValue=I.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gb(c,
|
||||
b)};I.prototype.size=I.prototype.size=function(){return Hb(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Ib(this.ptr)};J.prototype=Object.create(v.prototype);J.prototype.constructor=J;J.prototype.__class__=J;J.__cache__={};a.DracoInt8Array=J;J.prototype.GetValue=J.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Jb(c,b)};J.prototype.size=J.prototype.size=function(){return Kb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Lb(this.ptr)};
|
||||
K.prototype=Object.create(v.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DracoUInt8Array=K;K.prototype.GetValue=K.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Mb(c,b)};K.prototype.size=K.prototype.size=function(){return Nb(this.ptr)};K.prototype.__destroy__=K.prototype.__destroy__=function(){Ob(this.ptr)};L.prototype=Object.create(v.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.DracoInt16Array=
|
||||
L;L.prototype.GetValue=L.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Pb(c,b)};L.prototype.size=L.prototype.size=function(){return Qb(this.ptr)};L.prototype.__destroy__=L.prototype.__destroy__=function(){Rb(this.ptr)};M.prototype=Object.create(v.prototype);M.prototype.constructor=M;M.prototype.__class__=M;M.__cache__={};a.DracoUInt16Array=M;M.prototype.GetValue=M.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Sb(c,b)};
|
||||
M.prototype.size=M.prototype.size=function(){return Tb(this.ptr)};M.prototype.__destroy__=M.prototype.__destroy__=function(){Ub(this.ptr)};N.prototype=Object.create(v.prototype);N.prototype.constructor=N;N.prototype.__class__=N;N.__cache__={};a.DracoInt32Array=N;N.prototype.GetValue=N.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Vb(c,b)};N.prototype.size=N.prototype.size=function(){return Wb(this.ptr)};N.prototype.__destroy__=N.prototype.__destroy__=function(){Xb(this.ptr)};
|
||||
O.prototype=Object.create(v.prototype);O.prototype.constructor=O;O.prototype.__class__=O;O.__cache__={};a.DracoUInt32Array=O;O.prototype.GetValue=O.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Yb(c,b)};O.prototype.size=O.prototype.size=function(){return Zb(this.ptr)};O.prototype.__destroy__=O.prototype.__destroy__=function(){$b(this.ptr)};z.prototype=Object.create(v.prototype);z.prototype.constructor=z;z.prototype.__class__=z;z.__cache__={};a.MetadataQuerier=
|
||||
z;z.prototype.HasEntry=z.prototype.HasEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return!!ac(d,b,c)};z.prototype.GetIntEntry=z.prototype.GetIntEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return bc(d,b,c)};z.prototype.GetIntEntryArray=z.prototype.GetIntEntryArray=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===
|
||||
typeof c?c.ptr:R(c);d&&"object"===typeof d&&(d=d.ptr);cc(g,b,c,d)};z.prototype.GetDoubleEntry=z.prototype.GetDoubleEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return dc(d,b,c)};z.prototype.GetStringEntry=z.prototype.GetStringEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return p(ec(d,b,c))};z.prototype.NumEntries=z.prototype.NumEntries=function(b){var c=this.ptr;
|
||||
b&&"object"===typeof b&&(b=b.ptr);return fc(c,b)};z.prototype.GetEntryName=z.prototype.GetEntryName=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return p(gc(d,b,c))};z.prototype.__destroy__=z.prototype.__destroy__=function(){hc(this.ptr)};m.prototype=Object.create(v.prototype);m.prototype.constructor=m;m.prototype.__class__=m;m.__cache__={};a.Decoder=m;m.prototype.DecodeArrayToPointCloud=m.prototype.DecodeArrayToPointCloud=function(b,c,d){var g=
|
||||
this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(ic(g,b,c,d),C)};m.prototype.DecodeArrayToMesh=m.prototype.DecodeArrayToMesh=function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(jc(g,b,c,d),C)};m.prototype.GetAttributeId=m.prototype.GetAttributeId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
|
||||
(c=c.ptr);return kc(d,b,c)};m.prototype.GetAttributeIdByName=m.prototype.GetAttributeIdByName=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return lc(d,b,c)};m.prototype.GetAttributeIdByMetadataEntry=m.prototype.GetAttributeIdByMetadataEntry=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d=d&&"object"===typeof d?d.ptr:R(d);return mc(g,b,c,d)};m.prototype.GetAttribute=
|
||||
m.prototype.GetAttribute=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(nc(d,b,c),x)};m.prototype.GetAttributeByUniqueId=m.prototype.GetAttributeByUniqueId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(oc(d,b,c),x)};m.prototype.GetMetadata=m.prototype.GetMetadata=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return B(pc(c,b),T)};m.prototype.GetAttributeMetadata=m.prototype.GetAttributeMetadata=
|
||||
function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(qc(d,b,c),T)};m.prototype.GetFaceFromMesh=m.prototype.GetFaceFromMesh=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!rc(g,b,c,d)};m.prototype.GetTriangleStripsFromMesh=m.prototype.GetTriangleStripsFromMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);
|
||||
return sc(d,b,c)};m.prototype.GetTrianglesUInt16Array=m.prototype.GetTrianglesUInt16Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!tc(g,b,c,d)};m.prototype.GetTrianglesUInt32Array=m.prototype.GetTrianglesUInt32Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!uc(g,b,c,d)};m.prototype.GetAttributeFloat=m.prototype.GetAttributeFloat=
|
||||
function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!vc(g,b,c,d)};m.prototype.GetAttributeFloatForAllPoints=m.prototype.GetAttributeFloatForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!wc(g,b,c,d)};m.prototype.GetAttributeIntForAllPoints=m.prototype.GetAttributeIntForAllPoints=function(b,c,d){var g=this.ptr;
|
||||
b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!xc(g,b,c,d)};m.prototype.GetAttributeInt8ForAllPoints=m.prototype.GetAttributeInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!yc(g,b,c,d)};m.prototype.GetAttributeUInt8ForAllPoints=m.prototype.GetAttributeUInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=
|
||||
b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(g,b,c,d)};m.prototype.GetAttributeInt16ForAllPoints=m.prototype.GetAttributeInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(g,b,c,d)};m.prototype.GetAttributeUInt16ForAllPoints=m.prototype.GetAttributeUInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
|
||||
(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(g,b,c,d)};m.prototype.GetAttributeInt32ForAllPoints=m.prototype.GetAttributeInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(g,b,c,d)};m.prototype.GetAttributeUInt32ForAllPoints=m.prototype.GetAttributeUInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===
|
||||
typeof d&&(d=d.ptr);return!!Dc(g,b,c,d)};m.prototype.GetAttributeDataArrayForAllPoints=m.prototype.GetAttributeDataArrayForAllPoints=function(b,c,d,g,t){var aa=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);g&&"object"===typeof g&&(g=g.ptr);t&&"object"===typeof t&&(t=t.ptr);return!!Ec(aa,b,c,d,g,t)};m.prototype.SkipAttributeTransform=m.prototype.SkipAttributeTransform=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);Fc(c,
|
||||
b)};m.prototype.GetEncodedGeometryType_Deprecated=m.prototype.GetEncodedGeometryType_Deprecated=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gc(c,b)};m.prototype.DecodeBufferToPointCloud=m.prototype.DecodeBufferToPointCloud=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(Hc(d,b,c),C)};m.prototype.DecodeBufferToMesh=m.prototype.DecodeBufferToMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===
|
||||
typeof c&&(c=c.ptr);return B(Ic(d,b,c),C)};m.prototype.__destroy__=m.prototype.__destroy__=function(){Jc(this.ptr)};(function(){function b(){a.ATTRIBUTE_INVALID_TRANSFORM=Kc();a.ATTRIBUTE_NO_TRANSFORM=Lc();a.ATTRIBUTE_QUANTIZATION_TRANSFORM=Mc();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=Nc();a.INVALID=Oc();a.POSITION=Pc();a.NORMAL=Qc();a.COLOR=Rc();a.TEX_COORD=Sc();a.GENERIC=Tc();a.INVALID_GEOMETRY_TYPE=Uc();a.POINT_CLOUD=Vc();a.TRIANGULAR_MESH=Wc();a.DT_INVALID=Xc();a.DT_INT8=Yc();a.DT_UINT8=Zc();a.DT_INT16=
|
||||
$c();a.DT_UINT16=ad();a.DT_INT32=bd();a.DT_UINT32=cd();a.DT_INT64=dd();a.DT_UINT64=ed();a.DT_FLOAT32=fd();a.DT_FLOAT64=gd();a.DT_BOOL=hd();a.DT_TYPES_COUNT=id();a.OK=jd();a.DRACO_ERROR=kd();a.IO_ERROR=ld();a.INVALID_PARAMETER=md();a.UNSUPPORTED_VERSION=nd();a.UNKNOWN_VERSION=od()}va?b():oa.unshift(b)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();a.Decoder.prototype.GetEncodedGeometryType=function(b){if(b.__class__&&b.__class__===a.DecoderBuffer)return a.Decoder.prototype.GetEncodedGeometryType_Deprecated(b);
|
||||
if(8>b.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
||||
|
||||
const sha256File = (path) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
|
||||
if (manifest.version !== "0.34.1") {
|
||||
throw new Error(
|
||||
`Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const vendorRuntime = [
|
||||
{
|
||||
label: "wasm runtime",
|
||||
packagePath: resolve(packageRoot, "re_viewer_bg.wasm"),
|
||||
vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"),
|
||||
publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35",
|
||||
previousNodedcSha256: "6aca9da47ad561d2046ad0c7c0b8a7f653f023a77132c5c6304789320db512c2",
|
||||
nodedcSha256: "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469",
|
||||
},
|
||||
{
|
||||
label: "wasm JavaScript glue",
|
||||
packagePath: resolve(packageRoot, "re_viewer.js"),
|
||||
vendorPath: resolve(vendorRoot, "re_viewer.nodedc.js"),
|
||||
publishedSha256: "7c4ba900820137a6ba23e0f7f57d56e3b007db0de3825d5c8bf0d7684b3cc6a6",
|
||||
nodedcSha256: "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339",
|
||||
},
|
||||
];
|
||||
|
||||
for (const runtime of vendorRuntime) {
|
||||
const vendorSha256 = sha256File(runtime.vendorPath);
|
||||
if (vendorSha256 !== runtime.nodedcSha256) {
|
||||
throw new Error(
|
||||
`Refusing corrupt NODE.DC Rerun ${runtime.label}: ${vendorSha256} != ${runtime.nodedcSha256}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installedSha256 = sha256File(runtime.packagePath);
|
||||
if (
|
||||
![
|
||||
runtime.publishedSha256,
|
||||
runtime.previousNodedcSha256,
|
||||
runtime.nodedcSha256,
|
||||
].includes(installedSha256)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to replace unexpected Rerun ${runtime.label}: ${installedSha256}`,
|
||||
);
|
||||
}
|
||||
|
||||
copyFileSync(runtime.vendorPath, runtime.packagePath);
|
||||
if (sha256File(runtime.packagePath) !== runtime.nodedcSha256) {
|
||||
throw new Error(`NODE.DC Rerun ${runtime.label} verification failed after copy`);
|
||||
}
|
||||
}
|
||||
|
||||
const patches = [
|
||||
{
|
||||
label: "compiled watchdog",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "source watchdog",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
|
||||
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
|
||||
},
|
||||
{
|
||||
label: "compiled singleton global listener",
|
||||
path: resolve(packageRoot, "index.js"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
{
|
||||
label: "source singleton global listener",
|
||||
path: resolve(packageRoot, "index.ts"),
|
||||
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
|
||||
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const patch of patches) {
|
||||
const source = readFileSync(patch.path, "utf8");
|
||||
if (source.includes(patch.after)) continue;
|
||||
if (!source.includes(patch.before)) {
|
||||
throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`);
|
||||
}
|
||||
writeFileSync(patch.path, source.replace(patch.before, patch.after));
|
||||
}
|
||||
|
||||
for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1;
|
||||
if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) {
|
||||
throw new Error(`Vendor singleton global listener verification failed for ${path}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Patched @rerun-io/web-viewer 0.34.1 pointer navigation, camera continuity, watchdog teardown, and singleton global keyup listener.",
|
||||
);
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const [inputArgument, outputArgument] = process.argv.slice(2);
|
||||
if (!inputArgument || !outputArgument) {
|
||||
throw new Error("Usage: node transform-rerun-web-viewer-glue.mjs INPUT OUTPUT");
|
||||
}
|
||||
|
||||
const inputPath = resolve(inputArgument);
|
||||
const outputPath = resolve(outputArgument);
|
||||
const expectedGeneratedSha256 =
|
||||
"cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874";
|
||||
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
let code = readFileSync(inputPath, "utf8");
|
||||
|
||||
if (sha256(code) !== expectedGeneratedSha256) {
|
||||
throw new Error(`Refusing to transform unexpected wasm-bindgen output: ${inputPath}`);
|
||||
}
|
||||
|
||||
// This is the same no-modules-base transformation used by Rerun 0.34.1's
|
||||
// rerun_js/web-viewer/build-wasm.mjs. Each factory call gets isolated closure state.
|
||||
const wrapperStart = "let wasm_bindgen = (function(exports) {";
|
||||
const wrapperEnd = `return Object.assign(__wbg_init, { initSync }, exports);
|
||||
})({ __proto__: null });`;
|
||||
|
||||
if (!code.includes(wrapperStart) || !code.includes(wrapperEnd)) {
|
||||
throw new Error("Rerun wasm-bindgen wrapper markers no longer match");
|
||||
}
|
||||
code = code.replace(wrapperStart, "").replace(wrapperEnd, "");
|
||||
|
||||
code = `
|
||||
export default function() {
|
||||
const exports = { __proto__: null };
|
||||
${code}
|
||||
|
||||
function deinit() {
|
||||
__wbg_init.__wbindgen_wasm_module = null;
|
||||
wasmModule = null;
|
||||
wasm = null;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedFloat32ArrayMemory0 = null;
|
||||
cachedInt16ArrayMemory0 = null;
|
||||
cachedInt32ArrayMemory0 = null;
|
||||
cachedInt8ArrayMemory0 = null;
|
||||
cachedUint16ArrayMemory0 = null;
|
||||
cachedUint32ArrayMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
}
|
||||
|
||||
return Object.assign(__wbg_init, { initSync, deinit }, exports);
|
||||
}
|
||||
`;
|
||||
|
||||
const closureDtorsOriginal = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));`;
|
||||
const closureDtorsPatch = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => {
|
||||
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
});`;
|
||||
|
||||
if (!code.includes(closureDtorsOriginal)) {
|
||||
throw new Error("Rerun CLOSURE_DTORS block no longer matches");
|
||||
}
|
||||
code = code.replace(closureDtorsOriginal, closureDtorsPatch);
|
||||
|
||||
const makeMutClosureOriginal = `function makeMutClosure(arg0, arg1, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1 };
|
||||
const real = (...args) => {
|
||||
|
||||
// First up with a closure we increment the internal reference
|
||||
// count. This ensures that the Rust closure environment won't
|
||||
// be deallocated while we're invoking it.
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}`;
|
||||
const makeMutClosurePatch = `function makeMutClosure(arg0, arg1, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1 };
|
||||
const real = (...args) => {
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
if (!wasm) return;
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}`;
|
||||
|
||||
if (!code.includes(makeMutClosureOriginal)) {
|
||||
throw new Error("Rerun makeMutClosure block no longer matches");
|
||||
}
|
||||
code = code.replace(makeMutClosureOriginal, makeMutClosurePatch);
|
||||
|
||||
writeFileSync(outputPath, code);
|
||||
console.log(`Transformed Rerun wasm glue: ${sha256(code)} ${outputPath}`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EnvironmentBackground,
|
||||
EnvironmentMediaItem,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
||||
url: string;
|
||||
mediaKind: "image" | "video";
|
||||
};
|
||||
|
||||
function isReadyMediaItem(
|
||||
item: EnvironmentMediaItem,
|
||||
): item is ReadyEnvironmentMediaItem {
|
||||
return Boolean(item.url && item.mediaKind);
|
||||
}
|
||||
|
||||
export function EnvironmentBackgroundMedia({
|
||||
background,
|
||||
}: {
|
||||
background: EnvironmentBackground;
|
||||
}) {
|
||||
const items = useMemo(
|
||||
() => background.items.filter(isReadyMediaItem),
|
||||
[background.items],
|
||||
);
|
||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
setFailedIds(new Set());
|
||||
}, [playlistIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!background.enabled
|
||||
|| !activeItem
|
||||
|| activeItem.mediaKind !== "image"
|
||||
|| playableItems.length < 2
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
||||
}, background.imageDurationSeconds * 1_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
activeItem,
|
||||
background.enabled,
|
||||
background.imageDurationSeconds,
|
||||
playableItems.length,
|
||||
]);
|
||||
|
||||
if (!background.enabled || !activeItem) return null;
|
||||
|
||||
return (
|
||||
<div className="landing-stage__media" aria-hidden="true">
|
||||
{activeItem.mediaKind === "video" ? (
|
||||
<video
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop={playableItems.length === 1}
|
||||
playsInline
|
||||
onEnded={() => setActiveIndex((current) => (
|
||||
(current + 1) % playableItems.length
|
||||
))}
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={activeItem.url}
|
||||
src={activeItem.url}
|
||||
alt=""
|
||||
onError={() => {
|
||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
MediaSourceField,
|
||||
RangeControl,
|
||||
SortableList,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
appendEnvironmentMediaItem,
|
||||
inferEnvironmentMediaKind,
|
||||
maxEnvironmentMediaItems,
|
||||
removeEnvironmentMediaItem,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentMediaItem,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
|
||||
interface EnvironmentMediaPlaylistEditorProps {
|
||||
surfaceId: EnvironmentSurfaceId;
|
||||
background: EnvironmentBackground;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
onChange: (background: EnvironmentBackground) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
const acceptedEnvironmentMedia = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
].join(",");
|
||||
|
||||
function patchItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
patch: Partial<EnvironmentMediaItem>,
|
||||
): EnvironmentBackground {
|
||||
return {
|
||||
...background,
|
||||
items: background.items.map((item) => (
|
||||
item.id === itemId ? { ...item, ...patch } : item
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
export function EnvironmentMediaPlaylistEditor({
|
||||
surfaceId,
|
||||
background,
|
||||
disabled,
|
||||
error,
|
||||
onChange,
|
||||
onBusyChange,
|
||||
onUpload,
|
||||
}: EnvironmentMediaPlaylistEditorProps) {
|
||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
||||
const backgroundRef = useRef(background);
|
||||
backgroundRef.current = background;
|
||||
const displayedItems = useMemo(
|
||||
() => [...background.items].reverse(),
|
||||
[background.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange(uploadingIds.size > 0);
|
||||
}, [onBusyChange, uploadingIds.size]);
|
||||
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
|
||||
const setItemError = (itemId: string, message?: string) => {
|
||||
setItemErrors((current) => {
|
||||
const next = { ...current };
|
||||
if (message) next[itemId] = message;
|
||||
else delete next[itemId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadFile = async (itemId: string, file?: File) => {
|
||||
if (!file) return;
|
||||
setUploadingIds((current) => new Set(current).add(itemId));
|
||||
setItemError(itemId);
|
||||
try {
|
||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
||||
onChange(patchItem(backgroundRef.current, itemId, {
|
||||
source: "file",
|
||||
url: uploaded.url,
|
||||
mediaKind: uploaded.mediaKind,
|
||||
fileName: uploaded.fileName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
setItemError(
|
||||
itemId,
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить медиаконтент.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="environment-media-playlist">
|
||||
<div className="environment-media-playlist__head">
|
||||
<div>
|
||||
<span>Видео / картинка</span>
|
||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
||||
</div>
|
||||
<IconButton
|
||||
label="Добавить медиаконтент"
|
||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
||||
>
|
||||
<Icon name="plus" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{displayedItems.length ? (
|
||||
<SortableList
|
||||
items={displayedItems}
|
||||
getId={(item) => item.id}
|
||||
className="environment-media-playlist__items"
|
||||
onReorder={(items) => onChange({
|
||||
...background,
|
||||
items: [...items].reverse(),
|
||||
})}
|
||||
>
|
||||
{(item, { handle }) => {
|
||||
const playbackIndex = background.items.findIndex(
|
||||
(candidate) => candidate.id === item.id,
|
||||
);
|
||||
return (
|
||||
<div className="environment-media-playlist__item">
|
||||
<MediaSourceField
|
||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
||||
kindLabel={item.mediaKind ?? "media"}
|
||||
source={item.source}
|
||||
url={item.url ?? ""}
|
||||
fileName={item.fileName}
|
||||
uploading={uploadingIds.has(item.id)}
|
||||
previewSrc={item.url}
|
||||
previewKind={item.mediaKind}
|
||||
accept={acceptedEnvironmentMedia}
|
||||
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
|
||||
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
|
||||
error={itemErrors[item.id] ?? (
|
||||
playbackIndex === background.items.length - 1 ? error : null
|
||||
)}
|
||||
onSourceChange={(source) => {
|
||||
if (source === item.source) return;
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source,
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onUrlChange={(url) => {
|
||||
setItemError(item.id);
|
||||
onChange(patchItem(background, item.id, {
|
||||
source: "url",
|
||||
url: url || null,
|
||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
||||
fileName: null,
|
||||
}));
|
||||
}}
|
||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
||||
/>
|
||||
<div className="environment-media-playlist__item-actions">
|
||||
<IconButton
|
||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
||||
disabled={disabled || uploadingIds.has(item.id)}
|
||||
onClick={() => {
|
||||
setItemError(item.id);
|
||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</IconButton>
|
||||
{handle}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SortableList>
|
||||
) : (
|
||||
<>
|
||||
<p className="environment-media-playlist__empty">
|
||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="environment-media-playlist__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="environment-media-playlist__timing">
|
||||
<RangeControl
|
||||
label="Показывать изображение"
|
||||
value={background.imageDurationSeconds}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
formatValue={(value) => `${value} с`}
|
||||
onChange={(imageDurationSeconds) => onChange({
|
||||
...background,
|
||||
imageDurationSeconds,
|
||||
})}
|
||||
/>
|
||||
<span>
|
||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
||||
перетаскивание меняет порядок.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
Select,
|
||||
SettingsCard,
|
||||
Switch,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
cloneEnvironmentSettings,
|
||||
type EnvironmentBackground,
|
||||
type EnvironmentPage,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "../core/environment/environmentSettings";
|
||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
|
||||
import {
|
||||
roots,
|
||||
workspaces,
|
||||
workspacesForRoot,
|
||||
} from "../productModel";
|
||||
|
||||
interface EnvironmentSettingsWindowProps {
|
||||
open: boolean;
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
onUpload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
function patchPage(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentPage>,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
...draft,
|
||||
pages: {
|
||||
...draft.pages,
|
||||
[surfaceId]: {
|
||||
...draft.pages[surfaceId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function patchBackground(
|
||||
draft: EnvironmentSettings,
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
patch: Partial<EnvironmentBackground>,
|
||||
): EnvironmentSettings {
|
||||
const page = draft.pages[surfaceId];
|
||||
return patchPage(draft, surfaceId, {
|
||||
background: {
|
||||
...page.background,
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function EnvironmentSettingsWindow({
|
||||
open,
|
||||
settings,
|
||||
state,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
onUpload,
|
||||
}: EnvironmentSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}, [open, settings]);
|
||||
|
||||
const selectedPage = draft.pages[surfaceId];
|
||||
const selectedBackground = selectedPage.background;
|
||||
const pageOptions = useMemo(() => [
|
||||
{
|
||||
value: "home" as const,
|
||||
label: draft.pages.home.headerLabel,
|
||||
description: "Главная страница продукта",
|
||||
},
|
||||
...roots.map((root) => ({
|
||||
value: root.id,
|
||||
label: draft.pages[root.id].headerLabel,
|
||||
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
|
||||
})),
|
||||
], [draft.pages]);
|
||||
const quickActionWorkspaces = useMemo(
|
||||
() => surfaceId === "home"
|
||||
? workspaces.filter((workspace) => !workspace.internalOnly)
|
||||
: workspacesForRoot(surfaceId),
|
||||
[surfaceId],
|
||||
);
|
||||
const quickActionOptions = useMemo(() => [
|
||||
{
|
||||
value: "none",
|
||||
label: "Не показывать",
|
||||
description: "Кнопка скрыта на стартовом экране",
|
||||
},
|
||||
...quickActionWorkspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.label,
|
||||
description: workspace.description,
|
||||
})),
|
||||
], [quickActionWorkspaces]);
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
||||
[draft, settings],
|
||||
);
|
||||
const busy = state === "saving" || uploading;
|
||||
|
||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
||||
!page.headerLabel.trim()
|
||||
|| !page.eyebrow.trim()
|
||||
|| !page.title.trim()
|
||||
|| !page.description.trim()
|
||||
));
|
||||
if (invalid) {
|
||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedPage.primaryWorkspaceId
|
||||
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
|
||||
) {
|
||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
||||
return;
|
||||
}
|
||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
||||
(page.background.enabled && !page.background.items.length)
|
||||
|| page.background.items.some((item) => {
|
||||
if (!item.url || !item.mediaKind) return true;
|
||||
if (item.source !== "url") return false;
|
||||
try {
|
||||
const parsed = new URL(item.url);
|
||||
return !["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
));
|
||||
if (invalidMedia) {
|
||||
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
|
||||
setLocalError(
|
||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onSave({
|
||||
...draft,
|
||||
pages: Object.fromEntries(
|
||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
||||
...page,
|
||||
headerLabel: page.headerLabel.trim(),
|
||||
eyebrow: page.eyebrow.trim(),
|
||||
title: page.title.trim(),
|
||||
description: page.description.trim(),
|
||||
}]),
|
||||
) as EnvironmentSettings["pages"],
|
||||
});
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setLocalError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title="Настройки Mission Core"
|
||||
subtitle="Локальное операторское окружение"
|
||||
identity={{
|
||||
title: "DC",
|
||||
subtitle: "Mission Core",
|
||||
avatarLabel: "DC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение",
|
||||
group: "MISSION CORE",
|
||||
icon: "settings",
|
||||
},
|
||||
]}
|
||||
activeSection="environment"
|
||||
onSectionChange={() => undefined}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => {
|
||||
setDraft(cloneEnvironmentSettings(settings));
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
Сбросить изменения
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!dirty || busy}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings">
|
||||
<SettingsCard
|
||||
eyebrow="ОКРУЖЕНИЕ"
|
||||
title="Основные элементы управления"
|
||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
||||
actions={(
|
||||
<Switch
|
||||
checked={selectedBackground.enabled}
|
||||
label="Показывать фон"
|
||||
onChange={(enabled) => {
|
||||
if (enabled && !selectedBackground.items.length) {
|
||||
setLocalError("Сначала добавьте медиаконтент.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, { enabled }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="environment-settings__editor">
|
||||
<div className="environment-settings__surface">
|
||||
<span>Страница</span>
|
||||
<Select
|
||||
label="Выбрать страницу окружения"
|
||||
value={surfaceId}
|
||||
options={pageOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => {
|
||||
setSurfaceId(value);
|
||||
setLocalError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__copy">
|
||||
<TextField
|
||||
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
|
||||
value={selectedPage.headerLabel}
|
||||
maxLength={40}
|
||||
onChange={(event) => updatePage({
|
||||
headerLabel: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Надзаголовок"
|
||||
value={selectedPage.eyebrow}
|
||||
maxLength={80}
|
||||
onChange={(event) => updatePage({
|
||||
eyebrow: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextField
|
||||
label="Основной заголовок"
|
||||
value={selectedPage.title}
|
||||
maxLength={120}
|
||||
onChange={(event) => updatePage({
|
||||
title: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Описание"
|
||||
value={selectedPage.description}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
onChange={(event) => updatePage({
|
||||
description: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environment-settings__quick-actions">
|
||||
<div>
|
||||
<span>Кнопка 1</span>
|
||||
<Select
|
||||
label="Выбрать первую быструю кнопку"
|
||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
primaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>Кнопка 2</span>
|
||||
<Select
|
||||
label="Выбрать вторую быструю кнопку"
|
||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
||||
options={quickActionOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => updatePage({
|
||||
secondaryWorkspaceId: value === "none" ? null : value,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnvironmentMediaPlaylistEditor
|
||||
surfaceId={surfaceId}
|
||||
background={selectedBackground}
|
||||
disabled={busy}
|
||||
error={localError ?? error}
|
||||
onBusyChange={setUploading}
|
||||
onChange={(background) => {
|
||||
setLocalError(null);
|
||||
setDraft((current) =>
|
||||
patchBackground(current, surfaceId, background));
|
||||
}}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
|
||||
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
const WINDOW_WIDTH = 336;
|
||||
const WINDOW_HEIGHT = 210;
|
||||
const WINDOW_GAP = 16;
|
||||
const WINDOW_INSET = 18;
|
||||
const TIMELINE_CLEARANCE = 64;
|
||||
|
||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
||||
|
||||
export function shouldCaptureWorkspacePointer(
|
||||
button: number,
|
||||
target: ClosestTarget | null,
|
||||
): boolean {
|
||||
if (button !== 0 || !target) return false;
|
||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
||||
return !target.closest("button, input, select, textarea, a");
|
||||
}
|
||||
|
||||
export function initialObservationWindowRect(
|
||||
index: number,
|
||||
count = 1,
|
||||
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
||||
): ObservationWindowRect {
|
||||
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
||||
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
||||
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
||||
const maxColumns = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
||||
);
|
||||
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
||||
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
||||
const rowHeight = Math.max(
|
||||
1,
|
||||
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
||||
);
|
||||
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
||||
const row = Math.floor(index / columns);
|
||||
const column = index % columns;
|
||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||
|
||||
return {
|
||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||
y: Math.max(
|
||||
0,
|
||||
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function FloatingObservationWindow({
|
||||
source,
|
||||
index,
|
||||
count,
|
||||
boundsRef,
|
||||
rect,
|
||||
maximized,
|
||||
active,
|
||||
hidden = false,
|
||||
onRectChange,
|
||||
onMaximizedChange,
|
||||
onActivate,
|
||||
onClose,
|
||||
playback,
|
||||
prepareRecorded,
|
||||
recordedSessionGate,
|
||||
recordedAdmissionKey,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
index: number;
|
||||
count: number;
|
||||
boundsRef: RefObject<HTMLElement | null>;
|
||||
rect?: ObservationWindowRect;
|
||||
maximized: boolean;
|
||||
active: boolean;
|
||||
hidden?: boolean;
|
||||
onRectChange: (rect: ObservationWindowRect) => void;
|
||||
onMaximizedChange: (maximized: boolean) => void;
|
||||
onActivate: () => void;
|
||||
onClose: () => void;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = boundsRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const measure = () => {
|
||||
const next = { width: element.clientWidth, height: element.clientHeight };
|
||||
setBounds((current) => (
|
||||
current?.width === next.width && current.height === next.height ? current : next
|
||||
));
|
||||
};
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [boundsRef]);
|
||||
|
||||
// Camera media admission must not wait for the viewport observer. A source
|
||||
// can arrive in the same commit as a workspace transition; mounting it with
|
||||
// the canonical fallback geometry keeps the browser transport alive until
|
||||
// the real bounds are measured immediately afterward.
|
||||
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
||||
const windowRect = rect ?? initialRect;
|
||||
|
||||
return (
|
||||
<WorkspaceWindow
|
||||
boundsRef={boundsRef}
|
||||
rect={windowRect}
|
||||
onRectChange={onRectChange}
|
||||
maximized={maximized}
|
||||
onMaximizedChange={onMaximizedChange}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
title={source.label}
|
||||
subtitle={source.description}
|
||||
status={(
|
||||
<span className="floating-observation-window__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
)}
|
||||
footer={(
|
||||
<span className="floating-observation-window__footer">
|
||||
<span>{source.endpointLabel || source.transport}</span>
|
||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
||||
</span>
|
||||
)}
|
||||
minWidth={Math.min(280, windowRect.width)}
|
||||
minHeight={Math.min(190, windowRect.height)}
|
||||
resizable={source.capabilities.resizable}
|
||||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Pointer capture can fail if the browser ended the pointer between
|
||||
// dispatch and capture. The donor's window listeners remain fallback.
|
||||
}
|
||||
}}
|
||||
closeLabel={`Закрыть ${source.label}`}
|
||||
maximizeLabel={`Развернуть ${source.label}`}
|
||||
restoreLabel={`Восстановить ${source.label}`}
|
||||
moveLabel={`Переместить ${source.label}`}
|
||||
resizeLabel={`Изменить размер ${source.label}`}
|
||||
>
|
||||
<ObservationMedia
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepareRecorded={prepareRecorded}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedAdmissionKey}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
</WorkspaceWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||
import type { EnvironmentPage } from "../core/environment/environmentSettings";
|
||||
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
|
||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
|
||||
|
||||
export interface LandingStageProps {
|
||||
root: RootDefinition | null;
|
||||
backendStatus: BackendStatus;
|
||||
phase?: RuntimePhase | null;
|
||||
message?: string | null;
|
||||
page: EnvironmentPage;
|
||||
quickActions: readonly WorkspaceDefinition[];
|
||||
onOpenWorkspace: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export function LandingStage({
|
||||
root,
|
||||
backendStatus,
|
||||
phase,
|
||||
message,
|
||||
page,
|
||||
quickActions,
|
||||
onOpenWorkspace,
|
||||
}: LandingStageProps) {
|
||||
const { background } = page;
|
||||
const hasMedia = background.enabled && background.items.some(
|
||||
(item) => item.url && item.mediaKind,
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="landing-stage"
|
||||
data-root={root?.id ?? "home"}
|
||||
data-has-media={hasMedia ? "true" : undefined}
|
||||
>
|
||||
<EnvironmentBackgroundMedia background={background} />
|
||||
<div className="landing-stage__shade" aria-hidden="true" />
|
||||
<div className="landing-stage__copy">
|
||||
<span className="section-eyebrow">{page.eyebrow}</span>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.description}</p>
|
||||
{quickActions.length ? (
|
||||
<div className="landing-stage__actions">
|
||||
{quickActions.map((workspace, index) => (
|
||||
<Button
|
||||
key={workspace.id}
|
||||
variant={index === 0 ? "primary" : "secondary"}
|
||||
icon={<Icon name={workspace.icon} />}
|
||||
onClick={() => onOpenWorkspace(workspace.id)}
|
||||
>
|
||||
{workspace.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="landing-stage__status">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСТОЧНИК</span>
|
||||
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
|
||||
</div>
|
||||
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
|
||||
</div>
|
||||
|
||||
<footer className="landing-stage__footer">
|
||||
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationSourceDelivery } from "../core/runtime/contracts";
|
||||
import {
|
||||
cameraBrowserTransportIdentity,
|
||||
cameraTransportCallbackIsCurrent,
|
||||
initialCameraPlaybackRecoveryState,
|
||||
reduceCameraPlaybackRecovery,
|
||||
type CameraPlaybackRecoveryEvent,
|
||||
} from "../core/observation/liveCameraRecovery";
|
||||
import {
|
||||
createLiveViewerDiagnosticLifecycle,
|
||||
createLiveViewerInstanceId,
|
||||
createLiveViewerLineage,
|
||||
subscribeToLiveViewerBuildFence,
|
||||
type LiveViewerDiagnosticLifecycle,
|
||||
type LiveViewerFailureStage,
|
||||
} from "../core/observation/liveViewerDiagnostics";
|
||||
|
||||
type PlayerStatus = "connecting" | "buffering" | "playing" | "error";
|
||||
|
||||
export interface CameraLeaseRetryBudget {
|
||||
deliveryId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
||||
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
||||
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
||||
// main-thread stall. Keep one bounded append margin above that complete batch;
|
||||
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
||||
export const CAMERA_PENDING_QUEUE_MAX_BYTES = 12 * 1024 * 1024;
|
||||
export const CAMERA_PENDING_QUEUE_MAX_SEGMENTS = 96;
|
||||
|
||||
export type CameraStartupWatchdogStage = "first-media" | "first-playable-frame";
|
||||
|
||||
export interface CameraStartupWatchdog {
|
||||
armFirstMedia: () => void;
|
||||
markMediaReceived: () => void;
|
||||
markPlaying: () => void;
|
||||
clear: () => void;
|
||||
pendingStage: () => CameraStartupWatchdogStage | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervise only the disposable browser transport. One fixed first-media
|
||||
* deadline covers both an MSE that never opens and an open-but-silent socket.
|
||||
* The first non-empty fragment then starts a separate first-playable-frame
|
||||
* deadline. Every subsequent fragment extends that deadline: a browser that is
|
||||
* still receiving the authoritative stream must not destroy its partial MSE
|
||||
* decode solely because the main thread or decoder needed longer to start.
|
||||
*/
|
||||
export function createCameraStartupWatchdog({
|
||||
schedule,
|
||||
cancel,
|
||||
onTimeout,
|
||||
firstMediaTimeoutMs = CAMERA_FIRST_MEDIA_TIMEOUT_MS,
|
||||
firstPlayableFrameTimeoutMs = CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS,
|
||||
}: {
|
||||
schedule: (callback: () => void, timeoutMs: number) => number;
|
||||
cancel: (handle: number) => void;
|
||||
onTimeout: (stage: CameraStartupWatchdogStage) => void;
|
||||
firstMediaTimeoutMs?: number;
|
||||
firstPlayableFrameTimeoutMs?: number;
|
||||
}): CameraStartupWatchdog {
|
||||
let timer: number | undefined;
|
||||
let stage: CameraStartupWatchdogStage | null = null;
|
||||
let mediaReceived = false;
|
||||
let playing = false;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer !== undefined) cancel(timer);
|
||||
timer = undefined;
|
||||
stage = null;
|
||||
};
|
||||
const arm = (nextStage: CameraStartupWatchdogStage, timeoutMs: number) => {
|
||||
clearTimer();
|
||||
stage = nextStage;
|
||||
timer = schedule(() => {
|
||||
if (stage !== nextStage || playing) return;
|
||||
timer = undefined;
|
||||
stage = null;
|
||||
onTimeout(nextStage);
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
return {
|
||||
armFirstMedia() {
|
||||
if (mediaReceived || playing || stage !== null) return;
|
||||
arm("first-media", firstMediaTimeoutMs);
|
||||
},
|
||||
markMediaReceived() {
|
||||
if (playing) return;
|
||||
mediaReceived = true;
|
||||
arm("first-playable-frame", firstPlayableFrameTimeoutMs);
|
||||
},
|
||||
markPlaying() {
|
||||
if (playing) return;
|
||||
playing = true;
|
||||
clearTimer();
|
||||
},
|
||||
clear: clearTimer,
|
||||
pendingStage: () => stage,
|
||||
};
|
||||
}
|
||||
|
||||
export function cameraStartupWatchdogRecoveryMessage(
|
||||
stage: CameraStartupWatchdogStage,
|
||||
): string {
|
||||
return stage === "first-media"
|
||||
? "Камера подключена, но не передаёт медиаданные; восстанавливаем browser-preview."
|
||||
: "Медиаданные поступают, но первый кадр не воспроизводится; пересоздаём decoder.";
|
||||
}
|
||||
|
||||
export function resetCameraLeaseRetryBudget(deliveryId: string): CameraLeaseRetryBudget {
|
||||
return { deliveryId, count: 0 };
|
||||
}
|
||||
|
||||
export function consumeCameraLeaseRetry(
|
||||
current: CameraLeaseRetryBudget,
|
||||
deliveryId: string,
|
||||
): { budget: CameraLeaseRetryBudget; delay: number } {
|
||||
const count = current.deliveryId === deliveryId ? current.count : 0;
|
||||
const delayIndex = Math.min(count, CAMERA_LEASE_RETRY_DELAYS.length - 1);
|
||||
const delay = CAMERA_LEASE_RETRY_DELAYS[delayIndex];
|
||||
return {
|
||||
budget: {
|
||||
deliveryId,
|
||||
count: Math.min(count + 1, CAMERA_LEASE_RETRY_DELAYS.length),
|
||||
},
|
||||
delay,
|
||||
};
|
||||
}
|
||||
|
||||
export function cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityIdentity: string | null,
|
||||
expectedAuthorityIdentity: string | null,
|
||||
activeEpoch: number,
|
||||
expectedEpoch: number,
|
||||
disposed: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
expectedAuthorityIdentity
|
||||
&& activeAuthorityIdentity === expectedAuthorityIdentity
|
||||
&& cameraTransportCallbackIsCurrent(activeEpoch, expectedEpoch, disposed),
|
||||
);
|
||||
}
|
||||
|
||||
export function cameraTransportCanOpen(uiBuildStale: boolean): boolean {
|
||||
return !uiBuildStale;
|
||||
}
|
||||
|
||||
export function cameraPendingQueueCanAccept(
|
||||
queuedBytes: number,
|
||||
queuedSegments: number,
|
||||
incomingBytes: number,
|
||||
): boolean {
|
||||
return incomingBytes > 0
|
||||
&& queuedBytes + incomingBytes <= CAMERA_PENDING_QUEUE_MAX_BYTES
|
||||
&& queuedSegments + 1 <= CAMERA_PENDING_QUEUE_MAX_SEGMENTS;
|
||||
}
|
||||
|
||||
export function cameraTransportCloseRecoveryMessage(code: number): string {
|
||||
if (code === 4_008) {
|
||||
return "Browser-reader отстал от эфира; восстанавливаем текущую камеру.";
|
||||
}
|
||||
if (code === 1_008) {
|
||||
return "Camera adapter освобождает прежний browser-reader; переподключаемся.";
|
||||
}
|
||||
if (code === 1_000) {
|
||||
return "Browser-preview завершился; восстанавливаем текущую камеру.";
|
||||
}
|
||||
return "Browser-preview прерван; восстанавливаем текущую камеру.";
|
||||
}
|
||||
|
||||
export function cameraPlaybackRecoveryFailureStage(
|
||||
event: CameraPlaybackRecoveryEvent,
|
||||
): LiveViewerFailureStage {
|
||||
if (event.type === "heartbeat") return "camera-heartbeat-reopen";
|
||||
if (event.type === "network-online") return "camera-network-online-reopen";
|
||||
if (event.type === "page-restore") return "camera-page-restore-reopen";
|
||||
return "camera-visibility-reopen";
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const url = new URL(path, window.location.href);
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error("Адаптер вернул неподдерживаемый адрес видеопотока.");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function MseFmp4WebSocketPlayer({
|
||||
delivery,
|
||||
label,
|
||||
recoveryAuthorityIdentity,
|
||||
}: {
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" };
|
||||
label: string;
|
||||
recoveryAuthorityIdentity: string | null;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const leaseRetryRef = useRef(resetCameraLeaseRetryBudget(delivery.id));
|
||||
const activeAuthorityRef = useRef(recoveryAuthorityIdentity);
|
||||
const recoveryPendingAuthorityRef = useRef<string | null>(null);
|
||||
const transportHealthyRef = useRef(false);
|
||||
const transportEpochRef = useRef(0);
|
||||
const transportAuthorityRef = useRef(recoveryAuthorityIdentity);
|
||||
const activeTransportDisposeRef = useRef<(() => void) | null>(null);
|
||||
const activeDiagnosticRef = useRef<LiveViewerDiagnosticLifecycle | null>(null);
|
||||
const diagnosticInstanceIdRef = useRef<string | null>(null);
|
||||
const diagnosticGenerationRef = useRef(0);
|
||||
const uiBuildStaleRef = useRef(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [uiBuildStale, setUiBuildStale] = useState(false);
|
||||
const [status, setStatus] = useState<PlayerStatus>("connecting");
|
||||
const [message, setMessage] = useState("Подключение к локальному видеопотоку");
|
||||
activeAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
const transportIdentity = cameraBrowserTransportIdentity(
|
||||
delivery,
|
||||
recoveryAuthorityIdentity,
|
||||
);
|
||||
|
||||
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
||||
// Setting this local fence tears down the effect-owned WebSocket and MSE
|
||||
// buffer. It intentionally never invokes a plugin or device command.
|
||||
uiBuildStaleRef.current = true;
|
||||
activeTransportDisposeRef.current?.();
|
||||
setUiBuildStale(true);
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (uiBuildStale || !cameraTransportCanOpen(uiBuildStaleRef.current)) {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
return;
|
||||
}
|
||||
|
||||
if (transportAuthorityRef.current !== recoveryAuthorityIdentity) {
|
||||
transportAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const transportEpoch = transportEpochRef.current + 1;
|
||||
transportEpochRef.current = transportEpoch;
|
||||
diagnosticInstanceIdRef.current ??= createLiveViewerInstanceId();
|
||||
diagnosticGenerationRef.current += 1;
|
||||
const diagnosticLifecycle = createLiveViewerDiagnosticLifecycle({
|
||||
lineage: createLiveViewerLineage(
|
||||
diagnosticInstanceIdRef.current,
|
||||
diagnosticGenerationRef.current,
|
||||
),
|
||||
});
|
||||
activeDiagnosticRef.current = diagnosticLifecycle;
|
||||
diagnosticLifecycle.verifyBuild();
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_player_effect_started",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
if (!recoveryAuthorityIdentity) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_playback_authority_missing",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
}
|
||||
const transportIsCurrent = () => cameraTransportCallbackIsCurrent(
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
);
|
||||
let socket: WebSocket | null = null;
|
||||
let sourceBuffer: SourceBuffer | null = null;
|
||||
let onBufferUpdateEnd: (() => void) | null = null;
|
||||
let onBufferError: (() => void) | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
let playingReported = false;
|
||||
let startupWatchdog: CameraStartupWatchdog | null = null;
|
||||
transportHealthyRef.current = false;
|
||||
const recovering = Boolean(
|
||||
activeAuthorityRef.current
|
||||
&& recoveryPendingAuthorityRef.current === activeAuthorityRef.current,
|
||||
);
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.clear();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setStatus("error");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(4_000, "Live video buffer reset");
|
||||
}
|
||||
} catch {
|
||||
// The manual reconnect button will create a fresh transport and MSE buffer.
|
||||
}
|
||||
};
|
||||
|
||||
const retryTransport = (
|
||||
copy: string,
|
||||
failureStage: LiveViewerFailureStage,
|
||||
websocketCloseCode?: number,
|
||||
appendFailure?: {
|
||||
cameraAppendErrorName: string;
|
||||
cameraMediaSourceState: "closed" | "open" | "ended";
|
||||
cameraVideoErrorCode: number | null;
|
||||
},
|
||||
) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
if (!cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityRef.current,
|
||||
recoveryAuthorityIdentity,
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
)) {
|
||||
fail(copy);
|
||||
return;
|
||||
}
|
||||
startupWatchdog?.clear();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
leaseRetryRef.current = retry.budget;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_transport_restart_requested",
|
||||
failureStage,
|
||||
streamId: delivery.id,
|
||||
cameraQueueBytes: queuedBytes,
|
||||
cameraQueueSegments: queue.length,
|
||||
cameraRetryCount: retry.budget.count,
|
||||
websocketCloseCode,
|
||||
transportEpoch,
|
||||
...appendFailure,
|
||||
});
|
||||
queue.length = 0;
|
||||
queuedBytes = 0;
|
||||
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
setStatus("connecting");
|
||||
setMessage(copy);
|
||||
try {
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(4_001, "Live video transport recovery");
|
||||
}
|
||||
} catch {
|
||||
// The replacement effect still fences and releases this transport.
|
||||
}
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (cameraTransportRecoveryIsCurrent(
|
||||
activeAuthorityRef.current,
|
||||
recoveryAuthorityIdentity,
|
||||
transportEpochRef.current,
|
||||
transportEpoch,
|
||||
disposed || uiBuildStaleRef.current,
|
||||
)) {
|
||||
setAttempt((value) => value + 1);
|
||||
}
|
||||
}, retry.delay);
|
||||
};
|
||||
|
||||
if (recoveryAuthorityIdentity) {
|
||||
startupWatchdog = createCameraStartupWatchdog({
|
||||
schedule: (callback, timeoutMs) => window.setTimeout(callback, timeoutMs),
|
||||
cancel: (handle) => window.clearTimeout(handle),
|
||||
onTimeout: (stage) => {
|
||||
retryTransport(
|
||||
cameraStartupWatchdogRecoveryMessage(stage),
|
||||
stage === "first-media"
|
||||
? "camera-first-media-timeout"
|
||||
: "camera-first-playable-timeout",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onPlaying = () => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.markPlaying();
|
||||
transportHealthyRef.current = true;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setStatus("playing");
|
||||
setMessage("");
|
||||
if (!playingReported) {
|
||||
playingReported = true;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_transport_playing",
|
||||
streamId: delivery.id,
|
||||
cameraQueueBytes: queuedBytes,
|
||||
cameraQueueSegments: queue.length,
|
||||
transportEpoch,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener("playing", onPlaying);
|
||||
|
||||
const appendNext = () => {
|
||||
if (
|
||||
!transportIsCurrent()
|
||||
|| failed
|
||||
|| !sourceBuffer
|
||||
|| sourceBuffer.updating
|
||||
|| queue.length === 0
|
||||
) return;
|
||||
const chunk = queue.shift();
|
||||
if (!chunk) return;
|
||||
queuedBytes -= chunk.byteLength;
|
||||
try {
|
||||
sourceBuffer.appendBuffer(chunk);
|
||||
} catch (error) {
|
||||
const quotaExceeded = error instanceof DOMException
|
||||
&& error.name === "QuotaExceededError";
|
||||
retryTransport(
|
||||
quotaExceeded
|
||||
? "Live-буфер переполнен; восстанавливаем канал без накопленной задержки."
|
||||
: "Не удалось добавить видеосегмент; восстанавливаем browser-preview.",
|
||||
quotaExceeded ? "camera-append-quota" : "camera-append-error",
|
||||
undefined,
|
||||
{
|
||||
cameraAppendErrorName: error instanceof DOMException
|
||||
? error.name
|
||||
: error instanceof Error
|
||||
? error.name
|
||||
: "UnknownError",
|
||||
cameraMediaSourceState: mediaSource.readyState,
|
||||
cameraVideoErrorCode: video.error?.code ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (chunk: ArrayBuffer) => {
|
||||
if (!transportIsCurrent() || failed || chunk.byteLength === 0) return;
|
||||
startupWatchdog?.markMediaReceived();
|
||||
// Never drop arbitrary fMP4 fragments: the following samples may depend
|
||||
// on them. A bounded reset is safer and keeps live latency deterministic.
|
||||
if (!cameraPendingQueueCanAccept(queuedBytes, queue.length, chunk.byteLength)) {
|
||||
retryTransport(
|
||||
"Видеодекодер отстал от эфира; восстанавливаем live-буфер.",
|
||||
"camera-queue-capacity",
|
||||
);
|
||||
return;
|
||||
}
|
||||
queue.push(chunk);
|
||||
queuedBytes += chunk.byteLength;
|
||||
appendNext();
|
||||
};
|
||||
|
||||
const mediaType = delivery.mediaType?.trim();
|
||||
if (typeof MediaSource === "undefined") {
|
||||
fail("Этот браузер не поддерживает Media Source Extensions.");
|
||||
return;
|
||||
}
|
||||
if (!mediaType || !MediaSource.isTypeSupported(mediaType)) {
|
||||
fail(mediaType
|
||||
? `Браузер не поддерживает ${mediaType}`
|
||||
: "Адаптер не сообщил MIME/codec fMP4-потока.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("connecting");
|
||||
setMessage(recovering
|
||||
? "Восстановление камеры после разрыва браузерного канала"
|
||||
: "Подключение к локальному видеопотоку");
|
||||
const mediaSource = new MediaSource();
|
||||
objectUrl = URL.createObjectURL(mediaSource);
|
||||
|
||||
const onSourceOpen = () => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_media_source_open",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
try {
|
||||
sourceBuffer = mediaSource.addSourceBuffer(mediaType);
|
||||
} catch {
|
||||
fail("Не удалось создать MSE-буфер для указанного кодека.");
|
||||
return;
|
||||
}
|
||||
|
||||
onBufferUpdateEnd = () => {
|
||||
if (!transportIsCurrent() || failed || !sourceBuffer) return;
|
||||
const buffered = sourceBuffer.buffered;
|
||||
if (buffered.length > 0) {
|
||||
const end = buffered.end(buffered.length - 1);
|
||||
const start = buffered.start(0);
|
||||
if (end - video.currentTime > 1) video.currentTime = Math.max(0, end - 0.1);
|
||||
if (!receivedMedia) {
|
||||
receivedMedia = true;
|
||||
setStatus("buffering");
|
||||
setMessage(recovering
|
||||
? "Запуск первого кадра восстановленной камеры"
|
||||
: "Запуск первого декодированного кадра");
|
||||
void video.play().catch(() => undefined);
|
||||
}
|
||||
const removeBefore = end - 3;
|
||||
if (removeBefore > start && !sourceBuffer.updating) {
|
||||
try {
|
||||
sourceBuffer.remove(0, removeBefore);
|
||||
return;
|
||||
} catch {
|
||||
// Continue appending; quota handling performs a clean reset.
|
||||
}
|
||||
}
|
||||
}
|
||||
appendNext();
|
||||
};
|
||||
onBufferError = () => {
|
||||
retryTransport(
|
||||
"MSE сбросил видеосегмент; восстанавливаем decoder.",
|
||||
"camera-source-buffer-error",
|
||||
);
|
||||
};
|
||||
sourceBuffer.addEventListener("updateend", onBufferUpdateEnd);
|
||||
sourceBuffer.addEventListener("error", onBufferError);
|
||||
|
||||
try {
|
||||
socket = new WebSocket(websocketUrl(delivery.url));
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : "Некорректный адрес видеопотока.");
|
||||
return;
|
||||
}
|
||||
socket.binaryType = "arraybuffer";
|
||||
socket.addEventListener("open", () => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_camera_websocket_open",
|
||||
streamId: delivery.id,
|
||||
transportEpoch,
|
||||
});
|
||||
startupWatchdog?.armFirstMedia();
|
||||
setStatus("buffering");
|
||||
setMessage(recovering
|
||||
? "Ожидание первого кадра после восстановления"
|
||||
: "Ожидание первого видеокадра");
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
enqueue(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
void event.data.arrayBuffer().then(enqueue).catch(() => {
|
||||
retryTransport(
|
||||
"Получен повреждённый видеосегмент; восстанавливаем канал.",
|
||||
"camera-fragment-decode",
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
retryTransport(
|
||||
"Связь с локальным video adapter потеряна; переподключаемся.",
|
||||
"camera-websocket-error",
|
||||
);
|
||||
});
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
retryTransport(
|
||||
cameraTransportCloseRecoveryMessage(event.code),
|
||||
"camera-websocket-close",
|
||||
event.code,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Register before assigning the object URL. `video.src` is the action that
|
||||
// lets the browser open this MediaSource; a fast sequential MSE lease must
|
||||
// not fire `sourceopen` in the gap before its only listener exists.
|
||||
mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
|
||||
startupWatchdog?.armFirstMedia();
|
||||
video.src = objectUrl;
|
||||
|
||||
const disposeTransport = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
transportHealthyRef.current = false;
|
||||
startupWatchdog?.clear();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Browser preview transport replaced or hidden");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
||||
if (sourceBuffer && onBufferUpdateEnd) {
|
||||
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
||||
}
|
||||
if (sourceBuffer && onBufferError) {
|
||||
sourceBuffer.removeEventListener("error", onBufferError);
|
||||
}
|
||||
try {
|
||||
if (sourceBuffer?.updating) sourceBuffer.abort();
|
||||
} catch {
|
||||
// The MediaSource can already be closing.
|
||||
}
|
||||
try {
|
||||
if (mediaSource.readyState === "open") mediaSource.endOfStream();
|
||||
} catch {
|
||||
// Cleanup must continue even if the browser has already detached MSE.
|
||||
}
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
activeTransportDisposeRef.current = disposeTransport;
|
||||
return () => {
|
||||
if (activeTransportDisposeRef.current === disposeTransport) {
|
||||
activeTransportDisposeRef.current = null;
|
||||
}
|
||||
if (activeDiagnosticRef.current === diagnosticLifecycle) {
|
||||
activeDiagnosticRef.current = null;
|
||||
}
|
||||
diagnosticLifecycle.dispose();
|
||||
disposeTransport();
|
||||
};
|
||||
}, [attempt, recoveryAuthorityIdentity, transportIdentity, uiBuildStale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!recoveryAuthorityIdentity
|
||||
|| uiBuildStale
|
||||
|| !cameraTransportCanOpen(uiBuildStaleRef.current)
|
||||
) return;
|
||||
|
||||
let recovery = initialCameraPlaybackRecoveryState(
|
||||
recoveryAuthorityIdentity,
|
||||
Date.now(),
|
||||
);
|
||||
const dispatchRecovery = (event: CameraPlaybackRecoveryEvent) => {
|
||||
if (uiBuildStaleRef.current) return;
|
||||
const decision = reduceCameraPlaybackRecovery(recovery, event, {
|
||||
activeAuthorityIdentity: activeAuthorityRef.current,
|
||||
now: Date.now(),
|
||||
documentVisible: document.visibilityState === "visible",
|
||||
networkOnline: navigator.onLine !== false,
|
||||
transportHealthy: transportHealthyRef.current,
|
||||
});
|
||||
recovery = decision.state;
|
||||
if (!decision.reopen) return;
|
||||
activeDiagnosticRef.current?.post({
|
||||
eventCode: "live_camera_transport_restart_requested",
|
||||
failureStage: cameraPlaybackRecoveryFailureStage(event),
|
||||
streamId: delivery.id,
|
||||
transportEpoch: transportEpochRef.current,
|
||||
});
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = recoveryAuthorityIdentity;
|
||||
setStatus("connecting");
|
||||
setMessage("Восстановление камеры после разрыва браузерного канала");
|
||||
// The transport effect cleans up the old WebSocket and MSE object before
|
||||
// opening their replacement. No plugin action or device command occurs.
|
||||
setAttempt((value) => value + 1);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
dispatchRecovery({
|
||||
type: document.visibilityState === "hidden"
|
||||
? "document-hidden"
|
||||
: "document-visible",
|
||||
});
|
||||
};
|
||||
const onOnline = () => dispatchRecovery({ type: "network-online" });
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
dispatchRecovery({ type: "page-restore", persisted: event.persisted });
|
||||
};
|
||||
const heartbeat = window.setInterval(() => {
|
||||
dispatchRecovery({ type: "heartbeat" });
|
||||
}, 1_000);
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("online", onOnline);
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => {
|
||||
window.clearInterval(heartbeat);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("online", onOnline);
|
||||
window.removeEventListener("pageshow", onPageShow);
|
||||
};
|
||||
}, [delivery.id, recoveryAuthorityIdentity, uiBuildStale]);
|
||||
|
||||
return (
|
||||
<div className="mse-fmp4-player" data-status={status}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="observation-media__asset"
|
||||
aria-label={label}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
{status !== "playing" ? (
|
||||
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
||||
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
||||
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
|
||||
<span>{message}</span>
|
||||
{status === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
Подключиться повторно
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useState } from "react";
|
||||
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type ObservationSessionReplayLaunch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../core/observation/useObservationSessions";
|
||||
import type {
|
||||
ObservationPreparationPhase,
|
||||
ObservationReplayOutcome,
|
||||
} from "../core/observation/useObservationSessions";
|
||||
import "../styles/observation-sessions.css";
|
||||
|
||||
type SessionVisualState = "ready" | "processing" | "cold" | "error";
|
||||
|
||||
export function observationSessionVisualState(
|
||||
session: ObservationSessionSummary,
|
||||
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
|
||||
): SessionVisualState {
|
||||
if (failed) return "error";
|
||||
if (pending) return "processing";
|
||||
if (session.preparation !== null) {
|
||||
if (["queued", "validating", "exporting", "finalizing"].includes(
|
||||
session.preparation.state,
|
||||
)) return "processing";
|
||||
if (session.preparation.state === "ready" && session.replayable) return "ready";
|
||||
return "error";
|
||||
}
|
||||
if (session.status === "recording") return "processing";
|
||||
return session.replayable ? "cold" : "error";
|
||||
}
|
||||
|
||||
export function observationSessionVisualLabel(state: SessionVisualState): string {
|
||||
if (state === "ready") return "Готово";
|
||||
if (state === "processing") return "Обработка";
|
||||
if (state === "cold") return "Подготовить";
|
||||
return "Ошибка";
|
||||
}
|
||||
|
||||
const modalityLabel: Record<string, string> = {
|
||||
"point-cloud": "облако точек",
|
||||
pose: "траектория",
|
||||
trajectory: "траектория",
|
||||
video: "видео",
|
||||
image: "изображения",
|
||||
depth: "глубина",
|
||||
telemetry: "телеметрия",
|
||||
};
|
||||
|
||||
const preparationLabel: Record<ObservationPreparationPhase, string> = {
|
||||
requesting: "Запрашиваем подготовку",
|
||||
queued: "В очереди",
|
||||
validating: "Проверяем запись",
|
||||
exporting: "Готовим операторскую сцену",
|
||||
finalizing: "Завершаем подготовку",
|
||||
failed: "Подготовка не выполнена",
|
||||
cancelled: "Подготовка отменена",
|
||||
};
|
||||
|
||||
function progressCopy(phase: ObservationPreparationPhase): string {
|
||||
// Backend progress values are phase markers and heartbeat revisions, not a
|
||||
// measured fraction of bytes or frames. Presenting 0.5 as "50%" made a
|
||||
// healthy long export look stalled, especially after a browser reload.
|
||||
return preparationLabel[phase];
|
||||
}
|
||||
|
||||
function formatStartedAt(value: string): string {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
|
||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function sessionDescription(session: ObservationSessionSummary): string {
|
||||
const modalities = session.modalities.length
|
||||
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
|
||||
: "каналы не зафиксированы";
|
||||
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
|
||||
}
|
||||
|
||||
export interface ObservationSessionReplayCallbacks {
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplayAccepted?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => void | Promise<void>;
|
||||
onReplaySettled?: (
|
||||
session: ObservationSessionSummary,
|
||||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 100,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
}: ObservationSessionReplayCallbacks & {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: "source",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
});
|
||||
const triggerCopy = sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase)
|
||||
: sessions.state === "loading"
|
||||
? "Загружаем сессии…"
|
||||
: "Сохранённые сессии";
|
||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||
|
||||
return <>
|
||||
<Dropdown
|
||||
className="observation-session-select"
|
||||
placement="bottom-end"
|
||||
width={390}
|
||||
offset={10}
|
||||
disabled={disabled}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-session-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="observation-session-select__trigger"
|
||||
data-active={open ? "true" : undefined}
|
||||
aria-label="Открыть сохранённые сессии наблюдения"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
disabled={disabled}
|
||||
title={blockedReason ?? undefined}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={15} />
|
||||
<span>{presentedTriggerCopy}</span>
|
||||
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="observation-session-menu__content">
|
||||
<header className="observation-session-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
|
||||
<strong>Сохранённые сессии</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Обновить каталог сессий"
|
||||
disabled={sessions.state === "loading"}
|
||||
onClick={() => void sessions.refresh()}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{sessions.items.length > 0 ? (
|
||||
<div className="observation-session-menu__list">
|
||||
{sessions.items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const deleting = sessions.deletingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="observation-session-option"
|
||||
data-deleting={deleting ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option__open"
|
||||
disabled={pending || deleting || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">
|
||||
{session.lab &&
|
||||
!session.label.startsWith(`${session.lab.labId} ·`) ? (
|
||||
<span className="observation-session-option__lab">
|
||||
{session.lab.labId}
|
||||
</span>
|
||||
) : null}
|
||||
{session.label}
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-option__delete"
|
||||
aria-label={`Удалить сохранённую сессию ${session.label}`}
|
||||
disabled={pending || deleting}
|
||||
onClick={() => setDeleteTarget(session)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : sessions.state === "loading" ? (
|
||||
<div className="observation-session-menu__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Читаем каталог</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-session-menu__empty">
|
||||
<Icon name={sessions.error ? "alert" : "database"} size={18} />
|
||||
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
|
||||
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.error && sessions.items.length > 0 ? (
|
||||
<footer className="observation-session-menu__error" role="alert">
|
||||
<Icon name="alert" size={13} />
|
||||
<span>{sessions.error}</span>
|
||||
{sessions.failedSessionId ? (
|
||||
<button type="button" onClick={() => void sessions.retry()}>
|
||||
Повторить
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить сохранённую сессию?"
|
||||
description={deleteTarget ? <>
|
||||
<strong>{deleteTarget.label}</strong>
|
||||
{deleteTarget.lab ? (
|
||||
<p>
|
||||
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
|
||||
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
|
||||
лабораторный результат останутся неизменными.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
)}
|
||||
{sessions.error && sessions.deletingSessionId === null ? (
|
||||
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
|
||||
) : null}
|
||||
</> : null}
|
||||
confirmLabel="Удалить сессию"
|
||||
pendingLabel="Удаление…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (sessions.deletingSessionId === null) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ObservationSessionArchive({
|
||||
limit = 100,
|
||||
labsOnly = false,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
}: ObservationSessionReplayCallbacks & {
|
||||
limit?: number;
|
||||
labsOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: labsOnly ? "laboratory" : "source",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
onDeleteBegin,
|
||||
});
|
||||
const items = sessions.items;
|
||||
|
||||
return <>
|
||||
<section
|
||||
className="observation-session-archive"
|
||||
aria-label={labsOnly ? "Лабораторные записи" : "Сохранённые сессии"}
|
||||
>
|
||||
<header className="observation-session-archive__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{labsOnly ? "ВОСПРОИЗВОДИМЫЕ ЛАБОРАТОРНЫЕ ЗАПИСИ" : "АРХИВ НАБЛЮДЕНИЯ"}
|
||||
</span>
|
||||
<h2>{labsOnly ? "Записанные лабораторные работы" : "Сохранённые сессии"}</h2>
|
||||
<p>
|
||||
{labsOnly
|
||||
? "Каждая запись связана с LAB-конфигурацией и открывается как самостоятельный визуальный результат."
|
||||
: "Выберите запись: сцена и синхронные каналы откроются ниже, не меняя эфир наблюдения."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-archive__refresh"
|
||||
aria-label="Обновить каталог сессий"
|
||||
disabled={sessions.state === "loading"}
|
||||
onClick={() => void sessions.refresh()}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
<span>Обновить</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="observation-session-archive__list">
|
||||
{items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const deleting = sessions.deletingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<article
|
||||
key={session.id}
|
||||
className="observation-session-archive__item"
|
||||
data-deleting={deleting ? "true" : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-archive__open"
|
||||
disabled={disabled || pending || deleting || !session.replayable}
|
||||
title={blockedReason ?? undefined}
|
||||
onClick={() => void sessions.replay(session.id)}
|
||||
>
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>
|
||||
{session.lab &&
|
||||
!session.label.startsWith(`${session.lab.labId} ·`) ? (
|
||||
<small>{session.lab.labId}</small>
|
||||
) : null}
|
||||
{session.label}
|
||||
</strong>
|
||||
<small>{sessionDescription(session)}</small>
|
||||
</span>
|
||||
<em>
|
||||
{pending && sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase)
|
||||
: observationSessionVisualLabel(visualState)}
|
||||
</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-archive__delete"
|
||||
aria-label={`Удалить сохранённую сессию ${session.label}`}
|
||||
disabled={pending || deleting}
|
||||
onClick={() => setDeleteTarget(session)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : sessions.state === "loading" ? (
|
||||
<div className="observation-session-archive__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Читаем каталог</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-session-archive__empty">
|
||||
<Icon name={sessions.error ? "alert" : "database"} size={18} />
|
||||
<strong>
|
||||
{sessions.error
|
||||
? "Каталог недоступен"
|
||||
: labsOnly
|
||||
? "LAB-записей пока нет"
|
||||
: "Сессий пока нет"}
|
||||
</strong>
|
||||
<span>
|
||||
{sessions.error
|
||||
?? (labsOnly
|
||||
? "Зафиксированные лабораторные записи появятся здесь автоматически."
|
||||
: "Завершённые записи появятся здесь автоматически.")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.error && items.length > 0 ? (
|
||||
<footer className="observation-session-menu__error" role="alert">
|
||||
<Icon name="alert" size={13} />
|
||||
<span>{sessions.error}</span>
|
||||
{sessions.failedSessionId ? (
|
||||
<button type="button" onClick={() => void sessions.retry()}>
|
||||
Повторить
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить сохранённую сессию?"
|
||||
description={deleteTarget ? <>
|
||||
<strong>{deleteTarget.label}</strong>
|
||||
{deleteTarget.lab ? (
|
||||
<p>
|
||||
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
|
||||
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
|
||||
лабораторный результат останутся неизменными.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
)}
|
||||
</> : null}
|
||||
confirmLabel="Удалить сессию"
|
||||
pendingLabel="Удаление…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (sessions.deletingSessionId === null) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
ObservationSourceAvailability,
|
||||
ObservationSourceDescriptor,
|
||||
ObservationSourceModality,
|
||||
} from "../core/runtime/contracts";
|
||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "./RecordedFmp4Player";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||
|
||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
||||
"point-cloud": "globe",
|
||||
video: "video",
|
||||
image: "image",
|
||||
depth: "image",
|
||||
};
|
||||
|
||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
||||
unverified: "Не подтверждён",
|
||||
declared: "Канал объявлен",
|
||||
available: "Доступен",
|
||||
connecting: "Подключение",
|
||||
streaming: "Эфир",
|
||||
degraded: "Нестабильно",
|
||||
unavailable: "Недоступен",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
||||
return availabilityCopy[source.availability];
|
||||
}
|
||||
|
||||
export function ObservationMedia({
|
||||
source,
|
||||
playback,
|
||||
prepareRecorded = true,
|
||||
recordedSessionGate = "ready",
|
||||
recordedAdmissionKey = null,
|
||||
onRecordedAdmissionChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback?: RecordedObservationPlayback | null;
|
||||
prepareRecorded?: boolean;
|
||||
recordedSessionGate?: RecordedAdmissionPhase;
|
||||
recordedAdmissionKey?: string | null;
|
||||
onRecordedAdmissionChange?: (
|
||||
sourceId: string,
|
||||
state: RecordedCameraAdmissionState,
|
||||
) => void;
|
||||
}) {
|
||||
const sourceSelected = source.activation ? source.activation.selected : true;
|
||||
const deliveryActive = Boolean(source.delivery && sourceSelected);
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "mse-fmp4-websocket" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
return (
|
||||
<MseFmp4WebSocketPlayer
|
||||
delivery={source.delivery}
|
||||
label={source.label}
|
||||
recoveryAuthorityIdentity={liveCameraPlaybackAuthorityIdentity(source)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
source.modality === "video"
|
||||
) {
|
||||
if (!prepareRecorded) {
|
||||
return (
|
||||
<div className="observation-media__empty" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ожидает подготовки</strong>
|
||||
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
prepare
|
||||
sessionGate={recordedSessionGate}
|
||||
admissionKey={recordedAdmissionKey}
|
||||
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
className="observation-media__asset"
|
||||
src={source.delivery.url}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
deliveryActive &&
|
||||
source.delivery?.kind === "image-url" &&
|
||||
(source.modality === "image" || source.modality === "depth")
|
||||
) {
|
||||
return <img className="observation-media__asset" src={source.delivery.url} alt={source.label} />;
|
||||
}
|
||||
|
||||
if (sourceSelected && source.previewUrl && source.modality === "video") {
|
||||
return (
|
||||
<video
|
||||
className="observation-media__asset"
|
||||
src={source.previewUrl}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
sourceSelected &&
|
||||
source.previewUrl &&
|
||||
(source.modality === "image" || source.modality === "depth")
|
||||
) {
|
||||
return <img className="observation-media__asset" src={source.previewUrl} alt={source.label} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="observation-media__empty">
|
||||
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||
<strong>
|
||||
{source.modality === "video" && source.activation && !source.activation.selected
|
||||
? "Источник отключён"
|
||||
: observationSourceStatusLabel(source)}
|
||||
</strong>
|
||||
<span>
|
||||
{source.modality === "video"
|
||||
? source.activation?.controllable
|
||||
? source.activation.selected
|
||||
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||
: "Канал остановлен оператором; при необходимости его можно открыть снова"
|
||||
: "Канал известен, browser-preview сейчас недоступен"
|
||||
: source.description}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationSourcePicker({
|
||||
sources,
|
||||
visibleSourceIds,
|
||||
pendingSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
sources: readonly ObservationSourceDescriptor[];
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
pendingSourceIds?: ReadonlySet<string>;
|
||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
||||
}) {
|
||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
className="scene-source-picker"
|
||||
placement="bottom-start"
|
||||
width={340}
|
||||
offset={10}
|
||||
surfaceRole="dialog"
|
||||
surfaceClassName="observation-source-menu"
|
||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
||||
<button
|
||||
ref={(node) => {
|
||||
setAnchorRef(node);
|
||||
setTriggerRef(node);
|
||||
}}
|
||||
type="button"
|
||||
className="scene-source-picker__trigger"
|
||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
||||
aria-label="Источники данных сцены"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={17} />
|
||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<header className="observation-source-menu__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
||||
<strong>Источники данных</strong>
|
||||
</div>
|
||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
||||
</header>
|
||||
{sources.length ? (
|
||||
<div className="observation-source-menu__list">
|
||||
{sources.map((source) => {
|
||||
const selected = visibleSourceIds.has(source.id);
|
||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
||||
const canOpen = Boolean(
|
||||
selected ||
|
||||
source.modality === "point-cloud" ||
|
||||
source.previewUrl ||
|
||||
source.delivery ||
|
||||
source.activation?.controllable,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-source-option"
|
||||
data-selected={selected ? "true" : undefined}
|
||||
aria-pressed={selected}
|
||||
disabled={pending || !canOpen}
|
||||
onClick={() => void onToggle(source.id)}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
||||
</span>
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__check">
|
||||
{selected ? <Icon name="check" size={15} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="observation-source-menu__empty">
|
||||
<Icon name="database" size={18} />
|
||||
<strong>Каталог пока пуст</strong>
|
||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
||||
</div>
|
||||
)}
|
||||
<footer className="observation-source-menu__foot">
|
||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
||||
модель оборудования.
|
||||
</footer>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
||||
|
||||
export function ObservationTimeline({
|
||||
active,
|
||||
sourceCount,
|
||||
mode = "live-only",
|
||||
seekable = false,
|
||||
synchronization = "host-arrival-best-effort",
|
||||
rangeNs = null,
|
||||
currentNs = null,
|
||||
playing = false,
|
||||
onSeek,
|
||||
onPlayingChange,
|
||||
onJumpToEnd,
|
||||
showJumpToEnd = true,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
}: {
|
||||
active: boolean;
|
||||
sourceCount: number;
|
||||
mode?: ObservationTimelineMode;
|
||||
seekable?: boolean;
|
||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
||||
rangeNs?: { min: number; max: number } | null;
|
||||
currentNs?: number | null;
|
||||
playing?: boolean;
|
||||
onSeek?: (timeNs: number) => void;
|
||||
onPlayingChange?: (playing: boolean) => void;
|
||||
onJumpToEnd?: () => void;
|
||||
showJumpToEnd?: boolean;
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
||||
const buffered = Boolean(
|
||||
seekable &&
|
||||
(mode === "buffered" || mode === "recorded") &&
|
||||
playbackRange,
|
||||
);
|
||||
const elapsedSeconds = playbackRange
|
||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
||||
: 0;
|
||||
const durationSeconds = playbackRange
|
||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
||||
: 0;
|
||||
const synchronizationLabel = {
|
||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
||||
"shared-clock": "Общие часы",
|
||||
"frame-accurate": "Покадровая синхронизация",
|
||||
}[synchronization];
|
||||
const accumulationValue = accumulationSeconds === undefined
|
||||
? null
|
||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
||||
return (
|
||||
<div
|
||||
className={`observation-timeline ${className}`.trim()}
|
||||
data-active={active ? "true" : undefined}
|
||||
data-mode={mode}
|
||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
||||
>
|
||||
{accumulationValue !== null ? (
|
||||
<div className="observation-timeline__accumulation">
|
||||
<span>Накопление</span>
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
||||
onChange={(event) =>
|
||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
||||
onPointerUp={onAccumulationCommit}
|
||||
onTouchEnd={onAccumulationCommit}
|
||||
onKeyUp={onAccumulationCommit}
|
||||
onBlur={onAccumulationCommit}
|
||||
/>
|
||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="observation-timeline__playback">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Перейти к началу"
|
||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
className="observation-timeline__transport"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
/>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
value={String(playbackRate)}
|
||||
options={[
|
||||
{ value: "0.5", label: "0,5×" },
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="inline"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(durationSeconds, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
||||
disabled={!buffered || !onSeek}
|
||||
aria-label="Позиция воспроизведения"
|
||||
onChange={(event) => {
|
||||
if (!playbackRange) return;
|
||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
<div className="observation-timeline__meta">
|
||||
<code>
|
||||
{buffered
|
||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
||||
: active ? "LIVE" : "—:—:—.———"}
|
||||
</code>
|
||||
<small>
|
||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
||||
</small>
|
||||
</div>
|
||||
{showJumpToEnd ? (
|
||||
<button
|
||||
type="button"
|
||||
className="observation-timeline__follow"
|
||||
data-active={!buffered && active ? "true" : undefined}
|
||||
disabled={buffered ? !onJumpToEnd : true}
|
||||
onClick={onJumpToEnd}
|
||||
>
|
||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(120, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
export function formatAccumulationDuration(value: number): string {
|
||||
const seconds = normalizeAccumulationSeconds(value);
|
||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineRange(
|
||||
range: { min: number; max: number } | null | undefined,
|
||||
): { min: number; max: number } | null {
|
||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
||||
if (range.max <= range.min) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
export function timelineOffsetSeconds(
|
||||
range: { min: number; max: number },
|
||||
currentNs: number,
|
||||
): number {
|
||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
||||
}
|
||||
|
||||
export function formatTimelineDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
||||
const wholeMinutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds - wholeMinutes * 60;
|
||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { E30EngineeringGeneration } from "../../core/laboratory/e30Engineering";
|
||||
|
||||
export function E30EngineeringGenerationSummary({
|
||||
generation,
|
||||
}: {
|
||||
generation: E30EngineeringGeneration;
|
||||
}) {
|
||||
const summary = generation.summary;
|
||||
const confirmed = summary.verdictDistribution.confirmed ?? 0;
|
||||
const corrected = summary.verdictDistribution.corrected ?? 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="e30-engineering-generation"
|
||||
aria-label="Результат A3 engineering generation"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">A3 · IMMUTABLE ENGINEERING GENERATION</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={summary.humanExceptionCount ? "warning" : "accent"}
|
||||
>
|
||||
{summary.reviewedItemCount} / {summary.itemCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Подтверждено</dt>
|
||||
<dd>{confirmed.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исправлено</dt>
|
||||
<dd>{corrected.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исключения</dt>
|
||||
<dd>{summary.humanExceptionCount.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Средняя уверенность</dt>
|
||||
<dd>
|
||||
{(summary.meanConfidence * 100).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
%
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { E30ReviewItemDetail } from "../../core/laboratory/e30Review";
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function evidenceRange(item: E30ReviewItemDetail): string {
|
||||
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
|
||||
return value === null ? "недоступна" : `${formatNumber(value)} м`;
|
||||
}
|
||||
|
||||
export function E30EvidenceTelemetry({
|
||||
detail,
|
||||
mode,
|
||||
}: {
|
||||
detail: E30ReviewItemDetail;
|
||||
mode: "camera" | "3d";
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
className="e30-evidence-telemetry"
|
||||
aria-label="Параметры доказательства"
|
||||
>
|
||||
<span>
|
||||
{mode === "camera"
|
||||
? "Точный camera frame · LiDAR projection"
|
||||
: "Map frame · Z вверх"}
|
||||
</span>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Кадр / время</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.sourceFrameIndex)}
|
||||
{" · "}
|
||||
{formatNumber(detail.sessionSeconds)} с
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>LiDAR</dt>
|
||||
<dd>{formatNumber(detail.materialization.projectedPointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Кандидаты</dt>
|
||||
<dd>{formatNumber(detail.materialization.candidatePointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Выбрано / отклонено</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.materialization.selectedPointCount)}
|
||||
{" / "}
|
||||
{formatNumber(detail.materialization.rejectedCandidatePointCount)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Класс E29</dt>
|
||||
<dd>{detail.snapshot.geometryStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Дальность</dt>
|
||||
<dd>{evidenceRange(detail)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<span>Точные индексы LiDAR</span>
|
||||
<code>
|
||||
{detail.selected.sourceIndices.length
|
||||
? detail.selected.sourceIndices.slice(0, 18).join(", ")
|
||||
: "нет выбранных точек"}
|
||||
{detail.selected.sourceIndices.length > 18 ? " …" : ""}
|
||||
</code>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryEvidenceViewerMode<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function LaboratoryEvidenceViewer<
|
||||
T extends string,
|
||||
U extends string = string,
|
||||
>({
|
||||
label,
|
||||
className,
|
||||
mode,
|
||||
modes,
|
||||
expanded,
|
||||
onModeChange,
|
||||
onExpandedChange,
|
||||
actions,
|
||||
secondaryMode,
|
||||
overlay,
|
||||
transport,
|
||||
trailingActions,
|
||||
modeControlsVisible = true,
|
||||
chromeLayout = "overlay",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
mode: T;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<T>[];
|
||||
expanded: boolean;
|
||||
onModeChange: (mode: T) => void;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
actions?: ReactNode;
|
||||
secondaryMode?: {
|
||||
value: U;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<U>[];
|
||||
label: string;
|
||||
onChange: (mode: U) => void;
|
||||
};
|
||||
overlay?: ReactNode;
|
||||
transport?: ReactNode;
|
||||
trailingActions?: ReactNode;
|
||||
modeControlsVisible?: boolean;
|
||||
chromeLayout?: "overlay" | "stacked";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const viewerRef = useRef<HTMLDialogElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewer = viewerRef.current;
|
||||
if (!viewer) return;
|
||||
if (viewer.open) viewer.close();
|
||||
if (expanded) {
|
||||
viewer.showModal();
|
||||
} else {
|
||||
viewer.show();
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onExpandedChange(false);
|
||||
window.requestAnimationFrame(() => expandButtonRef.current?.focus());
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const controls = (
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{modeControlsVisible && secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
label={secondaryMode.label}
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
{modeControlsVisible ? (
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewer = (
|
||||
<dialog
|
||||
ref={viewerRef}
|
||||
role="region"
|
||||
className={[
|
||||
"laboratory-evidence-viewer",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
data-mode-controls={modeControlsVisible ? undefined : "content"}
|
||||
data-chrome-layout={chromeLayout}
|
||||
aria-label={label}
|
||||
>
|
||||
{chromeLayout === "stacked" ? (
|
||||
<>
|
||||
<div className="laboratory-evidence-viewer__header">
|
||||
<div className="laboratory-evidence-viewer__header-context">
|
||||
{overlay}
|
||||
</div>
|
||||
{controls}
|
||||
</div>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
{controls}
|
||||
</>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
|
||||
return viewer;
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
forwardRef,
|
||||
type CSSProperties,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import {
|
||||
recordedEvidenceSemanticCssColor,
|
||||
resolveRecordedEvidenceSemanticRgb,
|
||||
type RecordedEvidenceSemanticClass,
|
||||
type RecordedEvidenceSemanticPaletteEntry,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
|
||||
export type LaboratoryMetricPoint3 = readonly [number, number, number];
|
||||
export type LaboratoryMetricDecision = "threat" | "not-threat" | "unknown";
|
||||
export type LaboratoryMetricSceneMode = "3d" | "plan";
|
||||
|
||||
export interface LaboratoryMetricObstacleVisual {
|
||||
id: string;
|
||||
decision: LaboratoryMetricDecision;
|
||||
state: "current" | "retained" | "held" | "expired";
|
||||
centroidBodyXyzM: LaboratoryMetricPoint3;
|
||||
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
occupancySource?: "baseline" | "mixed" | "additive-low-step";
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricRigVisual {
|
||||
lengthM: number;
|
||||
widthM: number;
|
||||
nominalSensorHeightM: number;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricCorridorVisual {
|
||||
forwardLengthM: number;
|
||||
rearMarginM: number;
|
||||
halfWidthM: number;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricLegendEntry {
|
||||
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling" | "low-step";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function laboratoryMetricLegendEntries({
|
||||
pointCloudCount,
|
||||
localSurfaceCount,
|
||||
obstacles,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep = true,
|
||||
}: {
|
||||
pointCloudCount: number;
|
||||
localSurfaceCount: number;
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
showLowStep?: boolean;
|
||||
}): readonly LaboratoryMetricLegendEntry[] {
|
||||
const visibleObstacles = obstacles.filter((obstacle) => (
|
||||
(showLowStep || obstacle.occupancySource === undefined || obstacle.occupancySource === "baseline")
|
||||
&& (obstacle.state === "current"
|
||||
? showCurrentIncrement
|
||||
: obstacle.state === "retained"
|
||||
? showRollingMap
|
||||
: false)
|
||||
));
|
||||
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
|
||||
const entries: LaboratoryMetricLegendEntry[] = [];
|
||||
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
|
||||
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
|
||||
if (decisions.has("unknown")) entries.push({ id: "unknown", label: "Неизвестно" });
|
||||
if (
|
||||
showLowStep
|
||||
&& visibleObstacles.some((obstacle) => (
|
||||
obstacle.occupancySource !== undefined && obstacle.occupancySource !== "baseline"
|
||||
))
|
||||
) {
|
||||
entries.push({ id: "low-step", label: "LOW-STEP · добавлено системой" });
|
||||
}
|
||||
if (showCurrentIncrement && pointCloudCount > 0) {
|
||||
entries.push({ id: "context", label: "Текущий кадр" });
|
||||
}
|
||||
if (showLocalSurface && localSurfaceCount > 0) {
|
||||
entries.push({ id: "local-surface", label: "Локальная SLAM-поверхность" });
|
||||
}
|
||||
if (
|
||||
showRollingMap
|
||||
&& visibleObstacles.some((obstacle) => (
|
||||
obstacle.state === "retained" && obstacle.cellCentersBodyXyzM.length > 0
|
||||
))
|
||||
) {
|
||||
entries.push({ id: "rolling", label: "Занято на накопленной карте" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) return new THREE.Color(value);
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function disposeRenderable(object: THREE.Object3D): void {
|
||||
const renderable = object as THREE.Object3D & {
|
||||
geometry?: THREE.BufferGeometry;
|
||||
material?: THREE.Material | THREE.Material[];
|
||||
};
|
||||
renderable.geometry?.dispose();
|
||||
const materials = Array.isArray(renderable.material)
|
||||
? renderable.material
|
||||
: renderable.material
|
||||
? [renderable.material]
|
||||
: [];
|
||||
materials.forEach((material) => material.dispose());
|
||||
}
|
||||
|
||||
function clearGroup(group: THREE.Group): void {
|
||||
while (group.children.length) {
|
||||
const child = group.children[0];
|
||||
if (!child) break;
|
||||
group.remove(child);
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
}
|
||||
|
||||
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
|
||||
return [point[0], point[2], -point[1]];
|
||||
}
|
||||
|
||||
function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
|
||||
const result = new Float32Array(points.length * 3);
|
||||
points.forEach((point, index) => {
|
||||
const [x, y, z] = scenePoint(point);
|
||||
const offset = index * 3;
|
||||
result[offset] = x;
|
||||
result[offset + 1] = y;
|
||||
result[offset + 2] = z;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function decisionColor(
|
||||
host: HTMLElement,
|
||||
decision: LaboratoryMetricDecision,
|
||||
): THREE.Color {
|
||||
if (decision === "threat") {
|
||||
return tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
}
|
||||
if (decision === "not-threat") {
|
||||
return tokenColor(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
}
|
||||
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
|
||||
function obstacleColor(
|
||||
host: HTMLElement,
|
||||
obstacle: LaboratoryMetricObstacleVisual,
|
||||
): THREE.Color {
|
||||
if (obstacle.occupancySource !== "baseline") {
|
||||
return tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
return decisionColor(host, obstacle.decision);
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricEvidenceSceneHandle {
|
||||
resetView: () => void;
|
||||
}
|
||||
|
||||
export const LaboratoryMetricEvidenceScene = forwardRef<
|
||||
LaboratoryMetricEvidenceSceneHandle,
|
||||
{
|
||||
pointCloudBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
localSurfaceBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
rig: LaboratoryMetricRigVisual;
|
||||
corridor: LaboratoryMetricCorridorVisual;
|
||||
occupiedVoxelSizeM: number;
|
||||
mode: LaboratoryMetricSceneMode;
|
||||
label: string;
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
showLowStep?: boolean;
|
||||
pointSemanticClassIds?: readonly (number | null)[];
|
||||
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
}
|
||||
>(function LaboratoryMetricEvidenceScene({
|
||||
pointCloudBodyXyzM,
|
||||
localSurfaceBodyXyzM,
|
||||
obstacles,
|
||||
rig,
|
||||
corridor,
|
||||
occupiedVoxelSizeM,
|
||||
mode,
|
||||
label,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep = true,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
}, ref) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const staticContentRef = useRef<THREE.Group | null>(null);
|
||||
const dynamicContentRef = useRef<THREE.Group | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать метрическую 3D-сцену.");
|
||||
return;
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 300);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.4;
|
||||
controls.maxDistance = 80;
|
||||
const staticContent = new THREE.Group();
|
||||
const dynamicContent = new THREE.Group();
|
||||
scene.add(staticContent, dynamicContent);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
staticContentRef.current = staticContent;
|
||||
dynamicContentRef.current = dynamicContent;
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
scene.traverse(disposeRenderable);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = hostRef.current?.querySelector("canvas");
|
||||
if (canvas) canvas.setAttribute("aria-label", label);
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = dynamicContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
if (showLocalSurface) {
|
||||
const localSurfaceGeometry = new THREE.BufferGeometry();
|
||||
localSurfaceGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(localSurfaceBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
localSurfaceGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
size: 1.3,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if (showCurrentIncrement) {
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
const hasAlignedSemanticClasses =
|
||||
pointSemanticClassIds !== undefined
|
||||
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||
&& semanticClasses !== undefined
|
||||
&& semanticPalette !== undefined;
|
||||
if (hasAlignedSemanticClasses) {
|
||||
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||
for (const entry of semanticPalette) {
|
||||
if (!declaredIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||
}
|
||||
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
|
||||
pointSemanticClassIds.forEach((classId, index) => {
|
||||
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||
const offset = index * 3;
|
||||
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||
});
|
||||
contextGeometry.setAttribute(
|
||||
"color",
|
||||
new THREE.BufferAttribute(pointColors, 3),
|
||||
);
|
||||
}
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: hasAlignedSemanticClasses
|
||||
? new THREE.Color(1, 1, 1)
|
||||
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
vertexColors: hasAlignedSemanticClasses,
|
||||
size: 1.55,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.58,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
for (const obstacle of obstacles) {
|
||||
if (
|
||||
(
|
||||
!showLowStep
|
||||
&& obstacle.occupancySource !== undefined
|
||||
&& obstacle.occupancySource !== "baseline"
|
||||
)
|
||||
||
|
||||
(obstacle.state === "current" && !showCurrentIncrement)
|
||||
|| (obstacle.state === "retained" && !showRollingMap)
|
||||
|| obstacle.state === "held"
|
||||
|| obstacle.state === "expired"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const color = obstacleColor(host, obstacle);
|
||||
if (obstacle.state === "retained") {
|
||||
const geometry = new THREE.BoxGeometry(
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
occupiedVoxelSizeM * 0.82,
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
wireframe: true,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
});
|
||||
const voxels = new THREE.InstancedMesh(
|
||||
geometry,
|
||||
material,
|
||||
obstacle.cellCentersBodyXyzM.length,
|
||||
);
|
||||
const matrix = new THREE.Matrix4();
|
||||
obstacle.cellCentersBodyXyzM.forEach((point, index) => {
|
||||
matrix.makeTranslation(...scenePoint(point));
|
||||
voxels.setMatrixAt(index, matrix);
|
||||
});
|
||||
voxels.instanceMatrix.needsUpdate = true;
|
||||
voxels.userData.evidenceId = obstacle.id;
|
||||
content.add(voxels);
|
||||
} else {
|
||||
const cellsGeometry = new THREE.BufferGeometry();
|
||||
cellsGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
cellsGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color,
|
||||
size: 4.4,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
const centroid = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.065, 12, 8),
|
||||
new THREE.MeshBasicMaterial({ color }),
|
||||
);
|
||||
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
|
||||
centroid.userData.evidenceId = obstacle.id;
|
||||
content.add(centroid);
|
||||
}
|
||||
}
|
||||
|
||||
}, [
|
||||
obstacles,
|
||||
occupiedVoxelSizeM,
|
||||
localSurfaceBodyXyzM,
|
||||
pointCloudBodyXyzM,
|
||||
pointSemanticClassIds,
|
||||
semanticClasses,
|
||||
semanticPalette,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = staticContentRef.current;
|
||||
if (!host || !content) return;
|
||||
clearGroup(content);
|
||||
|
||||
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
|
||||
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
|
||||
const corridorMesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(corridorLength, corridor.halfWidthM * 2),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.11,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
corridorMesh.rotation.x = -Math.PI / 2;
|
||||
corridorMesh.position.set(corridorCenterX, 0.01, 0);
|
||||
content.add(corridorMesh);
|
||||
const corridorOutline = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(corridorLength, 0.01, corridor.halfWidthM * 2)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
}),
|
||||
);
|
||||
corridorOutline.position.set(corridorCenterX, 0.015, 0);
|
||||
content.add(corridorOutline);
|
||||
|
||||
const body = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(rig.lengthM, 0.34, rig.widthM)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
transparent: true,
|
||||
opacity: 0.86,
|
||||
}),
|
||||
);
|
||||
body.position.y = 0.17;
|
||||
content.add(body);
|
||||
const lidar = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.08, 0.08, 0.08, 20),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
}),
|
||||
);
|
||||
lidar.position.y = rig.nominalSensorHeightM;
|
||||
content.add(lidar);
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
Math.max(20, corridor.forwardLengthM * 2.5),
|
||||
40,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
|
||||
gridMaterials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.15;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [corridor, rig]);
|
||||
|
||||
const resetView = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
|
||||
if (mode === "plan") {
|
||||
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
|
||||
camera.up.set(0, 0, -1);
|
||||
} else {
|
||||
camera.position.set(-4.5, 4.8, 8.5);
|
||||
camera.up.set(0, 1, 0);
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
};
|
||||
|
||||
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
||||
useImperativeHandle(ref, () => ({ resetView }));
|
||||
|
||||
const semanticLegendEntries = (() => {
|
||||
if (
|
||||
!showCurrentIncrement
|
||||
|| !pointSemanticClassIds
|
||||
|| pointSemanticClassIds.length !== pointCloudBodyXyzM.length
|
||||
|| !semanticClasses
|
||||
|| !semanticPalette
|
||||
) return [];
|
||||
const presentIds = new Set(pointSemanticClassIds.filter((item): item is number => item !== null));
|
||||
const classesById = new Map(semanticClasses.map((item) => [item.id, item]));
|
||||
return semanticPalette.flatMap((entry) => {
|
||||
const semanticClass = classesById.get(entry.classId);
|
||||
if (!semanticClass || !presentIds.has(entry.classId) || entry.color.kind === "transparent") return [];
|
||||
return [{
|
||||
id: entry.classId,
|
||||
label: semanticClass.label,
|
||||
cssColor: recordedEvidenceSemanticCssColor(entry.color),
|
||||
}];
|
||||
});
|
||||
})();
|
||||
const metricLegendEntries = laboratoryMetricLegendEntries({
|
||||
pointCloudCount: pointCloudBodyXyzM.length,
|
||||
localSurfaceCount: localSurfaceBodyXyzM.length,
|
||||
obstacles,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
showLowStep,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="laboratory-metric-evidence-scene">
|
||||
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
|
||||
{renderError ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
{metricLegendEntries.map((entry) => (
|
||||
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
|
||||
))}
|
||||
{semanticLegendEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
data-decision="semantic"
|
||||
style={{ "--laboratory-metric-legend-color": entry.cssColor } as CSSProperties}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Icon, IconButton, Select, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
status?: "progress" | "retained" | "failed" | "unreviewed";
|
||||
}
|
||||
|
||||
export type LaboratoryExecutionClass =
|
||||
| "deterministic"
|
||||
| "ai-inference"
|
||||
| "hybrid";
|
||||
export type LaboratoryMethodCompleteness = "complete" | "legacy-partial";
|
||||
export type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model";
|
||||
|
||||
export interface LaboratoryMethodComponent {
|
||||
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
|
||||
name: string;
|
||||
version: string;
|
||||
role: string;
|
||||
identitySha256: string | null;
|
||||
}
|
||||
|
||||
export interface LaboratoryMethod {
|
||||
completeness: LaboratoryMethodCompleteness;
|
||||
executionClass: LaboratoryExecutionClass;
|
||||
pipelineId: string;
|
||||
components: readonly LaboratoryMethodComponent[];
|
||||
}
|
||||
|
||||
export interface LaboratoryBrief {
|
||||
question: string;
|
||||
approach: string;
|
||||
principalResult: string;
|
||||
limitation: string;
|
||||
}
|
||||
|
||||
export interface LaboratoryResultMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface LaboratoryResultConclusion {
|
||||
proved: string;
|
||||
notProved: string;
|
||||
decision: string;
|
||||
}
|
||||
|
||||
export const LABORATORY_REPORT_TEMPLATE_VERSION =
|
||||
"missioncore.laboratory-report/v1";
|
||||
|
||||
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
|
||||
deterministic: "Детерминированный",
|
||||
"ai-inference": "AI inference",
|
||||
hybrid: "Гибридный",
|
||||
};
|
||||
|
||||
const COMPONENT_LABELS: Record<LaboratoryMethodComponent["kind"], string> = {
|
||||
source: "Источник",
|
||||
tool: "Инструмент",
|
||||
model: "Модель",
|
||||
algorithm: "Алгоритм",
|
||||
runtime: "Runtime",
|
||||
};
|
||||
|
||||
export function LaboratorySelector<T extends string>({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
searchable = false,
|
||||
onChange,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly LaboratoryOption<T>[];
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-selector">
|
||||
<div>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="laboratory-selector__control">
|
||||
<span>{label}</span>
|
||||
<Select
|
||||
label={`Выбрать: ${label}`}
|
||||
value={value}
|
||||
options={options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
icon: option.status ? (
|
||||
<span
|
||||
className="laboratory-status-dot"
|
||||
data-status={option.status}
|
||||
aria-label={option.status === "progress"
|
||||
? "Есть подтверждённый прогресс"
|
||||
: option.status === "retained"
|
||||
? "Сохранено для сравнения"
|
||||
: option.status === "failed"
|
||||
? "Gate не пройден"
|
||||
: "Требует классификации"}
|
||||
>
|
||||
</span>
|
||||
) : undefined,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={disabled}
|
||||
searchable={searchable}
|
||||
searchPlaceholder={`Найти: ${label.toLocaleLowerCase("ru-RU")}`}
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratorySummary({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusTone = "neutral",
|
||||
facts,
|
||||
brief,
|
||||
method,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
facts: readonly { label: string; value: string }[];
|
||||
brief: LaboratoryBrief;
|
||||
method?: LaboratoryMethod | null;
|
||||
}) {
|
||||
const methodComplete = method?.completeness === "complete";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const detailsId = useId();
|
||||
return (
|
||||
<section className="laboratory-summary" data-expanded={expanded ? "true" : undefined}>
|
||||
<header>
|
||||
<div className="laboratory-summary__heading">
|
||||
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<div className="laboratory-summary__actions">
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
<IconButton
|
||||
label={expanded ? "Свернуть подробности лабораторной работы" : "Раскрыть подробности лабораторной работы"}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="laboratory-summary__toggle-glyph" aria-hidden="true">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id={detailsId} className="laboratory-summary__details" hidden={!expanded}>
|
||||
<p className="laboratory-summary__description">{description}</p>
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<dl className="laboratory-summary__brief">
|
||||
<div>
|
||||
<dt>Задача</dt>
|
||||
<dd>{brief.question}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Как проверяли</dt>
|
||||
<dd>{brief.approach}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Главный результат</dt>
|
||||
<dd>{brief.principalResult}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ограничение</dt>
|
||||
<dd>{brief.limitation}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
</div>
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryEvidence({
|
||||
eyebrow,
|
||||
title,
|
||||
kind,
|
||||
resizable = false,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
kind: LaboratoryEvidenceKind;
|
||||
resizable?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="lab-result-surface"
|
||||
data-evidence-kind={kind}
|
||||
data-resizable={resizable ? "true" : undefined}
|
||||
>
|
||||
<header>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<strong>{title}</strong>
|
||||
</header>
|
||||
<div className="laboratory-evidence-frame">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryConclusion({
|
||||
proved,
|
||||
notProved,
|
||||
decision,
|
||||
}: {
|
||||
proved: string;
|
||||
notProved: string;
|
||||
decision: string;
|
||||
}) {
|
||||
return (
|
||||
<dl className="laboratory-result-conclusion">
|
||||
<div>
|
||||
<dt>Доказано</dt>
|
||||
<dd>{proved}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Не доказано</dt>
|
||||
<dd>{notProved}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Решение</dt>
|
||||
<dd>{decision}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryMetricGrid({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: readonly LaboratoryResultMetric[];
|
||||
}) {
|
||||
return (
|
||||
<div className="laboratory-result-metrics">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong>{metric.value}</strong>
|
||||
<small>{metric.hint}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryResultSummary({
|
||||
title,
|
||||
status,
|
||||
statusTone = "neutral",
|
||||
metrics,
|
||||
conclusion,
|
||||
}: {
|
||||
title: string;
|
||||
status: string;
|
||||
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
metrics: readonly LaboratoryResultMetric[];
|
||||
conclusion: LaboratoryResultConclusion;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
</header>
|
||||
<LaboratoryMetricGrid metrics={metrics} />
|
||||
<LaboratoryConclusion {...conclusion} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryWorkTemplate({
|
||||
summary,
|
||||
evidence,
|
||||
result = null,
|
||||
details = null,
|
||||
}: {
|
||||
summary: ReactNode;
|
||||
evidence: ReactNode;
|
||||
result?: ReactNode;
|
||||
details?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="laboratory-work-template"
|
||||
data-template-contract={LABORATORY_REPORT_TEMPLATE_VERSION}
|
||||
>
|
||||
{summary}
|
||||
{evidence}
|
||||
{result}
|
||||
{details}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { SplitPane } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
export const LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT =
|
||||
"missioncore.laboratory-recorded-clip-viewer/v1" as const;
|
||||
|
||||
export interface LaboratoryRecordedClipFrame {
|
||||
sequence: number;
|
||||
sourceTimeNs: number;
|
||||
}
|
||||
|
||||
export function nearestLaboratoryRecordedClipFrame(
|
||||
frames: readonly LaboratoryRecordedClipFrame[],
|
||||
sourceTimeNs: number,
|
||||
): LaboratoryRecordedClipFrame | null {
|
||||
if (!frames.length || !Number.isFinite(sourceTimeNs)) return null;
|
||||
let left = 0;
|
||||
let right = frames.length - 1;
|
||||
while (left < right) {
|
||||
const middle = Math.floor((left + right) / 2);
|
||||
if (frames[middle]!.sourceTimeNs < sourceTimeNs) left = middle + 1;
|
||||
else right = middle;
|
||||
}
|
||||
const next = frames[left]!;
|
||||
const previous = frames[Math.max(0, left - 1)]!;
|
||||
return Math.abs(previous.sourceTimeNs - sourceTimeNs)
|
||||
<= Math.abs(next.sourceTimeNs - sourceTimeNs)
|
||||
? previous
|
||||
: next;
|
||||
}
|
||||
|
||||
export function laboratoryRecordedClipEndExclusiveNs(
|
||||
frames: readonly LaboratoryRecordedClipFrame[],
|
||||
): number | null {
|
||||
const last = frames.at(-1);
|
||||
if (!last) return null;
|
||||
const deltas = frames.slice(1).flatMap((frame, index) => {
|
||||
const delta = frame.sourceTimeNs - frames[index]!.sourceTimeNs;
|
||||
return Number.isSafeInteger(delta) && delta > 0 ? [delta] : [];
|
||||
}).sort((left, right) => left - right);
|
||||
const typicalDelta = deltas.length
|
||||
? deltas[Math.floor(deltas.length / 2)]!
|
||||
: 100_000_000;
|
||||
return last.sourceTimeNs + typicalDelta;
|
||||
}
|
||||
|
||||
export function LaboratoryRecordedClipPlayer({
|
||||
source,
|
||||
segmentCount,
|
||||
frames,
|
||||
sequence,
|
||||
playing,
|
||||
playbackRate,
|
||||
cameraPresentation,
|
||||
continuousPlayback,
|
||||
sourceCount,
|
||||
cameraRef,
|
||||
cameraOverlay,
|
||||
alternativeScene,
|
||||
onSequenceChange,
|
||||
onPlayingChange,
|
||||
onPlaybackRateChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
segmentCount: number;
|
||||
frames: readonly LaboratoryRecordedClipFrame[];
|
||||
sequence: number;
|
||||
playing: boolean;
|
||||
playbackRate: number;
|
||||
cameraPresentation: "primary" | "companion" | "hidden";
|
||||
continuousPlayback: boolean;
|
||||
sourceCount: number;
|
||||
cameraRef?: RefObject<HTMLDivElement | null>;
|
||||
cameraOverlay?: ReactNode;
|
||||
alternativeScene?: ReactNode;
|
||||
onSequenceChange: (sequence: number) => void;
|
||||
onPlayingChange: (playing: boolean) => void;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
}) {
|
||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||
const lastEmittedSequenceRef = useRef(sequence);
|
||||
lastEmittedSequenceRef.current = sequence;
|
||||
const frame = useMemo(
|
||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||
[frames, sequence],
|
||||
);
|
||||
const endExclusiveNs = useMemo(
|
||||
() => laboratoryRecordedClipEndExclusiveNs(frames),
|
||||
[frames],
|
||||
);
|
||||
const playback = useMemo<RecordedObservationPlayback | null>(() => frame ? ({
|
||||
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
|
||||
playing: continuousPlayback && playing,
|
||||
rate: playbackRate,
|
||||
}) : null, [continuousPlayback, frame, playbackRate, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||
}, [continuousPlayback, onPlayingChange, playing]);
|
||||
|
||||
const emitSequence = useCallback((nextSequence: number) => {
|
||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||
lastEmittedSequenceRef.current = nextSequence;
|
||||
onSequenceChange(nextSequence);
|
||||
}, [onSequenceChange]);
|
||||
|
||||
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
||||
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
||||
const first = frames[0];
|
||||
if (!first || endExclusiveNs === null) return;
|
||||
if (sourceTimeNs >= endExclusiveNs) {
|
||||
emitSequence(first.sequence);
|
||||
return;
|
||||
}
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}, [emitSequence, endExclusiveNs, frames]);
|
||||
|
||||
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
||||
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||
const companionVisible = cameraPresentation === "companion";
|
||||
const spatialSize = companionVisible
|
||||
? companionSpatialSize
|
||||
: cameraPresentation === "primary" ? 0 : 100;
|
||||
const spatialPane = (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player__spatial"
|
||||
aria-hidden={cameraPresentation === "primary"}
|
||||
>
|
||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
||||
</div>
|
||||
);
|
||||
const cameraPane = (
|
||||
<div
|
||||
ref={cameraRef}
|
||||
className="laboratory-recorded-clip-player__camera"
|
||||
aria-hidden={cameraPresentation === "hidden"}
|
||||
>
|
||||
{playback ? (
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive={false}
|
||||
prepare
|
||||
segmentSequence={frame?.sequence}
|
||||
segmentCount={segmentCount}
|
||||
admissionKey={`${source.id}:${source.delivery?.id ?? "recorded"}`}
|
||||
onPlaybackChange={handlePlaybackChange}
|
||||
onPlayingRejected={() => onPlayingChange(false)}
|
||||
/>
|
||||
) : null}
|
||||
{cameraPresentation !== "hidden" ? cameraOverlay : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player"
|
||||
data-contract={LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT}
|
||||
data-camera-presentation={cameraPresentation}
|
||||
>
|
||||
<div className="laboratory-recorded-clip-player__stage">
|
||||
<SplitPane
|
||||
className="laboratory-recorded-clip-player__split"
|
||||
primary={spatialPane}
|
||||
secondary={cameraPane}
|
||||
primarySize={spatialSize}
|
||||
onPrimarySizeChange={setCompanionSpatialSize}
|
||||
orientation="vertical"
|
||||
minPrimarySize={companionVisible ? 24 : 0}
|
||||
minSecondarySize={companionVisible ? 24 : 0}
|
||||
resizable={companionVisible}
|
||||
separatorLabel="Изменить размер 3D/ПЛАН и правой камеры"
|
||||
/>
|
||||
</div>
|
||||
<ObservationTimeline
|
||||
className="laboratory-recorded-clip-player__timeline"
|
||||
active
|
||||
sourceCount={sourceCount}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="frame-accurate"
|
||||
rangeNs={{ min: timelineStart, max: Math.max(timelineEnd, timelineStart + 1) }}
|
||||
currentNs={frame?.sourceTimeNs ?? timelineStart}
|
||||
playing={continuousPlayback && playing}
|
||||
playbackRate={continuousPlayback ? playbackRate : undefined}
|
||||
onPlaybackRateChange={continuousPlayback ? onPlaybackRateChange : undefined}
|
||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||
onSeek={(timeNs) => {
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const FOCUSABLE = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
function keepFocusInside(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
frame: HTMLElement,
|
||||
): void {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(frame.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
frame.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function LaboratoryReviewWorkspaceFrame({
|
||||
ariaLabel,
|
||||
toolbar,
|
||||
stage,
|
||||
inspector,
|
||||
overlays,
|
||||
interactionEnabled = true,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
toolbar: ReactNode;
|
||||
stage: ReactNode;
|
||||
inspector?: ReactNode;
|
||||
overlays?: ReactNode;
|
||||
interactionEnabled?: boolean;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const frameRef = useRef<HTMLElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
const firstFocusable = frameRef.current?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(firstFocusable ?? frameRef.current)?.focus();
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
const target = returnFocusTarget?.isConnected ? returnFocusTarget : previousFocus;
|
||||
window.requestAnimationFrame(() => target?.focus());
|
||||
};
|
||||
}, [returnFocusTarget]);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
ref={frameRef}
|
||||
className="laboratory-review-workspace"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
data-has-inspector={inspector ? "true" : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (!interactionEnabled) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
keepFocusInside(event, event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<header className="laboratory-review-workspace__toolbar">{toolbar}</header>
|
||||
<div className="laboratory-review-workspace__stage">{stage}</div>
|
||||
{inspector ? <footer className="laboratory-review-workspace__inspector">{inspector}</footer> : null}
|
||||
{overlays}
|
||||
</section>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type RecordedEvidenceBoxTone =
|
||||
| "accent"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "neutral";
|
||||
|
||||
export interface RecordedEvidenceBox {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
label: string;
|
||||
tone: RecordedEvidenceBoxTone;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function rgba(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
|
||||
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
|
||||
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceBoxOverlay({
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
ariaLabel,
|
||||
}: {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!host || !canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of boxes) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = toneColor(host, item.tone);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.dashed ? [5, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [boxes, imageHeight, imageWidth]);
|
||||
|
||||
return <canvas ref={canvasRef} role="img" aria-label={ariaLabel} />;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
RecordedEvidenceSemanticMaskOverlay,
|
||||
type RecordedEvidenceSemanticOverlay,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
import {
|
||||
RecordedEvidencePointCloudOverlay,
|
||||
type RecordedEvidencePointCloudOverlayData,
|
||||
} from "./RecordedEvidencePointCloudOverlay";
|
||||
|
||||
export function RecordedEvidenceImageScene({
|
||||
src,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
semanticOverlay,
|
||||
pointCloudOverlay,
|
||||
ariaLabel,
|
||||
}: {
|
||||
src: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => setState("loading"), [src]);
|
||||
|
||||
return (
|
||||
<div className="recorded-evidence-image-scene">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={() => setState("ready")}
|
||||
onError={() => setState("error")}
|
||||
/>
|
||||
{state === "ready" ? (
|
||||
<>
|
||||
{semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
{pointCloudOverlay ? (
|
||||
<RecordedEvidencePointCloudOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
overlay={pointCloudOverlay}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
{state === "loading" ? (
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="alert" size={18} />
|
||||
)}
|
||||
<span>
|
||||
{state === "loading"
|
||||
? "Декодируем один точный CAMERA-кадр"
|
||||
: "Точный CAMERA-кадр недоступен."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type RecordedEvidenceProjectedPoint = readonly [number, number, number];
|
||||
|
||||
export interface RecordedEvidencePointCloudOverlayData {
|
||||
pointsXyd: readonly RecordedEvidenceProjectedPoint[];
|
||||
sourcePointCount: number;
|
||||
projectedPointCount: number;
|
||||
projection:
|
||||
| "factory-kb4-exact"
|
||||
| "factory-kb4-causal-registered-accumulation";
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
const DEPTH_BUCKETS = 64;
|
||||
|
||||
function depthBucket(depthM: number): number {
|
||||
const normalized = Math.max(0, Math.min(1, (depthM - 0.5) / 24));
|
||||
return Math.round(normalized * (DEPTH_BUCKETS - 1));
|
||||
}
|
||||
|
||||
function depthColor(bucket: number): string {
|
||||
const hue = 18 + bucket / (DEPTH_BUCKETS - 1) * 190;
|
||||
return `hsla(${hue}, 96%, 62%, 0.86)`;
|
||||
}
|
||||
|
||||
export function RecordedEvidencePointCloudOverlay({
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
overlay,
|
||||
}: {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
overlay: RecordedEvidencePointCloudOverlayData;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !host) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const offsetX = (width - imageWidth * scale) / 2;
|
||||
const offsetY = (height - imageHeight * scale) / 2;
|
||||
const radius = Math.max(0.8, Math.min(2.2, scale * 1.45));
|
||||
const buckets = Array.from(
|
||||
{ length: DEPTH_BUCKETS },
|
||||
(): [number, number][] => [],
|
||||
);
|
||||
for (const [imageX, imageY, depthM] of overlay.pointsXyd) {
|
||||
const bucket = depthBucket(depthM);
|
||||
buckets[bucket]!.push([
|
||||
offsetX + imageX * scale,
|
||||
offsetY + imageY * scale,
|
||||
]);
|
||||
}
|
||||
for (let bucket = 0; bucket < buckets.length; bucket += 1) {
|
||||
const points = buckets[bucket];
|
||||
if (!points?.length) continue;
|
||||
context.beginPath();
|
||||
for (const [x, y] of points) {
|
||||
context.moveTo(x + radius, y);
|
||||
context.arc(x, y, radius, 0, Math.PI * 2);
|
||||
}
|
||||
context.fillStyle = depthColor(bucket);
|
||||
context.fill();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [imageHeight, imageWidth, overlay]);
|
||||
|
||||
return <canvas ref={canvasRef} role="img" aria-label={overlay.ariaLabel} />;
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface RecordedEvidenceSemanticClass {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type RecordedEvidenceSemanticToken =
|
||||
| "--nodedc-accent-rgb"
|
||||
| "--nodedc-danger-rgb"
|
||||
| "--nodedc-foreground-rgb"
|
||||
| "--nodedc-success-rgb"
|
||||
| "--nodedc-text-muted"
|
||||
| "--nodedc-warning-rgb";
|
||||
|
||||
export type RecordedEvidenceSemanticColor =
|
||||
| {
|
||||
kind: "token";
|
||||
token: RecordedEvidenceSemanticToken;
|
||||
}
|
||||
| {
|
||||
kind: "diagnostic";
|
||||
rgb: readonly [number, number, number];
|
||||
}
|
||||
| {
|
||||
kind: "transparent";
|
||||
};
|
||||
|
||||
export interface RecordedEvidenceSemanticPaletteEntry {
|
||||
classId: number;
|
||||
color: RecordedEvidenceSemanticColor;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface RecordedEvidenceSemanticOverlay {
|
||||
src: string;
|
||||
prefetchSrcs?: readonly string[];
|
||||
classes: readonly RecordedEvidenceSemanticClass[];
|
||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||
opacity?: number;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
interface DecodedSemanticMask {
|
||||
key: string;
|
||||
width: number;
|
||||
height: number;
|
||||
source: ImageBitmap | HTMLImageElement;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
interface PendingSemanticMask {
|
||||
controller: AbortController;
|
||||
subscribers: number;
|
||||
promise: Promise<DecodedSemanticMask>;
|
||||
}
|
||||
|
||||
const MASK_CACHE_LIMIT = 20;
|
||||
const MASK_PREFETCH_LIMIT = 12;
|
||||
const decodedMaskCache = new Map<string, DecodedSemanticMask>();
|
||||
const pendingMaskCache = new Map<string, PendingSemanticMask>();
|
||||
|
||||
const TOKEN_FALLBACKS: Record<RecordedEvidenceSemanticToken, readonly [number, number, number]> = {
|
||||
"--nodedc-accent-rgb": [232, 56, 126],
|
||||
"--nodedc-danger-rgb": [255, 104, 112],
|
||||
"--nodedc-foreground-rgb": [245, 245, 245],
|
||||
"--nodedc-success-rgb": [181, 255, 90],
|
||||
"--nodedc-text-muted": [147, 151, 159],
|
||||
"--nodedc-warning-rgb": [255, 197, 92],
|
||||
};
|
||||
|
||||
function clampChannel(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function clampOpacity(value: number | undefined, fallback: number): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(value) ? Number(value) : fallback));
|
||||
}
|
||||
|
||||
function semanticMaskKey(src: string, width: number, height: number): string {
|
||||
return `${width}x${height}:${src}`;
|
||||
}
|
||||
|
||||
function rememberMask(mask: DecodedSemanticMask): void {
|
||||
const previous = decodedMaskCache.get(mask.key);
|
||||
if (previous && previous !== mask) previous.release();
|
||||
decodedMaskCache.delete(mask.key);
|
||||
decodedMaskCache.set(mask.key, mask);
|
||||
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
|
||||
const oldest = decodedMaskCache.keys().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
const evicted = decodedMaskCache.get(oldest);
|
||||
decodedMaskCache.delete(oldest);
|
||||
evicted?.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function imageSourceFromBlob(
|
||||
blob: Blob,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ source: ImageBitmap | HTMLImageElement; width: number; height: number; release: () => void }> {
|
||||
if (typeof createImageBitmap === "function") {
|
||||
const bitmap = await createImageBitmap(blob, {
|
||||
colorSpaceConversion: "none",
|
||||
premultiplyAlpha: "none",
|
||||
});
|
||||
if (signal.aborted) {
|
||||
bitmap.close();
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
return {
|
||||
source: bitmap,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
release: () => bitmap.close(),
|
||||
};
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
image.removeEventListener("load", onLoad);
|
||||
image.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onLoad = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("Semantic mask image decode failed"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
image.addEventListener("load", onLoad, { once: true });
|
||||
image.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
image.src = objectUrl;
|
||||
});
|
||||
return {
|
||||
source: image,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
release: () => URL.revokeObjectURL(objectUrl),
|
||||
};
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeSemanticMask(
|
||||
key: string,
|
||||
src: string,
|
||||
expectedWidth: number,
|
||||
expectedHeight: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<DecodedSemanticMask> {
|
||||
const response = await fetch(src, { cache: "force-cache", signal });
|
||||
if (!response.ok) throw new Error(`Semantic mask request failed: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const decoded = await imageSourceFromBlob(blob, signal);
|
||||
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
|
||||
decoded.release();
|
||||
throw new Error(
|
||||
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
source: decoded.source,
|
||||
release: decoded.release,
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeToSemanticMask(
|
||||
src: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): { promise: Promise<DecodedSemanticMask>; release: () => void } {
|
||||
const key = semanticMaskKey(src, width, height);
|
||||
const cached = decodedMaskCache.get(key);
|
||||
if (cached) {
|
||||
decodedMaskCache.delete(key);
|
||||
decodedMaskCache.set(key, cached);
|
||||
return { promise: Promise.resolve(cached), release: () => undefined };
|
||||
}
|
||||
|
||||
let pending = pendingMaskCache.get(key);
|
||||
if (!pending) {
|
||||
const controller = new AbortController();
|
||||
let next: PendingSemanticMask;
|
||||
const promise = decodeSemanticMask(key, src, width, height, controller.signal)
|
||||
.then((mask) => {
|
||||
rememberMask(mask);
|
||||
return mask;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingMaskCache.get(key) === next) pendingMaskCache.delete(key);
|
||||
});
|
||||
next = { controller, subscribers: 0, promise };
|
||||
pendingMaskCache.set(key, next);
|
||||
pending = next;
|
||||
}
|
||||
pending.subscribers += 1;
|
||||
let released = false;
|
||||
return {
|
||||
promise: pending.promise,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
pending!.subscribers -= 1;
|
||||
if (pending!.subscribers > 0 || decodedMaskCache.has(key)) return;
|
||||
pending!.controller.abort();
|
||||
if (pendingMaskCache.get(key) === pending) pendingMaskCache.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordedEvidenceSemanticCssColor(
|
||||
color: RecordedEvidenceSemanticColor,
|
||||
): string {
|
||||
if (color.kind === "transparent") return "transparent";
|
||||
if (color.kind === "token") return `rgb(var(${color.token}))`;
|
||||
const [red, green, blue] = color.rgb.map(clampChannel);
|
||||
return `rgb(${red} ${green} ${blue})`;
|
||||
}
|
||||
|
||||
export function resolveRecordedEvidenceSemanticRgb(
|
||||
host: HTMLElement,
|
||||
color: RecordedEvidenceSemanticColor,
|
||||
): readonly [number, number, number] | null {
|
||||
if (color.kind === "transparent") return null;
|
||||
if (color.kind === "diagnostic") return color.rgb.map(clampChannel) as [number, number, number];
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(color.token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
return channels?.length === 3
|
||||
? channels.map(clampChannel) as [number, number, number]
|
||||
: TOKEN_FALLBACKS[color.token];
|
||||
}
|
||||
|
||||
interface SemanticMaskRenderer {
|
||||
clear: (width: number, height: number, pixelRatio: number) => void;
|
||||
render: (
|
||||
mask: DecodedSemanticMask,
|
||||
palette: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pixelRatio: number,
|
||||
) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function compileSemanticShader(
|
||||
gl: WebGLRenderingContext,
|
||||
type: number,
|
||||
source: string,
|
||||
): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
function createSemanticMaskRenderer(canvas: HTMLCanvasElement): SemanticMaskRenderer | null {
|
||||
const gl = canvas.getContext("webgl", {
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
depth: false,
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
if (!gl) return null;
|
||||
const vertexShader = compileSemanticShader(gl, gl.VERTEX_SHADER, `
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_uv;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
v_uv = a_uv;
|
||||
}
|
||||
`);
|
||||
const fragmentShader = compileSemanticShader(gl, gl.FRAGMENT_SHADER, `
|
||||
precision mediump float;
|
||||
uniform sampler2D u_mask;
|
||||
uniform sampler2D u_palette;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
// DOM image sources use a top-left origin while WebGL texture coordinates
|
||||
// use a bottom-left origin. UNPACK_FLIP_Y_WEBGL is ignored for
|
||||
// ImageBitmap uploads, so keep upload semantics stable and flip V here.
|
||||
vec2 mask_uv = vec2(v_uv.x, 1.0 - v_uv.y);
|
||||
float class_id = floor(texture2D(u_mask, mask_uv).r * 255.0 + 0.5);
|
||||
gl_FragColor = texture2D(u_palette, vec2((class_id + 0.5) / 256.0, 0.5));
|
||||
}
|
||||
`);
|
||||
if (!vertexShader || !fragmentShader) {
|
||||
if (vertexShader) gl.deleteShader(vertexShader);
|
||||
if (fragmentShader) gl.deleteShader(fragmentShader);
|
||||
return null;
|
||||
}
|
||||
const program = gl.createProgram();
|
||||
if (!program) {
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
return null;
|
||||
}
|
||||
gl.attachShader(program, vertexShader);
|
||||
gl.attachShader(program, fragmentShader);
|
||||
gl.linkProgram(program);
|
||||
gl.deleteShader(vertexShader);
|
||||
gl.deleteShader(fragmentShader);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
const buffer = gl.createBuffer();
|
||||
const maskTexture = gl.createTexture();
|
||||
const paletteTexture = gl.createTexture();
|
||||
if (!buffer || !maskTexture || !paletteTexture) {
|
||||
if (buffer) gl.deleteBuffer(buffer);
|
||||
if (maskTexture) gl.deleteTexture(maskTexture);
|
||||
if (paletteTexture) gl.deleteTexture(paletteTexture);
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
const positionLocation = gl.getAttribLocation(program, "a_position");
|
||||
const uvLocation = gl.getAttribLocation(program, "a_uv");
|
||||
const maskLocation = gl.getUniformLocation(program, "u_mask");
|
||||
const paletteLocation = gl.getUniformLocation(program, "u_palette");
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 0, 0,
|
||||
1, -1, 1, 0,
|
||||
-1, 1, 0, 1,
|
||||
1, 1, 1, 1,
|
||||
]), gl.STATIC_DRAW);
|
||||
|
||||
const resize = (width: number, height: number, pixelRatio: number) => {
|
||||
const pixelWidth = Math.max(1, Math.round(width * pixelRatio));
|
||||
const pixelHeight = Math.max(1, Math.round(height * pixelRatio));
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const clear = (width: number, height: number, pixelRatio: number) => {
|
||||
resize(width, height, pixelRatio);
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
};
|
||||
|
||||
return {
|
||||
clear,
|
||||
render: (mask, palette, width, height, pixelRatio) => {
|
||||
if (gl.isContextLost()) throw new Error("Semantic WebGL context lost");
|
||||
clear(width, height, pixelRatio);
|
||||
gl.useProgram(program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 16, 0);
|
||||
gl.enableVertexAttribArray(uvLocation);
|
||||
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 16, 8);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, maskTexture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, mask.source);
|
||||
gl.uniform1i(maskLocation, 0);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, palette);
|
||||
gl.uniform1i(paletteLocation, 1);
|
||||
|
||||
const scale = Math.min(width / mask.width, height / mask.height);
|
||||
const drawWidth = mask.width * scale;
|
||||
const drawHeight = mask.height * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
gl.viewport(
|
||||
Math.round(offsetX * pixelRatio),
|
||||
Math.round(offsetY * pixelRatio),
|
||||
Math.max(1, Math.round(drawWidth * pixelRatio)),
|
||||
Math.max(1, Math.round(drawHeight * pixelRatio)),
|
||||
);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
},
|
||||
dispose: () => {
|
||||
gl.deleteTexture(maskTexture);
|
||||
gl.deleteTexture(paletteTexture);
|
||||
gl.deleteBuffer(buffer);
|
||||
gl.deleteProgram(program);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function semanticPalettePixels(
|
||||
host: HTMLElement,
|
||||
classes: readonly RecordedEvidenceSemanticClass[],
|
||||
palette: readonly RecordedEvidenceSemanticPaletteEntry[],
|
||||
opacity: number,
|
||||
): Uint8Array {
|
||||
const pixels = new Uint8Array(256 * 4);
|
||||
const declaredClassIds = new Set(
|
||||
classes
|
||||
.map((item) => item.id)
|
||||
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
|
||||
);
|
||||
for (const entry of palette) {
|
||||
if (!declaredClassIds.has(entry.classId)) continue;
|
||||
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||
if (!rgb) continue;
|
||||
const offset = entry.classId * 4;
|
||||
pixels[offset] = rgb[0];
|
||||
pixels[offset + 1] = rgb[1];
|
||||
pixels[offset + 2] = rgb[2];
|
||||
pixels[offset + 3] = Math.round(
|
||||
clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46) * 255,
|
||||
);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
function renderSemanticMask2d(
|
||||
canvas: HTMLCanvasElement,
|
||||
mask: DecodedSemanticMask,
|
||||
palette: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
pixelRatio: number,
|
||||
) {
|
||||
canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
||||
canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const colorCanvas = document.createElement("canvas");
|
||||
colorCanvas.width = mask.width;
|
||||
colorCanvas.height = mask.height;
|
||||
const colorContext = colorCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!colorContext) return;
|
||||
colorContext.drawImage(mask.source, 0, 0);
|
||||
const imageData = colorContext.getImageData(0, 0, mask.width, mask.height);
|
||||
for (let offset = 0; offset < imageData.data.length; offset += 4) {
|
||||
const paletteOffset = (imageData.data[offset] ?? 0) * 4;
|
||||
imageData.data[offset] = palette[paletteOffset] ?? 0;
|
||||
imageData.data[offset + 1] = palette[paletteOffset + 1] ?? 0;
|
||||
imageData.data[offset + 2] = palette[paletteOffset + 2] ?? 0;
|
||||
imageData.data[offset + 3] = palette[paletteOffset + 3] ?? 0;
|
||||
}
|
||||
colorContext.putImageData(imageData, 0, 0);
|
||||
const scale = Math.min(width / mask.width, height / mask.height);
|
||||
const drawWidth = mask.width * scale;
|
||||
const drawHeight = mask.height * scale;
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.drawImage(
|
||||
colorCanvas,
|
||||
(width - drawWidth) / 2,
|
||||
(height - drawHeight) / 2,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceSemanticMaskOverlay({
|
||||
src,
|
||||
prefetchSrcs = [],
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
classes,
|
||||
palette,
|
||||
opacity = 0.46,
|
||||
ariaLabel,
|
||||
}: RecordedEvidenceSemanticOverlay & {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rendererRef = useRef<SemanticMaskRenderer | null | undefined>(undefined);
|
||||
const [rendererMode, setRendererMode] = useState<"webgl" | "2d">("webgl");
|
||||
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||
const prefetchSignature = prefetchSrcs.join("\n");
|
||||
const renderMask = failure
|
||||
? null
|
||||
: mask?.key === expectedKey
|
||||
? mask
|
||||
: decodedMaskCache.get(expectedKey) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setFailure(null);
|
||||
const subscription = subscribeToSemanticMask(src, imageWidth, imageHeight);
|
||||
let current = true;
|
||||
void subscription.promise.then((decoded) => {
|
||||
if (!current || decoded.key !== expectedKey) return;
|
||||
setMask(decoded);
|
||||
}).catch((error: unknown) => {
|
||||
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||
setFailure("Semantic mask не прошла проверку или декодирование.");
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
subscription.release();
|
||||
};
|
||||
}, [expectedKey, imageHeight, imageWidth, src]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = prefetchSignature
|
||||
.split("\n")
|
||||
.filter((candidate) => candidate && candidate !== src)
|
||||
.slice(0, MASK_PREFETCH_LIMIT)
|
||||
.map((candidate) => subscribeToSemanticMask(candidate, imageWidth, imageHeight));
|
||||
for (const subscription of subscriptions) {
|
||||
void subscription.promise
|
||||
.catch(() => undefined)
|
||||
.finally(subscription.release);
|
||||
}
|
||||
}, [imageHeight, imageWidth, prefetchSignature, src]);
|
||||
|
||||
useEffect(() => () => {
|
||||
rendererRef.current?.dispose();
|
||||
rendererRef.current = undefined;
|
||||
}, [rendererMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || rendererMode !== "webgl") return;
|
||||
const onContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setRendererMode("2d");
|
||||
};
|
||||
canvas.addEventListener("webglcontextlost", onContextLost);
|
||||
return () => canvas.removeEventListener("webglcontextlost", onContextLost);
|
||||
}, [rendererMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !host) return;
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
if (rendererMode === "webgl" && rendererRef.current === undefined) {
|
||||
rendererRef.current = createSemanticMaskRenderer(canvas);
|
||||
if (!rendererRef.current) {
|
||||
setRendererMode("2d");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const renderer = rendererMode === "webgl" ? rendererRef.current : null;
|
||||
if (!renderMask) {
|
||||
if (renderer) {
|
||||
renderer.clear(width, height, pixelRatio);
|
||||
} else {
|
||||
canvas.width = Math.max(1, Math.round(width * pixelRatio));
|
||||
canvas.height = Math.max(1, Math.round(height * pixelRatio));
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resolvedPalette = semanticPalettePixels(host, classes, palette, opacity);
|
||||
if (renderer) {
|
||||
try {
|
||||
renderer.render(renderMask, resolvedPalette, width, height, pixelRatio);
|
||||
} catch {
|
||||
setRendererMode("2d");
|
||||
}
|
||||
} else {
|
||||
renderSemanticMask2d(
|
||||
canvas,
|
||||
renderMask,
|
||||
resolvedPalette,
|
||||
width,
|
||||
height,
|
||||
pixelRatio,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, rendererMode, renderMask]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
key={rendererMode}
|
||||
ref={canvasRef}
|
||||
className="recorded-evidence-semantic-mask-overlay"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={!failure && !renderMask}
|
||||
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||
data-renderer={rendererMode}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{failure ? (
|
||||
<div className="recorded-evidence-semantic-mask-overlay__error" role="alert">
|
||||
{failure}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
type RecordedEvidenceBoxTone,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
RecordedEvidenceSemanticMaskOverlay,
|
||||
type RecordedEvidenceSemanticOverlay,
|
||||
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||
import {
|
||||
RecordedEvidencePointCloudOverlay,
|
||||
type RecordedEvidencePointCloudOverlayData,
|
||||
} from "./RecordedEvidencePointCloudOverlay";
|
||||
|
||||
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
|
||||
|
||||
export function RecordedEvidenceVideoScene({
|
||||
source,
|
||||
playback,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
semanticOverlay,
|
||||
pointCloudOverlay,
|
||||
ariaLabel,
|
||||
interactive = true,
|
||||
segmentSequence,
|
||||
segmentCount,
|
||||
onPlaybackChange,
|
||||
onPlayingRejected,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback: RecordedObservationPlayback;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
||||
ariaLabel: string;
|
||||
interactive?: boolean;
|
||||
segmentSequence?: number;
|
||||
segmentCount?: number;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
onPlayingRejected?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="recorded-evidence-video-scene">
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive={interactive}
|
||||
prepare
|
||||
segmentSequence={segmentSequence}
|
||||
segmentCount={segmentCount}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlayingRejected={onPlayingRejected}
|
||||
/>
|
||||
{semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
{pointCloudOverlay ? (
|
||||
<RecordedEvidencePointCloudOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
overlay={pointCloudOverlay}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
LaboratoryAnnotationAction,
|
||||
LaboratoryViewAction,
|
||||
} from "../../workspaces/contracts";
|
||||
|
||||
export function useLaboratoryAnnotationHeader(): {
|
||||
control: ReactNode;
|
||||
setAction: (action: LaboratoryAnnotationAction | null) => void;
|
||||
setViewAction: (action: LaboratoryViewAction | null) => void;
|
||||
} {
|
||||
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
|
||||
const [viewAction, setViewAction] = useState<LaboratoryViewAction | null>(null);
|
||||
return {
|
||||
setAction,
|
||||
setViewAction,
|
||||
control: action || viewAction ? (
|
||||
<div className="laboratory-header-tools">
|
||||
{viewAction ? (
|
||||
<Button shape="pill" variant="secondary" onClick={viewAction.onClick}>
|
||||
{viewAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{action ? (
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant="accent"
|
||||
icon={<Icon name="edit" size={16} />}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { RecordedObservationPlayback } from "../RecordedFmp4Player";
|
||||
|
||||
export interface RecordedEvidencePlaybackRange {
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
}
|
||||
|
||||
function validRange(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
): range is RecordedEvidencePlaybackRange {
|
||||
return Boolean(
|
||||
range
|
||||
&& Number.isFinite(range.startSeconds)
|
||||
&& Number.isFinite(range.endSeconds)
|
||||
&& range.endSeconds > range.startSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
export function clampRecordedEvidenceSeconds(
|
||||
seconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): number {
|
||||
return Math.min(range.endSeconds, Math.max(range.startSeconds, seconds));
|
||||
}
|
||||
|
||||
export function advanceRecordedEvidencePlayback(
|
||||
playback: RecordedObservationPlayback,
|
||||
elapsedSeconds: number,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): RecordedObservationPlayback {
|
||||
if (!playback.playing || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
|
||||
return playback;
|
||||
}
|
||||
const next = playback.currentSeconds + elapsedSeconds * (playback.rate ?? 1);
|
||||
if (next >= range.endSeconds) {
|
||||
return { ...playback, currentSeconds: range.endSeconds, playing: false };
|
||||
}
|
||||
return { ...playback, currentSeconds: clampRecordedEvidenceSeconds(next, range) };
|
||||
}
|
||||
|
||||
export function synchronizeRecordedEvidencePlayback(
|
||||
current: RecordedObservationPlayback,
|
||||
observed: RecordedObservationPlayback,
|
||||
range: RecordedEvidencePlaybackRange,
|
||||
): RecordedObservationPlayback {
|
||||
if (!current.playing) {
|
||||
// A paused transport also covers every explicit operator seek. Native
|
||||
// callbacks from the previously displayed frame remain stale until the
|
||||
// controlled video effect has applied that target, so they cannot move the
|
||||
// shared LAB clock at all while the operator state is paused.
|
||||
return current;
|
||||
}
|
||||
const currentSeconds = clampRecordedEvidenceSeconds(observed.currentSeconds, range);
|
||||
return {
|
||||
...current,
|
||||
currentSeconds,
|
||||
// The transport button owns play/pause intent. A native frame callback may
|
||||
// still report the previous media state immediately after an operator
|
||||
// command, so it must not restart playback. The sealed end is the only
|
||||
// media-clock transition allowed to stop the shared LAB clock.
|
||||
playing: currentSeconds >= range.endSeconds ? false : current.playing,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRecordedEvidencePlayback(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
{ clock = "animation" }: { clock?: "animation" | "external" } = {},
|
||||
) {
|
||||
const [playback, setPlayback] = useState<RecordedObservationPlayback>({
|
||||
currentSeconds: 0,
|
||||
playing: false,
|
||||
rate: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: current.currentSeconds === 0
|
||||
? range.startSeconds
|
||||
: clampRecordedEvidenceSeconds(current.currentSeconds, range),
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validRange(range) || !playback.playing || clock === "external") return;
|
||||
let animationFrame = 0;
|
||||
let previous = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const elapsed = Math.max(0, now - previous);
|
||||
if (elapsed >= 32) {
|
||||
previous = now;
|
||||
setPlayback((current) => {
|
||||
if (!current.playing) return current;
|
||||
return advanceRecordedEvidencePlayback(current, elapsed / 1_000, range);
|
||||
});
|
||||
}
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
}, [clock, playback.playing, range]);
|
||||
|
||||
const seek = useCallback((seconds: number, pause = true) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: clampRecordedEvidenceSeconds(seconds, range),
|
||||
playing: pause ? false : current.playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setPlaying = useCallback((playing: boolean) => {
|
||||
if (!validRange(range)) return;
|
||||
setPlayback((current) => ({
|
||||
...current,
|
||||
currentSeconds: playing && current.currentSeconds >= range.endSeconds
|
||||
? range.startSeconds
|
||||
: current.currentSeconds,
|
||||
playing,
|
||||
}));
|
||||
}, [range]);
|
||||
|
||||
const setRate = useCallback((rate: number) => {
|
||||
if (![0.5, 1, 2].includes(rate)) return;
|
||||
setPlayback((current) => ({ ...current, rate }));
|
||||
}, []);
|
||||
|
||||
const synchronize = useCallback((next: RecordedObservationPlayback) => {
|
||||
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
|
||||
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
|
||||
}, [range]);
|
||||
|
||||
return useMemo(() => ({
|
||||
playback,
|
||||
seek,
|
||||
setPlaying,
|
||||
setRate,
|
||||
synchronize,
|
||||
}), [playback, seek, setPlaying, setRate, synchronize]);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
InspectorSelectField,
|
||||
RangeControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
MapGhostPinLabelMode,
|
||||
MapViewDocument,
|
||||
} from "../../core/map/mapView";
|
||||
|
||||
export function GhostPinSettingsPanel({
|
||||
draft,
|
||||
updateDraft,
|
||||
onGenerate,
|
||||
onClear,
|
||||
}: {
|
||||
draft: MapViewDocument;
|
||||
updateDraft: (
|
||||
update: (current: MapViewDocument) => MapViewDocument,
|
||||
) => void;
|
||||
onGenerate: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const configuration = draft.view.ghostPins;
|
||||
const profile = configuration.presentation;
|
||||
return (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<ControlRow label="Контур">
|
||||
<StatusBadge tone="warning">Временная симуляция</StatusBadge>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Количество пинов"
|
||||
value={configuration.count}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}`}
|
||||
onChange={(count) => updateDraft((current) => {
|
||||
current.view.ghostPins.count = count;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Радиус от центра view"
|
||||
value={configuration.radiusMeters}
|
||||
min={100}
|
||||
max={100_000}
|
||||
step={100}
|
||||
formatValue={(value) => value >= 1_000
|
||||
? `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)} км`
|
||||
: `${value} м`}
|
||||
onChange={(radiusMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.radiusMeters = radiusMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Максимальная скорость"
|
||||
value={configuration.maximumSpeedMetersPerSecond}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} м/с`}
|
||||
onChange={(maximumSpeedMetersPerSecond) => updateDraft((current) => {
|
||||
current.view.ghostPins.maximumSpeedMetersPerSecond =
|
||||
maximumSpeedMetersPerSecond;
|
||||
current.view.ghostPins.positions =
|
||||
current.view.ghostPins.positions.map((pin) => ({
|
||||
...pin,
|
||||
speedMetersPerSecond: Math.min(
|
||||
pin.speedMetersPerSecond,
|
||||
maximumSpeedMetersPerSecond,
|
||||
),
|
||||
}));
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Симуляция движения"
|
||||
checked={configuration.simulationEnabled}
|
||||
onChange={(simulationEnabled) => updateDraft((current) => {
|
||||
current.view.ghostPins.simulationEnabled = simulationEnabled;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Позиции">
|
||||
<StatusBadge tone={configuration.positions.length ? "success" : "neutral"}>
|
||||
{configuration.positions.length
|
||||
? `${configuration.positions.length} на карте`
|
||||
: "Не созданы"}
|
||||
</StatusBadge>
|
||||
</ControlRow>
|
||||
<Button variant="secondary" size="compact" onClick={onGenerate}>
|
||||
Разместить в текущем view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
disabled={configuration.positions.length === 0}
|
||||
onClick={onClear}
|
||||
>
|
||||
Очистить позиции на карте
|
||||
</Button>
|
||||
|
||||
<Checker
|
||||
label="Показывать Ghost Pin"
|
||||
checked={draft.view.layerVisibility.targets}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.targets = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет пина">
|
||||
<ColorField
|
||||
label="Цвет Ghost Pin"
|
||||
value={profile.color}
|
||||
onChange={(color) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.color = color;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность пина"
|
||||
value={Math.round(profile.opacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.opacity = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Высота таргета"
|
||||
value={profile.stemHeightMeters}
|
||||
min={100}
|
||||
max={10_000}
|
||||
step={50}
|
||||
formatValue={(value) => `${value} м`}
|
||||
onChange={(stemHeightMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.stemHeightMeters = stemHeightMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Размер головки"
|
||||
value={profile.headSizePx}
|
||||
min={1}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(headSizePx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.headSizePx = headSizePx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Толщина стержня"
|
||||
value={profile.stemWidthPx}
|
||||
min={0.25}
|
||||
max={12}
|
||||
step={0.25}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(stemWidthPx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.stemWidthPx = stemWidthPx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет обводки">
|
||||
<ColorField
|
||||
label="Цвет обводки головки"
|
||||
value={profile.outlineColor}
|
||||
onChange={(outlineColor) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.outlineColor = outlineColor;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность обводки"
|
||||
value={Math.round(profile.outlineOpacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.outlineOpacity = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Толщина обводки"
|
||||
value={profile.outlineWidthPx}
|
||||
min={0}
|
||||
max={8}
|
||||
step={0.5}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(outlineWidthPx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.outlineWidthPx = outlineWidthPx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<InspectorSelectField<MapGhostPinLabelMode>
|
||||
label="Подпись"
|
||||
value={profile.labelMode}
|
||||
options={[
|
||||
{
|
||||
value: "attributes",
|
||||
label: "Имя",
|
||||
description: "Случайное числовое имя Ghost Pin",
|
||||
},
|
||||
{
|
||||
value: "subject_id",
|
||||
label: "ID",
|
||||
description: "Стабильный синтетический идентификатор",
|
||||
},
|
||||
{
|
||||
value: "none",
|
||||
label: "Нет",
|
||||
description: "Не показывать плашку",
|
||||
},
|
||||
]}
|
||||
onChange={(labelMode) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelMode = labelMode;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Насыщенность шрифта"
|
||||
value={profile.labelFontWeight}
|
||||
min={400}
|
||||
max={700}
|
||||
step={100}
|
||||
formatValue={(value) => `${value}`}
|
||||
onChange={(labelFontWeight) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelFontWeight = labelFontWeight;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Размер подписи"
|
||||
value={profile.labelSizePx}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelSizePx) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelSizePx = labelSizePx;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет подписи">
|
||||
<ColorField
|
||||
label="Цвет текста подписи"
|
||||
value={profile.labelColor}
|
||||
onChange={(labelColor) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelColor = labelColor;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Фон плашки">
|
||||
<ColorField
|
||||
label="Цвет фона подписи"
|
||||
value={profile.labelBackgroundColor}
|
||||
onChange={(labelBackgroundColor) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelBackgroundColor =
|
||||
labelBackgroundColor;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность плашки"
|
||||
value={Math.round(profile.labelBackgroundOpacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelBackgroundOpacity =
|
||||
value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Отступ плашки X"
|
||||
value={profile.labelPaddingX}
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelPaddingX) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelPaddingX = labelPaddingX;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Отступ плашки Y"
|
||||
value={profile.labelPaddingY}
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelPaddingY) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelPaddingY = labelPaddingY;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Смещение подписи X"
|
||||
value={profile.labelOffsetX}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelOffsetX) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelOffsetX = labelOffsetX;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Смещение подписи Y"
|
||||
value={profile.labelOffsetY}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(labelOffsetY) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelOffsetY = labelOffsetY;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Максимальная длина подписи"
|
||||
value={profile.labelMaxLength}
|
||||
min={1}
|
||||
max={64}
|
||||
step={1}
|
||||
formatValue={(value) => `${value}`}
|
||||
onChange={(labelMaxLength) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelMaxLength = labelMaxLength;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Скрывать подпись выше"
|
||||
value={profile.labelHideCameraHeightMeters}
|
||||
min={1_000}
|
||||
max={500_000}
|
||||
step={1_000}
|
||||
formatValue={(value) => `${Math.round(value / 1_000)} км`}
|
||||
onChange={(labelHideCameraHeightMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.labelHideCameraHeightMeters =
|
||||
labelHideCameraHeightMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Скрывать таргет выше"
|
||||
value={profile.targetHideCameraHeightMeters}
|
||||
min={1_000}
|
||||
max={500_000}
|
||||
step={1_000}
|
||||
formatValue={(value) => `${Math.round(value / 1_000)} км`}
|
||||
onChange={(targetHideCameraHeightMeters) => updateDraft((current) => {
|
||||
current.view.ghostPins.presentation.targetHideCameraHeightMeters =
|
||||
targetHideCameraHeightMeters;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import {
|
||||
Checker,
|
||||
ColorField,
|
||||
ControlRow,
|
||||
Inspector,
|
||||
RangeControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type {
|
||||
MapInspectorSection,
|
||||
MapViewDocument,
|
||||
} from "../../core/map/mapView";
|
||||
import type { useMapGatewayHealth } from "../../core/map/useMapGatewayHealth";
|
||||
import { GhostPinSettingsPanel } from "./GhostPinSettingsPanel";
|
||||
|
||||
export const editableMapInspectorSections = new Set<MapInspectorSection>([
|
||||
"base-terrain",
|
||||
"atmosphere-light",
|
||||
"buildings",
|
||||
"ghost-pins",
|
||||
"grid-lod",
|
||||
"camera",
|
||||
"tile-cache",
|
||||
]);
|
||||
|
||||
export function MapSettingsInspector({
|
||||
draft,
|
||||
updateDraft,
|
||||
gatewayHealth,
|
||||
onGenerateGhostPins,
|
||||
onClearGhostPins,
|
||||
}: {
|
||||
draft: MapViewDocument;
|
||||
updateDraft: (
|
||||
update: (current: MapViewDocument) => MapViewDocument,
|
||||
) => void;
|
||||
gatewayHealth: ReturnType<typeof useMapGatewayHealth>;
|
||||
onGenerateGhostPins: () => void;
|
||||
onClearGhostPins: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Inspector
|
||||
singleOpen
|
||||
sections={createInspectorSections(
|
||||
draft,
|
||||
updateDraft,
|
||||
gatewayHealth,
|
||||
onGenerateGhostPins,
|
||||
onClearGhostPins,
|
||||
)}
|
||||
openSections={draft.view.inspectorOpenSections.filter(
|
||||
(section) => editableMapInspectorSections.has(section),
|
||||
)}
|
||||
activeId={draft.view.inspectorOpenSections[0]}
|
||||
onOpenSectionsChange={(sections) => {
|
||||
updateDraft((current) => {
|
||||
current.view.inspectorOpenSections = sections.filter(
|
||||
(section): section is MapInspectorSection =>
|
||||
editableMapInspectorSections.has(section as MapInspectorSection),
|
||||
).slice(0, 1);
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function createInspectorSections(
|
||||
draft: MapViewDocument,
|
||||
updateDraft: (
|
||||
update: (current: MapViewDocument) => MapViewDocument,
|
||||
) => void,
|
||||
gatewayHealth: ReturnType<typeof useMapGatewayHealth>,
|
||||
onGenerateGhostPins: () => void,
|
||||
onClearGhostPins: () => void,
|
||||
) {
|
||||
const view = draft.view;
|
||||
const settings = view.visualSettings;
|
||||
return [
|
||||
{
|
||||
id: "base-terrain",
|
||||
label: "Подложка и terrain",
|
||||
description: "provider-neutral surface",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<ControlRow label="Подложка">
|
||||
<strong>Cesium World Imagery</strong>
|
||||
</ControlRow>
|
||||
<Checker
|
||||
label="Спутниковая подложка"
|
||||
checked={view.layerVisibility.imagery}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.imagery = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Terrain"
|
||||
checked={view.layerVisibility.terrain}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.terrain = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Вертикальное преувеличение рельефа"
|
||||
value={settings.terrainExaggeration * 100}
|
||||
min={25}
|
||||
max={300}
|
||||
step={1}
|
||||
formatValue={(value) => `${(value / 100).toFixed(2)}×`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.terrainExaggeration = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Монохромная поверхность"
|
||||
checked={settings.monochromeEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.monochromeEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет монохрома">
|
||||
<ColorField
|
||||
label="Цвет монохромной поверхности"
|
||||
value={settings.monochromeColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.monochromeColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Яркость"
|
||||
value={settings.imageryBrightness}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryBrightness = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Контраст"
|
||||
value={settings.imageryContrast}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryContrast = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Насыщенность"
|
||||
value={settings.imagerySaturation}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imagerySaturation = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Гамма"
|
||||
value={settings.imageryGamma}
|
||||
min={0}
|
||||
max={300}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryGamma = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Оттенок"
|
||||
value={settings.imageryHue}
|
||||
min={-180}
|
||||
max={180}
|
||||
formatValue={(value) => `${value}°`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryHue = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Прозрачность imagery"
|
||||
value={settings.imageryAlpha}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.imageryAlpha = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет планеты">
|
||||
<ColorField
|
||||
label="Цвет terrain без imagery"
|
||||
value={settings.globeColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.globeColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<ControlRow label="Фон сцены">
|
||||
<ColorField
|
||||
label="Цвет фона сцены"
|
||||
value={settings.backgroundColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.backgroundColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Высота карты"
|
||||
value={view.mapHeight}
|
||||
min={420}
|
||||
max={1080}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.mapHeight = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "atmosphere-light",
|
||||
label: "Атмосфера и освещение",
|
||||
description: "scene / color correction",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Показывать атмосферу"
|
||||
checked={settings.atmosphereEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Атмосфера: оттенок"
|
||||
value={settings.atmosphereHue}
|
||||
min={-100}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereHue = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Атмосфера: насыщенность"
|
||||
value={settings.atmosphereSaturation}
|
||||
min={-100}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereSaturation = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Атмосфера: яркость"
|
||||
value={settings.atmosphereBrightness}
|
||||
min={-100}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.atmosphereBrightness = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Туман"
|
||||
checked={settings.fogEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.fogEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Плотность тумана"
|
||||
value={settings.fogDensity}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${(value / 10_000).toFixed(4)}`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.fogDensity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Солнечное освещение"
|
||||
checked={settings.sunEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.sunEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Час солнца"
|
||||
value={settings.sunHour}
|
||||
min={0}
|
||||
max={24}
|
||||
formatValue={(value) => `${value}:00 UTC`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.sunHour = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Интенсивность света"
|
||||
value={settings.sunIntensity}
|
||||
min={0}
|
||||
max={200}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.sunIntensity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Тени"
|
||||
checked={settings.shadowsEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.shadowsEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "buildings",
|
||||
label: "3D здания",
|
||||
description: "3D Tiles / detail",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Показывать 3D здания"
|
||||
checked={view.layerVisibility.buildings}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.buildings = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет">
|
||||
<ColorField
|
||||
label="Цвет зданий"
|
||||
value={settings.buildingsColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.buildingsColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность"
|
||||
value={Math.round(settings.buildingsOpacity * 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.buildingsOpacity = value / 100;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Детализация"
|
||||
value={settings.buildingsMaximumScreenSpaceError}
|
||||
min={4}
|
||||
max={32}
|
||||
formatValue={(value) => `SSE ${value}`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.buildingsMaximumScreenSpaceError = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ghost-pins",
|
||||
label: "Гост-пин",
|
||||
description: "временная симуляция",
|
||||
content: (
|
||||
<GhostPinSettingsPanel
|
||||
draft={draft}
|
||||
updateDraft={updateDraft}
|
||||
onGenerate={onGenerateGhostPins}
|
||||
onClear={onClearGhostPins}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "grid-lod",
|
||||
label: "Сетка и LOD",
|
||||
description: "first adapter control",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="3D-сетка"
|
||||
checked={view.layerVisibility.grid}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.layerVisibility.grid = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="LOD по высоте камеры"
|
||||
checked={settings.gridLodEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLodEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Высота над поверхностью"
|
||||
value={settings.gridHeightMeters}
|
||||
min={0}
|
||||
max={1000}
|
||||
formatValue={(value) => `${value} м`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridHeightMeters = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 1: до высоты"
|
||||
value={settings.gridLod1MaxHeightKm}
|
||||
min={1}
|
||||
max={50}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod1MaxHeightKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 1: шаг"
|
||||
value={settings.gridLod1StepKm}
|
||||
min={1}
|
||||
max={10}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod1StepKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 2: до высоты"
|
||||
value={settings.gridLod2MaxHeightKm}
|
||||
min={10}
|
||||
max={200}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod2MaxHeightKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 2: шаг"
|
||||
value={settings.gridLod2StepKm}
|
||||
min={1}
|
||||
max={25}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod2StepKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="LOD 3: шаг"
|
||||
value={settings.gridLod3StepKm}
|
||||
min={5}
|
||||
max={100}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLod3StepKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Радиус сетки"
|
||||
value={settings.gridRadiusKm}
|
||||
min={5}
|
||||
max={150}
|
||||
formatValue={(value) => `${value} км`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridRadiusKm = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет линий">
|
||||
<ColorField
|
||||
label="Цвет линий сетки"
|
||||
value={settings.gridColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Толщина линий"
|
||||
value={settings.gridLineWidth}
|
||||
min={1}
|
||||
max={8}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridLineWidth = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Прозрачность сетки"
|
||||
value={settings.gridOpacity}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridOpacity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Точки в пересечениях"
|
||||
checked={settings.gridDotsEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<RangeControl
|
||||
label="Размер точки"
|
||||
value={settings.gridDotsSize}
|
||||
min={2}
|
||||
max={28}
|
||||
formatValue={(value) => `${value} px`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsSize = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Цвет точек">
|
||||
<ColorField
|
||||
label="Цвет точек сетки"
|
||||
value={settings.gridDotsColor}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsColor = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</ControlRow>
|
||||
<RangeControl
|
||||
label="Прозрачность точек"
|
||||
value={settings.gridDotsOpacity}
|
||||
min={0}
|
||||
max={100}
|
||||
formatValue={(value) => `${value}%`}
|
||||
onChange={(value) => updateDraft((current) => {
|
||||
current.view.visualSettings.gridDotsOpacity = value;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "camera",
|
||||
label: "Анимация камеры",
|
||||
description: "geodesic spiral survey",
|
||||
content: (
|
||||
<div className="mission-map__inspector-controls">
|
||||
<Checker
|
||||
label="Разрешить анимацию камеры"
|
||||
checked={settings.cameraAnimationEnabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.visualSettings.cameraAnimationEnabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<ControlRow label="Сохранённая позиция">
|
||||
<StatusBadge tone={view.camera ? "success" : "neutral"}>
|
||||
{view.camera ? "Есть" : "Не задана"}
|
||||
</StatusBadge>
|
||||
</ControlRow>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tile-cache",
|
||||
label: "TileCache",
|
||||
description: "Platform Map Gateway",
|
||||
content: (
|
||||
<div
|
||||
className="mission-map__inspector-controls"
|
||||
data-health-code={gatewayHealth.code ?? undefined}
|
||||
>
|
||||
<Checker
|
||||
label="Использовать общий TileCache"
|
||||
checked={view.cacheIntent.enabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.cacheIntent.enabled = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<Checker
|
||||
label="Не перезаписывать существующие тайлы"
|
||||
checked={view.cacheIntent.noOverwrite}
|
||||
disabled={!view.cacheIntent.enabled}
|
||||
onChange={(checked) => updateDraft((current) => {
|
||||
current.view.cacheIntent.noOverwrite = checked;
|
||||
return current;
|
||||
})}
|
||||
/>
|
||||
<CacheHealth health={gatewayHealth} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function CacheHealth({
|
||||
health,
|
||||
}: {
|
||||
health: ReturnType<typeof useMapGatewayHealth>;
|
||||
}) {
|
||||
if (health.state === "loading" || health.state === "idle") {
|
||||
return (
|
||||
<ControlRow label="Состояние">
|
||||
<StatusBadge tone="neutral">Проверяем</StatusBadge>
|
||||
</ControlRow>
|
||||
);
|
||||
}
|
||||
if (!health.snapshot) {
|
||||
return (
|
||||
<ControlRow label="Состояние">
|
||||
<StatusBadge tone="danger">Недоступен</StatusBadge>
|
||||
</ControlRow>
|
||||
);
|
||||
}
|
||||
const { cache } = health.snapshot;
|
||||
return (
|
||||
<dl className="mission-map__cache-health">
|
||||
<div>
|
||||
<dt>Хранилище</dt>
|
||||
<dd>
|
||||
<StatusBadge tone={cache.persistent ? "success" : "warning"}>
|
||||
{cache.persistent ? "Постоянное" : "Временное"}
|
||||
</StatusBadge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Объекты</dt>
|
||||
<dd>{cache.entries.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Объём</dt>
|
||||
<dd>
|
||||
{formatBytes(cache.bytes)}
|
||||
{cache.maxBytes === null ? "" : ` / ${formatBytes(cache.maxBytes)}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Заполнение</dt>
|
||||
<dd>
|
||||
<StatusBadge
|
||||
tone={
|
||||
cache.atCapacity || health.state === "stale"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{cache.atCapacity
|
||||
? "Лимит"
|
||||
: health.state === "stale"
|
||||
? "Устарело"
|
||||
: "Норма"}
|
||||
</StatusBadge>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
const units = ["КБ", "МБ", "ГБ", "ТБ"];
|
||||
let amount = value / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && amount >= 1024; index += 1) {
|
||||
amount /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
Application,
|
||||
Asset,
|
||||
BLEND_NORMAL,
|
||||
Color,
|
||||
CULLFACE_NONE,
|
||||
Entity,
|
||||
FILLMODE_NONE,
|
||||
Mat4,
|
||||
RESOLUTION_AUTO,
|
||||
StandardMaterial,
|
||||
Vec2,
|
||||
Vec3,
|
||||
WasmModule,
|
||||
type ContainerResource,
|
||||
type RenderComponent,
|
||||
type ScriptType,
|
||||
} from "playcanvas";
|
||||
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
||||
|
||||
import type {
|
||||
SimulationTransformAxis,
|
||||
SimulationViewerQuality,
|
||||
SimulationWorldManifest,
|
||||
} from "../../core/simulation/projects";
|
||||
|
||||
export type SimulationViewMode = "visual" | "collision" | "combined";
|
||||
export type SimulationQuality = SimulationViewerQuality;
|
||||
export type SimulationLayer = "visual" | "collision";
|
||||
export type SimulationCameraAxis = "horizontal" | "vertical";
|
||||
|
||||
export const PLAYCANVAS_IDENTITY_TRANSFORM = [
|
||||
1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export const PLAYCANVAS_X_180_TRANSFORM = [
|
||||
1, 0, 0, 0,
|
||||
0, -1, 0, 0,
|
||||
0, 0, -1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export const PLAYCANVAS_Y_180_TRANSFORM = [
|
||||
-1, 0, 0, 0,
|
||||
0, 1, 0, 0,
|
||||
0, 0, -1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export const PLAYCANVAS_Z_180_TRANSFORM = [
|
||||
-1, 0, 0, 0,
|
||||
0, -1, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
|
||||
export function playCanvasAxisTransform(axis: SimulationTransformAxis, inverted: boolean): number[] {
|
||||
if (!inverted) return PLAYCANVAS_IDENTITY_TRANSFORM;
|
||||
if (axis === "x") return PLAYCANVAS_X_180_TRANSFORM;
|
||||
if (axis === "y") return PLAYCANVAS_Y_180_TRANSFORM;
|
||||
return PLAYCANVAS_Z_180_TRANSFORM;
|
||||
}
|
||||
|
||||
export interface SimulationRuntime {
|
||||
mount(canvas: HTMLCanvasElement): Promise<void>;
|
||||
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
||||
setViewMode(mode: SimulationViewMode): Promise<void>;
|
||||
setQuality(quality: SimulationQuality): void;
|
||||
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
|
||||
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void;
|
||||
home(): void;
|
||||
focusBounds(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type DesktopCameraInput = {
|
||||
read(): { mouse: number[] } & Record<string, number[]>;
|
||||
};
|
||||
|
||||
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus"> & {
|
||||
_desktopInput?: DesktopCameraInput;
|
||||
};
|
||||
|
||||
const HOME_POSITION = new Vec3(0, 1, 0);
|
||||
const HOME_FOCUS = new Vec3(1, 1, 0);
|
||||
const DRACO_DECODER_ROOT = "/vendor/playcanvas/draco/1.5.7";
|
||||
|
||||
const QUALITY_PROFILES: Record<SimulationQuality, {
|
||||
lodBaseDistance: number;
|
||||
lodMultiplier: number;
|
||||
lodRangeMin: number;
|
||||
lodRangeMax: number;
|
||||
pixelRatio: number;
|
||||
splatBudget: number;
|
||||
targetFps: number;
|
||||
}> = {
|
||||
low: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 5, pixelRatio: 0.75, splatBudget: 350_000, targetFps: 30 },
|
||||
medium: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 5, pixelRatio: 1, splatBudget: 700_000, targetFps: 45 },
|
||||
high: { lodBaseDistance: 5, lodMultiplier: 3, lodRangeMin: 1, lodRangeMax: 5, pixelRatio: 1.25, splatBudget: 1_200_000, targetFps: 60 },
|
||||
ultra: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 1.5, splatBudget: 2_500_000, targetFps: 60 },
|
||||
maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60 },
|
||||
};
|
||||
|
||||
export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
private app: Application | null = null;
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private camera: Entity | null = null;
|
||||
private cameraController: CameraController | null = null;
|
||||
private visualEntity: Entity | null = null;
|
||||
private visualAsset: Asset | null = null;
|
||||
private collisionEntity: Entity | null = null;
|
||||
private collisionAsset: Asset | null = null;
|
||||
private collisionMaterial: StandardMaterial | null = null;
|
||||
private collisionSource: { meshUrl: string; projectId: string; worldTransform: number[] } | null = null;
|
||||
private collisionLoadPromise: Promise<void> | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private viewMode: SimulationViewMode = "visual";
|
||||
private quality: SimulationQuality = "high";
|
||||
private cameraInversion = { horizontal: true, vertical: false };
|
||||
private renderIntervalMs = 1000 / 60;
|
||||
private renderAccumulatorMs = 0;
|
||||
private disposed = false;
|
||||
private scheduleFrame = (deltaMs: number): void => {
|
||||
if (!this.app || this.disposed) return;
|
||||
this.renderAccumulatorMs += deltaMs;
|
||||
if (this.renderAccumulatorMs < this.renderIntervalMs) return;
|
||||
this.renderAccumulatorMs %= this.renderIntervalMs;
|
||||
this.app.renderNextFrame = true;
|
||||
};
|
||||
|
||||
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
||||
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
||||
this.disposed = false;
|
||||
this.renderAccumulatorMs = 0;
|
||||
this.canvas = canvas;
|
||||
configureDracoDecoder();
|
||||
canvas.tabIndex = 0;
|
||||
canvas.addEventListener("contextmenu", preventContextMenu);
|
||||
canvas.addEventListener("pointerdown", focusCanvas, true);
|
||||
const app = new Application(canvas, {
|
||||
graphicsDeviceOptions: {
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
preserveDrawingBuffer: false,
|
||||
powerPreference: "high-performance",
|
||||
},
|
||||
});
|
||||
this.app = app;
|
||||
app.autoRender = false;
|
||||
app.on("frameupdate", this.scheduleFrame);
|
||||
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||
|
||||
const camera = new Entity("SimulationCamera");
|
||||
camera.addComponent("camera", {
|
||||
clearColor: new Color(0.025, 0.03, 0.04),
|
||||
nearClip: 0.02,
|
||||
farClip: 20_000,
|
||||
fov: 58,
|
||||
});
|
||||
camera.setPosition(HOME_POSITION);
|
||||
camera.lookAt(HOME_FOCUS);
|
||||
camera.addComponent("script");
|
||||
const controller = camera.script?.create(CameraControls, {
|
||||
properties: {
|
||||
enableOrbit: true,
|
||||
enableFly: true,
|
||||
focusPoint: new Vec3(0, 0, 0),
|
||||
zoomRange: new Vec2(0.05, 20_000),
|
||||
},
|
||||
}) as CameraController | null;
|
||||
if (controller) configureCameraDrag(controller, () => this.cameraInversion);
|
||||
app.root.addChild(camera);
|
||||
this.camera = camera;
|
||||
this.cameraController = controller;
|
||||
|
||||
const light = new Entity("SimulationSun");
|
||||
light.addComponent("light", {
|
||||
type: "directional",
|
||||
color: new Color(1, 0.97, 0.9),
|
||||
intensity: 1.1,
|
||||
castShadows: false,
|
||||
});
|
||||
light.setEulerAngles(42, 28, 0);
|
||||
app.root.addChild(light);
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.resizeObserver.observe(canvas.parentElement ?? canvas);
|
||||
this.resize();
|
||||
app.start();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async loadWorld(manifest: SimulationWorldManifest): Promise<void> {
|
||||
const app = this.requiredApp();
|
||||
this.unloadWorld();
|
||||
const sourceUrl = manifest.visual.streamedSogUrl ?? manifest.visual.previewSogUrl;
|
||||
if (!sourceUrl) throw new Error("World manifest не содержит визуальный SOG.");
|
||||
const asset = new Asset(`SimulationWorld:${manifest.projectId}`, "gsplat", { url: sourceUrl });
|
||||
app.assets.add(asset);
|
||||
this.visualAsset = asset;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ready = (loaded: Asset) => {
|
||||
if (this.disposed || !this.app) {
|
||||
loaded.unload();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const visual = new Entity("GaussianWorld");
|
||||
visual.enabled = false;
|
||||
applyWorldTransform(visual, manifest.transforms.worldFromVisual);
|
||||
app.root.addChild(visual);
|
||||
visual.addComponent("gsplat", {
|
||||
asset: loaded,
|
||||
unified: true,
|
||||
});
|
||||
visual.enabled = true;
|
||||
this.visualEntity = visual;
|
||||
this.applyQuality();
|
||||
this.applyViewMode();
|
||||
resolve();
|
||||
};
|
||||
const failed = (error: unknown) => {
|
||||
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил SOG."));
|
||||
};
|
||||
asset.ready(ready);
|
||||
asset.once("error", failed);
|
||||
app.assets.load(asset);
|
||||
});
|
||||
if (manifest.collision.available && manifest.collision.meshUrl) {
|
||||
this.collisionSource = {
|
||||
meshUrl: manifest.collision.meshUrl,
|
||||
projectId: manifest.projectId,
|
||||
worldTransform: manifest.transforms.worldFromCollision,
|
||||
};
|
||||
}
|
||||
this.applyViewMode();
|
||||
this.home();
|
||||
}
|
||||
|
||||
async setViewMode(mode: SimulationViewMode): Promise<void> {
|
||||
this.viewMode = mode;
|
||||
this.applyViewMode();
|
||||
try {
|
||||
if (mode !== "visual" && !this.collisionEntity) {
|
||||
await this.ensureCollision();
|
||||
this.applyViewMode();
|
||||
}
|
||||
} finally {
|
||||
this.canvas?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
setQuality(quality: SimulationQuality): void {
|
||||
this.quality = quality;
|
||||
this.applyQuality();
|
||||
}
|
||||
|
||||
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void {
|
||||
if (transform.length !== 16) {
|
||||
throw new Error("World transform должен содержать матрицу 4×4.");
|
||||
}
|
||||
if (layer === "visual") {
|
||||
if (this.visualEntity) applyWorldTransform(this.visualEntity, transform);
|
||||
return;
|
||||
}
|
||||
if (this.collisionSource) this.collisionSource.worldTransform = [...transform];
|
||||
if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform);
|
||||
}
|
||||
|
||||
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void {
|
||||
this.cameraInversion[axis] = inverted;
|
||||
}
|
||||
|
||||
home(): void {
|
||||
if (this.cameraController) {
|
||||
this.cameraController.reset(HOME_FOCUS, HOME_POSITION);
|
||||
} else if (this.camera) {
|
||||
this.camera.setPosition(HOME_POSITION);
|
||||
this.camera.lookAt(HOME_FOCUS);
|
||||
}
|
||||
this.canvas?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
focusBounds(): void {
|
||||
const bounds = this.visualEntity?.gsplat?.customAabb;
|
||||
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
||||
const radius = bounds?.halfExtents.length() ?? 3;
|
||||
const distance = Math.max(1, radius * 2.4);
|
||||
const position = new Vec3(1, 0.55, 1).normalize().mulScalar(distance).add(focus);
|
||||
if (this.cameraController) {
|
||||
this.cameraController.reset(focus, position);
|
||||
} else if (this.camera) {
|
||||
this.camera.setPosition(position);
|
||||
this.camera.lookAt(focus);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
const webgl = this.canvas?.getContext("webgl2") ?? this.canvas?.getContext("webgl");
|
||||
this.unloadWorld();
|
||||
if (this.app) {
|
||||
this.app.autoRender = false;
|
||||
this.app.renderNextFrame = false;
|
||||
this.app.off("frameupdate", this.scheduleFrame);
|
||||
this.app.destroy();
|
||||
}
|
||||
webgl?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
if (this.canvas) {
|
||||
this.canvas.width = 1;
|
||||
this.canvas.height = 1;
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.removeEventListener("contextmenu", preventContextMenu);
|
||||
this.canvas.removeEventListener("pointerdown", focusCanvas, true);
|
||||
}
|
||||
this.app = null;
|
||||
this.canvas = null;
|
||||
this.camera = null;
|
||||
this.cameraController = null;
|
||||
}
|
||||
|
||||
private requiredApp(): Application {
|
||||
if (!this.app) throw new Error("PlayCanvas runtime не смонтирован.");
|
||||
return this.app;
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
if (!this.app || !this.canvas) return;
|
||||
const bounds = this.canvas.parentElement?.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(bounds?.width ?? this.canvas.clientWidth));
|
||||
const height = Math.max(1, Math.round(bounds?.height ?? this.canvas.clientHeight));
|
||||
this.app.resizeCanvas(width, height);
|
||||
}
|
||||
|
||||
private applyViewMode(): void {
|
||||
if (this.visualEntity) {
|
||||
this.visualEntity.enabled = this.viewMode !== "collision" || !this.collisionEntity;
|
||||
}
|
||||
if (this.collisionEntity) {
|
||||
this.collisionEntity.enabled = this.viewMode !== "visual";
|
||||
}
|
||||
if (this.collisionMaterial) {
|
||||
const combined = this.viewMode === "combined";
|
||||
this.collisionMaterial.opacity = combined ? 0.34 : 0.82;
|
||||
this.collisionMaterial.depthWrite = !combined;
|
||||
this.collisionMaterial.update();
|
||||
}
|
||||
}
|
||||
|
||||
private applyQuality(): void {
|
||||
const gsplat = this.visualEntity?.gsplat;
|
||||
const profile = QUALITY_PROFILES[this.quality];
|
||||
if (gsplat) {
|
||||
gsplat.lodBaseDistance = profile.lodBaseDistance;
|
||||
gsplat.lodMultiplier = profile.lodMultiplier;
|
||||
gsplat.lodRangeMin = profile.lodRangeMin;
|
||||
gsplat.lodRangeMax = profile.lodRangeMax;
|
||||
}
|
||||
if (this.app) {
|
||||
this.app.scene.gsplat.splatBudget = profile.splatBudget;
|
||||
this.renderIntervalMs = 1000 / profile.targetFps;
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
this.app.graphicsDevice.maxPixelRatio = Math.min(devicePixelRatio, profile.pixelRatio);
|
||||
this.resize();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCollision(meshUrl: string, projectId: string, worldTransform: number[]): Promise<void> {
|
||||
const app = this.requiredApp();
|
||||
const asset = new Asset(
|
||||
`SimulationCollision:${projectId}`,
|
||||
"container",
|
||||
{ url: meshUrl, filename: "scene.collision.glb" },
|
||||
);
|
||||
app.assets.add(asset);
|
||||
this.collisionAsset = asset;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
asset.ready((loaded) => {
|
||||
if (this.disposed || !this.app) {
|
||||
loaded.unload();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const resource = loaded.resource as ContainerResource | null;
|
||||
if (!resource) {
|
||||
reject(new Error("PlayCanvas не открыл collision GLB."));
|
||||
return;
|
||||
}
|
||||
const entity = resource.instantiateRenderEntity({
|
||||
castShadows: false,
|
||||
receiveShadows: false,
|
||||
});
|
||||
entity.name = "CollisionWorld";
|
||||
entity.enabled = false;
|
||||
applyWorldTransform(entity, worldTransform);
|
||||
app.root.addChild(entity);
|
||||
|
||||
const material = new StandardMaterial();
|
||||
material.name = "SimulationCollisionMaterial";
|
||||
material.diffuse = new Color(0.2, 0.95, 0.42);
|
||||
material.emissive = new Color(0.08, 0.36, 0.14);
|
||||
material.opacity = 0.34;
|
||||
material.blendType = BLEND_NORMAL;
|
||||
material.depthWrite = false;
|
||||
material.cull = CULLFACE_NONE;
|
||||
material.update();
|
||||
for (const render of entity.findComponents("render") as RenderComponent[]) {
|
||||
for (const meshInstance of render.meshInstances) {
|
||||
meshInstance.material = material;
|
||||
}
|
||||
}
|
||||
this.collisionEntity = entity;
|
||||
this.collisionMaterial = material;
|
||||
resolve();
|
||||
});
|
||||
asset.once("error", (error: unknown) => {
|
||||
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил collision GLB."));
|
||||
});
|
||||
app.assets.load(asset);
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureCollision(): Promise<void> {
|
||||
if (this.collisionEntity) return;
|
||||
if (!this.collisionSource) {
|
||||
throw new Error("Collision GLB отсутствует в world manifest.");
|
||||
}
|
||||
if (!this.collisionLoadPromise) {
|
||||
this.collisionLoadPromise = this.loadCollision(
|
||||
this.collisionSource.meshUrl,
|
||||
this.collisionSource.projectId,
|
||||
this.collisionSource.worldTransform,
|
||||
).finally(() => {
|
||||
this.collisionLoadPromise = null;
|
||||
});
|
||||
}
|
||||
await this.collisionLoadPromise;
|
||||
}
|
||||
|
||||
private unloadWorld(): void {
|
||||
if (this.visualEntity) {
|
||||
this.visualEntity.destroy();
|
||||
this.visualEntity = null;
|
||||
}
|
||||
if (this.visualAsset && this.app) {
|
||||
this.app.assets.remove(this.visualAsset);
|
||||
this.visualAsset.unload();
|
||||
this.visualAsset = null;
|
||||
}
|
||||
if (this.collisionEntity) {
|
||||
this.collisionEntity.destroy();
|
||||
this.collisionEntity = null;
|
||||
}
|
||||
if (this.collisionAsset && this.app) {
|
||||
this.app.assets.remove(this.collisionAsset);
|
||||
this.collisionAsset.unload();
|
||||
this.collisionAsset = null;
|
||||
}
|
||||
this.collisionMaterial?.destroy();
|
||||
this.collisionMaterial = null;
|
||||
this.collisionSource = null;
|
||||
this.collisionLoadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
function preventContextMenu(event: Event): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function configureDracoDecoder(): void {
|
||||
if (WasmModule.getConfig("DracoDecoderModule")) return;
|
||||
WasmModule.setConfig("DracoDecoderModule", {
|
||||
glueUrl: `${DRACO_DECODER_ROOT}/draco_wasm_wrapper_gltf.js`,
|
||||
wasmUrl: `${DRACO_DECODER_ROOT}/draco_decoder_gltf.wasm`,
|
||||
numWorkers: 2,
|
||||
});
|
||||
}
|
||||
|
||||
function focusCanvas(event: Event): void {
|
||||
(event.currentTarget as HTMLCanvasElement | null)?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function configureCameraDrag(
|
||||
controller: CameraController,
|
||||
inversion: () => { horizontal: boolean; vertical: boolean },
|
||||
): void {
|
||||
// CameraControls 2.21.4 deliberately exposes one pinned input source here.
|
||||
// Keep its pointer-capture and fly/orbit behavior; only the user-selected axes change sign.
|
||||
const input = controller._desktopInput;
|
||||
if (!input) return;
|
||||
const read = input.read.bind(input);
|
||||
input.read = () => {
|
||||
const frame = read();
|
||||
const selected = inversion();
|
||||
if (selected.horizontal && frame.mouse.length > 0) frame.mouse[0] *= -1;
|
||||
if (selected.vertical && frame.mouse.length > 1) frame.mouse[1] *= -1;
|
||||
return frame;
|
||||
};
|
||||
}
|
||||
|
||||
function applyWorldTransform(entity: Entity, values: number[]): void {
|
||||
const transform = new Mat4().set(values);
|
||||
entity.setLocalPosition(transform.getTranslation());
|
||||
entity.setLocalEulerAngles(transform.getEulerAngles());
|
||||
entity.setLocalScale(transform.getScale());
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
FieldFrame,
|
||||
Icon,
|
||||
Select,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createAndUploadSimulationProject,
|
||||
updateSimulationProject,
|
||||
type SimulationProject,
|
||||
type SimulationSceneType,
|
||||
type SimulationUploadCandidate,
|
||||
type SimulationUploadProgress,
|
||||
} from "../../core/simulation/projects";
|
||||
|
||||
export function SimulationProjectWindow({
|
||||
open,
|
||||
project,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
project: SimulationProject | null;
|
||||
onClose: () => void;
|
||||
onSaved: (project: SimulationProject) => void;
|
||||
}) {
|
||||
const archiveInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [sceneType, setSceneType] = useState<SimulationSceneType>("outdoor");
|
||||
const [candidates, setCandidates] = useState<SimulationUploadCandidate[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [progress, setProgress] = useState<SimulationUploadProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(project?.name ?? "");
|
||||
setSceneType(project?.sceneType ?? "outdoor");
|
||||
setCandidates([]);
|
||||
setProgress(null);
|
||||
setPending(false);
|
||||
setError(null);
|
||||
}, [open, project]);
|
||||
|
||||
const sourceSummary = useMemo(() => summarizeCandidates(candidates), [candidates]);
|
||||
const canSubmit = name.trim().length > 0 && (project !== null || candidates.length > 0) && !pending;
|
||||
|
||||
const acceptCandidates = (next: SimulationUploadCandidate[]) => {
|
||||
try {
|
||||
validateCandidates(next);
|
||||
setCandidates(next);
|
||||
setError(null);
|
||||
if (!name.trim() && next.length) {
|
||||
const named = next.find((candidate) => /\.lcc2?$/i.test(candidate.logicalPath)) ?? next[0];
|
||||
const first = named?.logicalPath.split("/").filter(Boolean).at(-1) ?? "Новая сцена";
|
||||
setName(first.replace(/\.(lcc2?|zip|rar|7z)$/i, ""));
|
||||
}
|
||||
} catch (caught) {
|
||||
setCandidates([]);
|
||||
setError(caught instanceof Error ? caught.message : "Источник не принят.");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = project
|
||||
? await updateSimulationProject(project.projectId, name, sceneType)
|
||||
: await createAndUploadSimulationProject(
|
||||
name,
|
||||
sceneType,
|
||||
candidates,
|
||||
setProgress,
|
||||
);
|
||||
onSaved(saved);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось сохранить проект.");
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
try {
|
||||
acceptCandidates(await candidatesFromDrop(event.dataTransfer));
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось прочитать источник.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window
|
||||
open={open}
|
||||
title={project ? "Редактировать проект" : "Новый проект симуляции"}
|
||||
subtitle={project ? "Метаданные сцены" : "LCC/LCC2 folder или ZIP/RAR/7z"}
|
||||
size="md"
|
||||
closeOnBackdrop={!pending}
|
||||
closeOnEscape={!pending}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<span className="simulation-project-window__footer-state">
|
||||
{pending && project ? <><ActivityIndicator /><span>Сохраняем</span></> : null}
|
||||
</span>
|
||||
<WindowFooterActions>
|
||||
<Button disabled={pending} onClick={onClose}>Отмена</Button>
|
||||
<Button variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
|
||||
{project ? "Сохранить" : "Создать и запустить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="simulation-project-window">
|
||||
<TextField
|
||||
label="Название"
|
||||
value={name}
|
||||
maxLength={120}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
<FieldFrame label="Тип сцены" description="Сохраняется как профиль будущей физики и навигации.">
|
||||
<Select
|
||||
label="Тип сцены"
|
||||
value={sceneType}
|
||||
disabled={pending}
|
||||
onChange={(value) => setSceneType(value)}
|
||||
options={[
|
||||
{ value: "outdoor", label: "Улица" },
|
||||
{ value: "interior", label: "Интерьер" },
|
||||
{ value: "object", label: "Отдельный объект" },
|
||||
]}
|
||||
/>
|
||||
</FieldFrame>
|
||||
{!project ? (
|
||||
<FieldFrame
|
||||
label="Исходник"
|
||||
description="Имя родительской папки не фиксировано. Внутри должна быть ровно одна сцена LCC или LCC2."
|
||||
>
|
||||
<div
|
||||
className="simulation-source-drop"
|
||||
data-dragging={dragging ? "true" : undefined}
|
||||
onDragEnter={(event) => { event.preventDefault(); setDragging(true); }}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
|
||||
}}
|
||||
onDrop={(event) => void handleDrop(event)}
|
||||
>
|
||||
<Icon name={candidates.length ? "check" : "upload"} size={20} />
|
||||
<strong>{sourceSummary.title}</strong>
|
||||
<span>{sourceSummary.detail}</span>
|
||||
<div>
|
||||
<Button
|
||||
size="compact"
|
||||
icon={<Icon name="file" size={16} />}
|
||||
disabled={pending}
|
||||
onClick={() => archiveInput.current?.click()}
|
||||
>
|
||||
Выбрать архив
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
icon={<Icon name="folder" size={16} />}
|
||||
disabled={pending}
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
Выбрать папку
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={archiveInput}
|
||||
type="file"
|
||||
accept=".zip,.rar,.7z"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (file) acceptCandidates([{ file, logicalPath: file.name }]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInput}
|
||||
type="file"
|
||||
hidden
|
||||
multiple
|
||||
{...({ webkitdirectory: "" } as Record<string, string>)}
|
||||
onChange={(event) => {
|
||||
const next = Array.from(event.currentTarget.files ?? []).map((file) => ({
|
||||
file,
|
||||
logicalPath: file.webkitRelativePath || file.name,
|
||||
}));
|
||||
if (next.length) acceptCandidates(next);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FieldFrame>
|
||||
) : null}
|
||||
{progress ? (
|
||||
<div className="simulation-upload-progress" role="status">
|
||||
<span><i style={{ width: `${Math.min(100, progress.totalBytes ? progress.uploadedBytes / progress.totalBytes * 100 : 0)}%` }} /></span>
|
||||
<div>
|
||||
<strong>{formatBytes(progress.uploadedBytes)} / {formatBytes(progress.totalBytes)}</strong>
|
||||
<small>{progress.currentPath}</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="simulation-project-window__error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
interface LegacyFileEntry {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file?: (callback: (file: File) => void, error?: (error: DOMException) => void) => void;
|
||||
createReader?: () => {
|
||||
readEntries: (
|
||||
callback: (entries: LegacyFileEntry[]) => void,
|
||||
error?: (error: DOMException) => void,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
|
||||
async function candidatesFromDrop(transfer: DataTransfer): Promise<SimulationUploadCandidate[]> {
|
||||
const candidates: SimulationUploadCandidate[] = [];
|
||||
for (const item of Array.from(transfer.items)) {
|
||||
const entry = (item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => LegacyFileEntry | null;
|
||||
}).webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
await collectEntry(entry, candidates);
|
||||
continue;
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (file) candidates.push({ file, logicalPath: file.name });
|
||||
}
|
||||
if (!candidates.length) {
|
||||
for (const file of Array.from(transfer.files)) {
|
||||
candidates.push({ file, logicalPath: file.name });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function collectEntry(entry: LegacyFileEntry, target: SimulationUploadCandidate[]): Promise<void> {
|
||||
if (entry.isFile && entry.file) {
|
||||
const file = await new Promise<File>((resolve, reject) => entry.file?.(resolve, reject));
|
||||
target.push({ file, logicalPath: entry.fullPath.replace(/^\//, "") || file.name });
|
||||
return;
|
||||
}
|
||||
if (!entry.isDirectory || !entry.createReader) return;
|
||||
const reader = entry.createReader();
|
||||
while (true) {
|
||||
const batch = await new Promise<LegacyFileEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
if (!batch.length) break;
|
||||
for (const child of batch) await collectEntry(child, target);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCandidates(candidates: SimulationUploadCandidate[]): void {
|
||||
if (!candidates.length) throw new Error("Выберите архив или папку с результатом LCC/LCC2.");
|
||||
const archives = candidates.filter((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath));
|
||||
if (archives.length) {
|
||||
if (candidates.length !== 1) throw new Error("Архив нужно загружать одним файлом.");
|
||||
return;
|
||||
}
|
||||
const descriptors = candidates.filter((candidate) => /\.lcc2?$/i.test(candidate.logicalPath));
|
||||
if (descriptors.length !== 1) {
|
||||
throw new Error("В папке должна быть ровно одна сцена .lcc или .lcc2.");
|
||||
}
|
||||
const paths = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const parts = candidate.logicalPath.split("/");
|
||||
if (
|
||||
!candidate.logicalPath ||
|
||||
candidate.logicalPath.startsWith("/") ||
|
||||
candidate.logicalPath.includes("\\") ||
|
||||
candidate.logicalPath.includes("\0") ||
|
||||
parts.some((part) => part === "" || part === "." || part === "..")
|
||||
) {
|
||||
throw new Error("Папка содержит небезопасный путь.");
|
||||
}
|
||||
if (candidate.file.size <= 0) throw new Error(`Пустой файл: ${candidate.logicalPath}`);
|
||||
if (paths.has(candidate.logicalPath)) throw new Error(`Повторяющийся путь: ${candidate.logicalPath}`);
|
||||
paths.add(candidate.logicalPath);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeCandidates(candidates: SimulationUploadCandidate[]): { title: string; detail: string } {
|
||||
if (!candidates.length) return {
|
||||
title: "Перетащите архив или папку сюда",
|
||||
detail: "ZIP, RAR, 7z либо parent folder с LCC/LCC2",
|
||||
};
|
||||
const total = candidates.reduce((sum, candidate) => sum + candidate.file.size, 0);
|
||||
const archive = candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "");
|
||||
return {
|
||||
title: archive ? candidates[0]?.logicalPath ?? "Архив" : "Папка принята",
|
||||
detail: `${candidates.length.toLocaleString("ru-RU")} файл(ов) · ${formatBytes(total)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`;
|
||||
return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
saveSimulationViewerSettings,
|
||||
type SimulationProject,
|
||||
type SimulationTransformAxis,
|
||||
type SimulationViewerSettings,
|
||||
} from "../../core/simulation/projects";
|
||||
import {
|
||||
createLatestAsyncCommitter,
|
||||
type LatestAsyncCommitter,
|
||||
} from "../../core/runtime/latestAsyncCommitter";
|
||||
import {
|
||||
PlayCanvasRuntime,
|
||||
playCanvasAxisTransform,
|
||||
type SimulationQuality,
|
||||
type SimulationViewMode,
|
||||
} from "./PlayCanvasRuntime";
|
||||
|
||||
const AXIS_OPTIONS: Array<{ value: SimulationTransformAxis; label: string }> = [
|
||||
{ value: "x", label: "X" },
|
||||
{ value: "y", label: "Y" },
|
||||
{ value: "z", label: "Z" },
|
||||
];
|
||||
|
||||
export function SimulationViewport({
|
||||
project,
|
||||
onProjectChange,
|
||||
}: {
|
||||
project: SimulationProject;
|
||||
onProjectChange?: (project: SimulationProject) => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
||||
const viewerSettingsRef = useRef(project.viewerSettings);
|
||||
const settingsCommitterRef = useRef<LatestAsyncCommitter<SimulationViewerSettings> | null>(null);
|
||||
const settingsId = useId();
|
||||
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
|
||||
const [quality, setQuality] = useState<SimulationQuality>(project.viewerSettings.quality);
|
||||
const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [visualInverted, setVisualInverted] = useState(project.viewerSettings.visual.inverted);
|
||||
const [visualAxis, setVisualAxis] = useState(project.viewerSettings.visual.axis);
|
||||
const [collisionInverted, setCollisionInverted] = useState(project.viewerSettings.collision.inverted);
|
||||
const [collisionAxis, setCollisionAxis] = useState(project.viewerSettings.collision.axis);
|
||||
const [horizontalInverted, setHorizontalInverted] = useState(
|
||||
project.viewerSettings.camera.invertHorizontal,
|
||||
);
|
||||
const [verticalInverted, setVerticalInverted] = useState(
|
||||
project.viewerSettings.camera.invertVertical,
|
||||
);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = project.viewerSettings;
|
||||
viewerSettingsRef.current = settings;
|
||||
setQuality(settings.quality);
|
||||
setVisualInverted(settings.visual.inverted);
|
||||
setVisualAxis(settings.visual.axis);
|
||||
setCollisionInverted(settings.collision.inverted);
|
||||
setCollisionAxis(settings.collision.axis);
|
||||
setHorizontalInverted(settings.camera.invertHorizontal);
|
||||
setVerticalInverted(settings.camera.invertVertical);
|
||||
setSettingsError(null);
|
||||
setSettingsOpen(false);
|
||||
}, [project.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const committer = createLatestAsyncCommitter<SimulationViewerSettings>({
|
||||
async commit(settings) {
|
||||
try {
|
||||
const saved = await saveSimulationViewerSettings(project.projectId, settings);
|
||||
if (active) onProjectChange?.(saved);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
if (active) {
|
||||
setSettingsError(
|
||||
caught instanceof Error ? caught.message : "Не удалось сохранить настройки сцены.",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onSettled({ applied, superseded }) {
|
||||
if (active && applied && !superseded) setSettingsError(null);
|
||||
},
|
||||
});
|
||||
settingsCommitterRef.current = committer;
|
||||
return () => {
|
||||
active = false;
|
||||
committer.dispose();
|
||||
if (settingsCommitterRef.current === committer) settingsCommitterRef.current = null;
|
||||
};
|
||||
}, [onProjectChange, project.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const manifest = project.worldManifest;
|
||||
if (!canvas || !manifest) return;
|
||||
const runtime = new PlayCanvasRuntime();
|
||||
runtimeRef.current = runtime;
|
||||
let cancelled = false;
|
||||
setState("mounting");
|
||||
setError(null);
|
||||
void runtime.mount(canvas).then(async () => {
|
||||
if (cancelled) return;
|
||||
const settings = viewerSettingsRef.current;
|
||||
runtime.setQuality(settings.quality);
|
||||
runtime.setCameraInversion("horizontal", settings.camera.invertHorizontal);
|
||||
runtime.setCameraInversion("vertical", settings.camera.invertVertical);
|
||||
setState("loading");
|
||||
await runtime.loadWorld(manifest);
|
||||
runtime.setLayerWorldTransform(
|
||||
"visual",
|
||||
playCanvasAxisTransform(settings.visual.axis, settings.visual.inverted),
|
||||
);
|
||||
runtime.setLayerWorldTransform(
|
||||
"collision",
|
||||
playCanvasAxisTransform(settings.collision.axis, settings.collision.inverted),
|
||||
);
|
||||
if (!cancelled) setState("ready");
|
||||
}).catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState("failed");
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось открыть сцену.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
runtime.dispose();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, [project.projectId, project.worldManifest]);
|
||||
|
||||
const collisionAvailable = project.worldManifest?.collision.available ?? false;
|
||||
const commitViewerSettings = (settings: SimulationViewerSettings) => {
|
||||
viewerSettingsRef.current = settings;
|
||||
settingsCommitterRef.current?.enqueue(settings);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
||||
<header className="simulation-viewport__toolbar">
|
||||
<div>
|
||||
<StatusBadge tone={state === "failed" || collisionState === "failed" ? "warning" : state === "ready" && collisionState !== "loading" ? "success" : "accent"}>
|
||||
{collisionState === "loading"
|
||||
? "Загрузка collision"
|
||||
: collisionState === "failed"
|
||||
? "Ошибка collision"
|
||||
: state === "ready"
|
||||
? "Runtime готов"
|
||||
: state === "failed"
|
||||
? "Ошибка runtime"
|
||||
: "Загрузка сцены"}
|
||||
</StatusBadge>
|
||||
<span>PlayCanvas Engine 2.21.4</span>
|
||||
</div>
|
||||
<div className="simulation-viewport__controls">
|
||||
<Select
|
||||
label="Качество Gaussian-сцены"
|
||||
value={quality}
|
||||
onChange={(next) => {
|
||||
setQuality(next);
|
||||
runtimeRef.current?.setQuality(next);
|
||||
commitViewerSettings({ ...viewerSettingsRef.current, quality: next });
|
||||
}}
|
||||
options={[
|
||||
{ value: "maximum", label: "Максимум", description: "До 4 млн сплатов · Retina до 2× · 60 FPS" },
|
||||
{ value: "ultra", label: "Ультра", description: "До 2,5 млн · Retina до 1,5× · 60 FPS" },
|
||||
{ value: "high", label: "Высокое", description: "До 1,2 млн · Retina до 1,25× · 60 FPS" },
|
||||
{ value: "medium", label: "Среднее", description: "До 700 тыс. · 1× · 45 FPS" },
|
||||
{ value: "low", label: "Низкое", description: "До 350 тыс. · 0,75× · 30 FPS" },
|
||||
]}
|
||||
minMenuWidth={230}
|
||||
menuWidth={230}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Слой сцены"
|
||||
value={viewMode}
|
||||
onChange={(next) => {
|
||||
setViewMode(next);
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) return;
|
||||
if (next !== "visual" && collisionAvailable && collisionState !== "ready") {
|
||||
setCollisionState("loading");
|
||||
}
|
||||
void runtime.setViewMode(next).then(() => {
|
||||
if (next !== "visual") setCollisionState("ready");
|
||||
}).catch((caught: unknown) => {
|
||||
setCollisionState("failed");
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось открыть collision GLB.");
|
||||
});
|
||||
}}
|
||||
items={[
|
||||
{ value: "visual", label: "Визуал" },
|
||||
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
|
||||
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
||||
]}
|
||||
/>
|
||||
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.home()}>
|
||||
Домой
|
||||
</Button>
|
||||
<div className="simulation-viewport__settings-anchor">
|
||||
<IconButton
|
||||
label="Настройки сцены"
|
||||
aria-controls={settingsId}
|
||||
aria-expanded={settingsOpen}
|
||||
aria-pressed={settingsOpen}
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
>
|
||||
<Icon name="settings" size={18} />
|
||||
</IconButton>
|
||||
{settingsOpen ? (
|
||||
<div
|
||||
id={settingsId}
|
||||
className="simulation-viewport__settings"
|
||||
role="dialog"
|
||||
aria-label="Настройки сцены"
|
||||
>
|
||||
<div className="simulation-viewport__settings-head">
|
||||
<div>
|
||||
<strong>Настройки сцены</strong>
|
||||
<span>Gaussian-мир и камера</span>
|
||||
</div>
|
||||
<IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<section className="simulation-viewport__settings-group">
|
||||
<div className="simulation-viewport__settings-group-head">
|
||||
<strong>Система координат</strong>
|
||||
<span>Мир PlayCanvas · Y вверх</span>
|
||||
</div>
|
||||
<p>
|
||||
Коррекция слоёв не меняет координаты камеры, навигации и будущей физики.
|
||||
</p>
|
||||
<div className="simulation-viewport__settings-switches">
|
||||
<div className="simulation-viewport__settings-layer-row">
|
||||
<Switch
|
||||
checked={visualInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия визуального слоя"
|
||||
onChange={(checked) => {
|
||||
setVisualInverted(checked);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"visual",
|
||||
playCanvasAxisTransform(visualAxis, checked),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
visual: { ...viewerSettingsRef.current.visual, inverted: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="simulation-viewport__axis-select"
|
||||
label="Ось инверсии визуального слоя"
|
||||
value={visualAxis}
|
||||
options={AXIS_OPTIONS}
|
||||
disabled={state !== "ready"}
|
||||
minMenuWidth={72}
|
||||
menuWidth={72}
|
||||
onChange={(axis) => {
|
||||
setVisualAxis(axis);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"visual",
|
||||
playCanvasAxisTransform(axis, visualInverted),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
visual: { ...viewerSettingsRef.current.visual, axis },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="simulation-viewport__settings-layer-row">
|
||||
<Switch
|
||||
checked={collisionInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия collision-слоя"
|
||||
onChange={(checked) => {
|
||||
setCollisionInverted(checked);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"collision",
|
||||
playCanvasAxisTransform(collisionAxis, checked),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
collision: { ...viewerSettingsRef.current.collision, inverted: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="simulation-viewport__axis-select"
|
||||
label="Ось инверсии collision-слоя"
|
||||
value={collisionAxis}
|
||||
options={AXIS_OPTIONS}
|
||||
disabled={state !== "ready"}
|
||||
minMenuWidth={72}
|
||||
menuWidth={72}
|
||||
onChange={(axis) => {
|
||||
setCollisionAxis(axis);
|
||||
runtimeRef.current?.setLayerWorldTransform(
|
||||
"collision",
|
||||
playCanvasAxisTransform(axis, collisionInverted),
|
||||
);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
collision: { ...viewerSettingsRef.current.collision, axis },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="simulation-viewport__settings-group">
|
||||
<div className="simulation-viewport__settings-group-head">
|
||||
<strong>Управление камерой</strong>
|
||||
<span>Мышь и тачпад</span>
|
||||
</div>
|
||||
<div className="simulation-viewport__settings-switches">
|
||||
<Switch
|
||||
checked={horizontalInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия управления по горизонтали"
|
||||
onChange={(checked) => {
|
||||
setHorizontalInverted(checked);
|
||||
runtimeRef.current?.setCameraInversion("horizontal", checked);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
camera: { ...viewerSettingsRef.current.camera, invertHorizontal: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
checked={verticalInverted}
|
||||
disabled={state !== "ready"}
|
||||
label="Инверсия управления по вертикали"
|
||||
onChange={(checked) => {
|
||||
setVerticalInverted(checked);
|
||||
runtimeRef.current?.setCameraInversion("vertical", checked);
|
||||
commitViewerSettings({
|
||||
...viewerSettingsRef.current,
|
||||
camera: { ...viewerSettingsRef.current.camera, invertVertical: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{settingsError ? (
|
||||
<p className="simulation-viewport__settings-error" role="alert">
|
||||
Настройки не сохранены: {settingsError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<span className="simulation-viewport__toolbar-balance" aria-hidden="true" />
|
||||
</header>
|
||||
<div className="simulation-viewport__stage">
|
||||
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
||||
{state !== "ready" ? (
|
||||
<div className="simulation-viewport__state" role={state === "failed" ? "alert" : "status"}>
|
||||
{state === "failed" ? null : <ActivityIndicator label="Загрузка сцены" />}
|
||||
<strong>{state === "failed" ? "Сцена не открылась" : "PlayCanvas загружает мир"}</strong>
|
||||
<p>{error ?? "Streamed SOG догружается по мере готовности runtime."}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!collisionAvailable ? (
|
||||
<footer className="simulation-viewport__notice">
|
||||
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
||||
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
|
||||
</footer>
|
||||
) : collisionState === "failed" ? (
|
||||
<footer className="simulation-viewport__notice" role="alert">
|
||||
<StatusBadge tone="warning">Collision не открылся</StatusBadge>
|
||||
<span>{error ?? "PlayCanvas не смог загрузить collision GLB."}</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FeatureSettingsWindow,
|
||||
FieldFrame,
|
||||
Icon,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
applyComputeContourNetwork,
|
||||
fetchComputeContourAgentInstall,
|
||||
fetchComputeContourNetwork,
|
||||
type ComputeContour,
|
||||
type ComputeContourAgentInstall,
|
||||
type ComputeContourDraft,
|
||||
type ComputeContourNetworkStatus,
|
||||
type ComputeContourPlatform,
|
||||
} from "../../core/system/computeContours";
|
||||
import {
|
||||
DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
|
||||
parseTelemetryPollIntervalDraft,
|
||||
resolveTelemetryPollIntervalDraft,
|
||||
} from "../../core/system/telemetryPollInterval";
|
||||
|
||||
interface ComputeContourSettingsWindowProps {
|
||||
open: boolean;
|
||||
contour: ComputeContour | null;
|
||||
mode: "create" | "edit";
|
||||
onClose: () => void;
|
||||
onCreate: (draft: ComputeContourDraft) => Promise<ComputeContour>;
|
||||
onUpdate: (
|
||||
contour: ComputeContour,
|
||||
draft: ComputeContourDraft,
|
||||
) => Promise<ComputeContour>;
|
||||
}
|
||||
|
||||
const platformOptions = [
|
||||
{ value: "windows", label: "Windows", description: "Worker с Windows service" },
|
||||
{ value: "linux", label: "Linux", description: "Worker с systemd service" },
|
||||
{ value: "unknown", label: "Не определено", description: "Платформа будет уточнена" },
|
||||
] satisfies Array<{
|
||||
value: ComputeContourPlatform;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
function emptyDraft(): ComputeContourDraft {
|
||||
return {
|
||||
display_name: "",
|
||||
expected_node_id: "",
|
||||
platform: "unknown",
|
||||
telemetry_mode: "agent-mqtt",
|
||||
address: "",
|
||||
ssh_port: 22,
|
||||
mqtt_host: "127.0.0.1",
|
||||
mqtt_port: 1883,
|
||||
mqtt_bind_address: "127.0.0.1",
|
||||
telemetry_poll_interval_seconds: DEFAULT_TELEMETRY_POLL_INTERVAL_SECONDS,
|
||||
mqtt_publish_interval_seconds: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFromContour(contour: ComputeContour | null): ComputeContourDraft {
|
||||
if (!contour) return emptyDraft();
|
||||
return {
|
||||
display_name: contour.display_name,
|
||||
expected_node_id: contour.expected_node_id,
|
||||
platform: contour.platform,
|
||||
telemetry_mode: contour.telemetry_mode,
|
||||
address: contour.address,
|
||||
ssh_port: contour.ssh_port,
|
||||
mqtt_host: contour.mqtt_host,
|
||||
mqtt_port: contour.mqtt_port,
|
||||
mqtt_bind_address: contour.mqtt_bind_address,
|
||||
telemetry_poll_interval_seconds: contour.telemetry_poll_interval_seconds,
|
||||
mqtt_publish_interval_seconds: contour.mqtt_publish_interval_seconds,
|
||||
};
|
||||
}
|
||||
|
||||
export function ComputeContourSettingsWindow({
|
||||
open,
|
||||
contour,
|
||||
mode,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: ComputeContourSettingsWindowProps) {
|
||||
const [draft, setDraft] = useState<ComputeContourDraft>(() => draftFromContour(contour));
|
||||
const [telemetryPollIntervalDraft, setTelemetryPollIntervalDraft] = useState(
|
||||
() => String(draftFromContour(contour).telemetry_poll_interval_seconds),
|
||||
);
|
||||
const [mqttPublishIntervalDraft, setMqttPublishIntervalDraft] = useState(
|
||||
() => String(draftFromContour(contour).mqtt_publish_interval_seconds),
|
||||
);
|
||||
const [activeSection, setActiveSection] = useState<"connection" | "agent">("connection");
|
||||
const [install, setInstall] = useState<ComputeContourAgentInstall | null>(null);
|
||||
const [network, setNetwork] = useState<ComputeContourNetworkStatus | null>(null);
|
||||
const [networkAction, setNetworkAction] = useState<
|
||||
"refresh" | "broker" | "worker" | null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const nextDraft = draftFromContour(mode === "edit" ? contour : null);
|
||||
setDraft(nextDraft);
|
||||
setTelemetryPollIntervalDraft(String(nextDraft.telemetry_poll_interval_seconds));
|
||||
setMqttPublishIntervalDraft(String(nextDraft.mqtt_publish_interval_seconds));
|
||||
setActiveSection("connection");
|
||||
setInstall(null);
|
||||
setNetwork(null);
|
||||
setNetworkAction(null);
|
||||
setError(null);
|
||||
}, [contour, mode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== "edit" || !contour) return;
|
||||
const controller = new AbortController();
|
||||
setNetworkAction("refresh");
|
||||
void fetchComputeContourNetwork(contour.contour_id, controller.signal)
|
||||
.then((document) => {
|
||||
if (!controller.signal.aborted) setNetwork(document);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setNetworkAction(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [contour, mode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || mode !== "edit" || !contour) return;
|
||||
const controller = new AbortController();
|
||||
void fetchComputeContourAgentInstall(contour.contour_id, controller.signal)
|
||||
.then((document) => {
|
||||
if (!controller.signal.aborted) setInstall(document);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : "Инструкция агента недоступна.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [contour, mode, open]);
|
||||
|
||||
const telemetryPollIntervalSeconds = useMemo(
|
||||
() => parseTelemetryPollIntervalDraft(telemetryPollIntervalDraft),
|
||||
[telemetryPollIntervalDraft],
|
||||
);
|
||||
const mqttPublishIntervalSeconds = useMemo(
|
||||
() => parseTelemetryPollIntervalDraft(mqttPublishIntervalDraft),
|
||||
[mqttPublishIntervalDraft],
|
||||
);
|
||||
|
||||
const valid = useMemo(() => (
|
||||
Boolean(draft.display_name.trim())
|
||||
&& Boolean(draft.expected_node_id.trim())
|
||||
&& Boolean(draft.mqtt_host.trim())
|
||||
&& Boolean(draft.mqtt_bind_address.trim())
|
||||
&& Number.isInteger(draft.ssh_port)
|
||||
&& draft.ssh_port > 0
|
||||
&& Number.isInteger(draft.mqtt_port)
|
||||
&& draft.mqtt_port > 0
|
||||
&& telemetryPollIntervalSeconds !== null
|
||||
&& mqttPublishIntervalSeconds !== null
|
||||
), [draft, mqttPublishIntervalSeconds, telemetryPollIntervalSeconds]);
|
||||
|
||||
const networkProfileDirty = Boolean(
|
||||
contour
|
||||
&& (
|
||||
draft.mqtt_host !== contour.mqtt_host
|
||||
|| draft.mqtt_port !== contour.mqtt_port
|
||||
|| draft.mqtt_bind_address !== contour.mqtt_bind_address
|
||||
|| draft.mqtt_publish_interval_seconds !== contour.mqtt_publish_interval_seconds
|
||||
),
|
||||
);
|
||||
|
||||
const commitTelemetryPollInterval = () => {
|
||||
const resolution = resolveTelemetryPollIntervalDraft(
|
||||
telemetryPollIntervalDraft,
|
||||
draft.telemetry_poll_interval_seconds,
|
||||
);
|
||||
setTelemetryPollIntervalDraft(resolution.draft);
|
||||
if (!resolution.accepted) return;
|
||||
setDraft((current) => (
|
||||
current.telemetry_poll_interval_seconds === resolution.seconds
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
telemetry_poll_interval_seconds: resolution.seconds,
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
const commitMqttPublishInterval = () => {
|
||||
const resolution = resolveTelemetryPollIntervalDraft(
|
||||
mqttPublishIntervalDraft,
|
||||
draft.mqtt_publish_interval_seconds,
|
||||
);
|
||||
setMqttPublishIntervalDraft(resolution.draft);
|
||||
if (!resolution.accepted) return;
|
||||
setDraft((current) => (
|
||||
current.mqtt_publish_interval_seconds === resolution.seconds
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
mqtt_publish_interval_seconds: resolution.seconds,
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (
|
||||
!valid
|
||||
|| busy
|
||||
|| telemetryPollIntervalSeconds === null
|
||||
|| mqttPublishIntervalSeconds === null
|
||||
) return;
|
||||
const committedDraft = {
|
||||
...draft,
|
||||
telemetry_poll_interval_seconds: telemetryPollIntervalSeconds,
|
||||
mqtt_publish_interval_seconds: mqttPublishIntervalSeconds,
|
||||
};
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "edit" && contour) {
|
||||
await onUpdate(contour, committedDraft);
|
||||
} else {
|
||||
await onCreate(committedDraft);
|
||||
}
|
||||
onClose();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Контур не сохранён.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshNetwork = async () => {
|
||||
if (!contour || networkAction) return;
|
||||
setNetworkAction("refresh");
|
||||
setError(null);
|
||||
try {
|
||||
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Сетевая проверка недоступна.");
|
||||
} finally {
|
||||
setNetworkAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const applyNetwork = async (target: "broker" | "worker") => {
|
||||
if (!contour || networkAction || networkProfileDirty) return;
|
||||
setNetworkAction(target);
|
||||
setError(null);
|
||||
try {
|
||||
await applyComputeContourNetwork(contour.contour_id, target);
|
||||
setNetwork(await fetchComputeContourNetwork(contour.contour_id));
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Сетевой профиль не применён.",
|
||||
);
|
||||
} finally {
|
||||
setNetworkAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureSettingsWindow
|
||||
open={open}
|
||||
title={mode === "create" ? "Добавить вычислительный контур" : "Настройки контура"}
|
||||
subtitle="Локальная переносимая конфигурация"
|
||||
identity={{
|
||||
title: contour?.display_name ?? "Новый контур",
|
||||
subtitle: contour?.expected_node_id ?? "Node ID не назначен",
|
||||
avatarLabel: contour?.display_name.slice(0, 2).toUpperCase() ?? "WC",
|
||||
}}
|
||||
sections={[
|
||||
{
|
||||
id: "connection",
|
||||
label: "Подключение",
|
||||
group: "ВЫЧИСЛИТЕЛЬНЫЙ КОНТУР",
|
||||
icon: "network",
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
label: "Агент",
|
||||
group: "ТЕЛЕМЕТРИЯ",
|
||||
icon: "activity",
|
||||
disabled: mode === "create",
|
||||
},
|
||||
]}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button variant="secondary" onClick={onClose}>Отмена</Button>
|
||||
<Button variant="primary" disabled={!valid || busy} onClick={() => void save()}>
|
||||
{busy ? "Сохраняем…" : mode === "create" ? "Добавить контур" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<div className="compute-contour-settings">
|
||||
{activeSection === "connection" ? (
|
||||
<>
|
||||
<SettingsCard
|
||||
eyebrow="КОНТУР"
|
||||
title="Идентичность и локальная сеть"
|
||||
description="Node ID проверяется независимо от IP-адреса. Адрес можно менять при переносе комплекта в другую локальную сеть."
|
||||
>
|
||||
<div className="compute-contour-settings__form">
|
||||
<TextField
|
||||
label="Название"
|
||||
value={draft.display_name}
|
||||
placeholder="Worker 006"
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
display_name: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Ожидаемый Node ID"
|
||||
value={draft.expected_node_id}
|
||||
placeholder="DESKTOP-OPJ8J04"
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
expected_node_id: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<FieldFrame label="Операционная система">
|
||||
<Select
|
||||
label="Операционная система"
|
||||
value={draft.platform}
|
||||
options={platformOptions}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={(platform) => setDraft((current) => ({ ...current, platform }))}
|
||||
/>
|
||||
</FieldFrame>
|
||||
<TextField
|
||||
label="Адрес узла"
|
||||
hint="IP или hostname в текущей LAN"
|
||||
value={draft.address}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
address: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="MQTT broker"
|
||||
hint="Стабильный hostname рекомендуется вместо выдаваемого DHCP IP"
|
||||
value={draft.mqtt_host}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_host: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="MQTT port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(draft.mqtt_port)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_port: Number(event.currentTarget.value),
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Публикация broker"
|
||||
hint="IP текущего Mac в разрешённой LAN · 0.0.0.0 запрещён"
|
||||
value={draft.mqtt_bind_address}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
mqtt_bind_address: event.currentTarget.value,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Обновление интерфейса"
|
||||
hint="1–60 с"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
aria-invalid={telemetryPollIntervalSeconds === null}
|
||||
value={telemetryPollIntervalDraft}
|
||||
onChange={(event) => setTelemetryPollIntervalDraft(event.currentTarget.value)}
|
||||
onBlur={commitTelemetryPollInterval}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
commitTelemetryPollInterval();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTelemetryPollIntervalDraft(
|
||||
String(draft.telemetry_poll_interval_seconds),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Интервал MQTT агента"
|
||||
hint="1–60 с · применяется конфигурацией агента"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
aria-invalid={mqttPublishIntervalSeconds === null}
|
||||
value={mqttPublishIntervalDraft}
|
||||
onChange={(event) => setMqttPublishIntervalDraft(event.currentTarget.value)}
|
||||
onBlur={commitMqttPublishInterval}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
commitMqttPublishInterval();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMqttPublishIntervalDraft(
|
||||
String(draft.mqtt_publish_interval_seconds),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{draft.telemetry_mode === "legacy-ssh" ? (
|
||||
<TextField
|
||||
label="SSH port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={String(draft.ssh_port)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
ssh_port: Number(event.currentTarget.value),
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
eyebrow="СЕТЕВОЙ ПРОФИЛЬ"
|
||||
title="MQTT · Mac ↔ Worker"
|
||||
description="Mesh-точки одной LAN не закрепляются по отдельности. Профиль хранит стабильное имя Mac и адрес публикации broker в текущей сети."
|
||||
actions={(
|
||||
<StatusBadge tone={network?.broker.ready ? "success" : "warning"}>
|
||||
{network?.broker.ready ? "Broker доступен" : "Нужна проверка"}
|
||||
</StatusBadge>
|
||||
)}
|
||||
>
|
||||
<div className="compute-contour-settings__network">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Endpoint Worker</dt>
|
||||
<dd>{network
|
||||
? `${network.broker.configured_host}:${network.broker.configured_port}`
|
||||
: "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Текущий IP</dt>
|
||||
<dd>{network?.broker.resolved_addresses.join(", ") || "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Публикация Mac</dt>
|
||||
<dd>{network?.broker.bind_address ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{contour?.display_name ?? "Worker"}</dt>
|
||||
<dd>{network?.worker.reachable
|
||||
? `${network.worker.mqtt_host ?? "—"}:${network.worker.mqtt_port ?? "—"}`
|
||||
: "Недоступен"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="compute-contour-settings__network-statuses">
|
||||
<StatusBadge tone={network?.broker.bind_reachable ? "success" : "danger"}>
|
||||
{network?.broker.bind_reachable
|
||||
? "Listener Mac доступен"
|
||||
: "Listener Mac недоступен"}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={network?.worker.broker_reachable ? "success" : "danger"}>
|
||||
{network?.worker.broker_reachable
|
||||
? "Worker видит broker"
|
||||
: "Worker не видит broker"}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={network?.worker.matches_profile ? "success" : "warning"}>
|
||||
{network?.worker.matches_profile
|
||||
? "Профиль синхронизирован"
|
||||
: "Профиль отличается"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="compute-contour-settings__network-actions">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={Boolean(networkAction)}
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
onClick={() => void refreshNetwork()}
|
||||
>
|
||||
{networkAction === "refresh" ? "Проверяем…" : "Проверить"}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={Boolean(networkAction) || networkProfileDirty}
|
||||
onClick={() => void applyNetwork("broker")}
|
||||
>
|
||||
{networkAction === "broker" ? "Применяем…" : "Применить на Mac"}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={Boolean(networkAction) || networkProfileDirty}
|
||||
onClick={() => void applyNetwork("worker")}
|
||||
>
|
||||
{networkAction === "worker" ? "Применяем…" : "Применить на Worker"}
|
||||
</Button>
|
||||
</div>
|
||||
{networkProfileDirty ? (
|
||||
<p>Сначала сохраните изменённый сетевой профиль, затем примените его.</p>
|
||||
) : null}
|
||||
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeSection === "agent" ? (
|
||||
<SettingsCard
|
||||
eyebrow="АГЕНТ"
|
||||
title="Telegraf · универсальный сборщик"
|
||||
description="Hardware и runtime собирает готовый агент. Mission Core нормализует только контур, LAB/run и стадии обработки."
|
||||
actions={(
|
||||
<StatusBadge tone={install?.ready ? "success" : "warning"}>
|
||||
{install?.ready ? "Готов к установке" : "Нужен scoped MQTT credential"}
|
||||
</StatusBadge>
|
||||
)}
|
||||
>
|
||||
{install ? (
|
||||
<div className="compute-contour-settings__install">
|
||||
<dl>
|
||||
<div><dt>Шаблон</dt><dd>{install.agent.configuration_template}</dd></div>
|
||||
<div><dt>Секрет</dt><dd>интерактивный ввод · не хранится в UI</dd></div>
|
||||
</dl>
|
||||
<code>{install.command}</code>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="copy" size={14} />}
|
||||
onClick={() => void navigator.clipboard.writeText(install.command)}
|
||||
>
|
||||
Копировать команду
|
||||
</Button>
|
||||
{install.blocked_reason ? <p>{install.blocked_reason}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="compute-contour-settings__empty">
|
||||
Сохраните контур, чтобы получить платформенную инструкцию установки.
|
||||
</p>
|
||||
)}
|
||||
{error ? <StatusBadge tone="danger">{error}</StatusBadge> : null}
|
||||
</SettingsCard>
|
||||
) : null}
|
||||
</div>
|
||||
</FeatureSettingsWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
|
||||
interface SystemNavigationPanelProps {
|
||||
title: string;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SystemNavigationPanel({
|
||||
title,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: SystemNavigationPanelProps) {
|
||||
const { contours, selectedContour, selectContour } = useComputeContours();
|
||||
const contourCountLabel = contours.length === 1
|
||||
? "1 конфигурация"
|
||||
: `${contours.length} конфигураций`;
|
||||
|
||||
return (
|
||||
<AdminNavigationPanel
|
||||
eyebrow="MISSION CORE"
|
||||
title={title}
|
||||
closeLabel={`Закрыть раздел «${title}»`}
|
||||
navigationLabel="Вычислительные контуры"
|
||||
onClose={onClose}
|
||||
headerActions={(
|
||||
<IconButton label="Добавить вычислительный контур" onClick={onAdd}>
|
||||
<Icon name="plus" size={16} strokeWidth={1.6} />
|
||||
</IconButton>
|
||||
)}
|
||||
contexts={contours.map((contour) => ({
|
||||
id: contour.contour_id,
|
||||
label: contour.display_name,
|
||||
description: contour.expected_node_id,
|
||||
icon: <Icon name="apps" />,
|
||||
active: selectedContour?.contour_id === contour.contour_id,
|
||||
onSelect: () => selectContour(contour.contour_id),
|
||||
}))}
|
||||
items={[]}
|
||||
onItemChange={() => undefined}
|
||||
footer={(
|
||||
<>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
<Icon name="activity" />
|
||||
</span>
|
||||
<span>{contourCountLabel}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SegmentedControl } from "@nodedc/ui-react";
|
||||
|
||||
import { workspacesForRoot } from "../../productModel";
|
||||
|
||||
const systemWorkspaces = workspacesForRoot("system");
|
||||
const compactLabels: Record<string, string> = {
|
||||
modules: "Модули",
|
||||
integrations: "Интеграции",
|
||||
network: "Сеть",
|
||||
audit: "Аудит",
|
||||
};
|
||||
|
||||
interface SystemWorkspaceSelectorProps {
|
||||
value: string;
|
||||
onChange: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export function SystemWorkspaceSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: SystemWorkspaceSelectorProps) {
|
||||
return (
|
||||
<SegmentedControl
|
||||
value={value}
|
||||
items={systemWorkspaces.map((definition) => ({
|
||||
value: definition.id,
|
||||
label: compactLabels[definition.id] ?? definition.label,
|
||||
}))}
|
||||
label="Рабочая поверхность вычислительного контура"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
interface TelemetrySeriesProps {
|
||||
label: string;
|
||||
values: Array<number | null>;
|
||||
value: string;
|
||||
resource?: string | null;
|
||||
ceiling?: number;
|
||||
}
|
||||
|
||||
function linePoints(values: Array<number | null>, ceiling?: number): string {
|
||||
const finite = values.filter((value): value is number =>
|
||||
value !== null && Number.isFinite(value),
|
||||
);
|
||||
if (finite.length === 0) return "";
|
||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1);
|
||||
const denominator = Math.max(1, values.length - 1);
|
||||
return values.map((value, index) => {
|
||||
const x = index / denominator * 100;
|
||||
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value));
|
||||
const y = 36 - normalized / maximum * 34;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
export function TelemetrySeries({
|
||||
label,
|
||||
values,
|
||||
value,
|
||||
resource,
|
||||
ceiling,
|
||||
}: TelemetrySeriesProps) {
|
||||
const points = linePoints(values, ceiling);
|
||||
return (
|
||||
<div className="system-telemetry-series">
|
||||
<div>
|
||||
<span className="system-telemetry-series__label">
|
||||
<span>{label}</span>
|
||||
{resource ? <small>{resource}</small> : null}
|
||||
</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 100 38"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`${label}: ${value}`}
|
||||
>
|
||||
<path d="M0 37 H100" />
|
||||
{points ? <polyline points={points} /> : null}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { WorkerPipelineStage } from "../../core/system/workerTelemetry";
|
||||
|
||||
interface WorkerPipelineStagesProps {
|
||||
stages: WorkerPipelineStage[];
|
||||
}
|
||||
|
||||
function stateLabel(state: WorkerPipelineStage["state"]): string {
|
||||
if (state === "active") return "выполняется";
|
||||
if (state === "waiting") return "ожидает";
|
||||
if (state === "ready") return "готов";
|
||||
return "нет live-состояния";
|
||||
}
|
||||
|
||||
function stageDuration(value: number | null): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
if (value < 1) return `${Math.round(value * 1000)} мс`;
|
||||
return `${new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: value < 10 ? 2 : 1,
|
||||
}).format(value)} с`;
|
||||
}
|
||||
|
||||
function stageDetail(stage: WorkerPipelineStage): string {
|
||||
if (
|
||||
typeof stage.activations !== "number"
|
||||
|| typeof stage.share_percent !== "number"
|
||||
|| !Number.isFinite(stage.share_percent)
|
||||
) {
|
||||
return "Измерений ещё нет";
|
||||
}
|
||||
const share = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
}).format(stage.share_percent);
|
||||
return `${stage.activations} проходов · ${share}% измеренного времени`;
|
||||
}
|
||||
|
||||
export function WorkerPipelineStages({ stages }: WorkerPipelineStagesProps) {
|
||||
return (
|
||||
<ol className="worker-stage-list" aria-label="Стадии текущей задачи">
|
||||
{stages.map((stage, index) => {
|
||||
const share = typeof stage.share_percent === "number"
|
||||
? Math.max(0, Math.min(100, stage.share_percent))
|
||||
: 0;
|
||||
const style = {
|
||||
"--worker-stage-share": `${share}%`,
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<li
|
||||
key={stage.id}
|
||||
data-state={stage.state}
|
||||
style={style}
|
||||
aria-label={`${stage.label}: ${stageDetail(stage)}`}
|
||||
>
|
||||
<span className="worker-stage-list__fill" aria-hidden="true" />
|
||||
<span className="worker-stage-list__index">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{stage.label}</strong>
|
||||
<small>{stageDetail(stage)}</small>
|
||||
</div>
|
||||
<div className="worker-stage-list__value">
|
||||
<strong>{stageDuration(stage.elapsed_seconds)}</strong>
|
||||
<small>{stateLabel(stage.state)}</small>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { WorkerRuntime } from "../../core/system/workerTelemetry";
|
||||
import {
|
||||
formatOptionalPercent,
|
||||
runtimeStateLabel,
|
||||
runtimeTone,
|
||||
} from "./systemFormat";
|
||||
|
||||
export function WorkerRuntimeCard({ runtime }: { runtime: WorkerRuntime }) {
|
||||
return (
|
||||
<GlassSurface
|
||||
className="worker-runtime-card"
|
||||
padding="md"
|
||||
tone={runtime.external ? "soft" : "default"}
|
||||
data-external={runtime.external ? "true" : undefined}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{runtime.external ? "ВНЕШНЯЯ НАГРУЗКА" : "MISSION CORE RUNTIME"}
|
||||
</span>
|
||||
<h3>{runtime.role}</h3>
|
||||
<code>{runtime.name}</code>
|
||||
</div>
|
||||
<StatusBadge tone={runtimeTone(runtime)}>
|
||||
{runtimeStateLabel(runtime)}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>CPU</dt>
|
||||
<dd>{formatOptionalPercent(runtime.cpu_percent)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Память</dt>
|
||||
<dd>{runtime.memory_usage ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Сеть I/O</dt>
|
||||
<dd>{runtime.network_io ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Процессы</dt>
|
||||
<dd>{runtime.pids ?? "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { StatusTone } from "@nodedc/ui-react";
|
||||
|
||||
import type { WorkerRuntime } from "../../core/system/workerTelemetry";
|
||||
|
||||
export function formatOptionalPercent(value: number | null | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1 }).format(value)}%`
|
||||
: "—";
|
||||
}
|
||||
|
||||
export function formatBytes(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"];
|
||||
let current = Math.max(0, value);
|
||||
let unit = 0;
|
||||
while (current >= 1024 && unit < units.length - 1) {
|
||||
current /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: current >= 100 ? 0 : 1,
|
||||
}).format(current)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function formatRate(value: number | null | undefined): string {
|
||||
const formatted = formatBytes(value);
|
||||
return formatted === "—" ? formatted : `${formatted}/с`;
|
||||
}
|
||||
|
||||
export function formatBitRate(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
const units = ["бит/с", "Кбит/с", "Мбит/с", "Гбит/с"];
|
||||
let current = Math.max(0, value);
|
||||
let unit = 0;
|
||||
while (current >= 1000 && unit < units.length - 1) {
|
||||
current /= 1000;
|
||||
unit += 1;
|
||||
}
|
||||
return `${new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: current >= 100 ? 0 : 1,
|
||||
}).format(current)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
const hours = Math.floor(value / 3600);
|
||||
if (hours < 24) return `${hours} ч`;
|
||||
return `${Math.floor(hours / 24)} д ${hours % 24} ч`;
|
||||
}
|
||||
|
||||
export function formatLatency(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return `${Math.round(value)} мс`;
|
||||
}
|
||||
|
||||
export function runtimeTone(runtime: WorkerRuntime): StatusTone {
|
||||
if (runtime.state !== "running") return "danger";
|
||||
if (runtime.health && runtime.health !== "healthy") return "warning";
|
||||
return runtime.external ? "neutral" : "success";
|
||||
}
|
||||
|
||||
export function runtimeStateLabel(runtime: WorkerRuntime): string {
|
||||
if (runtime.state !== "running") return "Недоступен";
|
||||
if (runtime.health === "healthy") return "Работает";
|
||||
if (runtime.health) return runtime.health;
|
||||
return "Запущен";
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
|
||||
|
||||
import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
import { ComputeContourSettingsWindow } from "./ComputeContourSettingsWindow";
|
||||
|
||||
interface ComputeContourSettingsController {
|
||||
canEdit: boolean;
|
||||
openCreate: () => void;
|
||||
openEdit: () => void;
|
||||
utilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
window: ReactNode;
|
||||
}
|
||||
|
||||
export function useComputeContourSettings(): ComputeContourSettingsController {
|
||||
const contours = useComputeContours();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"create" | "edit">("edit");
|
||||
const openCreate = useCallback(() => {
|
||||
setMode("create");
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const openEdit = useCallback(() => {
|
||||
setMode("edit");
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const utilityActions = useMemo<readonly ApplicationPanelUtilityAction[]>(() => [
|
||||
{
|
||||
label: "Обновить телеметрию выбранного контура",
|
||||
icon: "refresh",
|
||||
disabled: contours.selectedContour === null,
|
||||
onClick: contours.refreshTelemetry,
|
||||
},
|
||||
{
|
||||
label: "Настроить выбранный вычислительный контур",
|
||||
icon: "settings",
|
||||
disabled: contours.selectedContour === null,
|
||||
onClick: openEdit,
|
||||
},
|
||||
], [
|
||||
contours.refreshTelemetry,
|
||||
contours.selectedContour,
|
||||
openEdit,
|
||||
]);
|
||||
|
||||
return {
|
||||
canEdit: contours.selectedContour !== null,
|
||||
openCreate,
|
||||
openEdit,
|
||||
utilityActions,
|
||||
window: (
|
||||
<ComputeContourSettingsWindow
|
||||
open={open}
|
||||
mode={mode}
|
||||
contour={contours.selectedContour}
|
||||
onClose={() => setOpen(false)}
|
||||
onCreate={contours.createContour}
|
||||
onUpdate={contours.updateContour}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ApplicationPanelUtilityAction } from "@nodedc/ui-react";
|
||||
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
saveWorkspaceLayout: () => Promise<void>;
|
||||
workspaceLayoutSaving: boolean;
|
||||
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
}
|
||||
|
||||
export function deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
}: Pick<
|
||||
ApplicationPanelActionsOptions,
|
||||
"refreshRuntime" | "resetConnectionScenario" | "connectionScenarioResetting"
|
||||
>): ApplicationPanelUtilityAction {
|
||||
return {
|
||||
label: resetConnectionScenario
|
||||
? connectionScenarioResetting
|
||||
? "Сбрасываем подключение"
|
||||
: "Сбросить подключение"
|
||||
: "Обновить состояние локального контура",
|
||||
icon: resetConnectionScenario && connectionScenarioResetting
|
||||
? "activity"
|
||||
: "refresh",
|
||||
disabled: resetConnectionScenario && connectionScenarioResetting
|
||||
? true
|
||||
: undefined,
|
||||
onClick: resetConnectionScenario
|
||||
? connectionScenarioResetting
|
||||
? () => undefined
|
||||
: () => void resetConnectionScenario()
|
||||
: refreshRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving,
|
||||
systemUtilityActions,
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "device") {
|
||||
actions.push(deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
}));
|
||||
}
|
||||
if (definition && ["spatial", "recordings"].includes(definition.kind)) {
|
||||
actions.push({
|
||||
label: workspaceLayoutSaving ? "Сохраняем компоновку" : "Сохранить компоновку",
|
||||
icon: "save",
|
||||
disabled: workspaceLayoutSaving,
|
||||
onClick: () => void saveWorkspaceLayout(),
|
||||
});
|
||||
}
|
||||
if (definition?.root === "system") actions.push(...systemUtilityActions);
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
saveWorkspaceLayout,
|
||||
systemUtilityActions,
|
||||
workspaceLayoutSaving,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||
|
||||
// Composition root: this is the only place where Mission Core chooses which
|
||||
// statically reviewed device plugins are shipped in the current build.
|
||||
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||
xgridsK1Plugin,
|
||||
]);
|
||||
@@ -0,0 +1,91 @@
|
||||
export interface ArtifactScope {
|
||||
scope_id: string;
|
||||
file_count: number;
|
||||
logical_bytes: number;
|
||||
modified_at_utc: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactHealth {
|
||||
schema_version: "missioncore.artifact-health/v1";
|
||||
generated_at_utc: string;
|
||||
overall_state: "live" | "attention" | "unavailable";
|
||||
inventory: {
|
||||
state: "live" | "degraded" | "unavailable";
|
||||
scanned_at_utc: string;
|
||||
scan_duration_ms: number;
|
||||
file_count: number;
|
||||
logical_bytes: number;
|
||||
unreadable_entries: number;
|
||||
files_delta_since_previous: number | null;
|
||||
bytes_delta_since_previous: number | null;
|
||||
scopes: ArtifactScope[];
|
||||
};
|
||||
exact_audit: {
|
||||
state: "exact-current" | "exact-stale" | "unavailable";
|
||||
result_id: string | null;
|
||||
audited_at_utc: string | null;
|
||||
root_count: number;
|
||||
file_count: number;
|
||||
logical_bytes: number;
|
||||
unique_content_bytes: number;
|
||||
duplicate_bytes: number;
|
||||
duplicate_fraction: number;
|
||||
amplification_ratio: number;
|
||||
duplicate_content_groups: number;
|
||||
changed_roots: string[];
|
||||
limitations: string[];
|
||||
};
|
||||
content_references: {
|
||||
state: "not-indexed" | "indexed";
|
||||
storage_model:
|
||||
| "materialized-copies"
|
||||
| "materialized-copies-with-reference-index";
|
||||
result_id: string | null;
|
||||
indexed_at_utc: string | null;
|
||||
logical_reference_count: number;
|
||||
canonical_content_count: number;
|
||||
duplicate_reference_count: number;
|
||||
exact_duplicate_bytes: number;
|
||||
physical_reclamation_applied: false;
|
||||
storage_migration_authorized: false;
|
||||
next_gate: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export async function fetchArtifactHealth(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactHealth> {
|
||||
const response = await fetch("/api/v1/data/artifact-health", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API здоровья данных вернул HTTP ${response.status}.`);
|
||||
}
|
||||
const document: unknown = await response.json();
|
||||
if (
|
||||
!isRecord(document)
|
||||
|| document.schema_version !== "missioncore.artifact-health/v1"
|
||||
|| !isRecord(document.inventory)
|
||||
|| !Array.isArray(document.inventory.scopes)
|
||||
|| !isRecord(document.exact_audit)
|
||||
|| !isRecord(document.content_references)
|
||||
|| !["not-indexed", "indexed"].includes(
|
||||
String(document.content_references.state),
|
||||
)
|
||||
|| ![
|
||||
"materialized-copies",
|
||||
"materialized-copies-with-reference-index",
|
||||
].includes(String(document.content_references.storage_model))
|
||||
|| document.content_references.physical_reclamation_applied !== false
|
||||
|| document.content_references.storage_migration_authorized !== false
|
||||
) {
|
||||
throw new Error("Ответ здоровья данных не соответствует контракту.");
|
||||
}
|
||||
return document as unknown as ArtifactHealth;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
||||
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
||||
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
||||
|
||||
interface DevicePluginHostValue {
|
||||
registry: DevicePluginRegistry;
|
||||
selection: RegisteredDeviceModel | null;
|
||||
selectionTransitionPending: boolean;
|
||||
selectionTransitionError: string | null;
|
||||
selectModel: (modelId: string) => Promise<boolean>;
|
||||
clearSelection: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
|
||||
|
||||
export const DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY =
|
||||
"nodedc.mission-core.device-model-selection.v1";
|
||||
|
||||
export interface DeviceModelSelectionStorage {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
function browserDeviceModelSelectionStorage(): DeviceModelSelectionStorage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore only a model that is present in the current reviewed registry.
|
||||
* Removed/renamed models and malformed browser values fail closed to the
|
||||
* picker and are cleared so a later remount cannot keep retrying stale state.
|
||||
*/
|
||||
export function restorePersistedDeviceModelId(
|
||||
registry: DevicePluginRegistry,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): string | null {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const stored = storage.getItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
const modelId = stored?.trim() || null;
|
||||
if (modelId && registry.resolveModel(modelId)) return modelId;
|
||||
if (stored !== null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist only an already-admitted host transition; browser storage is never authority. */
|
||||
export function commitPersistedDeviceModelId(
|
||||
modelId: string | null,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
if (modelId === null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY, modelId);
|
||||
} catch {
|
||||
// A denied/full localStorage must not block the in-memory host transition.
|
||||
}
|
||||
}
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
children,
|
||||
}: {
|
||||
plugins: readonly DeviceUiPlugin[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
);
|
||||
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
|
||||
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
|
||||
const transitionInFlight = useRef(false);
|
||||
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
|
||||
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
|
||||
|
||||
const registerDeactivation = useCallback(
|
||||
(pluginId: string, handler: () => Promise<boolean>) => {
|
||||
deactivationHandlers.current.set(pluginId, handler);
|
||||
return () => {
|
||||
if (deactivationHandlers.current.get(pluginId) === handler) {
|
||||
deactivationHandlers.current.delete(pluginId);
|
||||
}
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const transitionTo = useCallback(
|
||||
async (nextModelId: string | null): Promise<boolean> => {
|
||||
if (transitionInFlight.current) return false;
|
||||
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
|
||||
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
|
||||
}
|
||||
if (nextModelId === selectedModelId) {
|
||||
// `clearSelection()` must clear a stale persisted value even when the
|
||||
// current in-memory selection is already empty.
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
}
|
||||
|
||||
transitionInFlight.current = true;
|
||||
setSelectionTransitionPending(true);
|
||||
setSelectionTransitionError(null);
|
||||
try {
|
||||
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
|
||||
const deactivate = current
|
||||
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
|
||||
: undefined;
|
||||
if (current && !deactivate) {
|
||||
setSelectionTransitionError(
|
||||
"Активный плагин не зарегистрировал безопасное завершение сессии.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (deactivate) {
|
||||
try {
|
||||
if (!(await deactivate())) {
|
||||
setSelectionTransitionError(
|
||||
"Плагин не подтвердил завершение текущей сессии.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setSelectionTransitionError(
|
||||
"Не удалось безопасно завершить текущую сессию плагина.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setSelectedModelId(nextModelId);
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
} finally {
|
||||
transitionInFlight.current = false;
|
||||
setSelectionTransitionPending(false);
|
||||
}
|
||||
},
|
||||
[registry, selectedModelId, selectionStorage],
|
||||
);
|
||||
|
||||
const deactivationRegistrars = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
registry.plugins.map((plugin) => [
|
||||
plugin.manifest.metadata.id,
|
||||
(handler: () => Promise<boolean>) =>
|
||||
registerDeactivation(plugin.manifest.metadata.id, handler),
|
||||
]),
|
||||
),
|
||||
[registerDeactivation, registry.plugins],
|
||||
);
|
||||
|
||||
const value = useMemo<DevicePluginHostValue>(
|
||||
() => ({
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionPending,
|
||||
selectionTransitionError,
|
||||
selectModel: (modelId) => transitionTo(modelId),
|
||||
clearSelection: () => transitionTo(null),
|
||||
}),
|
||||
[
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionError,
|
||||
selectionTransitionPending,
|
||||
transitionTo,
|
||||
],
|
||||
);
|
||||
|
||||
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
|
||||
const RuntimeProvider = plugin.RuntimeProvider;
|
||||
return (
|
||||
<RuntimeProvider
|
||||
key={plugin.manifest.metadata.id}
|
||||
activeModel={
|
||||
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
|
||||
? selection.model
|
||||
: null
|
||||
}
|
||||
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
|
||||
>
|
||||
{child}
|
||||
</RuntimeProvider>
|
||||
);
|
||||
}, children);
|
||||
|
||||
return (
|
||||
<DevicePluginHostContext.Provider value={value}>
|
||||
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
|
||||
</DevicePluginHostContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDevicePluginHost(): DevicePluginHostValue {
|
||||
const value = useContext(DevicePluginHostContext);
|
||||
if (!value) {
|
||||
throw new Error("DevicePluginHostProvider не подключён в composition root.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const;
|
||||
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const;
|
||||
export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
|
||||
] as const);
|
||||
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
|
||||
|
||||
export type DevicePluginApiVersion =
|
||||
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
|
||||
|
||||
export interface DeviceCapability {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DevicePluginActionDefinition {
|
||||
id: string;
|
||||
mutating: boolean;
|
||||
secretFields: readonly string[];
|
||||
}
|
||||
|
||||
export interface DeviceModelDefinition {
|
||||
id: string;
|
||||
vendor: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
description: string;
|
||||
verified: boolean;
|
||||
capabilities: readonly DeviceCapability[];
|
||||
ui: {
|
||||
slot: "device.connection";
|
||||
componentKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeviceCompatibilityProfileDefinition {
|
||||
profileId: string;
|
||||
path: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
interface DevicePluginManifestBase {
|
||||
apiVersion: DevicePluginApiVersion;
|
||||
kind: "DevicePlugin";
|
||||
metadata: {
|
||||
id: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1";
|
||||
runtime: {
|
||||
backendEntrypoint: string;
|
||||
isolation: "transitional-in-process";
|
||||
};
|
||||
permissions: readonly string[];
|
||||
actions: readonly DevicePluginActionDefinition[];
|
||||
models: readonly DeviceModelDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
spec: {
|
||||
hostApiRange: "v1alpha2";
|
||||
runtime: {
|
||||
backendEntrypoint: string;
|
||||
isolation: "transitional-in-process";
|
||||
};
|
||||
permissions: readonly string[];
|
||||
actions: readonly DevicePluginActionDefinition[];
|
||||
models: readonly DeviceModelDefinition[];
|
||||
compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export type DevicePluginManifest =
|
||||
| DevicePluginManifestV1Alpha1
|
||||
| DevicePluginManifestV1Alpha2;
|
||||
|
||||
export function isDevicePluginManifestV1Alpha2(
|
||||
manifest: DevicePluginManifest,
|
||||
): manifest is DevicePluginManifestV1Alpha2 {
|
||||
return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
}
|
||||
|
||||
export interface DevicePluginHostActions {
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
model: DeviceModelDefinition;
|
||||
host: DevicePluginHostActions;
|
||||
}
|
||||
|
||||
export interface DeviceUiPlugin {
|
||||
manifest: DevicePluginManifest;
|
||||
RuntimeProvider: ComponentType<{
|
||||
activeModel: DeviceModelDefinition | null;
|
||||
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
|
||||
export interface RegisteredDeviceModel {
|
||||
plugin: DeviceUiPlugin;
|
||||
model: DeviceModelDefinition;
|
||||
ConnectionView: ComponentType<DevicePluginConnectionProps>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Public frontend host surface for statically reviewed device plugins.
|
||||
*
|
||||
* Plugin source may depend on this module through the
|
||||
* `@mission-core/plugin-sdk` alias. It must not reach into Control Station
|
||||
* implementation paths directly.
|
||||
*/
|
||||
export * from "./contracts";
|
||||
export { parseDevicePluginManifest, requirePluginAction } from "./manifestParser";
|
||||
export {
|
||||
MissionRuntimeProvider,
|
||||
useMissionRuntime,
|
||||
} from "../runtime/MissionRuntimeContext";
|
||||
export type * from "../runtime/contracts";
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
|
||||
type DeviceCapability,
|
||||
type DeviceCompatibilityProfileDefinition,
|
||||
type DeviceModelDefinition,
|
||||
type DevicePluginActionDefinition,
|
||||
type DevicePluginManifest,
|
||||
type DevicePluginManifestV1Alpha1,
|
||||
type DevicePluginManifestV1Alpha2,
|
||||
} from "./contracts";
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
path: string,
|
||||
allowed: readonly string[],
|
||||
): void {
|
||||
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (extras.length) {
|
||||
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maxLength = 160): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
throw new Error(
|
||||
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, path: string): string {
|
||||
const candidate = text(value, path, 192);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) {
|
||||
throw new Error(`Некорректный manifest: ${path} не является идентификатором.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function flag(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function list(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
|
||||
return strictIdentifiers ? identifier(value, path) : text(value, path);
|
||||
}
|
||||
|
||||
function capability(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DeviceCapability {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "label"]);
|
||||
return {
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
label: text(item.label, `${path}.label`),
|
||||
};
|
||||
}
|
||||
|
||||
function action(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DevicePluginActionDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "mutating", "secretFields"]);
|
||||
return {
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
mutating: flag(item.mutating, `${path}.mutating`),
|
||||
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
|
||||
contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function model(
|
||||
value: unknown,
|
||||
path: string,
|
||||
strictIdentifiers: boolean,
|
||||
): DeviceModelDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, [
|
||||
"id",
|
||||
"vendor",
|
||||
"displayName",
|
||||
"category",
|
||||
"description",
|
||||
"verified",
|
||||
"capabilities",
|
||||
"ui",
|
||||
]);
|
||||
const ui = record(item.ui, `${path}.ui`);
|
||||
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
|
||||
const slot = text(ui.slot, `${path}.ui.slot`);
|
||||
if (slot !== "device.connection") {
|
||||
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
|
||||
}
|
||||
return {
|
||||
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||
vendor: text(item.vendor, `${path}.vendor`),
|
||||
displayName: text(item.displayName, `${path}.displayName`),
|
||||
category: text(item.category, `${path}.category`),
|
||||
description: text(item.description, `${path}.description`, 1024),
|
||||
verified: flag(item.verified, `${path}.verified`),
|
||||
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
|
||||
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
|
||||
),
|
||||
ui: {
|
||||
slot,
|
||||
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compatibilityProfile(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): DeviceCompatibilityProfileDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["profileId", "path", "modelId"]);
|
||||
const profilePath = text(item.path, `${path}.path`, 512);
|
||||
const pathSegments = profilePath.split("/");
|
||||
if (
|
||||
profilePath.startsWith("/") ||
|
||||
profilePath.includes("\\") ||
|
||||
!profilePath.endsWith(".json") ||
|
||||
pathSegments.some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
throw new Error(
|
||||
`Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
profileId: identifier(item.profileId, `${path}.profileId`),
|
||||
path: profilePath,
|
||||
modelId: identifier(item.modelId, `${path}.modelId`),
|
||||
};
|
||||
}
|
||||
|
||||
function validateV1Alpha2Profiles(
|
||||
models: readonly DeviceModelDefinition[],
|
||||
profiles: readonly DeviceCompatibilityProfileDefinition[],
|
||||
): void {
|
||||
if (!profiles.length) {
|
||||
throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles.");
|
||||
}
|
||||
const modelIds = new Set(models.map((modelItem) => modelItem.id));
|
||||
const profileIds = new Set<string>();
|
||||
const profilePaths = new Set<string>();
|
||||
const coveredModels = new Set<string>();
|
||||
for (const profile of profiles) {
|
||||
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
|
||||
throw new Error(
|
||||
`Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`,
|
||||
);
|
||||
}
|
||||
profileIds.add(profile.profileId);
|
||||
profilePaths.add(profile.path);
|
||||
if (!modelIds.has(profile.modelId)) {
|
||||
throw new Error(
|
||||
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
|
||||
);
|
||||
}
|
||||
coveredModels.add(profile.modelId);
|
||||
}
|
||||
const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId));
|
||||
if (uncovered.length) {
|
||||
throw new Error(
|
||||
`Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
|
||||
const root = record(document, "root");
|
||||
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
|
||||
const apiVersion = root.apiVersion;
|
||||
if (
|
||||
(apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
|
||||
apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) ||
|
||||
root.kind !== "DevicePlugin"
|
||||
) {
|
||||
throw new Error("Manifest использует несовместимую версию или kind.");
|
||||
}
|
||||
const metadata = record(root.metadata, "metadata");
|
||||
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
|
||||
const spec = record(root.spec, "spec");
|
||||
const specKeys = [
|
||||
"hostApiRange",
|
||||
"runtime",
|
||||
"permissions",
|
||||
"actions",
|
||||
"models",
|
||||
];
|
||||
exactKeys(
|
||||
spec,
|
||||
"spec",
|
||||
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2
|
||||
? [...specKeys, "compatibilityProfiles"]
|
||||
: specKeys,
|
||||
);
|
||||
const expectedHostApiRange =
|
||||
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1";
|
||||
if (spec.hostApiRange !== expectedHostApiRange) {
|
||||
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
|
||||
}
|
||||
const runtime = record(spec.runtime, "spec.runtime");
|
||||
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
|
||||
const isolation = text(runtime.isolation, "spec.runtime.isolation");
|
||||
if (isolation !== "transitional-in-process") {
|
||||
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
|
||||
}
|
||||
|
||||
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||
const models = list(spec.models, "spec.models").map((entry, index) =>
|
||||
model(entry, `spec.models[${index}]`, strictIdentifiers),
|
||||
);
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) {
|
||||
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
|
||||
}
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) {
|
||||
throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель.");
|
||||
}
|
||||
|
||||
const version = text(metadata.version, "metadata.version");
|
||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
|
||||
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
|
||||
}
|
||||
|
||||
const common = {
|
||||
kind: "DevicePlugin",
|
||||
metadata: {
|
||||
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
|
||||
version,
|
||||
displayName: text(metadata.displayName, "metadata.displayName"),
|
||||
},
|
||||
} as const;
|
||||
const commonSpec = {
|
||||
runtime: {
|
||||
backendEntrypoint: text(
|
||||
runtime.backendEntrypoint,
|
||||
"spec.runtime.backendEntrypoint",
|
||||
256,
|
||||
),
|
||||
isolation,
|
||||
},
|
||||
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
|
||||
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
|
||||
),
|
||||
actions: list(spec.actions, "spec.actions").map((entry, index) =>
|
||||
action(entry, `spec.actions[${index}]`, strictIdentifiers),
|
||||
),
|
||||
models,
|
||||
} as const;
|
||||
|
||||
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
|
||||
return {
|
||||
...common,
|
||||
apiVersion,
|
||||
spec: {
|
||||
...commonSpec,
|
||||
hostApiRange: "v1alpha1",
|
||||
},
|
||||
} satisfies DevicePluginManifestV1Alpha1;
|
||||
}
|
||||
|
||||
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
|
||||
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
|
||||
);
|
||||
validateV1Alpha2Profiles(models, profiles);
|
||||
return {
|
||||
...common,
|
||||
apiVersion,
|
||||
spec: {
|
||||
...commonSpec,
|
||||
hostApiRange: "v1alpha2",
|
||||
compatibilityProfiles: profiles,
|
||||
},
|
||||
} satisfies DevicePluginManifestV1Alpha2;
|
||||
}
|
||||
|
||||
export function requirePluginAction(
|
||||
manifest: DevicePluginManifest,
|
||||
actionId: string,
|
||||
): string {
|
||||
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
|
||||
if (!action) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
|
||||
}
|
||||
return action.id;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||
DEVICE_STATE_READ_ACTION_ID,
|
||||
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
|
||||
isDevicePluginManifestV1Alpha2,
|
||||
type DeviceUiPlugin,
|
||||
type RegisteredDeviceModel,
|
||||
} from "./contracts";
|
||||
|
||||
export interface DevicePluginRegistry {
|
||||
readonly plugins: readonly DeviceUiPlugin[];
|
||||
readonly models: readonly RegisteredDeviceModel[];
|
||||
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
||||
}
|
||||
|
||||
export function createDevicePluginRegistry(
|
||||
installedPlugins: readonly DeviceUiPlugin[],
|
||||
): DevicePluginRegistry {
|
||||
const pluginIds = new Set<string>();
|
||||
const modelIds = new Set<string>();
|
||||
const models: RegisteredDeviceModel[] = [];
|
||||
|
||||
for (const plugin of installedPlugins) {
|
||||
const { manifest } = plugin;
|
||||
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
|
||||
);
|
||||
}
|
||||
if (manifest.kind !== "DevicePlugin") {
|
||||
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
|
||||
}
|
||||
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
|
||||
throw new Error(
|
||||
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
pluginIds.add(manifest.metadata.id);
|
||||
|
||||
if (
|
||||
manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
|
||||
manifest.spec.models.length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
|
||||
);
|
||||
}
|
||||
if (!manifest.spec.models.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
|
||||
}
|
||||
|
||||
const permissions = new Set(manifest.spec.permissions);
|
||||
if (permissions.size !== manifest.spec.permissions.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
|
||||
}
|
||||
|
||||
const actionIds = new Set<string>();
|
||||
for (const action of manifest.spec.actions) {
|
||||
if (!action.id.trim() || actionIds.has(action.id)) {
|
||||
throw new Error(
|
||||
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
actionIds.add(action.id);
|
||||
if (new Set(action.secretFields).size !== action.secretFields.length) {
|
||||
throw new Error(`Действие ${action.id} повторяет secret field.`);
|
||||
}
|
||||
}
|
||||
const stateRead = manifest.spec.actions.find(
|
||||
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
|
||||
);
|
||||
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const model of manifest.spec.models) {
|
||||
if (!model.id.trim() || modelIds.has(model.id)) {
|
||||
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
|
||||
}
|
||||
modelIds.add(model.id);
|
||||
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
|
||||
if (capabilityIds.size !== model.capabilities.length) {
|
||||
throw new Error(`Модель ${model.id} повторяет capability.`);
|
||||
}
|
||||
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
|
||||
if (!ConnectionView) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
|
||||
);
|
||||
}
|
||||
models.push({
|
||||
plugin,
|
||||
model,
|
||||
ConnectionView,
|
||||
...(plugin.SpatialControlsView
|
||||
? { SpatialControlsView: plugin.SpatialControlsView }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (isDevicePluginManifestV1Alpha2(manifest)) {
|
||||
const profileIds = new Set<string>();
|
||||
const profilePaths = new Set<string>();
|
||||
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
|
||||
const coveredModelIds = new Set<string>();
|
||||
if (!manifest.spec.compatibilityProfiles.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`);
|
||||
}
|
||||
for (const profile of manifest.spec.compatibilityProfiles) {
|
||||
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`);
|
||||
}
|
||||
profileIds.add(profile.profileId);
|
||||
profilePaths.add(profile.path);
|
||||
if (!declaredModelIds.has(profile.modelId)) {
|
||||
throw new Error(
|
||||
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
|
||||
);
|
||||
}
|
||||
coveredModelIds.add(profile.modelId);
|
||||
}
|
||||
const uncovered = [...declaredModelIds].filter(
|
||||
(modelId) => !coveredModelIds.has(modelId),
|
||||
);
|
||||
if (uncovered.length) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||
return {
|
||||
plugins: Object.freeze([...installedPlugins]),
|
||||
models: Object.freeze(models),
|
||||
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { roots, type RootId } from "../../productModel";
|
||||
|
||||
export type EnvironmentSurfaceId = "home" | RootId;
|
||||
export type EnvironmentMediaKind = "image" | "video";
|
||||
export type EnvironmentMediaSource = "file" | "url";
|
||||
|
||||
export interface EnvironmentMediaItem {
|
||||
id: string;
|
||||
source: EnvironmentMediaSource;
|
||||
url: string | null;
|
||||
mediaKind: EnvironmentMediaKind | null;
|
||||
fileName: string | null;
|
||||
}
|
||||
|
||||
export interface EnvironmentBackground {
|
||||
enabled: boolean;
|
||||
imageDurationSeconds: number;
|
||||
items: EnvironmentMediaItem[];
|
||||
}
|
||||
|
||||
export interface EnvironmentPage {
|
||||
headerLabel: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
primaryWorkspaceId: string | null;
|
||||
secondaryWorkspaceId: string | null;
|
||||
background: EnvironmentBackground;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
revision: number;
|
||||
pages: Record<EnvironmentSurfaceId, EnvironmentPage>;
|
||||
}
|
||||
|
||||
export interface UploadedEnvironmentMedia {
|
||||
surfaceId: EnvironmentSurfaceId;
|
||||
itemId: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
mediaKind: EnvironmentMediaKind;
|
||||
mediaType: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
||||
"home",
|
||||
"fleet",
|
||||
"observation",
|
||||
"missions",
|
||||
"data",
|
||||
"system",
|
||||
"polygon",
|
||||
];
|
||||
|
||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
||||
export const maxEnvironmentMediaItems = 24;
|
||||
|
||||
function emptyBackground(): EnvironmentBackground {
|
||||
return {
|
||||
enabled: false,
|
||||
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
||||
return {
|
||||
id: `media-${crypto.randomUUID()}`,
|
||||
source: "file",
|
||||
url: null,
|
||||
mediaKind: null,
|
||||
fileName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
||||
): EnvironmentBackground {
|
||||
if (
|
||||
background.items.length >= maxEnvironmentMediaItems
|
||||
|| background.items.some((candidate) => candidate.id === item.id)
|
||||
) {
|
||||
return background;
|
||||
}
|
||||
return {
|
||||
...background,
|
||||
enabled: background.items.length === 0 ? true : background.enabled,
|
||||
items: [...background.items, item],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEnvironmentMediaItem(
|
||||
background: EnvironmentBackground,
|
||||
itemId: string,
|
||||
): EnvironmentBackground {
|
||||
const items = background.items.filter((item) => item.id !== itemId);
|
||||
if (items.length === background.items.length) return background;
|
||||
return {
|
||||
...background,
|
||||
enabled: items.length > 0 && background.enabled,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
||||
}
|
||||
|
||||
const defaultQuickActions: Record<
|
||||
EnvironmentSurfaceId,
|
||||
readonly [string | null, string | null]
|
||||
> = {
|
||||
home: ["spatial-scene", "local-device"],
|
||||
fleet: ["contour-health", "local-device"],
|
||||
observation: ["spatial-scene", "cameras"],
|
||||
missions: ["mission-planner", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", null],
|
||||
};
|
||||
|
||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||
const rootById = new Map(roots.map((root) => [root.id, root]));
|
||||
return {
|
||||
revision: 0,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
|
||||
const root = surfaceId === "home" ? null : rootById.get(surfaceId) ?? null;
|
||||
const [primaryWorkspaceId, secondaryWorkspaceId] = defaultQuickActions[surfaceId];
|
||||
return [surfaceId, {
|
||||
headerLabel: root?.label ?? "Mission Core",
|
||||
eyebrow: root?.eyebrow ?? "NODEDC / MISSION CORE",
|
||||
title: root?.title ?? "Mission Core",
|
||||
description: root?.statement
|
||||
?? "Наблюдение, планирование и корректировка миссий в одной модульной рабочей области.",
|
||||
primaryWorkspaceId,
|
||||
secondaryWorkspaceId,
|
||||
background: emptyBackground(),
|
||||
}];
|
||||
})) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${path} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
nullable = false,
|
||||
): string | null {
|
||||
if (nullable && value === null) return null;
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${path} должен быть непустой строкой.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeMediaItem(value: unknown, path: string): EnvironmentMediaItem {
|
||||
const record = requireRecord(value, path);
|
||||
if (
|
||||
typeof record.id !== "string"
|
||||
|| !/^media-[a-z0-9-]{1,58}$/.test(record.id)
|
||||
) {
|
||||
throw new Error(`${path}.id не поддерживается.`);
|
||||
}
|
||||
if (!["file", "url"].includes(String(record.source))) {
|
||||
throw new Error(`${path}.source не поддерживается.`);
|
||||
}
|
||||
if (!["image", "video"].includes(String(record.media_kind))) {
|
||||
throw new Error(`${path}.media_kind не поддерживается.`);
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
source: record.source as EnvironmentMediaSource,
|
||||
url: requireString(record.url, `${path}.url`),
|
||||
mediaKind: record.media_kind as EnvironmentMediaKind,
|
||||
fileName: requireString(record.file_name, `${path}.file_name`, true),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBackground(value: unknown, path: string): EnvironmentBackground {
|
||||
const record = requireRecord(value, path);
|
||||
if (typeof record.enabled !== "boolean") {
|
||||
throw new Error(`${path}.enabled должен быть boolean.`);
|
||||
}
|
||||
if (
|
||||
typeof record.image_duration_seconds !== "number"
|
||||
|| !Number.isSafeInteger(record.image_duration_seconds)
|
||||
|| record.image_duration_seconds < 1
|
||||
|| record.image_duration_seconds > 300
|
||||
) {
|
||||
throw new Error(`${path}.image_duration_seconds некорректен.`);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(record.items)
|
||||
|| record.items.length > maxEnvironmentMediaItems
|
||||
) {
|
||||
throw new Error(`${path}.items некорректен.`);
|
||||
}
|
||||
const items = record.items.map((item, index) =>
|
||||
decodeMediaItem(item, `${path}.items[${index}]`));
|
||||
if (new Set(items.map((item) => item.id)).size !== items.length) {
|
||||
throw new Error(`${path}.items содержит повторяющиеся id.`);
|
||||
}
|
||||
return {
|
||||
enabled: record.enabled,
|
||||
imageDurationSeconds: record.image_duration_seconds,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function decodePage(value: unknown, path: string): EnvironmentPage {
|
||||
const record = requireRecord(value, path);
|
||||
return {
|
||||
headerLabel: requireString(record.header_label, `${path}.header_label`)!,
|
||||
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
|
||||
title: requireString(record.title, `${path}.title`)!,
|
||||
description: requireString(record.description, `${path}.description`)!,
|
||||
primaryWorkspaceId: requireString(
|
||||
record.primary_workspace_id,
|
||||
`${path}.primary_workspace_id`,
|
||||
true,
|
||||
),
|
||||
secondaryWorkspaceId: requireString(
|
||||
record.secondary_workspace_id,
|
||||
`${path}.secondary_workspace_id`,
|
||||
true,
|
||||
),
|
||||
background: decodeBackground(record.background, `${path}.background`),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeEnvironmentSettings(value: unknown): EnvironmentSettings {
|
||||
const record = requireRecord(value, "environment");
|
||||
if (record.schema_version !== "missioncore.operator-environment/v3") {
|
||||
throw new Error("Версия настроек окружения не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.revision !== "number"
|
||||
|| !Number.isSafeInteger(record.revision)
|
||||
|| record.revision < 0
|
||||
) {
|
||||
throw new Error("Ревизия настроек окружения некорректна.");
|
||||
}
|
||||
const pages = requireRecord(record.pages, "environment.pages");
|
||||
return {
|
||||
revision: record.revision,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
surfaceId,
|
||||
decodePage(pages[surfaceId], `environment.pages.${surfaceId}`),
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeEnvironmentSettings(settings: EnvironmentSettings): unknown {
|
||||
return {
|
||||
revision: settings.revision,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => {
|
||||
const page = settings.pages[surfaceId];
|
||||
return [surfaceId, {
|
||||
header_label: page.headerLabel,
|
||||
eyebrow: page.eyebrow,
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
primary_workspace_id: page.primaryWorkspaceId,
|
||||
secondary_workspace_id: page.secondaryWorkspaceId,
|
||||
background: {
|
||||
enabled: page.background.enabled,
|
||||
image_duration_seconds: page.background.imageDurationSeconds,
|
||||
items: page.background.items.map((item) => ({
|
||||
id: item.id,
|
||||
source: item.source,
|
||||
url: item.url,
|
||||
media_kind: item.mediaKind,
|
||||
file_name: item.fileName,
|
||||
})),
|
||||
},
|
||||
}];
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeUploadedEnvironmentMedia(
|
||||
value: unknown,
|
||||
): UploadedEnvironmentMedia {
|
||||
const record = requireRecord(value, "environment media");
|
||||
if (record.schema_version !== "missioncore.operator-environment-media/v2") {
|
||||
throw new Error("Версия загруженного media не поддерживается.");
|
||||
}
|
||||
if (!surfaceIds.includes(record.surface_id as EnvironmentSurfaceId)) {
|
||||
throw new Error("Экран загруженного media не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.item_id !== "string"
|
||||
|| !/^media-[a-z0-9-]{1,58}$/.test(record.item_id)
|
||||
) {
|
||||
throw new Error("Идентификатор загруженного media не поддерживается.");
|
||||
}
|
||||
if (!["image", "video"].includes(String(record.media_kind))) {
|
||||
throw new Error("Тип загруженного media не поддерживается.");
|
||||
}
|
||||
if (
|
||||
typeof record.byte_length !== "number"
|
||||
|| !Number.isSafeInteger(record.byte_length)
|
||||
|| record.byte_length <= 0
|
||||
) {
|
||||
throw new Error("Размер загруженного media некорректен.");
|
||||
}
|
||||
return {
|
||||
surfaceId: record.surface_id as EnvironmentSurfaceId,
|
||||
itemId: record.item_id,
|
||||
url: requireString(record.url, "environment media.url")!,
|
||||
fileName: requireString(record.file_name, "environment media.file_name")!,
|
||||
mediaKind: record.media_kind as EnvironmentMediaKind,
|
||||
mediaType: requireString(record.media_type, "environment media.media_type")!,
|
||||
byteLength: record.byte_length,
|
||||
sha256: requireString(record.sha256, "environment media.sha256")!,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneEnvironmentSettings(
|
||||
settings: EnvironmentSettings,
|
||||
): EnvironmentSettings {
|
||||
return {
|
||||
revision: settings.revision,
|
||||
pages: Object.fromEntries(surfaceIds.map((surfaceId) => [
|
||||
surfaceId,
|
||||
{
|
||||
...settings.pages[surfaceId],
|
||||
background: {
|
||||
...settings.pages[surfaceId].background,
|
||||
items: settings.pages[surfaceId].background.items.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
},
|
||||
},
|
||||
])) as Record<EnvironmentSurfaceId, EnvironmentPage>,
|
||||
};
|
||||
}
|
||||
|
||||
export const environmentSurfaceIds = surfaceIds;
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
decodeEnvironmentSettings,
|
||||
decodeUploadedEnvironmentMedia,
|
||||
defaultEnvironmentSettings,
|
||||
encodeEnvironmentSettings,
|
||||
type EnvironmentSettings,
|
||||
type EnvironmentSurfaceId,
|
||||
type UploadedEnvironmentMedia,
|
||||
} from "./environmentSettings";
|
||||
|
||||
interface EnvironmentSettingsController {
|
||||
settings: EnvironmentSettings;
|
||||
state: "loading" | "ready" | "saving" | "error";
|
||||
error: string | null;
|
||||
save: (draft: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||
upload: (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => Promise<UploadedEnvironmentMedia>;
|
||||
}
|
||||
|
||||
async function responseError(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = await response.json() as { detail?: unknown };
|
||||
if (typeof body.detail === "string" && body.detail.trim()) return body.detail;
|
||||
} catch {
|
||||
// A non-JSON reverse-proxy response still gets a stable product message.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function useEnvironmentSettings(): EnvironmentSettingsController {
|
||||
const [settings, setSettings] = useState<EnvironmentSettings>(
|
||||
defaultEnvironmentSettings,
|
||||
);
|
||||
const [state, setState] = useState<EnvironmentSettingsController["state"]>(
|
||||
"loading",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetch("/api/v1/environment/settings", {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить настройки окружения.",
|
||||
));
|
||||
}
|
||||
return decodeEnvironmentSettings(await response.json());
|
||||
}).then((document) => {
|
||||
setSettings(document);
|
||||
setState("ready");
|
||||
setError(null);
|
||||
}).catch((reason: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState("error");
|
||||
setError(reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось загрузить настройки окружения.");
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (draft: EnvironmentSettings) => {
|
||||
setState("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/v1/environment/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(encodeEnvironmentSettings(draft)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось сохранить настройки окружения.",
|
||||
));
|
||||
}
|
||||
const document = decodeEnvironmentSettings(await response.json());
|
||||
setSettings(document);
|
||||
setState("ready");
|
||||
return document;
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error
|
||||
? reason.message
|
||||
: "Не удалось сохранить настройки окружения.";
|
||||
setState("error");
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(async (
|
||||
surfaceId: EnvironmentSurfaceId,
|
||||
itemId: string,
|
||||
file: File,
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`/api/v1/environment/media/${surfaceId}/${itemId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
"X-NODEDC-File-Name": encodeURIComponent(file.name),
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseError(
|
||||
response,
|
||||
"Не удалось загрузить фон окружения.",
|
||||
));
|
||||
}
|
||||
return decodeUploadedEnvironmentMedia(await response.json());
|
||||
}, []);
|
||||
|
||||
return { settings, state, error, save, upload };
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
fetchOne,
|
||||
parseE31,
|
||||
parseE32,
|
||||
parseE33,
|
||||
parseE37,
|
||||
parseE38,
|
||||
parseE39,
|
||||
type AdvancedLaboratoryResults,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import { fetchE34TemporalLayerResult } from "./e34TemporalLayer";
|
||||
import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery";
|
||||
import { fetchE40ProductGateResult } from "./e40ProductGate";
|
||||
import { fetchL3PointPillarsVisualAudit } from "./l3PointPillarsVisualAudit";
|
||||
import { fetchL31PointPillarsRavnoves } from "./l31PointPillarsRavnoves";
|
||||
import { fetchL32PointPillarsCameraReview } from "./l32PointPillarsCameraReview";
|
||||
import { fetchL33CameraFirstDetectorReview } from "./l33CameraFirstDetectorReview";
|
||||
import { fetchL34RightYoloxTruthIsland } from "./l34RightYoloxTruthIsland";
|
||||
import { fetchL34AAssistedYoloxErrorAudit } from "./l34aAssistedYoloxErrorAudit";
|
||||
import { fetchL34BNestedBoxConsolidation } from "./l34bNestedBoxConsolidation";
|
||||
import { fetchL34CTileSeamStitch } from "./l34cTileSeamStitch";
|
||||
import { fetchL34DCumulativePostprocessing } from "./l34dCumulativePostprocessing";
|
||||
import { fetchL34ESelfReviewDiagnostic } from "./l34eSelfReviewDiagnostic";
|
||||
import { fetchL34FFrozenResult } from "./l34fAdjudication";
|
||||
import { fetchE46BlindReviewResult } from "./e46BlindReview";
|
||||
import { fetchE46AAiEngineeringPreannotation } from "./e46aAiEngineeringPreannotation";
|
||||
import { fetchE46BTemporalMotion } from "./e46bTemporalMotion";
|
||||
import { fetchE46CFullReplayWorldTracks } from "./e46cFullReplayWorldTracks";
|
||||
import { fetchE46DTemporalFailureAudit } from "./e46dTemporalFailureAudit";
|
||||
import { fetchE46EReadyStack } from "./e46eReadyStack";
|
||||
import { fetchE46FDashCamBakeoff } from "./e46fDashCamBakeoff";
|
||||
import { fetchE46GRectifiedDetectorBakeoff } from "./e46gRectifiedDetectorBakeoff";
|
||||
import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
|
||||
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
||||
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||
import { fetchE47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import { fetchM47ReferenceGraphLab } from "./m47ReferenceGraph";
|
||||
import {
|
||||
fetchM48LifecycleResult,
|
||||
} from "./m48ObjectCentricQuality";
|
||||
import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression";
|
||||
import { fetchM48StaticOccupancyQualification } from "./m48StaticOccupancyQualification";
|
||||
import { fetchM48R3StaticOccupancyShadow } from "./m48r3StaticOccupancyShadow";
|
||||
import { fetchM48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
||||
import { fetchM48TRiskQualityResult } from "./m48tRiskQuality";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "m48-object-centric-quality"
|
||||
| "m48-small-static-passage-regression"
|
||||
| "m48-static-occupancy-qualification"
|
||||
| "m48r3-static-occupancy-shadow"
|
||||
| "m48s-fixed-class-detector"
|
||||
| "m48t-risk-quality-temporal"
|
||||
| "m47-reference-graph-shadow"
|
||||
| "m4-replay-threat"
|
||||
| "l3-pointpillars-visual-audit"
|
||||
| "l31-pointpillars-ravnoves"
|
||||
| "l32-pointpillars-camera-review"
|
||||
| "l33-camera-first-detector-review"
|
||||
| "e31-source-binding"
|
||||
| "e32-track-geometry"
|
||||
| "e33-worker-shadow"
|
||||
| "e34-temporal-layer"
|
||||
| "e35-degradation-recovery"
|
||||
| "e37-ravnoves-acceptance"
|
||||
| "e38-perception-baseline"
|
||||
| "e39-perception-refinement"
|
||||
| "e40-perception-product-gate"
|
||||
| "e46-detector-truth-island"
|
||||
| "e46a-ai-engineering-preannotation"
|
||||
| "e46b-temporal-motion"
|
||||
| "e46c-full-replay-world-tracks"
|
||||
| "e46d-temporal-failure-audit"
|
||||
| "e46e-ready-stack"
|
||||
| "e46f-dashcam-bakeoff"
|
||||
| "e46g-rectified-detector-bakeoff"
|
||||
| "e46h-full-rectified-front-replay"
|
||||
| "e46i-grounding-dino-full-replay"
|
||||
| "e46j-raw-fisheye-realtime"
|
||||
| "e47-semantic-slam-shadow"
|
||||
| "l34-right-yolox-truth-island-freeze"
|
||||
| "l34a-assisted-yolox-error-audit"
|
||||
| "l34b-nested-box-consolidation-shadow"
|
||||
| "l34c-tile-seam-stitch-shadow"
|
||||
| "l34d-cumulative-postprocessing-candidate"
|
||||
| "l34e-self-review-diagnostic"
|
||||
| "l34f-adjudicated-reference";
|
||||
|
||||
export interface AdvancedLaboratoryIndexItem {
|
||||
workId: AdvancedLaboratoryWorkId;
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
}
|
||||
|
||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
"m48-static-occupancy-qualification",
|
||||
"m48r3-static-occupancy-shadow",
|
||||
"m48s-fixed-class-detector",
|
||||
"m48t-risk-quality-temporal",
|
||||
"m47-reference-graph-shadow",
|
||||
"m4-replay-threat",
|
||||
"l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review",
|
||||
"l33-camera-first-detector-review",
|
||||
"e31-source-binding",
|
||||
"e32-track-geometry",
|
||||
"e33-worker-shadow",
|
||||
"e34-temporal-layer",
|
||||
"e35-degradation-recovery",
|
||||
"e37-ravnoves-acceptance",
|
||||
"e38-perception-baseline",
|
||||
"e39-perception-refinement",
|
||||
"e40-perception-product-gate",
|
||||
"e46-detector-truth-island",
|
||||
"e46a-ai-engineering-preannotation",
|
||||
"e46b-temporal-motion",
|
||||
"e46c-full-replay-world-tracks",
|
||||
"e46d-temporal-failure-audit",
|
||||
"e46e-ready-stack",
|
||||
"e46f-dashcam-bakeoff",
|
||||
"e46g-rectified-detector-bakeoff",
|
||||
"e46h-full-rectified-front-replay",
|
||||
"e46i-grounding-dino-full-replay",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow",
|
||||
"l34-right-yolox-truth-island-freeze",
|
||||
"l34a-assisted-yolox-error-audit",
|
||||
"l34b-nested-box-consolidation-shadow",
|
||||
"l34c-tile-seam-stitch-shadow",
|
||||
"l34d-cumulative-postprocessing-candidate",
|
||||
"l34e-self-review-diagnostic",
|
||||
"l34f-adjudicated-reference",
|
||||
];
|
||||
|
||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"m48-object-centric-quality": "m48-object-quality-(?:pack|result)",
|
||||
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
|
||||
"m48-static-occupancy-qualification": "m48-static-occupancy-qualification",
|
||||
"m48r3-static-occupancy-shadow": "m48r3-static-occupancy-shadow",
|
||||
"m48s-fixed-class-detector": "m48s-fixed-class-detector-lab",
|
||||
"m48t-risk-quality-temporal": "(?:m48t-risk-quality-temporal-lab|m48q-native-risk-quality-lab)",
|
||||
"m47-reference-graph-shadow": "m47-reference-graph-lab",
|
||||
"m4-replay-threat": "m4-threat-replay",
|
||||
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review": "l32-pointpillars-camera-review",
|
||||
"l33-camera-first-detector-review": "l33-camera-first-detector-review",
|
||||
"e31-source-binding": "e31-source-qualification",
|
||||
"e32-track-geometry": "e32-track-geometry",
|
||||
"e33-worker-shadow": "e33-worker-shadow",
|
||||
"e34-temporal-layer": "e34-temporal-occupied",
|
||||
"e35-degradation-recovery": "e35-degradation-recovery",
|
||||
"e37-ravnoves-acceptance": "e37-ravnoves-acceptance",
|
||||
"e38-perception-baseline": "e38-perception-baseline",
|
||||
"e39-perception-refinement": "e39-perception-refinement",
|
||||
"e40-perception-product-gate": "e40-perception-product-gate",
|
||||
"e46-detector-truth-island": "e46-detector-truth-island",
|
||||
"e46a-ai-engineering-preannotation": "e46a-ai-engineering-preannotation",
|
||||
"e46b-temporal-motion": "e46b-temporal-motion",
|
||||
"e46c-full-replay-world-tracks": "e46c-full-replay-world-tracks",
|
||||
"e46d-temporal-failure-audit": "e46d-temporal-failure-audit",
|
||||
"e46e-ready-stack": "e46e-ready-stack",
|
||||
"e46f-dashcam-bakeoff": "e46f-dashcam-bakeoff",
|
||||
"e46g-rectified-detector-bakeoff": "e46g-rectified-detector-bakeoff",
|
||||
"e46h-full-rectified-front-replay": "e46h-full-rectified-front-replay",
|
||||
"e46i-grounding-dino-full-replay": "e46i-grounding-dino-full-replay",
|
||||
"e46j-raw-fisheye-realtime": "e46j-raw-fisheye-realtime",
|
||||
"e47-semantic-slam-shadow": "e47-semantic-slam",
|
||||
"l34-right-yolox-truth-island-freeze": "l34-right-yolox-truth-island-freeze",
|
||||
"l34a-assisted-yolox-error-audit": "l34a-assisted-yolox-error-audit",
|
||||
"l34b-nested-box-consolidation-shadow": "l34b-nested-box-consolidation-shadow",
|
||||
"l34c-tile-seam-stitch-shadow": "l34c-tile-seam-stitch-shadow",
|
||||
"l34d-cumulative-postprocessing-candidate": "l34d-cumulative-postprocessing-candidate",
|
||||
"l34e-self-review-diagnostic": "l34e-self-review-diagnostic",
|
||||
"l34f-adjudicated-reference": "l34f-adjudicated-reference",
|
||||
};
|
||||
|
||||
export function isAdvancedLaboratoryWorkId(
|
||||
value: string,
|
||||
): value is AdvancedLaboratoryWorkId {
|
||||
return WORK_IDS.includes(value as AdvancedLaboratoryWorkId);
|
||||
}
|
||||
|
||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m47Graph: null,
|
||||
m48: null,
|
||||
m48SmallStatic: null,
|
||||
m48StaticOccupancy: null,
|
||||
m48r3StaticOccupancy: null,
|
||||
m48s: null,
|
||||
m48t: null,
|
||||
m4Threat: null,
|
||||
l3: null,
|
||||
l31: null,
|
||||
l32: null,
|
||||
l33: null,
|
||||
e31: null,
|
||||
e32: null,
|
||||
e33: null,
|
||||
e34: null,
|
||||
e35: null,
|
||||
e37: null,
|
||||
e38: null,
|
||||
e39: null,
|
||||
e40: null,
|
||||
e46: null,
|
||||
e46a: null,
|
||||
e46b: null,
|
||||
e46c: null,
|
||||
e46d: null,
|
||||
e46e: null,
|
||||
e46f: null,
|
||||
e46g: null,
|
||||
e46h: null,
|
||||
e46i: null,
|
||||
e46j: null,
|
||||
e47: null,
|
||||
l34: null,
|
||||
l34a: null,
|
||||
l34b: null,
|
||||
l34c: null,
|
||||
l34d: null,
|
||||
l34e: null,
|
||||
l34f: null,
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactString(value: unknown, expected: string, label: string): string {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function parseIndexItem(value: unknown): AdvancedLaboratoryIndexItem {
|
||||
const item = objectValue(value, "Индекс LAB.item");
|
||||
if (
|
||||
typeof item.work_id !== "string"
|
||||
|| !isAdvancedLaboratoryWorkId(item.work_id)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.work_id: неизвестная лабораторная работа.",
|
||||
);
|
||||
}
|
||||
const prefix = RESULT_PREFIX[item.work_id];
|
||||
if (
|
||||
typeof item.result_id !== "string"
|
||||
|| !new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(item.result_id)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.result_id: нарушена идентичность результата.",
|
||||
);
|
||||
}
|
||||
if (typeof item.created_at_utc !== "string" || !item.created_at_utc.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.created_at_utc: ожидалась строка.",
|
||||
);
|
||||
}
|
||||
exactString(item.access, "read-only", "Индекс LAB.item.access");
|
||||
return {
|
||||
workId: item.work_id,
|
||||
resultId: item.result_id,
|
||||
createdAtUtc: item.created_at_utc,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAdvancedLaboratoryIndex({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<readonly AdvancedLaboratoryIndexItem[]> {
|
||||
const response = await fetcher("/api/v1/laboratory/advanced-index", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`Индекс LAB недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "Индекс LAB");
|
||||
exactString(
|
||||
payload.schema_version,
|
||||
"missioncore.laboratory-advanced-index/v1",
|
||||
"Индекс LAB.schema_version",
|
||||
);
|
||||
exactString(payload.access, "read-only", "Индекс LAB.access");
|
||||
if (!Array.isArray(payload.items) || payload.items.length > WORK_IDS.length) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.items: нарушен размер каталога.",
|
||||
);
|
||||
}
|
||||
const items = payload.items.map(parseIndexItem);
|
||||
if (new Set(items.map((item) => item.workId)).size !== items.length) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Индекс LAB.items: лабораторная работа продублирована.",
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function advancedLaboratoryResultAvailable(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
): boolean {
|
||||
return workId === "m48-object-centric-quality" ? results.m48 !== null
|
||||
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
|
||||
: workId === "m48-static-occupancy-qualification" ? results.m48StaticOccupancy !== null
|
||||
: workId === "m48r3-static-occupancy-shadow" ? results.m48r3StaticOccupancy !== null
|
||||
: workId === "m48s-fixed-class-detector" ? results.m48s !== null
|
||||
: workId === "m48t-risk-quality-temporal" ? results.m48t !== null
|
||||
: workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||
: workId === "m4-replay-threat" ? results.m4Threat !== null
|
||||
: workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
: workId === "l31-pointpillars-ravnoves" ? results.l31 !== null
|
||||
: workId === "l32-pointpillars-camera-review" ? results.l32 !== null
|
||||
: workId === "l33-camera-first-detector-review" ? results.l33 !== null
|
||||
: workId === "e31-source-binding" ? results.e31 !== null
|
||||
: workId === "e32-track-geometry" ? results.e32 !== null
|
||||
: workId === "e33-worker-shadow" ? results.e33 !== null
|
||||
: workId === "e34-temporal-layer" ? results.e34 !== null
|
||||
: workId === "e35-degradation-recovery" ? results.e35 !== null
|
||||
: workId === "e37-ravnoves-acceptance" ? results.e37 !== null
|
||||
: workId === "e38-perception-baseline" ? results.e38 !== null
|
||||
: workId === "e39-perception-refinement" ? results.e39 !== null
|
||||
: workId === "e40-perception-product-gate" ? results.e40 !== null
|
||||
: workId === "e46-detector-truth-island" ? results.e46 !== null
|
||||
: workId === "e46a-ai-engineering-preannotation" ? results.e46a !== null
|
||||
: workId === "e46b-temporal-motion" ? results.e46b !== null
|
||||
: workId === "e46c-full-replay-world-tracks" ? results.e46c !== null
|
||||
: workId === "e46d-temporal-failure-audit" ? results.e46d !== null
|
||||
: workId === "e46e-ready-stack" ? results.e46e !== null
|
||||
: workId === "e46f-dashcam-bakeoff" ? results.e46f !== null
|
||||
: workId === "e46g-rectified-detector-bakeoff" ? results.e46g !== null
|
||||
: workId === "e46h-full-rectified-front-replay" ? results.e46h !== null
|
||||
: workId === "e46i-grounding-dino-full-replay" ? results.e46i !== null
|
||||
: workId === "e46j-raw-fisheye-realtime" ? results.e46j !== null
|
||||
: workId === "e47-semantic-slam-shadow" ? results.e47 !== null
|
||||
: workId === "l34-right-yolox-truth-island-freeze" ? results.l34 !== null
|
||||
: workId === "l34a-assisted-yolox-error-audit" ? results.l34a !== null
|
||||
: workId === "l34b-nested-box-consolidation-shadow" ? results.l34b !== null
|
||||
: workId === "l34c-tile-seam-stitch-shadow" ? results.l34c !== null
|
||||
: workId === "l34d-cumulative-postprocessing-candidate" ? results.l34d !== null
|
||||
: workId === "l34e-self-review-diagnostic" ? results.l34e !== null
|
||||
: results.l34f !== null;
|
||||
}
|
||||
|
||||
export async function fetchAdvancedLaboratoryResult(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
resultId,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
resultId?: string;
|
||||
} = {},
|
||||
): Promise<AdvancedLaboratoryResults> {
|
||||
const results = emptyAdvancedLaboratoryResults();
|
||||
if (workId === "m48-object-centric-quality") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8 lifecycle evidence identity не выбрана.");
|
||||
results.m48 = await fetchM48LifecycleResult(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48-small-static-passage-regression") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R1 regression identity не выбрана.");
|
||||
results.m48SmallStatic = await fetchM48SmallStaticRegression(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48-static-occupancy-qualification") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R2 qualification identity не выбрана.");
|
||||
results.m48StaticOccupancy = await fetchM48StaticOccupancyQualification(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48r3-static-occupancy-shadow") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R3 Worker shadow identity не выбрана.");
|
||||
results.m48r3StaticOccupancy = await fetchM48R3StaticOccupancyShadow(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48s-fixed-class-detector") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8S LAB identity не выбрана.");
|
||||
results.m48s = await fetchM48SFixedClassDetectorResult(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48t-risk-quality-temporal") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8T LAB identity не выбрана.");
|
||||
results.m48t = await fetchM48TRiskQualityResult(resultId, { fetcher, signal });
|
||||
} else if (workId === "m47-reference-graph-shadow") {
|
||||
if (!resultId) {
|
||||
throw new AdvancedLaboratoryContractError("M4.7 LAB identity не выбрана.");
|
||||
}
|
||||
results.m47Graph = await fetchM47ReferenceGraphLab({
|
||||
resultId,
|
||||
fetcher,
|
||||
signal,
|
||||
});
|
||||
} else if (workId === "m4-replay-threat") {
|
||||
results.m4Threat = await fetchM4ThreatReplayResult({ fetcher, signal });
|
||||
} else if (workId === "l3-pointpillars-visual-audit") {
|
||||
results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal });
|
||||
} else if (workId === "l31-pointpillars-ravnoves") {
|
||||
results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal });
|
||||
} else if (workId === "l32-pointpillars-camera-review") {
|
||||
results.l32 = await fetchL32PointPillarsCameraReview({ fetcher, signal });
|
||||
} else if (workId === "l33-camera-first-detector-review") {
|
||||
results.l33 = await fetchL33CameraFirstDetectorReview({ fetcher, signal });
|
||||
} else if (workId === "e31-source-binding") {
|
||||
results.e31 = await fetchOne(
|
||||
"/api/v1/laboratory/e31/results?limit=1",
|
||||
parseE31,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e32-track-geometry") {
|
||||
results.e32 = await fetchOne(
|
||||
"/api/v1/laboratory/e32/results?limit=1",
|
||||
parseE32,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e33-worker-shadow") {
|
||||
results.e33 = await fetchOne(
|
||||
"/api/v1/laboratory/e33/results?limit=1",
|
||||
parseE33,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e34-temporal-layer") {
|
||||
results.e34 = await fetchE34TemporalLayerResult({ fetcher, signal });
|
||||
} else if (workId === "e35-degradation-recovery") {
|
||||
results.e35 = await fetchE35DegradationRecoveryResult({ fetcher, signal });
|
||||
} else if (workId === "e37-ravnoves-acceptance") {
|
||||
results.e37 = await fetchOne(
|
||||
"/api/v1/laboratory/e37/results?limit=1",
|
||||
parseE37,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e38-perception-baseline") {
|
||||
results.e38 = await fetchOne(
|
||||
"/api/v1/laboratory/e38/results?limit=1",
|
||||
parseE38,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e39-perception-refinement") {
|
||||
results.e39 = await fetchOne(
|
||||
"/api/v1/laboratory/e39/results?limit=1",
|
||||
parseE39,
|
||||
fetcher,
|
||||
signal,
|
||||
);
|
||||
} else if (workId === "e40-perception-product-gate") {
|
||||
results.e40 = await fetchE40ProductGateResult({ fetcher, signal });
|
||||
} else if (workId === "e46-detector-truth-island") {
|
||||
results.e46 = await fetchE46BlindReviewResult({ fetcher, signal });
|
||||
} else if (workId === "e46a-ai-engineering-preannotation") {
|
||||
results.e46a = await fetchE46AAiEngineeringPreannotation({ fetcher, signal });
|
||||
} else if (workId === "e46b-temporal-motion") {
|
||||
results.e46b = await fetchE46BTemporalMotion({ fetcher, signal });
|
||||
} else if (workId === "e46c-full-replay-world-tracks") {
|
||||
results.e46c = await fetchE46CFullReplayWorldTracks({ fetcher, signal });
|
||||
} else if (workId === "e46d-temporal-failure-audit") {
|
||||
results.e46d = await fetchE46DTemporalFailureAudit({ fetcher, signal });
|
||||
} else if (workId === "e46e-ready-stack") {
|
||||
results.e46e = await fetchE46EReadyStack({ fetcher, signal });
|
||||
} else if (workId === "e46f-dashcam-bakeoff") {
|
||||
results.e46f = await fetchE46FDashCamBakeoff({ fetcher, signal });
|
||||
} else if (workId === "e46g-rectified-detector-bakeoff") {
|
||||
results.e46g = await fetchE46GRectifiedDetectorBakeoff({ fetcher, signal });
|
||||
} else if (workId === "e46h-full-rectified-front-replay") {
|
||||
results.e46h = await fetchE46HFullRectifiedFrontReplay({ fetcher, signal });
|
||||
} else if (workId === "e46i-grounding-dino-full-replay") {
|
||||
results.e46i = await fetchE46IGroundingDinoFullReplay({ fetcher, signal });
|
||||
} else if (workId === "e46j-raw-fisheye-realtime") {
|
||||
results.e46j = await fetchE46JRawFisheyeRealtime({ fetcher, signal });
|
||||
} else if (workId === "e47-semantic-slam-shadow") {
|
||||
results.e47 = await fetchE47SemanticSlamResult({ fetcher, signal });
|
||||
} else if (workId === "l34-right-yolox-truth-island-freeze") {
|
||||
results.l34 = await fetchL34RightYoloxTruthIsland({ fetcher, signal });
|
||||
} else if (workId === "l34a-assisted-yolox-error-audit") {
|
||||
results.l34a = await fetchL34AAssistedYoloxErrorAudit({ fetcher, signal });
|
||||
} else if (workId === "l34b-nested-box-consolidation-shadow") {
|
||||
results.l34b = await fetchL34BNestedBoxConsolidation({ fetcher, signal });
|
||||
} else if (workId === "l34c-tile-seam-stitch-shadow") {
|
||||
results.l34c = await fetchL34CTileSeamStitch({ fetcher, signal });
|
||||
} else if (workId === "l34d-cumulative-postprocessing-candidate") {
|
||||
results.l34d = await fetchL34DCumulativePostprocessing({ fetcher, signal });
|
||||
} else if (workId === "l34e-self-review-diagnostic") {
|
||||
results.l34e = await fetchL34ESelfReviewDiagnostic({ fetcher, signal });
|
||||
} else {
|
||||
results.l34f = await fetchL34FFrozenResult({ fetcher, signal });
|
||||
}
|
||||
if (!advancedLaboratoryResultAvailable(workId, results)) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"Выбранная лабораторная работа не прошла серверную проверку.",
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
E31LaboratoryResult,
|
||||
E32LaboratoryResult,
|
||||
E33LaboratoryResult,
|
||||
E37AcceptanceContractResult,
|
||||
E38PerceptionBaselineResult,
|
||||
E39PerceptionRefinementResult,
|
||||
} from "./advancedResults";
|
||||
import type { E34TemporalLayerResult } from "./e34TemporalLayer";
|
||||
import type { E35DegradationRecoveryResult } from "./e35DegradationRecovery";
|
||||
import type { E40PerceptionProductGateResult } from "./e40ProductGate";
|
||||
import type { L3PointPillarsVisualAuditResult } from "./l3PointPillarsVisualAudit";
|
||||
import type { L31PointPillarsRavnovesResult } from "./l31PointPillarsRavnoves";
|
||||
import type { L32PointPillarsCameraReviewResult } from "./l32PointPillarsCameraReview";
|
||||
import type { L33CameraFirstDetectorReviewResult } from "./l33CameraFirstDetectorReview";
|
||||
import type { L34RightYoloxTruthIslandResult } from "./l34RightYoloxTruthIsland";
|
||||
import type { L34AAssistedYoloxErrorAuditResult } from "./l34aAssistedYoloxErrorAudit";
|
||||
import type { L34BResult } from "./l34bNestedBoxConsolidation";
|
||||
import type { L34CResult } from "./l34cTileSeamStitch";
|
||||
import type { L34DResult } from "./l34dCumulativePostprocessing";
|
||||
import type { L34EResult } from "./l34eSelfReviewDiagnostic";
|
||||
import type { L34FFrozenResult } from "./l34fAdjudication";
|
||||
import type { E46BlindReviewResult } from "./e46BlindReview";
|
||||
import type { E46AAiEngineeringPreannotationResult } from "./e46aAiEngineeringPreannotation";
|
||||
import type { E46BTemporalMotionResult } from "./e46bTemporalMotion";
|
||||
import type { E46CFullReplayWorldTracksResult } from "./e46cFullReplayWorldTracks";
|
||||
import type { E46DTemporalFailureAuditResult } from "./e46dTemporalFailureAudit";
|
||||
import type { E46EReadyStackResult } from "./e46eReadyStack";
|
||||
import type { E46FDashCamBakeoffResult } from "./e46fDashCamBakeoff";
|
||||
import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetectorBakeoff";
|
||||
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
||||
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
||||
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||
import type { E47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph";
|
||||
import type { M48AdvancedResult } from "./m48ObjectCentricQuality";
|
||||
import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression";
|
||||
import type { M48StaticOccupancyQualificationResult } from "./m48StaticOccupancyQualification";
|
||||
import type { M48R3StaticOccupancyShadowResult } from "./m48r3StaticOccupancyShadow";
|
||||
import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector";
|
||||
import type { M48TRiskQualityResult } from "./m48tRiskQuality";
|
||||
|
||||
export interface AdvancedLaboratoryResults {
|
||||
m47Graph: M47ReferenceGraphLabResult | null;
|
||||
m48: M48AdvancedResult | null;
|
||||
m48SmallStatic: M48SmallStaticRegressionResult | null;
|
||||
m48StaticOccupancy: M48StaticOccupancyQualificationResult | null;
|
||||
m48r3StaticOccupancy: M48R3StaticOccupancyShadowResult | null;
|
||||
m48s: M48SFixedClassDetectorResult | null;
|
||||
m48t: M48TRiskQualityResult | null;
|
||||
m4Threat: M4ThreatReplayResult | null;
|
||||
l3: L3PointPillarsVisualAuditResult | null;
|
||||
l31: L31PointPillarsRavnovesResult | null;
|
||||
l32: L32PointPillarsCameraReviewResult | null;
|
||||
l33: L33CameraFirstDetectorReviewResult | null;
|
||||
e31: E31LaboratoryResult | null;
|
||||
e32: E32LaboratoryResult | null;
|
||||
e33: E33LaboratoryResult | null;
|
||||
e34: E34TemporalLayerResult | null;
|
||||
e35: E35DegradationRecoveryResult | null;
|
||||
e37: E37AcceptanceContractResult | null;
|
||||
e38: E38PerceptionBaselineResult | null;
|
||||
e39: E39PerceptionRefinementResult | null;
|
||||
e40: E40PerceptionProductGateResult | null;
|
||||
e46: E46BlindReviewResult | null;
|
||||
e46a: E46AAiEngineeringPreannotationResult | null;
|
||||
e46b: E46BTemporalMotionResult | null;
|
||||
e46c: E46CFullReplayWorldTracksResult | null;
|
||||
e46d: E46DTemporalFailureAuditResult | null;
|
||||
e46e: E46EReadyStackResult | null;
|
||||
e46f: E46FDashCamBakeoffResult | null;
|
||||
e46g: E46GRectifiedDetectorBakeoffResult | null;
|
||||
e46h: E46HFullRectifiedFrontReplayResult | null;
|
||||
e46i: E46IGroundingDinoFullReplayResult | null;
|
||||
e46j: E46JRawFisheyeRealtimeResult | null;
|
||||
e47: E47SemanticSlamResult | null;
|
||||
l34: L34RightYoloxTruthIslandResult | null;
|
||||
l34a: L34AAssistedYoloxErrorAuditResult | null;
|
||||
l34b: L34BResult | null;
|
||||
l34c: L34CResult | null;
|
||||
l34d: L34DResult | null;
|
||||
l34e: L34EResult | null;
|
||||
l34f: L34FFrozenResult | null;
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
import { fetchE34TemporalLayerResult } from "./e34TemporalLayer";
|
||||
import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery";
|
||||
import { fetchE40ProductGateResult } from "./e40ProductGate";
|
||||
import { settledCatalogValue } from "./catalogTransport";
|
||||
import type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults";
|
||||
export type { AdvancedLaboratoryResults } from "./advancedLaboratoryResults";
|
||||
export interface E31LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-diagnostic-source-profile";
|
||||
eligibleForE32: true;
|
||||
profileId: string;
|
||||
producerSha256: string;
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
availableBindingCount: number;
|
||||
availableFraction: number;
|
||||
lidarCameraP95Ms: number;
|
||||
posePointP95Ms: number;
|
||||
selectedOffsetMs: number;
|
||||
correspondenceCount: number;
|
||||
supportedFraction: number;
|
||||
semanticSelfSampleCount: number;
|
||||
semanticSelfCollateralCount: number;
|
||||
exactGeometryCorrectionCount: number;
|
||||
geometryPointMaskStatus: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E32LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-diagnostic-track-geometry-replay";
|
||||
profileId: string;
|
||||
producerSha256: string;
|
||||
e31ResultId: string;
|
||||
metrics: {
|
||||
framesTotal: number;
|
||||
framesSourceAvailable: number;
|
||||
semanticPublished: number;
|
||||
semanticMasked: number;
|
||||
geometryPublished: number;
|
||||
observationsArbitrated: number;
|
||||
overlappingClaimsRemoved: number;
|
||||
qualifiedPointsPublished: number;
|
||||
qualifiedPointsWithheld: number;
|
||||
frameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E33LaboratoryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-recorded-source-paced-shadow";
|
||||
e32ResultId: string;
|
||||
pipelineId: string;
|
||||
mode: string;
|
||||
worker: {
|
||||
node: string;
|
||||
containerImage: string;
|
||||
python: string;
|
||||
numpy: string;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
sourceAvailableFrames: number;
|
||||
sourceUnavailableFrames: number;
|
||||
deliveredFrames: number;
|
||||
inputSuperseded: number;
|
||||
resultSuperseded: number;
|
||||
effectiveDeliveryFps: number;
|
||||
sourceSpanSeconds: number;
|
||||
deadlineMissFraction: number;
|
||||
processingP95Ms: number;
|
||||
releaseLagP95Ms: number;
|
||||
resultAgeP95Ms: number;
|
||||
resultAgeMaxMs: number;
|
||||
resultDeadlineMs: number;
|
||||
processRssP95Mib: number;
|
||||
processRssMaxMib: number;
|
||||
processRssLimitMib: number;
|
||||
gpuUtilizationP95Percent: number;
|
||||
gpuVisible: boolean;
|
||||
workQueueCapacity: number;
|
||||
resultQueueCapacity: number;
|
||||
wallToIdealRatio: number;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E37AcceptanceContractResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "accepted-r0-source-scoped-contract";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
metrics: {
|
||||
reviewedItems: number;
|
||||
developmentItems: number;
|
||||
validationItems: number;
|
||||
engineeringItems: number;
|
||||
humanExceptionItems: number;
|
||||
terminalOutcomes: number;
|
||||
accountingFraction: number;
|
||||
falseFreeClaims: number;
|
||||
};
|
||||
dimensionDistributions: {
|
||||
presence: Readonly<Record<string, number>>;
|
||||
geometryAssociation: Readonly<Record<string, number>>;
|
||||
freshness: Readonly<Record<string, number>>;
|
||||
};
|
||||
severityDistribution: Readonly<Record<string, number>>;
|
||||
labelProvenance: {
|
||||
engineeringItems: number;
|
||||
humanExceptionItems: number;
|
||||
independentGroundTruth: false;
|
||||
};
|
||||
split: {
|
||||
strategy: string;
|
||||
validationFraction: number;
|
||||
};
|
||||
targets: {
|
||||
presenceTarget: number;
|
||||
geometryAssociationTarget: number;
|
||||
freshnessTarget: number;
|
||||
};
|
||||
qualityTargetEvaluated: false;
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E38DimensionMetric {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
target: number;
|
||||
passed: boolean;
|
||||
byStratum: Readonly<Record<string, {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
}>>;
|
||||
confusion: readonly {
|
||||
reference: string;
|
||||
prediction: string;
|
||||
count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface E38PerceptionBaselineResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "measured-r1-source-scoped-baseline";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
qualityGatePassed: boolean;
|
||||
metrics: {
|
||||
developmentItems: number;
|
||||
validationItems: number;
|
||||
terminalOutcomes: number;
|
||||
accountingFraction: number;
|
||||
falseFreeClaims: number;
|
||||
highSeverityFailures: number;
|
||||
dimensions: {
|
||||
presence: E38DimensionMetric;
|
||||
geometryAssociation: E38DimensionMetric;
|
||||
freshness: E38DimensionMetric;
|
||||
};
|
||||
};
|
||||
blockingChecks: readonly string[];
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface DevelopmentDimensionMetric {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
target: number;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export type E39DevelopmentDimensionMetric = DevelopmentDimensionMetric;
|
||||
|
||||
export interface E39PerceptionRefinementResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "measured-r1-source-scoped-refinement";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
qualityGatePassed: boolean;
|
||||
developmentCrossValidation: {
|
||||
strategy: string;
|
||||
seed: string;
|
||||
folds: number;
|
||||
items: number;
|
||||
validationLabelsUsed: false;
|
||||
passed: boolean;
|
||||
dimensions: {
|
||||
presence: DevelopmentDimensionMetric;
|
||||
geometryAssociation: DevelopmentDimensionMetric;
|
||||
freshness: DevelopmentDimensionMetric;
|
||||
};
|
||||
};
|
||||
metrics: E38PerceptionBaselineResult["metrics"];
|
||||
blockingChecks: readonly string[];
|
||||
method: {
|
||||
summary: string;
|
||||
selection: string;
|
||||
dimensionProjection: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class AdvancedLaboratoryContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AdvancedLaboratoryContractError";
|
||||
}
|
||||
}
|
||||
export type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, item]) => [
|
||||
key,
|
||||
integerValue(item, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверный SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: запрещённые полномочия.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCatalog<T>(
|
||||
payload: unknown,
|
||||
parseItem: (value: unknown) => T,
|
||||
): T | null {
|
||||
const value = record(payload, "Каталог LAB");
|
||||
exactString(
|
||||
value.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB.schema_version",
|
||||
);
|
||||
booleanValue(value.configured, "Каталог LAB.configured");
|
||||
integerValue(value.candidate_total, "Каталог LAB.candidate_total");
|
||||
integerValue(value.invalid_total, "Каталог LAB.invalid_total");
|
||||
exactString(value.access, "read-only", "Каталог LAB.access");
|
||||
if (!Array.isArray(value.items)) {
|
||||
throw new AdvancedLaboratoryContractError("Каталог LAB.items: ожидался массив.");
|
||||
}
|
||||
if (value.items.length > 1) {
|
||||
throw new AdvancedLaboratoryContractError("Каталог LAB.items: нарушен limit=1.");
|
||||
}
|
||||
return value.items.length ? parseItem(value.items[0]) : null;
|
||||
}
|
||||
|
||||
export function parseE31(value: unknown): E31LaboratoryResult {
|
||||
const item = record(value, "E31");
|
||||
const metrics = record(item.metrics, "E31.metrics");
|
||||
diagnosticAuthority(item.authority, "E31.authority");
|
||||
return {
|
||||
resultId: contentId(item.result_id, "e31-source-qualification", "E31.result_id"),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E31.created_at_utc"),
|
||||
sourceSessionId: stringValue(item.source_session_id, "E31.source_session_id"),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-diagnostic-source-profile",
|
||||
"E31.status",
|
||||
),
|
||||
eligibleForE32: trueValue(item.eligible_for_e32, "E31.eligible_for_e32"),
|
||||
profileId: stringValue(item.profile_id, "E31.profile_id"),
|
||||
producerSha256: sha256(item.producer_sha256, "E31.producer_sha256"),
|
||||
metrics: {
|
||||
frameCount: integerValue(metrics.frame_count, "E31.metrics.frame_count"),
|
||||
availableBindingCount: integerValue(metrics.available_binding_count, "E31.metrics.available_binding_count"),
|
||||
availableFraction: numberValue(metrics.available_fraction, "E31.metrics.available_fraction"),
|
||||
lidarCameraP95Ms: numberValue(metrics.lidar_camera_p95_ms, "E31.metrics.lidar_camera_p95_ms"),
|
||||
posePointP95Ms: numberValue(metrics.pose_point_p95_ms, "E31.metrics.pose_point_p95_ms"),
|
||||
selectedOffsetMs: finiteNumber(metrics.selected_offset_ms, "E31.metrics.selected_offset_ms"),
|
||||
correspondenceCount: integerValue(metrics.correspondence_count, "E31.metrics.correspondence_count"),
|
||||
supportedFraction: numberValue(metrics.supported_fraction, "E31.metrics.supported_fraction"),
|
||||
semanticSelfSampleCount: integerValue(metrics.semantic_self_sample_count, "E31.metrics.semantic_self_sample_count"),
|
||||
semanticSelfCollateralCount: integerValue(metrics.semantic_self_collateral_count, "E31.metrics.semantic_self_collateral_count"),
|
||||
exactGeometryCorrectionCount: integerValue(metrics.exact_geometry_correction_count, "E31.metrics.exact_geometry_correction_count"),
|
||||
geometryPointMaskStatus: stringValue(metrics.geometry_point_mask_status, "E31.metrics.geometry_point_mask_status"),
|
||||
},
|
||||
limitations: strings(item.limitations, "E31.limitations"),
|
||||
access: exactString(item.access, "read-only", "E31.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE32(value: unknown): E32LaboratoryResult {
|
||||
const item = record(value, "E32");
|
||||
const metrics = record(item.metrics, "E32.metrics");
|
||||
diagnosticAuthority(item.authority, "E32.authority");
|
||||
return {
|
||||
resultId: contentId(item.result_id, "e32-track-geometry", "E32.result_id"),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E32.created_at_utc"),
|
||||
sourceSessionId: stringValue(item.source_session_id, "E32.source_session_id"),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-diagnostic-track-geometry-replay",
|
||||
"E32.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E32.profile_id"),
|
||||
producerSha256: sha256(item.producer_sha256, "E32.producer_sha256"),
|
||||
e31ResultId: contentId(item.e31_result_id, "e31-source-qualification", "E32.e31_result_id"),
|
||||
metrics: {
|
||||
framesTotal: integerValue(metrics.frames_total, "E32.metrics.frames_total"),
|
||||
framesSourceAvailable: integerValue(metrics.frames_source_available, "E32.metrics.frames_source_available"),
|
||||
semanticPublished: integerValue(metrics.semantic_published, "E32.metrics.semantic_published"),
|
||||
semanticMasked: integerValue(metrics.semantic_masked, "E32.metrics.semantic_masked"),
|
||||
geometryPublished: integerValue(metrics.geometry_published, "E32.metrics.geometry_published"),
|
||||
observationsArbitrated: integerValue(metrics.observations_arbitrated, "E32.metrics.observations_arbitrated"),
|
||||
overlappingClaimsRemoved: integerValue(metrics.overlapping_claims_removed, "E32.metrics.overlapping_claims_removed"),
|
||||
qualifiedPointsPublished: integerValue(metrics.qualified_points_published, "E32.metrics.qualified_points_published"),
|
||||
qualifiedPointsWithheld: integerValue(metrics.qualified_points_withheld, "E32.metrics.qualified_points_withheld"),
|
||||
frameProcessingP95Ms: numberValue(metrics.frame_processing_p95_ms, "E32.metrics.frame_processing_p95_ms"),
|
||||
buildElapsedMs: numberValue(metrics.build_elapsed_ms, "E32.metrics.build_elapsed_ms"),
|
||||
},
|
||||
access: exactString(item.access, "read-only", "E32.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE33(value: unknown): E33LaboratoryResult {
|
||||
const item = record(value, "E33");
|
||||
const worker = record(item.worker, "E33.worker");
|
||||
const metrics = record(item.metrics, "E33.metrics");
|
||||
diagnosticAuthority(item.authority, "E33.authority");
|
||||
return {
|
||||
resultId: contentId(item.result_id, "e33-worker-shadow", "E33.result_id"),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E33.created_at_utc"),
|
||||
sourceSessionId: stringValue(item.source_session_id, "E33.source_session_id"),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-recorded-source-paced-shadow",
|
||||
"E33.status",
|
||||
),
|
||||
e32ResultId: contentId(item.e32_result_id, "e32-track-geometry", "E33.e32_result_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E33.pipeline_id"),
|
||||
mode: stringValue(item.mode, "E33.mode"),
|
||||
worker: {
|
||||
node: stringValue(worker.node, "E33.worker.node"),
|
||||
containerImage: stringValue(worker.container_image, "E33.worker.container_image"),
|
||||
python: stringValue(worker.python, "E33.worker.python"),
|
||||
numpy: stringValue(worker.numpy, "E33.worker.numpy"),
|
||||
},
|
||||
metrics: {
|
||||
sourceFrames: integerValue(metrics.source_frames, "E33.metrics.source_frames"),
|
||||
sourceAvailableFrames: integerValue(metrics.source_available_frames, "E33.metrics.source_available_frames"),
|
||||
sourceUnavailableFrames: integerValue(metrics.source_unavailable_frames, "E33.metrics.source_unavailable_frames"),
|
||||
deliveredFrames: integerValue(metrics.delivered_frames, "E33.metrics.delivered_frames"),
|
||||
inputSuperseded: integerValue(metrics.input_superseded, "E33.metrics.input_superseded"),
|
||||
resultSuperseded: integerValue(metrics.result_superseded, "E33.metrics.result_superseded"),
|
||||
effectiveDeliveryFps: numberValue(metrics.effective_delivery_fps, "E33.metrics.effective_delivery_fps"),
|
||||
sourceSpanSeconds: numberValue(metrics.source_span_seconds, "E33.metrics.source_span_seconds"),
|
||||
deadlineMissFraction: numberValue(metrics.deadline_miss_fraction, "E33.metrics.deadline_miss_fraction"),
|
||||
processingP95Ms: numberValue(metrics.processing_p95_ms, "E33.metrics.processing_p95_ms"),
|
||||
releaseLagP95Ms: numberValue(metrics.release_lag_p95_ms, "E33.metrics.release_lag_p95_ms"),
|
||||
resultAgeP95Ms: numberValue(metrics.result_age_p95_ms, "E33.metrics.result_age_p95_ms"),
|
||||
resultAgeMaxMs: numberValue(metrics.result_age_max_ms, "E33.metrics.result_age_max_ms"),
|
||||
resultDeadlineMs: numberValue(metrics.result_deadline_ms, "E33.metrics.result_deadline_ms"),
|
||||
processRssP95Mib: numberValue(metrics.process_rss_p95_mib, "E33.metrics.process_rss_p95_mib"),
|
||||
processRssMaxMib: numberValue(metrics.process_rss_max_mib, "E33.metrics.process_rss_max_mib"),
|
||||
processRssLimitMib: numberValue(metrics.process_rss_limit_mib, "E33.metrics.process_rss_limit_mib"),
|
||||
gpuUtilizationP95Percent: numberValue(metrics.gpu_utilization_p95_percent, "E33.metrics.gpu_utilization_p95_percent"),
|
||||
gpuVisible: booleanValue(metrics.gpu_visible, "E33.metrics.gpu_visible"),
|
||||
workQueueCapacity: integerValue(metrics.work_queue_capacity, "E33.metrics.work_queue_capacity"),
|
||||
resultQueueCapacity: integerValue(metrics.result_queue_capacity, "E33.metrics.result_queue_capacity"),
|
||||
wallToIdealRatio: numberValue(metrics.wall_to_ideal_ratio, "E33.metrics.wall_to_ideal_ratio"),
|
||||
},
|
||||
access: exactString(item.access, "read-only", "E33.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE37(value: unknown): E37AcceptanceContractResult {
|
||||
const item = record(value, "E37");
|
||||
const metrics = record(item.metrics, "E37.metrics");
|
||||
const distributions = record(
|
||||
item.dimension_distributions,
|
||||
"E37.dimension_distributions",
|
||||
);
|
||||
const provenance = record(item.label_provenance, "E37.label_provenance");
|
||||
const split = record(item.split, "E37.split");
|
||||
const targets = record(item.targets, "E37.targets");
|
||||
diagnosticAuthority(item.authority, "E37.authority");
|
||||
if (item.quality_target_evaluated !== false) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"E37.quality_target_evaluated: R0 не должен объявлять метрику проверенной.",
|
||||
);
|
||||
}
|
||||
if (provenance.independent_ground_truth !== false) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"E37.label_provenance: R0 не является независимой разметкой.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e37-ravnoves-acceptance",
|
||||
"E37.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E37.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E37.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E37.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-r0-source-scoped-contract",
|
||||
"E37.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E37.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E37.worker_node"),
|
||||
metrics: {
|
||||
reviewedItems: integerValue(
|
||||
metrics.reviewed_items,
|
||||
"E37.metrics.reviewed_items",
|
||||
),
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E37.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E37.metrics.validation_items",
|
||||
),
|
||||
engineeringItems: integerValue(
|
||||
metrics.engineering_items,
|
||||
"E37.metrics.engineering_items",
|
||||
),
|
||||
humanExceptionItems: integerValue(
|
||||
metrics.human_exception_items,
|
||||
"E37.metrics.human_exception_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E37.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E37.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E37.metrics.false_free_claims",
|
||||
),
|
||||
},
|
||||
dimensionDistributions: {
|
||||
presence: numberRecord(
|
||||
distributions.presence,
|
||||
"E37.dimension_distributions.presence",
|
||||
),
|
||||
geometryAssociation: numberRecord(
|
||||
distributions.geometry_association,
|
||||
"E37.dimension_distributions.geometry_association",
|
||||
),
|
||||
freshness: numberRecord(
|
||||
distributions.freshness,
|
||||
"E37.dimension_distributions.freshness",
|
||||
),
|
||||
},
|
||||
severityDistribution: numberRecord(
|
||||
item.severity_distribution,
|
||||
"E37.severity_distribution",
|
||||
),
|
||||
labelProvenance: {
|
||||
engineeringItems: integerValue(
|
||||
provenance.engineering_items,
|
||||
"E37.label_provenance.engineering_items",
|
||||
),
|
||||
humanExceptionItems: integerValue(
|
||||
provenance.human_exception_items,
|
||||
"E37.label_provenance.human_exception_items",
|
||||
),
|
||||
independentGroundTruth: false,
|
||||
},
|
||||
split: {
|
||||
strategy: stringValue(split.strategy, "E37.split.strategy"),
|
||||
validationFraction: numberValue(
|
||||
split.validation_fraction,
|
||||
"E37.split.validation_fraction",
|
||||
),
|
||||
},
|
||||
targets: {
|
||||
presenceTarget: numberValue(
|
||||
targets.presence_target,
|
||||
"E37.targets.presence_target",
|
||||
),
|
||||
geometryAssociationTarget: numberValue(
|
||||
targets.geometry_association_target,
|
||||
"E37.targets.geometry_association_target",
|
||||
),
|
||||
freshnessTarget: numberValue(
|
||||
targets.freshness_target,
|
||||
"E37.targets.freshness_target",
|
||||
),
|
||||
},
|
||||
qualityTargetEvaluated: false,
|
||||
limitations: strings(item.limitations, "E37.limitations"),
|
||||
access: exactString(item.access, "read-only", "E37.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseE38Dimension(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E38DimensionMetric {
|
||||
const source = record(value, label);
|
||||
const byStratumSource = record(source.by_stratum, `${label}.by_stratum`);
|
||||
if (!Array.isArray(source.confusion)) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`${label}.confusion: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
correct: integerValue(source.correct, `${label}.correct`),
|
||||
incorrect: integerValue(source.incorrect, `${label}.incorrect`),
|
||||
total: integerValue(source.total, `${label}.total`),
|
||||
accuracy: numberValue(source.accuracy, `${label}.accuracy`),
|
||||
target: numberValue(source.target, `${label}.target`),
|
||||
passed: booleanValue(source.passed, `${label}.passed`),
|
||||
byStratum: Object.fromEntries(
|
||||
Object.entries(byStratumSource).map(([stratum, value]) => {
|
||||
const metric = record(value, `${label}.by_stratum.${stratum}`);
|
||||
return [
|
||||
stratum,
|
||||
{
|
||||
correct: integerValue(
|
||||
metric.correct,
|
||||
`${label}.by_stratum.${stratum}.correct`,
|
||||
),
|
||||
incorrect: integerValue(
|
||||
metric.incorrect,
|
||||
`${label}.by_stratum.${stratum}.incorrect`,
|
||||
),
|
||||
total: integerValue(
|
||||
metric.total,
|
||||
`${label}.by_stratum.${stratum}.total`,
|
||||
),
|
||||
accuracy: numberValue(
|
||||
metric.accuracy,
|
||||
`${label}.by_stratum.${stratum}.accuracy`,
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
confusion: source.confusion.map((value, index) => {
|
||||
const row = record(value, `${label}.confusion[${index}]`);
|
||||
return {
|
||||
reference: stringValue(
|
||||
row.reference,
|
||||
`${label}.confusion[${index}].reference`,
|
||||
),
|
||||
prediction: stringValue(
|
||||
row.prediction,
|
||||
`${label}.confusion[${index}].prediction`,
|
||||
),
|
||||
count: integerValue(
|
||||
row.count,
|
||||
`${label}.confusion[${index}].count`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE38(value: unknown): E38PerceptionBaselineResult {
|
||||
const item = record(value, "E38");
|
||||
const metrics = record(item.metrics, "E38.metrics");
|
||||
const dimensions = record(metrics.dimensions, "E38.metrics.dimensions");
|
||||
diagnosticAuthority(item.authority, "E38.authority");
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e38-perception-baseline",
|
||||
"E38.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E38.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E38.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E38.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"measured-r1-source-scoped-baseline",
|
||||
"E38.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E38.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E38.worker_node"),
|
||||
qualityGatePassed: booleanValue(
|
||||
item.quality_gate_passed,
|
||||
"E38.quality_gate_passed",
|
||||
),
|
||||
metrics: {
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E38.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E38.metrics.validation_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E38.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E38.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E38.metrics.false_free_claims",
|
||||
),
|
||||
highSeverityFailures: integerValue(
|
||||
metrics.high_severity_failures,
|
||||
"E38.metrics.high_severity_failures",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseE38Dimension(
|
||||
dimensions.presence,
|
||||
"E38.metrics.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseE38Dimension(
|
||||
dimensions.geometry_association,
|
||||
"E38.metrics.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseE38Dimension(
|
||||
dimensions.freshness,
|
||||
"E38.metrics.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
blockingChecks: strings(item.blocking_checks, "E38.blocking_checks"),
|
||||
limitations: strings(item.limitations, "E38.limitations"),
|
||||
access: exactString(item.access, "read-only", "E38.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDevelopmentDimension(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): DevelopmentDimensionMetric {
|
||||
const source = record(value, label);
|
||||
return {
|
||||
correct: integerValue(source.correct, `${label}.correct`),
|
||||
incorrect: integerValue(source.incorrect, `${label}.incorrect`),
|
||||
total: integerValue(source.total, `${label}.total`),
|
||||
accuracy: numberValue(source.accuracy, `${label}.accuracy`),
|
||||
target: numberValue(source.target, `${label}.target`),
|
||||
passed: booleanValue(source.passed, `${label}.passed`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE39(value: unknown): E39PerceptionRefinementResult {
|
||||
const item = record(value, "E39");
|
||||
const metrics = record(item.metrics, "E39.metrics");
|
||||
const dimensions = record(metrics.dimensions, "E39.metrics.dimensions");
|
||||
const developmentCrossValidation = record(
|
||||
item.development_cross_validation,
|
||||
"E39.development_cross_validation",
|
||||
);
|
||||
const developmentDimensions = record(
|
||||
developmentCrossValidation.dimensions,
|
||||
"E39.development_cross_validation.dimensions",
|
||||
);
|
||||
const method = record(item.method, "E39.method");
|
||||
diagnosticAuthority(item.authority, "E39.authority");
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e39-perception-refinement",
|
||||
"E39.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E39.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E39.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E39.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"measured-r1-source-scoped-refinement",
|
||||
"E39.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E39.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E39.worker_node"),
|
||||
qualityGatePassed: booleanValue(
|
||||
item.quality_gate_passed,
|
||||
"E39.quality_gate_passed",
|
||||
),
|
||||
developmentCrossValidation: {
|
||||
strategy: stringValue(
|
||||
developmentCrossValidation.strategy,
|
||||
"E39.development_cross_validation.strategy",
|
||||
),
|
||||
seed: stringValue(
|
||||
developmentCrossValidation.seed,
|
||||
"E39.development_cross_validation.seed",
|
||||
),
|
||||
folds: integerValue(
|
||||
developmentCrossValidation.folds,
|
||||
"E39.development_cross_validation.folds",
|
||||
),
|
||||
items: integerValue(
|
||||
developmentCrossValidation.items,
|
||||
"E39.development_cross_validation.items",
|
||||
),
|
||||
validationLabelsUsed: falseValue(
|
||||
developmentCrossValidation.validation_labels_used,
|
||||
"E39.development_cross_validation.validation_labels_used",
|
||||
),
|
||||
passed: booleanValue(
|
||||
developmentCrossValidation.passed,
|
||||
"E39.development_cross_validation.passed",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseDevelopmentDimension(
|
||||
developmentDimensions.presence,
|
||||
"E39.development_cross_validation.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseDevelopmentDimension(
|
||||
developmentDimensions.geometry_association,
|
||||
"E39.development_cross_validation.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseDevelopmentDimension(
|
||||
developmentDimensions.freshness,
|
||||
"E39.development_cross_validation.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E39.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E39.metrics.validation_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E39.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E39.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E39.metrics.false_free_claims",
|
||||
),
|
||||
highSeverityFailures: integerValue(
|
||||
metrics.high_severity_failures,
|
||||
"E39.metrics.high_severity_failures",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseE38Dimension(
|
||||
dimensions.presence,
|
||||
"E39.metrics.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseE38Dimension(
|
||||
dimensions.geometry_association,
|
||||
"E39.metrics.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseE38Dimension(
|
||||
dimensions.freshness,
|
||||
"E39.metrics.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
blockingChecks: strings(item.blocking_checks, "E39.blocking_checks"),
|
||||
method: {
|
||||
summary: stringValue(method.summary, "E39.method.summary"),
|
||||
selection: stringValue(method.selection, "E39.method.selection"),
|
||||
dimensionProjection: stringValue(
|
||||
method.dimension_projection,
|
||||
"E39.method.dimension_projection",
|
||||
),
|
||||
},
|
||||
limitations: strings(item.limitations, "E39.limitations"),
|
||||
access: exactString(item.access, "read-only", "E39.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOne<T>(
|
||||
path: string,
|
||||
parser: (value: unknown) => T,
|
||||
fetcher: LaboratoryFetch,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T | null> {
|
||||
const response = await fetcher(path, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(`Каталог LAB недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseCatalog(await response.json(), parser);
|
||||
}
|
||||
export async function fetchAdvancedLaboratoryResults({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<AdvancedLaboratoryResults> {
|
||||
const settled = await Promise.allSettled([
|
||||
fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal),
|
||||
fetchE34TemporalLayerResult({ fetcher, signal }),
|
||||
fetchE35DegradationRecoveryResult({ fetcher, signal }),
|
||||
fetchOne("/api/v1/laboratory/e37/results?limit=1", parseE37, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e38/results?limit=1", parseE38, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e39/results?limit=1", parseE39, fetcher, signal),
|
||||
fetchE40ProductGateResult({ fetcher, signal }),
|
||||
]);
|
||||
const e31 = settledCatalogValue(settled[0]);
|
||||
const e32 = settledCatalogValue(settled[1]);
|
||||
const e33 = settledCatalogValue(settled[2]);
|
||||
const e34 = settledCatalogValue(settled[3]);
|
||||
const e35 = settledCatalogValue(settled[4]);
|
||||
const e37 = settledCatalogValue(settled[5]);
|
||||
const e38 = settledCatalogValue(settled[6]);
|
||||
const e39 = settledCatalogValue(settled[7]);
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
m47Graph: null, m48: null, m48SmallStatic: null, m48StaticOccupancy: null,
|
||||
m48r3StaticOccupancy: null,
|
||||
m48s: null, m48t: null, m4Threat: null,
|
||||
l3: null, l31: null, l32: null, l33: null,
|
||||
e31,
|
||||
e32,
|
||||
e33,
|
||||
e34,
|
||||
e35,
|
||||
e37,
|
||||
e38,
|
||||
e39,
|
||||
e40,
|
||||
e46: null,
|
||||
e46a: null,
|
||||
e46b: null,
|
||||
e46c: null,
|
||||
e46d: null,
|
||||
e46e: null, e46f: null,
|
||||
e46g: null, e46h: null, e46i: null, e46j: null,
|
||||
e47: null,
|
||||
l34: null,
|
||||
l34a: null,
|
||||
l34b: null,
|
||||
l34c: null,
|
||||
l34d: null,
|
||||
l34e: null,
|
||||
l34f: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function settledCatalogValue<T>(
|
||||
result: PromiseSettledResult<T>,
|
||||
): T | null {
|
||||
if (result.status === "fulfilled") {
|
||||
return result.value;
|
||||
}
|
||||
const reason = result.reason;
|
||||
const isAbort = reason instanceof Error && reason.name === "AbortError";
|
||||
const isHttpTransport = (
|
||||
reason instanceof Error
|
||||
&& /Каталог LAB.*недоступен: HTTP \d+\./.test(reason.message)
|
||||
);
|
||||
if (!isAbort && (reason instanceof TypeError || isHttpTransport)) {
|
||||
return null;
|
||||
}
|
||||
throw reason;
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
export interface E29ReviewFrame {
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
conflictCount: number;
|
||||
semanticObservationCount: number;
|
||||
geometryOnlyClusterCount: number;
|
||||
}
|
||||
|
||||
export interface E29EvidenceResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
status: "diagnostic-replay-complete";
|
||||
identity: {
|
||||
frameCount: number;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
profileId: string;
|
||||
producerSha256: string;
|
||||
};
|
||||
metrics: {
|
||||
frames: {
|
||||
total: number;
|
||||
sourceAvailable: number;
|
||||
localSurfaceValid: number;
|
||||
};
|
||||
semanticObservations: {
|
||||
total: number;
|
||||
current: number;
|
||||
agreementFractionOfCurrent: number;
|
||||
geometryStatus: {
|
||||
agree: number;
|
||||
cameraOnly: number;
|
||||
conflict: number;
|
||||
unknown: number;
|
||||
};
|
||||
};
|
||||
geometryOnlyOccupied: {
|
||||
clusterCount: number;
|
||||
pointCount: number;
|
||||
};
|
||||
runtime: {
|
||||
buildElapsedMs: number;
|
||||
frameProcessingP95Ms: number;
|
||||
};
|
||||
};
|
||||
decision: {
|
||||
cameraFirstContractImplemented: boolean;
|
||||
parallelGeometryOnlyLayerImplemented: boolean;
|
||||
productionPromotion: boolean;
|
||||
nextGate: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
authority: {
|
||||
commandsEnabled: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
reviewFrames: readonly E29ReviewFrame[];
|
||||
linkedEvidence: {
|
||||
sourceSessionId: string;
|
||||
localSurfaceModelId: string;
|
||||
sourcePackId: string;
|
||||
sourceResultId: string;
|
||||
publishedOnControlPlane: true;
|
||||
workerCopyRequired: false;
|
||||
};
|
||||
groundTruth: false;
|
||||
}
|
||||
|
||||
export interface E29EvidenceCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E29EvidenceResult[];
|
||||
}
|
||||
|
||||
export interface E29SemanticObservation {
|
||||
trackId: number;
|
||||
label: string;
|
||||
geometryStatus: string;
|
||||
geometryReason: string;
|
||||
rangeM: number | null;
|
||||
score: number;
|
||||
support: {
|
||||
classifiedPoints: number;
|
||||
occupiedPoints: number;
|
||||
surfacePoints: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E29GeometryOnlyCluster {
|
||||
nearestRangeM: number;
|
||||
pointCount: number;
|
||||
voxelCount: number;
|
||||
heightRangeM: readonly [number, number];
|
||||
}
|
||||
|
||||
export interface E29EvidenceFrame {
|
||||
resultId: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
semanticObservations: readonly E29SemanticObservation[];
|
||||
geometryOnlyOccupied: readonly E29GeometryOnlyCluster[];
|
||||
}
|
||||
|
||||
export class E29EvidenceContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E29EvidenceContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E29Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E29EvidenceContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (booleanValue(value, label) !== false) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (booleanValue(value, label) !== true) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null ? null : numberValue(value, label);
|
||||
}
|
||||
|
||||
function tuple2(value: unknown, label: string): readonly [number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 2) {
|
||||
throw new E29EvidenceContractError(`${label}: ожидалась пара чисел.`);
|
||||
}
|
||||
return [
|
||||
numberValue(value[0], `${label}[0]`),
|
||||
numberValue(value[1], `${label}[1]`),
|
||||
];
|
||||
}
|
||||
|
||||
function parseReviewFrame(value: unknown): E29ReviewFrame {
|
||||
const source = record(value, "review_frame");
|
||||
return {
|
||||
frameIndex: integerValue(source.frame_index, "review_frame.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
"review_frame.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
source.session_seconds,
|
||||
"review_frame.session_seconds",
|
||||
),
|
||||
conflictCount: integerValue(
|
||||
source.conflict_count,
|
||||
"review_frame.conflict_count",
|
||||
),
|
||||
semanticObservationCount: integerValue(
|
||||
source.semantic_observation_count,
|
||||
"review_frame.semantic_observation_count",
|
||||
),
|
||||
geometryOnlyClusterCount: integerValue(
|
||||
source.geometry_only_cluster_count,
|
||||
"review_frame.geometry_only_cluster_count",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEvidenceResult(value: unknown): E29EvidenceResult {
|
||||
const source = record(value, "E29 result");
|
||||
const identity = record(source.identity, "E29 identity");
|
||||
const profile = record(identity.profile, "E29 identity.profile");
|
||||
const metrics = record(source.metrics, "E29 metrics");
|
||||
const frames = record(metrics.frames, "E29 metrics.frames");
|
||||
const semantic = record(
|
||||
metrics.semantic_observations,
|
||||
"E29 metrics.semantic_observations",
|
||||
);
|
||||
const geometryStatus = record(
|
||||
semantic.geometry_status,
|
||||
"E29 metrics.semantic_observations.geometry_status",
|
||||
);
|
||||
const geometryOnly = record(
|
||||
metrics.geometry_only_occupied,
|
||||
"E29 metrics.geometry_only_occupied",
|
||||
);
|
||||
const runtime = record(metrics.runtime, "E29 metrics.runtime");
|
||||
const frameProcessing = record(
|
||||
runtime.frame_processing_ms,
|
||||
"E29 metrics.runtime.frame_processing_ms",
|
||||
);
|
||||
const decision = record(source.decision, "E29 decision");
|
||||
const authority = record(source.authority, "E29 authority");
|
||||
const linked = record(source.linked_evidence, "E29 linked evidence");
|
||||
const status = stringValue(source.status, "E29 status");
|
||||
if (status !== "diagnostic-replay-complete") {
|
||||
throw new E29EvidenceContractError("E29 status не завершён.");
|
||||
}
|
||||
if (!Array.isArray(source.limitations) || !Array.isArray(source.review_frames)) {
|
||||
throw new E29EvidenceContractError("E29 collections имеют неверный формат.");
|
||||
}
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E29 result_id"),
|
||||
createdAtUtc: source.created_at_utc === null
|
||||
? null
|
||||
: stringValue(source.created_at_utc, "E29 created_at_utc"),
|
||||
status,
|
||||
identity: {
|
||||
frameCount: integerValue(identity.frame_count, "E29 identity.frame_count"),
|
||||
timelineStartSeconds: numberValue(
|
||||
identity.timeline_start_seconds,
|
||||
"E29 identity.timeline_start_seconds",
|
||||
),
|
||||
timelineEndSeconds: numberValue(
|
||||
identity.timeline_end_seconds,
|
||||
"E29 identity.timeline_end_seconds",
|
||||
),
|
||||
profileId: stringValue(profile.profile_id, "E29 identity.profile.profile_id"),
|
||||
producerSha256: stringValue(
|
||||
identity.producer_sha256,
|
||||
"E29 identity.producer_sha256",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
frames: {
|
||||
total: integerValue(frames.total, "E29 metrics.frames.total"),
|
||||
sourceAvailable: integerValue(
|
||||
frames.source_available,
|
||||
"E29 metrics.frames.source_available",
|
||||
),
|
||||
localSurfaceValid: integerValue(
|
||||
frames.local_surface_valid,
|
||||
"E29 metrics.frames.local_surface_valid",
|
||||
),
|
||||
},
|
||||
semanticObservations: {
|
||||
total: integerValue(
|
||||
semantic.total,
|
||||
"E29 metrics.semantic_observations.total",
|
||||
),
|
||||
current: integerValue(
|
||||
semantic.current,
|
||||
"E29 metrics.semantic_observations.current",
|
||||
),
|
||||
agreementFractionOfCurrent: numberValue(
|
||||
semantic.agreement_fraction_of_current,
|
||||
"E29 metrics.semantic_observations.agreement_fraction_of_current",
|
||||
),
|
||||
geometryStatus: {
|
||||
agree: integerValue(geometryStatus.agree, "E29 geometry_status.agree"),
|
||||
cameraOnly: integerValue(
|
||||
geometryStatus["single-source-camera"],
|
||||
"E29 geometry_status.single-source-camera",
|
||||
),
|
||||
conflict: integerValue(
|
||||
geometryStatus.conflict,
|
||||
"E29 geometry_status.conflict",
|
||||
),
|
||||
unknown: integerValue(
|
||||
geometryStatus.unknown,
|
||||
"E29 geometry_status.unknown",
|
||||
),
|
||||
},
|
||||
},
|
||||
geometryOnlyOccupied: {
|
||||
clusterCount: integerValue(
|
||||
geometryOnly.cluster_count,
|
||||
"E29 geometry_only_occupied.cluster_count",
|
||||
),
|
||||
pointCount: integerValue(
|
||||
geometryOnly.point_count,
|
||||
"E29 geometry_only_occupied.point_count",
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
buildElapsedMs: numberValue(
|
||||
runtime.build_elapsed_ms,
|
||||
"E29 runtime.build_elapsed_ms",
|
||||
),
|
||||
frameProcessingP95Ms: numberValue(
|
||||
frameProcessing.p95,
|
||||
"E29 runtime.frame_processing_ms.p95",
|
||||
),
|
||||
},
|
||||
},
|
||||
decision: {
|
||||
cameraFirstContractImplemented: booleanValue(
|
||||
decision.camera_first_contract_implemented,
|
||||
"E29 decision.camera_first_contract_implemented",
|
||||
),
|
||||
parallelGeometryOnlyLayerImplemented: booleanValue(
|
||||
decision.parallel_geometry_only_layer_implemented,
|
||||
"E29 decision.parallel_geometry_only_layer_implemented",
|
||||
),
|
||||
productionPromotion: booleanValue(
|
||||
decision.production_promotion,
|
||||
"E29 decision.production_promotion",
|
||||
),
|
||||
nextGate: stringValue(decision.next_gate, "E29 decision.next_gate"),
|
||||
},
|
||||
limitations: source.limitations.map((item, index) => (
|
||||
stringValue(item, `E29 limitations[${index}]`)
|
||||
)),
|
||||
authority: {
|
||||
commandsEnabled: falseValue(
|
||||
authority.commands_enabled,
|
||||
"E29 authority.commands_enabled",
|
||||
),
|
||||
navigationOrSafetyAccepted: falseValue(
|
||||
authority.navigation_or_safety_accepted,
|
||||
"E29 authority.navigation_or_safety_accepted",
|
||||
),
|
||||
},
|
||||
reviewFrames: source.review_frames.map(parseReviewFrame),
|
||||
linkedEvidence: {
|
||||
sourceSessionId: stringValue(
|
||||
linked.source_session_id,
|
||||
"E29 linked_evidence.source_session_id",
|
||||
),
|
||||
localSurfaceModelId: stringValue(
|
||||
linked.local_surface_model_id,
|
||||
"E29 linked_evidence.local_surface_model_id",
|
||||
),
|
||||
sourcePackId: stringValue(
|
||||
linked.source_pack_id,
|
||||
"E29 linked_evidence.source_pack_id",
|
||||
),
|
||||
sourceResultId: stringValue(
|
||||
linked.source_result_id,
|
||||
"E29 linked_evidence.source_result_id",
|
||||
),
|
||||
publishedOnControlPlane: trueValue(
|
||||
linked.published_on_control_plane,
|
||||
"E29 linked_evidence.published_on_control_plane",
|
||||
),
|
||||
workerCopyRequired: falseValue(
|
||||
linked.worker_copy_required,
|
||||
"E29 linked_evidence.worker_copy_required",
|
||||
),
|
||||
},
|
||||
groundTruth: falseValue(source.ground_truth, "E29 ground_truth"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE29EvidenceCatalog(value: unknown): E29EvidenceCatalog {
|
||||
const source = record(value, "E29 catalog");
|
||||
if (source.schema_version !== "missioncore.laboratory-e29-catalog/v1") {
|
||||
throw new E29EvidenceContractError("E29 catalog schema не поддерживается.");
|
||||
}
|
||||
if (!Array.isArray(source.items)) {
|
||||
throw new E29EvidenceContractError("E29 catalog.items имеет неверный формат.");
|
||||
}
|
||||
return {
|
||||
configured: booleanValue(source.configured, "E29 catalog.configured"),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"E29 catalog.candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(
|
||||
source.invalid_total,
|
||||
"E29 catalog.invalid_total",
|
||||
),
|
||||
items: source.items.map(parseEvidenceResult),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSemanticObservation(value: unknown): E29SemanticObservation {
|
||||
const source = record(value, "E29 semantic observation");
|
||||
const support = record(source.support, "E29 semantic observation.support");
|
||||
return {
|
||||
trackId: integerValue(source.track_id, "E29 semantic observation.track_id"),
|
||||
label: stringValue(source.label, "E29 semantic observation.label"),
|
||||
geometryStatus: stringValue(
|
||||
source.geometry_status,
|
||||
"E29 semantic observation.geometry_status",
|
||||
),
|
||||
geometryReason: stringValue(
|
||||
source.geometry_reason,
|
||||
"E29 semantic observation.geometry_reason",
|
||||
),
|
||||
rangeM: nullableNumber(source.range_m, "E29 semantic observation.range_m"),
|
||||
score: numberValue(source.score, "E29 semantic observation.score"),
|
||||
support: {
|
||||
classifiedPoints: integerValue(
|
||||
support.classified_points_in_bbox,
|
||||
"E29 support.classified_points_in_bbox",
|
||||
),
|
||||
occupiedPoints: integerValue(
|
||||
support.occupied_points_in_bbox,
|
||||
"E29 support.occupied_points_in_bbox",
|
||||
),
|
||||
surfacePoints: integerValue(
|
||||
support.surface_points_in_bbox,
|
||||
"E29 support.surface_points_in_bbox",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeometryOnly(value: unknown): E29GeometryOnlyCluster {
|
||||
const source = record(value, "E29 geometry-only cluster");
|
||||
return {
|
||||
nearestRangeM: numberValue(
|
||||
source.nearest_range_m,
|
||||
"E29 geometry-only.nearest_range_m",
|
||||
),
|
||||
pointCount: integerValue(
|
||||
source.point_count,
|
||||
"E29 geometry-only.point_count",
|
||||
),
|
||||
voxelCount: integerValue(
|
||||
source.voxel_count,
|
||||
"E29 geometry-only.voxel_count",
|
||||
),
|
||||
heightRangeM: tuple2(
|
||||
source.height_range_m,
|
||||
"E29 geometry-only.height_range_m",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE29EvidenceFrame(value: unknown): E29EvidenceFrame {
|
||||
const source = record(value, "E29 frame response");
|
||||
if (source.schema_version !== "missioncore.laboratory-e29-frame/v1") {
|
||||
throw new E29EvidenceContractError("E29 frame schema не поддерживается.");
|
||||
}
|
||||
const frame = record(source.frame, "E29 frame");
|
||||
if (
|
||||
frame.schema_version !== "missioncore.e29-camera-geometry-frame/v1"
|
||||
|| !Array.isArray(frame.semantic_observations)
|
||||
|| !Array.isArray(frame.geometry_only_occupied)
|
||||
) {
|
||||
throw new E29EvidenceContractError("E29 frame имеет неверный формат.");
|
||||
}
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E29 frame result_id"),
|
||||
frameIndex: integerValue(frame.frame_index, "E29 frame.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
frame.source_frame_index,
|
||||
"E29 frame.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
frame.session_seconds,
|
||||
"E29 frame.session_seconds",
|
||||
),
|
||||
semanticObservations: frame.semantic_observations.map(
|
||||
parseSemanticObservation,
|
||||
),
|
||||
geometryOnlyOccupied: frame.geometry_only_occupied.map(parseGeometryOnly),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E29EvidenceContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchE29EvidenceCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||
): Promise<E29EvidenceCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/laboratory/e29/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseE29EvidenceCatalog(
|
||||
await responseJson(response, "Не удалось получить LAB E29."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE29EvidenceFrame(
|
||||
resultId: string,
|
||||
frameIndex: number,
|
||||
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||
): Promise<E29EvidenceFrame> {
|
||||
if (
|
||||
!/^e29-camera-geometry-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isSafeInteger(frameIndex)
|
||||
|| frameIndex < 0
|
||||
) {
|
||||
throw new E29EvidenceContractError("Некорректный LAB E29 frame id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e29/results/${resultId}/frames/${frameIndex}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE29EvidenceFrame(
|
||||
await responseJson(response, "Не удалось получить кадр LAB E29."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import {
|
||||
parseE30ReviewItem,
|
||||
type E30ReviewItem,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export type E30EngineeringVerdict =
|
||||
| "confirmed"
|
||||
| "corrected"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30DetectorAssessment =
|
||||
| "valid"
|
||||
| "class-mismatch"
|
||||
| "false-positive"
|
||||
| "missed-object"
|
||||
| "not-applicable"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30ProjectionAssessment =
|
||||
| "aligned"
|
||||
| "misaligned"
|
||||
| "not-assessable";
|
||||
|
||||
export type E30PointOwnership =
|
||||
| "object"
|
||||
| "surface-or-background"
|
||||
| "static-environment"
|
||||
| "self"
|
||||
| "insufficient-support"
|
||||
| "not-applicable"
|
||||
| "mixed"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export interface E30ExceptionReviewPrompt {
|
||||
question: string;
|
||||
focus: string;
|
||||
effects: Readonly<{
|
||||
"object-present": string;
|
||||
"background-or-noise": string;
|
||||
"insufficient-evidence": string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface E30EngineeringGeneration {
|
||||
generationId: string;
|
||||
createdAtUtc: string;
|
||||
materializationId: string;
|
||||
producer: {
|
||||
producerId: string;
|
||||
methodId: string;
|
||||
reviewSheetIdentitySha256: string;
|
||||
claimsHumanGroundTruth: false;
|
||||
};
|
||||
summary: {
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
verdictDistribution: Readonly<Record<string, number>>;
|
||||
detectorDistribution: Readonly<Record<string, number>>;
|
||||
projectionDistribution: Readonly<Record<string, number>>;
|
||||
pointOwnershipDistribution: Readonly<Record<string, number>>;
|
||||
humanExceptionCount: number;
|
||||
meanConfidence: number;
|
||||
};
|
||||
causeDistribution: readonly {
|
||||
reasonCode: string;
|
||||
count: number;
|
||||
}[];
|
||||
humanExceptions: readonly {
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
confidence: number;
|
||||
reviewPrompt: E30ExceptionReviewPrompt | null;
|
||||
}[];
|
||||
aiReviewComplete: true;
|
||||
humanExceptionComplete: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30EngineeringGeneration[];
|
||||
}
|
||||
|
||||
export interface E30EngineeringDecision {
|
||||
sequence: number;
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
verdict: E30EngineeringVerdict;
|
||||
effectiveStratum: E30Stratum | null;
|
||||
detectorAssessment: E30DetectorAssessment;
|
||||
projectionAssessment: E30ProjectionAssessment;
|
||||
pointOwnership: E30PointOwnership;
|
||||
causeCode: string | null;
|
||||
confidence: number;
|
||||
humanExceptionRequired: boolean;
|
||||
exceptionReason: "ambiguity" | "high-impact" | null;
|
||||
evidenceNote: string;
|
||||
reviewSheet: {
|
||||
path: string;
|
||||
sha256: string;
|
||||
ordinal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E30EngineeringExceptions {
|
||||
resultId: string;
|
||||
generationId: string;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
}
|
||||
|
||||
export class E30EngineeringContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30EngineeringContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E30EngineeringContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function authority(value: unknown, label: string): void {
|
||||
const parsed = record(value, label);
|
||||
falseValue(parsed.commands_enabled, `${label}.commands_enabled`);
|
||||
falseValue(
|
||||
parsed.navigation_or_safety_accepted,
|
||||
`${label}.navigation_or_safety_accepted`,
|
||||
);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const parsed = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableStratum(value: unknown, label: string): E30Stratum | null {
|
||||
return value === null ? null : stratum(value, label);
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
values: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !values.includes(value as T)) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function exceptionReviewPrompt(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionReviewPrompt | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const source = record(value, label);
|
||||
const effects = record(source.effects, `${label}.effects`);
|
||||
return {
|
||||
question: stringValue(source.question, `${label}.question`),
|
||||
focus: stringValue(source.focus, `${label}.focus`),
|
||||
effects: {
|
||||
"object-present": stringValue(
|
||||
effects["object-present"],
|
||||
`${label}.effects.object-present`,
|
||||
),
|
||||
"background-or-noise": stringValue(
|
||||
effects["background-or-noise"],
|
||||
`${label}.effects.background-or-noise`,
|
||||
),
|
||||
"insufficient-evidence": stringValue(
|
||||
effects["insufficient-evidence"],
|
||||
`${label}.effects.insufficient-evidence`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30EngineeringGeneration {
|
||||
const source = record(value, "A3 generation");
|
||||
const producer = record(source.producer, "A3 producer");
|
||||
const summary = record(source.summary, "A3 summary");
|
||||
const causes = record(source.cause_distribution, "A3 causes");
|
||||
const humanExceptions = source.human_exceptions;
|
||||
if (
|
||||
producer.kind !== "ai-assisted-engineering-review"
|
||||
|| !Array.isArray(causes.reasons)
|
||||
|| !Array.isArray(humanExceptions)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 provenance не поддерживается.");
|
||||
}
|
||||
falseValue(
|
||||
producer.claims_human_ground_truth,
|
||||
"A3 producer.claims_human_ground_truth",
|
||||
);
|
||||
authority(source.authority, "A3 authority");
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 generation_id",
|
||||
),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "A3 created_at_utc"),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"A3 materialization_id",
|
||||
),
|
||||
producer: {
|
||||
producerId: stringValue(producer.producer_id, "A3 producer_id"),
|
||||
methodId: stringValue(producer.method_id, "A3 method_id"),
|
||||
reviewSheetIdentitySha256: stringValue(
|
||||
producer.review_sheet_identity_sha256,
|
||||
"A3 review_sheet_identity_sha256",
|
||||
),
|
||||
claimsHumanGroundTruth: false,
|
||||
},
|
||||
summary: {
|
||||
itemCount: integerValue(summary.item_count, "A3 item_count"),
|
||||
reviewedItemCount: integerValue(
|
||||
summary.reviewed_item_count,
|
||||
"A3 reviewed_item_count",
|
||||
),
|
||||
verdictDistribution: distribution(
|
||||
summary.verdict_distribution,
|
||||
"A3 verdict_distribution",
|
||||
),
|
||||
detectorDistribution: distribution(
|
||||
summary.detector_distribution,
|
||||
"A3 detector_distribution",
|
||||
),
|
||||
projectionDistribution: distribution(
|
||||
summary.projection_distribution,
|
||||
"A3 projection_distribution",
|
||||
),
|
||||
pointOwnershipDistribution: distribution(
|
||||
summary.point_ownership_distribution,
|
||||
"A3 point_ownership_distribution",
|
||||
),
|
||||
humanExceptionCount: integerValue(
|
||||
summary.human_exception_count,
|
||||
"A3 human_exception_count",
|
||||
),
|
||||
meanConfidence: numberValue(
|
||||
summary.mean_confidence,
|
||||
"A3 mean_confidence",
|
||||
),
|
||||
},
|
||||
causeDistribution: causes.reasons.map((value, index) => {
|
||||
const reason = record(value, `A3 causes[${index}]`);
|
||||
return {
|
||||
reasonCode: stringValue(
|
||||
reason.reason_code,
|
||||
`A3 causes[${index}].reason_code`,
|
||||
),
|
||||
count: integerValue(reason.count, `A3 causes[${index}].count`),
|
||||
};
|
||||
}),
|
||||
humanExceptions: humanExceptions.map((value, index) => {
|
||||
const exception = record(value, `A3 exceptions[${index}]`);
|
||||
const confidence = numberValue(
|
||||
exception.confidence,
|
||||
`A3 exceptions[${index}].confidence`,
|
||||
);
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError(
|
||||
`A3 exceptions[${index}].confidence вне диапазона.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
exception.item_id,
|
||||
"e30-review-item",
|
||||
`A3 exceptions[${index}].item_id`,
|
||||
),
|
||||
reviewKey: stringValue(
|
||||
exception.review_key,
|
||||
`A3 exceptions[${index}].review_key`,
|
||||
),
|
||||
sourceStratum: stratum(
|
||||
exception.source_stratum,
|
||||
`A3 exceptions[${index}].source_stratum`,
|
||||
),
|
||||
confidence,
|
||||
reviewPrompt: exceptionReviewPrompt(
|
||||
exception.review_prompt,
|
||||
`A3 exceptions[${index}].review_prompt`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
aiReviewComplete: trueValue(
|
||||
source.ai_review_complete,
|
||||
"A3 ai_review_complete",
|
||||
),
|
||||
humanExceptionComplete: booleanValue(
|
||||
source.human_exception_complete,
|
||||
"A3 human_exception_complete",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"A3 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "A3 lab_published"),
|
||||
access: readOnly(source.access, "A3 access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringCatalog(
|
||||
value: unknown,
|
||||
): E30EngineeringCatalog {
|
||||
const source = record(value, "A3 catalog");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-generations/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 catalog schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "A3 catalog access");
|
||||
return {
|
||||
configured: Boolean(source.configured),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"A3 candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "A3 invalid_total"),
|
||||
items: source.items.map(parseGeneration),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringDecision(
|
||||
value: unknown,
|
||||
): E30EngineeringDecision {
|
||||
const response = record(value, "A3 decision response");
|
||||
if (
|
||||
response.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-decision/v1"
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 decision schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "A3 decision access");
|
||||
contentId(
|
||||
response.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 decision generation_id",
|
||||
);
|
||||
const source = record(response.decision, "A3 decision");
|
||||
const sheet = record(source.review_sheet, "A3 review_sheet");
|
||||
const confidence = numberValue(source.confidence, "A3 confidence");
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError("A3 confidence вне диапазона.");
|
||||
}
|
||||
const exceptionReason = source.exception_reason === null
|
||||
? null
|
||||
: enumValue(
|
||||
source.exception_reason,
|
||||
["ambiguity", "high-impact"] as const,
|
||||
"A3 exception_reason",
|
||||
);
|
||||
return {
|
||||
sequence: integerValue(source.sequence, "A3 sequence"),
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"A3 decision item_id",
|
||||
),
|
||||
reviewKey: stringValue(source.review_key, "A3 review_key"),
|
||||
sourceStratum: stratum(source.source_stratum, "A3 source_stratum"),
|
||||
verdict: enumValue(
|
||||
source.verdict,
|
||||
["confirmed", "corrected", "insufficient-evidence"] as const,
|
||||
"A3 verdict",
|
||||
),
|
||||
effectiveStratum: nullableStratum(
|
||||
source.effective_stratum,
|
||||
"A3 effective_stratum",
|
||||
),
|
||||
detectorAssessment: enumValue(
|
||||
source.detector_assessment,
|
||||
[
|
||||
"valid",
|
||||
"class-mismatch",
|
||||
"false-positive",
|
||||
"missed-object",
|
||||
"not-applicable",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 detector_assessment",
|
||||
),
|
||||
projectionAssessment: enumValue(
|
||||
source.projection_assessment,
|
||||
["aligned", "misaligned", "not-assessable"] as const,
|
||||
"A3 projection_assessment",
|
||||
),
|
||||
pointOwnership: enumValue(
|
||||
source.point_ownership,
|
||||
[
|
||||
"object",
|
||||
"surface-or-background",
|
||||
"static-environment",
|
||||
"self",
|
||||
"insufficient-support",
|
||||
"not-applicable",
|
||||
"mixed",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 point_ownership",
|
||||
),
|
||||
causeCode: source.cause_code === null
|
||||
? null
|
||||
: stringValue(source.cause_code, "A3 cause_code"),
|
||||
confidence,
|
||||
humanExceptionRequired: booleanValue(
|
||||
source.human_exception_required,
|
||||
"A3 human_exception_required",
|
||||
),
|
||||
exceptionReason,
|
||||
evidenceNote: stringValue(source.evidence_note, "A3 evidence_note"),
|
||||
reviewSheet: {
|
||||
path: stringValue(sheet.path, "A3 review_sheet.path"),
|
||||
sha256: stringValue(sheet.sha256, "A3 review_sheet.sha256"),
|
||||
ordinal: integerValue(sheet.ordinal, "A3 review_sheet.ordinal"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringExceptions(
|
||||
value: unknown,
|
||||
): E30EngineeringExceptions {
|
||||
const source = record(value, "A3 exception queue");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-exceptions/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError(
|
||||
"A3 exception queue schema не поддерживается.",
|
||||
);
|
||||
}
|
||||
readOnly(source.access, "A3 exception queue access");
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"A3 exception queue result_id",
|
||||
),
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 exception queue generation_id",
|
||||
),
|
||||
items: source.items.map(parseE30ReviewItem),
|
||||
total: integerValue(source.total, "A3 exception queue total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(
|
||||
source.next_cursor,
|
||||
"A3 exception queue next_cursor",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30EngineeringContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validMaterializationId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validGenerationId(value: string): boolean {
|
||||
return /^e30-engineering-generation-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringCatalog(
|
||||
resultId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringCatalog> {
|
||||
if (!validMaterializationId(resultId)) {
|
||||
throw new E30EngineeringContractError("Некорректный E30 materialization id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations?limit=1`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringCatalog(
|
||||
await responseJson(response, "Не удалось получить A3 generation."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringDecision(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringDecision> {
|
||||
if (
|
||||
!validMaterializationId(resultId)
|
||||
|| !validGenerationId(generationId)
|
||||
|| !validItemId(itemId)
|
||||
) {
|
||||
throw new E30EngineeringContractError("Некорректный A3 decision id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringDecision(
|
||||
await responseJson(response, "Не удалось получить A3 decision."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringExceptions(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringExceptions> {
|
||||
if (!validMaterializationId(resultId) || !validGenerationId(generationId)) {
|
||||
throw new E30EngineeringContractError("Некорректная A3 exception queue.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/exceptions?limit=128`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringExceptions(
|
||||
await responseJson(response, "Не удалось получить A3 exception queue."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
E30ReviewContractError,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export const E30_EXCEPTION_DISPOSITIONS = [
|
||||
"object-present",
|
||||
"background-or-noise",
|
||||
"insufficient-evidence",
|
||||
] as const;
|
||||
|
||||
export type E30ExceptionDisposition =
|
||||
(typeof E30_EXCEPTION_DISPOSITIONS)[number];
|
||||
export type E30HumanReviewState = "active" | "finalized";
|
||||
|
||||
export interface E30HumanReviewDecision {
|
||||
itemId: string;
|
||||
sourceStratum: E30Stratum;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
eventId: string;
|
||||
decidedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface E30HumanReviewDraft {
|
||||
draftId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
state: E30HumanReviewState;
|
||||
revision: number;
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
remainingItemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
generationId: string | null;
|
||||
decisions: readonly E30HumanReviewDecision[];
|
||||
labPublished: false;
|
||||
access: "review-write" | "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewGeneration {
|
||||
generationId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
itemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
coverage: {
|
||||
expectedItemCount: number;
|
||||
reviewedItemCount: number;
|
||||
complete: true;
|
||||
};
|
||||
humanReviewComplete: true;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewFinalized {
|
||||
draft: E30HumanReviewDraft;
|
||||
generation: E30HumanReviewGeneration;
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function disposition(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionDisposition {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_EXCEPTION_DISPOSITIONS.includes(
|
||||
value as E30ExceptionDisposition,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестное решение.`);
|
||||
}
|
||||
return value as E30ExceptionDisposition;
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDecision(value: unknown): E30HumanReviewDecision {
|
||||
const source = record(value, "Решение по исключению");
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"Решение.item_id",
|
||||
),
|
||||
sourceStratum: stratum(source.source_stratum, "Решение.source_stratum"),
|
||||
disposition: disposition(source.disposition, "Решение.disposition"),
|
||||
notes: nullableString(source.notes, "Решение.notes"),
|
||||
eventId: contentId(
|
||||
source.event_id,
|
||||
"e30-review-event",
|
||||
"Решение.event_id",
|
||||
),
|
||||
decidedAtUtc: stringValue(
|
||||
source.decided_at_utc,
|
||||
"Решение.decided_at_utc",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewDraft(
|
||||
value: unknown,
|
||||
): E30HumanReviewDraft {
|
||||
const source = record(value, "Проверка исключений");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-draft/v2"
|
||||
|| !Array.isArray(source.decisions)
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема проверки исключений не поддерживается.",
|
||||
);
|
||||
}
|
||||
const state = stringValue(source.state, "Проверка.state");
|
||||
const access = stringValue(source.access, "Проверка.access");
|
||||
if (
|
||||
(state !== "active" && state !== "finalized")
|
||||
|| (state === "active" && access !== "review-write")
|
||||
|| (state === "finalized" && access !== "read-only")
|
||||
) {
|
||||
throw new E30ReviewContractError("Состояние проверки некорректно.");
|
||||
}
|
||||
const itemCount = integerValue(source.item_count, "Проверка.item_count");
|
||||
const reviewedItemCount = integerValue(
|
||||
source.reviewed_item_count,
|
||||
"Проверка.reviewed_item_count",
|
||||
);
|
||||
const remainingItemCount = integerValue(
|
||||
source.remaining_item_count,
|
||||
"Проверка.remaining_item_count",
|
||||
);
|
||||
const decisions = source.decisions.map(parseDecision);
|
||||
if (
|
||||
reviewedItemCount + remainingItemCount !== itemCount
|
||||
|| decisions.length !== reviewedItemCount
|
||||
) {
|
||||
throw new E30ReviewContractError("Покрытие проверки расходится.");
|
||||
}
|
||||
return {
|
||||
draftId: contentId(
|
||||
source.draft_id,
|
||||
"e30-human-draft",
|
||||
"Проверка.draft_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
state,
|
||||
revision: integerValue(source.revision, "Проверка.revision"),
|
||||
itemCount,
|
||||
reviewedItemCount,
|
||||
remainingItemCount,
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
generationId: source.generation_id === null
|
||||
? null
|
||||
: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
decisions,
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: access as "review-write" | "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30HumanReviewGeneration {
|
||||
const source = record(value, "Зафиксированная проверка");
|
||||
const coverage = record(source.coverage, "Проверка.coverage");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-generation/v2"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема зафиксированной проверки не поддерживается.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
itemCount: integerValue(source.item_count, "Проверка.item_count"),
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
coverage: {
|
||||
expectedItemCount: integerValue(
|
||||
coverage.expected_item_count,
|
||||
"Проверка.coverage.expected",
|
||||
),
|
||||
reviewedItemCount: integerValue(
|
||||
coverage.reviewed_item_count,
|
||||
"Проверка.coverage.reviewed",
|
||||
),
|
||||
complete: trueValue(coverage.complete, "Проверка.coverage.complete"),
|
||||
},
|
||||
humanReviewComplete: trueValue(
|
||||
source.human_review_complete,
|
||||
"Проверка.human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewFinalized(
|
||||
value: unknown,
|
||||
): E30HumanReviewFinalized {
|
||||
const source = record(value, "Фиксация проверки");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-human-review-finalized/v2"
|
||||
) {
|
||||
throw new E30ReviewContractError("Схема фиксации не поддерживается.");
|
||||
}
|
||||
const draft = parseE30HumanReviewDraft(source.draft);
|
||||
const generation = parseGeneration(source.generation);
|
||||
if (
|
||||
draft.state !== "finalized"
|
||||
|| draft.generationId !== generation.generationId
|
||||
|| draft.engineeringGenerationId !== generation.engineeringGenerationId
|
||||
) {
|
||||
throw new E30ReviewContractError("Связь фиксации расходится.");
|
||||
}
|
||||
return { draft, generation };
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validId(value: string, prefix: string): boolean {
|
||||
return new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(value);
|
||||
}
|
||||
|
||||
function generationQuery(engineeringGenerationId: string): string {
|
||||
return `?engineering_generation_id=${encodeURIComponent(
|
||||
engineeringGenerationId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
export async function createOrResumeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
reviewerId = "DC",
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
if (
|
||||
!validId(resultId, "e30-materialization")
|
||||
|| !validId(engineeringGenerationId, "e30-engineering-generation")
|
||||
) {
|
||||
throw new E30ReviewContractError("Некорректная очередь проверки.");
|
||||
}
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: reviewerId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось открыть проверку."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveE30HumanReviewDecision(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
itemId: string,
|
||||
request: {
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
},
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/decisions/${itemId}${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: request.expectedRevision,
|
||||
idempotency_key: request.idempotencyKey,
|
||||
disposition: request.disposition,
|
||||
notes: request.notes,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось сохранить решение."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function finalizeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
expectedRevision: number,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewFinalized> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/finalize${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: expectedRevision,
|
||||
confirm_generation: true,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewFinalized(
|
||||
await responseJson(response, "Не удалось зафиксировать проверку."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
export const E30_STRATA = [
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
] as const;
|
||||
|
||||
export type E30Stratum = (typeof E30_STRATA)[number];
|
||||
|
||||
export interface E30ReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
reviewPackId: string;
|
||||
e29ResultId: string;
|
||||
sourceSessionId: string;
|
||||
itemCount: number;
|
||||
stratumCounts: Record<E30Stratum, number>;
|
||||
reasonTaxonomy: readonly string[];
|
||||
projection: {
|
||||
width: number;
|
||||
height: number;
|
||||
sourceId: string;
|
||||
calibrationSlot: string;
|
||||
};
|
||||
cameraEvidenceAvailable: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30ReviewCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30ReviewResult[];
|
||||
}
|
||||
|
||||
export interface E30ReviewSnapshot {
|
||||
label: string | null;
|
||||
geometryStatus: string;
|
||||
geometryReason: string | null;
|
||||
rangeM: number | null;
|
||||
nearestRangeM: number | null;
|
||||
bboxXyxy: readonly [number, number, number, number] | null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItem {
|
||||
itemId: string;
|
||||
sequence: number;
|
||||
reviewKey: string;
|
||||
stratum: E30Stratum;
|
||||
rangeBucket: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
locatorKind: "semantic-observation" | "geometry-only-cluster";
|
||||
snapshot: E30ReviewSnapshot;
|
||||
materialization: {
|
||||
framePointCount: number;
|
||||
projectedPointCount: number;
|
||||
projectionWidth: number;
|
||||
projectionHeight: number;
|
||||
candidatePointCount: number;
|
||||
selectedPointCount: number;
|
||||
rejectedCandidatePointCount: number;
|
||||
selectedAndCandidateLossless: true;
|
||||
freeSpaceValid: false;
|
||||
humanReviewComplete: false;
|
||||
detectorScore: number | null;
|
||||
};
|
||||
cameraFrameAvailable: boolean;
|
||||
engineeringTriage: E30EngineeringTriage;
|
||||
review: {
|
||||
state: "unreviewed";
|
||||
reasonCode: null;
|
||||
notes: null;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringTriage {
|
||||
provenance: "deterministic-evidence-readiness/v1";
|
||||
state: "ready-for-ai-review" | "blocked-camera-frame-unavailable";
|
||||
attention: "standard" | "elevated";
|
||||
signals: readonly string[];
|
||||
semanticVerdict: null;
|
||||
humanExceptionRequired: null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItems {
|
||||
resultId: string;
|
||||
stratum: E30Stratum;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
reasonTaxonomy: readonly string[];
|
||||
}
|
||||
|
||||
export interface E30ReviewItemDetail extends E30ReviewItem {
|
||||
cameraFrame: {
|
||||
available: true;
|
||||
url: string;
|
||||
sha256: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceFrameIndex: number;
|
||||
exactSourceFrame: true;
|
||||
} | null;
|
||||
selected: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
candidate: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
projection: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
pixelsXy: readonly (readonly [number, number])[];
|
||||
depthM: readonly number[];
|
||||
pointClass: readonly number[];
|
||||
pointHeightM: readonly number[];
|
||||
candidateMask: readonly number[];
|
||||
selectedMask: readonly number[];
|
||||
};
|
||||
pose: {
|
||||
positionMapXyzM: readonly [number, number, number];
|
||||
orientationMapFromLidarXyzw: readonly [number, number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
export class E30ReviewContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30ReviewContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось конечное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30ReviewContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullValue(value: unknown, label: string): null {
|
||||
if (value !== null) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось null.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function stratumValue(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_STRATA.includes(value as E30Stratum)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value as E30Stratum;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null || value === undefined ? null : numberValue(value, label);
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null || value === undefined ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberTuple<const N extends number>(
|
||||
value: unknown,
|
||||
length: N,
|
||||
label: string,
|
||||
): readonly number[] {
|
||||
if (!Array.isArray(value) || value.length !== length) {
|
||||
throw new E30ReviewContractError(`${label}: неверная размерность.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function integerArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => integerValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function points(
|
||||
value: unknown,
|
||||
dimensions: 2 | 3,
|
||||
label: string,
|
||||
): readonly (readonly number[])[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив точек.`);
|
||||
}
|
||||
return value.map((item, index) => (
|
||||
numberTuple(item, dimensions, `${label}[${index}]`)
|
||||
));
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив строк.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function contentId(
|
||||
value: unknown,
|
||||
prefix: string,
|
||||
label: string,
|
||||
): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E30ReviewResult {
|
||||
const source = record(value, "E30 result");
|
||||
const counts = record(source.stratum_counts, "E30 stratum_counts");
|
||||
const projection = record(source.projection, "E30 projection");
|
||||
const authority = record(source.authority, "E30 authority");
|
||||
falseValue(authority.commands_enabled, "E30 authority.commands_enabled");
|
||||
falseValue(
|
||||
authority.navigation_or_safety_accepted,
|
||||
"E30 authority.navigation_or_safety_accepted",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"E30 result_id",
|
||||
),
|
||||
createdAtUtc: source.created_at_utc === null
|
||||
? null
|
||||
: stringValue(source.created_at_utc, "E30 created_at_utc"),
|
||||
reviewPackId: contentId(
|
||||
source.review_pack_id,
|
||||
"e30-review-pack",
|
||||
"E30 review_pack_id",
|
||||
),
|
||||
e29ResultId: contentId(
|
||||
source.e29_result_id,
|
||||
"e29-camera-geometry",
|
||||
"E30 e29_result_id",
|
||||
),
|
||||
sourceSessionId: stringValue(
|
||||
source.source_session_id,
|
||||
"E30 source_session_id",
|
||||
),
|
||||
itemCount: integerValue(source.item_count, "E30 item_count"),
|
||||
stratumCounts: Object.fromEntries(E30_STRATA.map((stratum) => [
|
||||
stratum,
|
||||
integerValue(counts[stratum], `E30 stratum_counts.${stratum}`),
|
||||
])) as Record<E30Stratum, number>,
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 reason_taxonomy"),
|
||||
projection: {
|
||||
width: integerValue(projection.width, "E30 projection.width"),
|
||||
height: integerValue(projection.height, "E30 projection.height"),
|
||||
sourceId: stringValue(projection.source_id, "E30 projection.source_id"),
|
||||
calibrationSlot: stringValue(
|
||||
projection.calibration_slot,
|
||||
"E30 projection.calibration_slot",
|
||||
),
|
||||
},
|
||||
cameraEvidenceAvailable: booleanValue(
|
||||
source.camera_evidence_available,
|
||||
"E30 camera_evidence_available",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"E30 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "E30 lab_published"),
|
||||
access: readOnly(source.access, "E30 access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnapshot(value: unknown): E30ReviewSnapshot {
|
||||
const source = record(value, "E30 snapshot");
|
||||
const rawBbox = source.bbox_xyxy;
|
||||
return {
|
||||
label: nullableString(source.label ?? source.semantic_class, "E30 snapshot.label"),
|
||||
geometryStatus: stringValue(
|
||||
source.geometry_status,
|
||||
"E30 snapshot.geometry_status",
|
||||
),
|
||||
geometryReason: nullableString(
|
||||
source.geometry_reason,
|
||||
"E30 snapshot.geometry_reason",
|
||||
),
|
||||
rangeM: nullableNumber(source.range_m, "E30 snapshot.range_m"),
|
||||
nearestRangeM: nullableNumber(
|
||||
source.nearest_range_m,
|
||||
"E30 snapshot.nearest_range_m",
|
||||
),
|
||||
bboxXyxy: rawBbox === null || rawBbox === undefined
|
||||
? null
|
||||
: numberTuple(rawBbox, 4, "E30 snapshot.bbox_xyxy") as
|
||||
readonly [number, number, number, number],
|
||||
};
|
||||
}
|
||||
|
||||
function parseItem(value: unknown): E30ReviewItem {
|
||||
const source = record(value, "E30 item");
|
||||
const locator = record(source.locator, "E30 item.locator");
|
||||
const materialized = record(
|
||||
source.materialization,
|
||||
"E30 item.materialization",
|
||||
);
|
||||
const review = record(source.review, "E30 item.review");
|
||||
const triage = record(
|
||||
source.engineering_triage,
|
||||
"E30 item.engineering_triage",
|
||||
);
|
||||
const locatorKind = stringValue(locator.kind, "E30 item.locator.kind");
|
||||
if (
|
||||
locatorKind !== "semantic-observation"
|
||||
&& locatorKind !== "geometry-only-cluster"
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 item.locator.kind не поддерживается.");
|
||||
}
|
||||
if (review.state !== "unreviewed") {
|
||||
throw new E30ReviewContractError("E30 item уже изменён вне review gate.");
|
||||
}
|
||||
if (
|
||||
triage.provenance !== "deterministic-evidence-readiness/v1"
|
||||
|| (
|
||||
triage.state !== "ready-for-ai-review"
|
||||
&& triage.state !== "blocked-camera-frame-unavailable"
|
||||
)
|
||||
|| (triage.attention !== "standard" && triage.attention !== "elevated")
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 engineering triage не поддерживается.");
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"E30 item.item_id",
|
||||
),
|
||||
sequence: integerValue(source.sequence, "E30 item.sequence"),
|
||||
reviewKey: stringValue(source.review_key, "E30 item.review_key"),
|
||||
stratum: stratumValue(source.stratum, "E30 item.stratum"),
|
||||
rangeBucket: stringValue(source.range_bucket, "E30 item.range_bucket"),
|
||||
frameIndex: integerValue(source.frame_index, "E30 item.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
"E30 item.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
source.session_seconds,
|
||||
"E30 item.session_seconds",
|
||||
),
|
||||
locatorKind,
|
||||
snapshot: parseSnapshot(source.snapshot),
|
||||
materialization: {
|
||||
framePointCount: integerValue(
|
||||
materialized.frame_point_count,
|
||||
"E30 materialization.frame_point_count",
|
||||
),
|
||||
projectedPointCount: integerValue(
|
||||
materialized.projected_point_count,
|
||||
"E30 materialization.projected_point_count",
|
||||
),
|
||||
projectionWidth: integerValue(
|
||||
materialized.projection_width,
|
||||
"E30 materialization.projection_width",
|
||||
),
|
||||
projectionHeight: integerValue(
|
||||
materialized.projection_height,
|
||||
"E30 materialization.projection_height",
|
||||
),
|
||||
candidatePointCount: integerValue(
|
||||
materialized.candidate_point_count,
|
||||
"E30 materialization.candidate_point_count",
|
||||
),
|
||||
selectedPointCount: integerValue(
|
||||
materialized.selected_point_count,
|
||||
"E30 materialization.selected_point_count",
|
||||
),
|
||||
rejectedCandidatePointCount: integerValue(
|
||||
materialized.rejected_candidate_point_count,
|
||||
"E30 materialization.rejected_candidate_point_count",
|
||||
),
|
||||
selectedAndCandidateLossless: trueValue(
|
||||
materialized.selected_and_candidate_lossless,
|
||||
"E30 materialization.selected_and_candidate_lossless",
|
||||
),
|
||||
freeSpaceValid: falseValue(
|
||||
materialized.free_space_valid,
|
||||
"E30 materialization.free_space_valid",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
materialized.human_review_complete,
|
||||
"E30 materialization.human_review_complete",
|
||||
),
|
||||
detectorScore: nullableNumber(
|
||||
materialized.detector_score,
|
||||
"E30 materialization.detector_score",
|
||||
),
|
||||
},
|
||||
cameraFrameAvailable: booleanValue(
|
||||
source.camera_frame_available,
|
||||
"E30 item.camera_frame_available",
|
||||
),
|
||||
engineeringTriage: {
|
||||
provenance: "deterministic-evidence-readiness/v1",
|
||||
state: triage.state,
|
||||
attention: triage.attention,
|
||||
signals: strings(triage.signals, "E30 engineering_triage.signals"),
|
||||
semanticVerdict: nullValue(
|
||||
triage.semantic_verdict,
|
||||
"E30 engineering_triage.semantic_verdict",
|
||||
),
|
||||
humanExceptionRequired: nullValue(
|
||||
triage.human_exception_required,
|
||||
"E30 engineering_triage.human_exception_required",
|
||||
),
|
||||
},
|
||||
review: {
|
||||
state: "unreviewed",
|
||||
reasonCode: nullValue(review.reason_code, "E30 review.reason_code"),
|
||||
notes: nullValue(review.notes, "E30 review.notes"),
|
||||
},
|
||||
access: readOnly(source.access, "E30 item.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItem(value: unknown): E30ReviewItem {
|
||||
return parseItem(value);
|
||||
}
|
||||
|
||||
export function parseE30ReviewCatalog(value: unknown): E30ReviewCatalog {
|
||||
const source = record(value, "E30 catalog");
|
||||
if (source.schema_version !== "missioncore.laboratory-e30-catalog/v1") {
|
||||
throw new E30ReviewContractError("E30 catalog schema не поддерживается.");
|
||||
}
|
||||
if (!Array.isArray(source.items)) {
|
||||
throw new E30ReviewContractError("E30 catalog.items имеет неверный формат.");
|
||||
}
|
||||
readOnly(source.access, "E30 catalog.access");
|
||||
return {
|
||||
configured: booleanValue(source.configured, "E30 catalog.configured"),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"E30 catalog.candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "E30 catalog.invalid_total"),
|
||||
items: source.items.map(parseResult),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItems(value: unknown): E30ReviewItems {
|
||||
const source = record(value, "E30 items");
|
||||
if (
|
||||
source.schema_version !== "missioncore.laboratory-e30-items/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 items schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "E30 items.access");
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E30 items.result_id"),
|
||||
stratum: stratumValue(source.stratum, "E30 items.stratum"),
|
||||
items: source.items.map(parseItem),
|
||||
total: integerValue(source.total, "E30 items.total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(source.next_cursor, "E30 items.next_cursor"),
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 items.reason_taxonomy"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItemDetail(value: unknown): E30ReviewItemDetail {
|
||||
const response = record(value, "E30 item detail response");
|
||||
if (response.schema_version !== "missioncore.laboratory-e30-item-detail/v1") {
|
||||
throw new E30ReviewContractError("E30 detail schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "E30 detail access");
|
||||
const source = record(response.item, "E30 item detail");
|
||||
const item = parseItem(source);
|
||||
const selected = record(source.selected, "E30 selected");
|
||||
const candidate = record(source.candidate, "E30 candidate");
|
||||
const projection = record(source.projection, "E30 projection detail");
|
||||
const pose = record(source.pose, "E30 pose");
|
||||
const cameraFrame = source.camera_frame === null
|
||||
? null
|
||||
: record(source.camera_frame, "E30 camera_frame");
|
||||
const projectedLength = integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
).length;
|
||||
const projectionCollections = [
|
||||
projection.points_map_xyz_m,
|
||||
projection.pixels_xy,
|
||||
projection.depth_m,
|
||||
projection.point_class,
|
||||
projection.point_height_m,
|
||||
projection.candidate_mask,
|
||||
projection.selected_mask,
|
||||
];
|
||||
if (
|
||||
projectionCollections.some(
|
||||
(collection) => !Array.isArray(collection) || collection.length !== projectedLength,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection arrays имеют разную длину.");
|
||||
}
|
||||
const candidateMask = integerArray(
|
||||
projection.candidate_mask,
|
||||
"E30 projection.candidate_mask",
|
||||
);
|
||||
const selectedMask = integerArray(
|
||||
projection.selected_mask,
|
||||
"E30 projection.selected_mask",
|
||||
);
|
||||
if (
|
||||
candidateMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
|| selectedMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection mask должна быть бинарной.");
|
||||
}
|
||||
const selectedSourceIndices = integerArray(
|
||||
selected.source_indices,
|
||||
"E30 selected.source_indices",
|
||||
);
|
||||
const selectedPoints = points(
|
||||
selected.points_map_xyz_m,
|
||||
3,
|
||||
"E30 selected.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
const candidateSourceIndices = integerArray(
|
||||
candidate.source_indices,
|
||||
"E30 candidate.source_indices",
|
||||
);
|
||||
const candidatePoints = points(
|
||||
candidate.points_map_xyz_m,
|
||||
3,
|
||||
"E30 candidate.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
if (
|
||||
selectedSourceIndices.length !== selectedPoints.length
|
||||
|| candidateSourceIndices.length !== candidatePoints.length
|
||||
|| selectedSourceIndices.length !== item.materialization.selectedPointCount
|
||||
|| candidateSourceIndices.length !== item.materialization.candidatePointCount
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 selected/candidate arrays расходятся.");
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
cameraFrame: cameraFrame === null
|
||||
? null
|
||||
: {
|
||||
available: trueValue(
|
||||
cameraFrame.available,
|
||||
"E30 camera_frame.available",
|
||||
),
|
||||
url: stringValue(cameraFrame.url, "E30 camera_frame.url"),
|
||||
sha256: stringValue(cameraFrame.sha256, "E30 camera_frame.sha256"),
|
||||
width: integerValue(cameraFrame.width, "E30 camera_frame.width"),
|
||||
height: integerValue(cameraFrame.height, "E30 camera_frame.height"),
|
||||
sourceFrameIndex: integerValue(
|
||||
cameraFrame.source_frame_index,
|
||||
"E30 camera_frame.source_frame_index",
|
||||
),
|
||||
exactSourceFrame: trueValue(
|
||||
cameraFrame.exact_source_frame,
|
||||
"E30 camera_frame.exact_source_frame",
|
||||
),
|
||||
},
|
||||
selected: {
|
||||
sourceIndices: selectedSourceIndices,
|
||||
pointsMapXyzM: selectedPoints,
|
||||
},
|
||||
candidate: {
|
||||
sourceIndices: candidateSourceIndices,
|
||||
pointsMapXyzM: candidatePoints,
|
||||
},
|
||||
projection: {
|
||||
sourceIndices: integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
),
|
||||
pointsMapXyzM: points(
|
||||
projection.points_map_xyz_m,
|
||||
3,
|
||||
"E30 projection.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[],
|
||||
pixelsXy: points(
|
||||
projection.pixels_xy,
|
||||
2,
|
||||
"E30 projection.pixels_xy",
|
||||
) as readonly (readonly [number, number])[],
|
||||
depthM: numberArray(projection.depth_m, "E30 projection.depth_m"),
|
||||
pointClass: integerArray(
|
||||
projection.point_class,
|
||||
"E30 projection.point_class",
|
||||
),
|
||||
pointHeightM: numberArray(
|
||||
projection.point_height_m,
|
||||
"E30 projection.point_height_m",
|
||||
),
|
||||
candidateMask,
|
||||
selectedMask,
|
||||
},
|
||||
pose: {
|
||||
positionMapXyzM: numberTuple(
|
||||
pose.position_map_xyz_m,
|
||||
3,
|
||||
"E30 pose.position_map_xyz_m",
|
||||
) as readonly [number, number, number],
|
||||
orientationMapFromLidarXyzw: numberTuple(
|
||||
pose.orientation_map_from_lidar_xyzw,
|
||||
4,
|
||||
"E30 pose.orientation_map_from_lidar_xyzw",
|
||||
) as readonly [number, number, number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validResultId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/laboratory/e30/reviews?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseE30ReviewCatalog(
|
||||
await responseJson(response, "Не удалось получить LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItems(
|
||||
resultId: string,
|
||||
stratum: E30Stratum,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItems> {
|
||||
if (!validResultId(resultId) || !E30_STRATA.includes(stratum)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review query.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items?stratum=${stratum}&limit=128&cursor=0`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItems(
|
||||
await responseJson(response, "Не удалось получить выборку LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItemDetail(
|
||||
resultId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItemDetail> {
|
||||
if (!validResultId(resultId) || !validItemId(itemId)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review item id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItemDetail(
|
||||
await responseJson(response, "Не удалось получить доказательство LAB E30."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
export type E34TemporalState = "current" | "held" | "expired";
|
||||
export type E34OccupancyState = "occupied" | "unknown";
|
||||
export type E34OwnerKind = "camera-track" | "geometry-cluster";
|
||||
export type E34Point3 = readonly [number, number, number];
|
||||
|
||||
export interface E34TemporalHistoryPoint {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
centroidMapXyzM: E34Point3;
|
||||
}
|
||||
|
||||
export interface E34TemporalComponent {
|
||||
temporalId: number;
|
||||
state: E34TemporalState;
|
||||
occupancyState: E34OccupancyState;
|
||||
ownerKind: E34OwnerKind;
|
||||
centroidMapXyzM: E34Point3;
|
||||
lastObservedAgeSeconds: number;
|
||||
associationReason: string;
|
||||
history: readonly E34TemporalHistoryPoint[];
|
||||
}
|
||||
|
||||
export interface E34TemporalReviewFrame {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
layerState: "current" | "held" | "unknown";
|
||||
counts: {
|
||||
current: number;
|
||||
held: number;
|
||||
expired: number;
|
||||
};
|
||||
cellCentersMapXyzM: readonly E34Point3[];
|
||||
components: readonly E34TemporalComponent[];
|
||||
}
|
||||
|
||||
export interface E34TemporalLayerResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-bounded-occupied-unknown-temporal-layer";
|
||||
e32ResultId: string;
|
||||
e33ResultId: string;
|
||||
profileId: string;
|
||||
pipelineId: string;
|
||||
coordinateFrame: "map";
|
||||
configuration: {
|
||||
voxelSizeM: number;
|
||||
occupiedTtlSeconds: number;
|
||||
maximumActiveComponents: number;
|
||||
maximumCellsPerComponent: number;
|
||||
geometryMaximumGapSeconds: number;
|
||||
geometryMaximumCentroidDistanceM: number;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
processedFrames: number;
|
||||
framesWithCurrentLayer: number;
|
||||
heldOnlyFrames: number;
|
||||
unknownEmptyFrames: number;
|
||||
createdComponents: number;
|
||||
cameraCreatedComponents: number;
|
||||
geometryCreatedComponents: number;
|
||||
geometryReassociatedComponents: number;
|
||||
exactCameraAssociations: number;
|
||||
geometrySpatialReassociations: number;
|
||||
heldPublications: number;
|
||||
expiredComponents: number;
|
||||
peakActiveComponents: number;
|
||||
maximumObservedSpanSeconds: number;
|
||||
continuityFraction: number;
|
||||
geometryOnlyReassociationFraction: number;
|
||||
e32CurrentPointRows: number;
|
||||
e34ConsumedCurrentPointRows: number;
|
||||
currentCellRows: number;
|
||||
heldCellRows: number;
|
||||
storedCellRows: number;
|
||||
freeCellRows: number;
|
||||
peakCellsPerComponent: number;
|
||||
maximumHeldAgeSeconds: number;
|
||||
maximumExpiryDelaySeconds: number;
|
||||
maximumReplayMaterializationDelaySeconds: number;
|
||||
mapFrameJumpCandidates: number;
|
||||
frameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
reviewFrames: readonly E34TemporalReviewFrame[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E34TemporalLayerContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E34TemporalLayerContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function list(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
const parsed = finiteNumber(value, label);
|
||||
if (parsed < 0) {
|
||||
throw new E34TemporalLayerContractError(`${label}: число отрицательно.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
expected: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !expected.includes(value as T)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E34TemporalLayerContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): E34Point3 {
|
||||
const coordinates = list(value, label);
|
||||
if (coordinates.length !== 3) {
|
||||
throw new E34TemporalLayerContractError(`${label}: ожидались XYZ.`);
|
||||
}
|
||||
return [
|
||||
finiteNumber(coordinates[0], `${label}[0]`),
|
||||
finiteNumber(coordinates[1], `${label}[1]`),
|
||||
finiteNumber(coordinates[2], `${label}[2]`),
|
||||
];
|
||||
}
|
||||
|
||||
function parseHistory(value: unknown, label: string): E34TemporalHistoryPoint {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseComponent(value: unknown, label: string): E34TemporalComponent {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
|
||||
state: oneOf(
|
||||
item.state,
|
||||
["current", "held", "expired"] as const,
|
||||
`${label}.state`,
|
||||
),
|
||||
occupancyState: oneOf(
|
||||
item.occupancy_state,
|
||||
["occupied", "unknown"] as const,
|
||||
`${label}.occupancy_state`,
|
||||
),
|
||||
ownerKind: oneOf(
|
||||
item.owner_kind,
|
||||
["camera-track", "geometry-cluster"] as const,
|
||||
`${label}.owner_kind`,
|
||||
),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
lastObservedAgeSeconds: numberValue(
|
||||
item.last_observed_age_seconds,
|
||||
`${label}.last_observed_age_seconds`,
|
||||
),
|
||||
associationReason: stringValue(
|
||||
item.association_reason,
|
||||
`${label}.association_reason`,
|
||||
),
|
||||
history: list(item.history_tail, `${label}.history_tail`).map(
|
||||
(entry, index) => parseHistory(entry, `${label}.history_tail[${index}]`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewFrame(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E34TemporalReviewFrame {
|
||||
const item = record(value, label);
|
||||
const counts = record(item.counts, `${label}.counts`);
|
||||
return {
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
sourceAvailable: booleanValue(
|
||||
item.source_available,
|
||||
`${label}.source_available`,
|
||||
),
|
||||
layerState: oneOf(
|
||||
item.layer_state,
|
||||
["current", "held", "unknown"] as const,
|
||||
`${label}.layer_state`,
|
||||
),
|
||||
counts: {
|
||||
current: integerValue(counts.current, `${label}.counts.current`),
|
||||
held: integerValue(counts.held, `${label}.counts.held`),
|
||||
expired: integerValue(counts.expired, `${label}.counts.expired`),
|
||||
},
|
||||
cellCentersMapXyzM: list(
|
||||
item.cell_centers_map_xyz_m,
|
||||
`${label}.cell_centers_map_xyz_m`,
|
||||
).map((entry, index) => point(
|
||||
entry,
|
||||
`${label}.cell_centers_map_xyz_m[${index}]`,
|
||||
)),
|
||||
components: list(item.components, `${label}.components`).map(
|
||||
(entry, index) => parseComponent(
|
||||
entry,
|
||||
`${label}.components[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
`${label}: запрещённые полномочия.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function metricRecord(
|
||||
value: Record<string, unknown>,
|
||||
): E34TemporalLayerResult["metrics"] {
|
||||
const metric = (key: string) => numberValue(
|
||||
value[key],
|
||||
`E34.metrics.${key}`,
|
||||
);
|
||||
const integer = (key: string) => integerValue(
|
||||
value[key],
|
||||
`E34.metrics.${key}`,
|
||||
);
|
||||
return {
|
||||
sourceFrames: integer("source_frames"),
|
||||
processedFrames: integer("processed_frames"),
|
||||
framesWithCurrentLayer: integer("frames_with_current_layer"),
|
||||
heldOnlyFrames: integer("held_only_frames"),
|
||||
unknownEmptyFrames: integer("unknown_empty_frames"),
|
||||
createdComponents: integer("created_components"),
|
||||
cameraCreatedComponents: integer("camera_created_components"),
|
||||
geometryCreatedComponents: integer("geometry_created_components"),
|
||||
geometryReassociatedComponents: integer("geometry_reassociated_components"),
|
||||
exactCameraAssociations: integer("exact_camera_associations"),
|
||||
geometrySpatialReassociations: integer("geometry_spatial_reassociations"),
|
||||
heldPublications: integer("held_publications"),
|
||||
expiredComponents: integer("expired_components"),
|
||||
peakActiveComponents: integer("peak_active_components"),
|
||||
maximumObservedSpanSeconds: metric("maximum_observed_span_seconds"),
|
||||
continuityFraction: metric("continuity_fraction"),
|
||||
geometryOnlyReassociationFraction: metric(
|
||||
"geometry_only_reassociation_fraction",
|
||||
),
|
||||
e32CurrentPointRows: integer("e32_current_point_rows"),
|
||||
e34ConsumedCurrentPointRows: integer("e34_consumed_current_point_rows"),
|
||||
currentCellRows: integer("current_cell_rows"),
|
||||
heldCellRows: integer("held_cell_rows"),
|
||||
storedCellRows: integer("stored_cell_rows"),
|
||||
freeCellRows: integer("free_cell_rows"),
|
||||
peakCellsPerComponent: integer("peak_cells_per_component"),
|
||||
maximumHeldAgeSeconds: metric("maximum_held_age_seconds"),
|
||||
maximumExpiryDelaySeconds: metric("maximum_expiry_delay_seconds"),
|
||||
maximumReplayMaterializationDelaySeconds: metric(
|
||||
"maximum_replay_materialization_delay_seconds",
|
||||
),
|
||||
mapFrameJumpCandidates: integer("map_frame_jump_candidates"),
|
||||
frameProcessingP95Ms: metric("frame_processing_p95_ms"),
|
||||
buildElapsedMs: metric("build_elapsed_ms"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E34TemporalLayerResult {
|
||||
const item = record(value, "E34");
|
||||
const configuration = record(item.configuration, "E34.configuration");
|
||||
const review = record(item.review, "E34.review");
|
||||
const acceptance = record(item.acceptance, "E34.acceptance");
|
||||
diagnosticAuthority(item.authority, "E34.authority");
|
||||
if (acceptance.accepted !== true) {
|
||||
throw new E34TemporalLayerContractError("E34.acceptance: результат отклонён.");
|
||||
}
|
||||
exactString(
|
||||
review.schema_version,
|
||||
"missioncore.e34-temporal-review-timeline/v1",
|
||||
"E34.review.schema_version",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e34-temporal-occupied",
|
||||
"E34.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E34.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E34.source_session_id",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-bounded-occupied-unknown-temporal-layer",
|
||||
"E34.status",
|
||||
),
|
||||
e32ResultId: contentId(
|
||||
item.e32_result_id,
|
||||
"e32-track-geometry",
|
||||
"E34.e32_result_id",
|
||||
),
|
||||
e33ResultId: contentId(
|
||||
item.e33_result_id,
|
||||
"e33-worker-shadow",
|
||||
"E34.e33_result_id",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E34.profile_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E34.pipeline_id"),
|
||||
coordinateFrame: exactString(
|
||||
item.coordinate_frame,
|
||||
"map",
|
||||
"E34.coordinate_frame",
|
||||
),
|
||||
configuration: {
|
||||
voxelSizeM: numberValue(
|
||||
configuration.voxel_size_m,
|
||||
"E34.configuration.voxel_size_m",
|
||||
),
|
||||
occupiedTtlSeconds: numberValue(
|
||||
configuration.occupied_ttl_seconds,
|
||||
"E34.configuration.occupied_ttl_seconds",
|
||||
),
|
||||
maximumActiveComponents: integerValue(
|
||||
configuration.maximum_active_components,
|
||||
"E34.configuration.maximum_active_components",
|
||||
),
|
||||
maximumCellsPerComponent: integerValue(
|
||||
configuration.maximum_cells_per_component,
|
||||
"E34.configuration.maximum_cells_per_component",
|
||||
),
|
||||
geometryMaximumGapSeconds: numberValue(
|
||||
configuration.geometry_maximum_gap_seconds,
|
||||
"E34.configuration.geometry_maximum_gap_seconds",
|
||||
),
|
||||
geometryMaximumCentroidDistanceM: numberValue(
|
||||
configuration.geometry_maximum_centroid_distance_m,
|
||||
"E34.configuration.geometry_maximum_centroid_distance_m",
|
||||
),
|
||||
},
|
||||
metrics: metricRecord(record(item.metrics, "E34.metrics")),
|
||||
reviewFrames: list(review.frames, "E34.review.frames").map(
|
||||
(entry, index) => parseReviewFrame(
|
||||
entry,
|
||||
`E34.review.frames[${index}]`,
|
||||
),
|
||||
),
|
||||
access: exactString(item.access, "read-only", "E34.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE34TemporalLayerResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E34TemporalLayerResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e34/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
`Каталог LAB E34 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "Каталог LAB E34");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E34.schema_version",
|
||||
);
|
||||
booleanValue(catalog.configured, "Каталог LAB E34.configured");
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E34.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E34.invalid_total");
|
||||
exactString(
|
||||
catalog.access,
|
||||
"read-only",
|
||||
"Каталог LAB E34.access",
|
||||
);
|
||||
const items = list(catalog.items, "Каталог LAB E34.items");
|
||||
if (items.length > 1) {
|
||||
throw new E34TemporalLayerContractError(
|
||||
"Каталог LAB E34: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import type {
|
||||
E34OccupancyState,
|
||||
E34OwnerKind,
|
||||
E34Point3,
|
||||
E34TemporalState,
|
||||
} from "./e34TemporalLayer";
|
||||
|
||||
export type E35DegradationKind =
|
||||
| "camera-loss"
|
||||
| "lidar-loss"
|
||||
| "pose-staleness"
|
||||
| "delayed-frames"
|
||||
| "bounded-drop"
|
||||
| "timing-offset";
|
||||
|
||||
export type E35FaultPhase = "before" | "during" | "after";
|
||||
|
||||
export interface E35ScenarioMetrics {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
framesProcessed: number;
|
||||
injectedFrames: number;
|
||||
droppedFrames: number;
|
||||
hiddenSuccessFrames: number;
|
||||
falseFreeRows: number;
|
||||
semanticClaimsDuringCameraLoss: number;
|
||||
metricRowsDuringLidarOrPoseLoss: number;
|
||||
agreeClaimsDuringTimingOffset: number;
|
||||
lateResultsReintroduced: number;
|
||||
maximumCurrentComponentsDuringFault: number;
|
||||
maximumHeldComponentsDuringFault: number;
|
||||
expiredComponentsDuringFault: number;
|
||||
recoveryFrameIndex: number;
|
||||
recoverySeconds: number;
|
||||
}
|
||||
|
||||
export interface E35TemporalComponent {
|
||||
temporalId: number;
|
||||
state: E34TemporalState;
|
||||
occupancyState: E34OccupancyState;
|
||||
ownerKind: E34OwnerKind;
|
||||
semanticLabels: readonly string[];
|
||||
centroidMapXyzM: E34Point3;
|
||||
lastObservedAgeSeconds: number;
|
||||
}
|
||||
|
||||
export interface E35RecoveryReviewFrame {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
faultPhase: E35FaultPhase;
|
||||
action: string;
|
||||
channels: Readonly<Record<string, string>>;
|
||||
inputPointRows: number;
|
||||
transformedPointRows: number;
|
||||
layerState: "current" | "held" | "unknown";
|
||||
counts: {
|
||||
current: number;
|
||||
held: number;
|
||||
expired: number;
|
||||
};
|
||||
components: readonly E35TemporalComponent[];
|
||||
cellCentersMapXyzM: readonly E34Point3[];
|
||||
}
|
||||
|
||||
export interface E35RecoveryReviewScenario {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
frames: readonly E35RecoveryReviewFrame[];
|
||||
}
|
||||
|
||||
export interface E35DegradationRecoveryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-deterministic-degradation-recovery";
|
||||
e32ResultId: string;
|
||||
e33ResultId: string;
|
||||
e34ResultId: string;
|
||||
profileId: string;
|
||||
pipelineId: string;
|
||||
coordinateFrame: "map";
|
||||
configuration: {
|
||||
maximumRecoverySeconds: number;
|
||||
scenarioCount: number;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
variantCount: number;
|
||||
variantFrameOutcomes: number;
|
||||
injectionRecords: number;
|
||||
variantFrameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
scenarios: readonly E35ScenarioMetrics[];
|
||||
reviewScenarios: readonly E35RecoveryReviewScenario[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E35DegradationRecoveryContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E35DegradationRecoveryContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидался объект.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function list(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалась строка.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалось неотрицательное число.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалось целое число.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверное значение.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
expected: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !expected.includes(value as T)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверное значение.`,
|
||||
);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверный content id.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): E34Point3 {
|
||||
const coordinates = list(value, label);
|
||||
if (coordinates.length !== 3) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидались XYZ.`,
|
||||
);
|
||||
}
|
||||
const result = coordinates.map(
|
||||
(coordinate, index) => {
|
||||
if (typeof coordinate !== "number" || !Number.isFinite(coordinate)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}[${index}]: ожидалось число.`,
|
||||
);
|
||||
}
|
||||
return coordinate;
|
||||
},
|
||||
);
|
||||
return [result[0]!, result[1]!, result[2]!];
|
||||
}
|
||||
|
||||
const DEGRADATION_KINDS = [
|
||||
"camera-loss",
|
||||
"lidar-loss",
|
||||
"pose-staleness",
|
||||
"delayed-frames",
|
||||
"bounded-drop",
|
||||
"timing-offset",
|
||||
] as const;
|
||||
|
||||
function degradationKind(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35DegradationKind {
|
||||
return oneOf(value, DEGRADATION_KINDS, label);
|
||||
}
|
||||
|
||||
function parseComponent(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35TemporalComponent {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
|
||||
state: oneOf(
|
||||
item.state,
|
||||
["current", "held", "expired"] as const,
|
||||
`${label}.state`,
|
||||
),
|
||||
occupancyState: oneOf(
|
||||
item.occupancy_state,
|
||||
["occupied", "unknown"] as const,
|
||||
`${label}.occupancy_state`,
|
||||
),
|
||||
ownerKind: oneOf(
|
||||
item.owner_kind,
|
||||
["camera-track", "geometry-cluster"] as const,
|
||||
`${label}.owner_kind`,
|
||||
),
|
||||
semanticLabels: list(
|
||||
item.semantic_labels,
|
||||
`${label}.semantic_labels`,
|
||||
).map((entry, index) => stringValue(
|
||||
entry,
|
||||
`${label}.semantic_labels[${index}]`,
|
||||
)),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
lastObservedAgeSeconds: numberValue(
|
||||
item.last_observed_age_seconds,
|
||||
`${label}.last_observed_age_seconds`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewFrame(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35RecoveryReviewFrame {
|
||||
const item = record(value, label);
|
||||
const counts = record(item.counts, `${label}.counts`);
|
||||
const channels = record(item.channels, `${label}.channels`);
|
||||
return {
|
||||
scenarioId: degradationKind(
|
||||
item.scenario_id,
|
||||
`${label}.scenario_id`,
|
||||
),
|
||||
kind: degradationKind(item.kind, `${label}.kind`),
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
faultPhase: oneOf(
|
||||
item.fault_phase,
|
||||
["before", "during", "after"] as const,
|
||||
`${label}.fault_phase`,
|
||||
),
|
||||
action: stringValue(item.action, `${label}.action`),
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(channels).map(([key, entry]) => [
|
||||
key,
|
||||
stringValue(entry, `${label}.channels.${key}`),
|
||||
]),
|
||||
),
|
||||
inputPointRows: integerValue(
|
||||
item.input_point_rows,
|
||||
`${label}.input_point_rows`,
|
||||
),
|
||||
transformedPointRows: integerValue(
|
||||
item.transformed_point_rows,
|
||||
`${label}.transformed_point_rows`,
|
||||
),
|
||||
layerState: oneOf(
|
||||
item.layer_state,
|
||||
["current", "held", "unknown"] as const,
|
||||
`${label}.layer_state`,
|
||||
),
|
||||
counts: {
|
||||
current: integerValue(counts.current, `${label}.counts.current`),
|
||||
held: integerValue(counts.held, `${label}.counts.held`),
|
||||
expired: integerValue(counts.expired, `${label}.counts.expired`),
|
||||
},
|
||||
components: list(item.components, `${label}.components`).map(
|
||||
(entry, index) => parseComponent(
|
||||
entry,
|
||||
`${label}.components[${index}]`,
|
||||
),
|
||||
),
|
||||
cellCentersMapXyzM: list(
|
||||
item.cell_centers_map_xyz_m,
|
||||
`${label}.cell_centers_map_xyz_m`,
|
||||
).map((entry, index) => point(
|
||||
entry,
|
||||
`${label}.cell_centers_map_xyz_m[${index}]`,
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewScenario(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35RecoveryReviewScenario {
|
||||
const item = record(value, label);
|
||||
const scenario = record(item.scenario, `${label}.scenario`);
|
||||
return {
|
||||
scenarioId: degradationKind(
|
||||
scenario.scenario_id,
|
||||
`${label}.scenario.scenario_id`,
|
||||
),
|
||||
kind: degradationKind(
|
||||
scenario.kind,
|
||||
`${label}.scenario.kind`,
|
||||
),
|
||||
frameStart: integerValue(
|
||||
scenario.frame_start,
|
||||
`${label}.scenario.frame_start`,
|
||||
),
|
||||
frameEnd: integerValue(
|
||||
scenario.frame_end,
|
||||
`${label}.scenario.frame_end`,
|
||||
),
|
||||
frames: list(item.frames, `${label}.frames`).map(
|
||||
(entry, index) => parseReviewFrame(
|
||||
entry,
|
||||
`${label}.frames[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseScenarioMetrics(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35ScenarioMetrics {
|
||||
const item = record(value, label);
|
||||
const integer = (key: string) => integerValue(item[key], `${label}.${key}`);
|
||||
return {
|
||||
scenarioId: degradationKind(item.scenario_id, `${label}.scenario_id`),
|
||||
kind: degradationKind(item.kind, `${label}.kind`),
|
||||
frameStart: integer("frame_start"),
|
||||
frameEnd: integer("frame_end"),
|
||||
framesProcessed: integer("frames_processed"),
|
||||
injectedFrames: integer("injected_frames"),
|
||||
droppedFrames: integer("dropped_frames"),
|
||||
hiddenSuccessFrames: integer("hidden_success_frames"),
|
||||
falseFreeRows: integer("false_free_rows"),
|
||||
semanticClaimsDuringCameraLoss: integer(
|
||||
"semantic_claims_during_camera_loss",
|
||||
),
|
||||
metricRowsDuringLidarOrPoseLoss: integer(
|
||||
"metric_rows_during_lidar_or_pose_loss",
|
||||
),
|
||||
agreeClaimsDuringTimingOffset: integer(
|
||||
"agree_claims_during_timing_offset",
|
||||
),
|
||||
lateResultsReintroduced: integer("late_results_reintroduced"),
|
||||
maximumCurrentComponentsDuringFault: integer(
|
||||
"maximum_current_components_during_fault",
|
||||
),
|
||||
maximumHeldComponentsDuringFault: integer(
|
||||
"maximum_held_components_during_fault",
|
||||
),
|
||||
expiredComponentsDuringFault: integer(
|
||||
"expired_components_during_fault",
|
||||
),
|
||||
recoveryFrameIndex: integer("recovery_frame_index"),
|
||||
recoverySeconds: numberValue(
|
||||
item.recovery_seconds,
|
||||
`${label}.recovery_seconds`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: запрещённые полномочия.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E35DegradationRecoveryResult {
|
||||
const item = record(value, "E35");
|
||||
const configuration = record(item.configuration, "E35.configuration");
|
||||
const metrics = record(item.metrics, "E35.metrics");
|
||||
const review = record(item.review, "E35.review");
|
||||
const acceptance = record(item.acceptance, "E35.acceptance");
|
||||
diagnosticAuthority(item.authority, "E35.authority");
|
||||
if (acceptance.accepted !== true) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"E35.acceptance: результат отклонён.",
|
||||
);
|
||||
}
|
||||
exactString(
|
||||
review.schema_version,
|
||||
"missioncore.e35-recovery-review/v1",
|
||||
"E35.review.schema_version",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e35-degradation-recovery",
|
||||
"E35.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E35.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E35.source_session_id",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-deterministic-degradation-recovery",
|
||||
"E35.status",
|
||||
),
|
||||
e32ResultId: contentId(
|
||||
item.e32_result_id,
|
||||
"e32-track-geometry",
|
||||
"E35.e32_result_id",
|
||||
),
|
||||
e33ResultId: contentId(
|
||||
item.e33_result_id,
|
||||
"e33-worker-shadow",
|
||||
"E35.e33_result_id",
|
||||
),
|
||||
e34ResultId: contentId(
|
||||
item.e34_result_id,
|
||||
"e34-temporal-occupied",
|
||||
"E35.e34_result_id",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E35.profile_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E35.pipeline_id"),
|
||||
coordinateFrame: exactString(
|
||||
item.coordinate_frame,
|
||||
"map",
|
||||
"E35.coordinate_frame",
|
||||
),
|
||||
configuration: {
|
||||
maximumRecoverySeconds: numberValue(
|
||||
configuration.maximum_recovery_seconds,
|
||||
"E35.configuration.maximum_recovery_seconds",
|
||||
),
|
||||
scenarioCount: integerValue(
|
||||
configuration.scenario_count,
|
||||
"E35.configuration.scenario_count",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
sourceFrames: integerValue(
|
||||
metrics.source_frames,
|
||||
"E35.metrics.source_frames",
|
||||
),
|
||||
variantCount: integerValue(
|
||||
metrics.variant_count,
|
||||
"E35.metrics.variant_count",
|
||||
),
|
||||
variantFrameOutcomes: integerValue(
|
||||
metrics.variant_frame_outcomes,
|
||||
"E35.metrics.variant_frame_outcomes",
|
||||
),
|
||||
injectionRecords: integerValue(
|
||||
metrics.injection_records,
|
||||
"E35.metrics.injection_records",
|
||||
),
|
||||
variantFrameProcessingP95Ms: numberValue(
|
||||
metrics.variant_frame_processing_p95_ms,
|
||||
"E35.metrics.variant_frame_processing_p95_ms",
|
||||
),
|
||||
buildElapsedMs: numberValue(
|
||||
metrics.build_elapsed_ms,
|
||||
"E35.metrics.build_elapsed_ms",
|
||||
),
|
||||
},
|
||||
scenarios: list(item.scenarios, "E35.scenarios").map(
|
||||
(entry, index) => parseScenarioMetrics(
|
||||
entry,
|
||||
`E35.scenarios[${index}]`,
|
||||
),
|
||||
),
|
||||
reviewScenarios: list(review.scenarios, "E35.review.scenarios").map(
|
||||
(entry, index) => parseReviewScenario(
|
||||
entry,
|
||||
`E35.review.scenarios[${index}]`,
|
||||
),
|
||||
),
|
||||
access: exactString(item.access, "read-only", "E35.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE35DegradationRecoveryResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E35DegradationRecoveryResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e35/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`Каталог LAB E35 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "Каталог LAB E35");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E35.schema_version",
|
||||
);
|
||||
if (typeof catalog.configured !== "boolean") {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"Каталог LAB E35.configured: ожидался boolean.",
|
||||
);
|
||||
}
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E35.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E35.invalid_total");
|
||||
exactString(catalog.access, "read-only", "Каталог LAB E35.access");
|
||||
const items = list(catalog.items, "Каталог LAB E35.items");
|
||||
if (items.length > 1) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"Каталог LAB E35: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
export const E40_CASE_DIMENSIONS = [
|
||||
"presence",
|
||||
"geometry_association",
|
||||
"freshness",
|
||||
] as const;
|
||||
|
||||
export type E40CaseDimension = (typeof E40_CASE_DIMENSIONS)[number];
|
||||
export type E40CaseSeverity = "high" | "medium" | "standard";
|
||||
export type E40CaseStratum =
|
||||
| "agree"
|
||||
| "camera-only"
|
||||
| "conflict"
|
||||
| "geometry-only"
|
||||
| "unknown";
|
||||
export type E40PredictionBasis =
|
||||
| "camera-only-softmax"
|
||||
| "fixed-stratum-policy";
|
||||
|
||||
export interface E40CaseState {
|
||||
presence: "background-or-noise" | "object-present" | "occupied-environment";
|
||||
geometryAssociation:
|
||||
| "independent-occupied"
|
||||
| "insufficient-support"
|
||||
| "object-associated"
|
||||
| "rejected-nonobject"
|
||||
| "unknown";
|
||||
freshness: "current" | "stale" | "unavailable";
|
||||
}
|
||||
|
||||
export interface E40ErrorCase {
|
||||
itemId: string;
|
||||
sequence: number;
|
||||
sourceFrameIndex: number;
|
||||
sourceStratum: E40CaseStratum;
|
||||
severity: E40CaseSeverity;
|
||||
presenceConfidence: number;
|
||||
predictionBasis: E40PredictionBasis;
|
||||
reference: E40CaseState;
|
||||
prediction: E40CaseState;
|
||||
mismatchedDimensions: readonly E40CaseDimension[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E40CaseCatalog {
|
||||
resultId: string;
|
||||
materializationId: string;
|
||||
items: readonly E40ErrorCase[];
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable v1 storage vocabulary. Product UI interprets these legacy values
|
||||
* only as support/challenge of the E37 engineering label; neither value
|
||||
* establishes physical ground truth or authorizes an operational model.
|
||||
*/
|
||||
export type E40OperatorVerdict = "confirmed-error" | "rejected-error";
|
||||
|
||||
export interface E40OperatorDecision {
|
||||
itemId: string;
|
||||
verdict: E40OperatorVerdict;
|
||||
revision: number;
|
||||
idempotencyKey: string;
|
||||
decidedAtUtc: string;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
export interface E40OperatorReview {
|
||||
reviewId: string;
|
||||
resultId: string;
|
||||
materializationId: string;
|
||||
caseCatalogSha256: string;
|
||||
itemSetSha256: string;
|
||||
itemCount: number;
|
||||
reviewerId: string;
|
||||
revision: number;
|
||||
decisions: readonly E40OperatorDecision[];
|
||||
reviewedItemCount: number;
|
||||
remainingItemCount: number;
|
||||
updatedAtUtc: string | null;
|
||||
access: "review-write";
|
||||
}
|
||||
|
||||
export class E40CaseReviewContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E40CaseReviewContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E40CaseReviewContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256Value(value: unknown, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E40CaseReviewContractError(`${label}: неверный SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
if (value === null) return null;
|
||||
return stringValue(value, label);
|
||||
}
|
||||
|
||||
function probability(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isFinite(value)
|
||||
|| value < 0
|
||||
|| value > 1
|
||||
) {
|
||||
throw new E40CaseReviewContractError(`${label}: ожидалась вероятность.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E40CaseReviewContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
||||
throw new E40CaseReviewContractError(`${label}: значение не поддерживается.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function parseState(value: unknown, label: string): E40CaseState {
|
||||
const source = record(value, label);
|
||||
return {
|
||||
presence: enumValue(
|
||||
source.presence,
|
||||
[
|
||||
"background-or-noise",
|
||||
"object-present",
|
||||
"occupied-environment",
|
||||
],
|
||||
`${label}.presence`,
|
||||
),
|
||||
geometryAssociation: enumValue(
|
||||
source.geometry_association,
|
||||
[
|
||||
"independent-occupied",
|
||||
"insufficient-support",
|
||||
"object-associated",
|
||||
"rejected-nonobject",
|
||||
"unknown",
|
||||
],
|
||||
`${label}.geometry_association`,
|
||||
),
|
||||
freshness: enumValue(
|
||||
source.freshness,
|
||||
["current", "stale", "unavailable"],
|
||||
`${label}.freshness`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCase(value: unknown, index: number): E40ErrorCase {
|
||||
const label = `E40 case[${index}]`;
|
||||
const source = record(value, label);
|
||||
if (!Array.isArray(source.mismatched_dimensions)) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`${label}.mismatched_dimensions: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
const mismatchedDimensions = source.mismatched_dimensions.map(
|
||||
(dimension, dimensionIndex) => enumValue(
|
||||
dimension,
|
||||
E40_CASE_DIMENSIONS,
|
||||
`${label}.mismatched_dimensions[${dimensionIndex}]`,
|
||||
),
|
||||
);
|
||||
if (!mismatchedDimensions.length) {
|
||||
throw new E40CaseReviewContractError(`${label}: кейс не содержит ошибки.`);
|
||||
}
|
||||
return {
|
||||
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
|
||||
sequence: integerValue(source.sequence, `${label}.sequence`),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
`${label}.source_frame_index`,
|
||||
),
|
||||
sourceStratum: enumValue(
|
||||
source.source_stratum,
|
||||
["agree", "camera-only", "conflict", "geometry-only", "unknown"],
|
||||
`${label}.source_stratum`,
|
||||
),
|
||||
severity: enumValue(
|
||||
source.severity,
|
||||
["high", "medium", "standard"],
|
||||
`${label}.severity`,
|
||||
),
|
||||
presenceConfidence: probability(
|
||||
source.presence_confidence,
|
||||
`${label}.presence_confidence`,
|
||||
),
|
||||
predictionBasis: enumValue(
|
||||
source.prediction_basis,
|
||||
["camera-only-softmax", "fixed-stratum-policy"],
|
||||
`${label}.prediction_basis`,
|
||||
),
|
||||
reference: parseState(source.reference, `${label}.reference`),
|
||||
prediction: parseState(source.prediction, `${label}.prediction`),
|
||||
mismatchedDimensions,
|
||||
access: exactString(source.access, "read-only", `${label}.access`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE40CaseCatalog(payload: unknown): E40CaseCatalog {
|
||||
const source = record(payload, "E40 case catalog");
|
||||
exactString(
|
||||
source.schema_version,
|
||||
"missioncore.laboratory-e40-case-catalog/v1",
|
||||
"E40 case catalog.schema_version",
|
||||
);
|
||||
if (!Array.isArray(source.items) || source.items.length > 64) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 case catalog.items: нарушена граница.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e40-perception-product-gate",
|
||||
"E40 case catalog.result_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"E40 case catalog.materialization_id",
|
||||
),
|
||||
items: source.items.map(parseCase),
|
||||
total: integerValue(source.total, "E40 case catalog.total"),
|
||||
truncated: typeof source.truncated === "boolean"
|
||||
? source.truncated
|
||||
: (() => {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 case catalog.truncated: ожидался boolean.",
|
||||
);
|
||||
})(),
|
||||
access: exactString(
|
||||
source.access,
|
||||
"read-only",
|
||||
"E40 case catalog.access",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOperatorDecision(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): E40OperatorDecision {
|
||||
const label = `E40 operator decision[${index}]`;
|
||||
const source = record(value, label);
|
||||
return {
|
||||
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
|
||||
verdict: enumValue(
|
||||
source.verdict,
|
||||
["confirmed-error", "rejected-error"],
|
||||
`${label}.verdict`,
|
||||
),
|
||||
revision: integerValue(source.revision, `${label}.revision`),
|
||||
idempotencyKey: stringValue(
|
||||
source.idempotency_key,
|
||||
`${label}.idempotency_key`,
|
||||
),
|
||||
decidedAtUtc: stringValue(
|
||||
source.decided_at_utc,
|
||||
`${label}.decided_at_utc`,
|
||||
),
|
||||
eventId: contentId(
|
||||
source.event_id,
|
||||
"e40-operator-verdict",
|
||||
`${label}.event_id`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE40OperatorReview(
|
||||
payload: unknown,
|
||||
): E40OperatorReview {
|
||||
const source = record(payload, "E40 operator review");
|
||||
exactString(
|
||||
source.schema_version,
|
||||
"missioncore.e40-operator-review/v1",
|
||||
"E40 operator review.schema_version",
|
||||
);
|
||||
exactString(
|
||||
source.protocol,
|
||||
"sealed-error-adjudication/v1",
|
||||
"E40 operator review.protocol",
|
||||
);
|
||||
const binding = record(source.source, "E40 operator review.source");
|
||||
if (!Array.isArray(source.decisions) || source.decisions.length > 64) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 operator review.decisions: нарушена граница.",
|
||||
);
|
||||
}
|
||||
const decisions = source.decisions.map(parseOperatorDecision);
|
||||
const reviewedItemCount = integerValue(
|
||||
source.reviewed_item_count,
|
||||
"E40 operator review.reviewed_item_count",
|
||||
);
|
||||
const remainingItemCount = integerValue(
|
||||
source.remaining_item_count,
|
||||
"E40 operator review.remaining_item_count",
|
||||
);
|
||||
const itemCount = integerValue(
|
||||
binding.item_count,
|
||||
"E40 operator review.source.item_count",
|
||||
);
|
||||
if (
|
||||
reviewedItemCount !== decisions.length
|
||||
|| reviewedItemCount + remainingItemCount !== itemCount
|
||||
|| new Set(decisions.map((decision) => decision.itemId)).size
|
||||
!== decisions.length
|
||||
) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"E40 operator review: счётчики решений несовместимы.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
reviewId: contentId(
|
||||
source.review_id,
|
||||
"e40-operator-review",
|
||||
"E40 operator review.review_id",
|
||||
),
|
||||
resultId: contentId(
|
||||
binding.result_id,
|
||||
"e40-perception-product-gate",
|
||||
"E40 operator review.source.result_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
binding.materialization_id,
|
||||
"e30-materialization",
|
||||
"E40 operator review.source.materialization_id",
|
||||
),
|
||||
caseCatalogSha256: sha256Value(
|
||||
binding.case_catalog_sha256,
|
||||
"E40 operator review.source.case_catalog_sha256",
|
||||
),
|
||||
itemSetSha256: sha256Value(
|
||||
binding.item_set_sha256,
|
||||
"E40 operator review.source.item_set_sha256",
|
||||
),
|
||||
itemCount,
|
||||
reviewerId: stringValue(
|
||||
source.reviewer_id,
|
||||
"E40 operator review.reviewer_id",
|
||||
),
|
||||
revision: integerValue(
|
||||
source.revision,
|
||||
"E40 operator review.revision",
|
||||
),
|
||||
decisions,
|
||||
reviewedItemCount,
|
||||
remainingItemCount,
|
||||
updatedAtUtc: nullableString(
|
||||
source.updated_at_utc,
|
||||
"E40 operator review.updated_at_utc",
|
||||
),
|
||||
access: exactString(
|
||||
source.access,
|
||||
"review-write",
|
||||
"E40 operator review.access",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE40CaseCatalog(
|
||||
resultId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<E40CaseCatalog> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}/cases?limit=48`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`Case-review E40 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = parseE40CaseCatalog(await response.json());
|
||||
if (catalog.resultId !== resultId) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"Case-review E40 вернул другой результат.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function fetchE40OperatorReview(
|
||||
resultId: string,
|
||||
{
|
||||
reviewerId = "DC",
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
reviewerId?: string;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<E40OperatorReview> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
|
||||
+ `/operator-review?reviewer_id=${encodeURIComponent(reviewerId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`Операторская проверка E40 недоступна: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const review = parseE40OperatorReview(await response.json());
|
||||
if (review.resultId !== resultId || review.reviewerId !== reviewerId) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"Операторская проверка E40 вернула другой источник.",
|
||||
);
|
||||
}
|
||||
return review;
|
||||
}
|
||||
|
||||
export async function saveE40OperatorVerdict(
|
||||
resultId: string,
|
||||
itemId: string,
|
||||
{
|
||||
reviewerId = "DC",
|
||||
expectedRevision,
|
||||
idempotencyKey,
|
||||
verdict,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
reviewerId?: string;
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
verdict: E40OperatorVerdict;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<E40OperatorReview> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
|
||||
+ `/operator-review/decisions/${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: reviewerId,
|
||||
expected_revision: expectedRevision,
|
||||
idempotency_key: idempotencyKey,
|
||||
verdict,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40CaseReviewContractError(
|
||||
`Решение E40 не сохранено: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const review = parseE40OperatorReview(await response.json());
|
||||
if (
|
||||
review.resultId !== resultId
|
||||
|| review.reviewerId !== reviewerId
|
||||
|| !review.decisions.some(
|
||||
(decision) => decision.itemId === itemId
|
||||
&& decision.verdict === verdict,
|
||||
)
|
||||
) {
|
||||
throw new E40CaseReviewContractError(
|
||||
"Решение E40 не подтверждено сервером.",
|
||||
);
|
||||
}
|
||||
return review;
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
export interface E40DevelopmentDimensionMetric {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
target: number;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export interface E40DimensionMetric extends E40DevelopmentDimensionMetric {
|
||||
byStratum: Readonly<Record<string, {
|
||||
correct: number;
|
||||
incorrect: number;
|
||||
total: number;
|
||||
accuracy: number;
|
||||
}>>;
|
||||
confusion: readonly {
|
||||
reference: string;
|
||||
prediction: string;
|
||||
count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface E40DevelopmentProtocol {
|
||||
items: number;
|
||||
foldSizes: Readonly<Record<string, number>>;
|
||||
passed: boolean;
|
||||
dimensions: {
|
||||
presence: E40DevelopmentDimensionMetric;
|
||||
geometryAssociation: E40DevelopmentDimensionMetric;
|
||||
freshness: E40DevelopmentDimensionMetric;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E40PerceptionProductGateResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
sourceDisplayName: string;
|
||||
status: "measured-leakage-resistant-product-gate";
|
||||
profileId: string;
|
||||
workerNode: string;
|
||||
qualityGatePassed: boolean;
|
||||
developmentCrossValidation: {
|
||||
strategy: string;
|
||||
seed: string;
|
||||
folds: number;
|
||||
items: number;
|
||||
validationLabelsUsed: false;
|
||||
passed: boolean;
|
||||
protocols: {
|
||||
contiguousSourceTime: E40DevelopmentProtocol;
|
||||
wholeTrackOrSceneWindow: E40DevelopmentProtocol;
|
||||
};
|
||||
};
|
||||
metrics: {
|
||||
developmentItems: number;
|
||||
validationItems: number;
|
||||
terminalOutcomes: number;
|
||||
accountingFraction: number;
|
||||
falseFreeClaims: number;
|
||||
highSeverityFailures: number;
|
||||
dimensions: {
|
||||
presence: E40DimensionMetric;
|
||||
geometryAssociation: E40DimensionMetric;
|
||||
freshness: E40DimensionMetric;
|
||||
};
|
||||
};
|
||||
blockingChecks: readonly string[];
|
||||
method: {
|
||||
summary: string;
|
||||
selection: string;
|
||||
dimensionProjection: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export class E40ProductGateContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E40ProductGateContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E40ProductGateContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E40ProductGateContractError(`${label}: неверное значение.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E40ProductGateContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberRecord(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, item]) => [
|
||||
key,
|
||||
integerValue(item, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E40ProductGateContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E40ProductGateContractError(`${label}: запрещённые полномочия.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDevelopmentDimension(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E40DevelopmentDimensionMetric {
|
||||
const dimension = record(value, label);
|
||||
return {
|
||||
correct: integerValue(dimension.correct, `${label}.correct`),
|
||||
incorrect: integerValue(dimension.incorrect, `${label}.incorrect`),
|
||||
total: integerValue(dimension.total, `${label}.total`),
|
||||
accuracy: numberValue(dimension.accuracy, `${label}.accuracy`),
|
||||
target: numberValue(dimension.target, `${label}.target`),
|
||||
passed: booleanValue(dimension.passed, `${label}.passed`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDimension(value: unknown, label: string): E40DimensionMetric {
|
||||
const dimension = record(value, label);
|
||||
const byStratum = record(dimension.by_stratum, `${label}.by_stratum`);
|
||||
const confusion = dimension.confusion;
|
||||
if (!Array.isArray(confusion)) {
|
||||
throw new E40ProductGateContractError(
|
||||
`${label}.confusion: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...parseDevelopmentDimension(value, label),
|
||||
byStratum: Object.fromEntries(
|
||||
Object.entries(byStratum).map(([stratum, raw]) => {
|
||||
const item = record(raw, `${label}.by_stratum.${stratum}`);
|
||||
return [stratum, {
|
||||
correct: integerValue(
|
||||
item.correct,
|
||||
`${label}.by_stratum.${stratum}.correct`,
|
||||
),
|
||||
incorrect: integerValue(
|
||||
item.incorrect,
|
||||
`${label}.by_stratum.${stratum}.incorrect`,
|
||||
),
|
||||
total: integerValue(
|
||||
item.total,
|
||||
`${label}.by_stratum.${stratum}.total`,
|
||||
),
|
||||
accuracy: numberValue(
|
||||
item.accuracy,
|
||||
`${label}.by_stratum.${stratum}.accuracy`,
|
||||
),
|
||||
}];
|
||||
}),
|
||||
),
|
||||
confusion: confusion.map((raw, index) => {
|
||||
const item = record(raw, `${label}.confusion[${index}]`);
|
||||
return {
|
||||
reference: stringValue(
|
||||
item.reference,
|
||||
`${label}.confusion[${index}].reference`,
|
||||
),
|
||||
prediction: stringValue(
|
||||
item.prediction,
|
||||
`${label}.confusion[${index}].prediction`,
|
||||
),
|
||||
count: integerValue(
|
||||
item.count,
|
||||
`${label}.confusion[${index}].count`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDevelopmentProtocol(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E40DevelopmentProtocol {
|
||||
const protocol = record(value, label);
|
||||
const dimensions = record(protocol.dimensions, `${label}.dimensions`);
|
||||
return {
|
||||
items: integerValue(protocol.items, `${label}.items`),
|
||||
foldSizes: numberRecord(protocol.fold_sizes, `${label}.fold_sizes`),
|
||||
passed: booleanValue(protocol.passed, `${label}.passed`),
|
||||
dimensions: {
|
||||
presence: parseDevelopmentDimension(
|
||||
dimensions.presence,
|
||||
`${label}.dimensions.presence`,
|
||||
),
|
||||
geometryAssociation: parseDevelopmentDimension(
|
||||
dimensions.geometry_association,
|
||||
`${label}.dimensions.geometry_association`,
|
||||
),
|
||||
freshness: parseDevelopmentDimension(
|
||||
dimensions.freshness,
|
||||
`${label}.dimensions.freshness`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E40PerceptionProductGateResult {
|
||||
const item = record(value, "E40");
|
||||
const metrics = record(item.metrics, "E40.metrics");
|
||||
const dimensions = record(metrics.dimensions, "E40.metrics.dimensions");
|
||||
const development = record(
|
||||
item.development_cross_validation,
|
||||
"E40.development_cross_validation",
|
||||
);
|
||||
const protocols = record(
|
||||
development.protocols,
|
||||
"E40.development_cross_validation.protocols",
|
||||
);
|
||||
const method = record(item.method, "E40.method");
|
||||
diagnosticAuthority(item.authority, "E40.authority");
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e40-perception-product-gate",
|
||||
"E40.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E40.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E40.source_session_id",
|
||||
),
|
||||
sourceDisplayName: stringValue(
|
||||
item.source_display_name,
|
||||
"E40.source_display_name",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"measured-leakage-resistant-product-gate",
|
||||
"E40.status",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E40.profile_id"),
|
||||
workerNode: stringValue(item.worker_node, "E40.worker_node"),
|
||||
qualityGatePassed: booleanValue(
|
||||
item.quality_gate_passed,
|
||||
"E40.quality_gate_passed",
|
||||
),
|
||||
developmentCrossValidation: {
|
||||
strategy: stringValue(
|
||||
development.strategy,
|
||||
"E40.development_cross_validation.strategy",
|
||||
),
|
||||
seed: stringValue(
|
||||
development.seed,
|
||||
"E40.development_cross_validation.seed",
|
||||
),
|
||||
folds: integerValue(
|
||||
development.folds,
|
||||
"E40.development_cross_validation.folds",
|
||||
),
|
||||
items: integerValue(
|
||||
development.items,
|
||||
"E40.development_cross_validation.items",
|
||||
),
|
||||
validationLabelsUsed: falseValue(
|
||||
development.validation_labels_used,
|
||||
"E40.development_cross_validation.validation_labels_used",
|
||||
),
|
||||
passed: booleanValue(
|
||||
development.passed,
|
||||
"E40.development_cross_validation.passed",
|
||||
),
|
||||
protocols: {
|
||||
contiguousSourceTime: parseDevelopmentProtocol(
|
||||
protocols["contiguous-source-time-five-fold"],
|
||||
"E40.development_cross_validation.protocols.contiguous-source-time-five-fold",
|
||||
),
|
||||
wholeTrackOrSceneWindow: parseDevelopmentProtocol(
|
||||
protocols["whole-track-or-scene-window-five-fold"],
|
||||
"E40.development_cross_validation.protocols.whole-track-or-scene-window-five-fold",
|
||||
),
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
developmentItems: integerValue(
|
||||
metrics.development_items,
|
||||
"E40.metrics.development_items",
|
||||
),
|
||||
validationItems: integerValue(
|
||||
metrics.validation_items,
|
||||
"E40.metrics.validation_items",
|
||||
),
|
||||
terminalOutcomes: integerValue(
|
||||
metrics.terminal_outcomes,
|
||||
"E40.metrics.terminal_outcomes",
|
||||
),
|
||||
accountingFraction: numberValue(
|
||||
metrics.accounting_fraction,
|
||||
"E40.metrics.accounting_fraction",
|
||||
),
|
||||
falseFreeClaims: integerValue(
|
||||
metrics.false_free_claims,
|
||||
"E40.metrics.false_free_claims",
|
||||
),
|
||||
highSeverityFailures: integerValue(
|
||||
metrics.high_severity_failures,
|
||||
"E40.metrics.high_severity_failures",
|
||||
),
|
||||
dimensions: {
|
||||
presence: parseDimension(
|
||||
dimensions.presence,
|
||||
"E40.metrics.dimensions.presence",
|
||||
),
|
||||
geometryAssociation: parseDimension(
|
||||
dimensions.geometry_association,
|
||||
"E40.metrics.dimensions.geometry_association",
|
||||
),
|
||||
freshness: parseDimension(
|
||||
dimensions.freshness,
|
||||
"E40.metrics.dimensions.freshness",
|
||||
),
|
||||
},
|
||||
},
|
||||
blockingChecks: strings(item.blocking_checks, "E40.blocking_checks"),
|
||||
method: {
|
||||
summary: stringValue(method.summary, "E40.method.summary"),
|
||||
selection: stringValue(method.selection, "E40.method.selection"),
|
||||
dimensionProjection: stringValue(
|
||||
method.dimension_projection,
|
||||
"E40.method.dimension_projection",
|
||||
),
|
||||
},
|
||||
limitations: strings(item.limitations, "E40.limitations"),
|
||||
access: exactString(item.access, "read-only", "E40.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCatalog(payload: unknown): E40PerceptionProductGateResult | null {
|
||||
const catalog = record(payload, "Каталог LAB E40");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E40.schema_version",
|
||||
);
|
||||
booleanValue(catalog.configured, "Каталог LAB E40.configured");
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E40.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E40.invalid_total");
|
||||
exactString(catalog.access, "read-only", "Каталог LAB E40.access");
|
||||
if (!Array.isArray(catalog.items)) {
|
||||
throw new E40ProductGateContractError(
|
||||
"Каталог LAB E40.items: ожидался массив.",
|
||||
);
|
||||
}
|
||||
if (catalog.items.length > 1) {
|
||||
throw new E40ProductGateContractError(
|
||||
"Каталог LAB E40.items: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return catalog.items.length ? parseResult(catalog.items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchE40ProductGateResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E40PerceptionProductGateResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e40/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E40ProductGateContractError(
|
||||
`Каталог LAB E40 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
return parseCatalog(await response.json());
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
export interface E46BlindReviewSubmissionSummary {
|
||||
resultId: string;
|
||||
reviewerId: string;
|
||||
annotationSessionId: string;
|
||||
state: "completed-e48-review-input-not-truth";
|
||||
frameCount: number;
|
||||
objectCount: number;
|
||||
}
|
||||
|
||||
export interface E46BlindReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "prepared-awaiting-independent-human-review";
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
frameCount: 32;
|
||||
anchorCount: 16;
|
||||
temporalFrameCount: 16;
|
||||
temporalGroupCount: 4;
|
||||
reviewSessionCount: number;
|
||||
completedSubmissionCount: number;
|
||||
requiredSubmissionCount: 2;
|
||||
};
|
||||
submissions: readonly E46BlindReviewSubmissionSummary[];
|
||||
decision: {
|
||||
collectionReady: true;
|
||||
twoIndependentReviewsComplete: boolean;
|
||||
adjudicationReady: boolean;
|
||||
e48Sealed: false;
|
||||
l35Open: false;
|
||||
nextAction: string;
|
||||
};
|
||||
access: "review-collection-read-only-summary";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E46BlindReviewContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E46BlindReviewContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string | boolean | number>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E46BlindReviewContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function parseSubmission(value: unknown): E46BlindReviewSubmissionSummary {
|
||||
const item = object(value, "E46 submission");
|
||||
const resultId = string(item.result_id, "E46 submission id");
|
||||
if (!/^e46-lab-review-submission-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new E46BlindReviewContractError("E46 submission: нарушена identity.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
reviewerId: string(item.reviewer_id, "E46 reviewer"),
|
||||
annotationSessionId: string(item.annotation_session_id, "E46 session"),
|
||||
state: exact(
|
||||
item.state,
|
||||
"completed-e48-review-input-not-truth",
|
||||
"E46 submission state",
|
||||
),
|
||||
frameCount: integer(item.frame_count, "E46 submitted frames"),
|
||||
objectCount: integer(item.object_count, "E46 submitted objects"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46BlindReviewResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46BlindReviewResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new E46BlindReviewContractError(`E46 LAB недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const catalog = object(await response.json(), "E46 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.e46-detector-truth-island-catalog/v1",
|
||||
"E46 catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "E46 results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) {
|
||||
throw new E46BlindReviewContractError("E46 catalog: нарушен размер.");
|
||||
}
|
||||
const item = object(items[0], "E46 result");
|
||||
const resultId = string(item.result_id, "E46 result id");
|
||||
if (!/^e46-detector-truth-island-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new E46BlindReviewContractError("E46 result: нарушена identity.");
|
||||
}
|
||||
const metrics = object(item.metrics, "E46 metrics");
|
||||
const decision = object(item.decision, "E46 decision");
|
||||
const blindness = object(item.blindness, "E46 blindness");
|
||||
const authority = object(item.authority, "E46 authority");
|
||||
const submissions = array(item.submissions, "E46 submissions").map(parseSubmission);
|
||||
const reviewSessions = array(item.review_sessions, "E46 review sessions");
|
||||
const completedSubmissionCount = integer(
|
||||
metrics.completed_submission_count,
|
||||
"E46 completed submissions",
|
||||
);
|
||||
if (
|
||||
completedSubmissionCount !== submissions.length
|
||||
|| completedSubmissionCount > 2
|
||||
|| integer(metrics.review_session_count, "E46 session count") !== reviewSessions.length
|
||||
|| reviewSessions.length > 2
|
||||
|| submissions.some((submission) => submission.frameCount !== 32)
|
||||
) {
|
||||
throw new E46BlindReviewContractError("E46 review collection: нарушен размер.");
|
||||
}
|
||||
for (const key of (
|
||||
[
|
||||
"candidate_identity_included",
|
||||
"model_prelabels_included",
|
||||
"model_predictions_included",
|
||||
"model_scores_included",
|
||||
] as const
|
||||
)) exact(blindness[key], false, `E46 blindness ${key}`);
|
||||
for (const key of (
|
||||
[
|
||||
"ground_truth",
|
||||
"independent_truth",
|
||||
"metric_grade_reference",
|
||||
"candidate_accepted",
|
||||
"commands_enabled",
|
||||
"navigation_or_safety_accepted",
|
||||
] as const
|
||||
)) exact(authority[key], false, `E46 authority ${key}`);
|
||||
const twoComplete = boolean(
|
||||
decision.two_independent_reviews_complete,
|
||||
"E46 two review state",
|
||||
);
|
||||
const adjudicationReady = boolean(
|
||||
decision.adjudication_ready,
|
||||
"E46 adjudication state",
|
||||
);
|
||||
if (twoComplete !== (completedSubmissionCount === 2) || adjudicationReady !== twoComplete) {
|
||||
throw new E46BlindReviewContractError("E46 decision: нарушен review gate.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46 created time"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"prepared-awaiting-independent-human-review",
|
||||
"E46 status",
|
||||
),
|
||||
sourceSessionId: string(item.source_session_id, "E46 source session"),
|
||||
cameraSourceId: exact(
|
||||
item.camera_source_id,
|
||||
"sensor.camera.right",
|
||||
"E46 camera source",
|
||||
),
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 32, "E46 frame count"),
|
||||
anchorCount: exact(metrics.anchor_count, 16, "E46 anchors"),
|
||||
temporalFrameCount: exact(
|
||||
metrics.temporal_frame_count,
|
||||
16,
|
||||
"E46 temporal frames",
|
||||
),
|
||||
temporalGroupCount: exact(
|
||||
metrics.temporal_group_count,
|
||||
4,
|
||||
"E46 temporal groups",
|
||||
),
|
||||
reviewSessionCount: integer(metrics.review_session_count, "E46 sessions"),
|
||||
completedSubmissionCount,
|
||||
requiredSubmissionCount: exact(
|
||||
metrics.required_submission_count,
|
||||
2,
|
||||
"E46 required submissions",
|
||||
),
|
||||
},
|
||||
submissions,
|
||||
decision: {
|
||||
collectionReady: exact(
|
||||
decision.collection_ready,
|
||||
true,
|
||||
"E46 collection ready",
|
||||
),
|
||||
twoIndependentReviewsComplete: twoComplete,
|
||||
adjudicationReady,
|
||||
e48Sealed: exact(decision.e48_sealed, false, "E48 seal state"),
|
||||
l35Open: exact(decision.l35_open, false, "L3.5 state"),
|
||||
nextAction: string(decision.next_action, "E46 next action"),
|
||||
},
|
||||
access: exact(
|
||||
item.access,
|
||||
"review-collection-read-only-summary",
|
||||
"E46 access",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
export interface E46AObject {
|
||||
objectId: string;
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
occluded: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface E46ACase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
objects: readonly E46AObject[];
|
||||
objectCount: number;
|
||||
cameraUrl: string;
|
||||
groundTruth: false;
|
||||
independentReview: false;
|
||||
}
|
||||
|
||||
export interface E46AAiEngineeringPreannotationResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: "RAVNOVES00";
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
sourceE46ResultId: string;
|
||||
metrics: {
|
||||
frameCount: 32;
|
||||
reviewedFrameCount: 32;
|
||||
sourceObjectCount: number;
|
||||
objectCount: number;
|
||||
customClassRelabelCount: number;
|
||||
deletedFalseBoxCount: number;
|
||||
geometrySnappedObjectCount: number;
|
||||
sourceGeometryRetainedObjectCount: number;
|
||||
categoryCorrectedObjectCount: number;
|
||||
hardNegativeFrameCount: number;
|
||||
independentReviewSubmissionCount: 0;
|
||||
classCounts: Readonly<Record<string, number>>;
|
||||
};
|
||||
taxonomy: {
|
||||
classes: readonly string[];
|
||||
customClasses: readonly ["laptop", "stroller"];
|
||||
unresolvedClassCount: 0;
|
||||
};
|
||||
decision: {
|
||||
preannotationAvailable: true;
|
||||
visualReviewComplete: true;
|
||||
independentReviewCountAffected: false;
|
||||
e48TruthSealOpen: false;
|
||||
l35AcceptanceOpen: false;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
groundTruth: false;
|
||||
access: "read-only-ai-engineering-preannotation-not-truth";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E46AContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E46AContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new E46AContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E46AContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E46AContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new E46AContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalInteger(value: unknown, fallback: number, label: string): number {
|
||||
return value === undefined ? fallback : integer(value, label);
|
||||
}
|
||||
|
||||
function exact<T extends string | boolean | number>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new E46AContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E46AContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) throw new E46AContractError(`${label}: invalid hash.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseClassCounts(value: unknown): Readonly<Record<string, number>> {
|
||||
const raw = object(value, "E46A class counts");
|
||||
const parsed: Record<string, number> = {};
|
||||
for (const [key, count] of Object.entries(raw)) {
|
||||
if (!key.trim()) throw new E46AContractError("E46A class name: пустое значение.");
|
||||
parsed[key] = integer(count, `E46A ${key} count`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): E46AObject {
|
||||
const item = object(value, "E46A object");
|
||||
const box = array(item.box_xyxy, "E46A box").map((coordinate) => number(coordinate, "E46A coordinate"));
|
||||
if (box.length !== 4 || box[2] <= box[0] || box[3] <= box[1]) {
|
||||
throw new E46AContractError("E46A box: invalid geometry.");
|
||||
}
|
||||
const category = string(item.category, "E46A category");
|
||||
if (category === "unmapped") throw new E46AContractError("E46A category unresolved.");
|
||||
return {
|
||||
objectId: string(item.object_id, "E46A object id"),
|
||||
category,
|
||||
displayCategory: category === "stroller" ? "Коляска"
|
||||
: category === "laptop" ? "Ноутбук"
|
||||
: category,
|
||||
boxXyxy: box as [number, number, number, number],
|
||||
occluded: boolean(item.occluded, "E46A occluded"),
|
||||
truncated: boolean(item.truncated, "E46A truncated"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46AAiEngineeringPreannotation({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46AAiEngineeringPreannotationResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46a/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new E46AContractError(`E46A LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46A catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46a-ai-engineering-preannotation-catalog/v1", "E46A catalog schema");
|
||||
const items = array(catalog.items, "E46A results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new E46AContractError("E46A catalog: нарушен размер.");
|
||||
const item = object(items[0], "E46A result");
|
||||
exact(item.schema_version, "missioncore.e46a-ai-engineering-preannotation-view/v1", "E46A result schema");
|
||||
const resultId = string(item.result_id, "E46A result id");
|
||||
const sourceE46ResultId = string(item.source_e46_result_id, "E46A source id");
|
||||
if (!/^e46a-ai-engineering-preannotation-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !/^e46-detector-truth-island-[a-f0-9]{64}$/.test(sourceE46ResultId)) {
|
||||
throw new E46AContractError("E46A result: нарушена identity.");
|
||||
}
|
||||
const metrics = object(item.metrics, "E46A metrics");
|
||||
const taxonomy = object(item.taxonomy, "E46A taxonomy");
|
||||
const decision = object(item.decision, "E46A decision");
|
||||
const classes = array(taxonomy.classes, "E46A classes").map((value) => string(value, "E46A class"));
|
||||
const custom = array(taxonomy.custom_classes, "E46A custom classes").map((value) => string(value, "E46A custom class"));
|
||||
const objectCount = integer(metrics.object_count, "E46A object count");
|
||||
if (custom.length !== 2 || custom[0] !== "laptop" || custom[1] !== "stroller") {
|
||||
throw new E46AContractError("E46A custom taxonomy changed.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46A created time"),
|
||||
sourceSessionId: exact(item.source_session_id, "RAVNOVES00", "E46A source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46A camera source"),
|
||||
sourceE46ResultId,
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 32, "E46A frame count"),
|
||||
reviewedFrameCount: exact(metrics.reviewed_frame_count, 32, "E46A reviewed frames"),
|
||||
sourceObjectCount: optionalInteger(metrics.source_object_count, objectCount, "E46A source object count"),
|
||||
objectCount,
|
||||
customClassRelabelCount: integer(metrics.custom_class_relabel_count, "E46A relabel count"),
|
||||
deletedFalseBoxCount: optionalInteger(metrics.deleted_false_box_count, 0, "E46A deleted false boxes"),
|
||||
geometrySnappedObjectCount: optionalInteger(metrics.geometry_snapped_object_count, 0, "E46A snapped geometry"),
|
||||
sourceGeometryRetainedObjectCount: optionalInteger(metrics.source_geometry_retained_object_count, objectCount, "E46A retained geometry"),
|
||||
categoryCorrectedObjectCount: optionalInteger(metrics.category_corrected_object_count, 0, "E46A category corrections"),
|
||||
hardNegativeFrameCount: integer(metrics.hard_negative_frame_count, "E46A hard negatives"),
|
||||
independentReviewSubmissionCount: exact(metrics.independent_review_submission_count, 0, "E46A independent reviews"),
|
||||
classCounts: parseClassCounts(metrics.class_counts),
|
||||
},
|
||||
taxonomy: {
|
||||
classes,
|
||||
customClasses: ["laptop", "stroller"],
|
||||
unresolvedClassCount: exact(taxonomy.unresolved_class_count, 0, "E46A unresolved classes"),
|
||||
},
|
||||
decision: {
|
||||
preannotationAvailable: exact(decision.preannotation_available, true, "E46A available"),
|
||||
visualReviewComplete: exact(decision.visual_review_complete, true, "E46A visual review"),
|
||||
independentReviewCountAffected: exact(decision.independent_review_count_affected, false, "E46A reviewer isolation"),
|
||||
e48TruthSealOpen: exact(decision.e48_truth_seal_open, false, "E46A E48 state"),
|
||||
l35AcceptanceOpen: exact(decision.l35_acceptance_open, false, "E46A L3.5 state"),
|
||||
nextAction: string(decision.next_action, "E46A next action"),
|
||||
},
|
||||
limitations: array(item.limitations, "E46A limitations").map((value) => string(value, "E46A limitation")),
|
||||
groundTruth: exact(item.ground_truth, false, "E46A truth state"),
|
||||
access: exact(item.access, "read-only-ai-engineering-preannotation-not-truth", "E46A access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46ACase(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<E46ACase> {
|
||||
if (!/^e46a-ai-engineering-preannotation-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new E46AContractError("E46A case request invalid.");
|
||||
}
|
||||
const response = await fetcher(`/api/v1/laboratory/e46a/results/${resultId}/cases/${sequence}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new E46AContractError(`E46A frame недоступен: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "E46A case");
|
||||
exact(item.schema_version, "missioncore.e46a-ai-engineering-preannotation-case/v1", "E46A case schema");
|
||||
const objects = array(item.objects, "E46A objects").map(parseObject);
|
||||
if (integer(item.object_count, "E46A object count") !== objects.length) {
|
||||
throw new E46AContractError("E46A case object count mismatch.");
|
||||
}
|
||||
return {
|
||||
resultId: exact(item.result_id, resultId, "E46A case result"),
|
||||
truthIslandSequence: exact(item.truth_island_sequence, sequence, "E46A case sequence"),
|
||||
imageId: integer(item.image_id, "E46A image id"),
|
||||
frameIndex: integer(item.frame_index, "E46A frame index"),
|
||||
groupId: string(item.group_id, "E46A group"),
|
||||
sessionSeconds: number(item.session_seconds, "E46A session seconds"),
|
||||
sourceImageSha256: sha256(item.source_image_sha256, "E46A source hash"),
|
||||
objects,
|
||||
objectCount: objects.length,
|
||||
cameraUrl: string(item.camera_url, "E46A camera URL"),
|
||||
groundTruth: exact(item.ground_truth, false, "E46A case truth"),
|
||||
independentReview: exact(item.independent_review, false, "E46A independent review"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export type E46BMotionState = "dynamic" | "static" | "unknown";
|
||||
|
||||
export interface E46BObject {
|
||||
objectId: string;
|
||||
trackId: string;
|
||||
trackIndex: number;
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
motionState: E46BMotionState;
|
||||
motionConfidence: number;
|
||||
motionEvidenceObservationCount: number;
|
||||
trailCentersXy: readonly (readonly [number, number])[];
|
||||
}
|
||||
|
||||
export interface E46BCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
objects: readonly E46BObject[];
|
||||
objectCount: number;
|
||||
motionCounts: Readonly<Record<E46BMotionState, number>>;
|
||||
cameraUrl: string;
|
||||
}
|
||||
|
||||
export interface E46BTemporalMotionResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceE46AResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
metrics: {
|
||||
frameCount: 16;
|
||||
temporalGroupCount: 4;
|
||||
objectObservationCount: 136;
|
||||
trackCount: 34;
|
||||
matchedMotionObservationCount: number;
|
||||
unmatchedMotionObservationCount: number;
|
||||
dynamicTrackCount: number;
|
||||
staticTrackCount: number;
|
||||
unknownTrackCount: number;
|
||||
visuallyReviewedFrameCount: 16;
|
||||
};
|
||||
decision: { stableIdsAvailable: true; motionStateAvailable: true; metricVelocityAvailable: false; nextAction: string };
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
class ContractError extends Error {}
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ContractError(`${label}: объект.`);
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: массив.`);
|
||||
return value;
|
||||
};
|
||||
const string = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) throw new ContractError(`${label}: строка.`);
|
||||
return value;
|
||||
};
|
||||
const number = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new ContractError(`${label}: число.`);
|
||||
return value;
|
||||
};
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new ContractError(`${label}: целое.`);
|
||||
return parsed;
|
||||
};
|
||||
const exact = <T extends string | boolean | number>(value: unknown, expected: T, label: string): T => {
|
||||
if (value !== expected) throw new ContractError(`${label}: контракт.`);
|
||||
return expected;
|
||||
};
|
||||
const motionState = (value: unknown): E46BMotionState => {
|
||||
if (value !== "dynamic" && value !== "static" && value !== "unknown") throw new ContractError("E46B motion state.");
|
||||
return value;
|
||||
};
|
||||
const displayCategory = (category: string): string => category === "stroller" ? "Коляска"
|
||||
: category === "laptop" ? "Ноутбук"
|
||||
: category === "heavy_vehicle" ? "Тяжёлый транспорт"
|
||||
: category === "person" ? "Человек"
|
||||
: category === "car" ? "Авто" : category;
|
||||
|
||||
export async function fetchE46BTemporalMotion({ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46BTemporalMotionResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46b/results?limit=1", { headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new ContractError(`E46B LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46B catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46b-temporal-motion-catalog/v1", "E46B catalog schema");
|
||||
const items = array(catalog.items, "E46B results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46B catalog size.");
|
||||
const item = object(items[0], "E46B result");
|
||||
exact(item.schema_version, "missioncore.e46b-temporal-motion-view/v1", "E46B view schema");
|
||||
const resultId = string(item.result_id, "E46B result id");
|
||||
if (!/^e46b-temporal-motion-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46B identity.");
|
||||
const metrics = object(item.metrics, "E46B metrics");
|
||||
const decision = object(item.decision, "E46B decision");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46B created"),
|
||||
sourceE46AResultId: string(item.source_e46a_result_id, "E46B E46A source"),
|
||||
sourceE26ResultId: string(item.source_e26_result_id, "E46B E26 source"),
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 16, "E46B frames"),
|
||||
temporalGroupCount: exact(metrics.temporal_group_count, 4, "E46B groups"),
|
||||
objectObservationCount: exact(metrics.object_observation_count, 136, "E46B observations"),
|
||||
trackCount: exact(metrics.track_count, 34, "E46B tracks"),
|
||||
matchedMotionObservationCount: integer(metrics.matched_motion_observation_count, "E46B matched"),
|
||||
unmatchedMotionObservationCount: integer(metrics.unmatched_motion_observation_count, "E46B unmatched"),
|
||||
dynamicTrackCount: integer(metrics.dynamic_track_count, "E46B dynamic"),
|
||||
staticTrackCount: integer(metrics.static_track_count, "E46B static"),
|
||||
unknownTrackCount: integer(metrics.unknown_track_count, "E46B unknown"),
|
||||
visuallyReviewedFrameCount: exact(metrics.visually_reviewed_frame_count, 16, "E46B reviewed"),
|
||||
},
|
||||
decision: {
|
||||
stableIdsAvailable: exact(decision.stable_ids_available, true, "E46B stable IDs"),
|
||||
motionStateAvailable: exact(decision.motion_state_available, true, "E46B motion"),
|
||||
metricVelocityAvailable: exact(decision.metric_velocity_available, false, "E46B velocity"),
|
||||
nextAction: string(decision.next_action, "E46B next action"),
|
||||
},
|
||||
limitations: array(item.limitations, "E46B limitations").map((value) => string(value, "E46B limitation")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46BCase(resultId: string, sequence: number, { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46BCase> {
|
||||
if (!/^e46b-temporal-motion-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46B result id.");
|
||||
const response = await fetcher(`/api/v1/laboratory/e46b/results/${resultId}/cases/${sequence}`, { headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new ContractError(`E46B frame недоступен: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "E46B case");
|
||||
exact(item.schema_version, "missioncore.e46b-temporal-motion-case/v1", "E46B case schema");
|
||||
const objects = array(item.objects, "E46B objects").map((raw): E46BObject => {
|
||||
const value = object(raw, "E46B object");
|
||||
const box = array(value.box_xyxy, "E46B box").map((coordinate) => number(coordinate, "E46B coordinate"));
|
||||
const trail = array(value.trail_centers_xy, "E46B trail").map((rawPoint) => {
|
||||
const point = array(rawPoint, "E46B point").map((coordinate) => number(coordinate, "E46B point coordinate"));
|
||||
if (point.length !== 2) throw new ContractError("E46B point geometry.");
|
||||
return point as [number, number];
|
||||
});
|
||||
if (box.length !== 4 || box[2] <= box[0] || box[3] <= box[1]) throw new ContractError("E46B box geometry.");
|
||||
const category = string(value.category, "E46B category");
|
||||
return {
|
||||
objectId: string(value.object_id, "E46B object id"),
|
||||
trackId: string(value.track_id, "E46B track id"),
|
||||
trackIndex: integer(value.track_index, "E46B track index"),
|
||||
category,
|
||||
displayCategory: displayCategory(category),
|
||||
boxXyxy: box as [number, number, number, number],
|
||||
motionState: motionState(value.motion_state),
|
||||
motionConfidence: number(value.motion_confidence, "E46B motion confidence"),
|
||||
motionEvidenceObservationCount: integer(value.motion_evidence_observation_count, "E46B evidence count"),
|
||||
trailCentersXy: trail,
|
||||
};
|
||||
});
|
||||
const rawCounts = object(item.motion_counts, "E46B motion counts");
|
||||
return {
|
||||
resultId: exact(item.result_id, resultId, "E46B case result"),
|
||||
truthIslandSequence: integer(item.truth_island_sequence, "E46B sequence"),
|
||||
frameIndex: integer(item.frame_index, "E46B frame"),
|
||||
groupId: string(item.group_id, "E46B group"),
|
||||
sessionSeconds: number(item.session_seconds, "E46B time"),
|
||||
sourceImageSha256: string(item.source_image_sha256, "E46B image hash"),
|
||||
objects,
|
||||
objectCount: integer(item.object_count, "E46B object count"),
|
||||
motionCounts: { dynamic: integer(rawCounts.dynamic, "E46B dynamic count"), static: integer(rawCounts.static, "E46B static count"), unknown: integer(rawCounts.unknown, "E46B unknown count") },
|
||||
cameraUrl: string(item.camera_url, "E46B camera URL"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
export type E46CMotionState = "dynamic" | "static" | "unknown";
|
||||
|
||||
export interface E46CCameraObject {
|
||||
objectId: string;
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
routeTrackId: number | null;
|
||||
worldTrackId: number | null;
|
||||
motionState: E46CMotionState;
|
||||
}
|
||||
export interface E46CWorldObject {
|
||||
worldTrackId: number;
|
||||
routeTrackId: number;
|
||||
category: string;
|
||||
motionState: E46CMotionState;
|
||||
positionMapM: readonly [number, number, number];
|
||||
occupancyFootprintMapXy: readonly (readonly [number, number])[];
|
||||
occupancyEvidenceCurrent: boolean;
|
||||
occupancyCellCount: number;
|
||||
}
|
||||
export interface E46CCase {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
sessionSeconds: number;
|
||||
sourceImageSha256: string;
|
||||
fusionState: string;
|
||||
objects: readonly E46CCameraObject[];
|
||||
objectCount: number;
|
||||
matchedRouteObjectCount: number;
|
||||
worldObjects: readonly E46CWorldObject[];
|
||||
worldObjectCount: number;
|
||||
cameraUrl: string;
|
||||
}
|
||||
export interface E46CFullReplayWorldTracksResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceE46AResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
metrics: {
|
||||
routeFrameCount: 4489;
|
||||
routeSpanSeconds: number;
|
||||
fusionObservationCount: number;
|
||||
sourceTrackCount: number;
|
||||
worldTrackCandidateCount: number;
|
||||
worldTrackCount: number;
|
||||
sourceTrackWorldBoundCount: number;
|
||||
worldFrameCount: number;
|
||||
worldObservationCount: number;
|
||||
worldCurrentObservationCount: number;
|
||||
worldHeldObservationCount: number;
|
||||
visualSampleFrameCount: 32;
|
||||
sampleObjectCount: number;
|
||||
sampleMatchedRouteObjectCount: number;
|
||||
sampleUnmatchedObjectCount: number;
|
||||
sampleWorldFrameCount: number;
|
||||
motionObservationCounts: Readonly<Record<E46CMotionState, number>>;
|
||||
};
|
||||
acceptance: { benchmarkPassedEvents: 11; benchmarkTotalEvents: 11 };
|
||||
decision: { routeTrackLayerAvailable: true; worldOccupiedLayerAvailable: true; unknownRemainsOccupied: true; freeSpaceAvailable: false; nextAction: string };
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface E46CVideoObject {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
category: string;
|
||||
displayCategory: string;
|
||||
score: number;
|
||||
routeTrackId: number;
|
||||
worldTrackId: number | null;
|
||||
motionState: E46CMotionState;
|
||||
motionConfidence: number;
|
||||
trackHits: number;
|
||||
trackAgeSeconds: number;
|
||||
cameraEvidenceCurrent: boolean;
|
||||
worldEvidenceCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface E46CVideoFrame {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
fusionState: string;
|
||||
objects: readonly E46CVideoObject[];
|
||||
}
|
||||
|
||||
export interface E46CVideoReviewWindow {
|
||||
id: string;
|
||||
kind: string;
|
||||
classGroup: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
targetSourceTrackIds: readonly number[];
|
||||
}
|
||||
|
||||
export interface E46CVideoOverlay {
|
||||
resultId: string;
|
||||
recordedSourceSessionId: string;
|
||||
recordedSourceId: "sensor.camera.right";
|
||||
inputSha256: string;
|
||||
imageWidth: 800;
|
||||
imageHeight: 600;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
frameCount: 4489;
|
||||
frames: readonly E46CVideoFrame[];
|
||||
reviewWindows: readonly E46CVideoReviewWindow[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
class ContractError extends Error {}
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => { if (!value || typeof value !== "object" || Array.isArray(value)) throw new ContractError(`${label}: объект.`); return value as Record<string, unknown>; };
|
||||
const array = (value: unknown, label: string): readonly unknown[] => { if (!Array.isArray(value)) throw new ContractError(`${label}: массив.`); return value; };
|
||||
const string = (value: unknown, label: string): string => { if (typeof value !== "string" || !value.trim()) throw new ContractError(`${label}: строка.`); return value; };
|
||||
const number = (value: unknown, label: string): number => { if (typeof value !== "number" || !Number.isFinite(value)) throw new ContractError(`${label}: число.`); return value; };
|
||||
const integer = (value: unknown, label: string): number => { const parsed = number(value, label); if (!Number.isInteger(parsed) || parsed < 0) throw new ContractError(`${label}: целое.`); return parsed; };
|
||||
const optionalInteger = (value: unknown, label: string): number | null => value === null ? null : integer(value, label);
|
||||
const exact = <T extends string | boolean | number>(value: unknown, expected: T, label: string): T => { if (value !== expected) throw new ContractError(`${label}: контракт.`); return expected; };
|
||||
const motionState = (value: unknown): E46CMotionState => { if (value !== "dynamic" && value !== "static" && value !== "unknown") throw new ContractError("E46C motion state."); return value; };
|
||||
const displayCategory = (category: string): string => category === "stroller" ? "Коляска" : category === "laptop" ? "Ноутбук" : category === "heavy_vehicle" || category === "truck" || category === "bus" ? "Тяжёлый транспорт" : category === "person" ? "Человек" : category === "car" ? "Авто" : category;
|
||||
const vector = (value: unknown, length: number, label: string): number[] => { const parsed = array(value, label).map((item) => number(item, label)); if (parsed.length !== length) throw new ContractError(`${label}: geometry.`); return parsed; };
|
||||
|
||||
export async function fetchE46CFullReplayWorldTracks({ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46CFullReplayWorldTracksResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46c/results?limit=1", { headers: { Accept: "application/json" }, signal });
|
||||
if (!response.ok) throw new ContractError(`E46C LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46C catalog"); exact(catalog.schema_version, "missioncore.e46c-full-replay-world-tracks-catalog/v1", "E46C catalog schema");
|
||||
const items = array(catalog.items, "E46C results"); if (!items.length) return null; if (items.length !== 1) throw new ContractError("E46C catalog size.");
|
||||
const item = object(items[0], "E46C result"); exact(item.schema_version, "missioncore.e46c-full-replay-world-tracks-view/v1", "E46C view schema");
|
||||
const resultId = string(item.result_id, "E46C result id"); if (!/^e46c-full-replay-world-tracks-[a-f0-9]{64}$/.test(resultId)) throw new ContractError("E46C identity.");
|
||||
const metrics = object(item.metrics, "E46C metrics"); const counts = object(metrics.motion_observation_counts, "E46C motion counts"); const acceptance = object(item.acceptance, "E46C acceptance"); const decision = object(item.decision, "E46C decision");
|
||||
return { resultId, createdAtUtc: string(item.created_at_utc, "E46C created"), sourceE46AResultId: string(item.source_e46a_result_id, "E46C E46A"), sourceE26ResultId: string(item.source_e26_result_id, "E46C E26"), metrics: { routeFrameCount: exact(metrics.route_frame_count, 4489, "E46C route frames"), routeSpanSeconds: number(metrics.route_span_seconds, "E46C span"), fusionObservationCount: integer(metrics.fusion_observation_count, "E46C observations"), sourceTrackCount: integer(metrics.source_track_count, "E46C source tracks"), worldTrackCandidateCount: integer(metrics.world_track_candidate_count, "E46C world candidates"), worldTrackCount: integer(metrics.world_track_count, "E46C world tracks"), sourceTrackWorldBoundCount: integer(metrics.source_track_world_bound_count, "E46C bound tracks"), worldFrameCount: integer(metrics.world_frame_count, "E46C world frames"), worldObservationCount: integer(metrics.world_observation_count, "E46C world observations"), worldCurrentObservationCount: integer(metrics.world_current_observation_count, "E46C current world"), worldHeldObservationCount: integer(metrics.world_held_observation_count, "E46C held world"), visualSampleFrameCount: exact(metrics.visual_sample_frame_count, 32, "E46C samples"), sampleObjectCount: integer(metrics.sample_object_count, "E46C sample objects"), sampleMatchedRouteObjectCount: integer(metrics.sample_matched_route_object_count, "E46C matched"), sampleUnmatchedObjectCount: integer(metrics.sample_unmatched_object_count, "E46C unmatched"), sampleWorldFrameCount: integer(metrics.sample_world_frame_count, "E46C sample world frames"), motionObservationCounts: { dynamic: integer(counts.dynamic, "E46C dynamic"), static: integer(counts.static, "E46C static"), unknown: integer(counts.unknown, "E46C unknown") } }, acceptance: { benchmarkPassedEvents: exact(acceptance.benchmark_passed_events, 11, "E46C passed events"), benchmarkTotalEvents: exact(acceptance.benchmark_total_events, 11, "E46C total events") }, decision: { routeTrackLayerAvailable: exact(decision.route_track_layer_available, true, "E46C route layer"), worldOccupiedLayerAvailable: exact(decision.world_occupied_layer_available, true, "E46C world layer"), unknownRemainsOccupied: exact(decision.unknown_remains_occupied, true, "E46C unknown"), freeSpaceAvailable: exact(decision.free_space_available, false, "E46C free space"), nextAction: string(decision.next_action, "E46C next") }, limitations: array(item.limitations, "E46C limitations").map((value) => string(value, "E46C limitation")) };
|
||||
}
|
||||
|
||||
export async function fetchE46CCase(resultId: string, sequence: number, { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise<E46CCase> {
|
||||
const response = await fetcher(`/api/v1/laboratory/e46c/results/${resultId}/cases/${sequence}`, { headers: { Accept: "application/json" }, signal }); if (!response.ok) throw new ContractError(`E46C frame недоступен: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "E46C case"); exact(item.schema_version, "missioncore.e46c-full-replay-world-track-case/v1", "E46C case schema");
|
||||
const objects = array(item.objects, "E46C objects").map((raw): E46CCameraObject => { const value = object(raw, "E46C object"); const category = string(value.category, "E46C category"); const box = vector(value.box_xyxy, 4, "E46C box") as [number, number, number, number]; return { objectId: string(value.object_id, "E46C object id"), category, displayCategory: displayCategory(category), boxXyxy: box, routeTrackId: optionalInteger(value.route_track_id, "E46C route track"), worldTrackId: optionalInteger(value.world_track_id, "E46C world track"), motionState: motionState(value.motion_state) }; });
|
||||
const worldObjects = array(item.world_objects, "E46C world objects").map((raw): E46CWorldObject => { const value = object(raw, "E46C world object"); return { worldTrackId: integer(value.world_track_id, "E46C world id"), routeTrackId: integer(value.route_track_id, "E46C route id"), category: string(value.category, "E46C world category"), motionState: motionState(value.motion_state), positionMapM: vector(value.position_map_m, 3, "E46C position") as [number, number, number], occupancyFootprintMapXy: array(value.occupancy_footprint_map_xy, "E46C footprint").map((point) => vector(point, 2, "E46C footprint point") as [number, number]), occupancyEvidenceCurrent: exact(value.occupancy_evidence_current, Boolean(value.occupancy_evidence_current), "E46C current") as boolean, occupancyCellCount: integer(value.occupancy_cell_count, "E46C cells") }; });
|
||||
return { resultId: exact(item.result_id, resultId, "E46C case result"), truthIslandSequence: integer(item.truth_island_sequence, "E46C sequence"), frameIndex: integer(item.frame_index, "E46C frame"), groupId: string(item.group_id, "E46C group"), sessionSeconds: number(item.session_seconds, "E46C time"), sourceImageSha256: string(item.source_image_sha256, "E46C image hash"), fusionState: string(item.fusion_state, "E46C fusion"), objects, objectCount: integer(item.object_count, "E46C object count"), matchedRouteObjectCount: integer(item.matched_route_object_count, "E46C matched count"), worldObjects, worldObjectCount: integer(item.world_object_count, "E46C world count"), cameraUrl: string(item.camera_url, "E46C camera URL") };
|
||||
}
|
||||
|
||||
export async function fetchE46CVideoOverlay(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<E46CVideoOverlay> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e46c/results/${resultId}/video-overlay`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ContractError(`E46C video overlay недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = object(await response.json(), "E46C video overlay");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.e46c-recorded-video-overlay/v1",
|
||||
"E46C video overlay schema",
|
||||
);
|
||||
exact(payload.result_id, resultId, "E46C video result");
|
||||
exact(payload.ground_truth, false, "E46C video truth");
|
||||
const recordedSource = object(payload.recorded_source, "E46C recorded source");
|
||||
exact(recordedSource.source_id, "sensor.camera.right", "E46C video source");
|
||||
exact(
|
||||
recordedSource.synchronization,
|
||||
"host-arrival-best-effort",
|
||||
"E46C video synchronization",
|
||||
);
|
||||
const inputSha256 = string(recordedSource.input_sha256, "E46C video input hash");
|
||||
if (!/^[a-f0-9]{64}$/.test(inputSha256)) throw new ContractError("E46C video input hash.");
|
||||
const frames = array(payload.frames, "E46C video frames").map(
|
||||
(raw, frameSequence): E46CVideoFrame => {
|
||||
const frame = object(raw, "E46C video frame");
|
||||
const frameIndex = integer(frame.frame_index, "E46C video frame index");
|
||||
if (frameIndex !== frameSequence) throw new ContractError("E46C video frame order.");
|
||||
return {
|
||||
frameIndex,
|
||||
sessionSeconds: number(frame.session_seconds, "E46C video frame time"),
|
||||
fusionState: string(frame.fusion_state, "E46C video fusion state"),
|
||||
objects: array(frame.objects, "E46C video objects").map((rawObject) => {
|
||||
const item = object(rawObject, "E46C video object");
|
||||
const category = string(item.category, "E46C video category");
|
||||
return {
|
||||
boxXyxy: vector(item.bbox_xyxy, 4, "E46C video box") as [number, number, number, number],
|
||||
category,
|
||||
displayCategory: displayCategory(category),
|
||||
score: number(item.score, "E46C video score"),
|
||||
routeTrackId: integer(item.route_track_id, "E46C video route track"),
|
||||
worldTrackId: optionalInteger(item.world_track_id, "E46C video world track"),
|
||||
motionState: motionState(item.motion_state),
|
||||
motionConfidence: number(item.motion_confidence, "E46C video motion confidence"),
|
||||
trackHits: integer(item.track_hits, "E46C video track hits"),
|
||||
trackAgeSeconds: number(item.track_age_seconds, "E46C video track age"),
|
||||
cameraEvidenceCurrent: exact(
|
||||
item.camera_evidence_current,
|
||||
Boolean(item.camera_evidence_current),
|
||||
"E46C camera evidence",
|
||||
) as boolean,
|
||||
worldEvidenceCurrent: exact(
|
||||
item.world_evidence_current,
|
||||
Boolean(item.world_evidence_current),
|
||||
"E46C world evidence",
|
||||
) as boolean,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
const reviewWindows = array(payload.review_windows, "E46C video review windows").map(
|
||||
(raw): E46CVideoReviewWindow => {
|
||||
const window = object(raw, "E46C video review window");
|
||||
return {
|
||||
id: string(window.id, "E46C video review id"),
|
||||
kind: string(window.kind, "E46C video review kind"),
|
||||
classGroup: string(window.class_group, "E46C video review class"),
|
||||
startSeconds: number(window.start_seconds, "E46C video review start"),
|
||||
endSeconds: number(window.end_seconds, "E46C video review end"),
|
||||
targetSourceTrackIds: array(
|
||||
window.target_source_track_ids,
|
||||
"E46C video review targets",
|
||||
).map((value) => integer(value, "E46C video review target")),
|
||||
};
|
||||
},
|
||||
);
|
||||
const timelineStartSeconds = number(
|
||||
payload.timeline_start_seconds,
|
||||
"E46C video timeline start",
|
||||
);
|
||||
const timelineEndSeconds = number(
|
||||
payload.timeline_end_seconds,
|
||||
"E46C video timeline end",
|
||||
);
|
||||
if (
|
||||
frames.length !== 4489 ||
|
||||
exact(payload.frame_count, 4489, "E46C video frame count") !== frames.length ||
|
||||
frames[0]?.sessionSeconds !== timelineStartSeconds ||
|
||||
frames.at(-1)?.sessionSeconds !== timelineEndSeconds
|
||||
) {
|
||||
throw new ContractError("E46C video timeline coverage.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
recordedSourceSessionId: string(
|
||||
recordedSource.session_id,
|
||||
"E46C recorded source session",
|
||||
),
|
||||
recordedSourceId: "sensor.camera.right",
|
||||
inputSha256,
|
||||
imageWidth: exact(payload.image_width, 800, "E46C video width"),
|
||||
imageHeight: exact(payload.image_height, 600, "E46C video height"),
|
||||
timelineStartSeconds,
|
||||
timelineEndSeconds,
|
||||
frameCount: 4489,
|
||||
frames,
|
||||
reviewWindows,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectE46CVideoFrame(
|
||||
frames: readonly E46CVideoFrame[],
|
||||
sessionSeconds: number,
|
||||
): E46CVideoFrame | null {
|
||||
if (!frames.length || !Number.isFinite(sessionSeconds)) return null;
|
||||
let low = 0;
|
||||
let high = frames.length - 1;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const frame = frames[middle];
|
||||
if (!frame) break;
|
||||
if (frame.sessionSeconds <= sessionSeconds) low = middle + 1;
|
||||
else high = middle - 1;
|
||||
}
|
||||
const before = frames[Math.max(0, high)] ?? null;
|
||||
const after = frames[Math.min(frames.length - 1, low)] ?? null;
|
||||
if (!before) return after;
|
||||
if (!after) return before;
|
||||
return Math.abs(before.sessionSeconds - sessionSeconds) <=
|
||||
Math.abs(after.sessionSeconds - sessionSeconds)
|
||||
? before
|
||||
: after;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
export type E46DTemporalSignalKind =
|
||||
| "layer-blackout"
|
||||
| "camera-evidence-hold"
|
||||
| "route-layer-gap"
|
||||
| "route-id-rebirth-candidate"
|
||||
| "bbox-jump"
|
||||
| "motion-state-flap"
|
||||
| "world-binding-flap"
|
||||
| "short-track-burst";
|
||||
|
||||
export type E46DTemporalPriority = "critical" | "high" | "medium";
|
||||
|
||||
export interface E46DReviewClip {
|
||||
clipId: string;
|
||||
rank: number;
|
||||
priority: E46DTemporalPriority;
|
||||
kind: E46DTemporalSignalKind;
|
||||
signalId: string;
|
||||
startSeconds: number;
|
||||
eventStartSeconds: number;
|
||||
eventEndSeconds: number;
|
||||
endSeconds: number;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
routeTrackIds: readonly number[];
|
||||
worldTrackIds: readonly number[];
|
||||
evidence: Readonly<Record<string, string | number | boolean>>;
|
||||
}
|
||||
|
||||
export interface E46DTemporalFailureAuditResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceE46CResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
metrics: {
|
||||
routeFrameCount: 4489;
|
||||
routeSpanSeconds: number;
|
||||
objectObservationCount: number;
|
||||
routeTrackCount: number;
|
||||
zeroObjectFrameCount: number;
|
||||
zeroObjectFrameFraction: number;
|
||||
cameraHeldObservationCount: number;
|
||||
cameraHeldObservationFraction: number;
|
||||
detectorHoldEpisodeCount: number;
|
||||
layerBlackoutEpisodeCount: number;
|
||||
routeLayerGapEpisodeCount: number;
|
||||
routeIdRebirthCandidateCount: number;
|
||||
bboxJumpEpisodeCount: number;
|
||||
motionStateFlapEpisodeCount: number;
|
||||
worldBindingFlapEpisodeCount: number;
|
||||
shortRouteTrackCount: number;
|
||||
shortRouteTrackFraction: number;
|
||||
shortTrackBurstEpisodeCount: number;
|
||||
failureSignalCount: number;
|
||||
reviewClipCount: number;
|
||||
temporalContinuityPassed: boolean;
|
||||
};
|
||||
acceptance: {
|
||||
fullRouteAccounted: true;
|
||||
temporalContinuityPassed: boolean;
|
||||
independentTruthAvailable: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
decision: {
|
||||
temporalRegressionConfirmed: boolean;
|
||||
detectorGapVisible: boolean;
|
||||
routeFragmentationVisible: boolean;
|
||||
worldBindingInstabilityVisible: boolean;
|
||||
nextAction: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
reviewClips: readonly E46DReviewClip[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function priority(value: unknown): E46DTemporalPriority {
|
||||
if (value !== "critical" && value !== "high" && value !== "medium") {
|
||||
throw new ContractError("E46D priority: неизвестное значение.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function kind(value: unknown): E46DTemporalSignalKind {
|
||||
if (
|
||||
value !== "layer-blackout"
|
||||
&& value !== "camera-evidence-hold"
|
||||
&& value !== "route-layer-gap"
|
||||
&& value !== "route-id-rebirth-candidate"
|
||||
&& value !== "bbox-jump"
|
||||
&& value !== "motion-state-flap"
|
||||
&& value !== "world-binding-flap"
|
||||
&& value !== "short-track-burst"
|
||||
) {
|
||||
throw new ContractError("E46D signal kind: неизвестное значение.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function shaResult(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function evidence(value: unknown): Readonly<Record<string, string | number | boolean>> {
|
||||
const raw = object(value, "E46D clip evidence");
|
||||
const parsed: Record<string, string | number | boolean> = {};
|
||||
for (const [key, item] of Object.entries(raw)) {
|
||||
if (
|
||||
typeof item !== "string"
|
||||
&& typeof item !== "number"
|
||||
&& typeof item !== "boolean"
|
||||
) {
|
||||
throw new ContractError("E46D clip evidence: неизвестное значение.");
|
||||
}
|
||||
parsed[key] = item;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseClip(value: unknown, expectedRank: number): E46DReviewClip {
|
||||
const item = object(value, "E46D review clip");
|
||||
exact(item.schema_version, "missioncore.e46d-temporal-review-clip/v1", "E46D clip schema");
|
||||
const rank = integer(item.rank, "E46D clip rank");
|
||||
if (rank !== expectedRank) throw new ContractError("E46D clip order: нарушен контракт.");
|
||||
const clipId = string(item.clip_id, "E46D clip id");
|
||||
const signalId = string(item.signal_id, "E46D signal id");
|
||||
if (!/^e46d-clip-\d{2}-[a-f0-9]{20}$/.test(clipId) || !/^e46d-signal-[a-f0-9]{20}$/.test(signalId)) {
|
||||
throw new ContractError("E46D clip identity: нарушен контракт.");
|
||||
}
|
||||
const startSeconds = number(item.start_seconds, "E46D clip start");
|
||||
const eventStartSeconds = number(item.event_start_seconds, "E46D event start");
|
||||
const eventEndSeconds = number(item.event_end_seconds, "E46D event end");
|
||||
const endSeconds = number(item.end_seconds, "E46D clip end");
|
||||
if (!(startSeconds <= eventStartSeconds && eventStartSeconds <= eventEndSeconds && eventEndSeconds <= endSeconds)) {
|
||||
throw new ContractError("E46D clip timeline: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
clipId,
|
||||
rank,
|
||||
priority: priority(item.priority),
|
||||
kind: kind(item.kind),
|
||||
signalId,
|
||||
startSeconds,
|
||||
eventStartSeconds,
|
||||
eventEndSeconds,
|
||||
endSeconds,
|
||||
startFrame: integer(item.start_frame, "E46D clip start frame"),
|
||||
endFrame: integer(item.end_frame, "E46D clip end frame"),
|
||||
routeTrackIds: array(item.route_track_ids, "E46D clip route tracks").map((entry) => integer(entry, "E46D route track")),
|
||||
worldTrackIds: array(item.world_track_ids, "E46D clip world tracks").map((entry) => integer(entry, "E46D world track")),
|
||||
evidence: evidence(item.evidence),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46DTemporalFailureAudit({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46DTemporalFailureAuditResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46d/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46D LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46D catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.e46d-temporal-failure-audit-catalog/v1",
|
||||
"E46D catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "E46D results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46D catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46D result");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.e46d-temporal-failure-audit-view/v1",
|
||||
"E46D view schema",
|
||||
);
|
||||
exact(item.ground_truth, false, "E46D truth authority");
|
||||
const metrics = object(item.metrics, "E46D metrics");
|
||||
const acceptance = object(item.acceptance, "E46D acceptance");
|
||||
const decision = object(item.decision, "E46D decision");
|
||||
const clips = array(item.review_clips, "E46D review clips").map((clip, index) => (
|
||||
parseClip(clip, index + 1)
|
||||
));
|
||||
const result: E46DTemporalFailureAuditResult = {
|
||||
resultId: shaResult(item.result_id, "e46d-temporal-failure-audit", "E46D result id"),
|
||||
createdAtUtc: string(item.created_at_utc, "E46D created"),
|
||||
sourceE46CResultId: shaResult(item.source_e46c_result_id, "e46c-full-replay-world-tracks", "E46D E46C source"),
|
||||
sourceE26ResultId: shaResult(item.source_e26_result_id, "e10-integrated-perception", "E46D E26 source"),
|
||||
metrics: {
|
||||
routeFrameCount: exact(metrics.route_frame_count, 4489, "E46D route frames"),
|
||||
routeSpanSeconds: number(metrics.route_span_seconds, "E46D route span"),
|
||||
objectObservationCount: integer(metrics.object_observation_count, "E46D observations"),
|
||||
routeTrackCount: integer(metrics.route_track_count, "E46D route tracks"),
|
||||
zeroObjectFrameCount: integer(metrics.zero_object_frame_count, "E46D zero frames"),
|
||||
zeroObjectFrameFraction: number(metrics.zero_object_frame_fraction, "E46D zero fraction"),
|
||||
cameraHeldObservationCount: integer(metrics.camera_held_observation_count, "E46D held observations"),
|
||||
cameraHeldObservationFraction: number(metrics.camera_held_observation_fraction, "E46D held fraction"),
|
||||
detectorHoldEpisodeCount: integer(metrics.detector_hold_episode_count, "E46D detector holds"),
|
||||
layerBlackoutEpisodeCount: integer(metrics.layer_blackout_episode_count, "E46D blackouts"),
|
||||
routeLayerGapEpisodeCount: integer(metrics.route_layer_gap_episode_count, "E46D route gaps"),
|
||||
routeIdRebirthCandidateCount: integer(metrics.route_id_rebirth_candidate_count, "E46D ID candidates"),
|
||||
bboxJumpEpisodeCount: integer(metrics.bbox_jump_episode_count, "E46D bbox jumps"),
|
||||
motionStateFlapEpisodeCount: integer(metrics.motion_state_flap_episode_count, "E46D motion flaps"),
|
||||
worldBindingFlapEpisodeCount: integer(metrics.world_binding_flap_episode_count, "E46D world flaps"),
|
||||
shortRouteTrackCount: integer(metrics.short_route_track_count, "E46D short tracks"),
|
||||
shortRouteTrackFraction: number(metrics.short_route_track_fraction, "E46D short fraction"),
|
||||
shortTrackBurstEpisodeCount: integer(metrics.short_track_burst_episode_count, "E46D short bursts"),
|
||||
failureSignalCount: integer(metrics.failure_signal_count, "E46D failure signals"),
|
||||
reviewClipCount: integer(metrics.review_clip_count, "E46D review clips"),
|
||||
temporalContinuityPassed: boolean(metrics.temporal_continuity_passed, "E46D continuity"),
|
||||
},
|
||||
acceptance: {
|
||||
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46D full route"),
|
||||
temporalContinuityPassed: boolean(acceptance.temporal_continuity_passed, "E46D acceptance continuity"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46D truth"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46D safety"),
|
||||
},
|
||||
decision: {
|
||||
temporalRegressionConfirmed: boolean(decision.temporal_regression_confirmed, "E46D regression"),
|
||||
detectorGapVisible: boolean(decision.detector_gap_visible, "E46D detector gap"),
|
||||
routeFragmentationVisible: boolean(decision.route_fragmentation_visible, "E46D fragmentation"),
|
||||
worldBindingInstabilityVisible: boolean(decision.world_binding_instability_visible, "E46D world binding"),
|
||||
nextAction: string(decision.next_action, "E46D next action"),
|
||||
},
|
||||
limitations: array(item.limitations, "E46D limitations").map((value) => string(value, "E46D limitation")),
|
||||
reviewClips: clips,
|
||||
};
|
||||
if (result.metrics.reviewClipCount !== clips.length) {
|
||||
throw new ContractError("E46D review clip accounting: нарушен контракт.");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
export interface E46ELaboratoryMethod {
|
||||
completeness: "complete";
|
||||
executionClass: "hybrid";
|
||||
pipelineId: string;
|
||||
components: readonly {
|
||||
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
|
||||
name: string;
|
||||
version: string;
|
||||
role: string;
|
||||
identitySha256: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface E46EReadyStackResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: {
|
||||
frameCount: 4489;
|
||||
routeDurationSeconds: number;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
detectionBoxClippedCount: number;
|
||||
trackBoxClippedCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
trackerRecoveredFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
routeIdGapEventCount: number;
|
||||
shortTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
trackClassSwitchCount: number;
|
||||
};
|
||||
acceptance: {
|
||||
fullRouteAccounted: true;
|
||||
stockDetectorTrackerExecuted: true;
|
||||
visualOverlayAvailable: true;
|
||||
independentTruthAvailable: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
decision: {
|
||||
readyStackBaselineAvailable: true;
|
||||
customTemporalLogicUsed: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 800;
|
||||
height: 600;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46E method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46E method schema");
|
||||
exact(raw.completeness, "complete", "E46E method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46E execution class");
|
||||
const components = array(raw.components, "E46E method components").map((entry) => {
|
||||
const component = object(entry, "E46E method component");
|
||||
const kind = string(component.kind, "E46E component kind");
|
||||
if (!(["source", "tool", "model", "algorithm", "runtime"] as const).includes(
|
||||
kind as "source",
|
||||
)) {
|
||||
throw new ContractError("E46E component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46E component name"),
|
||||
version: string(component.version, "E46E component version"),
|
||||
role: string(component.role, "E46E component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46E component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46E pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46EReadyStack({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46EReadyStackResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46e/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46E LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46E catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46e-ready-stack-catalog/v1", "E46E catalog schema");
|
||||
const items = array(catalog.items, "E46E results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46E catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46E result");
|
||||
exact(item.schema_version, "missioncore.e46e-ready-stack-view/v1", "E46E view schema");
|
||||
exact(item.ground_truth, false, "E46E truth authority");
|
||||
const metrics = object(item.metrics, "E46E metrics");
|
||||
const acceptance = object(item.acceptance, "E46E acceptance");
|
||||
const decision = object(item.decision, "E46E decision");
|
||||
const video = object(item.video, "E46E video");
|
||||
const resultId = string(item.result_id, "E46E result id");
|
||||
if (!/^e46e-ready-stack-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46E result identity: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46E created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46E source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46E camera"),
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4489, "E46E frames"),
|
||||
routeDurationSeconds: number(metrics.route_duration_seconds, "E46E duration"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46E detections"),
|
||||
trackObservationCount: integer(metrics.track_observation_count, "E46E track observations"),
|
||||
detectionBoxClippedCount: integer(metrics.detection_box_clipped_count, "E46E clipped detections"),
|
||||
trackBoxClippedCount: integer(metrics.track_box_clipped_count, "E46E clipped tracks"),
|
||||
uniqueTrackCount: integer(metrics.unique_track_count, "E46E tracks"),
|
||||
meanTrackedObjectsPerFrame: number(metrics.mean_tracked_objects_per_frame, "E46E mean objects"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46E zero detections"),
|
||||
zeroTrackFrameCount: integer(metrics.zero_track_frame_count, "E46E zero tracks"),
|
||||
trackerRecoveredFrameCount: integer(metrics.tracker_recovered_frame_count, "E46E recovered frames"),
|
||||
fullLayerBlackoutEventCount: integer(metrics.full_layer_blackout_event_count, "E46E blackouts"),
|
||||
routeIdGapEventCount: integer(metrics.route_id_gap_event_count, "E46E ID gaps"),
|
||||
shortTrackCount: integer(metrics.short_track_count, "E46E short tracks"),
|
||||
shortTrackFraction: number(metrics.short_track_fraction, "E46E short fraction"),
|
||||
trackClassSwitchCount: integer(metrics.track_class_switch_count, "E46E class switches"),
|
||||
},
|
||||
acceptance: {
|
||||
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46E full route"),
|
||||
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46E ready stack"),
|
||||
visualOverlayAvailable: exact(acceptance.visual_overlay_available, true, "E46E visual"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46E truth"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46E safety"),
|
||||
},
|
||||
decision: {
|
||||
readyStackBaselineAvailable: exact(decision.ready_stack_baseline_available, true, "E46E baseline"),
|
||||
customTemporalLogicUsed: exact(decision.custom_temporal_logic_used, false, "E46E custom logic"),
|
||||
nextAction: string(decision.next_action, "E46E next action"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46E limitations").map((entry) => string(entry, "E46E limitation")),
|
||||
video: {
|
||||
url: string(video.url, "E46E video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46E media type"),
|
||||
byteLength: integer(video.byte_length, "E46E video bytes"),
|
||||
sha256: sha(video.sha256, "E46E video SHA"),
|
||||
width: exact(video.width, 800, "E46E video width"),
|
||||
height: exact(video.height, 600, "E46E video height"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46FMetrics {
|
||||
frameCount: 4489;
|
||||
routeDurationSeconds: number;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
detectionBoxClippedCount: number;
|
||||
trackBoxClippedCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
trackerRecoveredFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
routeIdGapEventCount: number;
|
||||
shortTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
trackClassSwitchCount: number;
|
||||
}
|
||||
|
||||
export interface E46FLargeBoxTriage {
|
||||
observationCount: number;
|
||||
frameCount: number;
|
||||
trackIdCount: number;
|
||||
classObservations: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface E46FDashCamBakeoffResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
metrics: E46FMetrics;
|
||||
acceptance: {
|
||||
fullRouteAccounted: true;
|
||||
stockDetectorTrackerExecuted: true;
|
||||
controlledDetectorOnlyChange: true;
|
||||
visualOverlayAvailable: true;
|
||||
independentTruthAvailable: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
comparison: {
|
||||
baselineResultId: string;
|
||||
baselineMetrics: E46FMetrics;
|
||||
delta: {
|
||||
zeroTrackFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
uniqueTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
};
|
||||
largeBoxVisualTriage: {
|
||||
areaRatioThreshold: 0.2;
|
||||
candidate: E46FLargeBoxTriage;
|
||||
baseline: E46FLargeBoxTriage;
|
||||
interpretation: string;
|
||||
};
|
||||
visualReview: {
|
||||
status: "rejected-semantic-regression";
|
||||
sampleVideoSeconds: readonly number[];
|
||||
finding: string;
|
||||
nextAction: string;
|
||||
};
|
||||
verdict: "reject-dashcamnet-on-unrectified-fisheye";
|
||||
};
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 800;
|
||||
height: 600;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function signedNumber(value: unknown, label: string): number {
|
||||
return number(value, label);
|
||||
}
|
||||
|
||||
function metrics(value: unknown, label: string): E46FMetrics {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
frameCount: exact(raw.frame_count, 4489, `${label} frames`),
|
||||
routeDurationSeconds: number(raw.route_duration_seconds, `${label} duration`),
|
||||
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
|
||||
trackObservationCount: integer(raw.track_observation_count, `${label} track observations`),
|
||||
detectionBoxClippedCount: integer(raw.detection_box_clipped_count, `${label} clipped detections`),
|
||||
trackBoxClippedCount: integer(raw.track_box_clipped_count, `${label} clipped tracks`),
|
||||
uniqueTrackCount: integer(raw.unique_track_count, `${label} tracks`),
|
||||
meanTrackedObjectsPerFrame: number(raw.mean_tracked_objects_per_frame, `${label} mean objects`),
|
||||
zeroDetectionFrameCount: integer(raw.zero_detection_frame_count, `${label} zero detections`),
|
||||
zeroTrackFrameCount: integer(raw.zero_track_frame_count, `${label} zero tracks`),
|
||||
trackerRecoveredFrameCount: integer(raw.tracker_recovered_frame_count, `${label} recovered`),
|
||||
fullLayerBlackoutEventCount: integer(raw.full_layer_blackout_event_count, `${label} blackouts`),
|
||||
routeIdGapEventCount: integer(raw.route_id_gap_event_count, `${label} ID gaps`),
|
||||
shortTrackCount: integer(raw.short_track_count, `${label} short tracks`),
|
||||
shortTrackFraction: number(raw.short_track_fraction, `${label} short fraction`),
|
||||
trackClassSwitchCount: integer(raw.track_class_switch_count, `${label} class switches`),
|
||||
};
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46F method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46F method schema");
|
||||
exact(raw.completeness, "complete", "E46F method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46F execution class");
|
||||
const components = array(raw.components, "E46F method components").map((entry) => {
|
||||
const component = object(entry, "E46F method component");
|
||||
const kind = string(component.kind, "E46F component kind");
|
||||
if (!(["source", "tool", "model", "algorithm", "runtime"] as const).includes(
|
||||
kind as "source",
|
||||
)) {
|
||||
throw new ContractError("E46F component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46F component name"),
|
||||
version: string(component.version, "E46F component version"),
|
||||
role: string(component.role, "E46F component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46F component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46F pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function classObservations(value: unknown, label: string): Readonly<Record<string, number>> {
|
||||
const raw = object(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).map(([key, entry]) => [key, integer(entry, `${label}.${key}`)]),
|
||||
);
|
||||
}
|
||||
|
||||
function triage(value: unknown, label: string): E46FLargeBoxTriage {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
observationCount: integer(raw.observation_count, `${label} observations`),
|
||||
frameCount: integer(raw.frame_count, `${label} frames`),
|
||||
trackIdCount: integer(raw.track_id_count, `${label} tracks`),
|
||||
classObservations: classObservations(raw.class_observations, `${label} classes`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46FDashCamBakeoff({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46FDashCamBakeoffResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46f/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46F LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46F catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46f-dashcam-bakeoff-catalog/v1", "E46F catalog schema");
|
||||
const items = array(catalog.items, "E46F results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46F catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46F result");
|
||||
exact(item.schema_version, "missioncore.e46f-dashcam-bakeoff-view/v1", "E46F view schema");
|
||||
exact(item.ground_truth, false, "E46F truth authority");
|
||||
const resultId = string(item.result_id, "E46F result id");
|
||||
if (!/^e46f-dashcam-bakeoff-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46F result identity: нарушен контракт.");
|
||||
}
|
||||
const acceptance = object(item.acceptance, "E46F acceptance");
|
||||
const comparison = object(item.comparison, "E46F comparison");
|
||||
exact(comparison.controlled_change, "detector-only", "E46F controlled change");
|
||||
const delta = object(comparison.delta, "E46F delta");
|
||||
const largeBox = object(comparison.large_box_visual_triage, "E46F large-box triage");
|
||||
const visualReview = object(comparison.visual_review, "E46F visual review");
|
||||
const video = object(item.video, "E46F video");
|
||||
const baselineResultId = string(comparison.baseline_result_id, "E46F baseline id");
|
||||
if (!/^e46e-ready-stack-[a-f0-9]{64}$/.test(baselineResultId)) {
|
||||
throw new ContractError("E46F baseline identity: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46F created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46F source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46F camera"),
|
||||
metrics: metrics(item.metrics, "E46F metrics"),
|
||||
acceptance: {
|
||||
fullRouteAccounted: exact(acceptance.full_route_accounted, true, "E46F full route"),
|
||||
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46F stack"),
|
||||
controlledDetectorOnlyChange: exact(acceptance.controlled_detector_only_change, true, "E46F controlled change"),
|
||||
visualOverlayAvailable: exact(acceptance.visual_overlay_available, true, "E46F visual"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46F truth"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46F safety"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46F limitations").map((entry) => string(entry, "E46F limitation")),
|
||||
comparison: {
|
||||
baselineResultId,
|
||||
baselineMetrics: metrics(comparison.baseline_metrics, "E46E baseline metrics"),
|
||||
delta: {
|
||||
zeroTrackFrameCount: signedNumber(delta.zero_track_frame_count, "E46F zero-track delta"),
|
||||
fullLayerBlackoutEventCount: signedNumber(delta.full_layer_blackout_event_count, "E46F blackout delta"),
|
||||
uniqueTrackCount: signedNumber(delta.unique_track_count, "E46F track delta"),
|
||||
shortTrackFraction: signedNumber(delta.short_track_fraction, "E46F short-track delta"),
|
||||
},
|
||||
largeBoxVisualTriage: {
|
||||
areaRatioThreshold: exact(largeBox.area_ratio_threshold, 0.2, "E46F triage threshold"),
|
||||
candidate: triage(largeBox.candidate, "E46F candidate triage"),
|
||||
baseline: triage(largeBox.baseline, "E46E baseline triage"),
|
||||
interpretation: string(largeBox.interpretation, "E46F triage interpretation"),
|
||||
},
|
||||
visualReview: {
|
||||
status: exact(visualReview.status, "rejected-semantic-regression", "E46F visual status"),
|
||||
sampleVideoSeconds: array(visualReview.sample_video_seconds, "E46F review timestamps")
|
||||
.map((entry) => number(entry, "E46F review timestamp")),
|
||||
finding: string(visualReview.finding, "E46F visual finding"),
|
||||
nextAction: string(visualReview.next_action, "E46F next action"),
|
||||
},
|
||||
verdict: exact(comparison.verdict, "reject-dashcamnet-on-unrectified-fisheye", "E46F verdict"),
|
||||
},
|
||||
video: {
|
||||
url: string(video.url, "E46F video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46F media type"),
|
||||
byteLength: integer(video.byte_length, "E46F video bytes"),
|
||||
sha256: sha(video.sha256, "E46F video SHA"),
|
||||
width: exact(video.width, 800, "E46F video width"),
|
||||
height: exact(video.height, 600, "E46F video height"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export type E46GCandidate = "trafficcamnet" | "dashcamnet";
|
||||
export type E46GView = "left" | "front" | "right";
|
||||
|
||||
export interface E46GViewMetrics {
|
||||
frameCount: 600;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
shortTrackFraction: number;
|
||||
largeTrackObservationCount: number;
|
||||
largeTrackFraction: number;
|
||||
}
|
||||
|
||||
export interface E46GCandidateMetrics {
|
||||
sourceFrameCount: 600;
|
||||
viewFrameCount: 1800;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
uniqueTrackCount: number;
|
||||
largeTrackObservationCount: number;
|
||||
largeTrackFraction: number;
|
||||
views: Readonly<Record<E46GView, E46GViewMetrics>>;
|
||||
}
|
||||
|
||||
export interface E46GVideo {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 2880;
|
||||
height: 544;
|
||||
durationSeconds: 60;
|
||||
viewOrder: readonly ["left", "front", "right"];
|
||||
}
|
||||
|
||||
export interface E46GRectifiedDetectorBakeoffResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
selection: {
|
||||
firstSourceFrameIndex: 1000;
|
||||
lastSourceFrameIndex: 1599;
|
||||
frameCount: 600;
|
||||
};
|
||||
rectification: {
|
||||
provider: "NVIDIA Gst-nvdewarper";
|
||||
providerVersion: string;
|
||||
outputResolution: readonly [960, 544];
|
||||
horizontalFovDegrees: 100;
|
||||
retainedSourceFrameIndexRange: readonly [0, 4487];
|
||||
excludedSourceTailFrameCount: 1;
|
||||
viewOrder: readonly ["left", "front", "right"];
|
||||
};
|
||||
metrics: Readonly<Record<E46GCandidate, E46GCandidateMetrics>>;
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: true;
|
||||
factoryCalibrationBound: true;
|
||||
officialNvidiaDewarperExecuted: true;
|
||||
stockDetectorTrackerExecuted: true;
|
||||
sameViewsAndFramesForBothCandidates: true;
|
||||
visualComparisonVideosAvailable: true;
|
||||
independentTruthAvailable: false;
|
||||
candidateAccepted: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
comparison: {
|
||||
visualReview: {
|
||||
status: "selected-for-next-diagnostic";
|
||||
reviewedVideoSeconds: readonly number[];
|
||||
selectedCandidate: "trafficcamnet";
|
||||
selectedView: "front";
|
||||
excludedViews: readonly ["left", "right"];
|
||||
finding: string;
|
||||
risk: string;
|
||||
nextAction: string;
|
||||
};
|
||||
verdict: "select-trafficcamnet-front-only-for-e46h";
|
||||
};
|
||||
videos: Readonly<Record<E46GCandidate, E46GVideo>>;
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function tuple<T extends readonly unknown[]>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
const parsed = array(value, label);
|
||||
if (parsed.length !== expected.length || parsed.some((entry, index) => entry !== expected[index])) {
|
||||
throw new ContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function viewMetrics(value: unknown, label: string): E46GViewMetrics {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
frameCount: exact(raw.frame_count, 600, `${label} frames`),
|
||||
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
|
||||
trackObservationCount: integer(raw.track_observation_count, `${label} tracks`),
|
||||
uniqueTrackCount: integer(raw.unique_track_count, `${label} identities`),
|
||||
meanTrackedObjectsPerFrame: number(raw.mean_tracked_objects_per_frame, `${label} mean`),
|
||||
zeroDetectionFrameCount: integer(raw.zero_detection_frame_count, `${label} zero detections`),
|
||||
zeroTrackFrameCount: integer(raw.zero_track_frame_count, `${label} zero tracks`),
|
||||
fullLayerBlackoutEventCount: integer(raw.full_layer_blackout_event_count, `${label} blackouts`),
|
||||
shortTrackFraction: number(raw.short_track_fraction, `${label} short fraction`),
|
||||
largeTrackObservationCount: integer(raw.large_track_observation_count, `${label} large tracks`),
|
||||
largeTrackFraction: number(raw.large_track_fraction, `${label} large fraction`),
|
||||
};
|
||||
}
|
||||
|
||||
function candidateMetrics(value: unknown, label: string): E46GCandidateMetrics {
|
||||
const raw = object(value, label);
|
||||
const views = object(raw.views, `${label} views`);
|
||||
return {
|
||||
sourceFrameCount: exact(raw.source_frame_count, 600, `${label} source frames`),
|
||||
viewFrameCount: exact(raw.view_frame_count, 1800, `${label} view frames`),
|
||||
detectionObservationCount: integer(raw.detection_observation_count, `${label} detections`),
|
||||
trackObservationCount: integer(raw.track_observation_count, `${label} tracks`),
|
||||
uniqueTrackCount: integer(raw.unique_track_count, `${label} identities`),
|
||||
largeTrackObservationCount: integer(raw.large_track_observation_count, `${label} large tracks`),
|
||||
largeTrackFraction: number(raw.large_track_fraction, `${label} large fraction`),
|
||||
views: {
|
||||
left: viewMetrics(views.left, `${label} left`),
|
||||
front: viewMetrics(views.front, `${label} front`),
|
||||
right: viewMetrics(views.right, `${label} right`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46G method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46G method schema");
|
||||
exact(raw.completeness, "complete", "E46G method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46G execution class");
|
||||
const components = array(raw.components, "E46G method components").map((entry) => {
|
||||
const component = object(entry, "E46G method component");
|
||||
const kind = string(component.kind, "E46G component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46G component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46G component name"),
|
||||
version: string(component.version, "E46G component version"),
|
||||
role: string(component.role, "E46G component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46G component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46G pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function video(value: unknown, label: string): E46GVideo {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
url: string(raw.url, `${label} URL`),
|
||||
mediaType: exact(raw.media_type, "video/mp4", `${label} media`),
|
||||
byteLength: integer(raw.byte_length, `${label} bytes`),
|
||||
sha256: sha(raw.sha256, `${label} SHA`),
|
||||
width: exact(raw.width, 2880, `${label} width`),
|
||||
height: exact(raw.height, 544, `${label} height`),
|
||||
durationSeconds: exact(raw.duration_seconds, 60, `${label} duration`),
|
||||
viewOrder: tuple(raw.view_order, ["left", "front", "right"] as const, `${label} views`),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46GRectifiedDetectorBakeoff({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46GRectifiedDetectorBakeoffResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46g/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46G LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46G catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46g-rectified-detector-bakeoff-catalog/v1", "E46G catalog schema");
|
||||
const items = array(catalog.items, "E46G results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46G catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46G result");
|
||||
exact(item.schema_version, "missioncore.e46g-rectified-detector-bakeoff-view/v1", "E46G view schema");
|
||||
exact(item.status, "selected-for-next-diagnostic-full-route", "E46G status");
|
||||
exact(item.ground_truth, false, "E46G truth authority");
|
||||
const resultId = string(item.result_id, "E46G result id");
|
||||
if (!/^e46g-rectified-detector-bakeoff-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46G result identity: нарушен контракт.");
|
||||
}
|
||||
const selection = object(item.selection, "E46G selection");
|
||||
const rectification = object(item.rectification, "E46G rectification");
|
||||
const metrics = object(item.metrics, "E46G metrics");
|
||||
const acceptance = object(item.acceptance, "E46G acceptance");
|
||||
const comparison = object(item.comparison, "E46G comparison");
|
||||
const review = object(comparison.visual_review, "E46G visual review");
|
||||
const videos = object(item.videos, "E46G videos");
|
||||
const authority = object(item.authority, "E46G authority");
|
||||
exact(authority.ground_truth, false, "E46G authority ground truth");
|
||||
exact(authority.independent_truth, false, "E46G authority independent truth");
|
||||
exact(authority.candidate_accepted, false, "E46G authority candidate");
|
||||
exact(authority.commands_enabled, false, "E46G authority commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46G authority safety");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46G created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46G source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46G camera"),
|
||||
selection: {
|
||||
firstSourceFrameIndex: exact(selection.first_source_frame_index, 1000, "E46G first frame"),
|
||||
lastSourceFrameIndex: exact(selection.last_source_frame_index, 1599, "E46G last frame"),
|
||||
frameCount: exact(selection.frame_count, 600, "E46G frame count"),
|
||||
},
|
||||
rectification: {
|
||||
provider: exact(rectification.provider, "NVIDIA Gst-nvdewarper", "E46G dewarper"),
|
||||
providerVersion: string(rectification.provider_version, "E46G dewarper version"),
|
||||
outputResolution: tuple(rectification.output_resolution, [960, 544] as const, "E46G output"),
|
||||
horizontalFovDegrees: exact(rectification.horizontal_fov_degrees, 100, "E46G FOV"),
|
||||
retainedSourceFrameIndexRange: tuple(rectification.retained_source_frame_index_range, [0, 4487] as const, "E46G retained source"),
|
||||
excludedSourceTailFrameCount: exact(rectification.excluded_source_tail_frame_count, 1, "E46G excluded tail"),
|
||||
viewOrder: tuple(rectification.view_order, ["left", "front", "right"] as const, "E46G views"),
|
||||
},
|
||||
metrics: {
|
||||
trafficcamnet: candidateMetrics(metrics.trafficcamnet, "TrafficCamNet"),
|
||||
dashcamnet: candidateMetrics(metrics.dashcamnet, "DashCamNet"),
|
||||
},
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: exact(acceptance.exact_recorded_right_source_bound, true, "E46G source"),
|
||||
factoryCalibrationBound: exact(acceptance.factory_calibration_bound, true, "E46G calibration"),
|
||||
officialNvidiaDewarperExecuted: exact(acceptance.official_nvidia_dewarper_executed, true, "E46G dewarper execution"),
|
||||
stockDetectorTrackerExecuted: exact(acceptance.stock_detector_tracker_executed, true, "E46G stock stack"),
|
||||
sameViewsAndFramesForBothCandidates: exact(acceptance.same_views_and_frames_for_both_candidates, true, "E46G controlled A/B"),
|
||||
visualComparisonVideosAvailable: exact(acceptance.visual_comparison_videos_available, true, "E46G videos"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46G truth"),
|
||||
candidateAccepted: exact(acceptance.candidate_accepted, false, "E46G acceptance authority"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46G safety"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46G limitations").map((entry) => string(entry, "E46G limitation")),
|
||||
comparison: {
|
||||
visualReview: {
|
||||
status: exact(review.status, "selected-for-next-diagnostic", "E46G review status"),
|
||||
reviewedVideoSeconds: array(review.reviewed_video_seconds, "E46G review seconds").map((entry) => number(entry, "E46G review second")),
|
||||
selectedCandidate: exact(review.selected_candidate, "trafficcamnet", "E46G candidate"),
|
||||
selectedView: exact(review.selected_view, "front", "E46G selected view"),
|
||||
excludedViews: tuple(review.excluded_views, ["left", "right"] as const, "E46G excluded views"),
|
||||
finding: string(review.finding, "E46G finding"),
|
||||
risk: string(review.risk, "E46G risk"),
|
||||
nextAction: string(review.next_action, "E46G next action"),
|
||||
},
|
||||
verdict: exact(comparison.verdict, "select-trafficcamnet-front-only-for-e46h", "E46G verdict"),
|
||||
},
|
||||
videos: {
|
||||
trafficcamnet: video(videos.trafficcamnet, "TrafficCamNet video"),
|
||||
dashcamnet: video(videos.dashcamnet, "DashCamNet video"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46HReviewWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
sourceTrackId: number | null;
|
||||
verdict: "semantic-false-positive" | "empty-scene-expected";
|
||||
}
|
||||
|
||||
export interface E46HFullRectifiedFrontReplayResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
baselineResultId: string;
|
||||
selection: {
|
||||
firstSourceFrameIndex: 0;
|
||||
lastSourceFrameIndex: 4487;
|
||||
frameCount: 4488;
|
||||
excludedSourceTailFrameCount: 1;
|
||||
};
|
||||
rectification: {
|
||||
provider: "NVIDIA Gst-nvdewarper";
|
||||
providerVersion: string;
|
||||
projection: "fisheye-to-perspective";
|
||||
view: "front";
|
||||
outputResolution: readonly [960, 544];
|
||||
horizontalFovDegrees: 100;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: 4488;
|
||||
routeDurationSeconds: number;
|
||||
detectionObservationCount: number;
|
||||
trackObservationCount: number;
|
||||
uniqueTrackCount: number;
|
||||
meanTrackedObjectsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
zeroTrackFrameCount: number;
|
||||
trackerRecoveredFrameCount: number;
|
||||
fullLayerBlackoutEventCount: number;
|
||||
routeIdGapEventCount: number;
|
||||
shortTrackCount: number;
|
||||
shortTrackFraction: number;
|
||||
trackClassSwitchCount: number;
|
||||
largeTrackObservationCount: number;
|
||||
largeTrackFraction: number;
|
||||
};
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: true;
|
||||
factoryCalibrationBound: true;
|
||||
officialNvidiaDewarperExecuted: true;
|
||||
selectedStockDetectorTrackerExecuted: true;
|
||||
retainedRouteAccounted: true;
|
||||
terminalSourceFrameExcluded: true;
|
||||
fullVisualReviewCompleted: false;
|
||||
independentTruthAvailable: false;
|
||||
candidateAccepted: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
decision: {
|
||||
selectedProvider: "front-trafficcamnet-stock-nvdcf";
|
||||
customDetectorOrTrackerLogicUsed: false;
|
||||
providerPromoted: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
visualReview: {
|
||||
status: "full-continuous-and-targeted-review-completed";
|
||||
completeVideoReviewed: true;
|
||||
reviewedVideoRangeSeconds: readonly [0, 448.8];
|
||||
verdict: "useful-front-continuity-but-semantic-regression-blocks-promotion";
|
||||
reviewWindows: readonly E46HReviewWindow[];
|
||||
finding: string;
|
||||
blackoutInterpretation: string;
|
||||
nextAction: string;
|
||||
};
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 960;
|
||||
height: 544;
|
||||
durationSeconds: 448.8;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46H method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46H method schema");
|
||||
exact(raw.completeness, "complete", "E46H method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46H execution class");
|
||||
const components = array(raw.components, "E46H method components").map((entry) => {
|
||||
const component = object(entry, "E46H method component");
|
||||
const kind = string(component.kind, "E46H component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46H component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: string(component.name, "E46H component name"),
|
||||
version: string(component.version, "E46H component version"),
|
||||
role: string(component.role, "E46H component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46H component identity"),
|
||||
};
|
||||
});
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: string(raw.pipeline_id, "E46H pipeline"),
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewWindow(value: unknown): E46HReviewWindow {
|
||||
const raw = object(value, "E46H review window");
|
||||
const sourceTrackId = raw.source_track_id === null
|
||||
? null
|
||||
: integer(raw.source_track_id, "E46H review track");
|
||||
const verdict = raw.verdict;
|
||||
if (verdict !== "semantic-false-positive" && verdict !== "empty-scene-expected") {
|
||||
throw new ContractError("E46H review verdict: неизвестное значение.");
|
||||
}
|
||||
const startSeconds = number(raw.start_seconds, "E46H review start");
|
||||
const endSeconds = number(raw.end_seconds, "E46H review end");
|
||||
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.8) {
|
||||
throw new ContractError("E46H review window: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
id: string(raw.id, "E46H review id"),
|
||||
label: string(raw.label, "E46H review label"),
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
sourceTrackId,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46HFullRectifiedFrontReplay({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46HFullRectifiedFrontReplayResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46h/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46H LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46H catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46h-full-rectified-front-replay-catalog/v1", "E46H catalog schema");
|
||||
const items = array(catalog.items, "E46H results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46H catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46H result");
|
||||
exact(item.schema_version, "missioncore.e46h-full-rectified-front-replay-view/v1", "E46H view schema");
|
||||
exact(item.status, "diagnostic-regression-large-semantic-false-tracks", "E46H status");
|
||||
exact(item.ground_truth, false, "E46H truth authority");
|
||||
const resultId = string(item.result_id, "E46H result id");
|
||||
if (!/^e46h-full-rectified-front-replay-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46H result identity: нарушен контракт.");
|
||||
}
|
||||
const selection = object(item.selection, "E46H selection");
|
||||
const rectification = object(item.rectification, "E46H rectification");
|
||||
const metrics = object(item.metrics, "E46H metrics");
|
||||
const acceptance = object(item.acceptance, "E46H acceptance");
|
||||
const decision = object(item.decision, "E46H decision");
|
||||
const visualReview = object(item.visual_review, "E46H visual review");
|
||||
const video = object(item.video, "E46H video");
|
||||
const authority = object(item.authority, "E46H authority");
|
||||
exact(authority.ground_truth, false, "E46H authority ground truth");
|
||||
exact(authority.independent_truth, false, "E46H authority independent truth");
|
||||
exact(authority.candidate_accepted, false, "E46H authority candidate");
|
||||
exact(authority.commands_enabled, false, "E46H authority commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46H authority safety");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "E46H created"),
|
||||
sourceSessionId: string(item.source_session_id, "E46H source session"),
|
||||
cameraSourceId: exact(item.camera_source_id, "sensor.camera.right", "E46H camera"),
|
||||
baselineResultId: string(item.baseline_result_id, "E46H baseline"),
|
||||
selection: {
|
||||
firstSourceFrameIndex: exact(selection.first_source_frame_index, 0, "E46H first frame"),
|
||||
lastSourceFrameIndex: exact(selection.last_source_frame_index, 4487, "E46H last frame"),
|
||||
frameCount: exact(selection.frame_count, 4488, "E46H frame count"),
|
||||
excludedSourceTailFrameCount: exact(selection.excluded_source_tail_frame_count, 1, "E46H excluded tail"),
|
||||
},
|
||||
rectification: {
|
||||
provider: exact(rectification.provider, "NVIDIA Gst-nvdewarper", "E46H dewarper"),
|
||||
providerVersion: string(rectification.provider_version, "E46H dewarper version"),
|
||||
projection: exact(rectification.projection, "fisheye-to-perspective", "E46H projection"),
|
||||
view: exact(rectification.view, "front", "E46H view"),
|
||||
outputResolution: (() => {
|
||||
const parsed = array(rectification.output_resolution, "E46H output");
|
||||
if (parsed.length !== 2 || parsed[0] !== 960 || parsed[1] !== 544) {
|
||||
throw new ContractError("E46H output: нарушен контракт.");
|
||||
}
|
||||
return [960, 544] as const;
|
||||
})(),
|
||||
horizontalFovDegrees: exact(rectification.horizontal_fov_degrees, 100, "E46H FOV"),
|
||||
},
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4488, "E46H metric frames"),
|
||||
routeDurationSeconds: number(metrics.route_duration_seconds, "E46H source duration"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46H detections"),
|
||||
trackObservationCount: integer(metrics.track_observation_count, "E46H tracks"),
|
||||
uniqueTrackCount: integer(metrics.unique_track_count, "E46H identities"),
|
||||
meanTrackedObjectsPerFrame: number(metrics.mean_tracked_objects_per_frame, "E46H mean"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46H zero detections"),
|
||||
zeroTrackFrameCount: integer(metrics.zero_track_frame_count, "E46H zero tracks"),
|
||||
trackerRecoveredFrameCount: integer(metrics.tracker_recovered_frame_count, "E46H recovered"),
|
||||
fullLayerBlackoutEventCount: integer(metrics.full_layer_blackout_event_count, "E46H blackouts"),
|
||||
routeIdGapEventCount: integer(metrics.route_id_gap_event_count, "E46H id gaps"),
|
||||
shortTrackCount: integer(metrics.short_track_count, "E46H short tracks"),
|
||||
shortTrackFraction: number(metrics.short_track_fraction, "E46H short fraction"),
|
||||
trackClassSwitchCount: integer(metrics.track_class_switch_count, "E46H class switches"),
|
||||
largeTrackObservationCount: integer(metrics.large_track_observation_count, "E46H large tracks"),
|
||||
largeTrackFraction: number(metrics.large_track_fraction, "E46H large fraction"),
|
||||
},
|
||||
acceptance: {
|
||||
exactRecordedRightSourceBound: exact(acceptance.exact_recorded_right_source_bound, true, "E46H source"),
|
||||
factoryCalibrationBound: exact(acceptance.factory_calibration_bound, true, "E46H calibration"),
|
||||
officialNvidiaDewarperExecuted: exact(acceptance.official_nvidia_dewarper_executed, true, "E46H dewarper execution"),
|
||||
selectedStockDetectorTrackerExecuted: exact(acceptance.selected_stock_detector_tracker_executed, true, "E46H stock stack"),
|
||||
retainedRouteAccounted: exact(acceptance.retained_route_accounted, true, "E46H retained route"),
|
||||
terminalSourceFrameExcluded: exact(acceptance.terminal_source_frame_excluded, true, "E46H tail"),
|
||||
fullVisualReviewCompleted: exact(acceptance.full_visual_review_completed, false, "E46H visual review authority"),
|
||||
independentTruthAvailable: exact(acceptance.independent_truth_available, false, "E46H truth"),
|
||||
candidateAccepted: exact(acceptance.candidate_accepted, false, "E46H candidate"),
|
||||
navigationOrSafetyAccepted: exact(acceptance.navigation_or_safety_accepted, false, "E46H safety"),
|
||||
},
|
||||
decision: {
|
||||
selectedProvider: exact(decision.selected_provider, "front-trafficcamnet-stock-nvdcf", "E46H provider"),
|
||||
customDetectorOrTrackerLogicUsed: exact(decision.custom_detector_or_tracker_logic_used, false, "E46H custom logic"),
|
||||
providerPromoted: exact(decision.provider_promoted, false, "E46H promotion"),
|
||||
nextAction: string(decision.next_action, "E46H decision"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46H limitations").map((entry) => string(entry, "E46H limitation")),
|
||||
visualReview: {
|
||||
status: exact(visualReview.status, "full-continuous-and-targeted-review-completed", "E46H review status"),
|
||||
completeVideoReviewed: exact(visualReview.complete_video_reviewed, true, "E46H complete video review"),
|
||||
reviewedVideoRangeSeconds: (() => {
|
||||
const parsed = array(visualReview.reviewed_video_range_seconds, "E46H reviewed video range");
|
||||
if (parsed.length !== 2 || parsed[0] !== 0 || parsed[1] !== 448.8) {
|
||||
throw new ContractError("E46H reviewed video range: нарушен контракт.");
|
||||
}
|
||||
return [0, 448.8] as const;
|
||||
})(),
|
||||
verdict: exact(visualReview.verdict, "useful-front-continuity-but-semantic-regression-blocks-promotion", "E46H review verdict"),
|
||||
reviewWindows: array(visualReview.review_windows, "E46H review windows").map(reviewWindow),
|
||||
finding: string(visualReview.finding, "E46H finding"),
|
||||
blackoutInterpretation: string(visualReview.blackout_interpretation, "E46H blackout interpretation"),
|
||||
nextAction: string(visualReview.next_action, "E46H next action"),
|
||||
},
|
||||
video: {
|
||||
url: string(video.url, "E46H video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46H video media"),
|
||||
byteLength: integer(video.byte_length, "E46H video bytes"),
|
||||
sha256: sha(video.sha256, "E46H video SHA"),
|
||||
width: exact(video.width, 960, "E46H video width"),
|
||||
height: exact(video.height, 544, "E46H video height"),
|
||||
durationSeconds: exact(video.duration_seconds, 448.8, "E46H video duration"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46IReviewWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
verdict:
|
||||
| "legacy-background-false-positive-suppressed"
|
||||
| "empty-scene-mostly-preserved";
|
||||
}
|
||||
|
||||
export interface E46IGroundingDinoFullReplayResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
baselineResultId: string;
|
||||
source: {
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
sessionId: string;
|
||||
view: "front";
|
||||
resolution: readonly [960, 544];
|
||||
frameCount: 4488;
|
||||
frameRate: 10;
|
||||
durationSeconds: 448.8;
|
||||
};
|
||||
provider: {
|
||||
name: "NVIDIA TAO Grounding DINO Swin-Tiny Commercial";
|
||||
version: "1.0";
|
||||
toolkit: "NVIDIA TAO Toolkit Deploy 7.0.1";
|
||||
precision: "FP16";
|
||||
};
|
||||
inference: {
|
||||
captions: readonly ["car", "person", "bicycle", "road sign"];
|
||||
confidenceThreshold: 0.5;
|
||||
meanFramesPerSecond: number;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: 4488;
|
||||
detectionObservationCount: number;
|
||||
classObservationCounts: Readonly<Record<string, number>>;
|
||||
meanDetectionsPerFrame: number;
|
||||
maxDetectionsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
longestZeroDetectionRunFrames: number;
|
||||
confidenceMean: number;
|
||||
largeBoxObservationCount: number;
|
||||
largeBoxObservationFraction: number;
|
||||
workerElapsedSeconds: number;
|
||||
workerMeanFramesPerSecond: number;
|
||||
};
|
||||
shadowGate: {
|
||||
legacyCases: 5;
|
||||
suppressedCases: 5;
|
||||
positiveCases: 5;
|
||||
positiveCasesWithRelevantDetection: 5;
|
||||
limitations: readonly string[];
|
||||
};
|
||||
visualReview: {
|
||||
completeVideoPlaybackCompleted: true;
|
||||
reviewedRangeSeconds: readonly [0, 448.8];
|
||||
verdict: "material-semantic-progress-not-yet-complete-perception";
|
||||
reviewWindows: readonly E46IReviewWindow[];
|
||||
finding: string;
|
||||
knownError: string;
|
||||
};
|
||||
decision: {
|
||||
selectedProvider: "nvidia-grounding-dino-swin-tiny-commercial-v1.0";
|
||||
providerSemanticProgress: true;
|
||||
providerPromoted: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 960;
|
||||
height: 544;
|
||||
frameRate: 10;
|
||||
frameCount: 4488;
|
||||
durationSeconds: 448.8;
|
||||
};
|
||||
visuals: {
|
||||
shadowGate: VisualArtifact;
|
||||
fullRoute: VisualArtifact;
|
||||
targetedWindows: VisualArtifact;
|
||||
};
|
||||
}
|
||||
|
||||
interface VisualArtifact {
|
||||
url: string;
|
||||
mediaType: "image/png";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pair<const T extends readonly [number, number]>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
const parsed = array(value, label);
|
||||
if (parsed.length !== 2 || parsed[0] !== expected[0] || parsed[1] !== expected[1]) {
|
||||
throw new ContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46I method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46I method schema");
|
||||
exact(raw.completeness, "complete", "E46I method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46I method execution");
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: text(raw.pipeline_id, "E46I pipeline"),
|
||||
components: array(raw.components, "E46I components").map((entry) => {
|
||||
const component = object(entry, "E46I component");
|
||||
const kind = text(component.kind, "E46I component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46I component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: text(component.name, "E46I component name"),
|
||||
version: text(component.version, "E46I component version"),
|
||||
role: text(component.role, "E46I component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46I component identity"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function visual(value: unknown, label: string): VisualArtifact {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
url: text(raw.url, `${label} URL`),
|
||||
mediaType: exact(raw.media_type, "image/png", `${label} media`),
|
||||
byteLength: integer(raw.byte_length, `${label} bytes`),
|
||||
sha256: sha(raw.sha256, `${label} SHA`),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewWindow(value: unknown): E46IReviewWindow {
|
||||
const raw = object(value, "E46I review window");
|
||||
const verdict = raw.verdict;
|
||||
if (
|
||||
verdict !== "legacy-background-false-positive-suppressed"
|
||||
&& verdict !== "empty-scene-mostly-preserved"
|
||||
) {
|
||||
throw new ContractError("E46I review verdict: неизвестное значение.");
|
||||
}
|
||||
const startSeconds = number(raw.start_seconds, "E46I review start");
|
||||
const endSeconds = number(raw.end_seconds, "E46I review end");
|
||||
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.8) {
|
||||
throw new ContractError("E46I review window: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
id: text(raw.id, "E46I review id"),
|
||||
label: text(raw.label, "E46I review label"),
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46IGroundingDinoFullReplay({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46IGroundingDinoFullReplayResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46i/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46I LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46I catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46i-grounding-dino-full-replay-catalog/v1", "E46I catalog schema");
|
||||
const items = array(catalog.items, "E46I results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46I catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46I result");
|
||||
exact(item.schema_version, "missioncore.e46i-grounding-dino-full-replay-view/v1", "E46I view schema");
|
||||
exact(item.status, "semantic-regression-suppressed-awaiting-temporal-layer", "E46I status");
|
||||
exact(item.ground_truth, false, "E46I truth authority");
|
||||
const authority = object(item.authority, "E46I authority");
|
||||
exact(authority.ground_truth, false, "E46I authority truth");
|
||||
exact(authority.independent_truth, false, "E46I independent truth");
|
||||
exact(authority.candidate_accepted, false, "E46I acceptance");
|
||||
exact(authority.commands_enabled, false, "E46I commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46I safety");
|
||||
const resultId = text(item.result_id, "E46I result id");
|
||||
if (!/^e46i-grounding-dino-full-replay-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46I result identity: нарушен контракт.");
|
||||
}
|
||||
const source = object(item.source, "E46I source");
|
||||
const provider = object(item.provider, "E46I provider");
|
||||
const inference = object(item.inference, "E46I inference");
|
||||
const metrics = object(item.metrics, "E46I metrics");
|
||||
const shadow = object(item.shadow_gate, "E46I shadow gate");
|
||||
const review = object(item.visual_review, "E46I visual review");
|
||||
const decision = object(item.decision, "E46I decision");
|
||||
const video = object(item.video, "E46I video");
|
||||
const visuals = object(item.visuals, "E46I visuals");
|
||||
const captions = array(inference.captions, "E46I captions");
|
||||
if (captions.join("|") !== "car|person|bicycle|road sign") {
|
||||
throw new ContractError("E46I captions: нарушен контракт.");
|
||||
}
|
||||
const classCounts = object(metrics.class_observation_counts, "E46I class counts");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "E46I created"),
|
||||
baselineResultId: text(item.baseline_result_id, "E46I baseline"),
|
||||
source: {
|
||||
cameraSourceId: exact(source.camera_source_id, "sensor.camera.right", "E46I camera"),
|
||||
sessionId: text(source.session_id, "E46I session"),
|
||||
view: exact(source.view, "front", "E46I view"),
|
||||
resolution: pair(source.projection_resolution, [960, 544], "E46I resolution"),
|
||||
frameCount: exact(source.video_frame_count, 4488, "E46I frames"),
|
||||
frameRate: exact(source.video_frame_rate, 10, "E46I fps"),
|
||||
durationSeconds: exact(source.video_duration_seconds, 448.8, "E46I duration"),
|
||||
},
|
||||
provider: {
|
||||
name: exact(provider.name, "NVIDIA TAO Grounding DINO Swin-Tiny Commercial", "E46I provider"),
|
||||
version: exact(provider.version, "1.0", "E46I provider version"),
|
||||
toolkit: exact(provider.deployment_toolkit, "NVIDIA TAO Toolkit Deploy 7.0.1", "E46I toolkit"),
|
||||
precision: exact(provider.engine_precision, "FP16", "E46I precision"),
|
||||
},
|
||||
inference: {
|
||||
captions: ["car", "person", "bicycle", "road sign"],
|
||||
confidenceThreshold: exact(inference.confidence_threshold, 0.5, "E46I threshold"),
|
||||
meanFramesPerSecond: number(inference.mean_frames_per_second, "E46I inference fps"),
|
||||
},
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4488, "E46I metric frames"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46I detections"),
|
||||
classObservationCounts: Object.fromEntries(
|
||||
Object.entries(classCounts).map(([key, value]) => [key, integer(value, `E46I ${key}`)]),
|
||||
),
|
||||
meanDetectionsPerFrame: number(metrics.mean_detections_per_frame, "E46I mean"),
|
||||
maxDetectionsPerFrame: integer(metrics.max_detections_per_frame, "E46I max"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46I zero"),
|
||||
longestZeroDetectionRunFrames: integer(metrics.longest_zero_detection_run_frames, "E46I zero run"),
|
||||
confidenceMean: number(metrics.confidence_mean, "E46I confidence"),
|
||||
largeBoxObservationCount: integer(metrics.large_box_observation_count, "E46I large boxes"),
|
||||
largeBoxObservationFraction: number(metrics.large_box_observation_fraction, "E46I large fraction"),
|
||||
workerElapsedSeconds: number(metrics.worker_elapsed_seconds, "E46I elapsed"),
|
||||
workerMeanFramesPerSecond: number(metrics.worker_mean_frames_per_second, "E46I worker fps"),
|
||||
},
|
||||
shadowGate: {
|
||||
legacyCases: exact(shadow.legacy_large_false_background_cases, 5, "E46I legacy cases"),
|
||||
suppressedCases: exact(shadow.legacy_large_false_background_cases_suppressed, 5, "E46I suppressed"),
|
||||
positiveCases: exact(shadow.positive_anchor_cases, 5, "E46I positives"),
|
||||
positiveCasesWithRelevantDetection: exact(shadow.positive_anchor_cases_with_relevant_detection, 5, "E46I retained positives"),
|
||||
limitations: array(shadow.known_semantic_limitations, "E46I gate limitations").map((entry) => text(entry, "E46I gate limitation")),
|
||||
},
|
||||
visualReview: {
|
||||
completeVideoPlaybackCompleted: exact(review.complete_video_playback_completed, true, "E46I playback"),
|
||||
reviewedRangeSeconds: pair(review.reviewed_video_range_seconds, [0, 448.8], "E46I reviewed range"),
|
||||
verdict: exact(review.verdict, "material-semantic-progress-not-yet-complete-perception", "E46I verdict"),
|
||||
reviewWindows: array(review.review_windows, "E46I review windows").map(reviewWindow),
|
||||
finding: text(review.finding, "E46I finding"),
|
||||
knownError: text(review.known_error, "E46I known error"),
|
||||
},
|
||||
decision: {
|
||||
selectedProvider: exact(decision.selected_provider, "nvidia-grounding-dino-swin-tiny-commercial-v1.0", "E46I selected provider"),
|
||||
providerSemanticProgress: exact(decision.provider_semantic_progress, true, "E46I progress"),
|
||||
providerPromoted: exact(decision.provider_promoted, false, "E46I promotion"),
|
||||
nextAction: text(decision.next_action, "E46I next action"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46I limitations").map((entry) => text(entry, "E46I limitation")),
|
||||
video: {
|
||||
url: text(video.url, "E46I video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46I video media"),
|
||||
byteLength: integer(video.byte_length, "E46I video bytes"),
|
||||
sha256: sha(video.sha256, "E46I video SHA"),
|
||||
width: exact(video.width, 960, "E46I video width"),
|
||||
height: exact(video.height, 544, "E46I video height"),
|
||||
frameRate: exact(video.frame_rate, 10, "E46I video fps"),
|
||||
frameCount: exact(video.frame_count, 4488, "E46I video frames"),
|
||||
durationSeconds: exact(video.duration_seconds, 448.8, "E46I video duration"),
|
||||
},
|
||||
visuals: {
|
||||
shadowGate: visual(visuals.shadow_gate, "E46I shadow visual"),
|
||||
fullRoute: visual(visuals.full_route, "E46I route visual"),
|
||||
targetedWindows: visual(visuals.targeted_windows, "E46I targeted visual"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import type { E46ELaboratoryMethod } from "./e46eReadyStack";
|
||||
|
||||
export interface E46JReviewWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
startSeconds: number;
|
||||
endSeconds: number;
|
||||
verdict:
|
||||
| "legacy-background-false-positive-suppressed"
|
||||
| "operator-shadow-person-false-positive-observed";
|
||||
}
|
||||
|
||||
interface VisualArtifact {
|
||||
url: string;
|
||||
mediaType: "image/png";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface E46JRawFisheyeRealtimeResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
source: {
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
sessionId: string;
|
||||
resolution: readonly [800, 600];
|
||||
frameCount: 4489;
|
||||
frameRate: number;
|
||||
durationSeconds: 448.723;
|
||||
calibrationModel: "KB4";
|
||||
};
|
||||
detector: {
|
||||
architecture: "YOLOX-S";
|
||||
source: string;
|
||||
license: "Apache-2.0";
|
||||
runtime: string;
|
||||
};
|
||||
detection: {
|
||||
minimumScore: 0.5;
|
||||
nmsIouThreshold: 0.45;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: 4489;
|
||||
failedFrameCount: 0;
|
||||
detectionObservationCount: number;
|
||||
classObservationCounts: Readonly<Record<string, number>>;
|
||||
meanDetectionsPerFrame: number;
|
||||
maxDetectionsPerFrame: number;
|
||||
zeroDetectionFrameCount: number;
|
||||
longestZeroDetectionRunFrames: number;
|
||||
coreCapacityFps: number;
|
||||
corePathMeanMs: number;
|
||||
corePathP95Ms: number;
|
||||
inferenceRequestMeanMs: number;
|
||||
inferenceRequestP95Ms: number;
|
||||
gpuUtilizationMeanPercent: number;
|
||||
operatorShadowWindowFrameCount: number;
|
||||
operatorShadowPersonFrameCount: number;
|
||||
};
|
||||
visualReview: {
|
||||
reviewedRangeSeconds: readonly [0, 448.723];
|
||||
verdict: "realtime-detector-progress-with-known-shadow-exception";
|
||||
reviewWindows: readonly E46JReviewWindow[];
|
||||
finding: string;
|
||||
knownError: string;
|
||||
};
|
||||
decision: {
|
||||
selectedProvider: "megvii-yolox-s-0.1.1rc0";
|
||||
realtimeCapacityPassed: true;
|
||||
readyForTemporalBakeoff: true;
|
||||
providerPromoted: false;
|
||||
nextAction: string;
|
||||
};
|
||||
method: E46ELaboratoryMethod;
|
||||
limitations: readonly string[];
|
||||
video: {
|
||||
url: string;
|
||||
mediaType: "video/mp4";
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
width: 800;
|
||||
height: 600;
|
||||
frameRate: number;
|
||||
frameCount: 4489;
|
||||
durationSeconds: 448.723;
|
||||
};
|
||||
visuals: {
|
||||
fullRoute: VisualArtifact;
|
||||
targetedWindows: VisualArtifact;
|
||||
operatorShadow: VisualArtifact;
|
||||
};
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
class ContractError extends Error {}
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) throw new ContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new ContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exact<T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function sha(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new ContractError(`${label}: нарушена SHA-256 идентичность.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function pair<const T extends readonly [number, number]>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
const parsed = array(value, label);
|
||||
if (parsed.length !== 2 || parsed[0] !== expected[0] || parsed[1] !== expected[1]) {
|
||||
throw new ContractError(`${label}: нарушен диапазон.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function visual(value: unknown, label: string): VisualArtifact {
|
||||
const raw = object(value, label);
|
||||
return {
|
||||
url: text(raw.url, `${label} URL`),
|
||||
mediaType: exact(raw.media_type, "image/png", `${label} media`),
|
||||
byteLength: integer(raw.byte_length, `${label} bytes`),
|
||||
sha256: sha(raw.sha256, `${label} SHA`),
|
||||
};
|
||||
}
|
||||
|
||||
function method(value: unknown): E46ELaboratoryMethod {
|
||||
const raw = object(value, "E46J method");
|
||||
exact(raw.schema_version, "missioncore.laboratory-method/v1", "E46J method schema");
|
||||
exact(raw.completeness, "complete", "E46J method completeness");
|
||||
exact(raw.execution_class, "hybrid", "E46J method execution");
|
||||
return {
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: text(raw.pipeline_id, "E46J pipeline"),
|
||||
components: array(raw.components, "E46J components").map((value) => {
|
||||
const component = object(value, "E46J component");
|
||||
const kind = text(component.kind, "E46J component kind");
|
||||
if (!("source tool model algorithm runtime".split(" ")).includes(kind)) {
|
||||
throw new ContractError("E46J component kind: неизвестное значение.");
|
||||
}
|
||||
return {
|
||||
kind: kind as "source" | "tool" | "model" | "algorithm" | "runtime",
|
||||
name: text(component.name, "E46J component name"),
|
||||
version: text(component.version, "E46J component version"),
|
||||
role: text(component.role, "E46J component role"),
|
||||
identitySha256: sha(component.identity_sha256, "E46J component identity"),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewWindow(value: unknown): E46JReviewWindow {
|
||||
const raw = object(value, "E46J review window");
|
||||
const verdict = raw.verdict;
|
||||
if (
|
||||
verdict !== "legacy-background-false-positive-suppressed"
|
||||
&& verdict !== "operator-shadow-person-false-positive-observed"
|
||||
) {
|
||||
throw new ContractError("E46J review verdict: неизвестное значение.");
|
||||
}
|
||||
const startSeconds = number(raw.start_seconds, "E46J review start");
|
||||
const endSeconds = number(raw.end_seconds, "E46J review end");
|
||||
if (startSeconds < 0 || endSeconds <= startSeconds || endSeconds > 448.723) {
|
||||
throw new ContractError("E46J review window: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
id: text(raw.id, "E46J review id"),
|
||||
label: text(raw.label, "E46J review label"),
|
||||
startSeconds,
|
||||
endSeconds,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE46JRawFisheyeRealtime({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E46JRawFisheyeRealtimeResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/e46j/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new ContractError(`E46J LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "E46J catalog");
|
||||
exact(catalog.schema_version, "missioncore.e46j-raw-fisheye-realtime-catalog/v1", "E46J catalog schema");
|
||||
const items = array(catalog.items, "E46J results");
|
||||
if (!items.length) return null;
|
||||
if (items.length !== 1) throw new ContractError("E46J catalog size: нарушен контракт.");
|
||||
const item = object(items[0], "E46J result");
|
||||
exact(item.schema_version, "missioncore.e46j-raw-fisheye-realtime-view/v1", "E46J view schema");
|
||||
exact(item.status, "realtime-capacity-passed-awaiting-temporal-layer", "E46J status");
|
||||
exact(item.ground_truth, false, "E46J truth authority");
|
||||
const authority = object(item.authority, "E46J authority");
|
||||
exact(authority.ground_truth, false, "E46J authority truth");
|
||||
exact(authority.provider_promoted, false, "E46J promotion");
|
||||
exact(authority.commands_enabled, false, "E46J commands");
|
||||
exact(authority.navigation_or_safety_accepted, false, "E46J safety");
|
||||
const resultId = text(item.result_id, "E46J result id");
|
||||
if (!/^e46j-raw-fisheye-realtime-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new ContractError("E46J result identity: нарушен контракт.");
|
||||
}
|
||||
const source = object(item.source, "E46J source");
|
||||
const detector = object(item.detector, "E46J detector");
|
||||
const detection = object(item.detection, "E46J detection");
|
||||
const metrics = object(item.metrics, "E46J metrics");
|
||||
const review = object(item.visual_review, "E46J visual review");
|
||||
const decision = object(item.decision, "E46J decision");
|
||||
const video = object(item.video, "E46J video");
|
||||
const visuals = object(item.visuals, "E46J visuals");
|
||||
const classCounts = object(metrics.class_observation_counts, "E46J class counts");
|
||||
const acceptance = object(item.acceptance, "E46J acceptance");
|
||||
exact(acceptance.ten_hz_capacity_gate_passed, true, "E46J capacity gate");
|
||||
exact(acceptance.latency_gate_passed, true, "E46J latency gate");
|
||||
exact(acceptance.full_raw_fisheye_retained, true, "E46J FOV gate");
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: text(item.created_at_utc, "E46J created"),
|
||||
source: {
|
||||
cameraSourceId: exact(source.camera_source_id, "sensor.camera.right", "E46J camera"),
|
||||
sessionId: text(source.session_id, "E46J session"),
|
||||
resolution: pair(source.resolution, [800, 600], "E46J resolution"),
|
||||
frameCount: exact(source.frame_count, 4489, "E46J frames"),
|
||||
frameRate: number(source.frame_rate, "E46J fps"),
|
||||
durationSeconds: 448.723,
|
||||
calibrationModel: exact(source.calibration_model, "KB4", "E46J calibration"),
|
||||
},
|
||||
detector: {
|
||||
architecture: exact(detector.architecture, "YOLOX-S", "E46J architecture"),
|
||||
source: text(detector.source, "E46J detector source"),
|
||||
license: exact(detector.license, "Apache-2.0", "E46J license"),
|
||||
runtime: text(detector.runtime, "E46J runtime"),
|
||||
},
|
||||
detection: {
|
||||
minimumScore: exact(detection.minimum_score, 0.5, "E46J score"),
|
||||
nmsIouThreshold: exact(detection.nms_iou_threshold, 0.45, "E46J NMS"),
|
||||
},
|
||||
metrics: {
|
||||
frameCount: exact(metrics.frame_count, 4489, "E46J metric frames"),
|
||||
failedFrameCount: exact(metrics.failed_frame_count, 0, "E46J failures"),
|
||||
detectionObservationCount: integer(metrics.detection_observation_count, "E46J detections"),
|
||||
classObservationCounts: Object.fromEntries(
|
||||
Object.entries(classCounts).map(([key, value]) => [key, integer(value, `E46J ${key}`)]),
|
||||
),
|
||||
meanDetectionsPerFrame: number(metrics.mean_detections_per_frame, "E46J mean"),
|
||||
maxDetectionsPerFrame: integer(metrics.max_detections_per_frame, "E46J max"),
|
||||
zeroDetectionFrameCount: integer(metrics.zero_detection_frame_count, "E46J zero"),
|
||||
longestZeroDetectionRunFrames: integer(metrics.longest_zero_detection_run_frames, "E46J zero run"),
|
||||
coreCapacityFps: number(metrics.core_capacity_fps, "E46J capacity"),
|
||||
corePathMeanMs: number(metrics.core_path_mean_ms, "E46J core mean"),
|
||||
corePathP95Ms: number(metrics.core_path_p95_ms, "E46J core p95"),
|
||||
inferenceRequestMeanMs: number(metrics.inference_request_mean_ms, "E46J infer mean"),
|
||||
inferenceRequestP95Ms: number(metrics.inference_request_p95_ms, "E46J infer p95"),
|
||||
gpuUtilizationMeanPercent: number(metrics.gpu_utilization_mean_percent, "E46J GPU"),
|
||||
operatorShadowWindowFrameCount: integer(metrics.operator_shadow_window_frame_count, "E46J shadow window"),
|
||||
operatorShadowPersonFrameCount: integer(metrics.operator_shadow_person_frame_count, "E46J shadow false frames"),
|
||||
},
|
||||
visualReview: {
|
||||
reviewedRangeSeconds: pair(review.reviewed_video_range_seconds, [0, 448.723], "E46J reviewed range"),
|
||||
verdict: exact(review.verdict, "realtime-detector-progress-with-known-shadow-exception", "E46J verdict"),
|
||||
reviewWindows: array(review.review_windows, "E46J review windows").map(reviewWindow),
|
||||
finding: text(review.finding, "E46J finding"),
|
||||
knownError: text(review.known_error, "E46J known error"),
|
||||
},
|
||||
decision: {
|
||||
selectedProvider: exact(decision.selected_provider, "megvii-yolox-s-0.1.1rc0", "E46J provider"),
|
||||
realtimeCapacityPassed: exact(decision.realtime_capacity_passed, true, "E46J realtime"),
|
||||
readyForTemporalBakeoff: exact(decision.ready_for_temporal_bakeoff, true, "E46J temporal readiness"),
|
||||
providerPromoted: exact(decision.provider_promoted, false, "E46J provider promotion"),
|
||||
nextAction: text(decision.next_action, "E46J next action"),
|
||||
},
|
||||
method: method(item.method),
|
||||
limitations: array(item.limitations, "E46J limitations").map((value) => text(value, "E46J limitation")),
|
||||
video: {
|
||||
url: text(video.url, "E46J video URL"),
|
||||
mediaType: exact(video.media_type, "video/mp4", "E46J video media"),
|
||||
byteLength: integer(video.byte_length, "E46J video bytes"),
|
||||
sha256: sha(video.sha256, "E46J video SHA"),
|
||||
width: exact(video.width, 800, "E46J video width"),
|
||||
height: exact(video.height, 600, "E46J video height"),
|
||||
frameRate: number(video.frame_rate, "E46J video fps"),
|
||||
frameCount: exact(video.frame_count, 4489, "E46J video frames"),
|
||||
durationSeconds: exact(video.duration_seconds, 448.723, "E46J video duration"),
|
||||
},
|
||||
visuals: {
|
||||
fullRoute: visual(visuals.full_route, "E46J route visual"),
|
||||
targetedWindows: visual(visuals.targeted_windows, "E46J targeted visual"),
|
||||
operatorShadow: visual(visuals.operator_shadow, "E46J shadow visual"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
export type E47SemanticDisposition = "labeled" | "ambiguous";
|
||||
|
||||
export interface E47SemanticClass {
|
||||
classId: number;
|
||||
label: string;
|
||||
disposition: E47SemanticDisposition;
|
||||
colorRgb: readonly [number, number, number];
|
||||
}
|
||||
|
||||
export interface E47SemanticSlamResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "diagnostic-semantic-slam-shadow";
|
||||
profileId: string;
|
||||
baseM4ResultId: string;
|
||||
semanticResultId: string;
|
||||
geometryResultId: string;
|
||||
sourcePackId: string;
|
||||
calibrationContentSha256: string;
|
||||
provider: {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
modelRevision: string;
|
||||
modelWeightsSha256: string;
|
||||
preprocessId: string;
|
||||
};
|
||||
temporalBinding: {
|
||||
semanticToCamera: "exact-sequence-and-session-time";
|
||||
cameraToLidar: "accepted-e6-nearest-host-arrival-best-effort";
|
||||
clockBasis: "recorded-host-monotonic-arrival";
|
||||
maximumLidarCameraDeltaMs: number;
|
||||
maximumPosePointDeltaMs: number;
|
||||
physicalSynchronizationProven: false;
|
||||
};
|
||||
taxonomy: readonly E47SemanticClass[];
|
||||
metrics: {
|
||||
frames: {
|
||||
total: number;
|
||||
maskAvailable: number;
|
||||
sourceAvailable: number;
|
||||
};
|
||||
points: {
|
||||
total: number;
|
||||
projected: number;
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
observations: {
|
||||
total: number;
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
runtime: {
|
||||
elapsedMs: number;
|
||||
framesPerSecond: number;
|
||||
};
|
||||
};
|
||||
acceptance: {
|
||||
artifactContractPassed: boolean;
|
||||
frameAccountingPassed: boolean;
|
||||
pointAccountingPassed: boolean;
|
||||
observationBindingPassed: boolean;
|
||||
temporalBindingPassed: boolean;
|
||||
independentSemanticTruthPassed: false;
|
||||
providerPromoted: false;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface E47SemanticTimelineFrame {
|
||||
sequence: number;
|
||||
sourcePointCount: number;
|
||||
classIds: readonly number[];
|
||||
statusCodes: readonly number[];
|
||||
counts: {
|
||||
labeled: number;
|
||||
ambiguous: number;
|
||||
unprojected: number;
|
||||
absent: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E47SemanticTimelineChunk {
|
||||
resultId: string;
|
||||
startSequence: number;
|
||||
frameCount: number;
|
||||
nextSequence: number | null;
|
||||
frames: readonly E47SemanticTimelineFrame[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export class E47SemanticSlamContractError extends Error {}
|
||||
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const text = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const finite = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = finite(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const exact = <T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T => {
|
||||
if (value !== expected) {
|
||||
throw new E47SemanticSlamContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
};
|
||||
|
||||
function resultId(value: unknown): string {
|
||||
const parsed = text(value, "E47 result id");
|
||||
if (!/^e47-semantic-slam-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError("E47 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function m4ResultId(value: unknown): string {
|
||||
const parsed = text(value, "E47 base M4 result id");
|
||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError("E47 base M4 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sha256(value: unknown, label: string): string {
|
||||
const parsed = text(value, label);
|
||||
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new E47SemanticSlamContractError(`${label}: ожидался SHA-256.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function taxonomy(value: unknown): readonly E47SemanticClass[] {
|
||||
const classes = array(value, "E47 taxonomy").map((raw) => {
|
||||
const item = object(raw, "E47 semantic class");
|
||||
const classId = integer(item.class_id, "E47 class id");
|
||||
if (classId > 255) {
|
||||
throw new E47SemanticSlamContractError("E47 class id: вышел за uint8.");
|
||||
}
|
||||
const disposition = text(item.disposition, "E47 class disposition");
|
||||
if (disposition !== "labeled" && disposition !== "ambiguous") {
|
||||
throw new E47SemanticSlamContractError("E47 class disposition: неизвестное значение.");
|
||||
}
|
||||
const rgb = array(item.color_rgb, "E47 class color").map(
|
||||
(channel) => integer(channel, "E47 color channel"),
|
||||
);
|
||||
if (rgb.length !== 3 || rgb.some((channel) => channel > 255)) {
|
||||
throw new E47SemanticSlamContractError("E47 class color: нарушен RGB-контракт.");
|
||||
}
|
||||
return {
|
||||
classId,
|
||||
label: text(item.label, "E47 class label"),
|
||||
disposition: disposition as E47SemanticDisposition,
|
||||
colorRgb: [rgb[0]!, rgb[1]!, rgb[2]!] as const,
|
||||
};
|
||||
});
|
||||
if (!classes.length || new Set(classes.map((item) => item.classId)).size !== classes.length) {
|
||||
throw new E47SemanticSlamContractError("E47 taxonomy: классы отсутствуют или дублируются.");
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E47SemanticSlamResult {
|
||||
const item = object(value, "E47 result");
|
||||
exact(item.schema_version, "missioncore.e47-semantic-slam-view/v1", "E47 view schema");
|
||||
exact(item.status, "diagnostic-semantic-slam-shadow", "E47 status");
|
||||
exact(item.ground_truth, false, "E47 ground truth");
|
||||
exact(item.semantic_authority, "diagnostic-only", "E47 semantic authority");
|
||||
exact(item.navigation_or_safety_accepted, false, "E47 safety authority");
|
||||
exact(item.actuation_allowed, false, "E47 actuation authority");
|
||||
const provider = object(item.provider, "E47 provider");
|
||||
const temporalBinding = object(item.temporal_binding, "E47 temporal binding");
|
||||
const metrics = object(item.metrics, "E47 metrics");
|
||||
const frames = object(metrics.frames, "E47 frame metrics");
|
||||
const points = object(metrics.points, "E47 point metrics");
|
||||
const observations = object(metrics.observations, "E47 observation metrics");
|
||||
const runtime = object(metrics.runtime, "E47 runtime metrics");
|
||||
const acceptance = object(item.acceptance, "E47 acceptance");
|
||||
const frameMetrics = {
|
||||
total: exact(frames.total, 4489, "E47 frame total"),
|
||||
maskAvailable: integer(frames.mask_available, "E47 mask frames"),
|
||||
sourceAvailable: integer(frames.source_available, "E47 source frames"),
|
||||
};
|
||||
if (
|
||||
frameMetrics.maskAvailable > frameMetrics.total
|
||||
|| frameMetrics.sourceAvailable > frameMetrics.total
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 frame accounting: нарушен контракт.");
|
||||
}
|
||||
const pointMetrics = {
|
||||
total: integer(points.total, "E47 total points"),
|
||||
projected: integer(points.projected, "E47 projected points"),
|
||||
labeled: integer(points.labeled, "E47 labeled points"),
|
||||
ambiguous: integer(points.ambiguous, "E47 ambiguous points"),
|
||||
unprojected: integer(points.unprojected, "E47 unprojected points"),
|
||||
absent: integer(points.absent, "E47 absent points"),
|
||||
};
|
||||
if (
|
||||
pointMetrics.projected !== pointMetrics.labeled + pointMetrics.ambiguous
|
||||
|| pointMetrics.total !== pointMetrics.projected
|
||||
+ pointMetrics.unprojected
|
||||
+ pointMetrics.absent
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 point accounting: нарушен контракт.");
|
||||
}
|
||||
const observationMetrics = {
|
||||
total: integer(observations.total, "E47 total observations"),
|
||||
labeled: integer(observations.labeled, "E47 labeled observations"),
|
||||
ambiguous: integer(observations.ambiguous, "E47 ambiguous observations"),
|
||||
unprojected: integer(observations.unprojected, "E47 unprojected observations"),
|
||||
absent: integer(observations.absent, "E47 absent observations"),
|
||||
};
|
||||
if (
|
||||
observationMetrics.total !== observationMetrics.labeled
|
||||
+ observationMetrics.ambiguous
|
||||
+ observationMetrics.unprojected
|
||||
+ observationMetrics.absent
|
||||
) {
|
||||
throw new E47SemanticSlamContractError("E47 observation accounting: нарушен контракт.");
|
||||
}
|
||||
const runtimeMetrics = {
|
||||
elapsedMs: finite(runtime.elapsed_ms, "E47 elapsed"),
|
||||
framesPerSecond: finite(runtime.frames_per_second, "E47 FPS"),
|
||||
};
|
||||
if (runtimeMetrics.elapsedMs <= 0 || runtimeMetrics.framesPerSecond <= 0) {
|
||||
throw new E47SemanticSlamContractError("E47 runtime accounting: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: resultId(item.result_id),
|
||||
createdAtUtc: text(item.created_at_utc, "E47 created at"),
|
||||
status: "diagnostic-semantic-slam-shadow",
|
||||
profileId: text(item.profile_id, "E47 profile"),
|
||||
baseM4ResultId: m4ResultId(item.base_m4_result_id),
|
||||
semanticResultId: text(item.semantic_result_id, "E47 semantic source"),
|
||||
geometryResultId: text(item.geometry_result_id, "E47 geometry source"),
|
||||
sourcePackId: text(item.source_pack_id, "E47 source pack"),
|
||||
calibrationContentSha256: sha256(item.calibration_content_sha256, "E47 calibration"),
|
||||
provider: {
|
||||
providerId: text(provider.provider_id, "E47 provider id"),
|
||||
modelId: text(provider.model_id, "E47 model id"),
|
||||
modelRevision: text(provider.model_revision, "E47 model revision"),
|
||||
modelWeightsSha256: sha256(provider.model_weights_sha256, "E47 model weights"),
|
||||
preprocessId: text(provider.preprocess_id, "E47 preprocess id"),
|
||||
},
|
||||
temporalBinding: {
|
||||
semanticToCamera: exact(
|
||||
temporalBinding.semantic_to_camera,
|
||||
"exact-sequence-and-session-time",
|
||||
"E47 semantic/camera binding",
|
||||
),
|
||||
cameraToLidar: exact(
|
||||
temporalBinding.camera_to_lidar,
|
||||
"accepted-e6-nearest-host-arrival-best-effort",
|
||||
"E47 camera/LiDAR binding",
|
||||
),
|
||||
clockBasis: exact(
|
||||
temporalBinding.clock_basis,
|
||||
"recorded-host-monotonic-arrival",
|
||||
"E47 clock basis",
|
||||
),
|
||||
maximumLidarCameraDeltaMs: finite(
|
||||
temporalBinding.maximum_lidar_camera_delta_ms,
|
||||
"E47 maximum camera/LiDAR delta",
|
||||
),
|
||||
maximumPosePointDeltaMs: finite(
|
||||
temporalBinding.maximum_pose_point_delta_ms,
|
||||
"E47 maximum pose/point delta",
|
||||
),
|
||||
physicalSynchronizationProven: exact(
|
||||
temporalBinding.physical_synchronization_proven,
|
||||
false,
|
||||
"E47 physical synchronization",
|
||||
),
|
||||
},
|
||||
taxonomy: taxonomy(item.taxonomy),
|
||||
metrics: {
|
||||
frames: frameMetrics,
|
||||
points: pointMetrics,
|
||||
observations: observationMetrics,
|
||||
runtime: runtimeMetrics,
|
||||
},
|
||||
acceptance: {
|
||||
artifactContractPassed: exact(
|
||||
acceptance.artifact_contract_passed,
|
||||
true,
|
||||
"E47 artifact contract",
|
||||
),
|
||||
frameAccountingPassed: exact(
|
||||
acceptance.frame_accounting_passed,
|
||||
true,
|
||||
"E47 frame accounting",
|
||||
),
|
||||
pointAccountingPassed: exact(
|
||||
acceptance.point_accounting_passed,
|
||||
true,
|
||||
"E47 point accounting",
|
||||
),
|
||||
observationBindingPassed: exact(
|
||||
acceptance.observation_binding_passed,
|
||||
true,
|
||||
"E47 observation binding",
|
||||
),
|
||||
temporalBindingPassed: exact(
|
||||
acceptance.temporal_binding_passed,
|
||||
true,
|
||||
"E47 temporal binding",
|
||||
),
|
||||
independentSemanticTruthPassed: exact(
|
||||
acceptance.independent_semantic_truth_passed,
|
||||
false,
|
||||
"E47 independent truth",
|
||||
),
|
||||
providerPromoted: exact(
|
||||
acceptance.provider_promoted,
|
||||
false,
|
||||
"E47 provider promotion",
|
||||
),
|
||||
},
|
||||
limitations: array(item.limitations, "E47 limitations").map(
|
||||
(entry) => text(entry, "E47 limitation"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE47SemanticSlamResult({
|
||||
resultId: requestedResultId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
resultId?: string;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E47SemanticSlamResult | null> {
|
||||
if (requestedResultId !== undefined) resultId(requestedResultId);
|
||||
let response = await fetcher(
|
||||
requestedResultId === undefined
|
||||
? "/api/v1/laboratory/e47-semantic-slam/results?limit=1"
|
||||
: `/api/v1/laboratory/e47-semantic-slam/results/${requestedResultId}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
let catalogFallback = false;
|
||||
if (requestedResultId !== undefined && response.status === 404) {
|
||||
response = await fetcher("/api/v1/laboratory/e47-semantic-slam/results?limit=10", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
catalogFallback = true;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new E47SemanticSlamContractError(`E47 LAB недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (requestedResultId === undefined || catalogFallback) {
|
||||
const catalog = object(payload, "E47 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.e47-semantic-slam-catalog/v1",
|
||||
"E47 catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "E47 catalog items");
|
||||
if (!items.length) return null;
|
||||
const selected = requestedResultId === undefined
|
||||
? items[0]
|
||||
: items.find((candidate) => (
|
||||
typeof candidate === "object"
|
||||
&& candidate !== null
|
||||
&& (candidate as Record<string, unknown>).result_id === requestedResultId
|
||||
));
|
||||
if (selected === undefined) {
|
||||
throw new E47SemanticSlamContractError(
|
||||
"E47 requested result: точный semantic-result отсутствует в каталоге.",
|
||||
);
|
||||
}
|
||||
const parsed = parseResult(selected);
|
||||
if (requestedResultId !== undefined && parsed.resultId !== requestedResultId) {
|
||||
throw new E47SemanticSlamContractError("E47 requested result: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
const parsed = parseResult(payload);
|
||||
if (parsed.resultId !== requestedResultId) {
|
||||
throw new E47SemanticSlamContractError("E47 requested result: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseFrame(
|
||||
value: unknown,
|
||||
expectedSequence: number,
|
||||
declaredTaxonomy: readonly E47SemanticClass[] | undefined,
|
||||
): E47SemanticTimelineFrame {
|
||||
const item = object(value, "E47 semantic frame");
|
||||
exact(item.schema_version, "missioncore.e47-semantic-slam-frame/v1", "E47 frame schema");
|
||||
const sequence = integer(item.sequence, "E47 frame sequence");
|
||||
if (sequence !== expectedSequence) {
|
||||
throw new E47SemanticSlamContractError("E47 frame sequence: нарушен порядок.");
|
||||
}
|
||||
const sourcePointCount = integer(item.source_point_count, "E47 frame source points");
|
||||
const classIds = array(item.class_ids, "E47 frame classes").map((entry) => {
|
||||
const parsed = finite(entry, "E47 frame class");
|
||||
if (!Number.isInteger(parsed) || parsed < -1 || parsed > 255) {
|
||||
throw new E47SemanticSlamContractError("E47 frame class: вышел за контракт.");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
const statusCodes = array(item.status_codes, "E47 frame statuses").map((entry) => {
|
||||
const parsed = integer(entry, "E47 frame status");
|
||||
if (parsed > 3) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status: неизвестное значение.");
|
||||
}
|
||||
return parsed;
|
||||
});
|
||||
if (classIds.length !== sourcePointCount || statusCodes.length !== sourcePointCount) {
|
||||
throw new E47SemanticSlamContractError("E47 frame point accounting: нарушен контракт.");
|
||||
}
|
||||
if (classIds.some((classId, index) => {
|
||||
const status = statusCodes[index];
|
||||
return status === 0 || status === 1 ? classId !== -1 : classId < 0;
|
||||
})) {
|
||||
throw new E47SemanticSlamContractError("E47 frame class/status binding: нарушен контракт.");
|
||||
}
|
||||
if (declaredTaxonomy) {
|
||||
const classesById = new Map(declaredTaxonomy.map((item) => [item.classId, item]));
|
||||
if (classIds.some((classId, index) => {
|
||||
const status = statusCodes[index];
|
||||
if (status !== 2 && status !== 3) return false;
|
||||
const semanticClass = classesById.get(classId);
|
||||
return !semanticClass
|
||||
|| (status === 2 && semanticClass.disposition !== "ambiguous")
|
||||
|| (status === 3 && semanticClass.disposition !== "labeled");
|
||||
})) {
|
||||
throw new E47SemanticSlamContractError("E47 frame taxonomy binding: нарушен контракт.");
|
||||
}
|
||||
}
|
||||
const counts = object(item.counts, "E47 frame counts");
|
||||
const parsedCounts = {
|
||||
labeled: integer(counts.labeled, "E47 frame labeled"),
|
||||
ambiguous: integer(counts.ambiguous, "E47 frame ambiguous"),
|
||||
unprojected: integer(counts.unprojected, "E47 frame unprojected"),
|
||||
absent: integer(counts.absent, "E47 frame absent"),
|
||||
};
|
||||
if (Object.values(parsedCounts).reduce((sum, count) => sum + count, 0) !== sourcePointCount) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status accounting: нарушен контракт.");
|
||||
}
|
||||
const actualCounts = {
|
||||
labeled: statusCodes.filter((status) => status === 3).length,
|
||||
ambiguous: statusCodes.filter((status) => status === 2).length,
|
||||
unprojected: statusCodes.filter((status) => status === 1).length,
|
||||
absent: statusCodes.filter((status) => status === 0).length,
|
||||
};
|
||||
if (Object.keys(actualCounts).some(
|
||||
(key) => actualCounts[key as keyof typeof actualCounts]
|
||||
!== parsedCounts[key as keyof typeof parsedCounts],
|
||||
)) {
|
||||
throw new E47SemanticSlamContractError("E47 frame status histogram: нарушен контракт.");
|
||||
}
|
||||
return { sequence, sourcePointCount, classIds, statusCodes, counts: parsedCounts };
|
||||
}
|
||||
|
||||
export async function fetchE47SemanticTimelineChunk(
|
||||
result: string,
|
||||
startSequence: number,
|
||||
frameCount: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
taxonomy,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
taxonomy?: readonly E47SemanticClass[];
|
||||
} = {},
|
||||
): Promise<E47SemanticTimelineChunk> {
|
||||
resultId(result);
|
||||
const parameters = new URLSearchParams({
|
||||
start: String(startSequence),
|
||||
count: String(frameCount),
|
||||
});
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e47-semantic-slam/results/${result}/timeline/chunk?${parameters}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E47SemanticSlamContractError(`E47 timeline chunk: HTTP ${response.status}.`);
|
||||
}
|
||||
const payload = object(await response.json(), "E47 semantic chunk");
|
||||
exact(payload.schema_version, "missioncore.e47-semantic-slam-chunk/v1", "E47 chunk schema");
|
||||
exact(payload.result_id, result, "E47 chunk result");
|
||||
const parsedStart = integer(payload.start_sequence, "E47 chunk start");
|
||||
if (parsedStart !== startSequence) {
|
||||
throw new E47SemanticSlamContractError("E47 chunk start: нарушен контракт.");
|
||||
}
|
||||
const frames = array(payload.frames, "E47 chunk frames").map(
|
||||
(frame, offset) => parseFrame(frame, parsedStart + offset, taxonomy),
|
||||
);
|
||||
const parsedCount = integer(payload.frame_count, "E47 chunk count");
|
||||
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||
throw new E47SemanticSlamContractError("E47 chunk frame count: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId: result,
|
||||
startSequence: parsedStart,
|
||||
frameCount: parsedCount,
|
||||
nextSequence: payload.next_sequence === null
|
||||
? null
|
||||
: integer(payload.next_sequence, "E47 next sequence"),
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
export function e47SemanticMaskUrl(result: string, sequence: number): string {
|
||||
resultId(result);
|
||||
if (!Number.isInteger(sequence) || sequence < 0 || sequence >= 4489) {
|
||||
throw new E47SemanticSlamContractError("E47 mask sequence: вне recorded replay.");
|
||||
}
|
||||
return `/api/v1/laboratory/e47-semantic-slam/results/${result}/masks/${sequence}`;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
export type LaboratoryEvidenceCompleteness = "recorded" | "not-recorded";
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface LaboratoryEvidenceArtifact {
|
||||
kind: string | null;
|
||||
path: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
schemaVersion: string | null;
|
||||
mediaType: string | null;
|
||||
verified: true;
|
||||
}
|
||||
|
||||
export interface LaboratoryEvidenceReport {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1";
|
||||
workId: string;
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
access: "read-only";
|
||||
proof: {
|
||||
documentSchemaVersion: string;
|
||||
documentSha256: string;
|
||||
identitySha256: string;
|
||||
reportSchemaVersion: string | null;
|
||||
reportSha256: string | null;
|
||||
artifactCount: number;
|
||||
verifiedArtifactCount: number;
|
||||
};
|
||||
completeness: Readonly<Record<string, LaboratoryEvidenceCompleteness>>;
|
||||
identity: Record<string, JsonValue>;
|
||||
source: Record<string, JsonValue> | null;
|
||||
configuration: Record<string, JsonValue> | null;
|
||||
method: Record<string, JsonValue> | null;
|
||||
execution: Record<string, JsonValue> | null;
|
||||
resources: Record<string, JsonValue> | null;
|
||||
metrics: Record<string, JsonValue> | null;
|
||||
gates: Record<string, JsonValue> | null;
|
||||
decision: JsonValue | undefined;
|
||||
limitations: JsonValue | undefined;
|
||||
authority: Record<string, JsonValue> | null;
|
||||
artifacts: readonly LaboratoryEvidenceArtifact[];
|
||||
visualEvidence: Record<string, JsonValue>;
|
||||
rawReport: Record<string, JsonValue>;
|
||||
canonicalJson: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export class LaboratoryEvidenceReportContractError extends Error {}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nullableRecord(value: unknown, label: string): Record<string, JsonValue> | null {
|
||||
return value === null ? null : jsonRecord(value, label);
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown, label: string): Record<string, JsonValue> {
|
||||
const document = record(value, label);
|
||||
for (const [key, item] of Object.entries(document)) validateJson(item, `${label}.${key}`);
|
||||
return document as Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
function validateJson(value: unknown, label: string): asserts value is JsonValue {
|
||||
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: число не конечно.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => validateJson(item, `${label}[${index}]`));
|
||||
return;
|
||||
}
|
||||
const document = record(value, label);
|
||||
Object.entries(document).forEach(([key, item]) => validateJson(item, `${label}.${key}`));
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, label: string): string | null {
|
||||
return value === null ? null : text(value, label);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArtifact(value: unknown): LaboratoryEvidenceArtifact {
|
||||
const artifact = record(value, "LAB artifact");
|
||||
if (artifact.verified !== true) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB artifact: хэш не подтверждён.");
|
||||
}
|
||||
return {
|
||||
kind: nullableText(artifact.kind, "LAB artifact kind"),
|
||||
path: text(artifact.path, "LAB artifact path"),
|
||||
byteLength: integer(artifact.byte_length, "LAB artifact byte_length"),
|
||||
sha256: text(artifact.sha256, "LAB artifact sha256"),
|
||||
schemaVersion: nullableText(artifact.schema_version, "LAB artifact schema_version"),
|
||||
mediaType: nullableText(artifact.media_type, "LAB artifact media_type"),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLaboratoryEvidenceReport(value: unknown): LaboratoryEvidenceReport {
|
||||
const payload = jsonRecord(value, "LAB evidence report") as Record<string, unknown>;
|
||||
if (payload.schema_version !== "missioncore.laboratory-evidence-report/v1") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: неизвестная схема.");
|
||||
}
|
||||
if (payload.access !== "read-only") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: доступ не read-only.");
|
||||
}
|
||||
const proof = record(payload.proof, "LAB evidence proof");
|
||||
const completenessPayload = record(payload.completeness, "LAB evidence completeness");
|
||||
const completeness: Record<string, LaboratoryEvidenceCompleteness> = {};
|
||||
for (const [key, state] of Object.entries(completenessPayload)) {
|
||||
if (state !== "recorded" && state !== "not-recorded") {
|
||||
throw new LaboratoryEvidenceReportContractError(`LAB completeness ${key}: неизвестное значение.`);
|
||||
}
|
||||
completeness[key] = state;
|
||||
}
|
||||
if (!Array.isArray(payload.artifacts)) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence artifacts: ожидался список.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1",
|
||||
workId: text(payload.work_id, "LAB work_id"),
|
||||
resultId: text(payload.result_id, "LAB result_id"),
|
||||
createdAtUtc: nullableText(payload.created_at_utc, "LAB created_at_utc"),
|
||||
access: "read-only",
|
||||
proof: {
|
||||
documentSchemaVersion: text(proof.document_schema_version, "document schema"),
|
||||
documentSha256: text(proof.document_sha256, "document sha256"),
|
||||
identitySha256: text(proof.identity_sha256, "identity sha256"),
|
||||
reportSchemaVersion: nullableText(proof.report_schema_version, "report schema"),
|
||||
reportSha256: nullableText(proof.report_sha256, "report sha256"),
|
||||
artifactCount: integer(proof.artifact_count, "artifact_count"),
|
||||
verifiedArtifactCount: integer(proof.verified_artifact_count, "verified_artifact_count"),
|
||||
},
|
||||
completeness,
|
||||
identity: jsonRecord(payload.identity, "LAB identity"),
|
||||
source: nullableRecord(payload.source, "LAB source"),
|
||||
configuration: nullableRecord(payload.configuration, "LAB configuration"),
|
||||
method: nullableRecord(payload.method, "LAB method"),
|
||||
execution: nullableRecord(payload.execution, "LAB execution"),
|
||||
resources: nullableRecord(payload.resources, "LAB resources"),
|
||||
metrics: nullableRecord(payload.metrics, "LAB metrics"),
|
||||
gates: nullableRecord(payload.gates, "LAB gates"),
|
||||
decision: payload.decision as JsonValue | undefined,
|
||||
limitations: payload.limitations as JsonValue | undefined,
|
||||
authority: nullableRecord(payload.authority, "LAB authority"),
|
||||
artifacts: payload.artifacts.map(parseArtifact),
|
||||
visualEvidence: jsonRecord(payload.visual_evidence, "LAB visual evidence"),
|
||||
rawReport: jsonRecord(payload.raw_report, "LAB raw report"),
|
||||
canonicalJson: payload as Record<string, JsonValue>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLaboratoryEvidenceReport({
|
||||
workId,
|
||||
resultId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
workId: string;
|
||||
resultId: string;
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<LaboratoryEvidenceReport> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/evidence-reports/${encodeURIComponent(workId)}/${encodeURIComponent(resultId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new LaboratoryEvidenceReportContractError(
|
||||
response.status === 404
|
||||
? "Для этой LAB канонический evidence-report пока не опубликован."
|
||||
: `Evidence-report LAB не прошёл серверную проверку: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const report = parseLaboratoryEvidenceReport(await response.json());
|
||||
if (report.workId !== workId || report.resultId !== resultId) {
|
||||
throw new LaboratoryEvidenceReportContractError("Evidence-report не совпадает с выбранной LAB identity.");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
|
||||
|
||||
export interface L31ClassCounts {
|
||||
Vehicle: number;
|
||||
Pedestrian: number;
|
||||
Cyclist: number;
|
||||
}
|
||||
|
||||
export interface L31VisualFrameSummary {
|
||||
frameId: string;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourcePointCount: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceMs: number;
|
||||
}
|
||||
|
||||
export interface L31PointPillarsRavnovesResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "cross-domain-transfer-measured-visual-review-required";
|
||||
sourceSessionId: string;
|
||||
sourcePackId: string;
|
||||
sourceLogicalContentSha256: string;
|
||||
model: {
|
||||
name: "pointpillars";
|
||||
sourceModelSha256: string;
|
||||
engineSha256: string;
|
||||
embeddedScoreThreshold: number;
|
||||
};
|
||||
execution: {
|
||||
workerHostId: "worker-006";
|
||||
sequential: true;
|
||||
parallelWorkers: 1;
|
||||
sourcePaced: false;
|
||||
existingTritonOnly: true;
|
||||
};
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
inputAdmissionFraction: number;
|
||||
outputSchemaValidFraction: number;
|
||||
framesWithPredictions: number;
|
||||
framesWithVehiclePredictions: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceLatencyMs: { p50: number; p95: number; maximum: number };
|
||||
poseBindingAgeMs: { p50: number; p95: number; maximum: number };
|
||||
deterministicReplayFraction: number;
|
||||
deterministicReplayFrames: number;
|
||||
};
|
||||
frames: readonly L31VisualFrameSummary[];
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface L31VisualFrame {
|
||||
frameId: string;
|
||||
summary: L31VisualFrameSummary & {
|
||||
deterministicReplay: boolean;
|
||||
replayInferenceMs: number;
|
||||
visualBoxCount: number;
|
||||
visualBoxTruncated: boolean;
|
||||
};
|
||||
sourcePointCount: number;
|
||||
modelRangePointCount: number;
|
||||
sampledPointCount: number;
|
||||
pointsXyzi: readonly number[];
|
||||
truthBoxes: readonly L3VisualBox[];
|
||||
predictionBoxes: readonly L3VisualBox[];
|
||||
}
|
||||
|
||||
const RESULT_ID = /^l31-pointpillars-ravnoves-[a-f0-9]{64}$/;
|
||||
const PACK_ID = /^lidar-replay-pack-[a-f0-9]{64}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const FRAME_ID = /^[0-9]{6}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string, minimum = 0): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isFinite(value)
|
||||
|| value < minimum
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactBoolean(
|
||||
value: unknown,
|
||||
expected: boolean,
|
||||
label: string,
|
||||
): boolean {
|
||||
const parsed = boolean(value, label);
|
||||
if (parsed !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function classCounts(value: unknown, label: string): L31ClassCounts {
|
||||
const counts = record(value, label);
|
||||
if (
|
||||
Object.keys(counts).sort().join(",") !== "Cyclist,Pedestrian,Vehicle"
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: классы изменились.`);
|
||||
}
|
||||
return {
|
||||
Vehicle: integer(counts.Vehicle, `${label}.Vehicle`),
|
||||
Pedestrian: integer(counts.Pedestrian, `${label}.Pedestrian`),
|
||||
Cyclist: integer(counts.Cyclist, `${label}.Cyclist`),
|
||||
};
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): { p50: number; p95: number; maximum: number } {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
p50: number(item.p50, `${label}.p50`),
|
||||
p95: number(item.p95, `${label}.p95`),
|
||||
maximum: number(item.maximum, `${label}.maximum`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L31VisualFrameSummary {
|
||||
const item = record(value, "L3.1 frame");
|
||||
const frameId = string(item.frame_id, "L3.1 frame_id");
|
||||
if (!FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 frame_id: неверный формат.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
frameIndex: integer(item.frame_index, "L3.1 frame_index"),
|
||||
sessionSeconds: number(item.session_seconds, "L3.1 session_seconds"),
|
||||
sourcePointCount: integer(
|
||||
item.source_point_count,
|
||||
"L3.1 source_point_count",
|
||||
),
|
||||
predictionCount: integer(
|
||||
item.prediction_count,
|
||||
"L3.1 prediction_count",
|
||||
),
|
||||
classCounts: classCounts(item.class_counts, "L3.1 class_counts"),
|
||||
inferenceMs: number(
|
||||
item.inference_ms,
|
||||
"L3.1 inference_ms",
|
||||
Number.MIN_VALUE,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L31PointPillarsRavnovesResult {
|
||||
const item = record(value, "L3.1 result");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l31-pointpillars-ravnoves-result/v1",
|
||||
"L3.1 schema_version",
|
||||
);
|
||||
exact(item.access, "read-only", "L3.1 access");
|
||||
const resultId = string(item.result_id, "L3.1 result_id");
|
||||
const sourcePackId = string(item.source_pack_id, "L3.1 source_pack_id");
|
||||
const logicalSha = string(
|
||||
item.source_logical_content_sha256,
|
||||
"L3.1 logical content",
|
||||
);
|
||||
if (
|
||||
!RESULT_ID.test(resultId)
|
||||
|| !PACK_ID.test(sourcePackId)
|
||||
|| !SHA256.test(logicalSha)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3.1: нарушена идентичность результата.",
|
||||
);
|
||||
}
|
||||
const model = record(item.model, "L3.1 model");
|
||||
const execution = record(item.execution, "L3.1 execution");
|
||||
const metrics = record(item.metrics, "L3.1 metrics");
|
||||
const frames = array(item.frames, "L3.1 frames").map(parseSummary);
|
||||
if (
|
||||
!frames.length
|
||||
|| frames.length > 18
|
||||
|| new Set(frames.map(({ frameId }) => frameId)).size !== frames.length
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 frames: неверный каталог.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.1 created_at_utc"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"cross-domain-transfer-measured-visual-review-required",
|
||||
"L3.1 status",
|
||||
),
|
||||
sourceSessionId: exact(
|
||||
item.source_session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"L3.1 source_session_id",
|
||||
),
|
||||
sourcePackId,
|
||||
sourceLogicalContentSha256: logicalSha,
|
||||
model: {
|
||||
name: exact(model.name, "pointpillars", "L3.1 model.name"),
|
||||
sourceModelSha256: string(
|
||||
model.source_model_sha256,
|
||||
"L3.1 model sha",
|
||||
),
|
||||
engineSha256: string(model.engine_sha256, "L3.1 engine sha"),
|
||||
embeddedScoreThreshold: number(
|
||||
model.embedded_score_threshold,
|
||||
"L3.1 score threshold",
|
||||
),
|
||||
},
|
||||
execution: {
|
||||
workerHostId: exact(
|
||||
execution.worker_host_id,
|
||||
"worker-006",
|
||||
"L3.1 worker",
|
||||
),
|
||||
sequential: exactBoolean(
|
||||
execution.sequential,
|
||||
true,
|
||||
"L3.1 sequential",
|
||||
) as true,
|
||||
parallelWorkers: (
|
||||
integer(execution.parallel_workers, "L3.1 parallel workers") === 1
|
||||
? 1
|
||||
: (() => {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
"L3.1 parallel workers: нарушен контракт.",
|
||||
);
|
||||
})()
|
||||
),
|
||||
sourcePaced: exactBoolean(
|
||||
execution.source_paced,
|
||||
false,
|
||||
"L3.1 source paced",
|
||||
) as false,
|
||||
existingTritonOnly: exactBoolean(
|
||||
execution.existing_triton_only,
|
||||
true,
|
||||
"L3.1 Triton",
|
||||
) as true,
|
||||
},
|
||||
metrics: {
|
||||
frameCount: integer(metrics.frame_count, "L3.1 frame count"),
|
||||
inputAdmissionFraction: number(
|
||||
metrics.input_admission_fraction,
|
||||
"L3.1 admission",
|
||||
),
|
||||
outputSchemaValidFraction: number(
|
||||
metrics.output_schema_valid_fraction,
|
||||
"L3.1 schema valid",
|
||||
),
|
||||
framesWithPredictions: integer(
|
||||
metrics.frames_with_predictions,
|
||||
"L3.1 frames with predictions",
|
||||
),
|
||||
framesWithVehiclePredictions: integer(
|
||||
metrics.frames_with_vehicle_predictions,
|
||||
"L3.1 frames with vehicles",
|
||||
),
|
||||
predictionCount: integer(
|
||||
metrics.prediction_count,
|
||||
"L3.1 predictions",
|
||||
),
|
||||
classCounts: classCounts(metrics.class_counts, "L3.1 metric classes"),
|
||||
inferenceLatencyMs: distribution(
|
||||
metrics.inference_latency_ms,
|
||||
"L3.1 latency",
|
||||
),
|
||||
poseBindingAgeMs: distribution(
|
||||
metrics.pose_binding_age_ms,
|
||||
"L3.1 pose age",
|
||||
),
|
||||
deterministicReplayFraction: number(
|
||||
metrics.deterministic_replay_fraction,
|
||||
"L3.1 determinism",
|
||||
),
|
||||
deterministicReplayFrames: integer(
|
||||
metrics.deterministic_replay_frames,
|
||||
"L3.1 determinism frames",
|
||||
),
|
||||
},
|
||||
frames,
|
||||
limitations: array(item.limitations, "L3.1 limitations").map(
|
||||
(entry) => string(entry, "L3.1 limitation"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBox(value: unknown): L3VisualBox {
|
||||
const box = record(value, "L3.1 box");
|
||||
const center = [
|
||||
number(box.x_m, "L3.1 box.x", -Infinity),
|
||||
number(box.y_m, "L3.1 box.y", -Infinity),
|
||||
number(box.z_m, "L3.1 box.z", -Infinity),
|
||||
] as const;
|
||||
const size = [
|
||||
number(box.length_m, "L3.1 box.length", Number.MIN_VALUE),
|
||||
number(box.width_m, "L3.1 box.width", Number.MIN_VALUE),
|
||||
number(box.height_m, "L3.1 box.height", Number.MIN_VALUE),
|
||||
] as const;
|
||||
const modelClass = string(box.model_class, "L3.1 box.class");
|
||||
if (!["Vehicle", "Pedestrian", "Cyclist"].includes(modelClass)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 box.class: неизвестен.");
|
||||
}
|
||||
return {
|
||||
benchmarkClass: modelClass,
|
||||
centerXyzM: center,
|
||||
sizeLwhM: size,
|
||||
yawRad: number(box.yaw_rad, "L3.1 box.yaw", -Infinity),
|
||||
status: "model-prediction",
|
||||
score: number(box.score, "L3.1 box.score"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL31PointPillarsRavnoves({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L31PointPillarsRavnovesResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/l31/pointpillars-ravnoves/results?limit=1",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.1 RAVNOVES недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "L3.1 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l31-pointpillars-ravnoves-catalog-results/v1",
|
||||
"L3.1 catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "L3.1 catalog.items");
|
||||
if (items.length > 1) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 catalog: лишние результаты.");
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL31PointPillarsRavnovesFrame(
|
||||
resultId: string,
|
||||
frameId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L31VisualFrame> {
|
||||
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 frame: неверная identity.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l31/pointpillars-ravnoves/${resultId}/frames/${frameId}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.1 frame недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = record(await response.json(), "L3.1 visual frame");
|
||||
exact(
|
||||
payload.schema_version,
|
||||
"missioncore.l31-pointpillars-ravnoves-visual-frame/v1",
|
||||
"L3.1 visual schema",
|
||||
);
|
||||
exact(payload.frame_id, frameId, "L3.1 visual frame_id");
|
||||
const summaryRaw = record(payload.summary, "L3.1 visual summary");
|
||||
const summary = parseSummary(summaryRaw);
|
||||
const points = record(payload.points, "L3.1 points");
|
||||
exact(points.layout, "flat-xyzi", "L3.1 points.layout");
|
||||
const pointValues = array(points.values, "L3.1 points.values").map(
|
||||
(entry, index) => number(entry, `L3.1 points[${index}]`, -Infinity),
|
||||
);
|
||||
const sampledPointCount = integer(
|
||||
points.sampled_point_count,
|
||||
"L3.1 sampled points",
|
||||
);
|
||||
if (sampledPointCount > 12_000 || pointValues.length !== sampledPointCount * 4) {
|
||||
throw new AdvancedLaboratoryContractError("L3.1 points: нарушен bound.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
summary: {
|
||||
...summary,
|
||||
deterministicReplay: boolean(
|
||||
summaryRaw.deterministic_replay,
|
||||
"L3.1 deterministic replay",
|
||||
),
|
||||
replayInferenceMs: number(
|
||||
summaryRaw.replay_inference_ms,
|
||||
"L3.1 replay latency",
|
||||
),
|
||||
visualBoxCount: integer(
|
||||
summaryRaw.visual_box_count,
|
||||
"L3.1 visual box count",
|
||||
),
|
||||
visualBoxTruncated: boolean(
|
||||
summaryRaw.visual_box_truncated,
|
||||
"L3.1 visual box truncated",
|
||||
),
|
||||
},
|
||||
sourcePointCount: integer(
|
||||
points.source_point_count,
|
||||
"L3.1 source points",
|
||||
),
|
||||
modelRangePointCount: integer(
|
||||
points.model_range_point_count,
|
||||
"L3.1 model range points",
|
||||
),
|
||||
sampledPointCount,
|
||||
pointsXyzi: pointValues,
|
||||
truthBoxes: [],
|
||||
predictionBoxes: array(
|
||||
payload.prediction_boxes,
|
||||
"L3.1 prediction boxes",
|
||||
).map(parseBox),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
|
||||
import type { L31ClassCounts } from "./l31PointPillarsRavnoves";
|
||||
|
||||
export interface L32FrameSummary {
|
||||
frameId: string;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourcePointCount: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceMs: number;
|
||||
cameraSourceFrameIndex: number;
|
||||
cameraSessionSeconds: number;
|
||||
cameraDeltaMs: number;
|
||||
}
|
||||
|
||||
export interface L32PointPillarsCameraReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "camera-bound-review-rejects-current-candidate";
|
||||
sourceSessionId: string;
|
||||
sourceL31ResultId: string;
|
||||
sourceE10PackId: string;
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
predictionCount: number;
|
||||
classCounts: L31ClassCounts;
|
||||
inferenceLatencyMs: { p50: number; p95: number; maximum: number };
|
||||
reviewFrameCount: number;
|
||||
reviewPredictionCount: number;
|
||||
reviewVisibleProjectedBoxCount: number;
|
||||
reviewProjectedPointCount: number;
|
||||
scoreBelow025Fraction: number;
|
||||
scoreBelow050Fraction: number;
|
||||
cameraBindingAbsoluteDeltaMs: { p95: number; maximum: number };
|
||||
};
|
||||
frames: readonly L32FrameSummary[];
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface L32ProjectedBox {
|
||||
modelClass: string;
|
||||
score: number;
|
||||
segmentsXyxy: readonly number[];
|
||||
}
|
||||
|
||||
export interface L32VisualFrame {
|
||||
frameId: string;
|
||||
summary: L32FrameSummary;
|
||||
cameraUrl: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
modelRangePointCount: number;
|
||||
pointsXyzi: readonly number[];
|
||||
predictionBoxes: readonly L3VisualBox[];
|
||||
projectedPointsXyd: readonly number[];
|
||||
projectedBoxes: readonly L32ProjectedBox[];
|
||||
}
|
||||
|
||||
export const L32_REVIEW_SCORE_THRESHOLD = 0.25;
|
||||
|
||||
const RESULT_ID = /^l32-pointpillars-camera-review-[a-f0-9]{64}$/;
|
||||
const L31_RESULT_ID = /^l31-pointpillars-ravnoves-[a-f0-9]{64}$/;
|
||||
const PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
|
||||
const FRAME_ID = /^[0-9]{6}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function counts(value: unknown, label: string): L31ClassCounts {
|
||||
const item = record(value, label);
|
||||
if (Object.keys(item).sort().join(",") !== "Cyclist,Pedestrian,Vehicle") {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: классы изменились.`);
|
||||
}
|
||||
return {
|
||||
Vehicle: integer(item.Vehicle, `${label}.Vehicle`),
|
||||
Pedestrian: integer(item.Pedestrian, `${label}.Pedestrian`),
|
||||
Cyclist: integer(item.Cyclist, `${label}.Cyclist`),
|
||||
};
|
||||
}
|
||||
|
||||
function distribution(value: unknown, label: string) {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
p50: number(item.p50, `${label}.p50`),
|
||||
p95: number(item.p95, `${label}.p95`),
|
||||
maximum: number(item.maximum, `${label}.maximum`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L32FrameSummary {
|
||||
const item = record(value, "L3.2 frame");
|
||||
const frameId = string(item.frame_id, "L3.2 frame_id");
|
||||
if (!FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 frame_id: неверный формат.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
frameIndex: integer(item.frame_index, "L3.2 frame index"),
|
||||
sessionSeconds: number(item.session_seconds, "L3.2 session seconds"),
|
||||
sourcePointCount: integer(item.source_point_count, "L3.2 source points"),
|
||||
predictionCount: integer(item.prediction_count, "L3.2 predictions"),
|
||||
classCounts: counts(item.class_counts, "L3.2 classes"),
|
||||
inferenceMs: number(item.inference_ms, "L3.2 inference", Number.MIN_VALUE),
|
||||
cameraSourceFrameIndex: integer(
|
||||
item.camera_source_frame_index,
|
||||
"L3.2 camera frame",
|
||||
),
|
||||
cameraSessionSeconds: number(
|
||||
item.camera_session_seconds,
|
||||
"L3.2 camera seconds",
|
||||
),
|
||||
cameraDeltaMs: number(item.camera_delta_ms, "L3.2 camera delta", -100),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBox(value: unknown): L3VisualBox {
|
||||
const box = record(value, "L3.2 box");
|
||||
const modelClass = string(box.model_class, "L3.2 box class");
|
||||
return {
|
||||
benchmarkClass: modelClass,
|
||||
centerXyzM: [
|
||||
number(box.x_m, "L3.2 box x", -Infinity),
|
||||
number(box.y_m, "L3.2 box y", -Infinity),
|
||||
number(box.z_m, "L3.2 box z", -Infinity),
|
||||
],
|
||||
sizeLwhM: [
|
||||
number(box.length_m, "L3.2 box length", Number.MIN_VALUE),
|
||||
number(box.width_m, "L3.2 box width", Number.MIN_VALUE),
|
||||
number(box.height_m, "L3.2 box height", Number.MIN_VALUE),
|
||||
],
|
||||
yawRad: number(box.yaw_rad, "L3.2 box yaw", -Infinity),
|
||||
status: "model-prediction",
|
||||
score: number(box.score, "L3.2 box score"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L32PointPillarsCameraReviewResult {
|
||||
const item = record(value, "L3.2 result");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l32-pointpillars-camera-review-result/v1",
|
||||
"L3.2 schema",
|
||||
);
|
||||
exact(item.access, "read-only", "L3.2 access");
|
||||
const resultId = string(item.result_id, "L3.2 result id");
|
||||
const sourceL31ResultId = string(item.source_l31_result_id, "L3.2 L3.1 id");
|
||||
const sourceE10PackId = string(item.source_e10_pack_id, "L3.2 E10 id");
|
||||
if (
|
||||
!RESULT_ID.test(resultId)
|
||||
|| !L31_RESULT_ID.test(sourceL31ResultId)
|
||||
|| !PACK_ID.test(sourceE10PackId)
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2: неверная identity.");
|
||||
}
|
||||
const metrics = record(item.metrics, "L3.2 metrics");
|
||||
const cameraDelta = distribution(
|
||||
metrics.camera_binding_absolute_delta_ms,
|
||||
"L3.2 camera delta",
|
||||
);
|
||||
const frames = array(item.frames, "L3.2 frames").map(parseSummary);
|
||||
if (!frames.length || frames.length > 18) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 frames: нарушен bound.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.2 created at"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"camera-bound-review-rejects-current-candidate",
|
||||
"L3.2 status",
|
||||
),
|
||||
sourceSessionId: exact(
|
||||
item.source_session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"L3.2 session",
|
||||
),
|
||||
sourceL31ResultId,
|
||||
sourceE10PackId,
|
||||
metrics: {
|
||||
frameCount: integer(metrics.frame_count, "L3.2 frame count"),
|
||||
predictionCount: integer(metrics.prediction_count, "L3.2 predictions"),
|
||||
classCounts: counts(metrics.class_counts, "L3.2 metric classes"),
|
||||
inferenceLatencyMs: distribution(
|
||||
metrics.inference_latency_ms,
|
||||
"L3.2 inference latency",
|
||||
),
|
||||
reviewFrameCount: integer(metrics.review_frame_count, "L3.2 review frames"),
|
||||
reviewPredictionCount: integer(
|
||||
metrics.review_prediction_count,
|
||||
"L3.2 review predictions",
|
||||
),
|
||||
reviewVisibleProjectedBoxCount: integer(
|
||||
metrics.review_visible_projected_box_count,
|
||||
"L3.2 visible boxes",
|
||||
),
|
||||
reviewProjectedPointCount: integer(
|
||||
metrics.review_projected_point_count,
|
||||
"L3.2 projected points",
|
||||
),
|
||||
scoreBelow025Fraction: number(
|
||||
metrics.score_below_0_25_fraction,
|
||||
"L3.2 low score 025",
|
||||
),
|
||||
scoreBelow050Fraction: number(
|
||||
metrics.score_below_0_50_fraction,
|
||||
"L3.2 low score 050",
|
||||
),
|
||||
cameraBindingAbsoluteDeltaMs: cameraDelta,
|
||||
},
|
||||
frames,
|
||||
limitations: array(item.limitations, "L3.2 limitations").map(
|
||||
(entry) => string(entry, "L3.2 limitation"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL32PointPillarsCameraReview({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L32PointPillarsCameraReviewResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/l32/pointpillars-camera-review/results?limit=1",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.2 RAVNOVES недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "L3.2 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l32-pointpillars-camera-review-catalog-results/v1",
|
||||
"L3.2 catalog schema",
|
||||
);
|
||||
const items = array(catalog.items, "L3.2 catalog items");
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL32PointPillarsCameraReviewFrame(
|
||||
resultId: string,
|
||||
frameId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L32VisualFrame> {
|
||||
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 frame: неверная identity.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l32/pointpillars-camera-review/${resultId}/frames/${frameId}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(
|
||||
`L3.2 frame недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const item = record(await response.json(), "L3.2 visual frame");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l32-pointpillars-camera-review-frame/v1",
|
||||
"L3.2 frame schema",
|
||||
);
|
||||
const summary = parseSummary(item.summary);
|
||||
const camera = record(item.camera, "L3.2 camera");
|
||||
const points = record(item.points, "L3.2 points");
|
||||
exact(points.layout, "flat-xyzi", "L3.2 point layout");
|
||||
const values = array(points.values, "L3.2 point values").map(
|
||||
(entry, index) => number(entry, `L3.2 points[${index}]`, -Infinity),
|
||||
);
|
||||
const projection = record(item.camera_projection, "L3.2 projection");
|
||||
exact(projection.point_layout, "flat-xy-depth-m", "L3.2 projection layout");
|
||||
const projected = array(projection.point_values, "L3.2 projected points").map(
|
||||
(entry, index) => number(entry, `L3.2 projected[${index}]`, -Infinity),
|
||||
);
|
||||
if (projected.length !== integer(projection.point_count, "L3.2 point count") * 3) {
|
||||
throw new AdvancedLaboratoryContractError("L3.2 projection: нарушен размер.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
summary,
|
||||
cameraUrl: `/api/v1/laboratory/l32/pointpillars-camera-review/${resultId}/frames/${frameId}/camera`,
|
||||
cameraWidth: integer(camera.width, "L3.2 camera width"),
|
||||
cameraHeight: integer(camera.height, "L3.2 camera height"),
|
||||
modelRangePointCount: integer(
|
||||
points.model_range_point_count,
|
||||
"L3.2 model range points",
|
||||
),
|
||||
pointsXyzi: values,
|
||||
predictionBoxes: array(item.prediction_boxes, "L3.2 boxes").map(parseBox),
|
||||
projectedPointsXyd: projected,
|
||||
projectedBoxes: array(projection.boxes, "L3.2 projected boxes").map(
|
||||
(entry) => {
|
||||
const box = record(entry, "L3.2 projected box");
|
||||
return {
|
||||
modelClass: string(box.model_class, "L3.2 projected class"),
|
||||
score: number(box.score, "L3.2 projected score"),
|
||||
segmentsXyxy: array(box.segments_xyxy, "L3.2 projected segments").map(
|
||||
(value, index) => number(value, `L3.2 segment[${index}]`, -Infinity),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
AdvancedLaboratoryContractError,
|
||||
type LaboratoryFetch,
|
||||
} from "./advancedResults";
|
||||
import type { L3VisualBox } from "./l3PointPillarsVisualAudit";
|
||||
|
||||
export type L33SemanticProvenance =
|
||||
| "recorded-current"
|
||||
| "bounded-track-interpolation"
|
||||
| "rectified-detector-current"
|
||||
| "e23-temporal-hold";
|
||||
|
||||
export interface L33FrameSummary {
|
||||
frameId: string;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
cameraSourceFrameIndex: number;
|
||||
cameraSessionSeconds: number;
|
||||
cameraDeltaMs: number;
|
||||
sourcePointCount: number;
|
||||
detectionCount: number;
|
||||
rangedDetectionCount: number;
|
||||
cameraOnlyDetectionCount: number;
|
||||
cuboidCount: number;
|
||||
semanticProvenance: L33SemanticProvenance;
|
||||
}
|
||||
|
||||
export interface L33CameraFirstDetectorReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "camera-first-boundary-restored-shadow-only";
|
||||
sourceSessionId: string;
|
||||
sourceL32ResultId: string;
|
||||
sourceE26ResultId: string;
|
||||
sourceE29ResultId: string;
|
||||
sourceE10PackId: string;
|
||||
sourceWorldStateResultId: string;
|
||||
detector: {
|
||||
id: string;
|
||||
architecture: string;
|
||||
classes: string;
|
||||
modelSha256: string;
|
||||
inputShape: readonly number[];
|
||||
};
|
||||
metrics: {
|
||||
reviewFrameCount: number;
|
||||
recordedCurrentFrameCount: number;
|
||||
recoveredCameraFrameCount: number;
|
||||
semanticFrameCoverageFraction: number;
|
||||
detectionCount: number;
|
||||
rangedDetectionCount: number;
|
||||
cameraOnlyDetectionCount: number;
|
||||
rangeCoverageFraction: number;
|
||||
minimumDetectorScore: number;
|
||||
cuboidCount: number;
|
||||
routeDetectionCount: number;
|
||||
routeDetectionFrameCount: number;
|
||||
framesProcessed: number;
|
||||
effectiveComposedFps: number;
|
||||
composedP95Ms: number;
|
||||
semanticEffectiveFps: number;
|
||||
lidarFusedFrames: number;
|
||||
rawAcceptedCuboids: number;
|
||||
duplicateSupportRejections: number;
|
||||
temporalAcceptedCuboids: number;
|
||||
worldObjectObservations: number;
|
||||
classCounts: Readonly<Record<string, number>>;
|
||||
};
|
||||
frames: readonly L33FrameSummary[];
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface L33Detection {
|
||||
sourceTrackId: number;
|
||||
label: string;
|
||||
associationGroup: string;
|
||||
score: number;
|
||||
bboxXyxy: readonly [number, number, number, number];
|
||||
semanticProvenance: L33SemanticProvenance;
|
||||
geometryStatus: string;
|
||||
geometryReason: string;
|
||||
rangeM: number | null;
|
||||
sourceE29OpticalDepthM: number | null;
|
||||
support: {
|
||||
projectedPoints: number;
|
||||
occupiedPoints: number;
|
||||
connectedOccupiedPoints: number;
|
||||
};
|
||||
geometryAnchorXyzM: readonly [number, number, number] | null;
|
||||
cameraRayLidar: {
|
||||
originXyzM: readonly [number, number, number];
|
||||
directionXyz: readonly [number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
export interface L33VisualFrame {
|
||||
frameId: string;
|
||||
summary: L33FrameSummary;
|
||||
cameraUrl: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
pointsXyzi: readonly number[];
|
||||
projectedPointsXyd: readonly number[];
|
||||
detections: readonly L33Detection[];
|
||||
predictionBoxes: readonly L3VisualBox[];
|
||||
}
|
||||
|
||||
const RESULT_ID = /^l33-camera-first-detector-review-[a-f0-9]{64}$/;
|
||||
const SOURCE_ID = /^(?:l32-pointpillars-camera-review|e10-integrated-perception|e29-camera-geometry|e10-lidar-pack|rectified-camera-world-state)-[a-f0-9]{64}$/;
|
||||
const FRAME_ID = /^[0-9]{6}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function provenance(value: unknown, label: string): L33SemanticProvenance {
|
||||
if (
|
||||
value !== "recorded-current"
|
||||
&& value !== "bounded-track-interpolation"
|
||||
&& value !== "rectified-detector-current"
|
||||
&& value !== "e23-temporal-hold"
|
||||
) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неизвестный источник.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function tuple(
|
||||
value: unknown,
|
||||
length: number,
|
||||
label: string,
|
||||
minimum = -Infinity,
|
||||
): readonly number[] {
|
||||
const values = array(value, label).map((item, index) => (
|
||||
number(item, `${label}[${index}]`, minimum)
|
||||
));
|
||||
if (values.length !== length) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверная размерность.`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function sourceId(value: unknown, label: string): string {
|
||||
const parsed = string(value, label);
|
||||
if (!SOURCE_ID.test(parsed)) {
|
||||
throw new AdvancedLaboratoryContractError(`${label}: неверная identity.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): L33FrameSummary {
|
||||
const item = record(value, "L3.3 frame");
|
||||
const frameId = string(item.frame_id, "L3.3 frame id");
|
||||
if (!FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 frame id: неверный формат.");
|
||||
}
|
||||
return {
|
||||
frameId,
|
||||
frameIndex: integer(item.frame_index, "L3.3 frame index"),
|
||||
sessionSeconds: number(item.session_seconds, "L3.3 session seconds"),
|
||||
cameraSourceFrameIndex: integer(item.camera_source_frame_index, "L3.3 camera frame"),
|
||||
cameraSessionSeconds: number(item.camera_session_seconds, "L3.3 camera seconds"),
|
||||
cameraDeltaMs: number(item.camera_delta_ms, "L3.3 camera delta", -100),
|
||||
sourcePointCount: integer(item.source_point_count, "L3.3 source points"),
|
||||
detectionCount: integer(item.detection_count, "L3.3 detections"),
|
||||
rangedDetectionCount: integer(item.ranged_detection_count, "L3.3 ranged detections"),
|
||||
cameraOnlyDetectionCount: integer(item.camera_only_detection_count, "L3.3 camera-only detections"),
|
||||
cuboidCount: item.cuboid_count === undefined
|
||||
? 0
|
||||
: integer(item.cuboid_count, "L3.3 cuboids"),
|
||||
semanticProvenance: provenance(item.semantic_provenance, "L3.3 provenance"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePredictionBox(value: unknown): L3VisualBox {
|
||||
const item = record(value, "L3.3 cuboid");
|
||||
exact(item.status, "model-prediction", "L3.3 cuboid status");
|
||||
return {
|
||||
benchmarkClass: string(item.benchmark_class, "L3.3 cuboid class"),
|
||||
centerXyzM: tuple(item.center_xyz_m, 3, "L3.3 cuboid center", -Infinity) as [number, number, number],
|
||||
sizeLwhM: tuple(item.size_lwh_m, 3, "L3.3 cuboid size", Number.MIN_VALUE) as [number, number, number],
|
||||
yawRad: number(item.yaw_rad, "L3.3 cuboid yaw", -Infinity),
|
||||
status: "model-prediction",
|
||||
score: number(item.score, "L3.3 cuboid score"),
|
||||
temporalStatus: string(item.temporal_status, "L3.3 cuboid temporal status"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDetection(value: unknown): L33Detection {
|
||||
const item = record(value, "L3.3 detection");
|
||||
const support = record(item.support, "L3.3 support");
|
||||
const cameraRay = record(item.camera_ray_lidar, "L3.3 camera ray");
|
||||
return {
|
||||
sourceTrackId: integer(item.source_track_id, "L3.3 track"),
|
||||
label: string(item.label, "L3.3 label"),
|
||||
associationGroup: string(item.association_group, "L3.3 group"),
|
||||
score: number(item.score, "L3.3 score", 0.25),
|
||||
bboxXyxy: tuple(item.bbox_xyxy, 4, "L3.3 bbox", -Infinity) as [number, number, number, number],
|
||||
semanticProvenance: provenance(item.semantic_provenance, "L3.3 detection provenance"),
|
||||
geometryStatus: string(item.geometry_status, "L3.3 geometry status"),
|
||||
geometryReason: string(item.geometry_reason, "L3.3 geometry reason"),
|
||||
rangeM: item.range_m === null ? null : number(item.range_m, "L3.3 range"),
|
||||
sourceE29OpticalDepthM: item.source_e29_optical_depth_m === null
|
||||
? null
|
||||
: number(item.source_e29_optical_depth_m, "L3.3 E29 optical depth"),
|
||||
support: {
|
||||
projectedPoints: integer(support.projected_points, "L3.3 projected support"),
|
||||
occupiedPoints: integer(support.occupied_points, "L3.3 occupied support"),
|
||||
connectedOccupiedPoints: integer(support.connected_occupied_points, "L3.3 connected support"),
|
||||
},
|
||||
geometryAnchorXyzM: item.geometry_anchor_xyz_m === null
|
||||
? null
|
||||
: tuple(item.geometry_anchor_xyz_m, 3, "L3.3 geometry anchor", -Infinity) as [number, number, number],
|
||||
cameraRayLidar: {
|
||||
originXyzM: tuple(cameraRay.origin_xyz_m, 3, "L3.3 ray origin", -Infinity) as [number, number, number],
|
||||
directionXyz: tuple(cameraRay.direction_xyz, 3, "L3.3 ray direction", -Infinity) as [number, number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): L33CameraFirstDetectorReviewResult {
|
||||
const item = record(value, "L3.3 result");
|
||||
exact(item.schema_version, "missioncore.l33-camera-first-detector-review-result/v1", "L3.3 schema");
|
||||
exact(item.access, "read-only", "L3.3 access");
|
||||
const resultId = string(item.result_id, "L3.3 result id");
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 result: неверная identity.");
|
||||
}
|
||||
const detector = record(item.detector, "L3.3 detector");
|
||||
const metrics = record(item.metrics, "L3.3 metrics");
|
||||
const classCounts = record(metrics.class_counts, "L3.3 class counts");
|
||||
const frames = array(item.frames, "L3.3 frames").map(parseSummary);
|
||||
if (!frames.length || frames.length > 18) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 frames: нарушен bound.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.3 created at"),
|
||||
status: exact(item.status, "camera-first-boundary-restored-shadow-only", "L3.3 status"),
|
||||
sourceSessionId: exact(item.source_session_id, "20260720T065719Z_viewer_live", "L3.3 session"),
|
||||
sourceL32ResultId: sourceId(item.source_l32_result_id, "L3.3 L3.2 id"),
|
||||
sourceE26ResultId: sourceId(item.source_e26_result_id, "L3.3 E26 id"),
|
||||
sourceE29ResultId: sourceId(item.source_e29_result_id, "L3.3 E29 id"),
|
||||
sourceE10PackId: sourceId(item.source_e10_pack_id, "L3.3 E10 id"),
|
||||
sourceWorldStateResultId: sourceId(item.source_world_state_result_id, "L3.3 world-state id"),
|
||||
detector: {
|
||||
id: string(detector.id, "L3.3 detector id"),
|
||||
architecture: string(detector.architecture, "L3.3 detector architecture"),
|
||||
classes: string(detector.classes, "L3.3 detector classes"),
|
||||
modelSha256: string(detector.model_sha256, "L3.3 detector sha"),
|
||||
inputShape: tuple(detector.input_shape, 4, "L3.3 detector shape"),
|
||||
},
|
||||
metrics: {
|
||||
reviewFrameCount: integer(metrics.review_frame_count, "L3.3 review frames"),
|
||||
recordedCurrentFrameCount: integer(metrics.recorded_current_frame_count, "L3.3 current frames"),
|
||||
recoveredCameraFrameCount: integer(metrics.recovered_camera_frame_count, "L3.3 recovered frames"),
|
||||
semanticFrameCoverageFraction: number(metrics.semantic_frame_coverage_fraction, "L3.3 semantic coverage"),
|
||||
detectionCount: integer(metrics.detection_count, "L3.3 detections"),
|
||||
rangedDetectionCount: integer(metrics.ranged_detection_count, "L3.3 ranged"),
|
||||
cameraOnlyDetectionCount: integer(metrics.camera_only_detection_count, "L3.3 camera only"),
|
||||
rangeCoverageFraction: number(metrics.range_coverage_fraction, "L3.3 range coverage"),
|
||||
minimumDetectorScore: number(metrics.minimum_detector_score, "L3.3 threshold"),
|
||||
cuboidCount: integer(metrics.cuboid_count, "L3.3 selected cuboids"),
|
||||
routeDetectionCount: integer(metrics.route_detection_count, "L3.3 route detections"),
|
||||
routeDetectionFrameCount: integer(metrics.route_detection_frame_count, "L3.3 route detection frames"),
|
||||
framesProcessed: integer(metrics.frames_processed, "L3.3 processed frames"),
|
||||
effectiveComposedFps: number(metrics.effective_composed_fps, "L3.3 composed FPS", Number.MIN_VALUE),
|
||||
composedP95Ms: number(metrics.composed_p95_ms, "L3.3 composed p95", Number.MIN_VALUE),
|
||||
semanticEffectiveFps: number(metrics.semantic_effective_fps, "L3.3 semantic FPS", Number.MIN_VALUE),
|
||||
lidarFusedFrames: integer(metrics.lidar_fused_frames, "L3.3 fused frames"),
|
||||
rawAcceptedCuboids: integer(metrics.raw_accepted_cuboids, "L3.3 raw cuboids"),
|
||||
duplicateSupportRejections: integer(
|
||||
metrics.duplicate_support_rejections,
|
||||
"L3.3 duplicate support rejections",
|
||||
),
|
||||
temporalAcceptedCuboids: integer(metrics.temporal_accepted_cuboids, "L3.3 temporal cuboids"),
|
||||
worldObjectObservations: integer(metrics.world_object_observations, "L3.3 world objects"),
|
||||
classCounts: Object.fromEntries(
|
||||
Object.entries(classCounts).map(([key, entry]) => [key, integer(entry, `L3.3 class ${key}`)]),
|
||||
),
|
||||
},
|
||||
frames,
|
||||
limitations: array(item.limitations, "L3.3 limitations").map((entry) => string(entry, "L3.3 limitation")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL33CameraFirstDetectorReview({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L33CameraFirstDetectorReviewResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/l33/camera-first-detector-review/results?limit=1",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(`L3.3 недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const catalog = record(await response.json(), "L3.3 catalog");
|
||||
exact(catalog.schema_version, "missioncore.l33-camera-first-detector-review-catalog-results/v1", "L3.3 catalog schema");
|
||||
const items = array(catalog.items, "L3.3 catalog items");
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL33CameraFirstDetectorReviewFrame(
|
||||
resultId: string,
|
||||
frameId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L33VisualFrame> {
|
||||
if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 frame: неверная identity.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l33/camera-first-detector-review/${resultId}/frames/${frameId}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdvancedLaboratoryContractError(`L3.3 frame недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
const item = record(await response.json(), "L3.3 visual frame");
|
||||
exact(item.schema_version, "missioncore.l33-camera-first-detector-review-frame/v1", "L3.3 frame schema");
|
||||
const summary = parseSummary(item.summary);
|
||||
const camera = record(item.camera, "L3.3 camera");
|
||||
const points = record(item.points, "L3.3 points");
|
||||
exact(points.layout, "flat-xyzi", "L3.3 point layout");
|
||||
const projection = record(item.camera_projection, "L3.3 projection");
|
||||
exact(projection.point_layout, "flat-xy-depth-m", "L3.3 projection layout");
|
||||
const projected = array(projection.point_values, "L3.3 projected points").map(
|
||||
(entry, index) => number(entry, `L3.3 projected[${index}]`, -Infinity),
|
||||
);
|
||||
if (projected.length !== integer(projection.point_count, "L3.3 projected count") * 3) {
|
||||
throw new AdvancedLaboratoryContractError("L3.3 projection: нарушен размер.");
|
||||
}
|
||||
const detections = array(item.detections, "L3.3 detections").map(parseDetection);
|
||||
const predictionBoxes = array(item.prediction_boxes, "L3.3 cuboids").map(parsePredictionBox);
|
||||
return {
|
||||
frameId,
|
||||
summary,
|
||||
cameraUrl: `/api/v1/laboratory/l33/camera-first-detector-review/${resultId}/frames/${frameId}/camera`,
|
||||
cameraWidth: integer(camera.width, "L3.3 camera width"),
|
||||
cameraHeight: integer(camera.height, "L3.3 camera height"),
|
||||
pointsXyzi: array(points.values, "L3.3 point values").map(
|
||||
(entry, index) => number(entry, `L3.3 points[${index}]`, -Infinity),
|
||||
),
|
||||
projectedPointsXyd: projected,
|
||||
detections,
|
||||
predictionBoxes,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
export interface L34FrameSummary {
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
predictionCount: number;
|
||||
maximumScore: number;
|
||||
}
|
||||
|
||||
export type L34PredictionLabel =
|
||||
| "person"
|
||||
| "bicycle"
|
||||
| "motorcycle"
|
||||
| "car"
|
||||
| "heavy_vehicle"
|
||||
| "static_obstacle"
|
||||
| "animal";
|
||||
|
||||
export interface L34Prediction {
|
||||
label: L34PredictionLabel;
|
||||
score: number;
|
||||
bboxXyxy: readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface L34VisualFrame {
|
||||
resultId: string;
|
||||
truthIslandSequence: number;
|
||||
imageId: number;
|
||||
frameIndex: number;
|
||||
groupId: string;
|
||||
role: "anchor" | "temporal";
|
||||
sessionSeconds: number;
|
||||
cameraUrl: string;
|
||||
cameraWidth: number;
|
||||
cameraHeight: number;
|
||||
cameraSha256: string;
|
||||
predictionRowsSha256: string;
|
||||
predictions: readonly L34Prediction[];
|
||||
truthLabelsRead: false;
|
||||
}
|
||||
|
||||
export interface L34RightYoloxTruthIslandResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
status: "predictions-frozen-awaiting-independent-truth";
|
||||
profileId: "RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1";
|
||||
pipelineId: string;
|
||||
sourceSessionId: string;
|
||||
cameraSourceId: "sensor.camera.right";
|
||||
candidate: {
|
||||
architecture: "YOLOX-S";
|
||||
modelSha256: string;
|
||||
minimumScore: number;
|
||||
l33ResultId: string;
|
||||
};
|
||||
truthIsland: {
|
||||
resultId: string;
|
||||
truthState: "labels-unavailable";
|
||||
};
|
||||
metrics: {
|
||||
frameCount: number;
|
||||
temporalGroupCount: number;
|
||||
predictionCount: number;
|
||||
framesWithPredictions: number;
|
||||
classCounts: Readonly<Record<string, number>>;
|
||||
accuracyMetricsAvailable: false;
|
||||
};
|
||||
frames: readonly L34FrameSummary[];
|
||||
decision: {
|
||||
candidatePredictionsFrozen: true;
|
||||
truthLabelsRead: false;
|
||||
candidateAccepted: false;
|
||||
modelRetrainingAuthorized: false;
|
||||
nextGate: string;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
class L34ContractError extends Error {}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function object(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new L34ContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new L34ContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new L34ContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new L34ContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finiteNumber(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum = 0,
|
||||
): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) {
|
||||
throw new L34ContractError(`${label}: ожидалось конечное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T extends string | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) throw new L34ContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function predictionLabel(value: unknown, label: string): L34PredictionLabel {
|
||||
if (
|
||||
value !== "person"
|
||||
&& value !== "bicycle"
|
||||
&& value !== "motorcycle"
|
||||
&& value !== "car"
|
||||
&& value !== "heavy_vehicle"
|
||||
&& value !== "static_obstacle"
|
||||
&& value !== "animal"
|
||||
) {
|
||||
throw new L34ContractError(`${label}: неизвестный класс.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFrameSummary(value: unknown): L34FrameSummary {
|
||||
const item = object(value, "L3.4 frame summary");
|
||||
const maximumScore = finiteNumber(
|
||||
item.maximum_score,
|
||||
"L3.4 frame maximum score",
|
||||
);
|
||||
if (maximumScore > 1) {
|
||||
throw new L34ContractError("L3.4 frame maximum score: нарушен диапазон.");
|
||||
}
|
||||
return {
|
||||
truthIslandSequence: integer(
|
||||
item.truth_island_sequence,
|
||||
"L3.4 frame sequence",
|
||||
),
|
||||
imageId: integer(item.image_id, "L3.4 frame image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4 frame index"),
|
||||
groupId: string(item.group_id, "L3.4 frame group"),
|
||||
predictionCount: integer(
|
||||
item.prediction_count,
|
||||
"L3.4 frame predictions",
|
||||
),
|
||||
maximumScore,
|
||||
};
|
||||
}
|
||||
|
||||
function parse(itemValue: unknown): L34RightYoloxTruthIslandResult {
|
||||
const item = object(itemValue, "L3.4");
|
||||
const candidate = object(item.candidate, "L3.4.candidate");
|
||||
const truthIsland = object(item.truth_island, "L3.4.truth_island");
|
||||
const metrics = object(item.metrics, "L3.4.metrics");
|
||||
const classCounts = object(metrics.class_counts, "L3.4.metrics.class_counts");
|
||||
const decision = object(item.decision, "L3.4.decision");
|
||||
const frames = array(item.frames, "L3.4.frames").map(parseFrameSummary);
|
||||
const resultId = string(item.result_id, "L3.4.result_id");
|
||||
const modelSha256 = string(candidate.model_sha256, "L3.4.candidate.model_sha256");
|
||||
if (
|
||||
!/^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$/.test(resultId)
|
||||
|| !/^[a-f0-9]{64}$/.test(modelSha256)
|
||||
) {
|
||||
throw new L34ContractError("L3.4: нарушена content identity.");
|
||||
}
|
||||
const frameCount = integer(metrics.frame_count, "L3.4.metrics.frame_count");
|
||||
const predictionCount = integer(
|
||||
metrics.prediction_count,
|
||||
"L3.4.metrics.prediction_count",
|
||||
);
|
||||
const framesWithPredictions = integer(
|
||||
metrics.frames_with_predictions,
|
||||
"L3.4.metrics.frames_with_predictions",
|
||||
);
|
||||
if (
|
||||
!frames.length
|
||||
|| frames.length > 32
|
||||
|| frames.length !== frameCount
|
||||
|| new Set(frames.map(({ truthIslandSequence }) => truthIslandSequence)).size
|
||||
!== frames.length
|
||||
|| frames.reduce((total, frame) => total + frame.predictionCount, 0)
|
||||
!== predictionCount
|
||||
|| frames.filter(({ predictionCount: count }) => count > 0).length
|
||||
!== framesWithPredictions
|
||||
) {
|
||||
throw new L34ContractError("L3.4.frames: нарушена визуальная связность.");
|
||||
}
|
||||
const counts = Object.fromEntries(Object.entries(classCounts).map(([key, value]) => (
|
||||
[key, integer(value, `L3.4.metrics.class_counts.${key}`)]
|
||||
)));
|
||||
const limitations = item.limitations;
|
||||
if (!Array.isArray(limitations) || limitations.some((value) => typeof value !== "string")) {
|
||||
throw new L34ContractError("L3.4.limitations: ожидался список строк.");
|
||||
}
|
||||
const minimumScore = candidate.minimum_score;
|
||||
if (typeof minimumScore !== "number" || minimumScore !== 0.25) {
|
||||
throw new L34ContractError("L3.4.candidate.minimum_score: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
createdAtUtc: string(item.created_at_utc, "L3.4.created_at_utc"),
|
||||
status: exact(
|
||||
item.status,
|
||||
"predictions-frozen-awaiting-independent-truth",
|
||||
"L3.4.status",
|
||||
),
|
||||
profileId: exact(
|
||||
item.profile_id,
|
||||
"RAVNOVES00_RIGHT_YOLOX_TRUTH_ISLAND_V1",
|
||||
"L3.4.profile_id",
|
||||
),
|
||||
pipelineId: string(item.pipeline_id, "L3.4.pipeline_id"),
|
||||
sourceSessionId: string(item.source_session_id, "L3.4.source_session_id"),
|
||||
cameraSourceId: exact(
|
||||
item.camera_source_id,
|
||||
"sensor.camera.right",
|
||||
"L3.4.camera_source_id",
|
||||
),
|
||||
candidate: {
|
||||
architecture: exact(candidate.architecture, "YOLOX-S", "L3.4.architecture"),
|
||||
modelSha256,
|
||||
minimumScore,
|
||||
l33ResultId: string(candidate.l33_result_id, "L3.4.candidate.l33_result_id"),
|
||||
},
|
||||
truthIsland: {
|
||||
resultId: string(truthIsland.result_id, "L3.4.truth_island.result_id"),
|
||||
truthState: exact(
|
||||
truthIsland.truth_state,
|
||||
"labels-unavailable",
|
||||
"L3.4.truth_island.truth_state",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
frameCount,
|
||||
temporalGroupCount: integer(
|
||||
metrics.temporal_group_count,
|
||||
"L3.4.metrics.temporal_group_count",
|
||||
),
|
||||
predictionCount,
|
||||
framesWithPredictions,
|
||||
classCounts: counts,
|
||||
accuracyMetricsAvailable: exact(
|
||||
metrics.accuracy_metrics_available,
|
||||
false,
|
||||
"L3.4.metrics.accuracy_metrics_available",
|
||||
),
|
||||
},
|
||||
frames,
|
||||
decision: {
|
||||
candidatePredictionsFrozen: exact(
|
||||
decision.candidate_predictions_frozen,
|
||||
true,
|
||||
"L3.4.decision.candidate_predictions_frozen",
|
||||
),
|
||||
truthLabelsRead: exact(
|
||||
decision.truth_labels_read,
|
||||
false,
|
||||
"L3.4.decision.truth_labels_read",
|
||||
),
|
||||
candidateAccepted: exact(
|
||||
decision.candidate_accepted,
|
||||
false,
|
||||
"L3.4.decision.candidate_accepted",
|
||||
),
|
||||
modelRetrainingAuthorized: exact(
|
||||
decision.model_retraining_authorized,
|
||||
false,
|
||||
"L3.4.decision.model_retraining_authorized",
|
||||
),
|
||||
nextGate: string(decision.next_gate, "L3.4.decision.next_gate"),
|
||||
},
|
||||
limitations: limitations as string[],
|
||||
access: exact(item.access, "read-only", "L3.4.access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseVisualFrame(
|
||||
value: unknown,
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
): L34VisualFrame {
|
||||
const item = object(value, "L3.4 visual frame");
|
||||
exact(
|
||||
item.schema_version,
|
||||
"missioncore.l34-right-yolox-truth-island-frame/v1",
|
||||
"L3.4 frame schema",
|
||||
);
|
||||
exact(item.access, "read-only", "L3.4 frame access");
|
||||
exact(item.truth_labels_read, false, "L3.4 frame truth state");
|
||||
if (
|
||||
item.result_id !== resultId
|
||||
|| item.truth_island_sequence !== sequence
|
||||
|| !Number.isInteger(sequence)
|
||||
|| sequence < 1
|
||||
|| sequence > 32
|
||||
) {
|
||||
throw new L34ContractError("L3.4 visual frame: нарушена identity.");
|
||||
}
|
||||
const camera = object(item.camera, "L3.4 frame camera");
|
||||
const cameraWidth = integer(camera.width, "L3.4 camera width");
|
||||
const cameraHeight = integer(camera.height, "L3.4 camera height");
|
||||
const cameraSha256 = string(camera.sha256, "L3.4 camera sha256");
|
||||
const predictionRowsSha256 = string(
|
||||
item.prediction_rows_sha256,
|
||||
"L3.4 prediction rows sha256",
|
||||
);
|
||||
if (
|
||||
!/^[a-f0-9]{64}$/.test(cameraSha256)
|
||||
|| !/^[a-f0-9]{64}$/.test(predictionRowsSha256)
|
||||
) {
|
||||
throw new L34ContractError("L3.4 visual frame: нарушена content identity.");
|
||||
}
|
||||
const predictions = array(item.predictions, "L3.4 frame predictions").map(
|
||||
(value, index): L34Prediction => {
|
||||
const prediction = object(value, `L3.4 prediction ${index}`);
|
||||
const score = finiteNumber(prediction.score, `L3.4 prediction ${index} score`, 0.25);
|
||||
const bbox = array(prediction.bbox_xyxy, `L3.4 prediction ${index} bbox`).map(
|
||||
(entry, coordinate) => finiteNumber(
|
||||
entry,
|
||||
`L3.4 prediction ${index} bbox ${coordinate}`,
|
||||
),
|
||||
);
|
||||
if (
|
||||
score > 1
|
||||
|| bbox.length !== 4
|
||||
|| !(bbox[0] < bbox[2] && bbox[2] <= cameraWidth)
|
||||
|| !(bbox[1] < bbox[3] && bbox[3] <= cameraHeight)
|
||||
) {
|
||||
throw new L34ContractError(`L3.4 prediction ${index}: нарушена геометрия.`);
|
||||
}
|
||||
return {
|
||||
label: predictionLabel(prediction.label, `L3.4 prediction ${index} label`),
|
||||
score,
|
||||
bboxXyxy: bbox as [number, number, number, number],
|
||||
};
|
||||
},
|
||||
);
|
||||
const role = item.role;
|
||||
if (role !== "anchor" && role !== "temporal") {
|
||||
throw new L34ContractError("L3.4 frame role: нарушен контракт.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
truthIslandSequence: sequence,
|
||||
imageId: integer(item.image_id, "L3.4 frame image"),
|
||||
frameIndex: integer(item.frame_index, "L3.4 frame index"),
|
||||
groupId: string(item.group_id, "L3.4 frame group"),
|
||||
role,
|
||||
sessionSeconds: finiteNumber(item.session_seconds, "L3.4 frame time"),
|
||||
cameraUrl: `/api/v1/laboratory/l34/results/${resultId}/frames/${sequence}/camera`,
|
||||
cameraWidth,
|
||||
cameraHeight,
|
||||
cameraSha256,
|
||||
predictionRowsSha256,
|
||||
predictions,
|
||||
truthLabelsRead: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchL34RightYoloxTruthIsland({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<L34RightYoloxTruthIslandResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/l34/results?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new L34ContractError(`L3.4 недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "L3.4 catalog");
|
||||
exact(
|
||||
catalog.schema_version,
|
||||
"missioncore.l34-right-yolox-truth-island-catalog/v1",
|
||||
"L3.4 catalog.schema_version",
|
||||
);
|
||||
exact(catalog.access, "read-only", "L3.4 catalog.access");
|
||||
if (!Array.isArray(catalog.items) || catalog.items.length > 1) {
|
||||
throw new L34ContractError("L3.4 catalog.items: нарушен размер.");
|
||||
}
|
||||
return catalog.items.length ? parse(catalog.items[0]) : null;
|
||||
}
|
||||
|
||||
export async function fetchL34RightYoloxTruthIslandFrame(
|
||||
resultId: string,
|
||||
sequence: number,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<L34VisualFrame> {
|
||||
if (!/^l34-right-yolox-truth-island-freeze-[a-f0-9]{64}$/.test(resultId)) {
|
||||
throw new L34ContractError("L3.4 visual frame: неверный result id.");
|
||||
}
|
||||
if (!Number.isInteger(sequence) || sequence < 1 || sequence > 32) {
|
||||
throw new L34ContractError("L3.4 visual frame: неверный sequence.");
|
||||
}
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/l34/results/${resultId}/frames/${sequence}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new L34ContractError(`L3.4 visual frame недоступен: HTTP ${response.status}.`);
|
||||
}
|
||||
return parseVisualFrame(await response.json(), resultId, sequence);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user