@volter/world-core 3.0.151 → 3.0.152

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 (49) 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/index.d.ts +3 -1
  5. package/dist/src/index.js +2 -1
  6. package/dist/src/live/access.d.ts +22 -0
  7. package/dist/src/live/access.js +83 -0
  8. package/dist/src/live/amf0.d.ts +7 -0
  9. package/dist/src/live/amf0.js +122 -0
  10. package/dist/src/live/flv.d.ts +42 -0
  11. package/dist/src/live/flv.js +148 -0
  12. package/dist/src/live/fmp4.d.ts +25 -0
  13. package/dist/src/live/fmp4.js +96 -0
  14. package/dist/src/live/hls.d.ts +22 -0
  15. package/dist/src/live/hls.js +46 -0
  16. package/dist/src/live/index.d.ts +9 -0
  17. package/dist/src/live/index.js +8 -0
  18. package/dist/src/live/ingest.d.ts +25 -0
  19. package/dist/src/live/ingest.js +229 -0
  20. package/dist/src/live/rtmp.d.ts +35 -0
  21. package/dist/src/live/rtmp.js +456 -0
  22. package/dist/src/live/segmenter.d.ts +57 -0
  23. package/dist/src/live/segmenter.js +204 -0
  24. package/dist/src/live/types.d.ts +102 -0
  25. package/dist/src/live/types.js +5 -0
  26. package/dist/src/pack-fetch.d.ts +4 -0
  27. package/dist/src/pack-fetch.js +24 -2
  28. package/dist/src/packRegistry.d.ts +1 -0
  29. package/dist/src/serve-http.d.ts +7 -1
  30. package/dist/src/serve-http.js +36 -3
  31. package/dist/src/twin-fetch.d.ts +4 -1
  32. package/package.json +5 -1
  33. package/src/derived-core.ts +10 -0
  34. package/src/derived.ts +4 -0
  35. package/src/index.ts +4 -1
  36. package/src/live/access.ts +89 -0
  37. package/src/live/amf0.ts +80 -0
  38. package/src/live/flv.ts +145 -0
  39. package/src/live/fmp4.ts +102 -0
  40. package/src/live/hls.ts +59 -0
  41. package/src/live/index.ts +13 -0
  42. package/src/live/ingest.ts +210 -0
  43. package/src/live/rtmp.ts +375 -0
  44. package/src/live/segmenter.ts +218 -0
  45. package/src/live/types.ts +105 -0
  46. package/src/pack-fetch.ts +28 -2
  47. package/src/packRegistry.ts +1 -1
  48. package/src/serve-http.ts +29 -4
  49. package/src/twin-fetch.ts +4 -2
