@camstack/addon-pipeline 1.1.57 → 1.1.59
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 +4 -4
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +9 -9
- package/dist/detection-pipeline/index.mjs +3 -3
- package/dist/{dist-CK8Ur-OS.js → dist-B1u-QN05.js} +4822 -4650
- package/dist/{dist-CL531uVA.mjs → dist-DKeNH_5z.mjs} +4823 -4651
- package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-Cp9f4dk6-0wh7OK3s.mjs} +2 -2
- package/dist/{model-download-service-C-IHWnXx-D326YNnt.js → model-download-service-Cp9f4dk6-Cp_GTKTw.js} +2 -2
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +147 -12
- package/dist/pipeline-runner/index.mjs +146 -11
- package/dist/recorder/index.js +993 -7
- package/dist/recorder/index.mjs +990 -5
- package/dist/session-decode/decode-worker-child.js +110 -2
- package/dist/session-decode/decode-worker-child.mjs +110 -2
- package/dist/{hub-hostname-cCknRYKj.mjs → sheet-geometry-BN80zpCa.js} +56 -1
- package/dist/{hub-hostname-DAJXlOgV.js → sheet-geometry-Bk3fR4s7.mjs} +39 -6
- package/dist/{step-definitions-DHAxruAQ.js → step-definitions-CPch37vh.js} +1 -1
- package/dist/{step-definitions-1d3_vQ6S.mjs → step-definitions-DUB27V8Y.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DdkfKDgw.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D3h_W9uu.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-DSXctoaw.mjs +26 -0
- package/dist/stream-broker/{hostInit-DEaqJpGA.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
- package/dist/stream-broker/index.js +626 -152
- package/dist/stream-broker/index.mjs +626 -152
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CmZbzLQi.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js → PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js} +1 -1
- package/embed-dist/assets/index-W9-H5LTj.js +81 -0
- package/embed-dist/index.html +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-Ba7Eyd47.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-TWCUWhCm.mjs +0 -26
- package/embed-dist/assets/index-DOsK8CZb.js +0 -81
|
@@ -3,9 +3,9 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-D6vf50IK.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
6
|
+
const require_dist = require("../dist-B1u-QN05.js");
|
|
7
7
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
8
|
-
const
|
|
8
|
+
const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Cp_GTKTw.js");
|
|
9
9
|
let node_child_process = require("node:child_process");
|
|
10
10
|
let node_url = require("node:url");
|
|
11
11
|
let node_fs = require("node:fs");
|
|
@@ -28,12 +28,28 @@ let node_events = require("node:events");
|
|
|
28
28
|
//#region src/stream-broker/embed-file-handler.ts
|
|
29
29
|
/** Build a `(req, res)` handler serving the embed bundle at `root` with SPA fallback.
|
|
30
30
|
* Web MIME types (`.html`/`.js`/`.css`/…) come from the core handler's defaults. */
|
|
31
|
+
/** Cache-Control for the CONTENT-HASHED vite assets (`/assets/index-<hash>.js`):
|
|
32
|
+
* a new build changes the filename, so the old bytes can cache forever. The
|
|
33
|
+
* entry `index.html` keeps the handler's default `no-cache`. Without this the
|
|
34
|
+
* WebView re-downloaded the ~1.5MB bundle on EVERY player open — a real chunk
|
|
35
|
+
* of the "live stream takes ~10s to start" cold-start cost (worse on 4G). */
|
|
36
|
+
var IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
31
37
|
function createEmbedSpaHandler(root) {
|
|
32
|
-
const fileHandler =
|
|
38
|
+
const fileHandler = require_model_download_service_Cp9f4dk6.createFileDataPlaneHandler({
|
|
39
|
+
getRoots: () => [root],
|
|
40
|
+
cacheControl: (rel) => rel.startsWith("assets/") ? IMMUTABLE_CACHE : void 0
|
|
41
|
+
});
|
|
33
42
|
return (req, res) => {
|
|
34
43
|
const [pathPart = "/", query] = (req.url ?? "/").split("?");
|
|
35
44
|
const lastSegment = pathPart.split("/").pop() ?? "";
|
|
36
45
|
if (pathPart === "/" || pathPart === "" || !lastSegment.includes(".")) req.url = query !== void 0 ? `/index.html?${query}` : "/index.html";
|
|
46
|
+
else if (pathPart.startsWith("/assets/")) {
|
|
47
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
48
|
+
res.writeHead = ((status, headers) => origWriteHead(status, {
|
|
49
|
+
...headers,
|
|
50
|
+
"cache-control": IMMUTABLE_CACHE
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
37
53
|
return fileHandler(req, res);
|
|
38
54
|
};
|
|
39
55
|
}
|
|
@@ -2239,6 +2255,133 @@ var H264Repacketizer = class {
|
|
|
2239
2255
|
}
|
|
2240
2256
|
};
|
|
2241
2257
|
//#endregion
|
|
2258
|
+
//#region src/stream-broker/webrtc/paced-rtp-replay.ts
|
|
2259
|
+
var PACED_REPLAY_DEFAULTS = {
|
|
2260
|
+
singleShotMax: 64,
|
|
2261
|
+
tickMs: 5,
|
|
2262
|
+
targetDurationMs: 300,
|
|
2263
|
+
minChunkSize: 32,
|
|
2264
|
+
maxChunkSize: 128
|
|
2265
|
+
};
|
|
2266
|
+
var defaultSchedule = (fn, ms) => {
|
|
2267
|
+
const t = setTimeout(fn, ms);
|
|
2268
|
+
return () => clearTimeout(t);
|
|
2269
|
+
};
|
|
2270
|
+
/**
|
|
2271
|
+
* Compute the per-tick chunk size for a burst of `total` packets: enough to
|
|
2272
|
+
* drain within `targetDurationMs` at one chunk per `tickMs`, clamped to
|
|
2273
|
+
* [minChunkSize, maxChunkSize]. Bursts ≤ `singleShotMax` are one chunk.
|
|
2274
|
+
*/
|
|
2275
|
+
function computeReplayChunkSize(total, tuning) {
|
|
2276
|
+
if (total <= tuning.singleShotMax) return Math.max(total, 1);
|
|
2277
|
+
const maxTicks = Math.max(1, Math.floor(tuning.targetDurationMs / tuning.tickMs));
|
|
2278
|
+
const ideal = Math.ceil(total / maxTicks);
|
|
2279
|
+
return Math.min(tuning.maxChunkSize, Math.max(tuning.minChunkSize, ideal));
|
|
2280
|
+
}
|
|
2281
|
+
/**
|
|
2282
|
+
* One in-flight paced replay. Create per burst; not reusable after
|
|
2283
|
+
* completion or abort.
|
|
2284
|
+
*/
|
|
2285
|
+
var PacedRtpReplay = class {
|
|
2286
|
+
send;
|
|
2287
|
+
onDone;
|
|
2288
|
+
tuning;
|
|
2289
|
+
schedule;
|
|
2290
|
+
now;
|
|
2291
|
+
queue = [];
|
|
2292
|
+
cancelTimer = null;
|
|
2293
|
+
started = false;
|
|
2294
|
+
finished = false;
|
|
2295
|
+
startedAt = 0;
|
|
2296
|
+
chunkSize = 0;
|
|
2297
|
+
initialPackets = 0;
|
|
2298
|
+
enqueuedLive = 0;
|
|
2299
|
+
sentCount = 0;
|
|
2300
|
+
chunkCount = 0;
|
|
2301
|
+
constructor(send, onDone, options) {
|
|
2302
|
+
this.send = send;
|
|
2303
|
+
this.onDone = onDone;
|
|
2304
|
+
this.tuning = {
|
|
2305
|
+
...PACED_REPLAY_DEFAULTS,
|
|
2306
|
+
...options?.tuning
|
|
2307
|
+
};
|
|
2308
|
+
this.schedule = options?.schedule ?? defaultSchedule;
|
|
2309
|
+
this.now = options?.now ?? Date.now;
|
|
2310
|
+
}
|
|
2311
|
+
/** True while packets remain to be drained (live packets must be enqueued,
|
|
2312
|
+
* not sent directly, to preserve order). */
|
|
2313
|
+
get active() {
|
|
2314
|
+
return this.started && !this.finished;
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Begin the replay: sends the first chunk synchronously, then paces the
|
|
2318
|
+
* rest. Calling `start` more than once is a no-op.
|
|
2319
|
+
*/
|
|
2320
|
+
start(initial) {
|
|
2321
|
+
if (this.started) return;
|
|
2322
|
+
this.started = true;
|
|
2323
|
+
this.startedAt = this.now();
|
|
2324
|
+
this.initialPackets = initial.length;
|
|
2325
|
+
this.queue = [...initial];
|
|
2326
|
+
this.chunkSize = computeReplayChunkSize(initial.length, this.tuning);
|
|
2327
|
+
this.drainChunk();
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Append a live packet behind the still-queued replay tail. No-op (returns
|
|
2331
|
+
* false) when the replay is not active — the caller must then send the
|
|
2332
|
+
* packet directly.
|
|
2333
|
+
*/
|
|
2334
|
+
enqueue(item) {
|
|
2335
|
+
if (!this.active) return false;
|
|
2336
|
+
this.queue.push(item);
|
|
2337
|
+
this.enqueuedLive++;
|
|
2338
|
+
return true;
|
|
2339
|
+
}
|
|
2340
|
+
/** Cancel the replay: pending packets are dropped, `onDone` fires with
|
|
2341
|
+
* `aborted: true`. Safe to call multiple times / before `start`. */
|
|
2342
|
+
abort() {
|
|
2343
|
+
if (this.finished) return;
|
|
2344
|
+
if (!this.started) {
|
|
2345
|
+
this.started = true;
|
|
2346
|
+
this.startedAt = this.now();
|
|
2347
|
+
}
|
|
2348
|
+
this.finish(true);
|
|
2349
|
+
}
|
|
2350
|
+
drainChunk() {
|
|
2351
|
+
if (this.finished) return;
|
|
2352
|
+
this.chunkCount++;
|
|
2353
|
+
const n = Math.min(this.chunkSize, this.queue.length);
|
|
2354
|
+
for (let i = 0; i < n; i++) if (this.send(this.queue[i])) this.sentCount++;
|
|
2355
|
+
this.queue = this.queue.slice(n);
|
|
2356
|
+
if (this.queue.length === 0) {
|
|
2357
|
+
this.finish(false);
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
this.cancelTimer = this.schedule(() => {
|
|
2361
|
+
this.cancelTimer = null;
|
|
2362
|
+
this.drainChunk();
|
|
2363
|
+
}, this.tuning.tickMs);
|
|
2364
|
+
}
|
|
2365
|
+
finish(aborted) {
|
|
2366
|
+
if (this.finished) return;
|
|
2367
|
+
this.finished = true;
|
|
2368
|
+
if (this.cancelTimer) {
|
|
2369
|
+
this.cancelTimer();
|
|
2370
|
+
this.cancelTimer = null;
|
|
2371
|
+
}
|
|
2372
|
+
this.queue = [];
|
|
2373
|
+
this.onDone({
|
|
2374
|
+
packets: this.initialPackets,
|
|
2375
|
+
enqueuedLive: this.enqueuedLive,
|
|
2376
|
+
sent: this.sentCount,
|
|
2377
|
+
chunks: this.chunkCount,
|
|
2378
|
+
chunkSize: this.chunkSize,
|
|
2379
|
+
durationMs: this.now() - this.startedAt,
|
|
2380
|
+
aborted
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
//#endregion
|
|
2242
2385
|
//#region src/stream-broker/webrtc/webrtc-fallback-profile.ts
|
|
2243
2386
|
/**
|
|
2244
2387
|
* Hardcoded fallback encode profile for the WebRTC viewer path. Used only
|
|
@@ -2844,6 +2987,10 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
2844
2987
|
/** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
|
|
2845
2988
|
rtpBootstrap;
|
|
2846
2989
|
rtpBootstrapDone = false;
|
|
2990
|
+
/** In-flight paced pre-buffer replay (instant-start bootstrap or PLI
|
|
2991
|
+
* recovery). While active, live source-RTP packets are enqueued behind
|
|
2992
|
+
* the replay tail to preserve packet order. Aborted on close/rebind. */
|
|
2993
|
+
activeRtpReplay = null;
|
|
2847
2994
|
/**
|
|
2848
2995
|
* Trickle ICE: the server's locally-gathered candidates, buffered as werift
|
|
2849
2996
|
* surfaces them via `onIceCandidate`. The client polls `getIceCandidates`
|
|
@@ -2875,6 +3022,8 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
2875
3022
|
/** Drop the repacketizer + bootstrap/sync state so a newly bound source
|
|
2876
3023
|
* stream re-seeds its SPS/PPS and replays instant-start cleanly. */
|
|
2877
3024
|
resetRepacketizer() {
|
|
3025
|
+
this.activeRtpReplay?.abort();
|
|
3026
|
+
this.activeRtpReplay = null;
|
|
2878
3027
|
this.h264Repacketizer = null;
|
|
2879
3028
|
this.h265Repacketizer = null;
|
|
2880
3029
|
this.videoRtpSynced = false;
|
|
@@ -3575,6 +3724,10 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3575
3724
|
this.feedAbort.abort();
|
|
3576
3725
|
this.feedAbort = null;
|
|
3577
3726
|
}
|
|
3727
|
+
if (this.activeRtpReplay) {
|
|
3728
|
+
this.activeRtpReplay.abort();
|
|
3729
|
+
this.activeRtpReplay = null;
|
|
3730
|
+
}
|
|
3578
3731
|
try {
|
|
3579
3732
|
await this.source.return(void 0);
|
|
3580
3733
|
} catch {}
|
|
@@ -3672,18 +3825,11 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3672
3825
|
this.rtpBootstrapDone = true;
|
|
3673
3826
|
const bootstrap = this.rtpBootstrap?.() ?? [];
|
|
3674
3827
|
if (bootstrap.length > 0) {
|
|
3675
|
-
|
|
3676
|
-
for (const b of bootstrap) if (this.sendSourceRtpPacket(b, codec)) sent++;
|
|
3677
|
-
this.logger.info("replayed source-RTP pre-buffer (instant start)", { meta: {
|
|
3678
|
-
phase: "session",
|
|
3679
|
-
sessionId: this.sessionId,
|
|
3680
|
-
codec,
|
|
3681
|
-
packets: bootstrap.length,
|
|
3682
|
-
sent
|
|
3683
|
-
} });
|
|
3828
|
+
this.startPacedRtpReplay(bootstrap, codec, "replayed source-RTP pre-buffer (instant start)");
|
|
3684
3829
|
return;
|
|
3685
3830
|
}
|
|
3686
3831
|
}
|
|
3832
|
+
if (this.activeRtpReplay?.active && this.activeRtpReplay.enqueue(rtpData)) return;
|
|
3687
3833
|
this.sendSourceRtpPacket(rtpData, codec);
|
|
3688
3834
|
}
|
|
3689
3835
|
/**
|
|
@@ -3773,6 +3919,35 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3773
3919
|
}
|
|
3774
3920
|
return sent;
|
|
3775
3921
|
}
|
|
3922
|
+
/**
|
|
3923
|
+
* Start a paced replay of a source-RTP pre-buffer burst (instant-start
|
|
3924
|
+
* bootstrap or PLI recovery). The first chunk goes out synchronously; the
|
|
3925
|
+
* rest drain on a short timer cadence (see `PACED_REPLAY_DEFAULTS`:
|
|
3926
|
+
* 5 ms ticks, 32-128 packets/tick scaled to finish within ~300 ms).
|
|
3927
|
+
* An already-running replay is aborted first (goLive re-bootstrap). The
|
|
3928
|
+
* completion log preserves the historical message and adds pacing stats.
|
|
3929
|
+
*/
|
|
3930
|
+
startPacedRtpReplay(bootstrap, codec, logMessage) {
|
|
3931
|
+
this.activeRtpReplay?.abort();
|
|
3932
|
+
const replay = new PacedRtpReplay((pkt) => this.sendSourceRtpPacket(pkt, codec), (stats) => {
|
|
3933
|
+
if (this.activeRtpReplay === replay) this.activeRtpReplay = null;
|
|
3934
|
+
const meta = {
|
|
3935
|
+
phase: "session",
|
|
3936
|
+
sessionId: this.sessionId,
|
|
3937
|
+
codec,
|
|
3938
|
+
packets: stats.packets,
|
|
3939
|
+
sent: stats.sent,
|
|
3940
|
+
chunks: stats.chunks,
|
|
3941
|
+
chunkSize: stats.chunkSize,
|
|
3942
|
+
durationMs: stats.durationMs,
|
|
3943
|
+
enqueuedLive: stats.enqueuedLive
|
|
3944
|
+
};
|
|
3945
|
+
if (stats.aborted) this.logger.debug(`${logMessage} — aborted mid-replay`, { meta });
|
|
3946
|
+
else this.logger.info(logMessage, { meta });
|
|
3947
|
+
});
|
|
3948
|
+
this.activeRtpReplay = replay;
|
|
3949
|
+
replay.start(bootstrap);
|
|
3950
|
+
}
|
|
3776
3951
|
setupPliListener() {
|
|
3777
3952
|
const sender = this.videoSender;
|
|
3778
3953
|
if (!sender) return;
|
|
@@ -3811,20 +3986,19 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3811
3986
|
const now = Date.now();
|
|
3812
3987
|
if (now - this.lastPliResendAt < AdaptiveSession.PLI_RESEND_COOLDOWN_MS) return;
|
|
3813
3988
|
this.lastPliResendAt = now;
|
|
3989
|
+
if (this.activeRtpReplay?.active) {
|
|
3990
|
+
this.logger.debug("PLI received — paced pre-buffer replay already in flight", { meta: {
|
|
3991
|
+
phase: "session",
|
|
3992
|
+
sessionId: this.sessionId
|
|
3993
|
+
} });
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
3814
3996
|
if (this.rtpBootstrap) {
|
|
3815
3997
|
const codec = this.negotiatedCodec;
|
|
3816
3998
|
if (codec === "H264" || codec === "H265") {
|
|
3817
3999
|
const bootstrap = this.rtpBootstrap();
|
|
3818
4000
|
if (bootstrap.length > 0) {
|
|
3819
|
-
|
|
3820
|
-
for (const b of bootstrap) if (this.sendSourceRtpPacket(b, codec)) sent++;
|
|
3821
|
-
this.logger.info("PLI received — replayed source-RTP pre-buffer", { meta: {
|
|
3822
|
-
phase: "session",
|
|
3823
|
-
sessionId: this.sessionId,
|
|
3824
|
-
codec,
|
|
3825
|
-
packets: bootstrap.length,
|
|
3826
|
-
sent
|
|
3827
|
-
} });
|
|
4001
|
+
this.startPacedRtpReplay(bootstrap, codec, "PLI received — replayed source-RTP pre-buffer");
|
|
3828
4002
|
return;
|
|
3829
4003
|
}
|
|
3830
4004
|
}
|
|
@@ -14379,8 +14553,9 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14379
14553
|
const keep = new Set(descriptors.map((d) => d.camStreamId));
|
|
14380
14554
|
await this.publishCatalog(deviceId, descriptors);
|
|
14381
14555
|
const existing = this.cameraStreams.get(deviceId);
|
|
14382
|
-
if (existing) {
|
|
14383
|
-
|
|
14556
|
+
if (existing) for (const [camStreamId, cam] of Array.from(existing.entries())) {
|
|
14557
|
+
if (cam.kind === "derived") continue;
|
|
14558
|
+
if (!keep.has(camStreamId)) await this.retractCameraStream({
|
|
14384
14559
|
deviceId,
|
|
14385
14560
|
camStreamId
|
|
14386
14561
|
});
|
|
@@ -16669,6 +16844,73 @@ var AdaptiveController = class {
|
|
|
16669
16844
|
}
|
|
16670
16845
|
};
|
|
16671
16846
|
//#endregion
|
|
16847
|
+
//#region src/stream-broker/recorded/control-protocol.ts
|
|
16848
|
+
var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
16849
|
+
require_dist.object({
|
|
16850
|
+
t: require_dist.literal("playRecorded"),
|
|
16851
|
+
deviceId: require_dist.number(),
|
|
16852
|
+
epoch: require_dist.number(),
|
|
16853
|
+
profile: require_dist.string()
|
|
16854
|
+
}),
|
|
16855
|
+
require_dist.object({
|
|
16856
|
+
t: require_dist.literal("seek"),
|
|
16857
|
+
epoch: require_dist.number()
|
|
16858
|
+
}),
|
|
16859
|
+
require_dist.object({
|
|
16860
|
+
t: require_dist.literal("scrubCommit"),
|
|
16861
|
+
epoch: require_dist.number()
|
|
16862
|
+
}),
|
|
16863
|
+
require_dist.object({
|
|
16864
|
+
t: require_dist.literal("setRate"),
|
|
16865
|
+
rate: require_dist.number().min(0).max(4)
|
|
16866
|
+
}),
|
|
16867
|
+
require_dist.object({
|
|
16868
|
+
t: require_dist.literal("stepFrame"),
|
|
16869
|
+
dir: require_dist.union([require_dist.literal(1), require_dist.literal(-1)])
|
|
16870
|
+
}),
|
|
16871
|
+
require_dist.object({ t: require_dist.literal("goLive") }),
|
|
16872
|
+
require_dist.object({
|
|
16873
|
+
t: require_dist.literal("setProfile"),
|
|
16874
|
+
profile: require_dist.string()
|
|
16875
|
+
})
|
|
16876
|
+
]);
|
|
16877
|
+
/**
|
|
16878
|
+
* Clamp a requested playback rate onto the supported domain: {0} ∪ [0.25, 4].
|
|
16879
|
+
* 0 (and any non-finite / non-positive input) pauses; anything else is a
|
|
16880
|
+
* continuous rate clamped into [0.25, 4]. Single server-side authority — the
|
|
16881
|
+
* feeder applies it to every inbound `setRate`.
|
|
16882
|
+
*/
|
|
16883
|
+
function clampPlaybackRate(rate) {
|
|
16884
|
+
if (!Number.isFinite(rate) || rate <= 0) return 0;
|
|
16885
|
+
return Math.min(4, Math.max(.25, rate));
|
|
16886
|
+
}
|
|
16887
|
+
var ServerMessageSchema = require_dist.discriminatedUnion("t", [require_dist.object({
|
|
16888
|
+
t: require_dist.literal("state"),
|
|
16889
|
+
state: require_dist._enum([
|
|
16890
|
+
"live",
|
|
16891
|
+
"loading",
|
|
16892
|
+
"playing",
|
|
16893
|
+
"paused",
|
|
16894
|
+
"gap",
|
|
16895
|
+
"ended",
|
|
16896
|
+
"error"
|
|
16897
|
+
])
|
|
16898
|
+
}), require_dist.object({
|
|
16899
|
+
t: require_dist.literal("position"),
|
|
16900
|
+
epochMs: require_dist.number()
|
|
16901
|
+
})]);
|
|
16902
|
+
function parseControlMessage(raw) {
|
|
16903
|
+
try {
|
|
16904
|
+
const parsed = ControlMessageSchema.safeParse(JSON.parse(raw));
|
|
16905
|
+
return parsed.success ? parsed.data : null;
|
|
16906
|
+
} catch {
|
|
16907
|
+
return null;
|
|
16908
|
+
}
|
|
16909
|
+
}
|
|
16910
|
+
function serializeServerMessage(msg) {
|
|
16911
|
+
return JSON.stringify(ServerMessageSchema.parse(msg));
|
|
16912
|
+
}
|
|
16913
|
+
//#endregion
|
|
16672
16914
|
//#region src/stream-broker/recorded/feed-clock.ts
|
|
16673
16915
|
/**
|
|
16674
16916
|
* Maps arbitrary, possibly-resetting source PTS (ms) onto a strictly increasing
|
|
@@ -16676,26 +16918,70 @@ var AdaptiveController = class {
|
|
|
16676
16918
|
* seek the output keeps advancing while the source anchor re-bases, so
|
|
16677
16919
|
* per-segment PTS resets never rewind the track. Video and audio use SEPARATE
|
|
16678
16920
|
* instances that `markSeek()` together to stay A/V aligned across boundaries.
|
|
16921
|
+
*
|
|
16922
|
+
* Playback rate: `setRate(r)` divides SOURCE elapsed time by `r` on the output
|
|
16923
|
+
* timeline (2× → half the output spacing → the receiver plays frames twice as
|
|
16924
|
+
* fast). A mid-stream rate change re-anchors at the last stamped point so the
|
|
16925
|
+
* output stays continuous and monotonic across the change (like `markSeek`,
|
|
16926
|
+
* but without discarding source continuity).
|
|
16679
16927
|
*/
|
|
16680
16928
|
var MonotonicClock = class {
|
|
16681
16929
|
rateHz;
|
|
16682
16930
|
srcAnchorMs = null;
|
|
16931
|
+
lastSrcMs = null;
|
|
16683
16932
|
outBaseMs = 0;
|
|
16684
16933
|
lastOutMs = 0;
|
|
16934
|
+
rate = 1;
|
|
16685
16935
|
constructor(rateHz = 9e4) {
|
|
16686
16936
|
this.rateHz = rateHz;
|
|
16687
16937
|
}
|
|
16688
16938
|
stamp(srcPtsMs) {
|
|
16689
16939
|
if (this.srcAnchorMs === null) this.srcAnchorMs = srcPtsMs;
|
|
16690
|
-
|
|
16940
|
+
this.lastSrcMs = srcPtsMs;
|
|
16941
|
+
const elapsed = Math.max(0, (srcPtsMs - this.srcAnchorMs) / this.rate);
|
|
16691
16942
|
const outMs = this.outBaseMs + elapsed;
|
|
16692
16943
|
this.lastOutMs = Math.max(this.lastOutMs, outMs);
|
|
16693
16944
|
return Math.round(this.lastOutMs * this.rateHz / 1e3);
|
|
16694
16945
|
}
|
|
16695
|
-
/**
|
|
16946
|
+
/**
|
|
16947
|
+
* Call before feeding a new CONTIGUOUS segment (the per-segment PTS reset at
|
|
16948
|
+
* a boundary during forward playback, or a paused frame-step re-feed).
|
|
16949
|
+
* Advances the output ~1 frame past the last emitted ts. The nudge lives in
|
|
16950
|
+
* the OUTPUT domain, so it scales with the rate factor (at 2× a source frame
|
|
16951
|
+
* occupies ~16.5 output ms) to preserve the perceived frame cadence across
|
|
16952
|
+
* the boundary.
|
|
16953
|
+
*/
|
|
16696
16954
|
markSeek() {
|
|
16697
|
-
this.
|
|
16955
|
+
this.markDiscontinuity(33 / this.rate);
|
|
16956
|
+
}
|
|
16957
|
+
/**
|
|
16958
|
+
* Call before feeding a NON-contiguous position (a seek / scrub commit).
|
|
16959
|
+
* Like `markSeek` but with an explicit, caller-chosen output gap: a seek must
|
|
16960
|
+
* be unmistakable to the receiver — with only a ~1-frame nudge, stale
|
|
16961
|
+
* in-flight access units from the aborted feed and the new GOP look like one
|
|
16962
|
+
* contiguous stream, and a lost IDR then smears the new deltas onto pre-seek
|
|
16963
|
+
* references. The gap is an intentional discontinuity, so it is NOT
|
|
16964
|
+
* rate-scaled.
|
|
16965
|
+
*/
|
|
16966
|
+
markDiscontinuity(gapMs) {
|
|
16967
|
+
this.outBaseMs = this.lastOutMs + gapMs;
|
|
16698
16968
|
this.srcAnchorMs = null;
|
|
16969
|
+
this.lastSrcMs = null;
|
|
16970
|
+
}
|
|
16971
|
+
/**
|
|
16972
|
+
* Change the playback-rate factor (r > 0; 0/negative/redundant are no-ops —
|
|
16973
|
+
* "paused" is the absence of stamping, not a clock rate). Re-anchors both the
|
|
16974
|
+
* source and output baselines at the last stamped point so already-emitted
|
|
16975
|
+
* timestamps stay fixed and the next stamp continues monotonically at the new
|
|
16976
|
+
* rate.
|
|
16977
|
+
*/
|
|
16978
|
+
setRate(rate) {
|
|
16979
|
+
if (!(rate > 0) || rate === this.rate) return;
|
|
16980
|
+
if (this.srcAnchorMs !== null && this.lastSrcMs !== null) {
|
|
16981
|
+
this.outBaseMs = this.lastOutMs;
|
|
16982
|
+
this.srcAnchorMs = this.lastSrcMs;
|
|
16983
|
+
}
|
|
16984
|
+
this.rate = rate;
|
|
16699
16985
|
}
|
|
16700
16986
|
};
|
|
16701
16987
|
//#endregion
|
|
@@ -16709,23 +16995,54 @@ var POSITION_INTERVAL_MS = 1e3;
|
|
|
16709
16995
|
* keeping seeks responsive without per-frame drift.
|
|
16710
16996
|
*/
|
|
16711
16997
|
var WAIT_TICK_MS = 250;
|
|
16998
|
+
/**
|
|
16999
|
+
* Output-clock gap stamped at every seek/scrub commit (markDiscontinuity on
|
|
17000
|
+
* both A/V clocks). Root cause of the "smeared frame on scrub": the aborted
|
|
17001
|
+
* pace loop's last access units are already in the RTP send path when the new
|
|
17002
|
+
* feed starts; with only the ~1-frame (+33 ms) continuation nudge the receiver
|
|
17003
|
+
* sees ONE contiguous timestamp stream, so if any packet of the new feed's IDR
|
|
17004
|
+
* is lost in the seek burst, the decoder decodes the following deltas against
|
|
17005
|
+
* PRE-seek reference frames — the classic wrong-reference smear. A 250 ms jump
|
|
17006
|
+
* makes the seek an unambiguous stream discontinuity: stale lower-ts units
|
|
17007
|
+
* can't look contiguous with the new GOP, and the receiver resets timing
|
|
17008
|
+
* instead of merging the two feeds.
|
|
17009
|
+
*/
|
|
17010
|
+
var SEEK_DISCONTINUITY_MS = 250;
|
|
16712
17011
|
var RecordedFeeder = class {
|
|
16713
17012
|
deps;
|
|
16714
17013
|
clock = new MonotonicClock();
|
|
16715
17014
|
/** Separate 8 kHz clock for PCMU audio; re-anchored together with video. */
|
|
16716
17015
|
audioClock = new MonotonicClock(8e3);
|
|
16717
17016
|
clockHasAdvanced = false;
|
|
17017
|
+
/** Playback rate: 0 (paused) or a continuous factor in [0.25, 4]. */
|
|
16718
17018
|
rate = 1;
|
|
16719
17019
|
disposed = false;
|
|
16720
17020
|
seekToken = 0;
|
|
16721
17021
|
advanceToken = 0;
|
|
16722
17022
|
cursorMs = 0;
|
|
16723
17023
|
positionTimer = null;
|
|
17024
|
+
/**
|
|
17025
|
+
* The demuxed segment "under the cursor" — the frame-step working set. Kept
|
|
17026
|
+
* in lockstep with every feed path (seek burst, paused partial feed, pacing)
|
|
17027
|
+
* so a pause at any moment can step from the exact last-fed frame.
|
|
17028
|
+
*/
|
|
17029
|
+
currentSegment = null;
|
|
17030
|
+
/** Index (into `currentSegment.aus`) of the last video AU fed; -1 = none. */
|
|
17031
|
+
lastFedVideoIdx = -1;
|
|
17032
|
+
/** Serializes frame steps: a step arriving while one is in flight is dropped. */
|
|
17033
|
+
stepInFlight = false;
|
|
17034
|
+
/**
|
|
17035
|
+
* Target epoch of the seek currently in flight (null when none). A rate
|
|
17036
|
+
* change re-enters via seek(); while a seek is still resolving, `cursorMs`
|
|
17037
|
+
* is stale — re-seeking the in-flight target instead avoids snapping back to
|
|
17038
|
+
* the pre-seek position when the client changes rate right after a seek.
|
|
17039
|
+
*/
|
|
17040
|
+
inFlightSeekEpochMs = null;
|
|
16724
17041
|
constructor(deps) {
|
|
16725
17042
|
this.deps = deps;
|
|
16726
17043
|
}
|
|
16727
17044
|
/**
|
|
16728
|
-
* Seek to an epoch and (if rate
|
|
17045
|
+
* Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
|
|
16729
17046
|
* the FIRST located segment is fed; the forward `advance` loop is started
|
|
16730
17047
|
* fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
|
|
16731
17048
|
* seek bumps the token and silently abandons this one.
|
|
@@ -16733,11 +17050,12 @@ var RecordedFeeder = class {
|
|
|
16733
17050
|
async seek(epochMs) {
|
|
16734
17051
|
const token = ++this.seekToken;
|
|
16735
17052
|
this.advanceToken++;
|
|
17053
|
+
this.inFlightSeekEpochMs = epochMs;
|
|
16736
17054
|
try {
|
|
16737
17055
|
this.deps.onState("loading");
|
|
16738
17056
|
if (this.clockHasAdvanced) {
|
|
16739
|
-
this.clock.
|
|
16740
|
-
this.audioClock.
|
|
17057
|
+
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
17058
|
+
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
16741
17059
|
}
|
|
16742
17060
|
const loc = await this.withTimeout(this.deps.locate({
|
|
16743
17061
|
deviceId: this.deps.deviceId,
|
|
@@ -16746,93 +17064,274 @@ var RecordedFeeder = class {
|
|
|
16746
17064
|
}));
|
|
16747
17065
|
if (this.disposed || token !== this.seekToken) return;
|
|
16748
17066
|
if (loc.kind === "gap") {
|
|
17067
|
+
this.currentSegment = null;
|
|
17068
|
+
this.lastFedVideoIdx = -1;
|
|
16749
17069
|
this.stopPositionReports();
|
|
16750
17070
|
this.deps.onState("gap");
|
|
16751
17071
|
return;
|
|
16752
17072
|
}
|
|
16753
17073
|
this.cursorMs = loc.startMs;
|
|
16754
|
-
await this.
|
|
16755
|
-
if (this.disposed || token !== this.seekToken) return;
|
|
16756
|
-
this.
|
|
16757
|
-
|
|
17074
|
+
const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
|
|
17075
|
+
if (this.disposed || token !== this.seekToken || loaded === null) return;
|
|
17076
|
+
this.feedPrefix(loc, loaded.video, epochMs);
|
|
17077
|
+
if (this.rate === 0) {
|
|
17078
|
+
this.deps.onState("paused");
|
|
17079
|
+
this.deps.onPosition(this.cursorMs);
|
|
17080
|
+
this.startPositionReports();
|
|
17081
|
+
return;
|
|
17082
|
+
}
|
|
17083
|
+
this.deps.onState("playing");
|
|
17084
|
+
this.deps.onPosition(this.cursorMs);
|
|
16758
17085
|
this.startPositionReports();
|
|
16759
|
-
|
|
17086
|
+
const lastFedAu = this.lastFedVideoIdx >= 0 ? loaded.video[this.lastFedVideoIdx] : void 0;
|
|
17087
|
+
this.startAdvance({
|
|
17088
|
+
prepared: {
|
|
17089
|
+
startMs: loc.startMs,
|
|
17090
|
+
durMs: loc.durMs,
|
|
17091
|
+
aus: loaded.video,
|
|
17092
|
+
audio: loaded.audio
|
|
17093
|
+
},
|
|
17094
|
+
resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
|
|
17095
|
+
});
|
|
16760
17096
|
} catch {
|
|
16761
17097
|
if (!this.disposed && token === this.seekToken) {
|
|
16762
17098
|
this.stopPositionReports();
|
|
16763
17099
|
this.deps.onState("error");
|
|
16764
17100
|
}
|
|
17101
|
+
} finally {
|
|
17102
|
+
if (token === this.seekToken) this.inFlightSeekEpochMs = null;
|
|
16765
17103
|
}
|
|
16766
17104
|
}
|
|
16767
|
-
/**
|
|
17105
|
+
/**
|
|
17106
|
+
* Set playback rate. 0 pauses in place (the pacing loop aborts on its next
|
|
17107
|
+
* guard check); any other value is clamped to [0.25, 4].
|
|
17108
|
+
*
|
|
17109
|
+
* Rate-change strategy (documented choice): every transition TO a non-zero
|
|
17110
|
+
* rate — resume from pause AND a playing→playing rate change — re-enters via
|
|
17111
|
+
* `seek(cursorMs)`. seek() bumps `advanceToken` up-front, which aborts any
|
|
17112
|
+
* mid-segment pacing loop, then re-feeds the covering segment from its IDR
|
|
17113
|
+
* (a burst — the decoder needs the GOP head) and restarts the advance loop
|
|
17114
|
+
* pacing at the new rate. No in-place re-pacing of a half-delivered segment.
|
|
17115
|
+
*/
|
|
16768
17116
|
setRate(rate) {
|
|
16769
17117
|
if (this.disposed) return;
|
|
16770
|
-
|
|
16771
|
-
this.rate
|
|
16772
|
-
|
|
16773
|
-
|
|
16774
|
-
|
|
17118
|
+
const next = clampPlaybackRate(rate);
|
|
17119
|
+
if (next === this.rate) return;
|
|
17120
|
+
this.rate = next;
|
|
17121
|
+
if (next === 0) {
|
|
17122
|
+
this.stopPositionReports();
|
|
17123
|
+
this.deps.onState("paused");
|
|
17124
|
+
return;
|
|
17125
|
+
}
|
|
17126
|
+
this.clock.setRate(next);
|
|
17127
|
+
this.seek(this.inFlightSeekEpochMs ?? this.cursorMs);
|
|
17128
|
+
}
|
|
17129
|
+
/**
|
|
17130
|
+
* Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
|
|
17131
|
+
* paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
|
|
17132
|
+
* at a segment end crosses into the next segment (gaps are skipped via the
|
|
17133
|
+
* same lookahead the advance loop uses); backward at a segment start loads
|
|
17134
|
+
* the PREVIOUS segment and lands on its last frame. Backward re-feeds from
|
|
17135
|
+
* the nearest keyframe at-or-before the target so the decoder has a valid
|
|
17136
|
+
* GOP head. Edge no-ops: forward past the end of the recording, backward
|
|
17137
|
+
* into a gap/start of the recording, and a step while another cross-segment
|
|
17138
|
+
* step is still in flight.
|
|
17139
|
+
*/
|
|
17140
|
+
stepFrame(dir) {
|
|
17141
|
+
if (this.disposed || this.rate !== 0 || this.stepInFlight) return;
|
|
17142
|
+
this.stepInFlight = true;
|
|
17143
|
+
this.doStep(dir).catch(() => {}).finally(() => {
|
|
17144
|
+
this.stepInFlight = false;
|
|
17145
|
+
});
|
|
16775
17146
|
}
|
|
16776
|
-
async
|
|
16777
|
-
const
|
|
17147
|
+
async doStep(dir) {
|
|
17148
|
+
const seg = this.currentSegment;
|
|
17149
|
+
if (!seg || seg.aus.length === 0) return;
|
|
17150
|
+
const token = this.seekToken;
|
|
17151
|
+
const gen = this.advanceToken;
|
|
17152
|
+
if (dir === 1) {
|
|
17153
|
+
const nextIdx = this.lastFedVideoIdx + 1;
|
|
17154
|
+
if (nextIdx < seg.aus.length) {
|
|
17155
|
+
this.feedOneAu(seg, nextIdx);
|
|
17156
|
+
return;
|
|
17157
|
+
}
|
|
17158
|
+
const prepared = await this.prepareNext(seg.startMs + seg.durMs, gen);
|
|
17159
|
+
if (prepared === null || prepared === "ended") return;
|
|
17160
|
+
if (this.disposed || token !== this.seekToken || this.rate !== 0) return;
|
|
17161
|
+
if (prepared.aus.length === 0) return;
|
|
17162
|
+
if (this.clockHasAdvanced) {
|
|
17163
|
+
this.clock.markSeek();
|
|
17164
|
+
this.audioClock.markSeek();
|
|
17165
|
+
}
|
|
17166
|
+
this.currentSegment = {
|
|
17167
|
+
startMs: prepared.startMs,
|
|
17168
|
+
durMs: prepared.durMs,
|
|
17169
|
+
aus: prepared.aus
|
|
17170
|
+
};
|
|
17171
|
+
this.feedOneAu(this.currentSegment, 0);
|
|
17172
|
+
return;
|
|
17173
|
+
}
|
|
17174
|
+
if (this.lastFedVideoIdx > 0) {
|
|
17175
|
+
this.refeedUpTo(seg, this.lastFedVideoIdx - 1);
|
|
17176
|
+
return;
|
|
17177
|
+
}
|
|
17178
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
16778
17179
|
deviceId: this.deps.deviceId,
|
|
16779
17180
|
profile: this.deps.profile,
|
|
16780
|
-
startMs
|
|
17181
|
+
epochMs: seg.startMs - 1
|
|
16781
17182
|
}));
|
|
16782
|
-
if (this.disposed || token !== this.seekToken) return;
|
|
16783
|
-
|
|
16784
|
-
|
|
16785
|
-
|
|
16786
|
-
|
|
16787
|
-
|
|
16788
|
-
|
|
16789
|
-
|
|
16790
|
-
|
|
16791
|
-
|
|
16792
|
-
|
|
16793
|
-
|
|
16794
|
-
|
|
16795
|
-
|
|
16796
|
-
|
|
16797
|
-
|
|
16798
|
-
|
|
16799
|
-
|
|
16800
|
-
|
|
16801
|
-
|
|
17183
|
+
if (this.disposed || token !== this.seekToken || this.rate !== 0) return;
|
|
17184
|
+
if (loc.kind === "gap" && loc.prevEndMs != null && loc.prevEndMs <= seg.startMs) {
|
|
17185
|
+
loc = await this.withTimeout(this.deps.locate({
|
|
17186
|
+
deviceId: this.deps.deviceId,
|
|
17187
|
+
profile: this.deps.profile,
|
|
17188
|
+
epochMs: loc.prevEndMs - 1
|
|
17189
|
+
}));
|
|
17190
|
+
if (this.disposed || token !== this.seekToken || this.rate !== 0) return;
|
|
17191
|
+
}
|
|
17192
|
+
if (loc.kind !== "segment" || loc.startMs >= seg.startMs) return;
|
|
17193
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
17194
|
+
if (loaded === null || loaded.video.length === 0) return;
|
|
17195
|
+
if (this.disposed || this.rate !== 0) return;
|
|
17196
|
+
this.currentSegment = {
|
|
17197
|
+
startMs: loc.startMs,
|
|
17198
|
+
durMs: loc.durMs,
|
|
17199
|
+
aus: loaded.video
|
|
17200
|
+
};
|
|
17201
|
+
this.refeedUpTo(this.currentSegment, this.currentSegment.aus.length - 1);
|
|
17202
|
+
}
|
|
17203
|
+
/**
|
|
17204
|
+
* Clamp a frame-derived cursor into the segment's covered window
|
|
17205
|
+
* [startMs, startMs + durMs). Real frames can outlive the muxed `durMs`
|
|
17206
|
+
* (last pts − first pts ≥ durMs), and `startMs + durMs` itself is already
|
|
17207
|
+
* inside the inter-segment crack — a cursor parked there makes the next
|
|
17208
|
+
* `seek(cursorMs)` (resume/rate-change) locate a GAP and freeze playback.
|
|
17209
|
+
*/
|
|
17210
|
+
clampIntoSegment(seg, epochMs) {
|
|
17211
|
+
return Math.min(Math.max(epochMs, seg.startMs), seg.startMs + seg.durMs - 1);
|
|
17212
|
+
}
|
|
17213
|
+
/** Push exactly one AU of `seg`, move the step cursor onto it, report position. */
|
|
17214
|
+
feedOneAu(seg, idx) {
|
|
17215
|
+
const au = seg.aus[idx];
|
|
17216
|
+
const first = seg.aus[0];
|
|
17217
|
+
if (!au || !first) return;
|
|
17218
|
+
this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
17219
|
+
this.clockHasAdvanced = true;
|
|
17220
|
+
this.lastFedVideoIdx = idx;
|
|
17221
|
+
this.cursorMs = this.clampIntoSegment(seg, seg.startMs + (au.ptsMs - first.ptsMs));
|
|
17222
|
+
this.deps.onPosition(this.cursorMs);
|
|
17223
|
+
}
|
|
17224
|
+
/**
|
|
17225
|
+
* Backward-step render: re-feed from the nearest keyframe at-or-before
|
|
17226
|
+
* `targetIdx` up TO `targetIdx` (decode needs the GOP head; the burst renders
|
|
17227
|
+
* fast and the display lands exactly one frame earlier). Re-anchors the
|
|
17228
|
+
* clocks first — the re-fed PTS run BEHIND the monotonic clock's anchor and
|
|
17229
|
+
* would otherwise clamp into duplicate output timestamps.
|
|
17230
|
+
*/
|
|
17231
|
+
refeedUpTo(seg, targetIdx) {
|
|
17232
|
+
const first = seg.aus[0];
|
|
17233
|
+
const target = seg.aus[targetIdx];
|
|
17234
|
+
if (!first || !target) return;
|
|
17235
|
+
let fromIdx = 0;
|
|
17236
|
+
for (let i = targetIdx; i >= 0; i--) if (seg.aus[i]?.keyframe) {
|
|
17237
|
+
fromIdx = i;
|
|
17238
|
+
break;
|
|
17239
|
+
}
|
|
17240
|
+
if (this.clockHasAdvanced) {
|
|
17241
|
+
this.clock.markSeek();
|
|
17242
|
+
this.audioClock.markSeek();
|
|
17243
|
+
}
|
|
17244
|
+
for (let i = fromIdx; i <= targetIdx; i++) {
|
|
17245
|
+
const au = seg.aus[i];
|
|
17246
|
+
if (au) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
17247
|
+
}
|
|
17248
|
+
this.clockHasAdvanced = true;
|
|
17249
|
+
this.lastFedVideoIdx = targetIdx;
|
|
17250
|
+
this.cursorMs = this.clampIntoSegment(seg, seg.startMs + (target.ptsMs - first.ptsMs));
|
|
17251
|
+
this.deps.onPosition(this.cursorMs);
|
|
17252
|
+
}
|
|
17253
|
+
/**
|
|
17254
|
+
* Seek prefix feed (decode priming): push only the AUs with
|
|
17255
|
+
* `ptsMs − firstPtsMs ≤ requestedEpochMs − loc.startMs` — always at least the
|
|
17256
|
+
* first (IDR-led) AU so the feed is never empty — then park the cursor at the
|
|
17257
|
+
* requested epoch (clamped into the segment window). While paused this IS the
|
|
17258
|
+
* feed (exact-frame render); at rate > 0 it primes the decoder up to the
|
|
17259
|
+
* cursor and the advance loop paces the remainder. The demuxer contract
|
|
17260
|
+
* guarantees `aus[0]` is the segment's params-fused IDR, so the prefix always
|
|
17261
|
+
* starts at a keyframe.
|
|
17262
|
+
*/
|
|
17263
|
+
feedPrefix(loc, aus, requestedEpochMs) {
|
|
17264
|
+
const clampedEpoch = Math.min(Math.max(requestedEpochMs, loc.startMs), loc.startMs + loc.durMs - 1);
|
|
17265
|
+
this.currentSegment = {
|
|
17266
|
+
startMs: loc.startMs,
|
|
17267
|
+
durMs: loc.durMs,
|
|
17268
|
+
aus
|
|
17269
|
+
};
|
|
17270
|
+
const first = aus[0];
|
|
17271
|
+
if (!first) {
|
|
17272
|
+
this.lastFedVideoIdx = -1;
|
|
17273
|
+
this.cursorMs = clampedEpoch;
|
|
16802
17274
|
return;
|
|
16803
17275
|
}
|
|
16804
|
-
|
|
16805
|
-
|
|
17276
|
+
const offsetMs = requestedEpochMs - loc.startMs;
|
|
17277
|
+
let lastIdx = 0;
|
|
17278
|
+
for (let i = 1; i < aus.length; i++) {
|
|
17279
|
+
const au = aus[i];
|
|
17280
|
+
if (au && au.ptsMs - first.ptsMs <= offsetMs) lastIdx = i;
|
|
17281
|
+
else break;
|
|
17282
|
+
}
|
|
17283
|
+
for (let i = 0; i <= lastIdx; i++) {
|
|
17284
|
+
const au = aus[i];
|
|
17285
|
+
if (au) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
17286
|
+
}
|
|
17287
|
+
this.clockHasAdvanced = true;
|
|
17288
|
+
this.lastFedVideoIdx = lastIdx;
|
|
17289
|
+
this.cursorMs = clampedEpoch;
|
|
16806
17290
|
}
|
|
16807
17291
|
/**
|
|
16808
17292
|
* Start (or restart) the single forward advance loop. Bumping `advanceToken`
|
|
16809
17293
|
* invalidates any loop already running, guaranteeing at most one survives.
|
|
17294
|
+
* `initial` hands the loop the seek's already-loaded covering segment plus
|
|
17295
|
+
* the prefix boundary, so pacing resumes FROM THE CURSOR instead of the loop
|
|
17296
|
+
* re-locating (and thereby skipping) the segment under it.
|
|
16810
17297
|
*/
|
|
16811
|
-
startAdvance() {
|
|
17298
|
+
startAdvance(initial) {
|
|
16812
17299
|
const gen = ++this.advanceToken;
|
|
16813
|
-
this.advanceLoop(gen).catch(() => {
|
|
17300
|
+
this.advanceLoop(gen, initial).catch(() => {
|
|
16814
17301
|
if (!this.disposed && gen === this.advanceToken) {
|
|
16815
17302
|
this.stopPositionReports();
|
|
16816
17303
|
this.deps.onState("error");
|
|
16817
17304
|
}
|
|
16818
17305
|
});
|
|
16819
17306
|
}
|
|
16820
|
-
async advanceLoop(gen) {
|
|
16821
|
-
let prepared
|
|
16822
|
-
|
|
17307
|
+
async advanceLoop(gen, initial) {
|
|
17308
|
+
let prepared;
|
|
17309
|
+
let resumeAfterPtsMs = null;
|
|
17310
|
+
let isResumedSegment = false;
|
|
17311
|
+
if (initial) {
|
|
17312
|
+
prepared = initial.prepared;
|
|
17313
|
+
resumeAfterPtsMs = initial.resumeAfterPtsMs;
|
|
17314
|
+
isResumedSegment = true;
|
|
17315
|
+
} else prepared = await this.prepareNext(this.cursorMs, gen);
|
|
17316
|
+
while (!this.disposed && gen === this.advanceToken && this.rate > 0) {
|
|
16823
17317
|
if (prepared === null) return;
|
|
16824
17318
|
if (prepared === "ended") {
|
|
16825
17319
|
this.stopPositionReports();
|
|
16826
17320
|
this.deps.onState("ended");
|
|
16827
17321
|
return;
|
|
16828
17322
|
}
|
|
16829
|
-
|
|
16830
|
-
|
|
16831
|
-
this.
|
|
16832
|
-
|
|
17323
|
+
if (isResumedSegment) {} else {
|
|
17324
|
+
this.cursorMs = prepared.startMs;
|
|
17325
|
+
if (this.clockHasAdvanced) {
|
|
17326
|
+
this.clock.markSeek();
|
|
17327
|
+
this.audioClock.markSeek();
|
|
17328
|
+
}
|
|
16833
17329
|
}
|
|
16834
17330
|
const prefetch = this.prepareNext(prepared.startMs + prepared.durMs, gen);
|
|
16835
|
-
|
|
17331
|
+
const aborted = await this.paceSegment(prepared, gen, resumeAfterPtsMs);
|
|
17332
|
+
resumeAfterPtsMs = null;
|
|
17333
|
+
isResumedSegment = false;
|
|
17334
|
+
if (aborted) {
|
|
16836
17335
|
prefetch.catch(() => {});
|
|
16837
17336
|
return;
|
|
16838
17337
|
}
|
|
@@ -16843,45 +17342,67 @@ var RecordedFeeder = class {
|
|
|
16843
17342
|
/**
|
|
16844
17343
|
* Drip one prepared segment to the sink at its real frame cadence: build a
|
|
16845
17344
|
* single PTS-ordered schedule of the segment's video + audio access units and
|
|
16846
|
-
* push each at `segmentStart + (pts - firstPts)` wall-clock time,
|
|
16847
|
-
* between items with a drift-free absolute-time wait. RTP timestamps
|
|
16848
|
-
* the SAME `clock.stamp` / `audioClock.stamp` calls as before
|
|
16849
|
-
*
|
|
16850
|
-
*
|
|
16851
|
-
|
|
16852
|
-
|
|
16853
|
-
|
|
17345
|
+
* push each at `segmentStart + (pts - firstPts) / rate` wall-clock time,
|
|
17346
|
+
* sleeping between items with a drift-free absolute-time wait. RTP timestamps
|
|
17347
|
+
* come from the SAME `clock.stamp` / `audioClock.stamp` calls as before (the
|
|
17348
|
+
* clock itself carries the rate factor) — only delivery timing changes.
|
|
17349
|
+
* `resumeAfterPtsMs` (a resumed covering segment) paces only the items
|
|
17350
|
+
* strictly after the seek prefix, over the segment's REMAINING window.
|
|
17351
|
+
* Returns `true` if a seek/dispose/pause aborted pacing mid-segment (the
|
|
17352
|
+
* caller must stop without advancing the cursor).
|
|
17353
|
+
*/
|
|
17354
|
+
async paceSegment(prepared, gen, resumeAfterPtsMs = null) {
|
|
17355
|
+
const rate = this.rate;
|
|
17356
|
+
const hasAudioSink = typeof this.deps.sink.pushAudioPcmu === "function" && rate === 1;
|
|
16854
17357
|
const schedule = [];
|
|
16855
|
-
for (const au of prepared.aus) schedule.push({
|
|
17358
|
+
for (const au of prepared.aus) if (resumeAfterPtsMs === null || au.ptsMs > resumeAfterPtsMs) schedule.push({
|
|
16856
17359
|
ptsMs: au.ptsMs,
|
|
16857
17360
|
video: au
|
|
16858
17361
|
});
|
|
16859
|
-
if (hasAudioSink)
|
|
16860
|
-
|
|
16861
|
-
|
|
16862
|
-
|
|
17362
|
+
if (hasAudioSink) {
|
|
17363
|
+
for (const au of prepared.audio) if (resumeAfterPtsMs === null || au.ptsMs > resumeAfterPtsMs) schedule.push({
|
|
17364
|
+
ptsMs: au.ptsMs,
|
|
17365
|
+
audio: au
|
|
17366
|
+
});
|
|
17367
|
+
}
|
|
16863
17368
|
schedule.sort((a, b) => {
|
|
16864
17369
|
if (a.ptsMs !== b.ptsMs) return a.ptsMs - b.ptsMs;
|
|
16865
17370
|
return (a.video ? 0 : 1) - (b.video ? 0 : 1);
|
|
16866
17371
|
});
|
|
16867
|
-
|
|
16868
|
-
|
|
17372
|
+
this.currentSegment = {
|
|
17373
|
+
startMs: prepared.startMs,
|
|
17374
|
+
durMs: prepared.durMs,
|
|
17375
|
+
aus: prepared.aus
|
|
17376
|
+
};
|
|
17377
|
+
let videoIdx = 0;
|
|
17378
|
+
if (resumeAfterPtsMs === null) this.lastFedVideoIdx = -1;
|
|
17379
|
+
else {
|
|
17380
|
+
while (videoIdx < prepared.aus.length && prepared.aus[videoIdx].ptsMs <= resumeAfterPtsMs) videoIdx++;
|
|
17381
|
+
this.lastFedVideoIdx = videoIdx - 1;
|
|
17382
|
+
}
|
|
17383
|
+
const firstVideoPtsMs = prepared.aus[0]?.ptsMs ?? 0;
|
|
17384
|
+
const consumedMs = resumeAfterPtsMs === null ? 0 : Math.max(0, resumeAfterPtsMs - firstVideoPtsMs);
|
|
17385
|
+
const remainingWindowMs = Math.max(0, prepared.durMs - consumedMs);
|
|
17386
|
+
if (schedule.length === 0) return await this.sleepUntil(performance.now() + remainingWindowMs / rate, gen);
|
|
17387
|
+
const baselinePtsMs = resumeAfterPtsMs ?? schedule[0].ptsMs;
|
|
16869
17388
|
const segmentStartWall = performance.now();
|
|
16870
17389
|
let pushedVideo = false;
|
|
16871
17390
|
for (const item of schedule) {
|
|
16872
|
-
const targetWall = segmentStartWall + (item.ptsMs -
|
|
17391
|
+
const targetWall = segmentStartWall + (item.ptsMs - baselinePtsMs) / rate;
|
|
16873
17392
|
if (await this.sleepUntil(targetWall, gen)) return true;
|
|
16874
|
-
if (this.disposed || gen !== this.advanceToken || this.rate
|
|
17393
|
+
if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
|
|
16875
17394
|
if (item.video) {
|
|
16876
17395
|
this.deps.sink.pushAccessUnit(item.video.data, this.clock.stamp(item.video.ptsMs), item.video.codec);
|
|
16877
17396
|
pushedVideo = true;
|
|
17397
|
+
this.lastFedVideoIdx = videoIdx++;
|
|
17398
|
+
this.cursorMs = prepared.startMs + (item.video.ptsMs - firstVideoPtsMs);
|
|
16878
17399
|
} else if (item.audio) {
|
|
16879
17400
|
const pushAudio = this.deps.sink.pushAudioPcmu;
|
|
16880
17401
|
if (pushAudio) pushAudio.call(this.deps.sink, item.audio.data, this.audioClock.stamp(item.audio.ptsMs));
|
|
16881
17402
|
}
|
|
16882
17403
|
}
|
|
16883
17404
|
if (pushedVideo) this.clockHasAdvanced = true;
|
|
16884
|
-
const segmentEndWall = segmentStartWall +
|
|
17405
|
+
const segmentEndWall = segmentStartWall + remainingWindowMs / rate;
|
|
16885
17406
|
return await this.sleepUntil(segmentEndWall, gen);
|
|
16886
17407
|
}
|
|
16887
17408
|
/**
|
|
@@ -16889,11 +17410,13 @@ var RecordedFeeder = class {
|
|
|
16889
17410
|
* value). Re-derives the remaining interval each tick and caps each individual
|
|
16890
17411
|
* timeout so a long inter-frame gap can't delay seek/dispose responsiveness —
|
|
16891
17412
|
* the loop re-checks `gen === advanceToken` between ticks. Returns `true` if a
|
|
16892
|
-
* dispose/seek/
|
|
17413
|
+
* dispose/seek/pause aborted the wait, so the caller stops pushing. (A rate
|
|
17414
|
+
* CHANGE aborts via the gen guard — setRate re-enters through seek(), which
|
|
17415
|
+
* bumps advanceToken; rate 0 is the direct pause abort.)
|
|
16893
17416
|
*/
|
|
16894
17417
|
async sleepUntil(targetWall, gen) {
|
|
16895
17418
|
for (;;) {
|
|
16896
|
-
if (this.disposed || gen !== this.advanceToken || this.rate
|
|
17419
|
+
if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
|
|
16897
17420
|
const remaining = targetWall - performance.now();
|
|
16898
17421
|
if (remaining <= 0) return false;
|
|
16899
17422
|
const tick = Math.min(remaining, WAIT_TICK_MS);
|
|
@@ -16941,8 +17464,9 @@ var RecordedFeeder = class {
|
|
|
16941
17464
|
/** Read + demux one segment's bytes into video (+ best-effort PCMU audio)
|
|
16942
17465
|
* access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
|
|
16943
17466
|
* (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
|
|
16944
|
-
* never blocks video).
|
|
16945
|
-
|
|
17467
|
+
* never blocks video). `wantAudio: false` skips the audio transcode entirely
|
|
17468
|
+
* (paused scrubs would throw it away). */
|
|
17469
|
+
async loadAus(startMs, wantAudio = true) {
|
|
16946
17470
|
const token = this.seekToken;
|
|
16947
17471
|
const bytes = await this.withTimeout(this.deps.readBytes({
|
|
16948
17472
|
deviceId: this.deps.deviceId,
|
|
@@ -16953,7 +17477,7 @@ var RecordedFeeder = class {
|
|
|
16953
17477
|
const video = await this.withTimeout(this.deps.demux(bytes));
|
|
16954
17478
|
if (this.disposed || token !== this.seekToken) return null;
|
|
16955
17479
|
let audio = [];
|
|
16956
|
-
if (this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
|
|
17480
|
+
if (wantAudio && this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
|
|
16957
17481
|
try {
|
|
16958
17482
|
audio = await this.withTimeout(this.deps.demuxAudio(bytes));
|
|
16959
17483
|
} catch {
|
|
@@ -17003,59 +17527,6 @@ var RecordedFeeder = class {
|
|
|
17003
17527
|
}
|
|
17004
17528
|
};
|
|
17005
17529
|
//#endregion
|
|
17006
|
-
//#region src/stream-broker/recorded/control-protocol.ts
|
|
17007
|
-
var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
17008
|
-
require_dist.object({
|
|
17009
|
-
t: require_dist.literal("playRecorded"),
|
|
17010
|
-
deviceId: require_dist.number(),
|
|
17011
|
-
epoch: require_dist.number(),
|
|
17012
|
-
profile: require_dist.string()
|
|
17013
|
-
}),
|
|
17014
|
-
require_dist.object({
|
|
17015
|
-
t: require_dist.literal("seek"),
|
|
17016
|
-
epoch: require_dist.number()
|
|
17017
|
-
}),
|
|
17018
|
-
require_dist.object({
|
|
17019
|
-
t: require_dist.literal("scrubCommit"),
|
|
17020
|
-
epoch: require_dist.number()
|
|
17021
|
-
}),
|
|
17022
|
-
require_dist.object({
|
|
17023
|
-
t: require_dist.literal("setRate"),
|
|
17024
|
-
rate: require_dist.union([require_dist.literal(0), require_dist.literal(1)])
|
|
17025
|
-
}),
|
|
17026
|
-
require_dist.object({ t: require_dist.literal("goLive") }),
|
|
17027
|
-
require_dist.object({
|
|
17028
|
-
t: require_dist.literal("setProfile"),
|
|
17029
|
-
profile: require_dist.string()
|
|
17030
|
-
})
|
|
17031
|
-
]);
|
|
17032
|
-
var ServerMessageSchema = require_dist.discriminatedUnion("t", [require_dist.object({
|
|
17033
|
-
t: require_dist.literal("state"),
|
|
17034
|
-
state: require_dist._enum([
|
|
17035
|
-
"live",
|
|
17036
|
-
"loading",
|
|
17037
|
-
"playing",
|
|
17038
|
-
"paused",
|
|
17039
|
-
"gap",
|
|
17040
|
-
"ended",
|
|
17041
|
-
"error"
|
|
17042
|
-
])
|
|
17043
|
-
}), require_dist.object({
|
|
17044
|
-
t: require_dist.literal("position"),
|
|
17045
|
-
epochMs: require_dist.number()
|
|
17046
|
-
})]);
|
|
17047
|
-
function parseControlMessage(raw) {
|
|
17048
|
-
try {
|
|
17049
|
-
const parsed = ControlMessageSchema.safeParse(JSON.parse(raw));
|
|
17050
|
-
return parsed.success ? parsed.data : null;
|
|
17051
|
-
} catch {
|
|
17052
|
-
return null;
|
|
17053
|
-
}
|
|
17054
|
-
}
|
|
17055
|
-
function serializeServerMessage(msg) {
|
|
17056
|
-
return JSON.stringify(ServerMessageSchema.parse(msg));
|
|
17057
|
-
}
|
|
17058
|
-
//#endregion
|
|
17059
17530
|
//#region src/stream-broker/recorded/timeline-session.ts
|
|
17060
17531
|
/**
|
|
17061
17532
|
* Per-viewer coordinator over one broker WebRTC session. Owns the playback mode
|
|
@@ -17088,6 +17559,9 @@ var TimelineSession = class {
|
|
|
17088
17559
|
case "setRate":
|
|
17089
17560
|
this.feeder?.setRate(msg.rate);
|
|
17090
17561
|
break;
|
|
17562
|
+
case "stepFrame":
|
|
17563
|
+
if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
|
|
17564
|
+
break;
|
|
17091
17565
|
case "setProfile":
|
|
17092
17566
|
this.profile = msg.profile;
|
|
17093
17567
|
if (this.mode === "recorded") this.enterRecorded();
|
|
@@ -19123,7 +19597,7 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
|
|
|
19123
19597
|
const streamingDebug = this.resolveSessionDebug(input.deviceId);
|
|
19124
19598
|
const transcodeProfile = this.resolveTranscodeProfile(input.deviceId, camStreamId);
|
|
19125
19599
|
try {
|
|
19126
|
-
return await this.webrtcServer.handleOffer(streamId, input.sdpOffer,
|
|
19600
|
+
return await this.webrtcServer.handleOffer(streamId, input.sdpOffer, input.hints, {
|
|
19127
19601
|
streamingDebug,
|
|
19128
19602
|
sessionId: input.sessionId,
|
|
19129
19603
|
relayOnly: input.relayOnly === true,
|