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