@camstack/addon-pipeline 1.2.284 → 1.2.285
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-XmUJCdXn.js → default-detection-model-B9sgmjNE.js} +1 -1
- package/dist/{default-detection-model-DuK-Xu10.mjs → default-detection-model-BbaeqJWd.mjs} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-CWJ_t5jq.mjs → dist-L6fspiMs.mjs} +1479 -3
- package/dist/{dist-BS_GvN2R.js → dist-bAVvh6zw.js} +1496 -2
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-CqOIo6CL.js → node-BAHXRXps.js} +1 -1
- package/dist/{node-Cz6ByejK.mjs → node-BKSzm60G.mjs} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-BFycx9BF.js → process-memory-B5Enn1QL.js} +1 -1
- package/dist/{process-memory-DAwCuIeI.mjs → process-memory-gIGkgoj7.mjs} +1 -1
- package/dist/recorder/index.js +2 -2
- package/dist/recorder/index.mjs +2 -2
- package/dist/{segment-demux-js-r4t4zgdr.js → segment-demux-js-BCxiqxMJ.js} +166 -148
- package/dist/{segment-demux-js-gLuKs5Mg.mjs → segment-demux-js-DIMPKsLl.mjs} +149 -149
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CROj7Fwq.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BQppCOBh.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BxqJbWC8.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DWcIgV47.mjs +26 -0
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-DvNcV7kj.mjs → hostInit-BMoj1Wh2.mjs} +2 -2
- package/dist/stream-broker/index.js +2077 -36
- package/dist/stream-broker/index.mjs +2077 -36
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DByJbFCy.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BnLVuoDg.mjs +0 -26
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import { $t as
|
|
3
|
-
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-
|
|
2
|
+
import { $t as isSoftwareDecode, A as RATE_CONTROL_RELAXED, An as string, At as rankStreams, C as G711_SCALE_CORRECTION_DB, Cn as discriminatedUnion, Dn as object, En as number, F as RingBuffer, G as clipExportFailureOf, H as batteryCapability, It as resolveEgressDecodeHwAccel, J as declareLogChannel, Jt as errMsg, K as createHwAccelCache, Mn as EventCategory, Qt as invocationFromEncodeProfile, R as addonWidgetsSourceCapability, Sn as boolean, Tn as literal, U as cameraStreamsCapability, Ut as streamBrokerCapability, X as deriveBatteryPresence, Yt as AUDIO_PRESETS, Zt as buildFfmpegArgs$1, _n as parseProfileBrokerId, an as DeviceType, b as EncodeProfileSchema, bn as _enum, en as BOOT_RECOVERY_BACKOFF_MS, et as egressTranscodeSharingKey, fn as isEvent, gt as maskUrlCredentials, hn as nodePin, i as BatteryStatusSchema, in as DeviceFeature, j as RATE_CONTROL_TIGHT, jn as union, kn as record, mn as makeSourceBrokerId, mt as logChannelsCapability, nn as CAM_PROFILE_ORDER, pn as makeProfileBrokerId, pt as loadContributionCapability, q as createLogChannelsProvider, qt as webrtcSessionCapability, rn as CamProfileSchema, sn as createEvent, tn as BaseAddon, tt as egressTransportFromRequest, un as expandAudioChunkToF32le, xn as array } from "../dist-L6fspiMs.mjs";
|
|
3
|
+
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BKSzm60G.mjs";
|
|
4
4
|
import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
|
|
5
5
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
6
6
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
7
|
-
import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-
|
|
7
|
+
import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-DIMPKsLl.mjs";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
@@ -24,6 +24,7 @@ import { createSocket } from "node:dgram";
|
|
|
24
24
|
import { once } from "events";
|
|
25
25
|
import { Socket } from "net";
|
|
26
26
|
import { lookup } from "node:dns/promises";
|
|
27
|
+
import { request } from "node:http";
|
|
27
28
|
import { Worker } from "node:worker_threads";
|
|
28
29
|
import { EventEmitter } from "node:events";
|
|
29
30
|
//#region src/stream-broker/client-log-ingest.ts
|
|
@@ -26686,6 +26687,21 @@ var ServerMessageSchema = discriminatedUnion("t", [
|
|
|
26686
26687
|
t: literal("clipDuration"),
|
|
26687
26688
|
clipId: string().min(1),
|
|
26688
26689
|
durationMs: number().positive()
|
|
26690
|
+
}),
|
|
26691
|
+
object({
|
|
26692
|
+
t: literal("clipAudio"),
|
|
26693
|
+
clipId: string().min(1),
|
|
26694
|
+
carried: boolean(),
|
|
26695
|
+
codec: _enum(["opus", "pcmu"]).optional(),
|
|
26696
|
+
/** Why there is no sound. Present iff `carried` is false. */
|
|
26697
|
+
reason: _enum([
|
|
26698
|
+
"not-negotiated",
|
|
26699
|
+
"not-offered",
|
|
26700
|
+
"no-audio-track",
|
|
26701
|
+
"unsupported-codec",
|
|
26702
|
+
"codec-mismatch",
|
|
26703
|
+
"stream-failed"
|
|
26704
|
+
]).optional()
|
|
26689
26705
|
})
|
|
26690
26706
|
]);
|
|
26691
26707
|
function parseControlMessage(raw) {
|
|
@@ -27263,7 +27279,7 @@ var SEGMENT_TIMEOUT_MS = 8e3;
|
|
|
27263
27279
|
* 2 ms, p95 202 ms, max 285 ms), so a segment that CAN yield audio still does.
|
|
27264
27280
|
*/
|
|
27265
27281
|
var AUDIO_DEMUX_TIMEOUT_MS = 1500;
|
|
27266
|
-
var POSITION_INTERVAL_MS = 1e3;
|
|
27282
|
+
var POSITION_INTERVAL_MS$1 = 1e3;
|
|
27267
27283
|
/** How often `recorded:play-census` fires while the advance loop is alive. */
|
|
27268
27284
|
var PLAY_CENSUS_INTERVAL_TICKS = 5;
|
|
27269
27285
|
/**
|
|
@@ -27285,7 +27301,7 @@ var PTS_REPAIR_LOG_MEMORY = 64;
|
|
|
27285
27301
|
* PTS gap (or an empty/tail wait) still re-checks the abort guards promptly,
|
|
27286
27302
|
* keeping seeks responsive without per-frame drift.
|
|
27287
27303
|
*/
|
|
27288
|
-
var WAIT_TICK_MS = 250;
|
|
27304
|
+
var WAIT_TICK_MS$1 = 250;
|
|
27289
27305
|
/**
|
|
27290
27306
|
* Output-clock gap stamped at every seek/scrub commit (markDiscontinuity on
|
|
27291
27307
|
* both A/V clocks). Root cause of the "smeared frame on scrub": the aborted
|
|
@@ -27298,7 +27314,7 @@ var WAIT_TICK_MS = 250;
|
|
|
27298
27314
|
* can't look contiguous with the new GOP, and the receiver resets timing
|
|
27299
27315
|
* instead of merging the two feeds.
|
|
27300
27316
|
*/
|
|
27301
|
-
var SEEK_DISCONTINUITY_MS = 250;
|
|
27317
|
+
var SEEK_DISCONTINUITY_MS$1 = 250;
|
|
27302
27318
|
/**
|
|
27303
27319
|
* Coalescing window for feeder-scrub `scrubTo` (experimental). Pointer-move
|
|
27304
27320
|
* targets arrive far faster than a segment demux completes; the feeder collapses
|
|
@@ -27884,8 +27900,8 @@ var RecordedFeeder = class {
|
|
|
27884
27900
|
try {
|
|
27885
27901
|
this.emitState("loading", "seek-start", token, dragId);
|
|
27886
27902
|
if (this.clockHasAdvanced) {
|
|
27887
|
-
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
27888
|
-
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
27903
|
+
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
|
|
27904
|
+
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
|
|
27889
27905
|
}
|
|
27890
27906
|
const locateStartedAt = performance.now();
|
|
27891
27907
|
let loc = await this.withTimeout(this.deps.locate({
|
|
@@ -28760,12 +28776,12 @@ var RecordedFeeder = class {
|
|
|
28760
28776
|
return;
|
|
28761
28777
|
}
|
|
28762
28778
|
for (const s of jumpTravel) {
|
|
28763
|
-
if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
28779
|
+
if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
|
|
28764
28780
|
this.deps.sink.pushAccessUnit(s.au.data, this.clock.stamp(s.au.ptsMs), s.au.codec);
|
|
28765
28781
|
this.clockHasAdvanced = true;
|
|
28766
28782
|
this.deps.onPosition(this.clampIntoSegment(s.seg, s.tMs));
|
|
28767
28783
|
}
|
|
28768
|
-
if ((mode === "jump" || rewind) && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
28784
|
+
if ((mode === "jump" || rewind) && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
|
|
28769
28785
|
else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
|
|
28770
28786
|
for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
28771
28787
|
const pushedAtMs = performance.now();
|
|
@@ -29189,7 +29205,7 @@ var RecordedFeeder = class {
|
|
|
29189
29205
|
if (still === "deadline") break;
|
|
29190
29206
|
if (still !== null && still.seg.startMs !== lastFragStartMs && !fragmentCovers(still.seg, toMs) && !fragmentCovers(still.seg, fromMs)) {
|
|
29191
29207
|
lastFragStartMs = still.seg.startMs;
|
|
29192
|
-
if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
29208
|
+
if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS$1);
|
|
29193
29209
|
this.deps.sink.pushAccessUnit(still.au.data, this.clock.stamp(still.au.ptsMs), still.au.codec);
|
|
29194
29210
|
this.clockHasAdvanced = true;
|
|
29195
29211
|
this.deps.onPosition(this.clampIntoSegment(still.seg, tMs));
|
|
@@ -29636,7 +29652,7 @@ var RecordedFeeder = class {
|
|
|
29636
29652
|
if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
|
|
29637
29653
|
const remaining = targetWall - performance.now();
|
|
29638
29654
|
if (remaining <= 0) return false;
|
|
29639
|
-
const tick = Math.min(remaining, WAIT_TICK_MS);
|
|
29655
|
+
const tick = Math.min(remaining, WAIT_TICK_MS$1);
|
|
29640
29656
|
await new Promise((resolve) => setTimeout(resolve, tick));
|
|
29641
29657
|
}
|
|
29642
29658
|
}
|
|
@@ -29761,7 +29777,7 @@ var RecordedFeeder = class {
|
|
|
29761
29777
|
if (this.rate <= 0) return;
|
|
29762
29778
|
this.positionTicks += 1;
|
|
29763
29779
|
if (this.positionTicks % PLAY_CENSUS_INTERVAL_TICKS === 0) this.emitPlayCensus("tick");
|
|
29764
|
-
}, POSITION_INTERVAL_MS);
|
|
29780
|
+
}, POSITION_INTERVAL_MS$1);
|
|
29765
29781
|
}
|
|
29766
29782
|
/** Tear down the ~1 Hz position interval so it stops reporting a stale cursor. */
|
|
29767
29783
|
stopPositionReports() {
|
|
@@ -30057,7 +30073,60 @@ var TimelineSession = class {
|
|
|
30057
30073
|
this.lastPositionMs = null;
|
|
30058
30074
|
const feeder = this.enterRecorded();
|
|
30059
30075
|
await feeder.seek(offsetMs);
|
|
30060
|
-
await this.reportClipDuration(clipId, feeder);
|
|
30076
|
+
await Promise.all([this.reportClipDuration(clipId, feeder), this.reportClipAudio(clipId, feeder)]);
|
|
30077
|
+
}
|
|
30078
|
+
/**
|
|
30079
|
+
* Tell the viewer whether this clip has sound.
|
|
30080
|
+
*
|
|
30081
|
+
* Silence has three causes the operator cannot tell apart by listening —
|
|
30082
|
+
* a camera with no microphone, a transport that carried no audio, and a
|
|
30083
|
+
* session that negotiated none — so the viewer is told which. Every branch
|
|
30084
|
+
* that sends nothing says so (D391): a feeder that cannot answer, an answer
|
|
30085
|
+
* that rejected, and an answer that landed after the pointer had left the
|
|
30086
|
+
* clip, which must not be pinned on whatever came next.
|
|
30087
|
+
*/
|
|
30088
|
+
async reportClipAudio(clipId, feeder) {
|
|
30089
|
+
if (feeder.audio === void 0) {
|
|
30090
|
+
this.deps.onLog?.("clip playback: audio not reported — this feeder cannot say", {
|
|
30091
|
+
deviceId: this.deps.deviceId,
|
|
30092
|
+
clipId
|
|
30093
|
+
});
|
|
30094
|
+
return;
|
|
30095
|
+
}
|
|
30096
|
+
let report;
|
|
30097
|
+
try {
|
|
30098
|
+
report = await feeder.audio();
|
|
30099
|
+
} catch (err) {
|
|
30100
|
+
this.deps.onLog?.("clip playback: audio not reported — the answer failed", {
|
|
30101
|
+
deviceId: this.deps.deviceId,
|
|
30102
|
+
clipId,
|
|
30103
|
+
error: err instanceof Error ? err.message : String(err)
|
|
30104
|
+
});
|
|
30105
|
+
return;
|
|
30106
|
+
}
|
|
30107
|
+
if (this.clip?.clipId !== clipId) {
|
|
30108
|
+
this.deps.onLog?.("clip playback: audio dropped — the pointer has left this clip", {
|
|
30109
|
+
deviceId: this.deps.deviceId,
|
|
30110
|
+
clipId,
|
|
30111
|
+
carried: report.carried
|
|
30112
|
+
});
|
|
30113
|
+
return;
|
|
30114
|
+
}
|
|
30115
|
+
this.deps.onLog?.("clip playback: audio reported", {
|
|
30116
|
+
deviceId: this.deps.deviceId,
|
|
30117
|
+
clipId,
|
|
30118
|
+
carried: report.carried,
|
|
30119
|
+
codec: report.codec,
|
|
30120
|
+
reason: report.reason,
|
|
30121
|
+
detail: report.detail
|
|
30122
|
+
});
|
|
30123
|
+
this.deps.session.sendControl(serializeServerMessage({
|
|
30124
|
+
t: "clipAudio",
|
|
30125
|
+
clipId,
|
|
30126
|
+
carried: report.carried,
|
|
30127
|
+
...report.codec !== null ? { codec: report.codec } : {},
|
|
30128
|
+
...report.reason !== null ? { reason: report.reason } : {}
|
|
30129
|
+
}));
|
|
30061
30130
|
}
|
|
30062
30131
|
/**
|
|
30063
30132
|
* Tell the viewer how long the file on the pointer REALLY is.
|
|
@@ -30100,6 +30169,13 @@ var TimelineSession = class {
|
|
|
30100
30169
|
});
|
|
30101
30170
|
return;
|
|
30102
30171
|
}
|
|
30172
|
+
if (durationMs === null) {
|
|
30173
|
+
this.deps.onLog?.("clip playback: duration unknown — the source ended without a length", {
|
|
30174
|
+
deviceId: this.deps.deviceId,
|
|
30175
|
+
clipId
|
|
30176
|
+
});
|
|
30177
|
+
return;
|
|
30178
|
+
}
|
|
30103
30179
|
if (!(durationMs > 0)) {
|
|
30104
30180
|
this.deps.onLog?.("clip playback: duration not reported — the measure is not positive", {
|
|
30105
30181
|
deviceId: this.deps.deviceId,
|
|
@@ -30298,7 +30374,7 @@ var LARGE_SIZE_HEADER_LENGTH = 16;
|
|
|
30298
30374
|
* structurally impossible (a size that does not fit), which the caller turns
|
|
30299
30375
|
* into `malformed` — never a throw, because these bytes come off a camera.
|
|
30300
30376
|
*/
|
|
30301
|
-
function childBoxes(bytes, start, end) {
|
|
30377
|
+
function childBoxes$1(bytes, start, end) {
|
|
30302
30378
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30303
30379
|
const out = [];
|
|
30304
30380
|
let off = start;
|
|
@@ -30328,7 +30404,7 @@ function childBoxes(bytes, start, end) {
|
|
|
30328
30404
|
}
|
|
30329
30405
|
return out;
|
|
30330
30406
|
}
|
|
30331
|
-
function findBox(boxes, type) {
|
|
30407
|
+
function findBox$1(boxes, type) {
|
|
30332
30408
|
for (const b of boxes) if (b.type === type) return b;
|
|
30333
30409
|
return null;
|
|
30334
30410
|
}
|
|
@@ -30358,7 +30434,7 @@ function readSyncSampleNumbers(bytes, box) {
|
|
|
30358
30434
|
for (let i = 0; i < entries; i++) out.add(view.getUint32(base + 4 + i * 4));
|
|
30359
30435
|
return out;
|
|
30360
30436
|
}
|
|
30361
|
-
function mediaTimescale(bytes, mdhd) {
|
|
30437
|
+
function mediaTimescale$1(bytes, mdhd) {
|
|
30362
30438
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30363
30439
|
const version = bytes[mdhd.contentStart] ?? 0;
|
|
30364
30440
|
const at = mdhd.contentStart + 4 + (version === 1 ? 16 : 8);
|
|
@@ -30366,7 +30442,7 @@ function mediaTimescale(bytes, mdhd) {
|
|
|
30366
30442
|
const ts = view.getUint32(at);
|
|
30367
30443
|
return ts > 0 ? ts : null;
|
|
30368
30444
|
}
|
|
30369
|
-
function handlerType(bytes, hdlr) {
|
|
30445
|
+
function handlerType$1(bytes, hdlr) {
|
|
30370
30446
|
const at = hdlr.contentStart + 8;
|
|
30371
30447
|
if (at + FOURCC_LENGTH > hdlr.end) return "";
|
|
30372
30448
|
let out = "";
|
|
@@ -30375,16 +30451,16 @@ function handlerType(bytes, hdlr) {
|
|
|
30375
30451
|
}
|
|
30376
30452
|
/** The video `trak` of a `moov`, or `null` when it has none. */
|
|
30377
30453
|
function videoTrack(bytes, moov) {
|
|
30378
|
-
const children = childBoxes(bytes, moov.contentStart, moov.end);
|
|
30454
|
+
const children = childBoxes$1(bytes, moov.contentStart, moov.end);
|
|
30379
30455
|
if (!children) return null;
|
|
30380
30456
|
for (const trak of children) {
|
|
30381
30457
|
if (trak.type !== "trak") continue;
|
|
30382
|
-
const trakKids = childBoxes(bytes, trak.contentStart, trak.end);
|
|
30383
|
-
const mdia = trakKids ? findBox(trakKids, "mdia") : null;
|
|
30458
|
+
const trakKids = childBoxes$1(bytes, trak.contentStart, trak.end);
|
|
30459
|
+
const mdia = trakKids ? findBox$1(trakKids, "mdia") : null;
|
|
30384
30460
|
if (!mdia) continue;
|
|
30385
|
-
const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
|
|
30386
|
-
const hdlr = mdiaKids ? findBox(mdiaKids, "hdlr") : null;
|
|
30387
|
-
if (hdlr && handlerType(bytes, hdlr) === "vide") return mdia;
|
|
30461
|
+
const mdiaKids = childBoxes$1(bytes, mdia.contentStart, mdia.end);
|
|
30462
|
+
const hdlr = mdiaKids ? findBox$1(mdiaKids, "hdlr") : null;
|
|
30463
|
+
if (hdlr && handlerType$1(bytes, hdlr) === "vide") return mdia;
|
|
30388
30464
|
}
|
|
30389
30465
|
return null;
|
|
30390
30466
|
}
|
|
@@ -30393,12 +30469,12 @@ function videoTrack(bytes, moov) {
|
|
|
30393
30469
|
* comes back as a named refusal so the caller can say WHY a clip will not play.
|
|
30394
30470
|
*/
|
|
30395
30471
|
function indexClipMp4(bytes) {
|
|
30396
|
-
const top = childBoxes(bytes, 0, bytes.length);
|
|
30472
|
+
const top = childBoxes$1(bytes, 0, bytes.length);
|
|
30397
30473
|
if (!top) return {
|
|
30398
30474
|
kind: "refused",
|
|
30399
30475
|
reason: "malformed"
|
|
30400
30476
|
};
|
|
30401
|
-
const moov = findBox(top, "moov");
|
|
30477
|
+
const moov = findBox$1(top, "moov");
|
|
30402
30478
|
if (!moov) return {
|
|
30403
30479
|
kind: "refused",
|
|
30404
30480
|
reason: "no-moov"
|
|
@@ -30408,34 +30484,34 @@ function indexClipMp4(bytes) {
|
|
|
30408
30484
|
kind: "refused",
|
|
30409
30485
|
reason: "no-video-track"
|
|
30410
30486
|
};
|
|
30411
|
-
const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
|
|
30487
|
+
const mdiaKids = childBoxes$1(bytes, mdia.contentStart, mdia.end);
|
|
30412
30488
|
if (!mdiaKids) return {
|
|
30413
30489
|
kind: "refused",
|
|
30414
30490
|
reason: "malformed"
|
|
30415
30491
|
};
|
|
30416
|
-
const mdhd = findBox(mdiaKids, "mdhd");
|
|
30417
|
-
const minf = findBox(mdiaKids, "minf");
|
|
30492
|
+
const mdhd = findBox$1(mdiaKids, "mdhd");
|
|
30493
|
+
const minf = findBox$1(mdiaKids, "minf");
|
|
30418
30494
|
if (!mdhd || !minf) return {
|
|
30419
30495
|
kind: "refused",
|
|
30420
30496
|
reason: "malformed"
|
|
30421
30497
|
};
|
|
30422
|
-
const timescale = mediaTimescale(bytes, mdhd);
|
|
30498
|
+
const timescale = mediaTimescale$1(bytes, mdhd);
|
|
30423
30499
|
if (timescale === null) return {
|
|
30424
30500
|
kind: "refused",
|
|
30425
30501
|
reason: "malformed"
|
|
30426
30502
|
};
|
|
30427
|
-
const minfKids = childBoxes(bytes, minf.contentStart, minf.end);
|
|
30428
|
-
const stbl = minfKids ? findBox(minfKids, "stbl") : null;
|
|
30503
|
+
const minfKids = childBoxes$1(bytes, minf.contentStart, minf.end);
|
|
30504
|
+
const stbl = minfKids ? findBox$1(minfKids, "stbl") : null;
|
|
30429
30505
|
if (!stbl) return {
|
|
30430
30506
|
kind: "refused",
|
|
30431
30507
|
reason: "no-samples"
|
|
30432
30508
|
};
|
|
30433
|
-
const stblKids = childBoxes(bytes, stbl.contentStart, stbl.end);
|
|
30509
|
+
const stblKids = childBoxes$1(bytes, stbl.contentStart, stbl.end);
|
|
30434
30510
|
if (!stblKids) return {
|
|
30435
30511
|
kind: "refused",
|
|
30436
30512
|
reason: "malformed"
|
|
30437
30513
|
};
|
|
30438
|
-
const sttsBox = findBox(stblKids, "stts");
|
|
30514
|
+
const sttsBox = findBox$1(stblKids, "stts");
|
|
30439
30515
|
if (!sttsBox) return {
|
|
30440
30516
|
kind: "refused",
|
|
30441
30517
|
reason: "no-samples"
|
|
@@ -30445,7 +30521,7 @@ function indexClipMp4(bytes) {
|
|
|
30445
30521
|
kind: "refused",
|
|
30446
30522
|
reason: "malformed"
|
|
30447
30523
|
};
|
|
30448
|
-
const stssBox = findBox(stblKids, "stss");
|
|
30524
|
+
const stssBox = findBox$1(stblKids, "stss");
|
|
30449
30525
|
const syncNumbers = stssBox === null ? null : readSyncSampleNumbers(bytes, stssBox);
|
|
30450
30526
|
if (stssBox !== null && syncNumbers === null) return {
|
|
30451
30527
|
kind: "refused",
|
|
@@ -30493,6 +30569,13 @@ var ClipSourceError = class extends Error {
|
|
|
30493
30569
|
this.name = "ClipSourceError";
|
|
30494
30570
|
}
|
|
30495
30571
|
};
|
|
30572
|
+
/**
|
|
30573
|
+
* 64 MB. Above `VIDEOCLIPS_MAX_READ_BYTES` (50 MB) on purpose: the cap's own
|
|
30574
|
+
* bound refuses first and by name, and this one exists so a provider that ever
|
|
30575
|
+
* answers larger cannot pin a broker runner's heap. A `high` twin measured
|
|
30576
|
+
* 20–35 MB (D570 § 9.6).
|
|
30577
|
+
*/
|
|
30578
|
+
var CLIP_SOURCE_MAX_BYTES = 64 * 1024 * 1024;
|
|
30496
30579
|
function createClipFileSource(deps) {
|
|
30497
30580
|
const maxBytes = deps.maxBytes ?? 67108864;
|
|
30498
30581
|
const { deviceId, clipId, provider, profile } = deps.handle;
|
|
@@ -30586,6 +30669,1817 @@ function createClipFileSource(deps) {
|
|
|
30586
30669
|
};
|
|
30587
30670
|
}
|
|
30588
30671
|
//#endregion
|
|
30672
|
+
//#region src/stream-broker/recorded/clip-path-choice.ts
|
|
30673
|
+
function chooseClipPath(input) {
|
|
30674
|
+
if (input.streamDialWired) return {
|
|
30675
|
+
path: "stream",
|
|
30676
|
+
reason: "stream-serves-every-profile"
|
|
30677
|
+
};
|
|
30678
|
+
if (input.fileReadWired) return {
|
|
30679
|
+
path: "file",
|
|
30680
|
+
reason: "no-stream-dial-wired"
|
|
30681
|
+
};
|
|
30682
|
+
return {
|
|
30683
|
+
path: "none",
|
|
30684
|
+
reason: "nothing-wired"
|
|
30685
|
+
};
|
|
30686
|
+
}
|
|
30687
|
+
/**
|
|
30688
|
+
* The feeder a `none` choice gets: every verb is a named refusal and the
|
|
30689
|
+
* first seek reports `error`, so a clip nothing can serve is never a frozen
|
|
30690
|
+
* frame with no line behind it (D391).
|
|
30691
|
+
*/
|
|
30692
|
+
function createRefusedClipFeeder(deps) {
|
|
30693
|
+
const data = {
|
|
30694
|
+
deviceId: deps.handle.deviceId,
|
|
30695
|
+
clipId: deps.handle.clipId,
|
|
30696
|
+
reason: deps.reason
|
|
30697
|
+
};
|
|
30698
|
+
let announced = false;
|
|
30699
|
+
const refuse = (verb) => {
|
|
30700
|
+
deps.onLog?.("clip playback: refused — no path can serve this clip", {
|
|
30701
|
+
...data,
|
|
30702
|
+
verb
|
|
30703
|
+
});
|
|
30704
|
+
if (announced) return;
|
|
30705
|
+
announced = true;
|
|
30706
|
+
deps.onState({
|
|
30707
|
+
from: null,
|
|
30708
|
+
to: "error",
|
|
30709
|
+
reason: `refused:${deps.reason}`,
|
|
30710
|
+
seekToken: 0
|
|
30711
|
+
});
|
|
30712
|
+
};
|
|
30713
|
+
return {
|
|
30714
|
+
seek: async () => refuse("seek"),
|
|
30715
|
+
setRate: () => {},
|
|
30716
|
+
stepFrame: () => refuse("stepFrame"),
|
|
30717
|
+
beginScrub: () => refuse("beginScrub"),
|
|
30718
|
+
scrubTo: () => {},
|
|
30719
|
+
scrubAck: () => {},
|
|
30720
|
+
durationMs: () => Promise.resolve(null),
|
|
30721
|
+
dispose: () => {}
|
|
30722
|
+
};
|
|
30723
|
+
}
|
|
30724
|
+
var PCMU_RATE_HZ = 8e3;
|
|
30725
|
+
function createPassthroughFramer() {
|
|
30726
|
+
return {
|
|
30727
|
+
push: (unit) => unit.data.byteLength === 0 ? [] : [{
|
|
30728
|
+
data: unit.data,
|
|
30729
|
+
ptsMs: unit.ptsMs,
|
|
30730
|
+
durationMs: unit.durationMs
|
|
30731
|
+
}],
|
|
30732
|
+
carriedBytes: 0,
|
|
30733
|
+
reanchors: 0
|
|
30734
|
+
};
|
|
30735
|
+
}
|
|
30736
|
+
var PcmuFramer = class {
|
|
30737
|
+
carry = new Uint8Array(0);
|
|
30738
|
+
/** `ptsMs` of the first byte currently held in {@link carry}. */
|
|
30739
|
+
anchorMs = null;
|
|
30740
|
+
/** Bytes emitted plus carried since {@link anchorMs} — the exact clock. */
|
|
30741
|
+
bytesSinceAnchor = 0;
|
|
30742
|
+
reanchorCount = 0;
|
|
30743
|
+
get carriedBytes() {
|
|
30744
|
+
return this.carry.byteLength;
|
|
30745
|
+
}
|
|
30746
|
+
get reanchors() {
|
|
30747
|
+
return this.reanchorCount;
|
|
30748
|
+
}
|
|
30749
|
+
push(unit) {
|
|
30750
|
+
if (unit.data.byteLength === 0) return [];
|
|
30751
|
+
const expectedMs = this.anchorMs === null ? null : this.anchorMs + this.bytesSinceAnchor * 1e3 / PCMU_RATE_HZ;
|
|
30752
|
+
if (expectedMs === null || Math.abs(expectedMs - unit.ptsMs) > 20) {
|
|
30753
|
+
if (expectedMs !== null) this.reanchorCount += 1;
|
|
30754
|
+
this.carry = new Uint8Array(0);
|
|
30755
|
+
this.anchorMs = unit.ptsMs;
|
|
30756
|
+
this.bytesSinceAnchor = 0;
|
|
30757
|
+
}
|
|
30758
|
+
const anchorMs = this.anchorMs;
|
|
30759
|
+
if (anchorMs === null) return [];
|
|
30760
|
+
const joined = new Uint8Array(this.carry.byteLength + unit.data.byteLength);
|
|
30761
|
+
joined.set(this.carry, 0);
|
|
30762
|
+
joined.set(unit.data, this.carry.byteLength);
|
|
30763
|
+
const out = [];
|
|
30764
|
+
let at = 0;
|
|
30765
|
+
while (at + 160 <= joined.byteLength) {
|
|
30766
|
+
out.push({
|
|
30767
|
+
data: joined.slice(at, at + 160),
|
|
30768
|
+
ptsMs: anchorMs + this.bytesSinceAnchor * 1e3 / PCMU_RATE_HZ,
|
|
30769
|
+
durationMs: 20
|
|
30770
|
+
});
|
|
30771
|
+
this.bytesSinceAnchor += 160;
|
|
30772
|
+
at += 160;
|
|
30773
|
+
}
|
|
30774
|
+
this.carry = joined.slice(at);
|
|
30775
|
+
return out;
|
|
30776
|
+
}
|
|
30777
|
+
};
|
|
30778
|
+
/** The framer for a codec: a pass-through for Opus, a re-cut for µ-law. */
|
|
30779
|
+
function createClipAudioFramer(codec) {
|
|
30780
|
+
return codec === "opus" ? createPassthroughFramer() : new PcmuFramer();
|
|
30781
|
+
}
|
|
30782
|
+
//#endregion
|
|
30783
|
+
//#region src/stream-broker/recorded/fmp4-stream-demux.ts
|
|
30784
|
+
/**
|
|
30785
|
+
* fmp4-stream-demux — an INCREMENTAL demux of a fragmented MP4 as it arrives.
|
|
30786
|
+
*
|
|
30787
|
+
* The clip STREAM (`/clip-stream/…`, D597) is an fMP4 written by ffmpeg with
|
|
30788
|
+
* `frag_keyframe+empty_moov+default_base_moof` while it is still reading the
|
|
30789
|
+
* camera: a `moov` with an EMPTY sample table, then one `moof`+`mdat` per
|
|
30790
|
+
* GOP, and no trailer until the end. Nothing in this directory can read that
|
|
30791
|
+
* shape as it flows — `clip-mp4-index.ts` wants `stbl` and refuses it
|
|
30792
|
+
* `no-samples` by design, `segment-demux-js.ts` (mp4box) wants a WHOLE
|
|
30793
|
+
* fragment in hand — so this module walks the boxes itself, byte by byte, and
|
|
30794
|
+
* emits every video sample the moment its bytes are in.
|
|
30795
|
+
*
|
|
30796
|
+
* ## What it emits and when
|
|
30797
|
+
*
|
|
30798
|
+
* {@link AccessUnit}s, in DECODE order, `ptsMs` anchored at 0 on the first
|
|
30799
|
+
* sample's composition time — the same contract `demuxSegmentFullJs` keeps,
|
|
30800
|
+
* so a streamed clip and a stored one push the same shape. A sample is
|
|
30801
|
+
* emitted as soon as ITS bytes have arrived, not when its `mdat` closes: a
|
|
30802
|
+
* GOP is a second or two of video, and a feeder that waited for the box would
|
|
30803
|
+
* hand the viewer that second late for nothing.
|
|
30804
|
+
*
|
|
30805
|
+
* Sync samples are fused with the parameter sets (`avcc-annexb.ts`), delta
|
|
30806
|
+
* samples are converted plain; the codec and the `avcC`/`hvcC` come from the
|
|
30807
|
+
* `moov`. ONE video track is followed, and — since D600 — ONE audio track
|
|
30808
|
+
* beside it; every other track's bytes in an `mdat` are skipped, never
|
|
30809
|
+
* emitted.
|
|
30810
|
+
*
|
|
30811
|
+
* ## The audio track, and the one clock both tracks are on
|
|
30812
|
+
*
|
|
30813
|
+
* A clip stream carries sound already transcoded to the session's codec
|
|
30814
|
+
* (D600): Opus, whose mp4 sample IS the RTP payload, or µ-law in a QuickTime
|
|
30815
|
+
* `ulaw` entry. Audio samples are handed to {@link Fmp4StreamDemuxOptions.onAudio}
|
|
30816
|
+
* as they land, interleaved with the video in FILE-OFFSET order — which is
|
|
30817
|
+
* the order the bytes arrive, and the only order a forward-only window can
|
|
30818
|
+
* drain in.
|
|
30819
|
+
*
|
|
30820
|
+
* **Both tracks are anchored on ONE origin**, the first sample of either to
|
|
30821
|
+
* be emitted, and every `ptsMs` is measured from it in exact integer
|
|
30822
|
+
* arithmetic over the two timescales. Anchoring each track on its own first
|
|
30823
|
+
* sample would zero out the offset BETWEEN them — which is the whole content
|
|
30824
|
+
* of lip sync — and would do it invisibly, in a way that only shows up as
|
|
30825
|
+
* drift on a long clip.
|
|
30826
|
+
*
|
|
30827
|
+
* An audio track whose sample entry is neither Opus nor µ-law is REPORTED
|
|
30828
|
+
* ({@link Fmp4StreamDemuxer.audio}) and not followed. It is never a refusal:
|
|
30829
|
+
* a codec this process cannot forward is a reason to play the clip silent,
|
|
30830
|
+
* never a reason to lose the video.
|
|
30831
|
+
*
|
|
30832
|
+
* ## What it refuses, by name
|
|
30833
|
+
*
|
|
30834
|
+
* A `moof` before any `moov` (`no-moov`), a `moov` with no video track
|
|
30835
|
+
* (`no-video-track`), a codec that is neither AVC nor HEVC
|
|
30836
|
+
* (`unsupported-codec`), and a box or a sample that cannot fit where it says
|
|
30837
|
+
* it is (`malformed`). Each is thrown ONCE as {@link Fmp4DemuxError}; the
|
|
30838
|
+
* demuxer is dead after it and says so on every further push. The bytes come
|
|
30839
|
+
* off a camera through two processes — a refusal here is a fact about the
|
|
30840
|
+
* stream, never a crash.
|
|
30841
|
+
*
|
|
30842
|
+
* ## Duration
|
|
30843
|
+
*
|
|
30844
|
+
* Unknown until the stream ENDS. `end()` answers the sum of the sample
|
|
30845
|
+
* durations, in ms, or `null` when no sample was ever emitted — never `0`
|
|
30846
|
+
* (D393). The caller learns the length when the camera has finished, which
|
|
30847
|
+
* is the truth about a forward-only source.
|
|
30848
|
+
*/
|
|
30849
|
+
var Fmp4DemuxError = class extends Error {
|
|
30850
|
+
reason;
|
|
30851
|
+
constructor(reason, message) {
|
|
30852
|
+
super(message);
|
|
30853
|
+
this.reason = reason;
|
|
30854
|
+
this.name = "Fmp4DemuxError";
|
|
30855
|
+
}
|
|
30856
|
+
};
|
|
30857
|
+
var BOX_HEADER = 8;
|
|
30858
|
+
var LARGE_BOX_HEADER = 16;
|
|
30859
|
+
var FULLBOX = 4;
|
|
30860
|
+
/** `sample_is_non_sync_sample`, ISO/IEC 14496-12 § 8.8.3.1. */
|
|
30861
|
+
var FLAG_NON_SYNC = 65536;
|
|
30862
|
+
var TFHD_BASE_DATA_OFFSET = 1;
|
|
30863
|
+
var TFHD_SAMPLE_DESC_INDEX = 2;
|
|
30864
|
+
var TFHD_DEFAULT_DURATION = 8;
|
|
30865
|
+
var TFHD_DEFAULT_SIZE = 16;
|
|
30866
|
+
var TFHD_DEFAULT_FLAGS = 32;
|
|
30867
|
+
var TFHD_DEFAULT_BASE_IS_MOOF = 131072;
|
|
30868
|
+
var TRUN_DATA_OFFSET = 1;
|
|
30869
|
+
var TRUN_FIRST_SAMPLE_FLAGS = 4;
|
|
30870
|
+
var TRUN_SAMPLE_DURATION = 256;
|
|
30871
|
+
var TRUN_SAMPLE_SIZE = 512;
|
|
30872
|
+
var TRUN_SAMPLE_FLAGS = 1024;
|
|
30873
|
+
var TRUN_SAMPLE_CTS = 2048;
|
|
30874
|
+
/** VisualSampleEntry fields between the sample-entry header and its children. */
|
|
30875
|
+
var VISUAL_SAMPLE_ENTRY_FIELDS = 78;
|
|
30876
|
+
/**
|
|
30877
|
+
* AudioSampleEntry fixed fields, ISO/IEC 14496-12 § 12.2.3: the 8-byte
|
|
30878
|
+
* `SampleEntry` (6 reserved + `data_reference_index`), then version, revision,
|
|
30879
|
+
* vendor, `channelcount`, `samplesize`, `compression_id`, `packet_size` and a
|
|
30880
|
+
* 16.16 `samplerate`.
|
|
30881
|
+
*/
|
|
30882
|
+
var AUDIO_SAMPLE_ENTRY_FIELDS = 28;
|
|
30883
|
+
var AUDIO_ENTRY_CHANNELS_AT = 16;
|
|
30884
|
+
var AUDIO_ENTRY_SAMPLE_RATE_AT = 24;
|
|
30885
|
+
/** `cts` in `timescale` ticks, as milliseconds from `origin`. Exact. */
|
|
30886
|
+
function ptsMsFrom(origin, cts, timescale) {
|
|
30887
|
+
if (timescale === origin.timescale) return Math.round((cts - origin.ticks) * 1e3 / timescale);
|
|
30888
|
+
const numerator = (cts * origin.timescale - origin.ticks * timescale) * 1e3;
|
|
30889
|
+
return Math.round(numerator / (timescale * origin.timescale));
|
|
30890
|
+
}
|
|
30891
|
+
function fourcc(bytes, at) {
|
|
30892
|
+
let out = "";
|
|
30893
|
+
for (let i = 0; i < 4; i++) out += String.fromCharCode(bytes[at + i] ?? 0);
|
|
30894
|
+
return out;
|
|
30895
|
+
}
|
|
30896
|
+
function u32(view, at) {
|
|
30897
|
+
return view.getUint32(at);
|
|
30898
|
+
}
|
|
30899
|
+
function u64(view, at) {
|
|
30900
|
+
return view.getUint32(at) * 4294967296 + view.getUint32(at + 4);
|
|
30901
|
+
}
|
|
30902
|
+
/** The direct children of `[start, end)`, or `null` when a size does not fit. */
|
|
30903
|
+
function childBoxes(bytes, start, end) {
|
|
30904
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30905
|
+
const out = [];
|
|
30906
|
+
let off = start;
|
|
30907
|
+
while (off + BOX_HEADER <= end) {
|
|
30908
|
+
const declared = u32(view, off);
|
|
30909
|
+
const type = fourcc(bytes, off + 4);
|
|
30910
|
+
let size = declared;
|
|
30911
|
+
let contentStart = off + BOX_HEADER;
|
|
30912
|
+
if (declared === 1) {
|
|
30913
|
+
if (off + LARGE_BOX_HEADER > end) return null;
|
|
30914
|
+
size = u64(view, off + 8);
|
|
30915
|
+
contentStart = off + LARGE_BOX_HEADER;
|
|
30916
|
+
} else if (declared === 0) size = end - off;
|
|
30917
|
+
if (size < contentStart - off) return null;
|
|
30918
|
+
const boxEnd = off + size;
|
|
30919
|
+
if (boxEnd > end) return null;
|
|
30920
|
+
out.push({
|
|
30921
|
+
type,
|
|
30922
|
+
start: off,
|
|
30923
|
+
end: boxEnd,
|
|
30924
|
+
contentStart
|
|
30925
|
+
});
|
|
30926
|
+
off = boxEnd;
|
|
30927
|
+
}
|
|
30928
|
+
return out;
|
|
30929
|
+
}
|
|
30930
|
+
function findBox(boxes, type) {
|
|
30931
|
+
if (boxes === null) return null;
|
|
30932
|
+
for (const b of boxes) if (b.type === type) return b;
|
|
30933
|
+
return null;
|
|
30934
|
+
}
|
|
30935
|
+
function handlerType(bytes, hdlr) {
|
|
30936
|
+
const at = hdlr.contentStart + FULLBOX + 4;
|
|
30937
|
+
return at + 4 <= hdlr.end ? fourcc(bytes, at) : "";
|
|
30938
|
+
}
|
|
30939
|
+
function trackId(bytes, tkhd) {
|
|
30940
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30941
|
+
const version = bytes[tkhd.contentStart] ?? 0;
|
|
30942
|
+
const at = tkhd.contentStart + FULLBOX + (version === 1 ? 16 : 8);
|
|
30943
|
+
return at + 4 <= tkhd.end ? u32(view, at) : null;
|
|
30944
|
+
}
|
|
30945
|
+
function mediaTimescale(bytes, mdhd) {
|
|
30946
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30947
|
+
const version = bytes[mdhd.contentStart] ?? 0;
|
|
30948
|
+
const at = mdhd.contentStart + FULLBOX + (version === 1 ? 16 : 8);
|
|
30949
|
+
if (at + 4 > mdhd.end) return null;
|
|
30950
|
+
const ts = u32(view, at);
|
|
30951
|
+
return ts > 0 ? ts : null;
|
|
30952
|
+
}
|
|
30953
|
+
/** The decoder config out of `stsd`, header stripped, with the codec it names. */
|
|
30954
|
+
function sampleDescription(bytes, stsd) {
|
|
30955
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30956
|
+
const countAt = stsd.contentStart + FULLBOX;
|
|
30957
|
+
if (countAt + 4 > stsd.end) return null;
|
|
30958
|
+
const count = u32(view, countAt);
|
|
30959
|
+
const entries = childBoxes(bytes, countAt + 4, stsd.end);
|
|
30960
|
+
if (entries === null || count === 0 || entries.length === 0) return null;
|
|
30961
|
+
for (const entry of entries) {
|
|
30962
|
+
const isAvc = entry.type === "avc1" || entry.type === "avc3";
|
|
30963
|
+
const isHevc = entry.type === "hvc1" || entry.type === "hev1";
|
|
30964
|
+
if (!isAvc && !isHevc) continue;
|
|
30965
|
+
const config = findBox(childBoxes(bytes, entry.contentStart + VISUAL_SAMPLE_ENTRY_FIELDS, entry.end), isAvc ? "avcC" : "hvcC");
|
|
30966
|
+
if (config === null) return null;
|
|
30967
|
+
return {
|
|
30968
|
+
codec: isAvc ? "H264" : "H265",
|
|
30969
|
+
description: bytes.subarray(config.contentStart, config.end)
|
|
30970
|
+
};
|
|
30971
|
+
}
|
|
30972
|
+
return "unsupported";
|
|
30973
|
+
}
|
|
30974
|
+
function audioSampleDescription(bytes, stsd) {
|
|
30975
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30976
|
+
const countAt = stsd.contentStart + FULLBOX;
|
|
30977
|
+
if (countAt + 4 > stsd.end) return null;
|
|
30978
|
+
const entry = childBoxes(bytes, countAt + 4, stsd.end)?.[0];
|
|
30979
|
+
if (entry === void 0) return null;
|
|
30980
|
+
if (entry.contentStart + AUDIO_SAMPLE_ENTRY_FIELDS > entry.end) return null;
|
|
30981
|
+
const codec = entry.type === "Opus" ? "opus" : entry.type === "ulaw" ? "pcmu" : null;
|
|
30982
|
+
if (codec === null) return { unsupported: entry.type };
|
|
30983
|
+
return {
|
|
30984
|
+
codec,
|
|
30985
|
+
channels: view.getUint16(entry.contentStart + AUDIO_ENTRY_CHANNELS_AT),
|
|
30986
|
+
sampleRateHz: view.getUint16(entry.contentStart + AUDIO_ENTRY_SAMPLE_RATE_AT)
|
|
30987
|
+
};
|
|
30988
|
+
}
|
|
30989
|
+
function readTrex(bytes, moov, id) {
|
|
30990
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30991
|
+
const mvex = findBox(childBoxes(bytes, moov.contentStart, moov.end), "mvex");
|
|
30992
|
+
const none = {
|
|
30993
|
+
duration: null,
|
|
30994
|
+
size: null,
|
|
30995
|
+
flags: null
|
|
30996
|
+
};
|
|
30997
|
+
if (mvex === null) return none;
|
|
30998
|
+
for (const trex of childBoxes(bytes, mvex.contentStart, mvex.end) ?? []) {
|
|
30999
|
+
if (trex.type !== "trex" || trex.contentStart + FULLBOX + 20 > trex.end) continue;
|
|
31000
|
+
const at = trex.contentStart + FULLBOX;
|
|
31001
|
+
if (u32(view, at) !== id) continue;
|
|
31002
|
+
return {
|
|
31003
|
+
duration: u32(view, at + 8),
|
|
31004
|
+
size: u32(view, at + 12),
|
|
31005
|
+
flags: u32(view, at + 16)
|
|
31006
|
+
};
|
|
31007
|
+
}
|
|
31008
|
+
return none;
|
|
31009
|
+
}
|
|
31010
|
+
/** The `trak`s of a `moov` whose handler is `want`, already unpacked. */
|
|
31011
|
+
function traksOfHandler(bytes, moov, want) {
|
|
31012
|
+
const kids = childBoxes(bytes, moov.contentStart, moov.end);
|
|
31013
|
+
if (kids === null) return "malformed";
|
|
31014
|
+
const out = [];
|
|
31015
|
+
for (const trak of kids) {
|
|
31016
|
+
if (trak.type !== "trak") continue;
|
|
31017
|
+
const trakKids = childBoxes(bytes, trak.contentStart, trak.end);
|
|
31018
|
+
const mdia = findBox(trakKids, "mdia");
|
|
31019
|
+
const tkhd = findBox(trakKids, "tkhd");
|
|
31020
|
+
if (mdia === null || tkhd === null) continue;
|
|
31021
|
+
const mdiaKids = childBoxes(bytes, mdia.contentStart, mdia.end);
|
|
31022
|
+
const hdlr = findBox(mdiaKids, "hdlr");
|
|
31023
|
+
if (hdlr === null || handlerType(bytes, hdlr) !== want) continue;
|
|
31024
|
+
const id = trackId(bytes, tkhd);
|
|
31025
|
+
const mdhd = findBox(mdiaKids, "mdhd");
|
|
31026
|
+
const minf = findBox(mdiaKids, "minf");
|
|
31027
|
+
if (id === null || mdhd === null || minf === null) return "malformed";
|
|
31028
|
+
const timescale = mediaTimescale(bytes, mdhd);
|
|
31029
|
+
const stbl = findBox(childBoxes(bytes, minf.contentStart, minf.end), "stbl");
|
|
31030
|
+
const stsd = findBox(stbl ? childBoxes(bytes, stbl.contentStart, stbl.end) : null, "stsd");
|
|
31031
|
+
if (timescale === null || stsd === null) return "malformed";
|
|
31032
|
+
out.push({
|
|
31033
|
+
id,
|
|
31034
|
+
timescale,
|
|
31035
|
+
stsd
|
|
31036
|
+
});
|
|
31037
|
+
}
|
|
31038
|
+
return out;
|
|
31039
|
+
}
|
|
31040
|
+
/** The one video track of a `moov`, or the refusal that stands in for it. */
|
|
31041
|
+
function readVideoTrack(bytes, moov) {
|
|
31042
|
+
const traks = traksOfHandler(bytes, moov, "vide");
|
|
31043
|
+
if (traks === "malformed") return "malformed";
|
|
31044
|
+
for (const trak of traks) {
|
|
31045
|
+
const desc = sampleDescription(bytes, trak.stsd);
|
|
31046
|
+
if (desc === null) return "malformed";
|
|
31047
|
+
if (desc === "unsupported") return "unsupported-codec";
|
|
31048
|
+
let nalLengthSize;
|
|
31049
|
+
try {
|
|
31050
|
+
nalLengthSize = parseNalLengthSize(desc.description, desc.codec === "H265");
|
|
31051
|
+
} catch {
|
|
31052
|
+
return "malformed";
|
|
31053
|
+
}
|
|
31054
|
+
return {
|
|
31055
|
+
id: trak.id,
|
|
31056
|
+
timescale: trak.timescale,
|
|
31057
|
+
codec: desc.codec,
|
|
31058
|
+
description: desc.description,
|
|
31059
|
+
nalLengthSize,
|
|
31060
|
+
trex: readTrex(bytes, moov, trak.id)
|
|
31061
|
+
};
|
|
31062
|
+
}
|
|
31063
|
+
return "no-video-track";
|
|
31064
|
+
}
|
|
31065
|
+
/**
|
|
31066
|
+
* The one audio track of a `moov`, or what to REPORT instead of one.
|
|
31067
|
+
*
|
|
31068
|
+
* Never a refusal: sound this process cannot forward is a silent clip, and a
|
|
31069
|
+
* silent clip is a clip. The only thing that must not happen is silence
|
|
31070
|
+
* nobody can account for, which is what {@link Fmp4AudioState} is for.
|
|
31071
|
+
*/
|
|
31072
|
+
function readAudioTrack$1(bytes, moov) {
|
|
31073
|
+
const traks = traksOfHandler(bytes, moov, "soun");
|
|
31074
|
+
if (traks === "malformed") return { kind: "absent" };
|
|
31075
|
+
for (const trak of traks) {
|
|
31076
|
+
const desc = audioSampleDescription(bytes, trak.stsd);
|
|
31077
|
+
if (desc === null) return {
|
|
31078
|
+
kind: "unsupported",
|
|
31079
|
+
sampleEntry: ""
|
|
31080
|
+
};
|
|
31081
|
+
if ("unsupported" in desc) return {
|
|
31082
|
+
kind: "unsupported",
|
|
31083
|
+
sampleEntry: desc.unsupported
|
|
31084
|
+
};
|
|
31085
|
+
return {
|
|
31086
|
+
id: trak.id,
|
|
31087
|
+
timescale: trak.timescale,
|
|
31088
|
+
codec: desc.codec,
|
|
31089
|
+
sampleRateHz: desc.sampleRateHz,
|
|
31090
|
+
channels: desc.channels,
|
|
31091
|
+
trex: readTrex(bytes, moov, trak.id)
|
|
31092
|
+
};
|
|
31093
|
+
}
|
|
31094
|
+
return { kind: "absent" };
|
|
31095
|
+
}
|
|
31096
|
+
function readTfhd(view, tfhd) {
|
|
31097
|
+
const flags = u32(view, tfhd.contentStart) & 16777215;
|
|
31098
|
+
let at = tfhd.contentStart + FULLBOX;
|
|
31099
|
+
if (at + 4 > tfhd.end) return null;
|
|
31100
|
+
const id = u32(view, at);
|
|
31101
|
+
at += 4;
|
|
31102
|
+
let baseDataOffset = null;
|
|
31103
|
+
if (flags & TFHD_BASE_DATA_OFFSET) {
|
|
31104
|
+
if (at + 8 > tfhd.end) return null;
|
|
31105
|
+
baseDataOffset = u64(view, at);
|
|
31106
|
+
at += 8;
|
|
31107
|
+
}
|
|
31108
|
+
if (flags & TFHD_SAMPLE_DESC_INDEX) at += 4;
|
|
31109
|
+
let duration = null;
|
|
31110
|
+
let size = null;
|
|
31111
|
+
let sampleFlags = null;
|
|
31112
|
+
if (flags & TFHD_DEFAULT_DURATION) {
|
|
31113
|
+
if (at + 4 > tfhd.end) return null;
|
|
31114
|
+
duration = u32(view, at);
|
|
31115
|
+
at += 4;
|
|
31116
|
+
}
|
|
31117
|
+
if (flags & TFHD_DEFAULT_SIZE) {
|
|
31118
|
+
if (at + 4 > tfhd.end) return null;
|
|
31119
|
+
size = u32(view, at);
|
|
31120
|
+
at += 4;
|
|
31121
|
+
}
|
|
31122
|
+
if (flags & TFHD_DEFAULT_FLAGS) {
|
|
31123
|
+
if (at + 4 > tfhd.end) return null;
|
|
31124
|
+
sampleFlags = u32(view, at);
|
|
31125
|
+
}
|
|
31126
|
+
return {
|
|
31127
|
+
trackId: id,
|
|
31128
|
+
baseDataOffset,
|
|
31129
|
+
defaultBaseIsMoof: (flags & TFHD_DEFAULT_BASE_IS_MOOF) !== 0,
|
|
31130
|
+
defaults: {
|
|
31131
|
+
duration,
|
|
31132
|
+
size,
|
|
31133
|
+
flags: sampleFlags
|
|
31134
|
+
}
|
|
31135
|
+
};
|
|
31136
|
+
}
|
|
31137
|
+
function readTfdt(view, bytes, tfdt) {
|
|
31138
|
+
const version = bytes[tfdt.contentStart] ?? 0;
|
|
31139
|
+
const at = tfdt.contentStart + FULLBOX;
|
|
31140
|
+
if (version === 1) return at + 8 <= tfdt.end ? u64(view, at) : null;
|
|
31141
|
+
return at + 4 <= tfdt.end ? u32(view, at) : null;
|
|
31142
|
+
}
|
|
31143
|
+
/** The samples one `trun` announces, or `null` when it does not fit. */
|
|
31144
|
+
function readTrun(args) {
|
|
31145
|
+
const { view, trun, base, trex, traf } = args;
|
|
31146
|
+
const version = view.getUint8(trun.contentStart);
|
|
31147
|
+
const flags = u32(view, trun.contentStart) & 16777215;
|
|
31148
|
+
let at = trun.contentStart + FULLBOX;
|
|
31149
|
+
if (at + 4 > trun.end) return null;
|
|
31150
|
+
const count = u32(view, at);
|
|
31151
|
+
at += 4;
|
|
31152
|
+
let dataOffset = 0;
|
|
31153
|
+
if (flags & TRUN_DATA_OFFSET) {
|
|
31154
|
+
if (at + 4 > trun.end) return null;
|
|
31155
|
+
dataOffset = view.getInt32(at);
|
|
31156
|
+
at += 4;
|
|
31157
|
+
}
|
|
31158
|
+
let firstSampleFlags = null;
|
|
31159
|
+
if (flags & TRUN_FIRST_SAMPLE_FLAGS) {
|
|
31160
|
+
if (at + 4 > trun.end) return null;
|
|
31161
|
+
firstSampleFlags = u32(view, at);
|
|
31162
|
+
at += 4;
|
|
31163
|
+
}
|
|
31164
|
+
const out = [];
|
|
31165
|
+
let offset = base + dataOffset;
|
|
31166
|
+
let dts = args.startDts;
|
|
31167
|
+
for (let i = 0; i < count; i++) {
|
|
31168
|
+
let duration = traf.defaults.duration ?? trex.duration;
|
|
31169
|
+
let size = traf.defaults.size ?? trex.size;
|
|
31170
|
+
let sampleFlags = traf.defaults.flags ?? trex.flags;
|
|
31171
|
+
let ctsOffset = 0;
|
|
31172
|
+
if (flags & TRUN_SAMPLE_DURATION) {
|
|
31173
|
+
if (at + 4 > trun.end) return null;
|
|
31174
|
+
duration = u32(view, at);
|
|
31175
|
+
at += 4;
|
|
31176
|
+
}
|
|
31177
|
+
if (flags & TRUN_SAMPLE_SIZE) {
|
|
31178
|
+
if (at + 4 > trun.end) return null;
|
|
31179
|
+
size = u32(view, at);
|
|
31180
|
+
at += 4;
|
|
31181
|
+
}
|
|
31182
|
+
if (flags & TRUN_SAMPLE_FLAGS) {
|
|
31183
|
+
if (at + 4 > trun.end) return null;
|
|
31184
|
+
sampleFlags = u32(view, at);
|
|
31185
|
+
at += 4;
|
|
31186
|
+
} else if (i === 0 && firstSampleFlags !== null) sampleFlags = firstSampleFlags;
|
|
31187
|
+
if (flags & TRUN_SAMPLE_CTS) {
|
|
31188
|
+
if (at + 4 > trun.end) return null;
|
|
31189
|
+
ctsOffset = version === 0 ? u32(view, at) : view.getInt32(at);
|
|
31190
|
+
at += 4;
|
|
31191
|
+
}
|
|
31192
|
+
if (duration === null || size === null) return null;
|
|
31193
|
+
const sync = sampleFlags === null ? i === 0 : (sampleFlags & FLAG_NON_SYNC) === 0;
|
|
31194
|
+
out.push({
|
|
31195
|
+
offset,
|
|
31196
|
+
size,
|
|
31197
|
+
dts,
|
|
31198
|
+
cts: dts + ctsOffset,
|
|
31199
|
+
duration,
|
|
31200
|
+
sync
|
|
31201
|
+
});
|
|
31202
|
+
offset += size;
|
|
31203
|
+
dts += duration;
|
|
31204
|
+
}
|
|
31205
|
+
return out;
|
|
31206
|
+
}
|
|
31207
|
+
/**
|
|
31208
|
+
* Every sample a `moof` announces for the followed tracks, in FILE-OFFSET
|
|
31209
|
+
* order, or `null`.
|
|
31210
|
+
*
|
|
31211
|
+
* Offset order, not decode order across tracks: the window is forward-only
|
|
31212
|
+
* and drains what has arrived, so two tracks whose runs are laid out one
|
|
31213
|
+
* after another inside one `mdat` — which is how ffmpeg writes them — must
|
|
31214
|
+
* be drained in the order their bytes land. Within one track the offsets are
|
|
31215
|
+
* increasing anyway, so each track's own decode order is preserved.
|
|
31216
|
+
*/
|
|
31217
|
+
function readMoof(bytes, moof, tracks) {
|
|
31218
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
31219
|
+
const kids = childBoxes(bytes, moof.contentStart, moof.end);
|
|
31220
|
+
if (kids === null) return null;
|
|
31221
|
+
const out = [];
|
|
31222
|
+
for (const traf of kids) {
|
|
31223
|
+
if (traf.type !== "traf") continue;
|
|
31224
|
+
const trafKids = childBoxes(bytes, traf.contentStart, traf.end);
|
|
31225
|
+
const tfhdBox = findBox(trafKids, "tfhd");
|
|
31226
|
+
if (tfhdBox === null) return null;
|
|
31227
|
+
const tfhd = readTfhd(view, tfhdBox);
|
|
31228
|
+
if (tfhd === null) return null;
|
|
31229
|
+
const kind = tfhd.trackId === tracks.video.id ? "video" : tracks.audio !== null && tfhd.trackId === tracks.audio.id ? "audio" : null;
|
|
31230
|
+
if (kind === null) continue;
|
|
31231
|
+
const trex = kind === "video" ? tracks.video.trex : tracks.audio?.trex ?? null;
|
|
31232
|
+
if (trex === null) continue;
|
|
31233
|
+
const tfdtBox = findBox(trafKids, "tfdt");
|
|
31234
|
+
const startDts = tfdtBox === null ? 0 : readTfdt(view, bytes, tfdtBox);
|
|
31235
|
+
if (startDts === null) return null;
|
|
31236
|
+
const base = tfhd.baseDataOffset ?? moof.start;
|
|
31237
|
+
let dts = startDts;
|
|
31238
|
+
for (const trun of trafKids ?? []) {
|
|
31239
|
+
if (trun.type !== "trun") continue;
|
|
31240
|
+
const samples = readTrun({
|
|
31241
|
+
view,
|
|
31242
|
+
trun,
|
|
31243
|
+
base,
|
|
31244
|
+
startDts: dts,
|
|
31245
|
+
trex,
|
|
31246
|
+
traf: tfhd
|
|
31247
|
+
});
|
|
31248
|
+
if (samples === null) return null;
|
|
31249
|
+
for (const sample of samples) out.push({
|
|
31250
|
+
kind,
|
|
31251
|
+
sample
|
|
31252
|
+
});
|
|
31253
|
+
const last = samples[samples.length - 1];
|
|
31254
|
+
if (last !== void 0) dts = last.dts + last.duration;
|
|
31255
|
+
}
|
|
31256
|
+
}
|
|
31257
|
+
return out.toSorted((a, b) => a.sample.offset - b.sample.offset);
|
|
31258
|
+
}
|
|
31259
|
+
/** Bytes are ABSOLUTE file offsets here, so the demuxer can reason about a
|
|
31260
|
+
* sample announced by a `moof` whose `mdat` has not fully arrived. */
|
|
31261
|
+
var ByteWindow = class {
|
|
31262
|
+
held = new Uint8Array(0);
|
|
31263
|
+
/** Absolute offset of `held[0]`. */
|
|
31264
|
+
base = 0;
|
|
31265
|
+
/** Bytes still to DISCARD from the front of what arrives next — a skip
|
|
31266
|
+
* past the end of what is held (the tail of an mdat nobody follows). */
|
|
31267
|
+
skipAhead = 0;
|
|
31268
|
+
get start() {
|
|
31269
|
+
return this.base;
|
|
31270
|
+
}
|
|
31271
|
+
get end() {
|
|
31272
|
+
return this.base + this.held.length;
|
|
31273
|
+
}
|
|
31274
|
+
get length() {
|
|
31275
|
+
return this.held.length;
|
|
31276
|
+
}
|
|
31277
|
+
append(chunk) {
|
|
31278
|
+
let incoming = chunk;
|
|
31279
|
+
if (this.skipAhead > 0) {
|
|
31280
|
+
const drop = Math.min(this.skipAhead, incoming.length);
|
|
31281
|
+
this.skipAhead -= drop;
|
|
31282
|
+
this.base += drop;
|
|
31283
|
+
incoming = incoming.subarray(drop);
|
|
31284
|
+
}
|
|
31285
|
+
if (incoming.length === 0) return;
|
|
31286
|
+
if (this.held.length === 0) {
|
|
31287
|
+
this.held = incoming;
|
|
31288
|
+
return;
|
|
31289
|
+
}
|
|
31290
|
+
const next = new Uint8Array(this.held.length + incoming.length);
|
|
31291
|
+
next.set(this.held, 0);
|
|
31292
|
+
next.set(incoming, this.held.length);
|
|
31293
|
+
this.held = next;
|
|
31294
|
+
}
|
|
31295
|
+
/** Drop everything before absolute `offset`, including bytes not yet arrived. */
|
|
31296
|
+
consumeTo(offset) {
|
|
31297
|
+
if (offset <= this.base) return;
|
|
31298
|
+
const drop = Math.min(offset - this.base, this.held.length);
|
|
31299
|
+
this.held = this.held.subarray(drop);
|
|
31300
|
+
this.base += drop;
|
|
31301
|
+
if (offset > this.base) {
|
|
31302
|
+
this.skipAhead += offset - this.base;
|
|
31303
|
+
this.base = offset;
|
|
31304
|
+
this.held = new Uint8Array(0);
|
|
31305
|
+
}
|
|
31306
|
+
}
|
|
31307
|
+
slice(absStart, absEnd) {
|
|
31308
|
+
return this.held.subarray(absStart - this.base, absEnd - this.base);
|
|
31309
|
+
}
|
|
31310
|
+
view() {
|
|
31311
|
+
return this.held;
|
|
31312
|
+
}
|
|
31313
|
+
};
|
|
31314
|
+
function createFmp4StreamDemuxer(options = {}) {
|
|
31315
|
+
const onAudio = options.onAudio;
|
|
31316
|
+
const window = new ByteWindow();
|
|
31317
|
+
let track = null;
|
|
31318
|
+
let audioTrack = null;
|
|
31319
|
+
let audioState = { kind: "unknown" };
|
|
31320
|
+
let dead = null;
|
|
31321
|
+
/** Samples announced by the current `moof`, not yet emitted, offset order. */
|
|
31322
|
+
let announced = [];
|
|
31323
|
+
/** Absolute end of the `mdat` the announced samples live in, once known. */
|
|
31324
|
+
let mdatEnd = null;
|
|
31325
|
+
/** The ONE origin both tracks are measured from — the first sample emitted. */
|
|
31326
|
+
let origin = null;
|
|
31327
|
+
let samples = 0;
|
|
31328
|
+
let audioSamples = 0;
|
|
31329
|
+
let durationTicks = 0;
|
|
31330
|
+
const refuse = (reason, message) => {
|
|
31331
|
+
dead = new Fmp4DemuxError(reason, message);
|
|
31332
|
+
throw dead;
|
|
31333
|
+
};
|
|
31334
|
+
/** Anchor the shared origin on the first sample of EITHER track. */
|
|
31335
|
+
const anchor = (cts, timescale) => {
|
|
31336
|
+
origin ??= {
|
|
31337
|
+
ticks: cts,
|
|
31338
|
+
timescale
|
|
31339
|
+
};
|
|
31340
|
+
return origin;
|
|
31341
|
+
};
|
|
31342
|
+
const toAccessUnit = (sample, data) => {
|
|
31343
|
+
if (track === null) throw new Error("unreachable: sample before moov");
|
|
31344
|
+
const at = anchor(sample.cts, track.timescale);
|
|
31345
|
+
const isHevc = track.codec === "H265";
|
|
31346
|
+
let annexB;
|
|
31347
|
+
try {
|
|
31348
|
+
annexB = sample.sync ? buildKeyframeAnnexB(data, track.description, isHevc) : avccSampleToAnnexB(data, track.nalLengthSize);
|
|
31349
|
+
} catch (err) {
|
|
31350
|
+
return refuse("malformed", `sample ${samples} is not a valid AVCC/HVCC sample: ${String(err)}`);
|
|
31351
|
+
}
|
|
31352
|
+
samples += 1;
|
|
31353
|
+
durationTicks += sample.duration;
|
|
31354
|
+
return {
|
|
31355
|
+
data: annexB,
|
|
31356
|
+
ptsMs: ptsMsFrom(at, sample.cts, track.timescale),
|
|
31357
|
+
keyframe: sample.sync,
|
|
31358
|
+
codec: track.codec
|
|
31359
|
+
};
|
|
31360
|
+
};
|
|
31361
|
+
const emitAudio = (sample, data) => {
|
|
31362
|
+
if (audioTrack === null || onAudio === void 0) return;
|
|
31363
|
+
const at = anchor(sample.cts, audioTrack.timescale);
|
|
31364
|
+
audioSamples += 1;
|
|
31365
|
+
onAudio({
|
|
31366
|
+
data: data.slice(),
|
|
31367
|
+
ptsMs: ptsMsFrom(at, sample.cts, audioTrack.timescale),
|
|
31368
|
+
durationMs: Math.round(sample.duration * 1e3 / audioTrack.timescale),
|
|
31369
|
+
codec: audioTrack.codec,
|
|
31370
|
+
sampleRateHz: audioTrack.sampleRateHz,
|
|
31371
|
+
channels: audioTrack.channels
|
|
31372
|
+
});
|
|
31373
|
+
};
|
|
31374
|
+
/** Emit every announced sample whose bytes are in, in order; stop at the first that is not. */
|
|
31375
|
+
const drainAnnounced = (out) => {
|
|
31376
|
+
while (announced.length > 0) {
|
|
31377
|
+
const next = announced[0];
|
|
31378
|
+
if (next === void 0) return;
|
|
31379
|
+
const sampleEnd = next.sample.offset + next.sample.size;
|
|
31380
|
+
if (mdatEnd !== null && sampleEnd > mdatEnd) refuse("malformed", `a sample runs past its mdat (${sampleEnd} > ${mdatEnd})`);
|
|
31381
|
+
if (next.sample.offset < window.start) refuse("malformed", `a sample points behind the bytes still held (${next.sample.offset} < ${window.start})`);
|
|
31382
|
+
if (sampleEnd > window.end) return;
|
|
31383
|
+
const bytes = window.slice(next.sample.offset, sampleEnd);
|
|
31384
|
+
if (next.kind === "video") out.push(toAccessUnit(next.sample, bytes));
|
|
31385
|
+
else emitAudio(next.sample, bytes);
|
|
31386
|
+
announced = announced.slice(1);
|
|
31387
|
+
window.consumeTo(sampleEnd);
|
|
31388
|
+
}
|
|
31389
|
+
if (mdatEnd !== null) {
|
|
31390
|
+
window.consumeTo(mdatEnd);
|
|
31391
|
+
mdatEnd = null;
|
|
31392
|
+
}
|
|
31393
|
+
};
|
|
31394
|
+
const push = (chunk) => {
|
|
31395
|
+
if (dead !== null) throw dead;
|
|
31396
|
+
window.append(chunk);
|
|
31397
|
+
const out = [];
|
|
31398
|
+
for (;;) {
|
|
31399
|
+
if (announced.length > 0 && mdatEnd !== null) {
|
|
31400
|
+
drainAnnounced(out);
|
|
31401
|
+
if (announced.length > 0) return out;
|
|
31402
|
+
}
|
|
31403
|
+
const held = window.view();
|
|
31404
|
+
if (held.length < BOX_HEADER) return out;
|
|
31405
|
+
const view = new DataView(held.buffer, held.byteOffset, held.byteLength);
|
|
31406
|
+
const declared = u32(view, 0);
|
|
31407
|
+
const type = fourcc(held, 4);
|
|
31408
|
+
let size = declared;
|
|
31409
|
+
let contentStart = BOX_HEADER;
|
|
31410
|
+
if (declared === 1) {
|
|
31411
|
+
if (held.length < LARGE_BOX_HEADER) return out;
|
|
31412
|
+
size = u64(view, 8);
|
|
31413
|
+
contentStart = LARGE_BOX_HEADER;
|
|
31414
|
+
} else if (declared === 0) refuse("malformed", `a "${type}" box extends to end-of-file, which a stream never reaches`);
|
|
31415
|
+
if (size < contentStart) refuse("malformed", `a "${type}" box declares ${size} bytes`);
|
|
31416
|
+
const boxStart = window.start;
|
|
31417
|
+
const boxEnd = boxStart + size;
|
|
31418
|
+
if (type === "mdat") {
|
|
31419
|
+
if (track === null) refuse("no-moov", "media arrived before any moov");
|
|
31420
|
+
if (announced.length === 0) {
|
|
31421
|
+
window.consumeTo(boxEnd);
|
|
31422
|
+
continue;
|
|
31423
|
+
}
|
|
31424
|
+
mdatEnd = boxEnd;
|
|
31425
|
+
window.consumeTo(boxStart + contentStart);
|
|
31426
|
+
continue;
|
|
31427
|
+
}
|
|
31428
|
+
if (held.length < size) return out;
|
|
31429
|
+
if (type === "moov") {
|
|
31430
|
+
const moov = {
|
|
31431
|
+
type,
|
|
31432
|
+
start: 0,
|
|
31433
|
+
end: size,
|
|
31434
|
+
contentStart
|
|
31435
|
+
};
|
|
31436
|
+
const read = readVideoTrack(held, moov);
|
|
31437
|
+
if (typeof read === "string") refuse(read, `the moov cannot be followed: ${read}`);
|
|
31438
|
+
else track = read;
|
|
31439
|
+
const audio = readAudioTrack$1(held, moov);
|
|
31440
|
+
if ("kind" in audio) audioState = audio;
|
|
31441
|
+
else {
|
|
31442
|
+
audioTrack = audio;
|
|
31443
|
+
audioState = {
|
|
31444
|
+
kind: "ready",
|
|
31445
|
+
codec: audio.codec,
|
|
31446
|
+
sampleRateHz: audio.sampleRateHz,
|
|
31447
|
+
channels: audio.channels
|
|
31448
|
+
};
|
|
31449
|
+
}
|
|
31450
|
+
} else if (type === "moof") {
|
|
31451
|
+
if (track === null) refuse("no-moov", "a moof arrived before any moov");
|
|
31452
|
+
const announcedHere = readMoof(held, {
|
|
31453
|
+
type,
|
|
31454
|
+
start: 0,
|
|
31455
|
+
end: size,
|
|
31456
|
+
contentStart
|
|
31457
|
+
}, {
|
|
31458
|
+
video: track,
|
|
31459
|
+
audio: onAudio === void 0 ? null : audioTrack
|
|
31460
|
+
});
|
|
31461
|
+
if (announcedHere === null) refuse("malformed", "a moof cannot be read");
|
|
31462
|
+
announced = announcedHere.map((p) => ({
|
|
31463
|
+
kind: p.kind,
|
|
31464
|
+
sample: {
|
|
31465
|
+
...p.sample,
|
|
31466
|
+
offset: p.sample.offset + boxStart
|
|
31467
|
+
}
|
|
31468
|
+
}));
|
|
31469
|
+
}
|
|
31470
|
+
window.consumeTo(boxEnd);
|
|
31471
|
+
}
|
|
31472
|
+
};
|
|
31473
|
+
return {
|
|
31474
|
+
push,
|
|
31475
|
+
end: () => ({
|
|
31476
|
+
durationMs: track !== null && samples > 0 && durationTicks > 0 ? durationTicks * 1e3 / track.timescale : null,
|
|
31477
|
+
samples,
|
|
31478
|
+
audioSamples,
|
|
31479
|
+
trailingBytes: window.length,
|
|
31480
|
+
pendingSamples: announced.length
|
|
31481
|
+
}),
|
|
31482
|
+
get ready() {
|
|
31483
|
+
return track !== null;
|
|
31484
|
+
},
|
|
31485
|
+
get codec() {
|
|
31486
|
+
return track?.codec ?? null;
|
|
31487
|
+
},
|
|
31488
|
+
get audio() {
|
|
31489
|
+
return audioState;
|
|
31490
|
+
},
|
|
31491
|
+
get pendingBytes() {
|
|
31492
|
+
return window.length;
|
|
31493
|
+
}
|
|
31494
|
+
};
|
|
31495
|
+
}
|
|
31496
|
+
//#endregion
|
|
31497
|
+
//#region src/stream-broker/recorded/clip-stream-feeder.ts
|
|
31498
|
+
/**
|
|
31499
|
+
* clip-stream-feeder — a clip plays FROM A STREAM, forward-only (D597).
|
|
31500
|
+
*
|
|
31501
|
+
* The second clip feeder, beside `clip-file-source.ts` + `RecordedFeeder`
|
|
31502
|
+
* and not in their place. The file path fetches the whole clip by handle,
|
|
31503
|
+
* indexes `stbl`, and lets the recorded feeder PULL access units by time.
|
|
31504
|
+
* This path is the other shape: the provider's clip STREAM
|
|
31505
|
+
* (`/clip-stream/…`, stage 1) is a fragmented MP4 written from the first
|
|
31506
|
+
* muxed byte, with no index and no length, and the bytes reach this process
|
|
31507
|
+
* as the camera delivers them. Nothing here pulls — the stream PUSHES, the
|
|
31508
|
+
* demuxer emits access units as their bytes land, and this feeder paces them
|
|
31509
|
+
* onto the session's track at the playback rate.
|
|
31510
|
+
*
|
|
31511
|
+
* ## Forward-only, and what that means for every verb
|
|
31512
|
+
*
|
|
31513
|
+
* The camera offers no seek inside a recording (operator's decision from the
|
|
31514
|
+
* packet capture, 2026-09-22): a replay names a FILE, and the bytes behind
|
|
31515
|
+
* the head were never kept. So:
|
|
31516
|
+
*
|
|
31517
|
+
* - `seek(t)` with `t` behind the HEAD (the last frame pushed) is REFUSED BY
|
|
31518
|
+
* NAME — logged, the position re-sent so the viewer's playhead snaps back
|
|
31519
|
+
* to the truth, and nothing else changes. "Back to the start" is as
|
|
31520
|
+
* impossible as any other backward move; `forward` is measured against the
|
|
31521
|
+
* head, never against zero (the viewer's `clipSeekAllowed` says the same).
|
|
31522
|
+
* - `seek(t)` ahead of the head is honoured if the bytes have arrived: what
|
|
31523
|
+
* was buffered before the last keyframe ≤ `t` is dropped, the run from that
|
|
31524
|
+
* keyframe to `t` is BURST (decode priming, as the recorded feeder's seek
|
|
31525
|
+
* prefix does), and pacing resumes from `t`. If the bytes have NOT arrived,
|
|
31526
|
+
* the seek waits for them and lands when they do — a stream cannot answer
|
|
31527
|
+
* faster than the camera sends.
|
|
31528
|
+
* - `stepFrame(-1)` and the scrub verbs are refused by name; `stepFrame(+1)`
|
|
31529
|
+
* pushes the next buffered unit.
|
|
31530
|
+
* - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 4],
|
|
31531
|
+
* the recorded domain). The camera's own `playSpeed` (8, 16 — real, the app
|
|
31532
|
+
* reopens the replay at 8) is a different mechanism and is NOT wired: it
|
|
31533
|
+
* would go on the dial (`videoclips.dialClipStream` → the cmd-5 replay's
|
|
31534
|
+
* `<playSpeed>`), costing a new stream, and is named in D597 as open.
|
|
31535
|
+
*
|
|
31536
|
+
* ## Duration is unknown until it is known
|
|
31537
|
+
*
|
|
31538
|
+
* A stream has no length up front. `durationMs()` resolves when the stream
|
|
31539
|
+
* ENDS, with the demuxer's own sum, or with `null` when it was cut or left
|
|
31540
|
+
* before it ended — never `0` (D393). The session reports it then, named by
|
|
31541
|
+
* clip, exactly as it reports the file path's measure.
|
|
31542
|
+
*
|
|
31543
|
+
* ## D9/D18, stated
|
|
31544
|
+
*
|
|
31545
|
+
* Media crosses the process boundary here COMPRESSED, in one TCP stream, at
|
|
31546
|
+
* the camera's delivery rate — the same shape the live path ingests from an
|
|
31547
|
+
* RTSP camera, and the same shape the file path's one by-handle fetch has,
|
|
31548
|
+
* spread over time instead of held whole. What D9 forbids is RAW frames at
|
|
31549
|
+
* frame-rate across processes, and nothing raw exists until the session's
|
|
31550
|
+
* decoder, which is downstream of this process. The backlog is bounded
|
|
31551
|
+
* ({@link CLIP_STREAM_BUFFER_MAX_BYTES}, the file path's own ceiling): above
|
|
31552
|
+
* it the socket is PAUSED (D447 on the consuming hop — read no chunk you have
|
|
31553
|
+
* not taken), and the producer's drain gate is what decides a consumer that
|
|
31554
|
+
* never drains. Frames behind the head are released as soon as they are
|
|
31555
|
+
* pushed, which is the same fact as "no backward seek".
|
|
31556
|
+
*
|
|
31557
|
+
* ## Sound, and the one clock it shares with the picture (D600)
|
|
31558
|
+
*
|
|
31559
|
+
* The stream carries audio ALREADY in the session's negotiated codec — the
|
|
31560
|
+
* provider transcodes before it muxes, because it is the process that holds
|
|
31561
|
+
* the camera's AAC and it already runs an ffmpeg. So this feeder decodes
|
|
31562
|
+
* nothing: it frames (`clip-stream-audio.ts`) and paces, on ONE schedule with
|
|
31563
|
+
* the video, on the SAME `ptsMs` axis the demuxer anchors both tracks on, and
|
|
31564
|
+
* off ONE {@link MonotonicClock} whose output is converted to each track's
|
|
31565
|
+
* RTP rate ({@link ClipStreamFeeder.stampAudio}). One clock rather than the
|
|
31566
|
+
* recorded path's pair: two clocks re-base on their own last output at every
|
|
31567
|
+
* discontinuity, so each seek leaves them a frame further apart.
|
|
31568
|
+
*
|
|
31569
|
+
* Two rules keep it honest, and both exist because a wrong one is inaudible
|
|
31570
|
+
* in a unit test:
|
|
31571
|
+
*
|
|
31572
|
+
* - **Audio is never pushed late.** ffmpeg's muxer writes a track's run when
|
|
31573
|
+
* its own fragment closes, so the first seconds of sound can land after the
|
|
31574
|
+
* picture they belong to (measured: 43 ms behind at the head on a 60x
|
|
31575
|
+
* delivery, and nothing after). A unit whose moment has passed by more than
|
|
31576
|
+
* {@link CLIP_STREAM_AUDIO_LATE_MS} is DROPPED and counted — never pushed
|
|
31577
|
+
* in a burst the receiver would have to discard anyway, and never silently.
|
|
31578
|
+
* - **Audio plays at 1x only**, like the recorded feeder's: a rate change
|
|
31579
|
+
* would need a resample this path has no decoder for, and pitch-shifted
|
|
31580
|
+
* sound is worse than none. Units dropped for the rate are counted apart.
|
|
31581
|
+
*
|
|
31582
|
+
* What the clip turned out to carry — sound, or silence and WHY — settles on
|
|
31583
|
+
* {@link ClipStreamFeeder.audio} the moment the `moov` is read, and is what
|
|
31584
|
+
* the session reports to the viewer. A dial that promises a codec promises
|
|
31585
|
+
* the codec, never the content: whether the recording has a microphone track
|
|
31586
|
+
* is only ever knowable from the stream.
|
|
31587
|
+
*
|
|
31588
|
+
* ## Every exit is logged
|
|
31589
|
+
*
|
|
31590
|
+
* Opened, first frame (with the time since the seek and since the dial),
|
|
31591
|
+
* ended, cut (by reason), refused (by the provider's code), disposed, every
|
|
31592
|
+
* refused verb, and every backpressure transition — each with `deviceId` in
|
|
31593
|
+
* `data`, which the broker's `onLog` puts in `tags`.
|
|
31594
|
+
*/
|
|
31595
|
+
/**
|
|
31596
|
+
* The same ceiling as the file path (`CLIP_SOURCE_MAX_BYTES`): a clip the
|
|
31597
|
+
* file path would refuse `too-large` is one this path stops taking. Frames
|
|
31598
|
+
* behind the head are released as they are pushed, so the backlog is the
|
|
31599
|
+
* distance between arrival and playback, not the clip.
|
|
31600
|
+
*/
|
|
31601
|
+
var CLIP_STREAM_BUFFER_MAX_BYTES = CLIP_SOURCE_MAX_BYTES;
|
|
31602
|
+
/** The RTP clock each codec's re-stamped timestamps are counted on. */
|
|
31603
|
+
var AUDIO_CLOCK_HZ = {
|
|
31604
|
+
opus: 48e3,
|
|
31605
|
+
pcmu: 8e3
|
|
31606
|
+
};
|
|
31607
|
+
/** A forward seek is a discontinuity for the receiver, like the recorded feeder's. */
|
|
31608
|
+
var SEEK_DISCONTINUITY_MS = 250;
|
|
31609
|
+
var WAIT_TICK_MS = 250;
|
|
31610
|
+
var POSITION_INTERVAL_MS = 1e3;
|
|
31611
|
+
function createClipStreamFeeder(deps) {
|
|
31612
|
+
return new ClipStreamFeeder(deps);
|
|
31613
|
+
}
|
|
31614
|
+
var ClipStreamFeeder = class {
|
|
31615
|
+
deps;
|
|
31616
|
+
phase = "idle";
|
|
31617
|
+
connection = null;
|
|
31618
|
+
demuxer;
|
|
31619
|
+
clock = new MonotonicClock();
|
|
31620
|
+
/** The RTP rate the audio sender negotiated — a CONVERSION, not a clock. */
|
|
31621
|
+
audioClockHz;
|
|
31622
|
+
audioAsk;
|
|
31623
|
+
audioPush;
|
|
31624
|
+
audioFramer = null;
|
|
31625
|
+
audioQueue = [];
|
|
31626
|
+
audioQueuedBytes = 0;
|
|
31627
|
+
audioPushed = 0;
|
|
31628
|
+
audioDroppedLate = 0;
|
|
31629
|
+
audioDroppedRate = 0;
|
|
31630
|
+
audioDroppedSeek = 0;
|
|
31631
|
+
firstAudioLogged = false;
|
|
31632
|
+
audioReport = null;
|
|
31633
|
+
audioWaiters = [];
|
|
31634
|
+
clockHasAdvanced = false;
|
|
31635
|
+
rate = 1;
|
|
31636
|
+
queue = [];
|
|
31637
|
+
queuedBytes = 0;
|
|
31638
|
+
headMs = null;
|
|
31639
|
+
arrivedMs = null;
|
|
31640
|
+
pendingSeekMs = null;
|
|
31641
|
+
pendingSteps = 0;
|
|
31642
|
+
seekToken = 0;
|
|
31643
|
+
anchorWall = 0;
|
|
31644
|
+
anchorPts = 0;
|
|
31645
|
+
paceTimer = null;
|
|
31646
|
+
positionTimer = null;
|
|
31647
|
+
stallTimer = null;
|
|
31648
|
+
socketPaused = false;
|
|
31649
|
+
streamEnded = false;
|
|
31650
|
+
streamEnd = null;
|
|
31651
|
+
duration = null;
|
|
31652
|
+
durationWaiters = [];
|
|
31653
|
+
lastAnnounced = null;
|
|
31654
|
+
scrubRefusedFor = void 0;
|
|
31655
|
+
pushed = 0;
|
|
31656
|
+
dropped = 0;
|
|
31657
|
+
bytesIn = 0;
|
|
31658
|
+
dialedAt = null;
|
|
31659
|
+
seekStartedAt = null;
|
|
31660
|
+
firstFrameLogged = false;
|
|
31661
|
+
maxBufferedBytes;
|
|
31662
|
+
stallTimeoutMs;
|
|
31663
|
+
now;
|
|
31664
|
+
tags;
|
|
31665
|
+
constructor(deps) {
|
|
31666
|
+
this.deps = deps;
|
|
31667
|
+
this.maxBufferedBytes = deps.maxBufferedBytes ?? CLIP_STREAM_BUFFER_MAX_BYTES;
|
|
31668
|
+
this.stallTimeoutMs = deps.stallTimeoutMs ?? 15e3;
|
|
31669
|
+
this.now = deps.now ?? (() => Date.now());
|
|
31670
|
+
this.tags = {
|
|
31671
|
+
deviceId: deps.handle.deviceId,
|
|
31672
|
+
clipId: deps.handle.clipId
|
|
31673
|
+
};
|
|
31674
|
+
const push = this.resolveAudioPush(deps);
|
|
31675
|
+
this.audioAsk = push === null ? "none" : deps.audio ?? "none";
|
|
31676
|
+
this.audioPush = this.audioAsk === "none" ? null : push;
|
|
31677
|
+
this.audioClockHz = this.audioAsk === "none" ? AUDIO_CLOCK_HZ.pcmu : AUDIO_CLOCK_HZ[this.audioAsk];
|
|
31678
|
+
this.demuxer = createFmp4StreamDemuxer(this.audioPush === null ? {} : { onAudio: (unit) => this.onAudioUnit(unit) });
|
|
31679
|
+
if (this.audioPush === null) this.settleAudio({
|
|
31680
|
+
carried: false,
|
|
31681
|
+
codec: null,
|
|
31682
|
+
reason: "not-negotiated"
|
|
31683
|
+
});
|
|
31684
|
+
}
|
|
31685
|
+
/**
|
|
31686
|
+
* The sink function for the negotiated codec, or `null`.
|
|
31687
|
+
*
|
|
31688
|
+
* Mirrors `RecordedFeeder.resolveAudioPush`: the sink is what decides, and
|
|
31689
|
+
* a session that offered a codec on the dial but wired no push for it would
|
|
31690
|
+
* otherwise make the provider transcode for nobody.
|
|
31691
|
+
*/
|
|
31692
|
+
resolveAudioPush(deps) {
|
|
31693
|
+
const { sink, audio } = deps;
|
|
31694
|
+
if (audio === "opus") {
|
|
31695
|
+
const opus = sink.pushAudioOpus;
|
|
31696
|
+
return opus === void 0 ? null : (data, ts) => opus.call(sink, data, ts);
|
|
31697
|
+
}
|
|
31698
|
+
if (audio === "pcmu") {
|
|
31699
|
+
const pcmu = sink.pushAudioPcmu;
|
|
31700
|
+
return pcmu === void 0 ? null : (data, ts) => pcmu.call(sink, data, ts);
|
|
31701
|
+
}
|
|
31702
|
+
return null;
|
|
31703
|
+
}
|
|
31704
|
+
async seek(epochMs) {
|
|
31705
|
+
const token = ++this.seekToken;
|
|
31706
|
+
if (this.phase === "disposed" || this.phase === "cut" || this.phase === "refused") {
|
|
31707
|
+
this.log("clip-stream:seek-after-exit", {
|
|
31708
|
+
targetMs: epochMs,
|
|
31709
|
+
phase: this.phase
|
|
31710
|
+
});
|
|
31711
|
+
return;
|
|
31712
|
+
}
|
|
31713
|
+
if (this.phase === "ended") {
|
|
31714
|
+
this.log("clip-stream:seek-at-end", {
|
|
31715
|
+
targetMs: epochMs,
|
|
31716
|
+
headMs: this.headMs
|
|
31717
|
+
});
|
|
31718
|
+
if (this.headMs !== null) this.deps.onPosition(this.headMs);
|
|
31719
|
+
return;
|
|
31720
|
+
}
|
|
31721
|
+
if (this.headMs !== null && epochMs < this.headMs) {
|
|
31722
|
+
this.log("clip-stream:seek-backward-refused", {
|
|
31723
|
+
targetMs: epochMs,
|
|
31724
|
+
headMs: this.headMs,
|
|
31725
|
+
behindMs: this.headMs - epochMs
|
|
31726
|
+
});
|
|
31727
|
+
this.deps.onPosition(this.headMs);
|
|
31728
|
+
return;
|
|
31729
|
+
}
|
|
31730
|
+
this.seekStartedAt = this.now();
|
|
31731
|
+
this.stopPacing();
|
|
31732
|
+
this.pendingSeekMs = epochMs;
|
|
31733
|
+
if (this.phase === "idle") {
|
|
31734
|
+
await this.open(token);
|
|
31735
|
+
if (token !== this.seekToken) return;
|
|
31736
|
+
if (!this.isStreaming()) return;
|
|
31737
|
+
} else if (this.headMs !== null && epochMs > this.headMs && this.clockHasAdvanced) {
|
|
31738
|
+
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
31739
|
+
this.emitState("loading", "seek-forward", token);
|
|
31740
|
+
this.log("clip-stream:seek-forward", {
|
|
31741
|
+
targetMs: epochMs,
|
|
31742
|
+
headMs: this.headMs,
|
|
31743
|
+
arrivedMs: this.arrivedMs,
|
|
31744
|
+
queued: this.queue.length
|
|
31745
|
+
});
|
|
31746
|
+
}
|
|
31747
|
+
this.resolvePendingSeek(token);
|
|
31748
|
+
}
|
|
31749
|
+
setRate(rate) {
|
|
31750
|
+
const next = clampPlaybackRate(rate);
|
|
31751
|
+
if (next === 0) {
|
|
31752
|
+
this.rate = 0;
|
|
31753
|
+
this.stopPacing();
|
|
31754
|
+
this.stopPositionReports();
|
|
31755
|
+
if (this.phase === "streaming" && this.pendingSeekMs === null) this.emitState("paused", "set-rate-zero", this.seekToken);
|
|
31756
|
+
return;
|
|
31757
|
+
}
|
|
31758
|
+
this.clock.setRate(next);
|
|
31759
|
+
const wasPaused = this.rate === 0;
|
|
31760
|
+
this.rate = next;
|
|
31761
|
+
if (this.phase !== "streaming" || this.pendingSeekMs !== null) return;
|
|
31762
|
+
if (wasPaused) this.emitState("playing", "set-rate-resume", this.seekToken);
|
|
31763
|
+
this.startPacing();
|
|
31764
|
+
}
|
|
31765
|
+
stepFrame(dir) {
|
|
31766
|
+
if (this.rate !== 0 || this.phase !== "streaming") return;
|
|
31767
|
+
if (dir === -1) {
|
|
31768
|
+
this.log("clip-stream:step-backward-refused", { headMs: this.headMs });
|
|
31769
|
+
return;
|
|
31770
|
+
}
|
|
31771
|
+
if (!this.pushNext()) {
|
|
31772
|
+
if (this.streamEnded) {
|
|
31773
|
+
this.log("clip-stream:step-at-end", { headMs: this.headMs });
|
|
31774
|
+
return;
|
|
31775
|
+
}
|
|
31776
|
+
this.pendingSteps += 1;
|
|
31777
|
+
}
|
|
31778
|
+
}
|
|
31779
|
+
beginScrub(dragId) {
|
|
31780
|
+
if (this.scrubRefusedFor === dragId) return;
|
|
31781
|
+
this.scrubRefusedFor = dragId;
|
|
31782
|
+
this.log("clip-stream:scrub-refused", {
|
|
31783
|
+
dragId,
|
|
31784
|
+
reason: "forward-only",
|
|
31785
|
+
headMs: this.headMs
|
|
31786
|
+
});
|
|
31787
|
+
}
|
|
31788
|
+
scrubTo() {}
|
|
31789
|
+
scrubAck() {}
|
|
31790
|
+
durationMs() {
|
|
31791
|
+
if (this.duration !== null) return Promise.resolve(this.duration.value);
|
|
31792
|
+
return new Promise((resolve) => this.durationWaiters.push(resolve));
|
|
31793
|
+
}
|
|
31794
|
+
/**
|
|
31795
|
+
* What this clip turned out to carry as sound — the answer the viewer needs
|
|
31796
|
+
* to say "this clip has no audio" rather than leave the operator wondering.
|
|
31797
|
+
*
|
|
31798
|
+
* Settles as soon as the stream's `moov` has been read, which is the first
|
|
31799
|
+
* fragment, so it lands beside `durationMs()`'s much later answer rather
|
|
31800
|
+
* than waiting for the stream to end.
|
|
31801
|
+
*/
|
|
31802
|
+
audio() {
|
|
31803
|
+
const settled = this.audioReport;
|
|
31804
|
+
if (settled !== null) return Promise.resolve(settled);
|
|
31805
|
+
return new Promise((resolve) => this.audioWaiters.push(resolve));
|
|
31806
|
+
}
|
|
31807
|
+
dispose() {
|
|
31808
|
+
if (this.phase === "disposed") return;
|
|
31809
|
+
const before = this.phase;
|
|
31810
|
+
this.phase = "disposed";
|
|
31811
|
+
this.teardown();
|
|
31812
|
+
this.settleDuration(null);
|
|
31813
|
+
this.settleAudio({
|
|
31814
|
+
carried: false,
|
|
31815
|
+
codec: null,
|
|
31816
|
+
reason: "stream-failed"
|
|
31817
|
+
});
|
|
31818
|
+
this.log("clip-stream:disposed", {
|
|
31819
|
+
phaseBefore: before,
|
|
31820
|
+
pushed: this.pushed,
|
|
31821
|
+
dropped: this.dropped,
|
|
31822
|
+
queued: this.queue.length,
|
|
31823
|
+
bytesIn: this.bytesIn,
|
|
31824
|
+
headMs: this.headMs
|
|
31825
|
+
});
|
|
31826
|
+
this.queue = [];
|
|
31827
|
+
this.queuedBytes = 0;
|
|
31828
|
+
this.audioQueue = [];
|
|
31829
|
+
this.audioQueuedBytes = 0;
|
|
31830
|
+
}
|
|
31831
|
+
/** A method, not a field read: `open()` moves the phase behind an await
|
|
31832
|
+
* that control-flow analysis does not see through. */
|
|
31833
|
+
isStreaming() {
|
|
31834
|
+
return this.phase === "streaming";
|
|
31835
|
+
}
|
|
31836
|
+
async open(token) {
|
|
31837
|
+
this.phase = "opening";
|
|
31838
|
+
this.emitState("loading", "dial", token);
|
|
31839
|
+
this.dialedAt = this.now();
|
|
31840
|
+
let answer;
|
|
31841
|
+
try {
|
|
31842
|
+
answer = await this.deps.dial(this.deps.handle, this.audioAsk);
|
|
31843
|
+
} catch (err) {
|
|
31844
|
+
answer = {
|
|
31845
|
+
kind: "refused",
|
|
31846
|
+
code: "fetch-failed",
|
|
31847
|
+
detail: describe(err)
|
|
31848
|
+
};
|
|
31849
|
+
}
|
|
31850
|
+
if (this.phase !== "opening") return;
|
|
31851
|
+
if (answer.kind !== "dialed") {
|
|
31852
|
+
this.settleAudio({
|
|
31853
|
+
carried: false,
|
|
31854
|
+
codec: null,
|
|
31855
|
+
reason: "stream-failed"
|
|
31856
|
+
});
|
|
31857
|
+
this.refuse(answer.kind === "unavailable" ? "stream-unavailable" : answer.code, answer.detail, token, answer.kind === "unavailable" ? "the provider predates videoclips.dialClipStream — deploy provider-reolink, or the file path" : void 0);
|
|
31858
|
+
return;
|
|
31859
|
+
}
|
|
31860
|
+
if (this.deps.handle.profile === "high" && answer.served !== void 0 && answer.served !== "high") this.deps.onLog?.("clip stream: no main twin — streaming the sub copy for a `high` ask", {
|
|
31861
|
+
deviceId: this.deps.handle.deviceId,
|
|
31862
|
+
asked: "high",
|
|
31863
|
+
served: answer.served
|
|
31864
|
+
});
|
|
31865
|
+
if (this.audioAsk !== "none" && answer.servedAudio !== this.audioAsk) {
|
|
31866
|
+
this.settleAudio({
|
|
31867
|
+
carried: false,
|
|
31868
|
+
codec: null,
|
|
31869
|
+
reason: "not-offered",
|
|
31870
|
+
detail: answer.audioReason ?? answer.servedAudio ?? "the provider did not say"
|
|
31871
|
+
});
|
|
31872
|
+
this.log("clip-stream:audio-not-offered", {
|
|
31873
|
+
asked: this.audioAsk,
|
|
31874
|
+
servedAudio: answer.servedAudio ?? null,
|
|
31875
|
+
audioReason: answer.audioReason ?? null
|
|
31876
|
+
});
|
|
31877
|
+
}
|
|
31878
|
+
let opened;
|
|
31879
|
+
try {
|
|
31880
|
+
opened = await this.deps.open(answer.url, {
|
|
31881
|
+
onData: (chunk) => this.onData(chunk),
|
|
31882
|
+
onEnd: () => this.onEnd(),
|
|
31883
|
+
onError: (err) => this.cut("stream-failed", err.message)
|
|
31884
|
+
});
|
|
31885
|
+
} catch (err) {
|
|
31886
|
+
opened = {
|
|
31887
|
+
kind: "refused",
|
|
31888
|
+
status: 0,
|
|
31889
|
+
code: "fetch-failed",
|
|
31890
|
+
detail: describe(err)
|
|
31891
|
+
};
|
|
31892
|
+
}
|
|
31893
|
+
if (this.phase !== "opening") {
|
|
31894
|
+
if (opened.kind === "open") opened.connection.destroy();
|
|
31895
|
+
return;
|
|
31896
|
+
}
|
|
31897
|
+
if (opened.kind === "refused") {
|
|
31898
|
+
this.refuse(opened.code, opened.detail, token, void 0, opened.status);
|
|
31899
|
+
return;
|
|
31900
|
+
}
|
|
31901
|
+
this.connection = opened.connection;
|
|
31902
|
+
this.phase = "streaming";
|
|
31903
|
+
this.armStall();
|
|
31904
|
+
this.applyBackpressure();
|
|
31905
|
+
this.log("clip-stream:opened", {
|
|
31906
|
+
sinceDialMs: this.now() - (this.dialedAt ?? this.now()),
|
|
31907
|
+
expiresInMs: answer.expiresAtMs - Date.now()
|
|
31908
|
+
});
|
|
31909
|
+
}
|
|
31910
|
+
refuse(code, detail, token, hint, status) {
|
|
31911
|
+
this.phase = "refused";
|
|
31912
|
+
this.teardown();
|
|
31913
|
+
this.settleDuration(null);
|
|
31914
|
+
this.settleAudio({
|
|
31915
|
+
carried: false,
|
|
31916
|
+
codec: null,
|
|
31917
|
+
reason: "stream-failed"
|
|
31918
|
+
});
|
|
31919
|
+
this.log("clip-stream:refused", {
|
|
31920
|
+
code,
|
|
31921
|
+
detail,
|
|
31922
|
+
...hint !== void 0 ? { hint } : {},
|
|
31923
|
+
...status !== void 0 ? { status } : {}
|
|
31924
|
+
});
|
|
31925
|
+
this.emitState("error", `refused:${code}`, token);
|
|
31926
|
+
}
|
|
31927
|
+
/**
|
|
31928
|
+
* Bytes are taken while OPENING too. Node flushes body bytes it already
|
|
31929
|
+
* holds on `process.nextTick` the moment the `data` listener attaches,
|
|
31930
|
+
* and that tick runs BEFORE the promise continuation that flips this
|
|
31931
|
+
* feeder to `streaming` — so the head of the stream (ftyp, moov) arrives
|
|
31932
|
+
* while the phase still says `opening`. Dropping it there turned every
|
|
31933
|
+
* live stream into `no-moov` while a fake opener, which delivered only
|
|
31934
|
+
* after resolving, kept the suite green (2026-09-22).
|
|
31935
|
+
*/
|
|
31936
|
+
takesBytes() {
|
|
31937
|
+
return this.phase === "streaming" || this.phase === "opening";
|
|
31938
|
+
}
|
|
31939
|
+
onData(chunk) {
|
|
31940
|
+
if (!this.takesBytes()) return;
|
|
31941
|
+
this.bytesIn += chunk.byteLength;
|
|
31942
|
+
this.armStall();
|
|
31943
|
+
let units;
|
|
31944
|
+
try {
|
|
31945
|
+
units = this.demuxer.push(chunk);
|
|
31946
|
+
} catch (err) {
|
|
31947
|
+
const reason = err instanceof Fmp4DemuxError ? err.reason : "malformed";
|
|
31948
|
+
this.cut("demux-refused", `${reason}: ${describe(err)}`);
|
|
31949
|
+
return;
|
|
31950
|
+
}
|
|
31951
|
+
for (const unit of units) {
|
|
31952
|
+
this.queue.push(unit);
|
|
31953
|
+
this.queuedBytes += unit.data.byteLength;
|
|
31954
|
+
this.arrivedMs = unit.ptsMs;
|
|
31955
|
+
}
|
|
31956
|
+
this.settleAudioFromMoov();
|
|
31957
|
+
if (units.length > 0 || this.audioQueue.length > 0) this.onUnitsArrived();
|
|
31958
|
+
this.applyBackpressure();
|
|
31959
|
+
}
|
|
31960
|
+
/**
|
|
31961
|
+
* What the stream really carries, decided from the `moov` and said ONCE.
|
|
31962
|
+
*
|
|
31963
|
+
* This is the answer a dial cannot give: the provider promises a codec
|
|
31964
|
+
* before the camera has delivered a single audio frame, and whether the
|
|
31965
|
+
* recording HAS sound is written in the container it produced. A track in a
|
|
31966
|
+
* codec this process cannot forward, or in one the session did not
|
|
31967
|
+
* negotiate, is named here and never re-encoded — a second transcode on
|
|
31968
|
+
* this hop is exactly the backlog this path is built to avoid.
|
|
31969
|
+
*/
|
|
31970
|
+
settleAudioFromMoov() {
|
|
31971
|
+
if (this.audioReport !== null || this.audioPush === null) return;
|
|
31972
|
+
const state = this.demuxer.audio;
|
|
31973
|
+
if (state.kind === "unknown") return;
|
|
31974
|
+
if (state.kind === "absent") {
|
|
31975
|
+
this.settleAudio({
|
|
31976
|
+
carried: false,
|
|
31977
|
+
codec: null,
|
|
31978
|
+
reason: "no-audio-track"
|
|
31979
|
+
});
|
|
31980
|
+
this.log("clip-stream:audio-absent", { asked: this.audioAsk });
|
|
31981
|
+
return;
|
|
31982
|
+
}
|
|
31983
|
+
if (state.kind === "unsupported") {
|
|
31984
|
+
this.settleAudio({
|
|
31985
|
+
carried: false,
|
|
31986
|
+
codec: null,
|
|
31987
|
+
reason: "unsupported-codec"
|
|
31988
|
+
});
|
|
31989
|
+
this.log("clip-stream:audio-unsupported", {
|
|
31990
|
+
asked: this.audioAsk,
|
|
31991
|
+
sampleEntry: state.sampleEntry
|
|
31992
|
+
});
|
|
31993
|
+
return;
|
|
31994
|
+
}
|
|
31995
|
+
if (state.codec !== this.audioAsk) {
|
|
31996
|
+
this.settleAudio({
|
|
31997
|
+
carried: false,
|
|
31998
|
+
codec: null,
|
|
31999
|
+
reason: "codec-mismatch"
|
|
32000
|
+
});
|
|
32001
|
+
this.log("clip-stream:audio-codec-mismatch", {
|
|
32002
|
+
asked: this.audioAsk,
|
|
32003
|
+
got: state.codec
|
|
32004
|
+
});
|
|
32005
|
+
return;
|
|
32006
|
+
}
|
|
32007
|
+
this.audioFramer = createClipAudioFramer(state.codec);
|
|
32008
|
+
this.settleAudio({
|
|
32009
|
+
carried: true,
|
|
32010
|
+
codec: state.codec,
|
|
32011
|
+
reason: null
|
|
32012
|
+
});
|
|
32013
|
+
this.log("clip-stream:audio-ready", {
|
|
32014
|
+
codec: state.codec,
|
|
32015
|
+
sampleRateHz: state.sampleRateHz,
|
|
32016
|
+
channels: state.channels
|
|
32017
|
+
});
|
|
32018
|
+
}
|
|
32019
|
+
/** One demuxed audio sample, framed and queued on the same axis as video. */
|
|
32020
|
+
onAudioUnit(unit) {
|
|
32021
|
+
this.settleAudioFromMoov();
|
|
32022
|
+
const framer = this.audioFramer;
|
|
32023
|
+
if (framer === null) return;
|
|
32024
|
+
for (const frame of framer.push(unit)) {
|
|
32025
|
+
this.audioQueue.push(frame);
|
|
32026
|
+
this.audioQueuedBytes += frame.data.byteLength;
|
|
32027
|
+
}
|
|
32028
|
+
}
|
|
32029
|
+
onUnitsArrived() {
|
|
32030
|
+
if (this.pendingSeekMs !== null) {
|
|
32031
|
+
this.resolvePendingSeek(this.seekToken);
|
|
32032
|
+
return;
|
|
32033
|
+
}
|
|
32034
|
+
while (this.rate === 0 && this.pendingSteps > 0 && this.pushNext()) this.pendingSteps -= 1;
|
|
32035
|
+
if (this.rate > 0 && this.paceTimer === null) this.tick();
|
|
32036
|
+
}
|
|
32037
|
+
onEnd() {
|
|
32038
|
+
if (!this.takesBytes()) return;
|
|
32039
|
+
this.streamEnded = true;
|
|
32040
|
+
this.clearStall();
|
|
32041
|
+
this.streamEnd = this.demuxer.end();
|
|
32042
|
+
this.log("clip-stream:ended", {
|
|
32043
|
+
samples: this.streamEnd.samples,
|
|
32044
|
+
durationMs: this.streamEnd.durationMs,
|
|
32045
|
+
bytesIn: this.bytesIn,
|
|
32046
|
+
trailingBytes: this.streamEnd.trailingBytes,
|
|
32047
|
+
pendingSamples: this.streamEnd.pendingSamples,
|
|
32048
|
+
queued: this.queue.length
|
|
32049
|
+
});
|
|
32050
|
+
this.settleDuration(this.streamEnd.durationMs);
|
|
32051
|
+
if (this.pendingSeekMs !== null) {
|
|
32052
|
+
this.resolvePendingSeek(this.seekToken);
|
|
32053
|
+
return;
|
|
32054
|
+
}
|
|
32055
|
+
if (this.rate > 0 && this.paceTimer === null) this.tick();
|
|
32056
|
+
}
|
|
32057
|
+
cut(reason, detail) {
|
|
32058
|
+
if (this.phase !== "streaming" && this.phase !== "opening") return;
|
|
32059
|
+
this.phase = "cut";
|
|
32060
|
+
this.teardown();
|
|
32061
|
+
this.settleDuration(null);
|
|
32062
|
+
this.settleAudio({
|
|
32063
|
+
carried: false,
|
|
32064
|
+
codec: null,
|
|
32065
|
+
reason: "stream-failed"
|
|
32066
|
+
});
|
|
32067
|
+
this.log("clip-stream:cut", {
|
|
32068
|
+
reason,
|
|
32069
|
+
detail,
|
|
32070
|
+
pushed: this.pushed,
|
|
32071
|
+
queued: this.queue.length,
|
|
32072
|
+
bytesIn: this.bytesIn,
|
|
32073
|
+
headMs: this.headMs,
|
|
32074
|
+
arrivedMs: this.arrivedMs,
|
|
32075
|
+
...this.audioTally()
|
|
32076
|
+
});
|
|
32077
|
+
this.emitState("error", `cut:${reason}`, this.seekToken);
|
|
32078
|
+
}
|
|
32079
|
+
armStall() {
|
|
32080
|
+
this.clearStall();
|
|
32081
|
+
this.stallTimer = setTimeout(() => this.cut("stream-stalled", `no bytes for ${this.stallTimeoutMs} ms`), this.stallTimeoutMs);
|
|
32082
|
+
}
|
|
32083
|
+
clearStall() {
|
|
32084
|
+
if (this.stallTimer !== null) clearTimeout(this.stallTimer);
|
|
32085
|
+
this.stallTimer = null;
|
|
32086
|
+
}
|
|
32087
|
+
/** D447 on the consuming hop: pause the socket above the bound, resume below half. */
|
|
32088
|
+
applyBackpressure() {
|
|
32089
|
+
if (this.connection === null) return;
|
|
32090
|
+
const held = this.queuedBytes + this.audioQueuedBytes + this.demuxer.pendingBytes;
|
|
32091
|
+
if (!this.socketPaused && held > this.maxBufferedBytes) {
|
|
32092
|
+
this.socketPaused = true;
|
|
32093
|
+
this.connection?.pause();
|
|
32094
|
+
this.log("clip-stream:backpressure-paused", {
|
|
32095
|
+
heldBytes: held,
|
|
32096
|
+
maxBytes: this.maxBufferedBytes
|
|
32097
|
+
});
|
|
32098
|
+
} else if (this.socketPaused && held < this.maxBufferedBytes / 2) {
|
|
32099
|
+
this.socketPaused = false;
|
|
32100
|
+
this.connection?.resume();
|
|
32101
|
+
this.log("clip-stream:backpressure-resumed", { heldBytes: held });
|
|
32102
|
+
}
|
|
32103
|
+
}
|
|
32104
|
+
/**
|
|
32105
|
+
* Land a pending seek if its bytes are here. Called on the seek itself,
|
|
32106
|
+
* on every arrival while one is pending, and on end-of-stream (the last
|
|
32107
|
+
* chance: a target past the end lands on the last frame and the clip ends).
|
|
32108
|
+
*/
|
|
32109
|
+
resolvePendingSeek(token) {
|
|
32110
|
+
const target = this.pendingSeekMs;
|
|
32111
|
+
if (target === null || this.phase !== "streaming") return;
|
|
32112
|
+
let keep = 0;
|
|
32113
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
32114
|
+
const unit = this.queue[i];
|
|
32115
|
+
if (unit === void 0 || unit.ptsMs > target) break;
|
|
32116
|
+
if (unit.keyframe) keep = i;
|
|
32117
|
+
}
|
|
32118
|
+
if (keep > 0) {
|
|
32119
|
+
for (let i = 0; i < keep; i++) this.queuedBytes -= this.queue[i]?.data.byteLength ?? 0;
|
|
32120
|
+
this.dropped += keep;
|
|
32121
|
+
this.queue = this.queue.slice(keep);
|
|
32122
|
+
}
|
|
32123
|
+
if (!(this.arrivedMs !== null && this.arrivedMs >= target) && !this.streamEnded) return;
|
|
32124
|
+
if (this.queue.length === 0) {
|
|
32125
|
+
if (this.streamEnded) {
|
|
32126
|
+
this.pendingSeekMs = null;
|
|
32127
|
+
this.log("clip-stream:seek-past-end", {
|
|
32128
|
+
targetMs: target,
|
|
32129
|
+
headMs: this.headMs
|
|
32130
|
+
});
|
|
32131
|
+
this.finish();
|
|
32132
|
+
}
|
|
32133
|
+
return;
|
|
32134
|
+
}
|
|
32135
|
+
let burst = 0;
|
|
32136
|
+
while (this.queue.length > 0) {
|
|
32137
|
+
const next = this.queue[0];
|
|
32138
|
+
if (next === void 0 || burst > 0 && next.ptsMs > target) break;
|
|
32139
|
+
this.pushNext();
|
|
32140
|
+
burst += 1;
|
|
32141
|
+
}
|
|
32142
|
+
this.pendingSeekMs = null;
|
|
32143
|
+
this.dropAudioBefore(this.headMs);
|
|
32144
|
+
this.log("clip-stream:seek-landed", {
|
|
32145
|
+
targetMs: target,
|
|
32146
|
+
landedMs: this.headMs,
|
|
32147
|
+
droppedUnits: keep,
|
|
32148
|
+
burstUnits: burst,
|
|
32149
|
+
queued: this.queue.length,
|
|
32150
|
+
streamEnded: this.streamEnded
|
|
32151
|
+
});
|
|
32152
|
+
if (this.headMs !== null) this.deps.onPosition(this.headMs);
|
|
32153
|
+
if (this.rate === 0) {
|
|
32154
|
+
this.emitState("paused", "seek-landed-paused", token);
|
|
32155
|
+
return;
|
|
32156
|
+
}
|
|
32157
|
+
this.emitState("playing", "seek-landed", token);
|
|
32158
|
+
this.startPacing();
|
|
32159
|
+
}
|
|
32160
|
+
/**
|
|
32161
|
+
* The audio RTP timestamp for a source instant — off the SAME clock as the
|
|
32162
|
+
* video, converted to the negotiated rate.
|
|
32163
|
+
*
|
|
32164
|
+
* ONE mapping from source time to output time, not two. The recorded path
|
|
32165
|
+
* runs a second `MonotonicClock` for audio and keeps the pair aligned by
|
|
32166
|
+
* calling `markSeek()` on both, and that alignment is only as good as the
|
|
32167
|
+
* discipline: each clock re-bases on its OWN last output, so every
|
|
32168
|
+
* discontinuity leaves the two a frame further apart than it found them —
|
|
32169
|
+
* a skew that is invisible on a short clip, grows with each seek, and is
|
|
32170
|
+
* exactly the drift this path is not going to pay for twice. Deriving the
|
|
32171
|
+
* audio stamp from the video clock's own output makes the alignment
|
|
32172
|
+
* structural: there is nothing to keep in step.
|
|
32173
|
+
*
|
|
32174
|
+
* The conversion is exact to within a rounding tick (the video clock counts
|
|
32175
|
+
* 90 kHz, so an output instant is known to 1/90 ms) and is computed from
|
|
32176
|
+
* the source pts every time, never accumulated.
|
|
32177
|
+
*/
|
|
32178
|
+
stampAudio(ptsMs) {
|
|
32179
|
+
return Math.round(this.clock.stamp(ptsMs) * this.audioClockHz / 9e4);
|
|
32180
|
+
}
|
|
32181
|
+
/** Forget sound the playhead has already passed — a forward seek's share. */
|
|
32182
|
+
dropAudioBefore(headMs) {
|
|
32183
|
+
if (headMs === null || this.audioQueue.length === 0) return;
|
|
32184
|
+
let drop = 0;
|
|
32185
|
+
while (drop < this.audioQueue.length) {
|
|
32186
|
+
const frame = this.audioQueue[drop];
|
|
32187
|
+
if (frame === void 0 || frame.ptsMs + frame.durationMs >= headMs) break;
|
|
32188
|
+
this.audioQueuedBytes -= frame.data.byteLength;
|
|
32189
|
+
drop += 1;
|
|
32190
|
+
}
|
|
32191
|
+
if (drop === 0) return;
|
|
32192
|
+
this.audioQueue = this.audioQueue.slice(drop);
|
|
32193
|
+
this.audioDroppedSeek += drop;
|
|
32194
|
+
}
|
|
32195
|
+
startPacing() {
|
|
32196
|
+
this.stopPacing();
|
|
32197
|
+
this.anchorWall = this.now();
|
|
32198
|
+
this.anchorPts = this.headMs ?? this.queue[0]?.ptsMs ?? 0;
|
|
32199
|
+
this.startPositionReports();
|
|
32200
|
+
this.tick();
|
|
32201
|
+
}
|
|
32202
|
+
stopPacing() {
|
|
32203
|
+
if (this.paceTimer !== null) clearTimeout(this.paceTimer);
|
|
32204
|
+
this.paceTimer = null;
|
|
32205
|
+
}
|
|
32206
|
+
/**
|
|
32207
|
+
* Push every unit that is due; sleep until the next; wait for data, or end.
|
|
32208
|
+
*
|
|
32209
|
+
* ONE schedule for both tracks, ordered by `ptsMs` with VIDEO first on a
|
|
32210
|
+
* tie — the same ordering `RecordedFeeder.paceSegment` builds, so a clip
|
|
32211
|
+
* and the recorder's own footage interleave identically. The two clocks
|
|
32212
|
+
* stay separate (each codec's RTP rate) but they stamp the SAME axis.
|
|
32213
|
+
*/
|
|
32214
|
+
tick() {
|
|
32215
|
+
this.paceTimer = null;
|
|
32216
|
+
if (this.phase !== "streaming" || this.rate === 0 || this.pendingSeekMs !== null) return;
|
|
32217
|
+
for (;;) {
|
|
32218
|
+
const video = this.queue[0];
|
|
32219
|
+
const audio = this.audioQueue[0];
|
|
32220
|
+
if (video === void 0 && audio === void 0) {
|
|
32221
|
+
if (this.streamEnded) this.finish();
|
|
32222
|
+
return;
|
|
32223
|
+
}
|
|
32224
|
+
const takeVideo = video !== void 0 && (audio === void 0 || video.ptsMs <= audio.ptsMs);
|
|
32225
|
+
const ptsMs = takeVideo ? video?.ptsMs ?? 0 : audio?.ptsMs ?? 0;
|
|
32226
|
+
const wait = this.anchorWall + (ptsMs - this.anchorPts) / this.rate - this.now();
|
|
32227
|
+
if (takeVideo) {
|
|
32228
|
+
if (wait > 0) {
|
|
32229
|
+
this.paceTimer = setTimeout(() => this.tick(), Math.min(wait, WAIT_TICK_MS));
|
|
32230
|
+
return;
|
|
32231
|
+
}
|
|
32232
|
+
this.pushNext();
|
|
32233
|
+
continue;
|
|
32234
|
+
}
|
|
32235
|
+
if (wait > 0) {
|
|
32236
|
+
this.paceTimer = setTimeout(() => this.tick(), Math.min(wait, WAIT_TICK_MS));
|
|
32237
|
+
return;
|
|
32238
|
+
}
|
|
32239
|
+
this.pushNextAudio(-wait);
|
|
32240
|
+
}
|
|
32241
|
+
}
|
|
32242
|
+
/**
|
|
32243
|
+
* Push or DROP the head of the audio queue. False only when it was empty.
|
|
32244
|
+
*
|
|
32245
|
+
* `lateMs` is how far the frame's moment has already passed. Past the
|
|
32246
|
+
* bound it is dropped and counted: a receiver would discard it anyway, and
|
|
32247
|
+
* pushing a run of them is the burst that makes a clip sound broken rather
|
|
32248
|
+
* than silent. At any rate but 1x it is dropped for a different reason,
|
|
32249
|
+
* counted apart, because resampling would need a decoder this path has not
|
|
32250
|
+
* got and pitch-shifted sound is worse than none.
|
|
32251
|
+
*/
|
|
32252
|
+
pushNextAudio(lateMs) {
|
|
32253
|
+
const frame = this.audioQueue[0];
|
|
32254
|
+
if (frame === void 0) return false;
|
|
32255
|
+
this.audioQueue = this.audioQueue.slice(1);
|
|
32256
|
+
this.audioQueuedBytes -= frame.data.byteLength;
|
|
32257
|
+
if (this.rate !== 1) {
|
|
32258
|
+
this.audioDroppedRate += 1;
|
|
32259
|
+
return true;
|
|
32260
|
+
}
|
|
32261
|
+
if (lateMs > 120) {
|
|
32262
|
+
this.audioDroppedLate += 1;
|
|
32263
|
+
return true;
|
|
32264
|
+
}
|
|
32265
|
+
this.audioPush?.(frame.data, this.stampAudio(frame.ptsMs));
|
|
32266
|
+
this.audioPushed += 1;
|
|
32267
|
+
if (!this.firstAudioLogged) {
|
|
32268
|
+
this.firstAudioLogged = true;
|
|
32269
|
+
this.log("clip-stream:first-audio", {
|
|
32270
|
+
ptsMs: frame.ptsMs,
|
|
32271
|
+
lateMs: Math.round(lateMs),
|
|
32272
|
+
headMs: this.headMs,
|
|
32273
|
+
droppedLate: this.audioDroppedLate
|
|
32274
|
+
});
|
|
32275
|
+
}
|
|
32276
|
+
this.applyBackpressure();
|
|
32277
|
+
return true;
|
|
32278
|
+
}
|
|
32279
|
+
/** Push the head of the queue onto the track. False when the queue is empty. */
|
|
32280
|
+
pushNext() {
|
|
32281
|
+
const unit = this.queue.shift();
|
|
32282
|
+
if (unit === void 0) return false;
|
|
32283
|
+
this.queuedBytes -= unit.data.byteLength;
|
|
32284
|
+
this.deps.sink.pushAccessUnit(unit.data, this.clock.stamp(unit.ptsMs), unit.codec);
|
|
32285
|
+
this.clockHasAdvanced = true;
|
|
32286
|
+
this.headMs = this.headMs === null ? unit.ptsMs : Math.max(this.headMs, unit.ptsMs);
|
|
32287
|
+
this.pushed += 1;
|
|
32288
|
+
if (!this.firstFrameLogged) {
|
|
32289
|
+
this.firstFrameLogged = true;
|
|
32290
|
+
const at = this.now();
|
|
32291
|
+
this.log("clip-stream:first-frame", {
|
|
32292
|
+
sinceSeekMs: this.seekStartedAt === null ? null : at - this.seekStartedAt,
|
|
32293
|
+
sinceDialMs: this.dialedAt === null ? null : at - this.dialedAt,
|
|
32294
|
+
bytesIn: this.bytesIn,
|
|
32295
|
+
ptsMs: unit.ptsMs,
|
|
32296
|
+
codec: unit.codec
|
|
32297
|
+
});
|
|
32298
|
+
}
|
|
32299
|
+
this.applyBackpressure();
|
|
32300
|
+
return true;
|
|
32301
|
+
}
|
|
32302
|
+
finish() {
|
|
32303
|
+
if (this.phase !== "streaming") return;
|
|
32304
|
+
this.phase = "ended";
|
|
32305
|
+
this.settleAudio({
|
|
32306
|
+
carried: false,
|
|
32307
|
+
codec: null,
|
|
32308
|
+
reason: this.demuxer.ready ? "no-audio-track" : "stream-failed"
|
|
32309
|
+
});
|
|
32310
|
+
this.stopPacing();
|
|
32311
|
+
this.stopPositionReports();
|
|
32312
|
+
if (this.headMs !== null) this.deps.onPosition(this.headMs);
|
|
32313
|
+
this.log("clip-stream:played-out", {
|
|
32314
|
+
pushed: this.pushed,
|
|
32315
|
+
dropped: this.dropped,
|
|
32316
|
+
headMs: this.headMs,
|
|
32317
|
+
durationMs: this.streamEnd?.durationMs ?? null,
|
|
32318
|
+
...this.audioTally()
|
|
32319
|
+
});
|
|
32320
|
+
this.emitState("ended", "stream-played-out", this.seekToken);
|
|
32321
|
+
}
|
|
32322
|
+
startPositionReports() {
|
|
32323
|
+
if (this.positionTimer !== null) return;
|
|
32324
|
+
this.positionTimer = setInterval(() => {
|
|
32325
|
+
if (this.headMs !== null && this.phase === "streaming") this.deps.onPosition(this.headMs);
|
|
32326
|
+
}, POSITION_INTERVAL_MS);
|
|
32327
|
+
}
|
|
32328
|
+
stopPositionReports() {
|
|
32329
|
+
if (this.positionTimer !== null) clearInterval(this.positionTimer);
|
|
32330
|
+
this.positionTimer = null;
|
|
32331
|
+
}
|
|
32332
|
+
emitState(to, reason, seekToken) {
|
|
32333
|
+
const from = this.lastAnnounced;
|
|
32334
|
+
this.lastAnnounced = to;
|
|
32335
|
+
this.deps.onState({
|
|
32336
|
+
from,
|
|
32337
|
+
to,
|
|
32338
|
+
reason,
|
|
32339
|
+
seekToken
|
|
32340
|
+
});
|
|
32341
|
+
}
|
|
32342
|
+
/** The audio counters every exit line carries. Silence is never unexplained. */
|
|
32343
|
+
audioTally() {
|
|
32344
|
+
return {
|
|
32345
|
+
audioAsked: this.audioAsk,
|
|
32346
|
+
audioCarried: this.audioReport?.carried ?? false,
|
|
32347
|
+
audioSilentReason: this.audioReport?.reason ?? null,
|
|
32348
|
+
audioPushed: this.audioPushed,
|
|
32349
|
+
audioDroppedLate: this.audioDroppedLate,
|
|
32350
|
+
audioDroppedRate: this.audioDroppedRate,
|
|
32351
|
+
audioDroppedSeek: this.audioDroppedSeek,
|
|
32352
|
+
audioQueued: this.audioQueue.length
|
|
32353
|
+
};
|
|
32354
|
+
}
|
|
32355
|
+
settleAudio(report) {
|
|
32356
|
+
if (this.audioReport !== null) return;
|
|
32357
|
+
this.audioReport = report;
|
|
32358
|
+
const waiters = this.audioWaiters.splice(0);
|
|
32359
|
+
for (const resolve of waiters) resolve(report);
|
|
32360
|
+
}
|
|
32361
|
+
settleDuration(value) {
|
|
32362
|
+
if (this.duration !== null) return;
|
|
32363
|
+
this.duration = {
|
|
32364
|
+
settled: true,
|
|
32365
|
+
value
|
|
32366
|
+
};
|
|
32367
|
+
const waiters = this.durationWaiters.splice(0);
|
|
32368
|
+
for (const resolve of waiters) resolve(value);
|
|
32369
|
+
}
|
|
32370
|
+
teardown() {
|
|
32371
|
+
this.stopPacing();
|
|
32372
|
+
this.stopPositionReports();
|
|
32373
|
+
this.clearStall();
|
|
32374
|
+
const connection = this.connection;
|
|
32375
|
+
this.connection = null;
|
|
32376
|
+
connection?.destroy();
|
|
32377
|
+
}
|
|
32378
|
+
log(msg, data) {
|
|
32379
|
+
this.deps.onLog?.(msg, {
|
|
32380
|
+
...this.tags,
|
|
32381
|
+
...data
|
|
32382
|
+
});
|
|
32383
|
+
}
|
|
32384
|
+
};
|
|
32385
|
+
function describe(err) {
|
|
32386
|
+
return err instanceof Error ? err.message : String(err);
|
|
32387
|
+
}
|
|
32388
|
+
function headerText(res, name) {
|
|
32389
|
+
const value = res.headers[name];
|
|
32390
|
+
if (Array.isArray(value)) return value[0];
|
|
32391
|
+
return value;
|
|
32392
|
+
}
|
|
32393
|
+
function createHttpClipStreamOpener(options = {}) {
|
|
32394
|
+
const headersTimeoutMs = options.headersTimeoutMs ?? 3e4;
|
|
32395
|
+
return (url, handlers) => new Promise((resolve) => {
|
|
32396
|
+
let settled = false;
|
|
32397
|
+
const settle = (value) => {
|
|
32398
|
+
if (settled) return;
|
|
32399
|
+
settled = true;
|
|
32400
|
+
clearTimeout(timer);
|
|
32401
|
+
resolve(value);
|
|
32402
|
+
};
|
|
32403
|
+
let target;
|
|
32404
|
+
try {
|
|
32405
|
+
target = new URL(url);
|
|
32406
|
+
} catch (err) {
|
|
32407
|
+
settle({
|
|
32408
|
+
kind: "refused",
|
|
32409
|
+
status: 0,
|
|
32410
|
+
code: "bad-url",
|
|
32411
|
+
detail: String(err)
|
|
32412
|
+
});
|
|
32413
|
+
return;
|
|
32414
|
+
}
|
|
32415
|
+
if (target.protocol !== "http:") {
|
|
32416
|
+
settle({
|
|
32417
|
+
kind: "refused",
|
|
32418
|
+
status: 0,
|
|
32419
|
+
code: "bad-url",
|
|
32420
|
+
detail: `a clip stream is dialled over plain http on the provider's listener, not ${target.protocol}`
|
|
32421
|
+
});
|
|
32422
|
+
return;
|
|
32423
|
+
}
|
|
32424
|
+
const req = request(target, {
|
|
32425
|
+
method: "GET",
|
|
32426
|
+
headers: { accept: "video/mp4" }
|
|
32427
|
+
});
|
|
32428
|
+
const timer = setTimeout(() => {
|
|
32429
|
+
req.destroy(/* @__PURE__ */ new Error(`no status line in ${headersTimeoutMs} ms`));
|
|
32430
|
+
}, headersTimeoutMs);
|
|
32431
|
+
req.on("error", (err) => {
|
|
32432
|
+
settle({
|
|
32433
|
+
kind: "refused",
|
|
32434
|
+
status: 0,
|
|
32435
|
+
code: "connect-failed",
|
|
32436
|
+
detail: err.message
|
|
32437
|
+
});
|
|
32438
|
+
});
|
|
32439
|
+
req.on("response", (res) => {
|
|
32440
|
+
const status = res.statusCode ?? 0;
|
|
32441
|
+
if (status !== 200) {
|
|
32442
|
+
res.resume();
|
|
32443
|
+
settle({
|
|
32444
|
+
kind: "refused",
|
|
32445
|
+
status,
|
|
32446
|
+
code: headerText(res, "x-camstack-reason-code") ?? `http-${status}`,
|
|
32447
|
+
detail: headerText(res, "x-camstack-reason") ?? ""
|
|
32448
|
+
});
|
|
32449
|
+
return;
|
|
32450
|
+
}
|
|
32451
|
+
if (headerText(res, "x-camstack-clip-transport") !== "forward-only") {
|
|
32452
|
+
req.destroy();
|
|
32453
|
+
settle({
|
|
32454
|
+
kind: "refused",
|
|
32455
|
+
status,
|
|
32456
|
+
code: "not-a-stream",
|
|
32457
|
+
detail: "the URL answered 200 without declaring the forward-only clip transport"
|
|
32458
|
+
});
|
|
32459
|
+
return;
|
|
32460
|
+
}
|
|
32461
|
+
res.on("data", (chunk) => handlers.onData(chunk));
|
|
32462
|
+
res.on("end", () => handlers.onEnd());
|
|
32463
|
+
res.on("error", (err) => handlers.onError(err));
|
|
32464
|
+
settle({
|
|
32465
|
+
kind: "open",
|
|
32466
|
+
connection: {
|
|
32467
|
+
pause: () => {
|
|
32468
|
+
res.pause();
|
|
32469
|
+
},
|
|
32470
|
+
resume: () => {
|
|
32471
|
+
res.resume();
|
|
32472
|
+
},
|
|
32473
|
+
destroy: () => {
|
|
32474
|
+
req.destroy();
|
|
32475
|
+
}
|
|
32476
|
+
}
|
|
32477
|
+
});
|
|
32478
|
+
});
|
|
32479
|
+
req.end();
|
|
32480
|
+
});
|
|
32481
|
+
}
|
|
32482
|
+
//#endregion
|
|
30589
32483
|
//#region src/stream-broker/recorded/demux-off-thread.ts
|
|
30590
32484
|
/**
|
|
30591
32485
|
* Off-thread JS demux client — mp4box parse on a `worker_threads` Worker
|
|
@@ -31332,6 +33226,7 @@ var BrokerWebrtcServer = class {
|
|
|
31332
33226
|
readRecordedSegmentBytes;
|
|
31333
33227
|
readRecordedGopBytes;
|
|
31334
33228
|
readClipBytes;
|
|
33229
|
+
dialClipStream;
|
|
31335
33230
|
resolveFfmpegPath;
|
|
31336
33231
|
/**
|
|
31337
33232
|
* THE ffmpeg primitive, for the transcode leg. `null` until the manager wires
|
|
@@ -31425,6 +33320,7 @@ var BrokerWebrtcServer = class {
|
|
|
31425
33320
|
this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
|
|
31426
33321
|
this.readRecordedGopBytes = options.readRecordedGopBytes;
|
|
31427
33322
|
this.readClipBytes = options.readClipBytes;
|
|
33323
|
+
this.dialClipStream = options.dialClipStream;
|
|
31428
33324
|
this.resolveFfmpegPath = options.resolveFfmpegPath;
|
|
31429
33325
|
}
|
|
31430
33326
|
registerBroker(brokerId, broker) {
|
|
@@ -32145,14 +34041,93 @@ var BrokerWebrtcServer = class {
|
|
|
32145
34041
|
};
|
|
32146
34042
|
} : void 0;
|
|
32147
34043
|
const readClip = this.readClipBytes;
|
|
34044
|
+
const dialClip = this.dialClipStream;
|
|
34045
|
+
const openClipStream = createHttpClipStreamOpener();
|
|
32148
34046
|
let timeline;
|
|
32149
34047
|
/**
|
|
32150
34048
|
* ONE feeder builder for BOTH sources (D575). `clip === null` is the
|
|
32151
34049
|
* recorder's own footage; a clip swaps only the two byte closures —
|
|
32152
34050
|
* everything else (demux, sink, audio codec branch, state/position/log
|
|
32153
34051
|
* seams) is identical, which is the whole claim the clip path makes.
|
|
34052
|
+
*
|
|
34053
|
+
* Since D597 a clip has TWO paths, and which one it gets is decided
|
|
34054
|
+
* ONCE, here, by `chooseClipPath`, and logged with its reason: the
|
|
34055
|
+
* STREAM for the sub twin (forward-only, first frame at the first
|
|
34056
|
+
* fragment) whenever this broker can dial one, the FILE for `high` and
|
|
34057
|
+
* for a broker with no dial. Never an implicit fallthrough.
|
|
32154
34058
|
*/
|
|
32155
34059
|
const buildFeeder = (profile, clip) => {
|
|
34060
|
+
if (clip) {
|
|
34061
|
+
const choice = chooseClipPath({
|
|
34062
|
+
profile: clip.profile,
|
|
34063
|
+
streamDialWired: dialClip !== void 0,
|
|
34064
|
+
fileReadWired: readClip !== void 0
|
|
34065
|
+
});
|
|
34066
|
+
sessionLogger.info("clip playback: path chosen", {
|
|
34067
|
+
tags: { deviceId: clip.deviceId },
|
|
34068
|
+
meta: {
|
|
34069
|
+
sessionId,
|
|
34070
|
+
clipId: clip.clipId,
|
|
34071
|
+
provider: clip.provider,
|
|
34072
|
+
profile: clip.profile,
|
|
34073
|
+
path: choice.path,
|
|
34074
|
+
reason: choice.reason
|
|
34075
|
+
}
|
|
34076
|
+
});
|
|
34077
|
+
const clipLog = (msg, data) => sessionLogger.info(msg, {
|
|
34078
|
+
tags: { deviceId: clip.deviceId },
|
|
34079
|
+
meta: {
|
|
34080
|
+
sessionId,
|
|
34081
|
+
clipId: clip.clipId,
|
|
34082
|
+
...data
|
|
34083
|
+
}
|
|
34084
|
+
});
|
|
34085
|
+
const onClipState = (t) => {
|
|
34086
|
+
sessionLogger.info("recorded: feeder state", {
|
|
34087
|
+
tags: { deviceId: clip.deviceId },
|
|
34088
|
+
meta: {
|
|
34089
|
+
sessionId,
|
|
34090
|
+
deviceId,
|
|
34091
|
+
profile,
|
|
34092
|
+
clipId: clip.clipId,
|
|
34093
|
+
path: choice.path,
|
|
34094
|
+
from: t.from,
|
|
34095
|
+
to: t.to,
|
|
34096
|
+
reason: t.reason,
|
|
34097
|
+
seekToken: t.seekToken
|
|
34098
|
+
}
|
|
34099
|
+
});
|
|
34100
|
+
timeline._emitState(t.to);
|
|
34101
|
+
};
|
|
34102
|
+
if (choice.path === "none") return createRefusedClipFeeder({
|
|
34103
|
+
handle: clip,
|
|
34104
|
+
reason: choice.reason,
|
|
34105
|
+
onState: onClipState,
|
|
34106
|
+
onLog: clipLog
|
|
34107
|
+
});
|
|
34108
|
+
if (choice.path === "stream" && dialClip !== void 0) return createClipStreamFeeder({
|
|
34109
|
+
handle: clip,
|
|
34110
|
+
dial: (handle, audio) => dialClip({
|
|
34111
|
+
deviceId: handle.deviceId,
|
|
34112
|
+
clipId: handle.clipId,
|
|
34113
|
+
provider: handle.provider,
|
|
34114
|
+
profile: handle.profile,
|
|
34115
|
+
audio
|
|
34116
|
+
}),
|
|
34117
|
+
open: openClipStream,
|
|
34118
|
+
audio: session.isOpusAudio ? "opus" : "pcmu",
|
|
34119
|
+
sink: session.isOpusAudio ? {
|
|
34120
|
+
pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
|
|
34121
|
+
pushAudioOpus: (opus, ts) => session.writeRecordedAudioOpus(Buffer.from(opus), ts)
|
|
34122
|
+
} : {
|
|
34123
|
+
pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
|
|
34124
|
+
pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
|
|
34125
|
+
},
|
|
34126
|
+
onState: onClipState,
|
|
34127
|
+
onPosition: (epochMs) => timeline._emitPosition(epochMs),
|
|
34128
|
+
onLog: clipLog
|
|
34129
|
+
});
|
|
34130
|
+
}
|
|
32156
34131
|
const opusSession = session.isOpusAudio;
|
|
32157
34132
|
const audioSink = opusSession ? {
|
|
32158
34133
|
pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
|
|
@@ -32266,7 +34241,7 @@ var BrokerWebrtcServer = class {
|
|
|
32266
34241
|
});
|
|
32267
34242
|
} : void 0,
|
|
32268
34243
|
makeFeeder: (profile) => buildFeeder(profile, null),
|
|
32269
|
-
...readClip ? { makeClipFeeder: ({ handle }) => buildFeeder(handle.profile ?? "clip", handle) } : {},
|
|
34244
|
+
...readClip || dialClip ? { makeClipFeeder: ({ handle }) => buildFeeder(handle.profile ?? "clip", handle) } : {},
|
|
32270
34245
|
onLog: (msg, data) => sessionLogger.warn(msg, {
|
|
32271
34246
|
tags: { deviceId },
|
|
32272
34247
|
meta: {
|
|
@@ -33311,6 +35286,51 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
|
|
|
33311
35286
|
}
|
|
33312
35287
|
};
|
|
33313
35288
|
//#endregion
|
|
35289
|
+
//#region src/stream-broker/recorded/clip-stream-dial-answer.ts
|
|
35290
|
+
/**
|
|
35291
|
+
* clip-stream-dial-answer — what a THROWN `videoclips.dialClipStream` means.
|
|
35292
|
+
*
|
|
35293
|
+
* The cap call has three ways to fail, and the feeder must tell them apart
|
|
35294
|
+
* because they lead to different lines and different states:
|
|
35295
|
+
*
|
|
35296
|
+
* - the provider PREDATES the method (`NOT_IMPLEMENTED`, "does not
|
|
35297
|
+
* implement") — the stream does not exist on this cluster yet, which is a
|
|
35298
|
+
* deploy-order fact, not a clip fact;
|
|
35299
|
+
* - the provider REFUSED in the clip vocabulary (`clipExportFailure`):
|
|
35300
|
+
* `sleeping`, `catalog-miss`, `clip-in-progress`, `unsupported-option`… —
|
|
35301
|
+
* the code is kept, the prose travels with it;
|
|
35302
|
+
* - anything else — the transport, a bug — is `fetch-failed` with the prose
|
|
35303
|
+
* verbatim, exactly as the export engine treats an unknown error (D572).
|
|
35304
|
+
*/
|
|
35305
|
+
function trpcCodeOf(err) {
|
|
35306
|
+
if (typeof err !== "object" || err === null) return null;
|
|
35307
|
+
const shaped = err;
|
|
35308
|
+
if (typeof shaped.data?.code === "string") return shaped.data.code;
|
|
35309
|
+
if (typeof shaped.code === "string") return shaped.code;
|
|
35310
|
+
return null;
|
|
35311
|
+
}
|
|
35312
|
+
function dialAnswerFromError(err) {
|
|
35313
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
35314
|
+
if (trpcCodeOf(err) === "NOT_IMPLEMENTED" || /does not implement/i.test(message)) return {
|
|
35315
|
+
kind: "unavailable",
|
|
35316
|
+
detail: message
|
|
35317
|
+
};
|
|
35318
|
+
const code = clipExportFailureOf(message);
|
|
35319
|
+
if (code !== null) {
|
|
35320
|
+
const sep = message.indexOf(":");
|
|
35321
|
+
return {
|
|
35322
|
+
kind: "refused",
|
|
35323
|
+
code,
|
|
35324
|
+
detail: sep === -1 ? "" : message.slice(sep + 1).trim()
|
|
35325
|
+
};
|
|
35326
|
+
}
|
|
35327
|
+
return {
|
|
35328
|
+
kind: "refused",
|
|
35329
|
+
code: "fetch-failed",
|
|
35330
|
+
detail: message
|
|
35331
|
+
};
|
|
35332
|
+
}
|
|
35333
|
+
//#endregion
|
|
33314
35334
|
//#region src/stream-broker/event-loop-stall-monitor.ts
|
|
33315
35335
|
var DEFAULT_INTERVAL_MS = 100;
|
|
33316
35336
|
var DEFAULT_THRESHOLD_MS = 250;
|
|
@@ -33785,6 +35805,27 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
33785
35805
|
});
|
|
33786
35806
|
return new Uint8Array(Buffer.from(res.base64, "base64"));
|
|
33787
35807
|
},
|
|
35808
|
+
dialClipStream: async (args) => {
|
|
35809
|
+
try {
|
|
35810
|
+
const dial = await this.ctx.api.videoclips.dialClipStream.query({
|
|
35811
|
+
deviceId: args.deviceId,
|
|
35812
|
+
clipId: args.clipId,
|
|
35813
|
+
provider: args.provider,
|
|
35814
|
+
...args.profile !== null ? { profile: args.profile } : {},
|
|
35815
|
+
audio: args.audio
|
|
35816
|
+
});
|
|
35817
|
+
return {
|
|
35818
|
+
kind: "dialed",
|
|
35819
|
+
url: dial.url,
|
|
35820
|
+
expiresAtMs: dial.expiresAtMs,
|
|
35821
|
+
...typeof dial.served === "string" ? { served: dial.served } : {},
|
|
35822
|
+
...typeof dial.servedAudio === "string" ? { servedAudio: dial.servedAudio } : {},
|
|
35823
|
+
...typeof dial.audioReason === "string" ? { audioReason: dial.audioReason } : {}
|
|
35824
|
+
};
|
|
35825
|
+
} catch (err) {
|
|
35826
|
+
return dialAnswerFromError(err);
|
|
35827
|
+
}
|
|
35828
|
+
},
|
|
33788
35829
|
readRecordedGopBytes: async (args) => {
|
|
33789
35830
|
try {
|
|
33790
35831
|
return await this.ctx.api.recording.readGopBytes.query({
|