@volter/world-core 3.0.151 → 3.0.153

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.
Files changed (52) hide show
  1. package/dist/src/derived-core.d.ts +8 -0
  2. package/dist/src/derived-core.js +2 -0
  3. package/dist/src/derived.d.ts +4 -0
  4. package/dist/src/events.d.ts +4 -4
  5. package/dist/src/events.js +39 -15
  6. package/dist/src/index.d.ts +3 -1
  7. package/dist/src/index.js +2 -1
  8. package/dist/src/live/access.d.ts +22 -0
  9. package/dist/src/live/access.js +83 -0
  10. package/dist/src/live/amf0.d.ts +7 -0
  11. package/dist/src/live/amf0.js +122 -0
  12. package/dist/src/live/flv.d.ts +42 -0
  13. package/dist/src/live/flv.js +148 -0
  14. package/dist/src/live/fmp4.d.ts +25 -0
  15. package/dist/src/live/fmp4.js +96 -0
  16. package/dist/src/live/hls.d.ts +22 -0
  17. package/dist/src/live/hls.js +46 -0
  18. package/dist/src/live/index.d.ts +9 -0
  19. package/dist/src/live/index.js +8 -0
  20. package/dist/src/live/ingest.d.ts +25 -0
  21. package/dist/src/live/ingest.js +229 -0
  22. package/dist/src/live/rtmp.d.ts +35 -0
  23. package/dist/src/live/rtmp.js +456 -0
  24. package/dist/src/live/segmenter.d.ts +57 -0
  25. package/dist/src/live/segmenter.js +204 -0
  26. package/dist/src/live/types.d.ts +102 -0
  27. package/dist/src/live/types.js +5 -0
  28. package/dist/src/pack-fetch.d.ts +4 -0
  29. package/dist/src/pack-fetch.js +24 -2
  30. package/dist/src/packRegistry.d.ts +1 -0
  31. package/dist/src/serve-http.d.ts +7 -1
  32. package/dist/src/serve-http.js +36 -3
  33. package/dist/src/twin-fetch.d.ts +4 -1
  34. package/package.json +5 -1
  35. package/src/derived-core.ts +10 -0
  36. package/src/derived.ts +4 -0
  37. package/src/events.ts +40 -16
  38. package/src/index.ts +4 -1
  39. package/src/live/access.ts +89 -0
  40. package/src/live/amf0.ts +80 -0
  41. package/src/live/flv.ts +145 -0
  42. package/src/live/fmp4.ts +102 -0
  43. package/src/live/hls.ts +59 -0
  44. package/src/live/index.ts +13 -0
  45. package/src/live/ingest.ts +210 -0
  46. package/src/live/rtmp.ts +375 -0
  47. package/src/live/segmenter.ts +218 -0
  48. package/src/live/types.ts +105 -0
  49. package/src/pack-fetch.ts +28 -2
  50. package/src/packRegistry.ts +1 -1
  51. package/src/serve-http.ts +29 -4
  52. package/src/twin-fetch.ts +4 -2
package/src/events.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
8
8
  // delivery is made, never when a browser client imports the kernel.
9
9
  import { createHash, createHmac } from 'node:crypto';
10
- import { applyTwinWrite, twinResources } from './serve.ts';
10
+ import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from './serve.ts';
11
11
  import { equalSecrets as same, ksuidFrom } from './signing.ts';
12
12
  import { deliveryTraceHeaders } from './trace-context.ts';
13
13
 
@@ -123,19 +123,17 @@ export type EventDelivery = { url: string; body: string; headers: Record<string,
123
123
  /** The transport a delivery is made over; the World's by default, a test's own when given. */
124
124
  export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
125
125
 
126
- const digest = (s: string): string => createHash('sha256').update(s).digest('hex');
127
-
128
126
  /** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
129
127
  const webhookKey = (scheme: { secretPrefix?: string; secretKey?: 'base64' | 'utf8' }, secret: string): Buffer =>
130
128
  scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
131
129
 
132
130
  /** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
133
- /** `seq` tells apart two identical deliveries at one instant (a frozen World clock): the World's count of deliveries
134
- * made before this one, so a receiver that deduplicates by id drops neither. */
135
- export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
131
+ /** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
132
+ * occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
133
+ export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
136
134
  if (scheme.kind === 'webhook-id') {
137
135
  // Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
138
- const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
136
+ const id = `msg_${ksuidFrom(seconds, seq, identity ?? `${url}|${seconds}|${seq}|${body}`)}`;
139
137
  const p = scheme.prefix.toLowerCase();
140
138
  const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
141
139
  return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
@@ -161,8 +159,25 @@ const hmacBase = (scheme: { timestampHeader?: string; signed?: string }, seconds
161
159
  scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
162
160
 
163
161
  /** Every scheme's headers for one delivery, merged. */
164
- export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
165
- return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq)));
162
+ export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
163
+ return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq, identity)));
164
+ }
165
+
166
+ /** Reserve occurrences under the same cross-process lock that appends actions. This private retained row is never a
167
+ * vendor resource. Deleted event/delivery rows, concurrent renders and restarts cannot give its ordinals back. */
168
+ async function reserveEventOrdinals(service: string, root: string | undefined, occurredAt: string, count: number): Promise<number> {
169
+ const { value } = await applyTwinWriteAtomic(service, (resources) => {
170
+ const counter = resources.find((r) => r.type === '_world_event_sequence' && r.id === 'sequence');
171
+ const last = counter === undefined ? 0 : (counter as Record<string, unknown>).last_ordinal;
172
+ if (typeof last !== 'number' || !Number.isSafeInteger(last) || last < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(last + count)) {
173
+ throw new Error('Invalid retained declared-event sequence; occurrence ids cannot be reused.');
174
+ }
175
+ return { kind: 'write', value: last + 1, write: {
176
+ operation: 'world_event_sequence.reserve', subjectType: '_world_event_sequence', subjectId: 'sequence',
177
+ fields: { last_ordinal: last + count }, input: { clock: true }, occurredAt, actor: { kind: 'system' },
178
+ } };
179
+ }, root);
180
+ return value;
166
181
  }
