@synapse-sdk/core 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ /** NAL unit types this package cares about (H.264 Table 7-1). */
2
+ export declare const NAL_TYPE: {
3
+ readonly SLICE: 1;
4
+ readonly SLICE_DPA: 2;
5
+ readonly SLICE_DPB: 3;
6
+ readonly SLICE_DPC: 4;
7
+ readonly IDR: 5;
8
+ readonly SEI: 6;
9
+ readonly SPS: 7;
10
+ readonly PPS: 8;
11
+ readonly AUD: 9;
12
+ };
13
+ /** SEI payload type of a recovery point (H.264 D.1.8). */
14
+ export declare const SEI_RECOVERY_POINT = 6;
15
+ /** One NAL unit inside an Annex-B buffer. */
16
+ export interface Nal {
17
+ /** Offset of the start code in the buffer. */
18
+ offset: number;
19
+ /** 3 or 4 (bytes of `00 00 01` / `00 00 00 01`). */
20
+ startCodeLen: number;
21
+ /** The NAL unit bytes after the start code: header byte, then the payload (still escaped). A view, not a copy. */
22
+ bytes: Uint8Array;
23
+ }
24
+ /** `nal_unit_type` (low 5 bits of the header byte); 0 for an empty NAL. */
25
+ export declare function nalType(n: Nal): number;
26
+ /** Payload after the 1-byte NAL header (escaped RBSP). */
27
+ export declare function nalPayload(n: Nal): Uint8Array;
28
+ /** A VCL NAL type: a coded slice (types 1-5). */
29
+ export declare function isVclType(t: number): boolean;
30
+ export declare function nalIsVcl(n: Nal): boolean;
31
+ /** Start code plus NAL unit, as it sits in the stream (a view). */
32
+ export declare function nalWithStartCode(buf: Uint8Array, n: Nal): Uint8Array;
33
+ /** Next Annex-B start code (`00 00 01` or `00 00 00 01`) at or after `from`: `[offset, len]`. */
34
+ export declare function findStartCode(buf: Uint8Array, from: number): [offset: number, len: number] | undefined;
35
+ /**
36
+ * The NAL units of an Annex-B buffer, in order. Bytes before the first start code are skipped;
37
+ * trailing zero bytes (`trailing_zero_8bits`) stay attached to the NAL before them, which is
38
+ * harmless for type/flag decisions and keeps the bytes of a walked AU identical to the input.
39
+ */
40
+ export declare function nalUnits(buf: Uint8Array): Nal[];
41
+ /** The SPS + PPS NAL units of an access unit (with their start codes, in stream order), if it carries any. Defined ⇒ the AU deserves the `CONFIG` flag. */
42
+ export declare function auParamSets(au: Uint8Array): Uint8Array | undefined;
43
+ /**
44
+ * The sync points an Annex-B access unit offers.
45
+ * - `idr`: an IDR slice (NAL type 5) is present → instantaneous start point (`KEY`).
46
+ * - `recovery`: a recovery point SEI (NAL 6, payload type 6) is present → gradual start point,
47
+ * clean one refresh period later (`RECOVERY`; x264 and nvenc both emit it at each intra-refresh sweep).
48
+ * Decided from the bitstream rather than the encoder's keyframe flag, which libx264 raises for
49
+ * both kinds and nvenc only for IDRs.
50
+ */
51
+ export declare function auSyncPoints(au: Uint8Array): {
52
+ idr: boolean;
53
+ recovery: boolean;
54
+ };
55
+ /** An IDR is present: the AU is a keyframe in the `KEY` sense. */
56
+ export declare function isKeyframe(au: Uint8Array): boolean;
57
+ /** The AU carries at least one coded slice (otherwise it is parameter sets / SEI only). */
58
+ export declare function hasVcl(au: Uint8Array): boolean;
59
+ /** Whether an SEI NAL payload (after the NAL header byte) contains a recovery point message (payload type 6). */
60
+ export declare function seiHasRecoveryPoint(payload: Uint8Array): boolean;
61
+ /** The payload types of the SEI messages in an SEI NAL payload (D.1.1), in order. Emulation-prevention bytes are removed first. */
62
+ export declare function seiPayloadTypes(payload: Uint8Array): number[];
63
+ /** Strip H.264 emulation-prevention bytes (the `03` in `00 00 03`), giving the RBSP. */
64
+ export declare function rbspUnescape(nal: Uint8Array): Uint8Array;
65
+ /** The SPS of an AU (NAL bytes without start code), if present. */
66
+ export declare function findSps(au: Uint8Array): Uint8Array | undefined;
67
+ /** The WebCodecs / RFC 6381 codec string for an AU that carries an SPS: `avc1.` + profile_idc + constraint flags + level_idc as hex (e.g. `avc1.42c01e`). */
68
+ export declare function codecStringFromSps(au: Uint8Array): string | undefined;
69
+ /** `parts` joined into one new buffer (a single part is copied too, so the result never aliases the input). */
70
+ export declare function concatBytes(parts: readonly Uint8Array[]): Uint8Array;
71
+ /**
72
+ * Splits a continuous Annex-B byte stream (a file, an encoder pipe) into access units. Rule
73
+ * (dylithium's reader loop): an AU ends with its coded slice; the next NAL of any kind starts a
74
+ * new AU, so non-VCL NALs (SPS, PPS, SEI, AUD) are attached to the picture that follows them.
75
+ * Sufficient for one-slice-per-picture streams (x264 `slices=1`, nvenc, WebCodecs), which is what
76
+ * the encoder options enforce.
77
+ */
78
+ export declare class AuSplitter {
79
+ #private;
80
+ /** Feeds bytes; returns every access unit completed by them (in order). */
81
+ push(bytes: Uint8Array): Uint8Array[];
82
+ /** End of stream: the last NAL (whose end no start code marks) closes the last AU, or starts one more if the current AU already has its slice. */
83
+ finish(): Uint8Array[];
84
+ }
85
+ /** Splits a whole Annex-B buffer into access units (see {@link AuSplitter}). */
86
+ export declare function splitAccessUnits(stream: Uint8Array): Uint8Array[];
package/dist/annexb.js ADDED
@@ -0,0 +1,226 @@
1
+ // H.264 Annex-B byte-stream walkers (ITU-T H.264 Annex B, §7.3.1, §7.4.1, D.2.8).
2
+ //
3
+ // A line-for-line port of `crates/synapse-media/src/annexb.rs` (itself from dylithium-core and
4
+ // dylithium-web-2 `wire.ts`), so the browser, Rust and Python SDKs flag the same access units the
5
+ // same way (`vectors/media-00.json` is the shared fixture). Lives in core because the client reads
6
+ // the flags of a `video/h264` body off the bitstream when it publishes. Pure functions over `Uint8Array`;
7
+ // nothing here throws on malformed input: a truncated or garbage buffer yields no NAL units or
8
+ // `false`, never an error, because these run on wire input (lessons register).
9
+ /** NAL unit types this package cares about (H.264 Table 7-1). */
10
+ export const NAL_TYPE = { SLICE: 1, SLICE_DPA: 2, SLICE_DPB: 3, SLICE_DPC: 4, IDR: 5, SEI: 6, SPS: 7, PPS: 8, AUD: 9 };
11
+ /** SEI payload type of a recovery point (H.264 D.1.8). */
12
+ export const SEI_RECOVERY_POINT = 6;
13
+ /** `nal_unit_type` (low 5 bits of the header byte); 0 for an empty NAL. */
14
+ export function nalType(n) { return n.bytes.length ? n.bytes[0] & 0x1f : 0; }
15
+ /** Payload after the 1-byte NAL header (escaped RBSP). */
16
+ export function nalPayload(n) { return n.bytes.subarray(1); }
17
+ /** A VCL NAL type: a coded slice (types 1-5). */
18
+ export function isVclType(t) { return t >= NAL_TYPE.SLICE && t <= NAL_TYPE.IDR; }
19
+ export function nalIsVcl(n) { return isVclType(nalType(n)); }
20
+ /** Start code plus NAL unit, as it sits in the stream (a view). */
21
+ export function nalWithStartCode(buf, n) { return buf.subarray(n.offset, n.offset + n.startCodeLen + n.bytes.length); }
22
+ /** Next Annex-B start code (`00 00 01` or `00 00 00 01`) at or after `from`: `[offset, len]`. */
23
+ export function findStartCode(buf, from) {
24
+ const n = buf.length;
25
+ for (let i = from; i + 3 <= n; i++) {
26
+ if (buf[i] === 0 && buf[i + 1] === 0) {
27
+ if (buf[i + 2] === 1)
28
+ return [i, 3];
29
+ if (i + 4 <= n && buf[i + 2] === 0 && buf[i + 3] === 1)
30
+ return [i, 4];
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * The NAL units of an Annex-B buffer, in order. Bytes before the first start code are skipped;
37
+ * trailing zero bytes (`trailing_zero_8bits`) stay attached to the NAL before them, which is
38
+ * harmless for type/flag decisions and keeps the bytes of a walked AU identical to the input.
39
+ */
40
+ export function nalUnits(buf) {
41
+ const out = [];
42
+ let sc = findStartCode(buf, 0);
43
+ while (sc) {
44
+ const [s, l] = sc;
45
+ const start = s + l;
46
+ const next = findStartCode(buf, start);
47
+ out.push({ offset: s, startCodeLen: l, bytes: buf.subarray(start, next ? next[0] : buf.length) });
48
+ sc = next;
49
+ }
50
+ return out;
51
+ }
52
+ /** The SPS + PPS NAL units of an access unit (with their start codes, in stream order), if it carries any. Defined ⇒ the AU deserves the `CONFIG` flag. */
53
+ export function auParamSets(au) {
54
+ const parts = [];
55
+ for (const n of nalUnits(au)) {
56
+ const t = nalType(n);
57
+ if (t === NAL_TYPE.SPS || t === NAL_TYPE.PPS)
58
+ parts.push(nalWithStartCode(au, n));
59
+ }
60
+ return parts.length ? concatBytes(parts) : undefined;
61
+ }
62
+ /**
63
+ * The sync points an Annex-B access unit offers.
64
+ * - `idr`: an IDR slice (NAL type 5) is present → instantaneous start point (`KEY`).
65
+ * - `recovery`: a recovery point SEI (NAL 6, payload type 6) is present → gradual start point,
66
+ * clean one refresh period later (`RECOVERY`; x264 and nvenc both emit it at each intra-refresh sweep).
67
+ * Decided from the bitstream rather than the encoder's keyframe flag, which libx264 raises for
68
+ * both kinds and nvenc only for IDRs.
69
+ */
70
+ export function auSyncPoints(au) {
71
+ let idr = false, recovery = false;
72
+ for (const n of nalUnits(au)) {
73
+ const t = nalType(n);
74
+ if (t === NAL_TYPE.IDR)
75
+ idr = true;
76
+ else if (t === NAL_TYPE.SEI)
77
+ recovery ||= seiHasRecoveryPoint(nalPayload(n));
78
+ }
79
+ return { idr, recovery };
80
+ }
81
+ /** An IDR is present: the AU is a keyframe in the `KEY` sense. */
82
+ export function isKeyframe(au) { return auSyncPoints(au).idr; }
83
+ /** The AU carries at least one coded slice (otherwise it is parameter sets / SEI only). */
84
+ export function hasVcl(au) { return nalUnits(au).some(nalIsVcl); }
85
+ /** Whether an SEI NAL payload (after the NAL header byte) contains a recovery point message (payload type 6). */
86
+ export function seiHasRecoveryPoint(payload) { return seiPayloadTypes(payload).includes(SEI_RECOVERY_POINT); }
87
+ /** The payload types of the SEI messages in an SEI NAL payload (D.1.1), in order. Emulation-prevention bytes are removed first. */
88
+ export function seiPayloadTypes(payload) {
89
+ const rbsp = rbspUnescape(payload);
90
+ const out = [];
91
+ let i = 0;
92
+ // 0x80 is the rbsp_trailing_bits byte that ends the SEI NAL.
93
+ while (i < rbsp.length && rbsp[i] !== 0x80) {
94
+ // payloadType and payloadSize: runs of 0xFF each adding 255, then a final byte.
95
+ let ty = 0;
96
+ while (i < rbsp.length && rbsp[i] === 0xff) {
97
+ ty += 255;
98
+ i++;
99
+ }
100
+ if (i >= rbsp.length)
101
+ break;
102
+ ty += rbsp[i];
103
+ i++;
104
+ let sz = 0;
105
+ while (i < rbsp.length && rbsp[i] === 0xff) {
106
+ sz += 255;
107
+ i++;
108
+ }
109
+ if (i >= rbsp.length)
110
+ break;
111
+ sz += rbsp[i];
112
+ i++;
113
+ out.push(ty);
114
+ i += sz;
115
+ }
116
+ return out;
117
+ }
118
+ /** Strip H.264 emulation-prevention bytes (the `03` in `00 00 03`), giving the RBSP. */
119
+ export function rbspUnescape(nal) {
120
+ const out = new Uint8Array(nal.length);
121
+ let o = 0, zeros = 0;
122
+ for (const b of nal) {
123
+ if (zeros >= 2 && b === 3) {
124
+ zeros = 0;
125
+ continue;
126
+ }
127
+ out[o++] = b;
128
+ zeros = b === 0 ? zeros + 1 : 0;
129
+ }
130
+ return out.subarray(0, o);
131
+ }
132
+ /** The SPS of an AU (NAL bytes without start code), if present. */
133
+ export function findSps(au) {
134
+ for (const n of nalUnits(au))
135
+ if (nalType(n) === NAL_TYPE.SPS)
136
+ return n.bytes;
137
+ return undefined;
138
+ }
139
+ /** The WebCodecs / RFC 6381 codec string for an AU that carries an SPS: `avc1.` + profile_idc + constraint flags + level_idc as hex (e.g. `avc1.42c01e`). */
140
+ export function codecStringFromSps(au) {
141
+ const sps = findSps(au);
142
+ if (!sps || sps.length < 4)
143
+ return undefined;
144
+ const hex = (b) => b.toString(16).padStart(2, "0");
145
+ return `avc1.${hex(sps[1])}${hex(sps[2])}${hex(sps[3])}`;
146
+ }
147
+ /** `parts` joined into one new buffer (a single part is copied too, so the result never aliases the input). */
148
+ export function concatBytes(parts) {
149
+ let n = 0;
150
+ for (const p of parts)
151
+ n += p.length;
152
+ const out = new Uint8Array(n);
153
+ let o = 0;
154
+ for (const p of parts) {
155
+ out.set(p, o);
156
+ o += p.length;
157
+ }
158
+ return out;
159
+ }
160
+ /**
161
+ * Splits a continuous Annex-B byte stream (a file, an encoder pipe) into access units. Rule
162
+ * (dylithium's reader loop): an AU ends with its coded slice; the next NAL of any kind starts a
163
+ * new AU, so non-VCL NALs (SPS, PPS, SEI, AUD) are attached to the picture that follows them.
164
+ * Sufficient for one-slice-per-picture streams (x264 `slices=1`, nvenc, WebCodecs), which is what
165
+ * the encoder options enforce.
166
+ */
167
+ export class AuSplitter {
168
+ #pending = new Uint8Array(0);
169
+ #au = [];
170
+ #auHasVcl = false;
171
+ /** Feeds bytes; returns every access unit completed by them (in order). */
172
+ push(bytes) {
173
+ this.#pending = this.#pending.length ? concatBytes([this.#pending, bytes]) : bytes.slice();
174
+ const out = [];
175
+ const p = this.#pending;
176
+ let pos = 0;
177
+ for (;;) {
178
+ const sc0 = findStartCode(p, pos);
179
+ if (!sc0)
180
+ break; // no start code yet (maybe a partial one): keep the bytes
181
+ const [s0, l0] = sc0;
182
+ // The NAL is complete only once the next start code is visible.
183
+ const sc1 = findStartCode(p, s0 + l0);
184
+ if (!sc1) {
185
+ pos = s0;
186
+ break;
187
+ }
188
+ const isVcl = s0 + l0 < p.length && isVclType(p[s0 + l0] & 0x1f);
189
+ if (this.#auHasVcl)
190
+ out.push(this.#take()); // the previous AU already has its slice: this NAL belongs to the next one
191
+ this.#au.push(p.slice(s0, sc1[0]));
192
+ if (isVcl)
193
+ this.#auHasVcl = true;
194
+ pos = sc1[0];
195
+ }
196
+ this.#pending = pos > 0 ? p.slice(pos) : p;
197
+ return out;
198
+ }
199
+ /** End of stream: the last NAL (whose end no start code marks) closes the last AU, or starts one more if the current AU already has its slice. */
200
+ finish() {
201
+ const out = [];
202
+ const sc0 = findStartCode(this.#pending, 0);
203
+ if (sc0) {
204
+ if (this.#auHasVcl)
205
+ out.push(this.#take());
206
+ this.#au.push(this.#pending.slice(sc0[0]));
207
+ }
208
+ if (this.#au.length)
209
+ out.push(this.#take());
210
+ this.#pending = new Uint8Array(0);
211
+ return out;
212
+ }
213
+ #take() {
214
+ const au = this.#au.length === 1 ? this.#au[0] : concatBytes(this.#au);
215
+ this.#au = [];
216
+ this.#auHasVcl = false;
217
+ return au;
218
+ }
219
+ }
220
+ /** Splits a whole Annex-B buffer into access units (see {@link AuSplitter}). */
221
+ export function splitAccessUnits(stream) {
222
+ const sp = new AuSplitter();
223
+ const out = sp.push(stream);
224
+ out.push(...sp.finish());
225
+ return out;
226
+ }
@@ -10,6 +10,8 @@ export declare function jsonSchemaOf(schema: StandardSchemaV1, io: "input" | "ou
10
10
  export declare const MSGPACK = "application/msgpack";
11
11
  /** The MIME type of a body the SDK does not touch (a `raw` topic). */
12
12
  export declare const OCTET_STREAM = "application/octet-stream";
13
+ /** A `video/*` content type: published as `MEDIA` frames with flags read off the bitstream; a datagram topic of it carries FEC by default. */
14
+ export declare function isVideo(contentType: string): boolean;
13
15
  export interface TopicDef<S extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined> {
14
16
  /** Slash path (`imu`, `cam/front`). */
15
17
  name: string;
@@ -17,12 +19,10 @@ export interface TopicDef<S extends StandardSchemaV1 | undefined = StandardSchem
17
19
  schema?: S;
18
20
  /** How frames travel (WP §6): `datagram` (the default: one datagram per message, newest wins) or `stream` (ordered, nothing dropped). */
19
21
  delivery?: Delivery;
20
- /** MIME type of the body; default `application/msgpack`. A topic with another type is `raw`: bodies are bytes. */
22
+ /** MIME type of the body; default `application/msgpack`. A topic with another type is `raw`: bodies are bytes. A `video/*` type (`video/h264`) makes a datagram topic carry FEC with the defaults unless `fec` is given. */
21
23
  contentType?: string;
22
- /** `datagram` only: FEC coding of each message into symbols (WP §6.3), for bodies larger than a datagram (video). `symbolSize` 0 = sized from the session's datagram budget. */
23
- fec?: TopicFec | true;
24
- /** Describes a raw body's layout for other readers (e.g. a video stream's codec and dimensions). */
25
- layout?: Record<string, unknown>;
24
+ /** `datagram` only: FEC coding of each message into symbols (WP §6.3), for bodies larger than a datagram (video). `symbolSize` 0 = sized from the session's datagram budget. `false`: none, also on a `video/*` topic. */
25
+ fec?: TopicFec | boolean;
26
26
  toJsonSchema?: ToJsonSchema;
27
27
  }
28
28
  export interface TopicFec {
@@ -40,7 +40,6 @@ export interface TopicContract<TIn = unknown, TOut = TIn> {
40
40
  readonly raw: boolean;
41
41
  /** FEC parameters (wire shape); `symbol_size` 0 = sized from the session. Only on `datagram` topics. */
42
42
  readonly fec: FecParams | undefined;
43
- readonly layout: Record<string, unknown> | undefined;
44
43
  /** JSON Schema of the wire body (input side: defaulted fields optional), or undefined for schemaless/raw topics. */
45
44
  readonly jsonSchema: JsonSchema | undefined;
46
45
  /** sha256 of the canonical JSON Schema; rides in the descriptor. */
package/dist/contracts.js CHANGED
@@ -32,6 +32,8 @@ function memo(f) { const m = { done: false }; return () => { if (!m.done) {
32
32
  export const MSGPACK = "application/msgpack";
33
33
  /** The MIME type of a body the SDK does not touch (a `raw` topic). */
34
34
  export const OCTET_STREAM = "application/octet-stream";
35
+ /** A `video/*` content type: published as `MEDIA` frames with flags read off the bitstream; a datagram topic of it carries FEC by default. */
36
+ export function isVideo(contentType) { return contentType.startsWith("video/"); }
35
37
  export function defineTopic(def) {
36
38
  const contentType = def.contentType ?? MSGPACK;
37
39
  const raw = contentType !== MSGPACK;
@@ -41,7 +43,9 @@ export function defineTopic(def) {
41
43
  const delivery = def.delivery ?? "datagram";
42
44
  if (def.fec && delivery !== "datagram")
43
45
  throw new Error(`topic ${def.name}: fec is a datagram setting (WP §6)`);
44
- const f = def.fec === true ? {} : def.fec;
46
+ // A `video/*` datagram topic carries FEC with the defaults (the media defaults) unless `fec` was given.
47
+ const fecDef = def.fec ?? (isVideo(contentType) && delivery === "datagram" ? true : undefined);
48
+ const f = fecDef === true ? {} : fecDef === false ? undefined : fecDef;
45
49
  const fec = f ? { symbol_size: f.symbolSize ?? 0, repair_pct: f.repairPct ?? FEC_DEFAULTS.repair_pct, deadline_ms: f.deadlineMs ?? FEC_DEFAULTS.deadline_ms } : undefined;
46
50
  if (fec && fec.symbol_size !== 0 && (fec.symbol_size < 8 || fec.symbol_size % 8 !== 0))
47
51
  throw new Error(`topic ${def.name}: fec symbolSize must be a multiple of 8`);
@@ -55,7 +59,6 @@ export function defineTopic(def) {
55
59
  contentType,
56
60
  raw,
57
61
  fec,
58
- layout: def.layout,
59
62
  get jsonSchema() { return js(); },
60
63
  get schemaHash() { return hash(); },
61
64
  desc(dom) {
@@ -65,8 +68,6 @@ export function defineTopic(def) {
65
68
  d.schema = s;
66
69
  d.schema_hash = hash();
67
70
  }
68
- if (this.layout)
69
- d.layout = this.layout;
70
71
  if (this.fec)
71
72
  d.fec = { ...this.fec };
72
73
  return d;
@@ -185,6 +185,24 @@
185
185
  "source": "broker",
186
186
  "description": "patch, reparent, rename or despawn of an unknown scene entity id (data.id)"
187
187
  },
188
+ {
189
+ "code": "preflight_failed",
190
+ "retryable": false,
191
+ "source": "broker",
192
+ "description": "a task run was refused before anything started: a declaration names a task the catalog lacks, pins another schema, or its spec could not be resolved (data.report: [{scope, entity?, entity_name?, instance, addr, reason, detail?}])"
193
+ },
194
+ {
195
+ "code": "already_running",
196
+ "retryable": false,
197
+ "source": "broker",
198
+ "description": "the scope's tasks are already running (or starting); stop them first"
199
+ },
200
+ {
201
+ "code": "launch_failed",
202
+ "retryable": false,
203
+ "source": "broker",
204
+ "description": "a task process could not be started; what the run had started is stopped (data.report)"
205
+ },
188
206
  {
189
207
  "code": "not_supported",
190
208
  "retryable": false,
package/dist/index.d.ts CHANGED
@@ -13,3 +13,4 @@ export * from "./fec.ts";
13
13
  export * from "./fec_flow.ts";
14
14
  export * from "./fec_wasm.ts";
15
15
  export * from "./media.ts";
16
+ export * from "./annexb.ts";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export * from "./fec.js";
14
14
  export * from "./fec_flow.js";
15
15
  export * from "./fec_wasm.js";
16
16
  export * from "./media.js";
17
+ export * from "./annexb.js";
package/dist/media.d.ts CHANGED
@@ -11,7 +11,7 @@ export interface Stages {
11
11
  }
12
12
  /**
13
13
  * One encoded access unit and its header facts (R27 §2).
14
- * - `data`: the body bytes exactly as they travel (Annex-B for H.264; `layout.codec` says which).
14
+ * - `data`: the body bytes exactly as they travel (Annex-B for `video/h264`; the topic's `content_type` says which).
15
15
  * - `tUs`: capture time (header `t_us`), the first stage stamp; 0 = "stamp now" when publishing.
16
16
  * - `key`: an IDR, instantaneous start point (flag `KEY`).
17
17
  * - `config`: SPS/PPS present in this AU (flag `CONFIG`).
@@ -26,16 +26,6 @@ export interface MediaFrame {
26
26
  recovery: boolean;
27
27
  stages?: Stages;
28
28
  }
29
- /** Descriptor `layout` of a media topic (WP §7.2): what the raw bytes are. */
30
- export interface MediaLayout {
31
- codec: "h264" | (string & {});
32
- /** `annexb` for H.264 (start codes, SPS/PPS in-band). */
33
- stream?: "annexb" | (string & {});
34
- width?: number;
35
- height?: number;
36
- fps?: number;
37
- [k: string]: unknown;
38
- }
39
29
  /** The header flag bits a frame sets (WP §4 bit0 KEY, bit5 CONFIG, bit6 RECOVERY). */
40
30
  export declare function mediaFlags(f: Pick<MediaFrame, "key" | "config" | "recovery">): number;
41
31
  /** A decoder may start here: an IDR, or a recovery point that carries its parameter sets. */
package/dist/media.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The media frame as the wire profile sees it (WP §4 type `MEDIA`): one encoded access unit plus
2
2
  // the header facts that describe it. Mirrors `crates/synapse-core/src/media.rs` so every SDK maps
3
- // the same fields onto the same header bits. No codec knowledge here: `@synapse-sdk/media` derives
4
- // the flags from the bitstream (`mediaFrameFromAnnexb`).
3
+ // the same fields onto the same header bits. No codec knowledge here: the flags come from the
4
+ // bitstream (`annexb.ts`: `auSyncPoints`, `auParamSets`).
5
5
  import { FLAGS, TYPES } from "./header.js";
6
6
  /** The header flag bits a frame sets (WP §4 bit0 KEY, bit5 CONFIG, bit6 RECOVERY). */
7
7
  export function mediaFlags(f) {
package/dist/types.d.ts CHANGED
@@ -24,19 +24,27 @@ export declare function isRole(s: unknown): s is Role;
24
24
  export type ClientType = "agent" | "observer" | "web" | "hmi";
25
25
  export declare const CLIENT_TYPES: readonly ClientType[];
26
26
  export declare function isClientType(s: unknown): s is ClientType;
27
- /** What a node declares on connect (`Identity`, WP §3.1): query parameters `type=`, `name=`, `user_id=`, `credential=`. */
27
+ /** What a node declares on connect (`Identity`, WP §3.1): `type`, `name`, `user_id`, `credential`, carried to the runtime in its connect request (`ConnectRequest`, PLAN v0.26 §3.1). */
28
28
  export interface Identity {
29
29
  type: ClientType;
30
30
  name?: string;
31
31
  user_id?: string;
32
32
  credential?: string;
33
33
  }
34
- /** `Identity::to_query`. */
35
- export declare function identityQuery(id: Identity): string;
36
34
  /** `is_valid_name`: a client name is `[a-z0-9_-]{1,64}`, one path segment of `client/<name>/…`. */
37
35
  export declare function isValidClientName(name: string): boolean;
38
- /** Standing project intent on connect (`&project=`, WP §3.5). */
36
+ /**
37
+ * `name_from_query`: the `name=` of a dial's raw query string (without `?`), all the relay level reads
38
+ * from the URL (it grants the connection publish under `client/<name>/**`). `undefined` when it is
39
+ * missing, given twice, or breaks `isValidClientName`. Every other parameter is ignored.
40
+ */
41
+ export declare function nameFromQuery(query: string): string | undefined;
42
+ /** Standing project intent on connect (the connect request's `project`, WP §3.5). */
39
43
  export type ProjectIntent = "operator";
44
+ /** `SID_LEN`: a session id is 8 lowercase hex characters, minted at random by the client for each connect attempt (PLAN v0.26 §5.1). */
45
+ export declare const SID_LEN = 8;
46
+ /** `is_sid`. */
47
+ export declare function isSid(s: string): boolean;
40
48
  /** Time domain (WP §7.3): the id of the clock `t_us` stamps are read from. */
41
49
  export interface TimeDomain {
42
50
  id: string;
@@ -48,7 +56,6 @@ export interface TrackDesc {
48
56
  content_type: string;
49
57
  schema?: JsonSchema;
50
58
  schema_hash?: string;
51
- layout?: Record<string, unknown>;
52
59
  dom: TimeDomain;
53
60
  fec?: FecParams;
54
61
  }
@@ -64,10 +71,16 @@ export interface Card {
64
71
  cancelable: boolean;
65
72
  min_role: Role;
66
73
  }
67
- /** `client/<name>/hello`. */
68
- export declare function helloPath(name: string): string;
69
- /** A session's request broadcast (track `req`): `<prefix>/rpc`. */
70
- export declare function rpcPath(prefix: string): string;
74
+ /** A session's publish prefix `client/<name>/<sid>` (WP §1, §3.3), as the hello carries it. */
75
+ export declare function sessionPrefix(name: string, sid: string): string;
76
+ /** Where a client asks for a session (PLAN v0.26 §3.1): `client/<name>/<sid>/connect`, track `hello`, one frame (the `ConnectRequest`). The session lasts as long as it is announced. */
77
+ export declare function connectPath(name: string, sid: string): string;
78
+ /** Where the runtime answers a connect request: `client/<name>/<sid>/hello`, track `hello`, one frame (the `Hello`, or an error frame). */
79
+ export declare function helloPath(name: string, sid: string): string;
80
+ /** A session's request broadcast (track `req`) as a hello grants it: `<prefix>/rpc/@<epoch>`, the epoch minted by the runtime per admission (PLAN v0.26 §7.1). Clients publish at `Hello.rpc`; this builds one for tests. */
81
+ export declare function rpcPath(prefix: string, epoch: string): string;
82
+ /** A session's reply broadcast (track `res`) as a hello grants it: `runtime/rpc/@<epoch>`, the same admission epoch as its `rpcPath` (PLAN v0.26 §7.2). Clients subscribe at `Hello.res`; this builds one for tests. */
83
+ export declare function resPath(epoch: string): string;
71
84
  /** A publisher's broadcast for binding `bind` of `topic`: `<prefix>/topic/<bind>/<topic>`. */
72
85
  export declare function topicPath(prefix: string, bind: string, topic: string): string;
73
86
  /** A provider's broadcast for binding `bind` of `service`: `<prefix>/svc/<bind>/<service>`. */
@@ -80,7 +93,25 @@ export declare function parseSessionPath(path: string): {
80
93
  } | undefined;
81
94
  /** A call's identity on every hop (`CallId`): 16 bytes, carried as msgpack bin. */
82
95
  export type CallId = Uint8Array;
83
- /** The first frame on `client/<name>/hello` track `hello` (WP §8.2): readiness, the grant, and our publish prefix. */
96
+ /**
97
+ * The connect request (`ConnectRequest`, PLAN v0.26 §3.1): the one frame (`RPC_CALL`, `END`) a client
98
+ * writes on track `hello` of `client/<name>/<sid>/connect` and announces to ask the runtime for a
99
+ * session. The runtime answers on `client/<name>/<sid>/hello` with the `Hello`, or with an error frame
100
+ * (`RPC_RESP`, `END|ERR`, an `ErrorBody` without `call`): `admission_refused` (retryable),
101
+ * `invalid_input`, `internal`. The session lasts as long as this broadcast is announced.
102
+ */
103
+ export interface ConnectRequest {
104
+ type: ClientType;
105
+ name: string;
106
+ user_id?: string;
107
+ credential?: string;
108
+ project?: ProjectIntent;
109
+ /** The client's time-domain id (WP §7.3): the one it stamps on its descriptors and reports. */
110
+ dom: string;
111
+ }
112
+ /** The connect request's msgpack body, in the Rust field order; absent optionals are left out. */
113
+ export declare function encodeConnectRequest(r: ConnectRequest): Uint8Array;
114
+ /** The one frame on `client/<name>/<sid>/hello` track `hello` (WP §8.2): readiness, the grant, our publish prefix, our request and reply paths. */
84
115
  export interface Hello {
85
116
  hello: true;
86
117
  type: ClientType;
@@ -88,9 +119,17 @@ export interface Hello {
88
119
  user_id?: string;
89
120
  role: Role;
90
121
  prefix: string;
122
+ /** The path of the session's request broadcast (track `req`), granted by this hello: `<prefix>/rpc/@<epoch>` (`rpcPath`). The runtime follows the session's calls on this path only. */
123
+ rpc: string;
124
+ /** The path of the session's reply broadcast (track `res`), granted by this hello: `runtime/rpc/@<epoch>` (`resPath`; the same epoch as `rpc`). The runtime writes the session's replies there. */
125
+ res: string;
91
126
  dom: string;
92
127
  broker_version: string;
93
128
  robot: string;
129
+ /** The runtime's name in its cluster (`name` in its `broker.toml`; default its hostname); empty from a runtime older than v0.28. */
130
+ runtime_name: string;
131
+ /** The runtime's own id (PLAN v0.26 §5.2): a UUIDv7 minted on its first run; empty from a runtime older than v0.26. */
132
+ runtime_id: string;
94
133
  }
95
134
  /** `synapse/time` request (WP §8.7). */
96
135
  export interface TimeRequest {
@@ -182,7 +221,6 @@ export interface ProjectSnapshot {
182
221
  active: ActiveProject | null;
183
222
  operators: OperatorEntry[];
184
223
  settling_until_us: number | null;
185
- persisted: boolean;
186
224
  }
187
225
  /** The active project; `scene_id`, `store_id` and `behavior_trees` (slot → `tree:<id>`) are its project document's. */
188
226
  export interface ActiveProject {
@@ -192,6 +230,23 @@ export interface ActiveProject {
192
230
  store_id: string;
193
231
  behavior_trees: Record<string, string>;
194
232
  }
233
+ /** The reply of `synapse/tasks/run_robot` and `synapse/tasks/run_project`: how many tasks were started, and how many were declared but have nothing to run. */
234
+ export interface TasksRun {
235
+ launched: number;
236
+ passive: number;
237
+ }
238
+ /** Why a declared task held a run back: unknown to the catalog, another schema than the pinned one, a spec that could not be read, or a config the spec refuses; `launch_failed` when its process could not be started. */
239
+ export type TasksReportReason = "missing" | "schema_mismatch" | "spec_error" | "invalid_input" | "launch_failed";
240
+ /** One entry of `data.report` on a `preflight_failed` or `launch_failed` error: the declaration it is about (`entity` and `entity_name` for a project task) and why. */
241
+ export interface TasksReportItem {
242
+ scope: "robot" | "project";
243
+ entity?: string;
244
+ entity_name?: string;
245
+ instance: string;
246
+ addr: string;
247
+ reason: TasksReportReason;
248
+ detail?: string;
249
+ }
195
250
  export interface OperatorEntry {
196
251
  name: string;
197
252
  user_id?: string;
@@ -214,6 +269,16 @@ export type RuntimeNotice = {
214
269
  reason: string;
215
270
  };
216
271
  export declare const STORE_CONFIG = "config";
272
+ /** The robot's task declarations (`<instance>` → `{ref, enabled?, config?, schema?}`): operator-written, kept by the runtime. */
273
+ export declare const STORE_TASKS = "tasks";
274
+ /** The tasks the runtime knows (one row per task address); read-only for clients. */
275
+ export declare const STORE_TASK_CATALOG = "task_catalog";
276
+ /** The task runner's run rows (`robot`, `project`) and one row per running instance; read-only for clients. */
277
+ export declare const STORE_TASK_STATUS = "task_status";
278
+ /** The robot-scoped namespaces (store.rs `robot_scoped`): they belong to the robot, not to a project, so a project switch leaves them open. */
279
+ export declare const STORE_ROBOT_SCOPED: readonly string[];
280
+ /** Whether `ns` is robot-scoped ({@link STORE_ROBOT_SCOPED}). */
281
+ export declare function storeRobotScoped(ns: string): boolean;
217
282
  /** The incarnation's track: every group is a snapshot frame then one diff frame per applied op. */
218
283
  export declare const STORE_TRACK = "data";
219
284
  /** `base_path`: where a namespace's incarnations are announced (`<base>/@<epoch>`). */
package/dist/types.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { encode } from "./msgpack.js";
1
2
  /** A `stream` track starts a new MoQ group every 100 ms of frames (`synapse_core::class::STREAM_GROUP_SPAN`). */
2
3
  export const STREAM_GROUP_SPAN_MS = 100;
3
4
  /** A `stream` subscriber's moq latency budget (`STREAM_SUBSCRIBE_MAX_AGE`). */
@@ -15,23 +16,31 @@ export function roleRank(r) { return ROLES.indexOf(r); }
15
16
  export function isRole(s) { return typeof s === "string" && ROLES.includes(s); }
16
17
  export const CLIENT_TYPES = ["agent", "observer", "web", "hmi"];
17
18
  export function isClientType(s) { return typeof s === "string" && CLIENT_TYPES.includes(s); }
18
- /** `Identity::to_query`. */
19
- export function identityQuery(id) {
20
- let q = `type=${id.type}`;
21
- if (id.name)
22
- q += `&name=${encodeURIComponent(id.name)}`;
23
- if (id.user_id)
24
- q += `&user_id=${encodeURIComponent(id.user_id)}`;
25
- if (id.credential)
26
- q += `&credential=${encodeURIComponent(id.credential)}`;
27
- return q;
28
- }
29
19
  /** `is_valid_name`: a client name is `[a-z0-9_-]{1,64}`, one path segment of `client/<name>/…`. */
30
20
  export function isValidClientName(name) { return /^[a-z0-9_-]{1,64}$/.test(name); }
31
- /** `client/<name>/hello`. */
32
- export function helloPath(name) { return `client/${name}/hello`; }
33
- /** A session's request broadcast (track `req`): `<prefix>/rpc`. */
34
- export function rpcPath(prefix) { return `${prefix}/rpc`; }
21
+ /**
22
+ * `name_from_query`: the `name=` of a dial's raw query string (without `?`), all the relay level reads
23
+ * from the URL (it grants the connection publish under `client/<name>/**`). `undefined` when it is
24
+ * missing, given twice, or breaks `isValidClientName`. Every other parameter is ignored.
25
+ */
26
+ export function nameFromQuery(query) {
27
+ const names = query.split("&").filter(kv => kv.startsWith("name=")).map(kv => kv.slice(5));
28
+ return names.length === 1 && isValidClientName(names[0]) ? names[0] : undefined;
29
+ }
30
+ /** `SID_LEN`: a session id is 8 lowercase hex characters, minted at random by the client for each connect attempt (PLAN v0.26 §5.1). */
31
+ export const SID_LEN = 8;
32
+ /** `is_sid`. */
33
+ export function isSid(s) { return /^[0-9a-f]{8}$/.test(s); }
34
+ /** A session's publish prefix `client/<name>/<sid>` (WP §1, §3.3), as the hello carries it. */
35
+ export function sessionPrefix(name, sid) { return `client/${name}/${sid}`; }
36
+ /** Where a client asks for a session (PLAN v0.26 §3.1): `client/<name>/<sid>/connect`, track `hello`, one frame (the `ConnectRequest`). The session lasts as long as it is announced. */
37
+ export function connectPath(name, sid) { return `${sessionPrefix(name, sid)}/connect`; }
38
+ /** Where the runtime answers a connect request: `client/<name>/<sid>/hello`, track `hello`, one frame (the `Hello`, or an error frame). */
39
+ export function helloPath(name, sid) { return `${sessionPrefix(name, sid)}/hello`; }
40
+ /** A session's request broadcast (track `req`) as a hello grants it: `<prefix>/rpc/@<epoch>`, the epoch minted by the runtime per admission (PLAN v0.26 §7.1). Clients publish at `Hello.rpc`; this builds one for tests. */
41
+ export function rpcPath(prefix, epoch) { return `${prefix}/rpc/@${epoch}`; }
42
+ /** A session's reply broadcast (track `res`) as a hello grants it: `runtime/rpc/@<epoch>`, the same admission epoch as its `rpcPath` (PLAN v0.26 §7.2). Clients subscribe at `Hello.res`; this builds one for tests. */
43
+ export function resPath(epoch) { return `runtime/rpc/@${epoch}`; }
35
44
  /** A publisher's broadcast for binding `bind` of `topic`: `<prefix>/topic/<bind>/<topic>`. */
36
45
  export function topicPath(prefix, bind, topic) { return `${prefix}/topic/${bind}/${topic}`; }
37
46
  /** A provider's broadcast for binding `bind` of `service`: `<prefix>/svc/<bind>/<service>`. */
@@ -41,6 +50,10 @@ export function parseSessionPath(path) {
41
50
  const m = /^client\/([a-z0-9_-]{1,64})\/([0-9a-f]{8})\/(.+)$/.exec(path);
42
51
  return m ? { name: m[1], sid: m[2], rest: m[3] } : undefined;
43
52
  }
53
+ /** The connect request's msgpack body, in the Rust field order; absent optionals are left out. */
54
+ export function encodeConnectRequest(r) {
55
+ return encode({ type: r.type, name: r.name, user_id: r.user_id, credential: r.credential, project: r.project, dom: r.dom });
56
+ }
44
57
  const num = (x) => (typeof x === "number" ? x : typeof x === "bigint" ? Number(x) : undefined);
45
58
  const str = (x) => (typeof x === "string" ? x : "");
46
59
  /** Normalises a decoded topics catalog (missing collections become empty; bigints become numbers). */
@@ -92,10 +105,20 @@ export function normalizeProjectSnapshot(v) {
92
105
  type: isClientType(e.type) ? e.type : "observer", since_us: num(e.since_us) ?? 0,
93
106
  }))
94
107
  : [];
95
- return { active, operators, settling_until_us: num(o.settling_until_us) ?? null, persisted: o.persisted === true };
108
+ return { active, operators, settling_until_us: num(o.settling_until_us) ?? null };
96
109
  }
97
110
  // ------------------------------------------------------------------------------------ store.rs
98
111
  export const STORE_CONFIG = "config";
112
+ /** The robot's task declarations (`<instance>` → `{ref, enabled?, config?, schema?}`): operator-written, kept by the runtime. */
113
+ export const STORE_TASKS = "tasks";
114
+ /** The tasks the runtime knows (one row per task address); read-only for clients. */
115
+ export const STORE_TASK_CATALOG = "task_catalog";
116
+ /** The task runner's run rows (`robot`, `project`) and one row per running instance; read-only for clients. */
117
+ export const STORE_TASK_STATUS = "task_status";
118
+ /** The robot-scoped namespaces (store.rs `robot_scoped`): they belong to the robot, not to a project, so a project switch leaves them open. */
119
+ export const STORE_ROBOT_SCOPED = [STORE_CONFIG, STORE_TASKS, STORE_TASK_CATALOG, STORE_TASK_STATUS];
120
+ /** Whether `ns` is robot-scoped ({@link STORE_ROBOT_SCOPED}). */
121
+ export function storeRobotScoped(ns) { return STORE_ROBOT_SCOPED.includes(ns); }
99
122
  /** The incarnation's track: every group is a snapshot frame then one diff frame per applied op. */
100
123
  export const STORE_TRACK = "data";
101
124
  /** `base_path`: where a namespace's incarnations are announced (`<base>/@<epoch>`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapse-sdk/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Synapse SDK core: message encoding, contracts and error codes shared by the other packages.",
5
5
  "type": "module",
6
6
  "sideEffects": false,