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