@camstack/addon-pipeline 1.1.58 → 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-Bn6Uq4vq.js → dist-B1u-QN05.js} +176 -12
- package/dist/{dist-CnwwDzX3.mjs → dist-DKeNH_5z.mjs} +176 -12
- 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 +123 -4
- package/dist/pipeline-runner/index.mjs +122 -3
- package/dist/recorder/index.js +993 -7
- package/dist/recorder/index.mjs +990 -5
- package/dist/session-decode/decode-worker-child.js +14 -1
- package/dist/session-decode/decode-worker-child.mjs +14 -1
- 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-D7NOrnzz.js → step-definitions-CPch37vh.js} +1 -1
- package/dist/{step-definitions-DhEe57Wn.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-B8FQ1e0a.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-D1Vdyu-4.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
- package/dist/stream-broker/index.js +622 -149
- package/dist/stream-broker/index.mjs +622 -149
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CG5EE0W4.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BFeLUNOl.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-pQQunq1F.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-BxqQ931j.js +0 -81
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as __require } from "../chunk-BdkLduGY.mjs";
|
|
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-
|
|
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
|
}
|
|
@@ -16665,6 +16839,73 @@ var AdaptiveController = class {
|
|
|
16665
16839
|
}
|
|
16666
16840
|
};
|
|
16667
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
|
|
16668
16909
|
//#region src/stream-broker/recorded/feed-clock.ts
|
|
16669
16910
|
/**
|
|
16670
16911
|
* Maps arbitrary, possibly-resetting source PTS (ms) onto a strictly increasing
|
|
@@ -16672,26 +16913,70 @@ var AdaptiveController = class {
|
|
|
16672
16913
|
* seek the output keeps advancing while the source anchor re-bases, so
|
|
16673
16914
|
* per-segment PTS resets never rewind the track. Video and audio use SEPARATE
|
|
16674
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).
|
|
16675
16922
|
*/
|
|
16676
16923
|
var MonotonicClock = class {
|
|
16677
16924
|
rateHz;
|
|
16678
16925
|
srcAnchorMs = null;
|
|
16926
|
+
lastSrcMs = null;
|
|
16679
16927
|
outBaseMs = 0;
|
|
16680
16928
|
lastOutMs = 0;
|
|
16929
|
+
rate = 1;
|
|
16681
16930
|
constructor(rateHz = 9e4) {
|
|
16682
16931
|
this.rateHz = rateHz;
|
|
16683
16932
|
}
|
|
16684
16933
|
stamp(srcPtsMs) {
|
|
16685
16934
|
if (this.srcAnchorMs === null) this.srcAnchorMs = srcPtsMs;
|
|
16686
|
-
|
|
16935
|
+
this.lastSrcMs = srcPtsMs;
|
|
16936
|
+
const elapsed = Math.max(0, (srcPtsMs - this.srcAnchorMs) / this.rate);
|
|
16687
16937
|
const outMs = this.outBaseMs + elapsed;
|
|
16688
16938
|
this.lastOutMs = Math.max(this.lastOutMs, outMs);
|
|
16689
16939
|
return Math.round(this.lastOutMs * this.rateHz / 1e3);
|
|
16690
16940
|
}
|
|
16691
|
-
/**
|
|
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
|
+
*/
|
|
16692
16949
|
markSeek() {
|
|
16693
|
-
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;
|
|
16694
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;
|
|
16695
16980
|
}
|
|
16696
16981
|
};
|
|
16697
16982
|
//#endregion
|
|
@@ -16705,23 +16990,54 @@ var POSITION_INTERVAL_MS = 1e3;
|
|
|
16705
16990
|
* keeping seeks responsive without per-frame drift.
|
|
16706
16991
|
*/
|
|
16707
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;
|
|
16708
17006
|
var RecordedFeeder = class {
|
|
16709
17007
|
deps;
|
|
16710
17008
|
clock = new MonotonicClock();
|
|
16711
17009
|
/** Separate 8 kHz clock for PCMU audio; re-anchored together with video. */
|
|
16712
17010
|
audioClock = new MonotonicClock(8e3);
|
|
16713
17011
|
clockHasAdvanced = false;
|
|
17012
|
+
/** Playback rate: 0 (paused) or a continuous factor in [0.25, 4]. */
|
|
16714
17013
|
rate = 1;
|
|
16715
17014
|
disposed = false;
|
|
16716
17015
|
seekToken = 0;
|
|
16717
17016
|
advanceToken = 0;
|
|
16718
17017
|
cursorMs = 0;
|
|
16719
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;
|
|
16720
17036
|
constructor(deps) {
|
|
16721
17037
|
this.deps = deps;
|
|
16722
17038
|
}
|
|
16723
17039
|
/**
|
|
16724
|
-
* Seek to an epoch and (if rate
|
|
17040
|
+
* Seek to an epoch and (if rate > 0) begin forward playback. Resolves after
|
|
16725
17041
|
* the FIRST located segment is fed; the forward `advance` loop is started
|
|
16726
17042
|
* fire-and-forget so seek never blocks on its pacing. Latest-wins: a newer
|
|
16727
17043
|
* seek bumps the token and silently abandons this one.
|
|
@@ -16729,11 +17045,12 @@ var RecordedFeeder = class {
|
|
|
16729
17045
|
async seek(epochMs) {
|
|
16730
17046
|
const token = ++this.seekToken;
|
|
16731
17047
|
this.advanceToken++;
|
|
17048
|
+
this.inFlightSeekEpochMs = epochMs;
|
|
16732
17049
|
try {
|
|
16733
17050
|
this.deps.onState("loading");
|
|
16734
17051
|
if (this.clockHasAdvanced) {
|
|
16735
|
-
this.clock.
|
|
16736
|
-
this.audioClock.
|
|
17052
|
+
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
17053
|
+
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
16737
17054
|
}
|
|
16738
17055
|
const loc = await this.withTimeout(this.deps.locate({
|
|
16739
17056
|
deviceId: this.deps.deviceId,
|
|
@@ -16742,93 +17059,274 @@ var RecordedFeeder = class {
|
|
|
16742
17059
|
}));
|
|
16743
17060
|
if (this.disposed || token !== this.seekToken) return;
|
|
16744
17061
|
if (loc.kind === "gap") {
|
|
17062
|
+
this.currentSegment = null;
|
|
17063
|
+
this.lastFedVideoIdx = -1;
|
|
16745
17064
|
this.stopPositionReports();
|
|
16746
17065
|
this.deps.onState("gap");
|
|
16747
17066
|
return;
|
|
16748
17067
|
}
|
|
16749
17068
|
this.cursorMs = loc.startMs;
|
|
16750
|
-
await this.
|
|
16751
|
-
if (this.disposed || token !== this.seekToken) return;
|
|
16752
|
-
this.
|
|
16753
|
-
|
|
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);
|
|
16754
17080
|
this.startPositionReports();
|
|
16755
|
-
|
|
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
|
+
});
|
|
16756
17091
|
} catch {
|
|
16757
17092
|
if (!this.disposed && token === this.seekToken) {
|
|
16758
17093
|
this.stopPositionReports();
|
|
16759
17094
|
this.deps.onState("error");
|
|
16760
17095
|
}
|
|
17096
|
+
} finally {
|
|
17097
|
+
if (token === this.seekToken) this.inFlightSeekEpochMs = null;
|
|
16761
17098
|
}
|
|
16762
17099
|
}
|
|
16763
|
-
/**
|
|
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
|
+
*/
|
|
16764
17111
|
setRate(rate) {
|
|
16765
17112
|
if (this.disposed) return;
|
|
16766
|
-
|
|
16767
|
-
this.rate
|
|
16768
|
-
|
|
16769
|
-
|
|
16770
|
-
|
|
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
|
+
});
|
|
16771
17141
|
}
|
|
16772
|
-
async
|
|
16773
|
-
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({
|
|
16774
17174
|
deviceId: this.deps.deviceId,
|
|
16775
17175
|
profile: this.deps.profile,
|
|
16776
|
-
startMs
|
|
17176
|
+
epochMs: seg.startMs - 1
|
|
16777
17177
|
}));
|
|
16778
|
-
if (this.disposed || token !== this.seekToken) return;
|
|
16779
|
-
|
|
16780
|
-
|
|
16781
|
-
|
|
16782
|
-
|
|
16783
|
-
|
|
16784
|
-
|
|
16785
|
-
|
|
16786
|
-
|
|
16787
|
-
|
|
16788
|
-
|
|
16789
|
-
|
|
16790
|
-
|
|
16791
|
-
|
|
16792
|
-
|
|
16793
|
-
|
|
16794
|
-
|
|
16795
|
-
|
|
16796
|
-
|
|
16797
|
-
|
|
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;
|
|
16798
17269
|
return;
|
|
16799
17270
|
}
|
|
16800
|
-
|
|
16801
|
-
|
|
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;
|
|
16802
17285
|
}
|
|
16803
17286
|
/**
|
|
16804
17287
|
* Start (or restart) the single forward advance loop. Bumping `advanceToken`
|
|
16805
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.
|
|
16806
17292
|
*/
|
|
16807
|
-
startAdvance() {
|
|
17293
|
+
startAdvance(initial) {
|
|
16808
17294
|
const gen = ++this.advanceToken;
|
|
16809
|
-
this.advanceLoop(gen).catch(() => {
|
|
17295
|
+
this.advanceLoop(gen, initial).catch(() => {
|
|
16810
17296
|
if (!this.disposed && gen === this.advanceToken) {
|
|
16811
17297
|
this.stopPositionReports();
|
|
16812
17298
|
this.deps.onState("error");
|
|
16813
17299
|
}
|
|
16814
17300
|
});
|
|
16815
17301
|
}
|
|
16816
|
-
async advanceLoop(gen) {
|
|
16817
|
-
let prepared
|
|
16818
|
-
|
|
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) {
|
|
16819
17312
|
if (prepared === null) return;
|
|
16820
17313
|
if (prepared === "ended") {
|
|
16821
17314
|
this.stopPositionReports();
|
|
16822
17315
|
this.deps.onState("ended");
|
|
16823
17316
|
return;
|
|
16824
17317
|
}
|
|
16825
|
-
|
|
16826
|
-
|
|
16827
|
-
this.
|
|
16828
|
-
|
|
17318
|
+
if (isResumedSegment) {} else {
|
|
17319
|
+
this.cursorMs = prepared.startMs;
|
|
17320
|
+
if (this.clockHasAdvanced) {
|
|
17321
|
+
this.clock.markSeek();
|
|
17322
|
+
this.audioClock.markSeek();
|
|
17323
|
+
}
|
|
16829
17324
|
}
|
|
16830
17325
|
const prefetch = this.prepareNext(prepared.startMs + prepared.durMs, gen);
|
|
16831
|
-
|
|
17326
|
+
const aborted = await this.paceSegment(prepared, gen, resumeAfterPtsMs);
|
|
17327
|
+
resumeAfterPtsMs = null;
|
|
17328
|
+
isResumedSegment = false;
|
|
17329
|
+
if (aborted) {
|
|
16832
17330
|
prefetch.catch(() => {});
|
|
16833
17331
|
return;
|
|
16834
17332
|
}
|
|
@@ -16839,45 +17337,67 @@ var RecordedFeeder = class {
|
|
|
16839
17337
|
/**
|
|
16840
17338
|
* Drip one prepared segment to the sink at its real frame cadence: build a
|
|
16841
17339
|
* single PTS-ordered schedule of the segment's video + audio access units and
|
|
16842
|
-
* push each at `segmentStart + (pts - firstPts)` wall-clock time,
|
|
16843
|
-
* between items with a drift-free absolute-time wait. RTP timestamps
|
|
16844
|
-
* the SAME `clock.stamp` / `audioClock.stamp` calls as before
|
|
16845
|
-
*
|
|
16846
|
-
*
|
|
16847
|
-
|
|
16848
|
-
|
|
16849
|
-
|
|
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;
|
|
16850
17352
|
const schedule = [];
|
|
16851
|
-
for (const au of prepared.aus) schedule.push({
|
|
17353
|
+
for (const au of prepared.aus) if (resumeAfterPtsMs === null || au.ptsMs > resumeAfterPtsMs) schedule.push({
|
|
16852
17354
|
ptsMs: au.ptsMs,
|
|
16853
17355
|
video: au
|
|
16854
17356
|
});
|
|
16855
|
-
if (hasAudioSink)
|
|
16856
|
-
|
|
16857
|
-
|
|
16858
|
-
|
|
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
|
+
}
|
|
16859
17363
|
schedule.sort((a, b) => {
|
|
16860
17364
|
if (a.ptsMs !== b.ptsMs) return a.ptsMs - b.ptsMs;
|
|
16861
17365
|
return (a.video ? 0 : 1) - (b.video ? 0 : 1);
|
|
16862
17366
|
});
|
|
16863
|
-
|
|
16864
|
-
|
|
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;
|
|
16865
17383
|
const segmentStartWall = performance.now();
|
|
16866
17384
|
let pushedVideo = false;
|
|
16867
17385
|
for (const item of schedule) {
|
|
16868
|
-
const targetWall = segmentStartWall + (item.ptsMs -
|
|
17386
|
+
const targetWall = segmentStartWall + (item.ptsMs - baselinePtsMs) / rate;
|
|
16869
17387
|
if (await this.sleepUntil(targetWall, gen)) return true;
|
|
16870
|
-
if (this.disposed || gen !== this.advanceToken || this.rate
|
|
17388
|
+
if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
|
|
16871
17389
|
if (item.video) {
|
|
16872
17390
|
this.deps.sink.pushAccessUnit(item.video.data, this.clock.stamp(item.video.ptsMs), item.video.codec);
|
|
16873
17391
|
pushedVideo = true;
|
|
17392
|
+
this.lastFedVideoIdx = videoIdx++;
|
|
17393
|
+
this.cursorMs = prepared.startMs + (item.video.ptsMs - firstVideoPtsMs);
|
|
16874
17394
|
} else if (item.audio) {
|
|
16875
17395
|
const pushAudio = this.deps.sink.pushAudioPcmu;
|
|
16876
17396
|
if (pushAudio) pushAudio.call(this.deps.sink, item.audio.data, this.audioClock.stamp(item.audio.ptsMs));
|
|
16877
17397
|
}
|
|
16878
17398
|
}
|
|
16879
17399
|
if (pushedVideo) this.clockHasAdvanced = true;
|
|
16880
|
-
const segmentEndWall = segmentStartWall +
|
|
17400
|
+
const segmentEndWall = segmentStartWall + remainingWindowMs / rate;
|
|
16881
17401
|
return await this.sleepUntil(segmentEndWall, gen);
|
|
16882
17402
|
}
|
|
16883
17403
|
/**
|
|
@@ -16885,11 +17405,13 @@ var RecordedFeeder = class {
|
|
|
16885
17405
|
* value). Re-derives the remaining interval each tick and caps each individual
|
|
16886
17406
|
* timeout so a long inter-frame gap can't delay seek/dispose responsiveness —
|
|
16887
17407
|
* the loop re-checks `gen === advanceToken` between ticks. Returns `true` if a
|
|
16888
|
-
* 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.)
|
|
16889
17411
|
*/
|
|
16890
17412
|
async sleepUntil(targetWall, gen) {
|
|
16891
17413
|
for (;;) {
|
|
16892
|
-
if (this.disposed || gen !== this.advanceToken || this.rate
|
|
17414
|
+
if (this.disposed || gen !== this.advanceToken || this.rate === 0) return true;
|
|
16893
17415
|
const remaining = targetWall - performance.now();
|
|
16894
17416
|
if (remaining <= 0) return false;
|
|
16895
17417
|
const tick = Math.min(remaining, WAIT_TICK_MS);
|
|
@@ -16937,8 +17459,9 @@ var RecordedFeeder = class {
|
|
|
16937
17459
|
/** Read + demux one segment's bytes into video (+ best-effort PCMU audio)
|
|
16938
17460
|
* access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
|
|
16939
17461
|
* (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
|
|
16940
|
-
* never blocks video).
|
|
16941
|
-
|
|
17462
|
+
* never blocks video). `wantAudio: false` skips the audio transcode entirely
|
|
17463
|
+
* (paused scrubs would throw it away). */
|
|
17464
|
+
async loadAus(startMs, wantAudio = true) {
|
|
16942
17465
|
const token = this.seekToken;
|
|
16943
17466
|
const bytes = await this.withTimeout(this.deps.readBytes({
|
|
16944
17467
|
deviceId: this.deps.deviceId,
|
|
@@ -16949,7 +17472,7 @@ var RecordedFeeder = class {
|
|
|
16949
17472
|
const video = await this.withTimeout(this.deps.demux(bytes));
|
|
16950
17473
|
if (this.disposed || token !== this.seekToken) return null;
|
|
16951
17474
|
let audio = [];
|
|
16952
|
-
if (this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
|
|
17475
|
+
if (wantAudio && this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
|
|
16953
17476
|
try {
|
|
16954
17477
|
audio = await this.withTimeout(this.deps.demuxAudio(bytes));
|
|
16955
17478
|
} catch {
|
|
@@ -16999,59 +17522,6 @@ var RecordedFeeder = class {
|
|
|
16999
17522
|
}
|
|
17000
17523
|
};
|
|
17001
17524
|
//#endregion
|
|
17002
|
-
//#region src/stream-broker/recorded/control-protocol.ts
|
|
17003
|
-
var ControlMessageSchema = discriminatedUnion("t", [
|
|
17004
|
-
object({
|
|
17005
|
-
t: literal("playRecorded"),
|
|
17006
|
-
deviceId: number(),
|
|
17007
|
-
epoch: number(),
|
|
17008
|
-
profile: string()
|
|
17009
|
-
}),
|
|
17010
|
-
object({
|
|
17011
|
-
t: literal("seek"),
|
|
17012
|
-
epoch: number()
|
|
17013
|
-
}),
|
|
17014
|
-
object({
|
|
17015
|
-
t: literal("scrubCommit"),
|
|
17016
|
-
epoch: number()
|
|
17017
|
-
}),
|
|
17018
|
-
object({
|
|
17019
|
-
t: literal("setRate"),
|
|
17020
|
-
rate: union([literal(0), literal(1)])
|
|
17021
|
-
}),
|
|
17022
|
-
object({ t: literal("goLive") }),
|
|
17023
|
-
object({
|
|
17024
|
-
t: literal("setProfile"),
|
|
17025
|
-
profile: string()
|
|
17026
|
-
})
|
|
17027
|
-
]);
|
|
17028
|
-
var ServerMessageSchema = discriminatedUnion("t", [object({
|
|
17029
|
-
t: literal("state"),
|
|
17030
|
-
state: _enum([
|
|
17031
|
-
"live",
|
|
17032
|
-
"loading",
|
|
17033
|
-
"playing",
|
|
17034
|
-
"paused",
|
|
17035
|
-
"gap",
|
|
17036
|
-
"ended",
|
|
17037
|
-
"error"
|
|
17038
|
-
])
|
|
17039
|
-
}), object({
|
|
17040
|
-
t: literal("position"),
|
|
17041
|
-
epochMs: number()
|
|
17042
|
-
})]);
|
|
17043
|
-
function parseControlMessage(raw) {
|
|
17044
|
-
try {
|
|
17045
|
-
const parsed = ControlMessageSchema.safeParse(JSON.parse(raw));
|
|
17046
|
-
return parsed.success ? parsed.data : null;
|
|
17047
|
-
} catch {
|
|
17048
|
-
return null;
|
|
17049
|
-
}
|
|
17050
|
-
}
|
|
17051
|
-
function serializeServerMessage(msg) {
|
|
17052
|
-
return JSON.stringify(ServerMessageSchema.parse(msg));
|
|
17053
|
-
}
|
|
17054
|
-
//#endregion
|
|
17055
17525
|
//#region src/stream-broker/recorded/timeline-session.ts
|
|
17056
17526
|
/**
|
|
17057
17527
|
* Per-viewer coordinator over one broker WebRTC session. Owns the playback mode
|
|
@@ -17084,6 +17554,9 @@ var TimelineSession = class {
|
|
|
17084
17554
|
case "setRate":
|
|
17085
17555
|
this.feeder?.setRate(msg.rate);
|
|
17086
17556
|
break;
|
|
17557
|
+
case "stepFrame":
|
|
17558
|
+
if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
|
|
17559
|
+
break;
|
|
17087
17560
|
case "setProfile":
|
|
17088
17561
|
this.profile = msg.profile;
|
|
17089
17562
|
if (this.mode === "recorded") this.enterRecorded();
|