167
182
 
168
183
  /** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
@@ -386,11 +401,19 @@ async function deliverEventType(
386
401
  const seconds = Math.floor(millis / 1000);
387
402
  const eventData = await data(type);
388
403
  const extra = values ? await values(type) : {};
389
- // the World's count of events kept so far tells apart two identical events at one instant (a frozen clock)
390
- const kept = decl.store !== undefined ? all.filter((r) => r.type === decl.store).length : 0;
404
+ // Reserve one event and at most one delivery per live endpoint before assigning identities. Unused ordinals are
405
+ // harmless gaps; a failed render/store never gives them back. This is not an atomic originating-write outbox.
406
+ const ordinal = await reserveEventOrdinals(service, write.root, write.occurredAt, 1 + live.length);
407
+ // Fresh/forked Worlds can reserve the same ordinal at a pinned clock. Keep
408
+ // content binding as well as the lineage's occurrence ordinal, so different
409
+ // event payloads cannot reuse a receiver's event/webhook id across those Worlds.
410
+ const eventContent = createHash('sha256').update(JSON.stringify({
411
+ operation: write.operation, type, data: eventData, envelope: decl.envelope,
412
+ values: extra, request: requestValues(write.request),
413
+ }), 'utf8').digest('hex');
391
414
  const filled: Record<string, unknown> = {
392
415
  $type: type, $data: eventData,
393
- $id: `evt_${ksuidFrom(seconds, kept, `${type}|${write.occurredAt}|${kept}|${JSON.stringify(eventData)}`)}`,
416
+ $id: `evt_${ksuidFrom(seconds, ordinal, `world-events:v1:${service}:${type}:${ordinal}:${eventContent}`)}`,
394
417
  '$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
395
418
  ...requestValues(write.request),
396
419
  ...extra,
@@ -407,18 +430,19 @@ async function deliverEventType(
407
430
  const shared = JSON.stringify(envelope);
408
431
  const each = perEndpoint(decl.envelope);
409
432
  const sent: EventDelivery[] = [];
410
- const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
411
433
  for (const [i, { row, kind }] of endpoints.entries()) {
412
434
  const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
413
435
  const url = String(at(row, kind.url));
414
- const seq = before + i;
436
+ const seq = ordinal + 1 + i;
437
+ const deliveryContent = createHash('sha256').update(url, 'utf8').update('\0').update(body, 'utf8').digest('hex');
438
+ const identity = `world-events:v1:${service}:${seq}:${deliveryContent}`;
415
439
  const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
416
- const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
440
+ const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
417
441
  sent.push({ url, body, headers });
418
442
  // kept with the write, by an id of the delivery itself (two made at once never take the same one)
419
443
  if (decl.record !== undefined) {
420
444
  await applyTwinWrite(service, {
421
- operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_${digest(`${url}|${seconds}|${seq}|${body}`).slice(0, 16)}`,
445
+ operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_v1_${seq}`,
422
446
  fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
423
447
  }, write.root);
424
448
  }
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ export { verifyEvent } from './events.ts';
29
29
  export { deriveStateSystem, deriveVendorStateSystem } from './derived-real.ts';
30
30
  export type { IngestDecl } from './derived-core.ts';
31
31
  export type { SocketDecl, SocketEngine, SocketSession } from './sockets.ts';
32
+ // live media (architecture, "Other wires": live media): an ingest's declaration, its dialect and what a screen reads
33
+ export type { LiveAccess, LiveAdmission, LiveAudio, LiveDecl, LiveEngine, LivePublish, LiveRecording, LiveRefusal, LiveServeOptions, LiveVideo } from './live/types.ts';
32
34
  export { machinePool, isMachineCapabilityGap, type ExecSpec, type MachinePool, type MachineProcess, type MachineRun, type MachineSpec, type MachineStatus, type MachineFiles, type MachineFileStat } from './machines.ts';
33
35
  export type { CorsDecl } from './cors.ts';
34
36
 
@@ -51,7 +53,7 @@ export { VendorUnreachableError } from './vendor-call.ts';
51
53
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.ts';
52
54
 
53
55
  // ── the serve factory ──────────────────────────────────────────────────────────────────────────
54
- export { serveHttp, serveStream, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
56
+ export { serveHttp, serveStream, StreamListenerUnavailable, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
55
57
  export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
56
58
  export { privateTransportKey } from './private-transport.ts';
57
59
  // where a page links a hostname its twin answers, inside the World that shows it
@@ -88,6 +90,7 @@ export { placeholderPng } from './placeholder-image.ts';
88
90
  export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.ts';
89
91
  export * as redis from './redis/index.ts';
90
92
  export * as mysql from './mysql/index.ts';
93
+ export * as live from './live/index.ts';
91
94
  export * as git from './git/index.ts';
92
95
 
93
96
  // ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
@@ -0,0 +1,89 @@
1
+ // What a handler and a screen read of the World's recordings (`ctx.live`, architecture "Other wires": live media): a
2
+ // recording's facts from its bookkeeping subjects, and its playlists, init and segments answered as a media host
3
+ // answers them. The recording's rows are the tree's (`_live`, `_live_segment`); the bytes are the pack's resource blobs.
4
+ import { bytesResponse } from '../file-response.ts';
5
+ import { masterPlaylist, mediaPlaylist, type HlsSegment } from './hls.ts';
6
+ import type { LiveAccess, LiveAudio, LiveRecording, LiveVideo } from './types.ts';
7
+
8
+ /** The bookkeeping types a recording is kept in. */
9
+ export const LIVE = '_live';
10
+ export const LIVE_SEGMENT = '_live_segment';
11
+
12
+ /** Where a recording's bytes are kept among the pack's resource blobs. */
13
+ export const initBlob = (stream: string, init: number): string => `live/${stream}/init-${init}.mp4`;
14
+ export const segmentBlob = (stream: string, seq: number): string => `live/${stream}/seg-${seq}.m4s`;
15
+
16
+ type Row = Record<string, unknown>;
17
+ export type LiveReads = {
18
+ rows(type: string): Row[];
19
+ blob(key: string): Promise<Uint8Array | null>;
20
+ };
21
+
22
+ /** A recording's kept segments, in order. */
23
+ export function segmentsOf(reads: LiveReads, stream: string): Array<HlsSegment & { start: number; blob: string }> {
24
+ return reads.rows(LIVE_SEGMENT)
25
+ .filter((r) => r.stream === stream && r.deleted !== true)
26
+ .map((r) => ({ seq: Number(r.seq), init: Number(r.init), duration: Number(r.duration), size: Number(r.size), start: Number(r.start), blob: String(r.blob) }))
27
+ .sort((a, b) => a.seq - b.seq);
28
+ }
29
+
30
+ export function recordingOf(row: Row, segments: ReadonlyArray<HlsSegment>): LiveRecording {
31
+ return {
32
+ stream: String(row.id),
33
+ app: String(row.app ?? ''),
34
+ key: String(row.key ?? ''),
35
+ state: row.state === 'ended' ? 'ended' : 'publishing',
36
+ startedAt: String(row.startedAt ?? ''),
37
+ ...(typeof row.endedAt === 'string' ? { endedAt: row.endedAt } : {}),
38
+ publishes: Number(row.publishes ?? 1),
39
+ segments: segments.length,
40
+ duration: Math.round(segments.reduce((n, s) => n + s.duration, 0) * 1000) / 1000,
41
+ ...(row.video && typeof row.video === 'object' ? { video: row.video as LiveVideo } : {}),
42
+ ...(row.audio && typeof row.audio === 'object' ? { audio: row.audio as LiveAudio } : {}),
43
+ ...(row.metadata && typeof row.metadata === 'object' ? { metadata: row.metadata as Record<string, unknown> } : {}),
44
+ };
45
+ }
46
+
47
+ const CORS = { 'access-control-allow-origin': '*' };
48
+ const missing = (): Response => new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8', ...CORS } });
49
+
50
+ /** `ctx.live` over a request (the one a media host answers, for its Range and its method). */
51
+ export function liveAccess(reads: LiveReads, request: Request): LiveAccess {
52
+ const rowOf = (stream: string): Row | undefined => reads.rows(LIVE).find((r) => r.id === stream && r.deleted !== true);
53
+ return {
54
+ recording(stream) {
55
+ const row = rowOf(stream);
56
+ return row ? recordingOf(row, segmentsOf(reads, stream)) : undefined;
57
+ },
58
+ recordings() {
59
+ return reads.rows(LIVE).filter((r) => r.deleted !== true).map((r) => recordingOf(r, segmentsOf(reads, String(r.id))));
60
+ },
61
+ async serve(stream, file, options = {}) {
62
+ const row = rowOf(stream);
63
+ if (!row) return missing();
64
+ const segments = segmentsOf(reads, stream);
65
+ const ended = row.state === 'ended';
66
+ if (file === 'index.m3u8' || file === 'master.m3u8') {
67
+ // a recording with nothing playable yet has no playlist: a player waits, as for a stream not yet live
68
+ if (!segments.length) return missing();
69
+ const recording = recordingOf(row, segments);
70
+ const uri = options.uri ?? ((f: string) => f);
71
+ const text = file === 'index.m3u8'
72
+ ? mediaPlaylist({ state: recording.state, ...(typeof row.window === 'number' ? { window: row.window } : {}) }, segments, options)
73
+ : masterPlaylist(recording, segments, uri('index.m3u8'));
74
+ return new Response(request.method === 'HEAD' ? null : text, { headers: { 'content-type': 'application/vnd.apple.mpegurl', 'cache-control': ended ? 'public, max-age=3600' : 'no-cache, no-store', ...CORS } });
75
+ }
76
+ const init = /^init-(\d{1,9})\.mp4$/.exec(file);
77
+ const seg = /^seg-(\d{1,12})\.m4s$/.exec(file);
78
+ let key: string | undefined;
79
+ if (init && segments.some((s) => s.init === Number(init[1]))) key = initBlob(stream, Number(init[1]));
80
+ else if (seg) key = segments.find((s) => s.seq === Number(seg[1]))?.blob;
81
+ if (!key) return missing();
82
+ const bytes = await reads.blob(key);
83
+ if (!bytes) return missing();
84
+ const answer = bytesResponse(request, bytes, 'video/mp4');
85
+ for (const [k, v] of Object.entries(CORS)) answer.headers.set(k, v);
86
+ return answer;
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,80 @@
1
+ // AMF0, the encoding of RTMP's commands and data messages (Adobe's AMF0 specification, as RTMP 1.0 §7 uses it): a
2
+ // command is a sequence of values (its name, a transaction id, an object, its arguments). Read: number, boolean,
3
+ // string, object, null, undefined, ECMA array, strict array, date, long string, typed object; a reference (0x07) is read
4
+ // as undefined, never followed, so no value read is cyclic (a publisher's metadata pointing back at itself); anything
5
+ // else stops the read where it is. Written: number, boolean, string, object (and a plain record), null, undefined.
6
+
7
+ export type Amf0Value = number | boolean | string | null | undefined | Date | Amf0Value[] | { [key: string]: Amf0Value };
8
+
9
+ const utf8 = new TextDecoder();
10
+ const encoder = new TextEncoder();
11
+
12
+ /** Every value in `bytes`, in order; a value of a type AMF0 commands do not carry ends the read. */
13
+ export function decodeAmf0(bytes: Uint8Array): Amf0Value[] {
14
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
15
+ let at = 0;
16
+ const str = (length: number): string => { const s = utf8.decode(bytes.subarray(at, at + length)); at += length; return s; };
17
+ const key = (): string => { const n = view.getUint16(at); at += 2; return str(n); };
18
+ const properties = (into: Record<string, Amf0Value>): Record<string, Amf0Value> => {
19
+ for (;;) {
20
+ if (at + 3 > bytes.length) return into;
21
+ if (view.getUint16(at) === 0 && bytes[at + 2] === 0x09) { at += 3; return into; }
22
+ const name = key();
23
+ into[name] = value();
24
+ }
25
+ };
26
+ const value = (): Amf0Value => {
27
+ const marker = bytes[at++];
28
+ switch (marker) {
29
+ case 0x00: { const n = view.getFloat64(at); at += 8; return n; }
30
+ case 0x01: return bytes[at++] !== 0;
31
+ case 0x02: return key();
32
+ case 0x03: return properties({});
33
+ case 0x05: return null;
34
+ case 0x06: return undefined;
35
+ case 0x07: at += 2; return undefined;
36
+ case 0x08: at += 4; return properties({});
37
+ case 0x0a: {
38
+ const n = view.getUint32(at); at += 4;
39
+ const list: Amf0Value[] = [];
40
+ for (let i = 0; i < n && at < bytes.length; i++) list.push(value());
41
+ return list;
42
+ }
43
+ case 0x0b: { const ms = view.getFloat64(at); at += 10; return new Date(ms); }
44
+ case 0x0c: { const n = view.getUint32(at); at += 4; return str(n); }
45
+ case 0x10: key(); return properties({});
46
+ default: throw new Amf0End();
47
+ }
48
+ };
49
+ const out: Amf0Value[] = [];
50
+ while (at < bytes.length) {
51
+ try { out.push(value()); } catch (error) { if (error instanceof Amf0End || error instanceof RangeError) break; throw error; }
52
+ }
53
+ return out;
54
+ }
55
+ class Amf0End extends Error {}
56
+
57
+ /** `values` as AMF0, in order. */
58
+ export function encodeAmf0(...values: Amf0Value[]): Uint8Array {
59
+ const parts: number[] = [];
60
+ const u16 = (n: number): void => { parts.push((n >> 8) & 0xff, n & 0xff); };
61
+ const text = (s: string): void => { const b = encoder.encode(s); u16(b.length); for (const x of b) parts.push(x); };
62
+ const write = (v: Amf0Value): void => {
63
+ if (typeof v === 'number') {
64
+ parts.push(0x00);
65
+ const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v);
66
+ for (const x of b) parts.push(x);
67
+ } else if (typeof v === 'boolean') parts.push(0x01, v ? 1 : 0);
68
+ else if (typeof v === 'string') { parts.push(0x02); text(v); }
69
+ else if (v === null) parts.push(0x05);
70
+ else if (v === undefined) parts.push(0x06);
71
+ else if (v instanceof Date || Array.isArray(v)) throw new Error('amf0: this writer writes no dates or arrays');
72
+ else {
73
+ parts.push(0x03);
74
+ for (const [k, x] of Object.entries(v)) { text(k); write(x); }
75
+ parts.push(0x00, 0x00, 0x09);
76
+ }
77
+ };
78
+ for (const v of values) write(v);
79
+ return Uint8Array.from(parts);
80
+ }
@@ -0,0 +1,145 @@
1
+ // An FLV tag's body as RTMP carries it (FLV 10.1, Annex E.4.2 and E.4.3): video as AVC (codec 7, its packet type 0 the
2
+ // AVCDecoderConfigurationRecord, 1 length-prefixed NAL units with a composition time offset), audio as AAC (format 10, its
3
+ // packet type 0 the AudioSpecificConfig, 1 a raw frame). Anything else is named as the codec it is and not read: other
4
+ // codecs, and Enhanced RTMP's ex-header (its first byte's high bit), which carries HEVC, AV1 and multitrack video.
5
+
6
+ export type AvcConfig = {
7
+ /** the AVCDecoderConfigurationRecord as sent, which an `avcC` box holds as is (ISO/IEC 14496-15 §5.3.3.1) */
8
+ record: Uint8Array;
9
+ /** the RFC 6381 codec string: `avc1.` and the profile, constraint flags and level, in hex */
10
+ codec: string;
11
+ width: number;
12
+ height: number;
13
+ };
14
+ export type AacConfig = {
15
+ /** the AudioSpecificConfig as sent, which an `esds` box's DecoderSpecificInfo holds as is */
16
+ asc: Uint8Array;
17
+ codec: string;
18
+ objectType: number;
19
+ sampleRate: number;
20
+ channels: number;
21
+ };
22
+
23
+ export type VideoTag =
24
+ | { kind: 'config'; config: AvcConfig }
25
+ | { kind: 'frame'; key: boolean; cts: number; data: Uint8Array }
26
+ | { kind: 'other' }
27
+ | { kind: 'unsupported'; codec: string };
28
+ export type AudioTag =
29
+ | { kind: 'config'; config: AacConfig }
30
+ | { kind: 'frame'; data: Uint8Array }
31
+ | { kind: 'unsupported'; codec: string };
32
+
33
+ const VIDEO_CODECS: Record<number, string> = { 2: 'Sorenson H.263', 3: 'Screen video', 4: 'On2 VP6', 5: 'On2 VP6 with alpha', 6: 'Screen video 2', 12: 'HEVC', 13: 'AV1' };
34
+ const AUDIO_FORMATS: Record<number, string> = { 0: 'linear PCM', 1: 'ADPCM', 2: 'MP3', 3: 'linear PCM (little-endian)', 4: 'Nellymoser 16 kHz', 5: 'Nellymoser 8 kHz', 6: 'Nellymoser', 7: 'G.711 A-law', 8: 'G.711 mu-law', 11: 'Speex', 14: 'MP3 8 kHz', 15: 'device-specific' };
35
+ const hex2 = (n: number): string => n.toString(16).padStart(2, '0');
36
+
37
+ export function readVideoTag(p: Uint8Array): VideoTag {
38
+ if (p.length < 1) return { kind: 'other' };
39
+ if (p[0]! & 0x80) {
40
+ const fourcc = p.length >= 5 ? String.fromCharCode(p[1]!, p[2]!, p[3]!, p[4]!) : '?';
41
+ return { kind: 'unsupported', codec: `Enhanced RTMP ${fourcc}` };
42
+ }
43
+ const frameType = p[0]! >> 4;
44
+ const codec = p[0]! & 0x0f;
45
+ if (codec !== 7) return { kind: 'unsupported', codec: VIDEO_CODECS[codec] ?? `video codec ${codec}` };
46
+ // a video info/command frame (frame type 5) carries no picture
47
+ if (frameType === 5 || p.length < 5) return { kind: 'other' };
48
+ const packetType = p[1]!;
49
+ const cts = ((p[2]! << 16) | (p[3]! << 8) | p[4]!) << 8 >> 8;
50
+ const data = p.subarray(5);
51
+ if (packetType === 0) return { kind: 'config', config: avcConfig(data) };
52
+ if (packetType === 1) return { kind: 'frame', key: frameType === 1, cts, data };
53
+ return { kind: 'other' };
54
+ }
55
+
56
+ export function readAudioTag(p: Uint8Array): AudioTag {
57
+ if (p.length < 2) return { kind: 'frame', data: new Uint8Array(0) };
58
+ const format = p[0]! >> 4;
59
+ if (format !== 10) return { kind: 'unsupported', codec: AUDIO_FORMATS[format] ?? `audio format ${format}` };
60
+ if (p[1] === 0) return { kind: 'config', config: aacConfig(p.subarray(2)) };
61
+ return { kind: 'frame', data: p.subarray(2) };
62
+ }
63
+
64
+ function avcConfig(record: Uint8Array): AvcConfig {
65
+ const copy = record.slice();
66
+ const codec = copy.length >= 4 ? `avc1.${hex2(copy[1]!)}${hex2(copy[2]!)}${hex2(copy[3]!)}` : 'avc1.42e01e';
67
+ let width = 0;
68
+ let height = 0;
69
+ // the first SPS: after the version, profile, compatibility, level, length size and SPS count
70
+ if (copy.length > 8 && (copy[5]! & 0x1f) > 0) {
71
+ const length = (copy[6]! << 8) | copy[7]!;
72
+ try { ({ width, height } = spsSize(copy.subarray(8, 8 + length))); } catch { /* the metadata's size stands in */ }
73
+ }
74
+ return { record: copy, codec, width, height };
75
+ }
76
+
77
+ function aacConfig(asc: Uint8Array): AacConfig {
78
+ const copy = asc.slice();
79
+ const RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
80
+ const bits = new Bits(copy);
81
+ let objectType = bits.u(5);
82
+ if (objectType === 31) objectType = 32 + bits.u(6);
83
+ const index = bits.u(4);
84
+ const sampleRate = index === 15 ? bits.u(24) : RATES[index] ?? 44100;
85
+ const channels = bits.u(4);
86
+ return { asc: copy, codec: `mp4a.40.${objectType}`, objectType, sampleRate, channels };
87
+ }
88
+
89
+ /** A bit reader over bytes, MSB first; a read past the end reads zeros. */
90
+ class Bits {
91
+ private at = 0;
92
+ constructor(private readonly bytes: Uint8Array) {}
93
+ bit(): number { const byte = this.bytes[this.at >> 3] ?? 0; const v = (byte >> (7 - (this.at & 7))) & 1; this.at++; return v; }
94
+ u(n: number): number { let v = 0; for (let i = 0; i < n; i++) v = v * 2 + this.bit(); return v; }
95
+ ue(): number { let zeros = 0; while (this.bit() === 0) { if (++zeros > 31) throw new Error('exp-Golomb code too long'); } return 2 ** zeros - 1 + this.u(zeros); }
96
+ se(): number { const k = this.ue(); return k % 2 ? (k + 1) / 2 : -(k / 2); }
97
+ }
98
+
99
+ /** A sequence parameter set's picture size (H.264 §7.3.2.1.1), its cropping applied. */
100
+ function spsSize(nal: Uint8Array): { width: number; height: number } {
101
+ // the NAL unit's RBSP: its header byte off, each emulation-prevention byte (00 00 03) removed
102
+ const rbsp: number[] = [];
103
+ for (let i = 1; i < nal.length; i++) {
104
+ if (i >= 3 && nal[i] === 3 && nal[i - 1] === 0 && nal[i - 2] === 0) continue;
105
+ rbsp.push(nal[i]!);
106
+ }
107
+ const b = new Bits(Uint8Array.from(rbsp));
108
+ const profile = b.u(8);
109
+ b.u(16);
110
+ b.ue();
111
+ let chroma = 1;
112
+ if ([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135].includes(profile)) {
113
+ chroma = b.ue();
114
+ if (chroma === 3) b.bit();
115
+ b.ue(); b.ue(); b.bit();
116
+ if (b.bit()) {
117
+ for (let i = 0; i < (chroma === 3 ? 12 : 8); i++) {
118
+ if (!b.bit()) continue;
119
+ let last = 8; let next = 8;
120
+ for (let j = 0; j < (i < 6 ? 16 : 64); j++) {
121
+ if (next !== 0) next = (last + b.se() + 256) % 256;
122
+ last = next === 0 ? last : next;
123
+ }
124
+ }
125
+ }
126
+ }
127
+ b.ue();
128
+ const poc = b.ue();
129
+ if (poc === 0) b.ue();
130
+ else if (poc === 1) { b.bit(); b.se(); b.se(); const n = b.ue(); for (let i = 0; i < n; i++) b.se(); }
131
+ b.ue(); b.bit();
132
+ const widthMbs = b.ue() + 1;
133
+ const heightUnits = b.ue() + 1;
134
+ const frameMbsOnly = b.bit();
135
+ if (!frameMbsOnly) b.bit();
136
+ b.bit();
137
+ let crop = [0, 0, 0, 0];
138
+ if (b.bit()) crop = [b.ue(), b.ue(), b.ue(), b.ue()];
139
+ const unitX = chroma === 1 || chroma === 2 ? 2 : 1;
140
+ const unitY = (chroma === 1 ? 2 : 1) * (2 - frameMbsOnly);
141
+ return {
142
+ width: widthMbs * 16 - (crop[0]! + crop[1]!) * unitX,
143
+ height: (2 - frameMbsOnly) * heightUnits * 16 - (crop[2]! + crop[3]!) * unitY,
144
+ };
145
+ }
@@ -0,0 +1,102 @@
1
+ // Fragmented MP4, as HLS plays it (RFC 8216 §3.3) and Media Source Extensions append it (W3C's ISO BMFF byte stream
2
+ // format): an init segment (`ftyp`, `moov` with each track's sample description and `mvex`), then media segments, each
3
+ // one `moof` (a `traf` per track: its decode time and its samples' durations, sizes, flags and composition offsets) and
4
+ // one `mdat`. Video is track 1 at 90 kHz with the publisher's avcC as is; audio is track 2 at its sample rate with its
5
+ // AudioSpecificConfig in an `esds`. Boxes per ISO/IEC 14496-12 and -14 and -15.
6
+ import type { AacConfig, AvcConfig } from './flv.ts';
7
+
8
+ export const VIDEO_TRACK = 1;
9
+ export const AUDIO_TRACK = 2;
10
+ export const VIDEO_TIMESCALE = 90_000;
11
+
12
+ const ascii = (s: string): number[] => Array.from(s, (c) => c.charCodeAt(0));
13
+ const u16 = (n: number): number[] => [(n >> 8) & 0xff, n & 0xff];
14
+ const u32 = (n: number): number[] => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
15
+ const u64 = (n: number): number[] => [...u32(Math.floor(n / 2 ** 32)), ...u32(n % 2 ** 32)];
16
+ const zeros = (n: number): number[] => new Array<number>(n).fill(0);
17
+
18
+ type Part = number[] | Uint8Array;
19
+ function box(type: string, ...parts: Part[]): Uint8Array {
20
+ const size = 8 + parts.reduce((n, p) => n + p.length, 0);
21
+ const out = new Uint8Array(size);
22
+ out.set(u32(size), 0);
23
+ out.set(ascii(type), 4);
24
+ let at = 8;
25
+ for (const p of parts) { out.set(p, at); at += p.length; }
26
+ return out;
27
+ }
28
+ const full = (type: string, version: number, flags: number, ...parts: Part[]): Uint8Array => box(type, [version, (flags >> 16) & 0xff, (flags >> 8) & 0xff, flags & 0xff], ...parts);
29
+ const MATRIX = [...u32(0x00010000), ...u32(0), ...u32(0), ...u32(0), ...u32(0x00010000), ...u32(0), ...u32(0), ...u32(0), ...u32(0x40000000)];
30
+
31
+ function descriptor(tag: number, ...parts: Part[]): number[] {
32
+ const body = parts.flatMap((p) => Array.from(p));
33
+ // the size as four bytes of seven bits, as MP4 writers write it
34
+ const n = body.length;
35
+ return [tag, 0x80 | ((n >> 21) & 0x7f), 0x80 | ((n >> 14) & 0x7f), 0x80 | ((n >> 7) & 0x7f), n & 0x7f, ...body];
36
+ }
37
+
38
+ function trak(id: number, kind: 'video' | 'audio', timescale: number, width: number, height: number, entry: Uint8Array): Uint8Array {
39
+ const tkhd = full('tkhd', 0, 3, u32(0), u32(0), u32(id), u32(0), u32(0), zeros(8), u16(0), u16(0), u16(kind === 'audio' ? 0x0100 : 0), u16(0), MATRIX, u32(width * 65536), u32(height * 65536));
40
+ const mdhd = full('mdhd', 0, 0, u32(0), u32(0), u32(timescale), u32(0), u16(0x55c4), u16(0));
41
+ const hdlr = full('hdlr', 0, 0, u32(0), ascii(kind === 'video' ? 'vide' : 'soun'), zeros(12), [...ascii(kind === 'video' ? 'VideoHandler' : 'SoundHandler'), 0]);
42
+ const header = kind === 'video' ? full('vmhd', 0, 1, u16(0), zeros(6)) : full('smhd', 0, 0, u16(0), u16(0));
43
+ const dinf = box('dinf', full('dref', 0, 0, u32(1), full('url ', 0, 1)));
44
+ const stbl = box('stbl', full('stsd', 0, 0, u32(1), entry), full('stts', 0, 0, u32(0)), full('stsc', 0, 0, u32(0)), full('stsz', 0, 0, u32(0), u32(0)), full('stco', 0, 0, u32(0)));
45
+ return box('trak', tkhd, box('mdia', mdhd, hdlr, box('minf', header, dinf, stbl)));
46
+ }
47
+
48
+ /** The init segment for the tracks a publish carries. */
49
+ export function initSegment(tracks: { video?: AvcConfig & { width: number; height: number }; audio?: AacConfig }): Uint8Array {
50
+ const traks: Uint8Array[] = [];
51
+ const trex: Uint8Array[] = [];
52
+ const brands = ['isom', 'iso6', ...(tracks.video ? ['avc1'] : []), 'mp41'];
53
+ if (tracks.video) {
54
+ const v = tracks.video;
55
+ const avc1 = box('avc1', zeros(6), u16(1), u16(0), u16(0), zeros(12), u16(v.width), u16(v.height), u32(0x00480000), u32(0x00480000), u32(0), u16(1), zeros(32), u16(0x0018), u16(0xffff), box('avcC', v.record));
56
+ traks.push(trak(VIDEO_TRACK, 'video', VIDEO_TIMESCALE, v.width, v.height, avc1));
57
+ trex.push(full('trex', 0, 0, u32(VIDEO_TRACK), u32(1), u32(0), u32(0), u32(0)));
58
+ }
59
+ if (tracks.audio) {
60
+ const a = tracks.audio;
61
+ const esds = full('esds', 0, 0, descriptor(3, u16(0), [0], descriptor(4, [0x40, 0x15], zeros(3), u32(0), u32(0), descriptor(5, a.asc)), descriptor(6, [0x02])));
62
+ const mp4a = box('mp4a', zeros(6), u16(1), zeros(8), u16(Math.max(1, a.channels)), u16(16), u16(0), u16(0), u32((a.sampleRate > 0xffff ? 0 : a.sampleRate) * 65536), esds);
63
+ traks.push(trak(AUDIO_TRACK, 'audio', a.sampleRate, 0, 0, mp4a));
64
+ trex.push(full('trex', 0, 0, u32(AUDIO_TRACK), u32(1), u32(0), u32(0), u32(0)));
65
+ }
66
+ const mvhd = full('mvhd', 0, 0, u32(0), u32(0), u32(1000), u32(0), u32(0x00010000), u16(0x0100), u16(0), zeros(8), MATRIX, zeros(24), u32(3));
67
+ const ftyp = box('ftyp', ascii('isom'), u32(0x200), ...brands.map(ascii));
68
+ const moov = box('moov', mvhd, ...traks, box('mvex', ...trex));
69
+ const out = new Uint8Array(ftyp.length + moov.length);
70
+ out.set(ftyp, 0); out.set(moov, ftyp.length);
71
+ return out;
72
+ }
73
+
74
+ export type Sample = { duration: number; data: Uint8Array; key: boolean; cts?: number };
75
+ export type Fragment = { track: number; baseTime: number; samples: Sample[] };
76
+
77
+ /** A media segment: one `moof` over the fragments, then their samples in one `mdat`, video's first. */
78
+ export function mediaSegment(sequence: number, fragments: Fragment[]): Uint8Array {
79
+ const present = fragments.filter((f) => f.samples.length > 0);
80
+ const moofOf = (offsets: number[]): Uint8Array => box('moof', full('mfhd', 0, 0, u32(sequence)), ...present.map((f, i) => {
81
+ const video = f.track === VIDEO_TRACK;
82
+ // data offset, duration, size, flags and (video) composition offset per sample; version 1 for signed offsets
83
+ const flags = video ? 0x000f01 : 0x000301;
84
+ const rows = f.samples.flatMap((s) => [
85
+ ...u32(s.duration), ...u32(s.data.length),
86
+ ...(video ? [...u32(s.key ? 0x02000000 : 0x01010000), ...u32((s.cts ?? 0) | 0)] : []),
87
+ ]);
88
+ return box('traf', full('tfhd', 0, 0x020000, u32(f.track)), full('tfdt', 1, 0, u64(f.baseTime)), full('trun', video ? 1 : 0, flags, u32(f.samples.length), u32(offsets[i]! | 0), rows));
89
+ }));
90
+ const sized = moofOf(present.map(() => 0)).length;
91
+ const offsets: number[] = [];
92
+ let at = sized + 8;
93
+ for (const f of present) { offsets.push(at); at += f.samples.reduce((n, s) => n + s.data.length, 0); }
94
+ const moof = moofOf(offsets);
95
+ // one buffer for the whole segment, the samples written straight into its mdat: a segment's bytes are copied once
96
+ const out = new Uint8Array(at);
97
+ out.set(moof, 0);
98
+ out.set([...u32(at - moof.length), ...ascii('mdat')], moof.length);
99
+ let to = moof.length + 8;
100
+ for (const f of present) for (const s of f.samples) { out.set(s.data, to); to += s.data.length; }
101
+ return out;
102
+ }
@@ -0,0 +1,59 @@
1
+ // A recording's HLS playlists (RFC 8216): the media playlist over its fragmented-MP4 segments (§4.3: EXT-X-MAP names
2
+ // each segment's init, EXT-X-DISCONTINUITY where the init changes, EXT-X-ENDLIST once the recording ended) and a
3
+ // multivariant playlist naming that one rendition (§4.3.4.2: its bandwidth, codecs, resolution and frame rate). Pure.
4
+ import type { LiveAudio, LiveVideo } from './types.ts';
5
+
6
+ /** A segment as the recording keeps it. */
7
+ export type HlsSegment = { seq: number; init: number; duration: number; size: number };
8
+
9
+ const DEFAULT_WINDOW = 6;
10
+
11
+ /** The media playlist: the last `window` segments while the recording is published, every segment once it ended (or
12
+ * with `window: 'all'`, an event playlist). */
13
+ export function mediaPlaylist(
14
+ recording: { state: 'publishing' | 'ended'; window?: number },
15
+ segments: ReadonlyArray<HlsSegment>,
16
+ options: { window?: number | 'all'; uri?: (file: string) => string } = {},
17
+ ): string {
18
+ const uri = options.uri ?? ((file: string) => file);
19
+ const ended = recording.state === 'ended';
20
+ const window = options.window ?? recording.window ?? DEFAULT_WINDOW;
21
+ const listed = ended || window === 'all' ? [...segments] : segments.slice(Math.max(0, segments.length - Math.max(1, window)));
22
+ // every EXTINF, rounded, is at most the target (§4.3.3.1)
23
+ const target = Math.max(1, ...segments.map((s) => Math.round(s.duration)));
24
+ const first = listed[0];
25
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${target}`, `#EXT-X-MEDIA-SEQUENCE:${first?.seq ?? 0}`];
26
+ // each init after the recording's first is one discontinuity: those before the window are its sequence (§4.3.3.3)
27
+ if (first && first.init > 0) lines.push(`#EXT-X-DISCONTINUITY-SEQUENCE:${first.init}`);
28
+ if (ended) lines.push('#EXT-X-PLAYLIST-TYPE:VOD');
29
+ else if (window === 'all') lines.push('#EXT-X-PLAYLIST-TYPE:EVENT');
30
+ lines.push('#EXT-X-INDEPENDENT-SEGMENTS');
31
+ let init: number | undefined;
32
+ for (const s of listed) {
33
+ if (s.init !== init) {
34
+ if (init !== undefined) lines.push('#EXT-X-DISCONTINUITY');
35
+ lines.push(`#EXT-X-MAP:URI="${uri(`init-${s.init}.mp4`)}"`);
36
+ init = s.init;
37
+ }
38
+ lines.push(`#EXTINF:${s.duration.toFixed(3)},`, uri(`seg-${s.seq}.m4s`));
39
+ }
40
+ if (ended) lines.push('#EXT-X-ENDLIST');
41
+ return `${lines.join('\n')}\n`;
42
+ }
43
+
44
+ /** The multivariant playlist naming the recording's one rendition, its peak bandwidth measured from its segments. */
45
+ export function masterPlaylist(
46
+ recording: { video?: LiveVideo; audio?: LiveAudio },
47
+ segments: ReadonlyArray<HlsSegment>,
48
+ media: string,
49
+ ): string {
50
+ const peak = Math.max(1, ...segments.filter((s) => s.duration > 0).map((s) => Math.ceil((s.size * 8) / s.duration)));
51
+ const codecs = [recording.video?.codec, recording.audio?.codec].filter(Boolean).join(',');
52
+ const attributes = [
53
+ `BANDWIDTH=${peak}`,
54
+ ...(codecs ? [`CODECS="${codecs}"`] : []),
55
+ ...(recording.video && recording.video.width && recording.video.height ? [`RESOLUTION=${recording.video.width}x${recording.video.height}`] : []),
56
+ ...(recording.video?.frameRate ? [`FRAME-RATE=${recording.video.frameRate.toFixed(3)}`] : []),
57
+ ];
58
+ return `#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-STREAM-INF:${attributes.join(',')}\n${media}\n`;
59
+ }
@@ -0,0 +1,13 @@
1
+ // @volter/world-core/live — live media (docs/contributing/architecture.md, "Other wires": live media): RTMP ingest as
2
+ // OBS and ffmpeg publish, recorded in the World, played back as HLS over fragmented MP4. A pack declares an ingest in its
3
+ // manifest's `live`, writes its dialect in `semantics/live.ts` (a `LiveEngine` per ingest), serves its fetch's `ingest`
4
+ // on a side port, and answers its media host with `ctx.live.serve`. The root exports this module as `live`.
5
+ export type { LiveAccess, LiveAdmission, LiveAudio, LiveDecl, LiveEngine, LivePublish, LiveRecording, LiveRefusal, LiveServeOptions, LiveVideo } from './types.ts';
6
+ export { liveIngest, type LiveIngestOptions, type LiveWorld } from './ingest.ts';
7
+ export { liveAccess, LIVE, LIVE_SEGMENT, type LiveReads } from './access.ts';
8
+ export { masterPlaylist, mediaPlaylist, type HlsSegment } from './hls.ts';
9
+ export { rtmpSession, type RtmpPublisher } from './rtmp.ts';
10
+ export { decodeAmf0, encodeAmf0, type Amf0Value } from './amf0.ts';
11
+ export { readAudioTag, readVideoTag, type AacConfig, type AudioTag, type AvcConfig, type VideoTag } from './flv.ts';
12
+ export { initSegment, mediaSegment, type Fragment, type Sample } from './fmp4.ts';
13
+ export { Segmenter, type Cut } from './segmenter.ts';