@camstack/addon-pipeline 1.2.7 → 1.2.8
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 +3 -3
- package/dist/audio-analyzer/index.mjs +2 -3
- package/dist/{chunk-D6vf50IK.js → chunk-emK7D4bc.js} +7 -0
- package/dist/detection-pipeline/index.js +104 -8
- package/dist/detection-pipeline/index.mjs +103 -8
- package/dist/{dist-CAT3VF7A.js → dist-DNjy_sr4.js} +1239 -1058
- package/dist/{dist-BwyGTnMu.mjs → dist-owmpaeIY.mjs} +867 -680
- package/dist/fmp4-keyframes-BzTPoPVn.js +6676 -0
- package/dist/fmp4-keyframes-qOgJBjMO.mjs +6669 -0
- package/dist/{sheet-geometry-DcpuGTCm.mjs → hub-hostname-DAJXlOgV.js} +6 -39
- package/dist/{sheet-geometry-BR-ip0CP.js → hub-hostname-cCknRYKj.mjs} +1 -56
- package/dist/{model-download-service-Cp9f4dk6-CwUb5v3Y.mjs → model-download-service-Cp9f4dk6-WO-foD7W.mjs} +26 -1
- package/dist/{model-download-service-Cp9f4dk6-Dv-oFwjN.js → model-download-service-Cp9f4dk6-fsdDExML.js} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +40 -126
- package/dist/pipeline-runner/index.mjs +38 -124
- package/dist/recorder/index.js +695 -974
- package/dist/recorder/index.mjs +694 -972
- package/dist/session-decode/decode-worker-child.js +9 -5
- package/dist/session-decode/decode-worker-child.mjs +8 -4
- package/dist/{step-definitions-BPEExRug.js → step-definitions-C2bNlww3.js} +1 -1
- package/dist/{step-definitions-DN1obXS7.mjs → step-definitions-CkvctwuS.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BtN6sZcL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-XdI0RgvI.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DSfbOjdi.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-BdxY9zld.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js--zEZtV2N.mjs} +1 -1
- package/dist/stream-broker/{hostInit-BIZvt2bk.mjs → hostInit-DsMIdlwP.mjs} +2 -2
- package/dist/stream-broker/index.js +646 -10
- package/dist/stream-broker/index.mjs +645 -10
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-3uL2OmvC.js → MaskShapeCanvas-DI4BY7W2-CftzR7Pj.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-JkIkJTeI.js → MotionZonesSettings-NcxxQN8r-DPgVIdEn.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-AKGABxQk.js → PrivacyMaskSettings-APgPLF7p-D0HqiXHW.js} +1 -1
- package/embed-dist/assets/{index-DfcKIHhA.js → index-DRtbV_en.js} +17 -15
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/python/postprocessors/ctc.py +14 -5
- package/python/postprocessors/test_ctc.py +35 -0
- package/dist/chunk-BdkLduGY.mjs +0 -5
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D34ivtL2.mjs +0 -26
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { l as __require, n as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-WO-foD7W.mjs";
|
|
2
|
+
import { B as webrtcSessionCapability, G as DeviceType, H as BaseAddon, J as isEvent, K as createEvent, O as maskUrlCredentials, Q as parseProfileBrokerId, R as streamBrokerCapability, U as CAM_PROFILE_ORDER, V as errMsg, W as DeviceFeature, X as makeSourceBrokerId, Y as makeProfileBrokerId, ct as object, dt as union, ft as EventCategory, g as addonWidgetsSourceCapability, it as discriminatedUnion, j as nodePin, l as EncodeProfileSchema, lt as record, m as RingBuffer, nt as array, ot as literal, rt as boolean, st as number, tt as _enum, ut as string, y as cameraStreamsCapability } from "../dist-owmpaeIY.mjs";
|
|
3
3
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { t as extractKeyframes } from "../fmp4-keyframes-qOgJBjMO.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
@@ -13572,6 +13572,15 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
13572
13572
|
* time without an activation handshake.
|
|
13573
13573
|
*/
|
|
13574
13574
|
brokers = /* @__PURE__ */ new Map();
|
|
13575
|
+
/**
|
|
13576
|
+
* Devices whose `deviceManager` meta says `disabled:true` — mirrored by the
|
|
13577
|
+
* SAME `reconcileAllCatalogs` poll that already reads `listAll` (the D8
|
|
13578
|
+
* reconcile backstop; no event plumbing). While a device is in this set,
|
|
13579
|
+
* `ensureBroker` refuses (re)creation so a retrying audio/frame subscriber
|
|
13580
|
+
* cannot resurrect a released broker, and the reconcile releases any broker
|
|
13581
|
+
* that still exists. Cleared by the same poll on re-enable.
|
|
13582
|
+
*/
|
|
13583
|
+
disabledDevices = /* @__PURE__ */ new Set();
|
|
13575
13584
|
/** profileBrokerId (`${deviceId}/${profile}`) → sourceBrokerId currently aliased. */
|
|
13576
13585
|
profileRestreamerBindings = /* @__PURE__ */ new Map();
|
|
13577
13586
|
/** Per-device published cam-stream registry. */
|
|
@@ -14186,6 +14195,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14186
14195
|
* unchanged behavior.
|
|
14187
14196
|
*/
|
|
14188
14197
|
async ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId, tier) {
|
|
14198
|
+
if (this.disabledDevices.has(deviceId)) return;
|
|
14189
14199
|
if (this.brokers.has(brokerId)) return;
|
|
14190
14200
|
if (this.remoteBrokerAdapters.has(brokerId)) return;
|
|
14191
14201
|
if (camStreamId === void 0) return;
|
|
@@ -14610,7 +14620,33 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14610
14620
|
return;
|
|
14611
14621
|
}
|
|
14612
14622
|
const cameras = devices.filter((d) => d.isCamera || d.type === DeviceType.Camera);
|
|
14613
|
-
|
|
14623
|
+
for (const d of cameras) if (d.disabled) {
|
|
14624
|
+
const firstSeen = !this.disabledDevices.has(d.id);
|
|
14625
|
+
this.disabledDevices.add(d.id);
|
|
14626
|
+
await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
|
|
14627
|
+
} else this.disabledDevices.delete(d.id);
|
|
14628
|
+
await Promise.allSettled(cameras.filter((d) => !d.disabled).map((d) => this.reconcileDeviceCatalog(d.id)));
|
|
14629
|
+
}
|
|
14630
|
+
/** Release every live broker of a disabled device (idempotent — later poll
|
|
14631
|
+
* ticks find none and no-op). `logTransition` marks the disable EDGE. */
|
|
14632
|
+
async releaseDisabledDeviceBrokers(deviceId, logTransition) {
|
|
14633
|
+
const streams = this.cameraStreams.get(deviceId);
|
|
14634
|
+
if (!streams) return;
|
|
14635
|
+
const live = Array.from(streams.keys()).filter((camStreamId) => this.brokers.has(brokerIdFor(deviceId, camStreamId)));
|
|
14636
|
+
if (live.length === 0) return;
|
|
14637
|
+
if (logTransition || live.length > 0) this.logger.info("device disabled — releasing its brokers (no dial while disabled)", {
|
|
14638
|
+
tags: { deviceId },
|
|
14639
|
+
meta: { camStreamIds: live }
|
|
14640
|
+
});
|
|
14641
|
+
for (const camStreamId of live) await this.releaseBroker(deviceId, camStreamId).catch((err) => {
|
|
14642
|
+
this.logger.warn("disabled-device broker release failed", {
|
|
14643
|
+
tags: {
|
|
14644
|
+
deviceId,
|
|
14645
|
+
camStreamId
|
|
14646
|
+
},
|
|
14647
|
+
meta: { error: errMsg(err) }
|
|
14648
|
+
});
|
|
14649
|
+
});
|
|
14614
14650
|
}
|
|
14615
14651
|
/**
|
|
14616
14652
|
* Startup catalog pull: run a full reconcile, then a bounded number of extra
|
|
@@ -16354,6 +16390,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16354
16390
|
}
|
|
16355
16391
|
}
|
|
16356
16392
|
async ensureBroker(deviceId, camStreamId) {
|
|
16393
|
+
if (this.disabledDevices.has(deviceId)) return;
|
|
16357
16394
|
const cam = this.cameraStreams.get(deviceId)?.get(camStreamId);
|
|
16358
16395
|
if (!cam) return;
|
|
16359
16396
|
const brokerId = brokerIdFor(deviceId, camStreamId);
|
|
@@ -16904,6 +16941,11 @@ var ControlMessageSchema = discriminatedUnion("t", [
|
|
|
16904
16941
|
t: literal("scrubCommit"),
|
|
16905
16942
|
epoch: number()
|
|
16906
16943
|
}),
|
|
16944
|
+
object({ t: literal("scrubStart") }),
|
|
16945
|
+
object({
|
|
16946
|
+
t: literal("scrubTo"),
|
|
16947
|
+
epoch: number()
|
|
16948
|
+
}),
|
|
16907
16949
|
object({
|
|
16908
16950
|
t: literal("setRate"),
|
|
16909
16951
|
rate: number().min(0).max(4)
|
|
@@ -17052,6 +17094,31 @@ var WAIT_TICK_MS = 250;
|
|
|
17052
17094
|
* instead of merging the two feeds.
|
|
17053
17095
|
*/
|
|
17054
17096
|
var SEEK_DISCONTINUITY_MS = 250;
|
|
17097
|
+
/**
|
|
17098
|
+
* Coalescing window for feeder-scrub `scrubTo` (experimental). Pointer-move
|
|
17099
|
+
* targets arrive far faster than a segment demux completes; the feeder collapses
|
|
17100
|
+
* them to at most one keyframe push per this window (≈ 16 pushes/s), always
|
|
17101
|
+
* pushing only the LATEST pending target (latest-wins). The FIRST move fires a
|
|
17102
|
+
* leading-edge tick immediately; subsequent moves ride the pending timer.
|
|
17103
|
+
*
|
|
17104
|
+
* Tightened 100 → 60 ms: same-fragment moves are memo/cache hits (zero demux) so
|
|
17105
|
+
* the window is now the dominant per-push cost inside a fragment, and 60 ms is
|
|
17106
|
+
* sustainable there — cross-fragment moves remain paced by the demux + the
|
|
17107
|
+
* one-in-flight guard, so this cannot spawn parallel demuxes.
|
|
17108
|
+
*/
|
|
17109
|
+
var SCRUB_COALESCE_MS = 60;
|
|
17110
|
+
/**
|
|
17111
|
+
* Upper bound on the per-scrub-session fragment cache (LRU by segment `startMs`).
|
|
17112
|
+
* A demuxed keyframe set is one AU per fragment, so 8 fragments is a few KB;
|
|
17113
|
+
* back-and-forth drags over this window push instantly with zero re-demux.
|
|
17114
|
+
*/
|
|
17115
|
+
var SCRUB_CACHE_MAX = 8;
|
|
17116
|
+
/**
|
|
17117
|
+
* Consecutive same-direction `scrubTo` deltas required before a direction-aware
|
|
17118
|
+
* prefetch commits its (single) background slot. Two deltas ⇒ the drag shows a
|
|
17119
|
+
* sustained direction, so a jittery/reversing drag never wastes the slot.
|
|
17120
|
+
*/
|
|
17121
|
+
var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
|
|
17055
17122
|
var RecordedFeeder = class {
|
|
17056
17123
|
deps;
|
|
17057
17124
|
clock = new MonotonicClock();
|
|
@@ -17082,6 +17149,61 @@ var RecordedFeeder = class {
|
|
|
17082
17149
|
* the pre-seek position when the client changes rate right after a seek.
|
|
17083
17150
|
*/
|
|
17084
17151
|
inFlightSeekEpochMs = null;
|
|
17152
|
+
/**
|
|
17153
|
+
* EXPERIMENTAL feeder-scrub sub-mode. While true the normal advance/feed loop
|
|
17154
|
+
* is suspended and each coalesced `scrubTick` pushes the covering fragment's
|
|
17155
|
+
* nearest keyframe ≤ the drag target. `scrubToken` is the MODE-EXIT guard (not
|
|
17156
|
+
* a per-move token): it is bumped on `beginScrub`, on the commit `seek`, and on
|
|
17157
|
+
* `dispose`, so an in-flight tick whose captured generation no longer matches
|
|
17158
|
+
* drops itself. Latest-wins across rapid moves is handled by COALESCING —
|
|
17159
|
+
* `scrubPendingEpochMs` always holds the newest target, and one tick per
|
|
17160
|
+
* `SCRUB_COALESCE_MS` window processes it.
|
|
17161
|
+
*/
|
|
17162
|
+
scrubbing = false;
|
|
17163
|
+
scrubToken = 0;
|
|
17164
|
+
/** The newest un-processed drag target (null when the tick cycle has drained). */
|
|
17165
|
+
scrubPendingEpochMs = null;
|
|
17166
|
+
/**
|
|
17167
|
+
* The active coalescing timer. Non-null means a tick cycle is live (scheduled
|
|
17168
|
+
* OR running its async load) — it is the single re-entrancy guard, so a burst
|
|
17169
|
+
* of `scrubTo` calls coalesce onto `scrubPendingEpochMs` instead of spawning
|
|
17170
|
+
* parallel demuxes.
|
|
17171
|
+
*/
|
|
17172
|
+
scrubTimer = null;
|
|
17173
|
+
/**
|
|
17174
|
+
* The fragment demuxed by the last tick (same-fragment memo): consecutive drag
|
|
17175
|
+
* targets inside ONE fragment reuse these access units instead of re-running
|
|
17176
|
+
* readBytes/demux (ffmpeg) per pointer move. Reset on fragment change + exit.
|
|
17177
|
+
*/
|
|
17178
|
+
scrubSegment = null;
|
|
17179
|
+
/**
|
|
17180
|
+
* Per-scrub-session LRU of demuxed keyframe sets, keyed by segment `startMs`
|
|
17181
|
+
* (Map insertion order IS the LRU order; a hit re-inserts to mark it most
|
|
17182
|
+
* recent). Held ACROSS re-entrant drags in the same session — recorded footage
|
|
17183
|
+
* is immutable, so a revisited fragment (back-and-forth drag) pushes instantly
|
|
17184
|
+
* with zero re-demux. Bounded to {@link SCRUB_CACHE_MAX}; cleared only on the
|
|
17185
|
+
* commit `seek`, `dispose`, and goLive teardown (all of which dispose/reset the
|
|
17186
|
+
* feeder), never on `beginScrub`.
|
|
17187
|
+
*/
|
|
17188
|
+
scrubCache = /* @__PURE__ */ new Map();
|
|
17189
|
+
/**
|
|
17190
|
+
* The last drag target a `scrubTick` PROCESSED (null before the first). The
|
|
17191
|
+
* sign of `(newTarget − this)` is the drag direction that steers prefetch.
|
|
17192
|
+
*/
|
|
17193
|
+
scrubPrevTarget = null;
|
|
17194
|
+
/**
|
|
17195
|
+
* Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
|
|
17196
|
+
* − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
|
|
17197
|
+
* a reversal or an in-fragment no-move resets it.
|
|
17198
|
+
*/
|
|
17199
|
+
scrubDirRun = 0;
|
|
17200
|
+
/**
|
|
17201
|
+
* The SECOND bounded concurrency slot: `true` while a direction-aware prefetch
|
|
17202
|
+
* demux is in flight. At most one prefetch runs at a time and it only fills
|
|
17203
|
+
* `scrubCache` — it never pushes, never touches the pending/timer/latest-wins
|
|
17204
|
+
* state, and never blocks the live coalesced tick.
|
|
17205
|
+
*/
|
|
17206
|
+
scrubPrefetchInFlight = false;
|
|
17085
17207
|
constructor(deps) {
|
|
17086
17208
|
this.deps = deps;
|
|
17087
17209
|
}
|
|
@@ -17093,6 +17215,18 @@ var RecordedFeeder = class {
|
|
|
17093
17215
|
*/
|
|
17094
17216
|
async seek(epochMs) {
|
|
17095
17217
|
const token = ++this.seekToken;
|
|
17218
|
+
if (this.scrubbing) {
|
|
17219
|
+
this.scrubbing = false;
|
|
17220
|
+
this.scrubToken++;
|
|
17221
|
+
this.scrubSegment = null;
|
|
17222
|
+
this.scrubCache.clear();
|
|
17223
|
+
this.scrubPrevTarget = null;
|
|
17224
|
+
this.scrubDirRun = 0;
|
|
17225
|
+
if (this.scrubTimer) {
|
|
17226
|
+
clearTimeout(this.scrubTimer);
|
|
17227
|
+
this.scrubTimer = null;
|
|
17228
|
+
}
|
|
17229
|
+
}
|
|
17096
17230
|
this.advanceToken++;
|
|
17097
17231
|
this.inFlightSeekEpochMs = epochMs;
|
|
17098
17232
|
try {
|
|
@@ -17171,6 +17305,200 @@ var RecordedFeeder = class {
|
|
|
17171
17305
|
this.seek(this.inFlightSeekEpochMs ?? this.cursorMs);
|
|
17172
17306
|
}
|
|
17173
17307
|
/**
|
|
17308
|
+
* EXPERIMENTAL feeder-scrub: enter the scrub sub-mode. Aborts any in-flight
|
|
17309
|
+
* seek/advance loop (latest-wins, exactly like `seek()` does up-front) and
|
|
17310
|
+
* suspends the ~1 Hz position timer — during a drag position is reported per
|
|
17311
|
+
* pushed keyframe instead. `this.rate` is preserved for the post-commit resume
|
|
17312
|
+
* (the commit `seek` re-enters at the same rate).
|
|
17313
|
+
*
|
|
17314
|
+
* Idempotent WITHIN a drag: a second `beginScrub` while already scrubbing is a
|
|
17315
|
+
* NO-OP. The app may re-send `scrubStart` mid-gesture (the observed repeated
|
|
17316
|
+
* `scrubStart` storm), and resetting the token/coalescing state on each re-send
|
|
17317
|
+
* would abort in-flight keyframe work and restart the drag — the exact stutter
|
|
17318
|
+
* this guards against. The ONLY exit from scrub mode is the commit `seek()`
|
|
17319
|
+
* (which flips `scrubbing` false and clears the session cache) or `dispose()`;
|
|
17320
|
+
* a genuinely NEW drag is therefore always preceded by that exit, so its
|
|
17321
|
+
* `beginScrub` runs the full reset below. (D-scrub-ux)
|
|
17322
|
+
*/
|
|
17323
|
+
beginScrub() {
|
|
17324
|
+
if (this.disposed || this.scrubbing) return;
|
|
17325
|
+
this.seekToken++;
|
|
17326
|
+
this.advanceToken++;
|
|
17327
|
+
this.scrubbing = true;
|
|
17328
|
+
this.scrubToken++;
|
|
17329
|
+
this.scrubPendingEpochMs = null;
|
|
17330
|
+
this.scrubSegment = null;
|
|
17331
|
+
this.scrubPrevTarget = null;
|
|
17332
|
+
this.scrubDirRun = 0;
|
|
17333
|
+
if (this.scrubTimer) {
|
|
17334
|
+
clearTimeout(this.scrubTimer);
|
|
17335
|
+
this.scrubTimer = null;
|
|
17336
|
+
}
|
|
17337
|
+
this.stopPositionReports();
|
|
17338
|
+
}
|
|
17339
|
+
/**
|
|
17340
|
+
* EXPERIMENTAL feeder-scrub: register a drag target. Records the newest target
|
|
17341
|
+
* and, if no tick cycle is live, starts a leading-edge tick. A burst of moves
|
|
17342
|
+
* within the coalescing window collapses onto `scrubPendingEpochMs` (latest-
|
|
17343
|
+
* wins) so only one keyframe is demuxed+pushed per `SCRUB_COALESCE_MS`. A
|
|
17344
|
+
* `scrubTo` without a prior `beginScrub` (or after commit/dispose) is a no-op.
|
|
17345
|
+
*/
|
|
17346
|
+
scrubTo(epochMs) {
|
|
17347
|
+
if (this.disposed || !this.scrubbing) return;
|
|
17348
|
+
this.scrubPendingEpochMs = epochMs;
|
|
17349
|
+
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
17350
|
+
}
|
|
17351
|
+
/**
|
|
17352
|
+
* One coalesced feeder-scrub push. Captures the newest pending target + the
|
|
17353
|
+
* current mode generation, resolves the covering fragment (reusing the memo'd
|
|
17354
|
+
* one when the target stays inside it), picks the nearest keyframe ≤ the
|
|
17355
|
+
* target, stamps a discontinuity, and pushes that single access unit. Re-arms
|
|
17356
|
+
* itself after `SCRUB_COALESCE_MS` while more moves are pending; otherwise
|
|
17357
|
+
* ends the cycle. Video only — no audio during scrub. A gap target holds the
|
|
17358
|
+
* last pushed frame (no push, no error).
|
|
17359
|
+
*/
|
|
17360
|
+
async scrubTick() {
|
|
17361
|
+
const gen = this.scrubToken;
|
|
17362
|
+
const target = this.scrubPendingEpochMs;
|
|
17363
|
+
this.scrubPendingEpochMs = null;
|
|
17364
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || target === null) {
|
|
17365
|
+
this.scrubTimer = null;
|
|
17366
|
+
return;
|
|
17367
|
+
}
|
|
17368
|
+
try {
|
|
17369
|
+
const loc = await this.withTimeout(this.deps.locate({
|
|
17370
|
+
deviceId: this.deps.deviceId,
|
|
17371
|
+
profile: this.deps.profile,
|
|
17372
|
+
epochMs: target
|
|
17373
|
+
}));
|
|
17374
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
17375
|
+
if (loc.kind === "gap") return;
|
|
17376
|
+
let seg = this.scrubSegment;
|
|
17377
|
+
if (!seg || seg.startMs !== loc.startMs) {
|
|
17378
|
+
seg = this.scrubCacheGet(loc.startMs);
|
|
17379
|
+
if (!seg) {
|
|
17380
|
+
const loaded = await this.loadAus(loc.startMs, false, true);
|
|
17381
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
17382
|
+
seg = {
|
|
17383
|
+
startMs: loc.startMs,
|
|
17384
|
+
durMs: loc.durMs,
|
|
17385
|
+
aus: loaded.video
|
|
17386
|
+
};
|
|
17387
|
+
this.scrubCachePut(seg);
|
|
17388
|
+
}
|
|
17389
|
+
this.scrubSegment = seg;
|
|
17390
|
+
}
|
|
17391
|
+
const au = seg.aus[this.pickScrubKeyframe(seg, target)];
|
|
17392
|
+
if (!au) return;
|
|
17393
|
+
if (this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
17394
|
+
this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
17395
|
+
this.clockHasAdvanced = true;
|
|
17396
|
+
this.deps.onPosition(this.clampIntoSegment(seg, target));
|
|
17397
|
+
this.maybeScrubPrefetch(seg, target, gen);
|
|
17398
|
+
} catch {} finally {
|
|
17399
|
+
if (gen !== this.scrubToken) {} else if (!this.disposed && this.scrubbing && this.scrubPendingEpochMs !== null) this.scrubTimer = setTimeout(() => void this.scrubTick(), SCRUB_COALESCE_MS);
|
|
17400
|
+
else this.scrubTimer = null;
|
|
17401
|
+
}
|
|
17402
|
+
}
|
|
17403
|
+
/**
|
|
17404
|
+
* Index of the nearest keyframe at-or-before the drag target within `seg`
|
|
17405
|
+
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
17406
|
+
* guarantees). Scans the access units whose PTS offset is ≤ the target offset
|
|
17407
|
+
* and returns the LAST keyframe among them, so the pushed frame is standalone-
|
|
17408
|
+
* decodable yet as close to the target as the GOP structure allows.
|
|
17409
|
+
*/
|
|
17410
|
+
pickScrubKeyframe(seg, target) {
|
|
17411
|
+
const first = seg.aus[0];
|
|
17412
|
+
if (!first) return 0;
|
|
17413
|
+
const offsetMs = target - seg.startMs;
|
|
17414
|
+
let idx = 0;
|
|
17415
|
+
for (let i = 0; i < seg.aus.length; i++) {
|
|
17416
|
+
const au = seg.aus[i];
|
|
17417
|
+
if (!au) continue;
|
|
17418
|
+
if (au.ptsMs - first.ptsMs > offsetMs) break;
|
|
17419
|
+
if (au.keyframe) idx = i;
|
|
17420
|
+
}
|
|
17421
|
+
return idx;
|
|
17422
|
+
}
|
|
17423
|
+
/**
|
|
17424
|
+
* Scrub fragment cache lookup with LRU touch: a hit is re-inserted so it moves
|
|
17425
|
+
* to the most-recent end of the Map's insertion order (the eviction cursor is
|
|
17426
|
+
* the oldest key).
|
|
17427
|
+
*/
|
|
17428
|
+
scrubCacheGet(startMs) {
|
|
17429
|
+
const seg = this.scrubCache.get(startMs);
|
|
17430
|
+
if (!seg) return null;
|
|
17431
|
+
this.scrubCache.delete(startMs);
|
|
17432
|
+
this.scrubCache.set(startMs, seg);
|
|
17433
|
+
return seg;
|
|
17434
|
+
}
|
|
17435
|
+
/** Insert (or refresh) a demuxed fragment, evicting the oldest over the cap. */
|
|
17436
|
+
scrubCachePut(seg) {
|
|
17437
|
+
this.scrubCache.delete(seg.startMs);
|
|
17438
|
+
this.scrubCache.set(seg.startMs, seg);
|
|
17439
|
+
while (this.scrubCache.size > SCRUB_CACHE_MAX) {
|
|
17440
|
+
const oldest = this.scrubCache.keys().next().value;
|
|
17441
|
+
if (oldest === void 0) break;
|
|
17442
|
+
this.scrubCache.delete(oldest);
|
|
17443
|
+
}
|
|
17444
|
+
}
|
|
17445
|
+
/**
|
|
17446
|
+
* Update the drag-direction run from the just-served target and, once the drag
|
|
17447
|
+
* shows a sustained direction, kick off a single background prefetch of the
|
|
17448
|
+
* NEXT fragment in that direction. Prefetch fills the cache only — it never
|
|
17449
|
+
* pushes, so crossing into the neighbour later is an instant cache hit.
|
|
17450
|
+
*/
|
|
17451
|
+
maybeScrubPrefetch(seg, target, gen) {
|
|
17452
|
+
const prev = this.scrubPrevTarget;
|
|
17453
|
+
this.scrubPrevTarget = target;
|
|
17454
|
+
if (prev === null) {
|
|
17455
|
+
this.scrubDirRun = 0;
|
|
17456
|
+
return;
|
|
17457
|
+
}
|
|
17458
|
+
const dir = target > prev ? 1 : target < prev ? -1 : 0;
|
|
17459
|
+
if (dir === 0) {
|
|
17460
|
+
this.scrubDirRun = 0;
|
|
17461
|
+
return;
|
|
17462
|
+
}
|
|
17463
|
+
this.scrubDirRun = Math.sign(this.scrubDirRun) === dir ? this.scrubDirRun + dir : dir;
|
|
17464
|
+
if (Math.abs(this.scrubDirRun) < SCRUB_PREFETCH_DIR_THRESHOLD) return;
|
|
17465
|
+
if (this.scrubPrefetchInFlight) return;
|
|
17466
|
+
const probeMs = dir === 1 ? seg.startMs + seg.durMs + 1 : seg.startMs - 1;
|
|
17467
|
+
if (probeMs < 0) return;
|
|
17468
|
+
this.scrubPrefetch(probeMs, gen);
|
|
17469
|
+
}
|
|
17470
|
+
/**
|
|
17471
|
+
* Background (second-slot) prefetch: locate + keyframe-only demux the fragment
|
|
17472
|
+
* covering `probeMs` into `scrubCache`. Bounded to one in flight; aborts at any
|
|
17473
|
+
* teardown (`disposed` / left scrub / a bumped `scrubToken`); a failure is
|
|
17474
|
+
* swallowed (the neighbour just demuxes on demand). Never pushes and never
|
|
17475
|
+
* mutates latest-wins state.
|
|
17476
|
+
*/
|
|
17477
|
+
async scrubPrefetch(probeMs, gen) {
|
|
17478
|
+
if (this.scrubPrefetchInFlight) return;
|
|
17479
|
+
this.scrubPrefetchInFlight = true;
|
|
17480
|
+
try {
|
|
17481
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
17482
|
+
const loc = await this.withTimeout(this.deps.locate({
|
|
17483
|
+
deviceId: this.deps.deviceId,
|
|
17484
|
+
profile: this.deps.profile,
|
|
17485
|
+
epochMs: probeMs
|
|
17486
|
+
}));
|
|
17487
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
17488
|
+
if (loc.kind !== "segment") return;
|
|
17489
|
+
if (this.scrubCache.has(loc.startMs)) return;
|
|
17490
|
+
const loaded = await this.loadAus(loc.startMs, false, true);
|
|
17491
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
17492
|
+
this.scrubCachePut({
|
|
17493
|
+
startMs: loc.startMs,
|
|
17494
|
+
durMs: loc.durMs,
|
|
17495
|
+
aus: loaded.video
|
|
17496
|
+
});
|
|
17497
|
+
} catch {} finally {
|
|
17498
|
+
this.scrubPrefetchInFlight = false;
|
|
17499
|
+
}
|
|
17500
|
+
}
|
|
17501
|
+
/**
|
|
17174
17502
|
* Feed exactly one frame forward (+1) or backward (-1). Only meaningful while
|
|
17175
17503
|
* paused over a segment (rate 0, recorded mode) — ignored otherwise. Forward
|
|
17176
17504
|
* at a segment end crosses into the next segment (gaps are skipped via the
|
|
@@ -17182,7 +17510,7 @@ var RecordedFeeder = class {
|
|
|
17182
17510
|
* step is still in flight.
|
|
17183
17511
|
*/
|
|
17184
17512
|
stepFrame(dir) {
|
|
17185
|
-
if (this.disposed || this.rate !== 0 || this.stepInFlight) return;
|
|
17513
|
+
if (this.disposed || this.rate !== 0 || this.stepInFlight || this.scrubbing) return;
|
|
17186
17514
|
this.stepInFlight = true;
|
|
17187
17515
|
this.doStep(dir).catch(() => {}).finally(() => {
|
|
17188
17516
|
this.stepInFlight = false;
|
|
@@ -17509,8 +17837,10 @@ var RecordedFeeder = class {
|
|
|
17509
17837
|
* access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
|
|
17510
17838
|
* (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
|
|
17511
17839
|
* never blocks video). `wantAudio: false` skips the audio transcode entirely
|
|
17512
|
-
* (paused scrubs would throw it away).
|
|
17513
|
-
|
|
17840
|
+
* (paused scrubs would throw it away). `useScrubDemux: true` selects the
|
|
17841
|
+
* keyframe-only scrub demux (`deps.demuxScrub`, falling back to `deps.demux`
|
|
17842
|
+
* when absent — flag-off byte-identity). */
|
|
17843
|
+
async loadAus(startMs, wantAudio = true, useScrubDemux = false) {
|
|
17514
17844
|
const token = this.seekToken;
|
|
17515
17845
|
const bytes = await this.withTimeout(this.deps.readBytes({
|
|
17516
17846
|
deviceId: this.deps.deviceId,
|
|
@@ -17518,7 +17848,8 @@ var RecordedFeeder = class {
|
|
|
17518
17848
|
startMs
|
|
17519
17849
|
}));
|
|
17520
17850
|
if (this.disposed || token !== this.seekToken) return null;
|
|
17521
|
-
const
|
|
17851
|
+
const demux = useScrubDemux && this.deps.demuxScrub ? this.deps.demuxScrub : this.deps.demux;
|
|
17852
|
+
const video = await this.withTimeout(demux(bytes));
|
|
17522
17853
|
if (this.disposed || token !== this.seekToken) return null;
|
|
17523
17854
|
let audio = [];
|
|
17524
17855
|
if (wantAudio && this.deps.demuxAudio && this.deps.sink.pushAudioPcmu) {
|
|
@@ -17567,6 +17898,16 @@ var RecordedFeeder = class {
|
|
|
17567
17898
|
this.disposed = true;
|
|
17568
17899
|
this.seekToken++;
|
|
17569
17900
|
this.advanceToken++;
|
|
17901
|
+
this.scrubbing = false;
|
|
17902
|
+
this.scrubToken++;
|
|
17903
|
+
this.scrubSegment = null;
|
|
17904
|
+
this.scrubCache.clear();
|
|
17905
|
+
this.scrubPrevTarget = null;
|
|
17906
|
+
this.scrubDirRun = 0;
|
|
17907
|
+
if (this.scrubTimer) {
|
|
17908
|
+
clearTimeout(this.scrubTimer);
|
|
17909
|
+
this.scrubTimer = null;
|
|
17910
|
+
}
|
|
17570
17911
|
this.stopPositionReports();
|
|
17571
17912
|
}
|
|
17572
17913
|
};
|
|
@@ -17600,6 +17941,12 @@ var TimelineSession = class {
|
|
|
17600
17941
|
case "scrubCommit":
|
|
17601
17942
|
await (this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).seek(msg.epoch);
|
|
17602
17943
|
break;
|
|
17944
|
+
case "scrubStart":
|
|
17945
|
+
(this.mode === "recorded" && this.feeder ? this.feeder : this.enterRecorded()).beginScrub();
|
|
17946
|
+
break;
|
|
17947
|
+
case "scrubTo":
|
|
17948
|
+
if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
|
|
17949
|
+
break;
|
|
17603
17950
|
case "setRate":
|
|
17604
17951
|
this.feeder?.setRate(msg.rate);
|
|
17605
17952
|
break;
|
|
@@ -17919,7 +18266,7 @@ function demuxMpegTsToAnnexB(ts) {
|
|
|
17919
18266
|
let current = null;
|
|
17920
18267
|
const finalize = () => {
|
|
17921
18268
|
if (current === null) return;
|
|
17922
|
-
const pes = current.length === 1 ? current[0] : concat(current);
|
|
18269
|
+
const pes = current.length === 1 ? current[0] : concat$1(current);
|
|
17923
18270
|
current = null;
|
|
17924
18271
|
const split = splitPes(pes);
|
|
17925
18272
|
if (split === null || split.es.length === 0) return;
|
|
@@ -17950,7 +18297,7 @@ function demuxMpegTsToAnnexB(ts) {
|
|
|
17950
18297
|
};
|
|
17951
18298
|
}
|
|
17952
18299
|
/** Concatenate TS payload fragments into one contiguous PES buffer. */
|
|
17953
|
-
function concat(parts) {
|
|
18300
|
+
function concat$1(parts) {
|
|
17954
18301
|
let total = 0;
|
|
17955
18302
|
for (const p of parts) total += p.length;
|
|
17956
18303
|
const out = new Uint8Array(total);
|
|
@@ -18007,6 +18354,33 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
|
|
|
18007
18354
|
"pipe:1"
|
|
18008
18355
|
];
|
|
18009
18356
|
/**
|
|
18357
|
+
* ffmpeg arguments for the SCRUB keyframe-only demux: same crash-isolated
|
|
18358
|
+
* `-c:v copy` mpegts remux as {@link SEGMENT_DEMUX_FFMPEG_ARGS}, but `-frames:v 1`
|
|
18359
|
+
* stops after the FIRST output access unit — a recorded fragment is a single
|
|
18360
|
+
* IDR-led GOP, so frame 1 is the params-fused keyframe the scrub sub-mode needs
|
|
18361
|
+
* (it never renders a delta frame). This reliably yields exactly the IDR with
|
|
18362
|
+
* its VPS/SPS/PPS fused, whereas input-side `-discard nokey` is a no-op over the
|
|
18363
|
+
* `pipe:0` fragmented-mp4 demuxer (empirically kept 39/40 packets). Exported for
|
|
18364
|
+
* the spec.
|
|
18365
|
+
*/
|
|
18366
|
+
var SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS = [
|
|
18367
|
+
"-hide_banner",
|
|
18368
|
+
"-loglevel",
|
|
18369
|
+
"error",
|
|
18370
|
+
"-i",
|
|
18371
|
+
"pipe:0",
|
|
18372
|
+
"-map",
|
|
18373
|
+
"0:v:0",
|
|
18374
|
+
"-frames:v",
|
|
18375
|
+
"1",
|
|
18376
|
+
"-c:v",
|
|
18377
|
+
"copy",
|
|
18378
|
+
"-an",
|
|
18379
|
+
"-f",
|
|
18380
|
+
"mpegts",
|
|
18381
|
+
"pipe:1"
|
|
18382
|
+
];
|
|
18383
|
+
/**
|
|
18010
18384
|
* Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
|
|
18011
18385
|
* access units.
|
|
18012
18386
|
*
|
|
@@ -18032,6 +18406,253 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
|
18032
18406
|
codec
|
|
18033
18407
|
}));
|
|
18034
18408
|
}
|
|
18409
|
+
/**
|
|
18410
|
+
* Keyframe-only demux for the feeder-scrub sub-mode: remux with `-frames:v 1`
|
|
18411
|
+
* (single-GOP fragment ⇒ the one IDR) and return ONLY keyframe access units.
|
|
18412
|
+
* The scrub path never renders delta frames — it always pushes the nearest
|
|
18413
|
+
* keyframe ≤ the drag target — so extracting only the IDR keeps the per-session
|
|
18414
|
+
* scrub fragment cache tiny (one AU/fragment instead of the full ~40) with no
|
|
18415
|
+
* loss of scrub fidelity.
|
|
18416
|
+
*
|
|
18417
|
+
* Wall time per fragment is dominated by the ffmpeg subprocess spawn, so this is
|
|
18418
|
+
* NOT meaningfully faster per fragment than {@link demuxSegmentToAnnexB}; the
|
|
18419
|
+
* frequency win comes from the feeder's fragment cache + direction-aware
|
|
18420
|
+
* prefetch (which eliminate / overlap the demux), for which this compact
|
|
18421
|
+
* keyframe-only payload is the cache unit.
|
|
18422
|
+
*
|
|
18423
|
+
* Defensive parse-side filter: keeps `keyframe` AUs, falling back to the first
|
|
18424
|
+
* AU (the demux contract's params-fused IDR) if none were flagged, so the result
|
|
18425
|
+
* is keyframes-only yet never empty for a valid fragment.
|
|
18426
|
+
*
|
|
18427
|
+
* @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
|
|
18428
|
+
* @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
|
|
18429
|
+
*/
|
|
18430
|
+
async function demuxSegmentKeyframesToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
18431
|
+
const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
|
|
18432
|
+
ffmpegPath,
|
|
18433
|
+
args: SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS,
|
|
18434
|
+
input: bytes
|
|
18435
|
+
}));
|
|
18436
|
+
const mapped = accessUnits.map((au) => ({
|
|
18437
|
+
data: au.data,
|
|
18438
|
+
ptsMs: au.ptsMs,
|
|
18439
|
+
keyframe: au.keyframe,
|
|
18440
|
+
codec
|
|
18441
|
+
}));
|
|
18442
|
+
const keyframes = mapped.filter((au) => au.keyframe);
|
|
18443
|
+
return keyframes.length > 0 ? keyframes : mapped.slice(0, 1);
|
|
18444
|
+
}
|
|
18445
|
+
//#endregion
|
|
18446
|
+
//#region src/stream-broker/recorded/avcc-annexb.ts
|
|
18447
|
+
/**
|
|
18448
|
+
* avcc-annexb — pure byte helpers to turn an ISO-BMFF (mp4) length-prefixed
|
|
18449
|
+
* video sample plus its `avcC`/`hvcC` decoder config into an Annex-B access
|
|
18450
|
+
* unit with the parameter sets prepended (SPS/PPS for H.264, VPS/SPS/PPS for
|
|
18451
|
+
* H.265).
|
|
18452
|
+
*
|
|
18453
|
+
* mp4 stores VCL NALs length-prefixed (AVCC/HVCC: a 1–4 byte big-endian length
|
|
18454
|
+
* field, `lengthSizeMinusOne+1`, before each NAL) and keeps the parameter sets
|
|
18455
|
+
* OUT of the samples, inside the `avcC`/`hvcC` box. A raw decoder / RTP
|
|
18456
|
+
* packetizer needs Annex-B start codes (`00 00 00 01`) AND the parameter sets
|
|
18457
|
+
* fused onto every IDR. These helpers do exactly that in JS — no ffmpeg, no
|
|
18458
|
+
* decode, byte copies only — so the feeder-scrub keyframe path can skip the
|
|
18459
|
+
* per-fragment ffmpeg spawn.
|
|
18460
|
+
*
|
|
18461
|
+
* All functions are pure and total for well-formed input; malformed config /
|
|
18462
|
+
* sample bytes throw a `RangeError` (the caller treats a throw as "fall back to
|
|
18463
|
+
* ffmpeg"). The `description` bytes are the box payload WITH the 8-byte box
|
|
18464
|
+
* header already stripped (i.e. starting at `configurationVersion`), matching
|
|
18465
|
+
* `mp4box`'s `avcC`/`hvcC` DataStream output minus header — see
|
|
18466
|
+
* `recorder/scrub/fmp4-keyframes.ts` `extractDescription`.
|
|
18467
|
+
*/
|
|
18468
|
+
/** Annex-B 4-byte start code prepended before every emitted NAL. */
|
|
18469
|
+
var START_CODE = Uint8Array.of(0, 0, 0, 1);
|
|
18470
|
+
/** Big-endian unsigned read of `size` (1..4) bytes at `off`. */
|
|
18471
|
+
function readUint(bytes, off, size) {
|
|
18472
|
+
if (off + size > bytes.length) throw new RangeError(`readUint out of range: off=${off} size=${size} len=${bytes.length}`);
|
|
18473
|
+
let v = 0;
|
|
18474
|
+
for (let i = 0; i < size; i++) v = v * 256 + bytes[off + i];
|
|
18475
|
+
return v;
|
|
18476
|
+
}
|
|
18477
|
+
/**
|
|
18478
|
+
* NAL length-prefix size in bytes (`lengthSizeMinusOne + 1`, 1..4) read from the
|
|
18479
|
+
* decoder config. `avcC` carries it at byte 4 (low 2 bits); `hvcC` at byte 21
|
|
18480
|
+
* (low 2 bits).
|
|
18481
|
+
*/
|
|
18482
|
+
function parseNalLengthSize(description, isHevc) {
|
|
18483
|
+
const idx = isHevc ? 21 : 4;
|
|
18484
|
+
if (description.length <= idx) throw new RangeError(`config too short for lengthSizeMinusOne (len=${description.length})`);
|
|
18485
|
+
return (description[idx] & 3) + 1;
|
|
18486
|
+
}
|
|
18487
|
+
/** Concatenate byte chunks into one Uint8Array. */
|
|
18488
|
+
function concat(chunks) {
|
|
18489
|
+
let total = 0;
|
|
18490
|
+
for (const c of chunks) total += c.length;
|
|
18491
|
+
const out = new Uint8Array(total);
|
|
18492
|
+
let off = 0;
|
|
18493
|
+
for (const c of chunks) {
|
|
18494
|
+
out.set(c, off);
|
|
18495
|
+
off += c.length;
|
|
18496
|
+
}
|
|
18497
|
+
return out;
|
|
18498
|
+
}
|
|
18499
|
+
/** Push one Annex-B NAL (start code + payload) onto `chunks`. */
|
|
18500
|
+
function pushNal(chunks, payload) {
|
|
18501
|
+
chunks.push(START_CODE, payload);
|
|
18502
|
+
}
|
|
18503
|
+
/**
|
|
18504
|
+
* Parse the `avcC` parameter sets (SPS then PPS), emitting each as an Annex-B
|
|
18505
|
+
* NAL. Layout after the 4-byte profile/level prefix: byte 4 low-2 = length
|
|
18506
|
+
* size; byte 5 low-5 = numSPS; then `[u16 len][SPS]…`; then `[u8 numPPS]`; then
|
|
18507
|
+
* `[u16 len][PPS]…`.
|
|
18508
|
+
*/
|
|
18509
|
+
function extractAvcParameterSets(desc) {
|
|
18510
|
+
const chunks = [];
|
|
18511
|
+
const numSps = desc[5] & 31;
|
|
18512
|
+
let p = 6;
|
|
18513
|
+
for (let i = 0; i < numSps; i++) {
|
|
18514
|
+
const len = readUint(desc, p, 2);
|
|
18515
|
+
p += 2;
|
|
18516
|
+
if (p + len > desc.length) throw new RangeError("avcC SPS length overruns config");
|
|
18517
|
+
pushNal(chunks, desc.subarray(p, p + len));
|
|
18518
|
+
p += len;
|
|
18519
|
+
}
|
|
18520
|
+
const numPps = readUint(desc, p, 1);
|
|
18521
|
+
p += 1;
|
|
18522
|
+
for (let i = 0; i < numPps; i++) {
|
|
18523
|
+
const len = readUint(desc, p, 2);
|
|
18524
|
+
p += 2;
|
|
18525
|
+
if (p + len > desc.length) throw new RangeError("avcC PPS length overruns config");
|
|
18526
|
+
pushNal(chunks, desc.subarray(p, p + len));
|
|
18527
|
+
p += len;
|
|
18528
|
+
}
|
|
18529
|
+
return chunks;
|
|
18530
|
+
}
|
|
18531
|
+
/**
|
|
18532
|
+
* Parse the `hvcC` parameter-set arrays (typically VPS, SPS, PPS in order),
|
|
18533
|
+
* emitting each contained NAL as Annex-B. Layout: byte 22 = numOfArrays; then
|
|
18534
|
+
* per array `[u8 flags+NALtype][u16 numNalus]` followed by `numNalus` ×
|
|
18535
|
+
* `[u16 len][NAL]`.
|
|
18536
|
+
*/
|
|
18537
|
+
function extractHevcParameterSets(desc) {
|
|
18538
|
+
const chunks = [];
|
|
18539
|
+
const numArrays = readUint(desc, 22, 1);
|
|
18540
|
+
let p = 23;
|
|
18541
|
+
for (let a = 0; a < numArrays; a++) {
|
|
18542
|
+
p += 1;
|
|
18543
|
+
const numNalus = readUint(desc, p, 2);
|
|
18544
|
+
p += 2;
|
|
18545
|
+
for (let n = 0; n < numNalus; n++) {
|
|
18546
|
+
const len = readUint(desc, p, 2);
|
|
18547
|
+
p += 2;
|
|
18548
|
+
if (p + len > desc.length) throw new RangeError("hvcC NAL length overruns config");
|
|
18549
|
+
pushNal(chunks, desc.subarray(p, p + len));
|
|
18550
|
+
p += len;
|
|
18551
|
+
}
|
|
18552
|
+
}
|
|
18553
|
+
return chunks;
|
|
18554
|
+
}
|
|
18555
|
+
/**
|
|
18556
|
+
* Parameter sets from the decoder config as one Annex-B blob (each NAL led by a
|
|
18557
|
+
* `00 00 00 01` start code): SPS+PPS for H.264, VPS+SPS+PPS (+ any hvcC arrays)
|
|
18558
|
+
* for H.265. Throws on a malformed config.
|
|
18559
|
+
*/
|
|
18560
|
+
function extractParameterSetsAnnexB(description, isHevc) {
|
|
18561
|
+
return concat(isHevc ? extractHevcParameterSets(description) : extractAvcParameterSets(description));
|
|
18562
|
+
}
|
|
18563
|
+
/**
|
|
18564
|
+
* Convert one length-prefixed (AVCC/HVCC) sample into Annex-B: walk each
|
|
18565
|
+
* `nalLengthSize`-byte length field, replacing it with a `00 00 00 01` start
|
|
18566
|
+
* code. Handles multi-NAL samples (multiple slices / SEI + VCL). Throws if a
|
|
18567
|
+
* length field overruns the sample.
|
|
18568
|
+
*/
|
|
18569
|
+
function avccSampleToAnnexB(sample, nalLengthSize) {
|
|
18570
|
+
const chunks = [];
|
|
18571
|
+
let p = 0;
|
|
18572
|
+
while (p + nalLengthSize <= sample.length) {
|
|
18573
|
+
const len = readUint(sample, p, nalLengthSize);
|
|
18574
|
+
p += nalLengthSize;
|
|
18575
|
+
if (len === 0) continue;
|
|
18576
|
+
if (p + len > sample.length) throw new RangeError("AVCC NAL length overruns sample");
|
|
18577
|
+
pushNal(chunks, sample.subarray(p, p + len));
|
|
18578
|
+
p += len;
|
|
18579
|
+
}
|
|
18580
|
+
if (chunks.length === 0) throw new RangeError("AVCC sample yielded no NALs");
|
|
18581
|
+
return concat(chunks);
|
|
18582
|
+
}
|
|
18583
|
+
/**
|
|
18584
|
+
* Build a standalone-decodable Annex-B keyframe access unit: the config's
|
|
18585
|
+
* parameter sets, then the sample's NALs, all Annex-B framed. Throws on
|
|
18586
|
+
* malformed input (caller falls back to the ffmpeg demux).
|
|
18587
|
+
*/
|
|
18588
|
+
function buildKeyframeAnnexB(sample, description, isHevc) {
|
|
18589
|
+
const nalLengthSize = parseNalLengthSize(description, isHevc);
|
|
18590
|
+
return concat([extractParameterSetsAnnexB(description, isHevc), avccSampleToAnnexB(sample, nalLengthSize)]);
|
|
18591
|
+
}
|
|
18592
|
+
//#endregion
|
|
18593
|
+
//#region src/stream-broker/recorded/segment-demux-js.ts
|
|
18594
|
+
/**
|
|
18595
|
+
* segment-demux-js — PURE-JS keyframe demux for the feeder-scrub sub-mode.
|
|
18596
|
+
*
|
|
18597
|
+
* Replaces the per-fragment ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB},
|
|
18598
|
+
* a ~140 ms subprocess floor) with an in-process mp4box parse. A recorded `.m4s`
|
|
18599
|
+
* is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
|
|
18600
|
+
* carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
|
|
18601
|
+
* sample(s) are all in the bytes we already hold — no init segment to fetch.
|
|
18602
|
+
*
|
|
18603
|
+
* Flow: reuse {@link extractKeyframes} (the proven mp4box sync-sample demux the
|
|
18604
|
+
* scrub-pack service already ships — box parsing is NOT duplicated here) to get
|
|
18605
|
+
* the codec `description` + length-prefixed sync samples, then
|
|
18606
|
+
* {@link buildKeyframeAnnexB} converts each to a standalone-decodable Annex-B
|
|
18607
|
+
* access unit with the parameter sets fused on. The result matches the
|
|
18608
|
+
* {@link AccessUnit} contract the ffmpeg path produces: `codec` H264|H265,
|
|
18609
|
+
* `keyframe:true`, and `ptsMs` segment-relative (offset from the first sync
|
|
18610
|
+
* sample — the only thing the feeder reads, via `au.ptsMs - first.ptsMs`).
|
|
18611
|
+
*
|
|
18612
|
+
* Anchoring `extractKeyframes(bytes, 0)` makes the first sync sample's `ts` = 0,
|
|
18613
|
+
* so `ptsMs` is a plain segment-relative offset in ms, exactly like the ffmpeg
|
|
18614
|
+
* demux's PES-derived deltas — `clampIntoSegment`/position reporting are
|
|
18615
|
+
* unaffected.
|
|
18616
|
+
*/
|
|
18617
|
+
/** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
|
|
18618
|
+
function isHevcCodec(codec) {
|
|
18619
|
+
return /^h[ev][vc]1\./i.test(codec);
|
|
18620
|
+
}
|
|
18621
|
+
/**
|
|
18622
|
+
* Pure-JS keyframe-only demux of one self-contained `.m4s` fragment. Returns the
|
|
18623
|
+
* fragment's sync-sample access unit(s) as params-fused Annex-B, or `null` if
|
|
18624
|
+
* mp4box found no video / no decodable keyframe (caller falls back to ffmpeg).
|
|
18625
|
+
* Throws only on a genuinely malformed decoder config / sample — the wrapper
|
|
18626
|
+
* ({@link demuxSegmentKeyframesPreferJs}) turns that into an ffmpeg fallback.
|
|
18627
|
+
*/
|
|
18628
|
+
async function demuxSegmentKeyframesJs(bytes) {
|
|
18629
|
+
const extracted = await extractKeyframes(bytes, 0);
|
|
18630
|
+
if (!extracted || extracted.keyframes.length === 0) return null;
|
|
18631
|
+
const isHevc = isHevcCodec(extracted.codec);
|
|
18632
|
+
const codec = isHevc ? "H265" : "H264";
|
|
18633
|
+
return extracted.keyframes.slice(0, 1).map((k) => ({
|
|
18634
|
+
data: buildKeyframeAnnexB(k.data, extracted.description, isHevc),
|
|
18635
|
+
ptsMs: k.ts,
|
|
18636
|
+
keyframe: true,
|
|
18637
|
+
codec
|
|
18638
|
+
}));
|
|
18639
|
+
}
|
|
18640
|
+
/**
|
|
18641
|
+
* Feeder-scrub demux: try the pure-JS keyframe extractor first, fall back to the
|
|
18642
|
+
* ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB}) if the JS parse yields no
|
|
18643
|
+
* keyframe or throws on malformed bytes. Preserves the exact {@link AccessUnit}
|
|
18644
|
+
* contract either way, so it is a drop-in for `RecordedFeederDeps.demuxScrub`.
|
|
18645
|
+
*/
|
|
18646
|
+
async function demuxSegmentKeyframesPreferJs(bytes, opts = {}) {
|
|
18647
|
+
try {
|
|
18648
|
+
const js = await demuxSegmentKeyframesJs(bytes);
|
|
18649
|
+
if (js && js.length > 0) return js;
|
|
18650
|
+
opts.onFfmpegFallback?.("mp4box parse found no keyframe");
|
|
18651
|
+
} catch (err) {
|
|
18652
|
+
opts.onFfmpegFallback?.(`mp4box parse threw: ${String(err)}`);
|
|
18653
|
+
}
|
|
18654
|
+
return demuxSegmentKeyframesToAnnexB(bytes, opts.ffmpegPath);
|
|
18655
|
+
}
|
|
18035
18656
|
//#endregion
|
|
18036
18657
|
//#region src/stream-broker/recorded/segment-audio.ts
|
|
18037
18658
|
/**
|
|
@@ -18906,6 +19527,7 @@ var BrokerWebrtcServer = class {
|
|
|
18906
19527
|
const locateRecorded = this.locateRecordedSegment;
|
|
18907
19528
|
const readRecorded = this.readRecordedSegmentBytes;
|
|
18908
19529
|
const resolveFfmpeg = this.resolveFfmpegPath;
|
|
19530
|
+
let scrubJsFallbackLogged = false;
|
|
18909
19531
|
if (locateRecorded && readRecorded) {
|
|
18910
19532
|
let timeline;
|
|
18911
19533
|
timeline = new TimelineSession({
|
|
@@ -18917,6 +19539,19 @@ var BrokerWebrtcServer = class {
|
|
|
18917
19539
|
locate: locateRecorded,
|
|
18918
19540
|
readBytes: readRecorded,
|
|
18919
19541
|
demux: (b) => demuxSegmentToAnnexB(b, resolveFfmpeg?.()),
|
|
19542
|
+
demuxScrub: (b) => demuxSegmentKeyframesPreferJs(b, {
|
|
19543
|
+
ffmpegPath: resolveFfmpeg?.(),
|
|
19544
|
+
onFfmpegFallback: (reason) => {
|
|
19545
|
+
if (scrubJsFallbackLogged) return;
|
|
19546
|
+
scrubJsFallbackLogged = true;
|
|
19547
|
+
sessionLogger.warn("recorded scrub: JS keyframe demux failed — ffmpeg fallback (once/session)", { meta: {
|
|
19548
|
+
sessionId,
|
|
19549
|
+
deviceId,
|
|
19550
|
+
profile,
|
|
19551
|
+
reason
|
|
19552
|
+
} });
|
|
19553
|
+
}
|
|
19554
|
+
}),
|
|
18920
19555
|
demuxAudio: (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.()),
|
|
18921
19556
|
sink: {
|
|
18922
19557
|
pushAccessUnit: (data, ts, codec) => session.pushRecordedAccessUnit(data, ts, codec),
|