@@ -0,0 +1,22 @@
1
+ import type { LiveAudio, LiveVideo } from './types.js';
2
+ /** A segment as the recording keeps it. */
3
+ export type HlsSegment = {
4
+ seq: number;
5
+ init: number;
6
+ duration: number;
7
+ size: number;
8
+ };
9
+ /** The media playlist: the last `window` segments while the recording is published, every segment once it ended (or
10
+ * with `window: 'all'`, an event playlist). */
11
+ export declare function mediaPlaylist(recording: {
12
+ state: 'publishing' | 'ended';
13
+ window?: number;
14
+ }, segments: ReadonlyArray<HlsSegment>, options?: {
15
+ window?: number | 'all';
16
+ uri?: (file: string) => string;
17
+ }): string;
18
+ /** The multivariant playlist naming the recording's one rendition, its peak bandwidth measured from its segments. */
19
+ export declare function masterPlaylist(recording: {
20
+ video?: LiveVideo;
21
+ audio?: LiveAudio;
22
+ }, segments: ReadonlyArray<HlsSegment>, media: string): string;
@@ -0,0 +1,46 @@
1
+ const DEFAULT_WINDOW = 6;
2
+ /** The media playlist: the last `window` segments while the recording is published, every segment once it ended (or
3
+ * with `window: 'all'`, an event playlist). */
4
+ export function mediaPlaylist(recording, segments, options = {}) {
5
+ const uri = options.uri ?? ((file) => file);
6
+ const ended = recording.state === 'ended';
7
+ const window = options.window ?? recording.window ?? DEFAULT_WINDOW;
8
+ const listed = ended || window === 'all' ? [...segments] : segments.slice(Math.max(0, segments.length - Math.max(1, window)));
9
+ // every EXTINF, rounded, is at most the target (§4.3.3.1)
10
+ const target = Math.max(1, ...segments.map((s) => Math.round(s.duration)));
11
+ const first = listed[0];
12
+ const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${target}`, `#EXT-X-MEDIA-SEQUENCE:${first?.seq ?? 0}`];
13
+ // each init after the recording's first is one discontinuity: those before the window are its sequence (§4.3.3.3)
14
+ if (first && first.init > 0)
15
+ lines.push(`#EXT-X-DISCONTINUITY-SEQUENCE:${first.init}`);
16
+ if (ended)
17
+ lines.push('#EXT-X-PLAYLIST-TYPE:VOD');
18
+ else if (window === 'all')
19
+ lines.push('#EXT-X-PLAYLIST-TYPE:EVENT');
20
+ lines.push('#EXT-X-INDEPENDENT-SEGMENTS');
21
+ let init;
22
+ for (const s of listed) {
23
+ if (s.init !== init) {
24
+ if (init !== undefined)
25
+ lines.push('#EXT-X-DISCONTINUITY');
26
+ lines.push(`#EXT-X-MAP:URI="${uri(`init-${s.init}.mp4`)}"`);
27
+ init = s.init;
28
+ }
29
+ lines.push(`#EXTINF:${s.duration.toFixed(3)},`, uri(`seg-${s.seq}.m4s`));
30
+ }
31
+ if (ended)
32
+ lines.push('#EXT-X-ENDLIST');
33
+ return `${lines.join('\n')}\n`;
34
+ }
35
+ /** The multivariant playlist naming the recording's one rendition, its peak bandwidth measured from its segments. */
36
+ export function masterPlaylist(recording, segments, media) {
37
+ const peak = Math.max(1, ...segments.filter((s) => s.duration > 0).map((s) => Math.ceil((s.size * 8) / s.duration)));
38
+ const codecs = [recording.video?.codec, recording.audio?.codec].filter(Boolean).join(',');
39
+ const attributes = [
40
+ `BANDWIDTH=${peak}`,
41
+ ...(codecs ? [`CODECS="${codecs}"`] : []),
42
+ ...(recording.video && recording.video.width && recording.video.height ? [`RESOLUTION=${recording.video.width}x${recording.video.height}`] : []),
43
+ ...(recording.video?.frameRate ? [`FRAME-RATE=${recording.video.frameRate.toFixed(3)}`] : []),
44
+ ];
45
+ return `#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-STREAM-INF:${attributes.join(',')}\n${media}\n`;
46
+ }
@@ -0,0 +1,9 @@
1
+ export type { LiveAccess, LiveAdmission, LiveAudio, LiveDecl, LiveEngine, LivePublish, LiveRecording, LiveRefusal, LiveServeOptions, LiveVideo } from './types.js';
2
+ export { liveIngest, type LiveIngestOptions, type LiveWorld } from './ingest.js';
3
+ export { liveAccess, LIVE, LIVE_SEGMENT, type LiveReads } from './access.js';
4
+ export { masterPlaylist, mediaPlaylist, type HlsSegment } from './hls.js';
5
+ export { rtmpSession, type RtmpPublisher } from './rtmp.js';
6
+ export { decodeAmf0, encodeAmf0, type Amf0Value } from './amf0.js';
7
+ export { readAudioTag, readVideoTag, type AacConfig, type AudioTag, type AvcConfig, type VideoTag } from './flv.js';
8
+ export { initSegment, mediaSegment, type Fragment, type Sample } from './fmp4.js';
9
+ export { Segmenter, type Cut } from './segmenter.js';
@@ -0,0 +1,8 @@
1
+ export { liveIngest } from "./ingest.js";
2
+ export { liveAccess, LIVE, LIVE_SEGMENT } from "./access.js";
3
+ export { masterPlaylist, mediaPlaylist } from "./hls.js";
4
+ export { rtmpSession } from "./rtmp.js";
5
+ export { decodeAmf0, encodeAmf0 } from "./amf0.js";
6
+ export { readAudioTag, readVideoTag } from "./flv.js";
7
+ export { initSegment, mediaSegment } from "./fmp4.js";
8
+ export { Segmenter } from "./segmenter.js";
@@ -0,0 +1,25 @@
1
+ import type { TwinStream } from '../twin-fetch.js';
2
+ import { type LiveReads } from './access.js';
3
+ import type { LiveDecl, LiveEngine, LivePublish } from './types.js';
4
+ /** What the ingest reads and writes of the World, bound by the pack's fetch to its service and root. */
5
+ export type LiveWorld = LiveReads & {
6
+ /** a bookkeeping subject written (or, with `deleted: true`, deleted) at the World's time */
7
+ record(type: string, id: string, fields: Record<string, unknown>, operation: string): Promise<void>;
8
+ putBlob(key: string, bytes: Uint8Array): Promise<void>;
9
+ removeBlob(key: string): Promise<void>;
10
+ now(): string;
11
+ };
12
+ export type LiveIngestOptions<C> = {
13
+ /** the World this ingest's recordings are kept in (its service and root): one publisher per stream within it */
14
+ world: string;
15
+ decls: ReadonlyArray<LiveDecl>;
16
+ engines: Record<string, LiveEngine<C>>;
17
+ readOnly: boolean;
18
+ /** the handler contract's context a hook runs over, opened at the moment it runs */
19
+ open(decl: LiveDecl, ask: LivePublish): Promise<C>;
20
+ /** runs a hook and the writes it causes as the vendor's own move, one correlation */
21
+ run<T>(fn: () => Promise<T>): Promise<T>;
22
+ store: LiveWorld;
23
+ log?(line: string): void;
24
+ };
25
+ export declare function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream;
@@ -0,0 +1,229 @@
1
+ import { initBlob, LIVE, LIVE_SEGMENT, recordingOf, segmentBlob, segmentsOf } from "./access.js";
2
+ import { readAudioTag, readVideoTag } from "./flv.js";
3
+ import { RtmpRefusal, rtmpSession } from "./rtmp.js";
4
+ import { LiveOverrun, Segmenter } from "./segmenter.js";
5
+ const STREAM_ID = /^[A-Za-z0-9._-]{1,128}$/;
6
+ /** Connections one ingest's listener takes at once: publishers and the connections still on their way to publishing. */
7
+ const MAX_CONNECTIONS = 32;
8
+ /** The streams being published now, per World: a second publisher to one is refused, as a vendor's ingest refuses it. */
9
+ const publishing = new Map();
10
+ export function liveIngest(options) {
11
+ for (const d of options.decls)
12
+ if (!options.engines[d.id])
13
+ throw new Error(`the manifest declares the ingest ${d.id}, and semantics/live.ts exports no ${d.id}`);
14
+ const log = options.log ?? ((line) => console.error(`live: ${line}`));
15
+ const store = options.store;
16
+ const active = publishing.get(options.world) ?? publishing.set(options.world, new Set()).get(options.world);
17
+ // the listener's cap (serveStream reads it): a World's publishers are its encoders, a few at once
18
+ return Object.assign((sink, peer) => {
19
+ let decl;
20
+ let ask;
21
+ let stream;
22
+ let admitted;
23
+ let segmenter;
24
+ let initIndex = -1;
25
+ let priorInit;
26
+ let started = false;
27
+ let metadata;
28
+ let refusedCodec;
29
+ const recording = () => {
30
+ const row = store.rows(LIVE).find((r) => r.id === stream);
31
+ return recordingOf(row, segmentsOf(store, stream));
32
+ };
33
+ const hook = async (name) => {
34
+ const engine = options.engines[decl.id];
35
+ const fn = engine[name];
36
+ if (!fn)
37
+ return;
38
+ try {
39
+ await options.run(async () => fn.call(engine, await options.open(decl, ask), recording()));
40
+ }
41
+ catch (error) {
42
+ log(`${decl.id}'s ${name} for ${stream} was not written: ${String(error?.message ?? error)}`);
43
+ }
44
+ };
45
+ /** A closed segment kept: its init first when it opens one, its bytes, its subject; then what `retain` lets go. */
46
+ const keep = async (cut) => {
47
+ if (!stream || !admitted)
48
+ return;
49
+ if (cut.init) {
50
+ // a publisher that reconnects with the encoder it had plays on under the recording's last init, its timeline
51
+ // carried on, so no player meets a discontinuity; a changed configuration opens the next init
52
+ const prior = initIndex < 0 && priorInit !== undefined ? await store.blob(initBlob(stream, priorInit)) : null;
53
+ if (prior && sameBytes(prior, cut.init.bytes))
54
+ initIndex = priorInit;
55
+ else {
56
+ initIndex = initIndex < 0 ? (priorInit ?? -1) + 1 : initIndex + 1;
57
+ await store.putBlob(initBlob(stream, initIndex), cut.init.bytes);
58
+ }
59
+ await options.run(() => store.record(LIVE, stream, {
60
+ ...(cut.init.video ? { video: cut.init.video } : {}),
61
+ ...(cut.init.audio ? { audio: cut.init.audio } : {}),
62
+ ...(metadata ? { metadata } : {}),
63
+ }, 'live.configure'));
64
+ }
65
+ const blob = segmentBlob(stream, cut.sequence);
66
+ await store.putBlob(blob, cut.bytes);
67
+ await options.run(() => store.record(LIVE_SEGMENT, `${stream}:${cut.sequence}`, {
68
+ stream, seq: cut.sequence, init: initIndex, start: round(cut.start), duration: round(cut.duration), size: cut.bytes.length, blob,
69
+ }, 'live.segment'));
70
+ if (admitted.retain !== undefined && admitted.retain > 0) {
71
+ for (const old of segmentsOf(store, stream).slice(0, -admitted.retain)) {
72
+ await store.removeBlob(old.blob);
73
+ await options.run(() => store.record(LIVE_SEGMENT, `${stream}:${old.seq}`, { deleted: true }, 'live.segment.expire'));
74
+ }
75
+ }
76
+ if (!started) {
77
+ started = true;
78
+ await hook('started');
79
+ }
80
+ };
81
+ const keepAll = async (cuts) => { for (const cut of cuts)
82
+ await keep(cut); };
83
+ return rtmpSession(sink, {
84
+ connect(app) {
85
+ decl = options.decls.find((d) => d.app === app);
86
+ return decl ? undefined : `There is no application ${app || '(none)'} here.`;
87
+ },
88
+ async publish(asked) {
89
+ if (!decl)
90
+ return { code: 'NetStream.Publish.BadName', description: 'No application was connected.' };
91
+ if (options.readOnly)
92
+ return { code: 'NetStream.Publish.Denied', description: 'This World is read-only: it records nothing.' };
93
+ const [key = '', query = ''] = splitOnce(asked.name, '?');
94
+ ask = {
95
+ app: asked.app, key, query: Object.fromEntries(new URLSearchParams(query)), peer,
96
+ ...(asked.tcUrl !== undefined ? { tcUrl: asked.tcUrl } : {}), ...(asked.flashVer !== undefined ? { flashVer: asked.flashVer } : {}), ...(asked.type !== undefined ? { type: asked.type } : {}),
97
+ };
98
+ const engine = options.engines[decl.id];
99
+ const ruling = await options.run(async () => engine.publish(await options.open(decl, ask), ask));
100
+ if ('refused' in ruling)
101
+ return { code: ruling.refused, description: ruling.description };
102
+ if (!STREAM_ID.test(ruling.stream))
103
+ throw new Error(`${decl.id}'s publish named the stream ${JSON.stringify(ruling.stream)}; a stream is 1-128 letters, digits, '.', '_' or '-'`);
104
+ if (active.has(ruling.stream))
105
+ return { code: 'NetStream.Publish.BadName', description: `The stream ${ruling.stream} is already being published.` };
106
+ const named = ruling.stream;
107
+ active.add(named);
108
+ try {
109
+ // a recording continued: its segments' numbers, its inits' and its timeline carry on from where it stood
110
+ const prior = store.rows(LIVE).find((r) => r.id === named && r.deleted !== true);
111
+ const kept = segmentsOf(store, named);
112
+ const last = kept[kept.length - 1];
113
+ const offsetMs = last ? Math.ceil((last.start + last.duration) * 1000) : 0;
114
+ const now = store.now();
115
+ await options.run(() => store.record(LIVE, named, {
116
+ app: ask.app, key: ask.key, state: 'publishing', startedAt: typeof prior?.startedAt === 'string' ? prior.startedAt : now, endedAt: null,
117
+ publishes: Number(prior?.publishes ?? 0) + 1, window: ruling.window ?? 6, segmentSeconds: ruling.segmentSeconds ?? 2, ...(ruling.retain !== undefined ? { retain: ruling.retain } : {}),
118
+ }, 'live.publish'));
119
+ priorInit = last?.init;
120
+ segmenter = new Segmenter({ targetMs: Math.max(0.5, ruling.segmentSeconds ?? 2) * 1000, offsetMs, firstSequence: last ? last.seq + 1 : 0 });
121
+ stream = named;
122
+ admitted = ruling;
123
+ }
124
+ catch (error) {
125
+ // the publish was not recorded: the stream is free again, and the publisher refused (rtmp.ts)
126
+ active.delete(named);
127
+ throw error;
128
+ }
129
+ return undefined;
130
+ },
131
+ metadata(meta) {
132
+ const flat = flatMetadata(meta);
133
+ metadata = { ...(metadata ?? {}), ...flat };
134
+ segmenter?.metadata(flat);
135
+ },
136
+ async video(timestamp, payload) {
137
+ if (!segmenter)
138
+ return;
139
+ const tag = readVideoTag(payload);
140
+ if (tag.kind === 'unsupported') {
141
+ if (refusedCodec !== tag.codec)
142
+ log(`${stream}: ${tag.codec} video is not recorded (H.264 is)`);
143
+ refusedCodec = tag.codec;
144
+ return;
145
+ }
146
+ const s = segmenter;
147
+ if (tag.kind === 'config')
148
+ await keepAll(s.videoConfig(tag.config));
149
+ else if (tag.kind === 'frame')
150
+ await keepAll(overrun(() => s.videoFrame(timestamp, tag.key, tag.cts, tag.data)));
151
+ },
152
+ async audio(timestamp, payload) {
153
+ if (!segmenter)
154
+ return;
155
+ const tag = readAudioTag(payload);
156
+ if (tag.kind === 'unsupported') {
157
+ if (refusedCodec !== tag.codec)
158
+ log(`${stream}: ${tag.codec} audio is not recorded (AAC is)`);
159
+ refusedCodec = tag.codec;
160
+ return;
161
+ }
162
+ const s = segmenter;
163
+ if (tag.kind === 'config')
164
+ await keepAll(s.audioConfig(tag.config));
165
+ else
166
+ await keepAll(overrun(() => s.audioFrame(timestamp, tag.data)));
167
+ },
168
+ async unpublish() {
169
+ if (!stream || !segmenter)
170
+ return;
171
+ const s = segmenter;
172
+ segmenter = undefined;
173
+ // the recording always ends: what was cut is kept where it can be, and the end is written whatever the last
174
+ // segment did, so no playlist is left waiting on a publisher that is gone
175
+ try {
176
+ await keepAll(s.flush());
177
+ }
178
+ catch (error) {
179
+ log(`${stream}: the last segment was not kept: ${String(error?.message ?? error)}`);
180
+ }
181
+ try {
182
+ await options.run(() => store.record(LIVE, stream, { state: 'ended', endedAt: store.now() }, 'live.end'));
183
+ }
184
+ catch (error) {
185
+ log(`${stream}: its end was not written: ${String(error?.message ?? error)}`);
186
+ }
187
+ finally {
188
+ active.delete(stream);
189
+ }
190
+ await hook('ended');
191
+ },
192
+ }, log);
193
+ }, { maxConnections: MAX_CONNECTIONS });
194
+ }
195
+ const round = (n) => Math.round(n * 1000) / 1000;
196
+ /** What a segmenter refuses, as RTMP's refusal of the publish (rtmp.ts ends it and closes the connection). */
197
+ function overrun(fn) {
198
+ try {
199
+ return fn();
200
+ }
201
+ catch (error) {
202
+ throw error instanceof LiveOverrun ? new RtmpRefusal('NetStream.Failed', error.message) : error;
203
+ }
204
+ }
205
+ /** The publisher's metadata as the World keeps it: its flat values (`onMetaData` is one level: an encoder's name, sizes,
206
+ * rates), at most 64 of them, a string at most 256 characters; nested values are not kept, so nothing it sent (a
207
+ * reference back to itself among them) can make the recording's row unwritable. */
208
+ function flatMetadata(meta) {
209
+ const out = {};
210
+ for (const [key, value] of Object.entries(meta).slice(0, 64)) {
211
+ if (typeof value === 'number' ? Number.isFinite(value) : typeof value === 'boolean')
212
+ out[key.slice(0, 64)] = value;
213
+ else if (typeof value === 'string')
214
+ out[key.slice(0, 64)] = value.slice(0, 256);
215
+ }
216
+ return out;
217
+ }
218
+ function sameBytes(a, b) {
219
+ if (a.length !== b.length)
220
+ return false;
221
+ for (let i = 0; i < a.length; i++)
222
+ if (a[i] !== b[i])
223
+ return false;
224
+ return true;
225
+ }
226
+ function splitOnce(s, sep) {
227
+ const at = s.indexOf(sep);
228
+ return at < 0 ? [s, ''] : [s.slice(0, at), s.slice(at + 1)];
229
+ }
@@ -0,0 +1,35 @@
1
+ import type { TwinStreamConnection, TwinStreamSink } from '../twin-fetch.js';
2
+ import { type Amf0Value } from './amf0.js';
3
+ /** What the session asks of its caller. */
4
+ export type RtmpPublisher = {
5
+ /** `connect`: the application the publisher names, and its command object; a string refuses it, with that reason */
6
+ connect(app: string, command: Record<string, Amf0Value>): string | undefined;
7
+ /** `publish`: whether the stream may be published, or RTMP's refusal (its code and description) */
8
+ publish(ask: {
9
+ app: string;
10
+ name: string;
11
+ type?: string;
12
+ tcUrl?: string;
13
+ flashVer?: string;
14
+ }): Promise<{
15
+ code: string;
16
+ description: string;
17
+ } | undefined>;
18
+ /** `@setDataFrame` / `onMetaData`, as published */
19
+ metadata(meta: Record<string, Amf0Value>): void | Promise<void>;
20
+ /** an FLV audio or video tag's body, at its RTMP timestamp (milliseconds); an `RtmpRefusal` thrown ends the publish */
21
+ audio(timestamp: number, payload: Uint8Array): void | Promise<void>;
22
+ video(timestamp: number, payload: Uint8Array): void | Promise<void>;
23
+ /** the publish ended (`FCUnpublish`, `closeStream`, `deleteStream`, the connection ended while publishing, or a
24
+ * refusal of what was published) */
25
+ unpublish(): void | Promise<void>;
26
+ };
27
+ /** What a publisher's media can draw mid-publish: its `onStatus` error, then the publish ended and the connection closed. */
28
+ export declare class RtmpRefusal extends Error {
29
+ readonly code: string;
30
+ constructor(code: string, description: string);
31
+ }
32
+ /** One publisher's connection: its bytes in, the server's answers out through the sink. */
33
+ export declare function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log?: (line: string) => void): TwinStreamConnection & {
34
+ backlog(): number;
35
+ };