@camstack/addon-provider-reolink 1.2.158 → 1.2.159

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/addon.js CHANGED
@@ -5390,7 +5390,7 @@ var ZodIssueCode = {
5390
5390
  var ZodFirstPartyTypeKind;
5391
5391
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5392
5392
  //#endregion
5393
- //#region ../types/dist/sleep-BVhJDJka.mjs
5393
+ //#region ../types/dist/sleep-CUmLTud7.mjs
5394
5394
  /**
5395
5395
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5396
5396
  * window to float samples (D455).
@@ -76815,7 +76815,7 @@ var require_lz4 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports) => {
76815
76815
  };
76816
76816
  }));
76817
76817
  //#endregion
76818
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-5PJ6XPBF.js
76818
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-GNC5U3YA.js
76819
76819
  var import_fxp = require_fxp();
76820
76820
  var import_lz4 = /* @__PURE__ */ require_chunk.__toESM(require_lz4(), 1);
76821
76821
  function encodeHeader(h) {
@@ -80980,6 +80980,7 @@ var BaichuanClient = class _BaichuanClient extends events.EventEmitter {
80980
80980
  lockedChannelId = frame.header.channelId;
80981
80981
  lockedStreamType = frame.header.streamType;
80982
80982
  }
80983
+ params.onChunk?.(decrypted);
80983
80984
  chunks.push(decrypted);
80984
80985
  armIdleFinish();
80985
80986
  if (frame.header.responseCode === 201) finish(Buffer.concat(chunks));
@@ -92986,7 +92987,8 @@ ${stderr}`));
92986
92987
  payloadXml,
92987
92988
  streamType: 0,
92988
92989
  timeoutMs,
92989
- ...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {}
92990
+ ...params.idleTimeoutMs != null ? { idleTimeoutMs: params.idleTimeoutMs } : {},
92991
+ ...params.onChunk ? { onChunk: params.onChunk } : {}
92990
92992
  });
92991
92993
  } catch (e) {
92992
92994
  trace(`download failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -149050,6 +149052,123 @@ function createClipPlaybackHandler(deps) {
149050
149052
  }).pipe(res);
149051
149053
  };
149052
149054
  }
149055
+ /**
149056
+ * `/addon/provider-reolink/clip-stream/<deviceId>/<clipKey>.mp4` — the same
149057
+ * recording as `clip/…`, served as it ARRIVES from the camera.
149058
+ *
149059
+ * **Not `Range`-able, and it says so.** The response has no length: it is a
149060
+ * fragmented MP4 written by an ffmpeg that is still reading the camera when
149061
+ * the first byte leaves. So there is no `content-length`, `accept-ranges` is
149062
+ * `none`, a `Range` header is IGNORED (a 206 over something with no length
149063
+ * would be a lie), and the peer may not cache it (`no-store`) — a stream is
149064
+ * not an immutable artifact, the file route is. The status line is written
149065
+ * on the first muxed byte, never before: until then every refusal still owns
149066
+ * it, and a camera that serves nothing answers a status with a reason, not an
149067
+ * empty 200 (D391).
149068
+ *
149069
+ * `HEAD` answers the headers and touches no camera.
149070
+ */
149071
+ var CLIP_STREAM_PREFIX = "clip-stream";
149072
+ var STREAM_HEADERS = {
149073
+ "content-type": "video/mp4",
149074
+ "accept-ranges": "none",
149075
+ "cache-control": "no-store",
149076
+ ["x-camstack-clip-transport"]: "forward-only"
149077
+ };
149078
+ /** The status a refusal answers. `404` = nothing here to serve; `409` = a
149079
+ * clip that exists and cannot be served right now; `502` = the camera. */
149080
+ function streamRefusalStatus(code) {
149081
+ switch (code) {
149082
+ case "unknown-device":
149083
+ case "no-catalog-row": return 404;
149084
+ case "sleeping":
149085
+ case "uid-missing":
149086
+ case "clip-in-progress": return 409;
149087
+ case "camera-refused":
149088
+ case "fetch-failed": return 502;
149089
+ }
149090
+ }
149091
+ function createClipStreamHandler(deps) {
149092
+ return async (req, res) => {
149093
+ if (req.method !== "GET" && req.method !== "HEAD") {
149094
+ res.writeHead(405, { allow: "GET, HEAD" });
149095
+ res.end();
149096
+ return;
149097
+ }
149098
+ const match = MEDIA_PATH.exec(pathnameOf(req));
149099
+ if (match === null) {
149100
+ res.writeHead(400);
149101
+ res.end();
149102
+ return;
149103
+ }
149104
+ const deviceId = Number(match[1]);
149105
+ const clipKey = match[2] ?? "";
149106
+ if (req.method === "HEAD") {
149107
+ res.writeHead(200, STREAM_HEADERS);
149108
+ res.end();
149109
+ return;
149110
+ }
149111
+ if (req.headers.range !== void 0) deps.logger.debug("videoclips: a Range header on the clip stream route is ignored", {
149112
+ tags: { deviceId },
149113
+ meta: {
149114
+ clipKey,
149115
+ range: req.headers.range
149116
+ }
149117
+ });
149118
+ const sink = {
149119
+ begin: () => {
149120
+ res.writeHead(200, STREAM_HEADERS);
149121
+ },
149122
+ write: (chunk) => res.write(chunk),
149123
+ onceDrain: (listener) => {
149124
+ res.once("drain", listener);
149125
+ },
149126
+ onceClose: (listener) => {
149127
+ res.once("close", listener);
149128
+ },
149129
+ end: () => {
149130
+ res.end();
149131
+ },
149132
+ destroy: () => {
149133
+ res.destroy();
149134
+ }
149135
+ };
149136
+ let outcome;
149137
+ try {
149138
+ outcome = await deps.stream(deviceId, clipKey, sink);
149139
+ } catch (err) {
149140
+ deps.logger.warn("videoclips: the clip stream route threw", {
149141
+ tags: { deviceId },
149142
+ meta: {
149143
+ clipKey,
149144
+ branch: "stream-threw",
149145
+ error: err instanceof Error ? err.message : String(err)
149146
+ }
149147
+ });
149148
+ if (res.headersSent) res.destroy();
149149
+ else {
149150
+ res.writeHead(500);
149151
+ res.end();
149152
+ }
149153
+ return;
149154
+ }
149155
+ if (outcome.kind !== "refused") return;
149156
+ deps.logger.info("videoclips: clip stream not served", {
149157
+ tags: { deviceId },
149158
+ meta: {
149159
+ clipKey,
149160
+ code: outcome.code,
149161
+ reason: outcome.detail,
149162
+ status: streamRefusalStatus(outcome.code)
149163
+ }
149164
+ });
149165
+ res.writeHead(streamRefusalStatus(outcome.code), {
149166
+ [CLIP_STILL_REASON_HEADER]: outcome.detail.slice(0, 500),
149167
+ [CLIP_STILL_REASON_CODE_HEADER]: outcome.code
149168
+ });
149169
+ res.end();
149170
+ };
149171
+ }
149053
149172
  //#endregion
149054
149173
  //#region src/clips/clip-mux.ts
149055
149174
  /** A clip that has not transferred by here is not going to. */
@@ -149207,6 +149326,94 @@ function describeMuxFailure(run) {
149207
149326
  if (run.code !== 0) return `mux-failed (exit ${String(run.code)}): ${run.stderr.slice(0, 300)}`;
149208
149327
  return null;
149209
149328
  }
149329
+ /** The fragmented container a stream is muxed into — see the file header. */
149330
+ var CLIP_STREAM_MOVFLAGS = "frag_keyframe+empty_moov+default_base_moof";
149331
+ /**
149332
+ * The rate the probe measured, through the library's one derivation.
149333
+ *
149334
+ * `estimateVideoTiming` falls back to the info header when the timestamps
149335
+ * cannot say; that fallback is REFUSED here (`fpsSource: 'unmeasured'`), as
149336
+ * D567 says, so a guess is never handed to `-r` under a measurement's name.
149337
+ */
149338
+ function probeClipStreamRate(timestampsUs) {
149339
+ const timing = estimateVideoTiming({ timestampsUs });
149340
+ if (timing.fpsSource !== "timestamps" || timing.fps === null) return {
149341
+ fps: null,
149342
+ fpsSource: "unmeasured",
149343
+ accessUnits: timestampsUs.length
149344
+ };
149345
+ return {
149346
+ fps: timing.fps,
149347
+ fpsSource: "timestamps",
149348
+ accessUnits: timestampsUs.length
149349
+ };
149350
+ }
149351
+ /**
149352
+ * One ffmpeg, stdin to stdout, video only, fragmented MP4.
149353
+ *
149354
+ * Mirrors `buildClipMuxArgs` where a stream can: the demuxer is named from
149355
+ * the DETECTED codec (the BcMedia header lies on a hub child), `-r` is an
149356
+ * input option before its own `-i`, and the video is copied. Where it
149357
+ * cannot — `+faststart`, the audio input — the file header says why.
149358
+ */
149359
+ function buildClipStreamMuxArgs(input) {
149360
+ const demuxer = input.videoType === "H265" ? "hevc" : "h264";
149361
+ return [
149362
+ "-hide_banner",
149363
+ "-loglevel",
149364
+ "error",
149365
+ "-nostdin",
149366
+ ...input.fps !== null && Number.isFinite(input.fps) && input.fps > 0 ? ["-r", String(input.fps)] : [],
149367
+ "-f",
149368
+ demuxer,
149369
+ "-i",
149370
+ "pipe:0",
149371
+ "-c:v",
149372
+ "copy",
149373
+ "-movflags",
149374
+ CLIP_STREAM_MOVFLAGS,
149375
+ "-f",
149376
+ "mp4",
149377
+ "pipe:1"
149378
+ ];
149379
+ }
149380
+ /**
149381
+ * The D447 rule as a state machine, so it can be proved without a socket.
149382
+ *
149383
+ * A `false` write ARMS the timer; further `false` writes while it is armed do
149384
+ * not push the deadline out. `drain` — the hop's own word that it took what
149385
+ * it held — disarms it. Stall fires at most once, and a disposed gate is
149386
+ * silent for ever.
149387
+ */
149388
+ function createDrainGate(deps) {
149389
+ let handle = null;
149390
+ let stalled = false;
149391
+ const disarm = () => {
149392
+ if (handle !== null) clearTimeout(handle);
149393
+ handle = null;
149394
+ };
149395
+ return {
149396
+ wrote(accepted) {
149397
+ if (accepted || handle !== null || stalled) return;
149398
+ handle = setTimeout(() => {
149399
+ handle = null;
149400
+ if (stalled) return;
149401
+ stalled = true;
149402
+ deps.onStall();
149403
+ }, deps.timeoutMs);
149404
+ },
149405
+ drained() {
149406
+ disarm();
149407
+ },
149408
+ waiting() {
149409
+ return handle !== null;
149410
+ },
149411
+ dispose() {
149412
+ disarm();
149413
+ stalled = true;
149414
+ }
149415
+ };
149416
+ }
149210
149417
  //#endregion
149211
149418
  //#region src/clips/clip-truncation.ts
149212
149419
  /**
@@ -149253,6 +149460,500 @@ function judgeClipDuration(input) {
149253
149460
  };
149254
149461
  }
149255
149462
  //#endregion
149463
+ //#region src/clips/clip-stream.ts
149464
+ var defaultDecoder = (handlers) => new require_chunk_ZBACPERX.BcMediaAnnexBDecoder({
149465
+ strict: false,
149466
+ onVideoAccessUnit: handlers.onVideoAccessUnit,
149467
+ onAudioFrame: () => handlers.onAudioFrame()
149468
+ });
149469
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
149470
+ function createClipStreamProducer(deps) {
149471
+ const sleep = deps.sleep ?? defaultSleep;
149472
+ const createDecoder = deps.createDecoder ?? defaultDecoder;
149473
+ const drainTimeoutMs = deps.drainTimeoutMs ?? 1e4;
149474
+ const flushTimeoutMs = deps.flushTimeoutMs ?? 15e3;
149475
+ const lanes = /* @__PURE__ */ new Map();
149476
+ const activeByHost = /* @__PURE__ */ new Map();
149477
+ const runAttempt = (input, attempt) => new ClipStreamRun({
149478
+ logger: deps.logger,
149479
+ spawnMux: deps.spawnMux,
149480
+ createDecoder,
149481
+ drainTimeoutMs,
149482
+ flushTimeoutMs
149483
+ }, input, attempt).run();
149484
+ const runAttempts = async (input) => {
149485
+ const { deviceId } = input.device;
149486
+ let lastDetail = "no reason given";
149487
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
149488
+ if (attempt > 1) await sleep(500 * 2 ** (attempt - 2));
149489
+ const result = await runAttempt(input, attempt);
149490
+ if (result.kind !== "no-media") return result;
149491
+ lastDetail = result.detail;
149492
+ deps.logger.warn("videoclips: a clip stream produced no media — retrying", {
149493
+ tags: { deviceId },
149494
+ meta: {
149495
+ clipKey: input.row.clipKey,
149496
+ twin: input.twin,
149497
+ branch: "stream-no-media",
149498
+ attempt,
149499
+ error: lastDetail
149500
+ }
149501
+ });
149502
+ }
149503
+ deps.logger.warn("videoclips: the camera never delivered this clip stream", {
149504
+ tags: { deviceId },
149505
+ meta: {
149506
+ clipKey: input.row.clipKey,
149507
+ twin: input.twin,
149508
+ branch: "camera-refused",
149509
+ attempts: 3,
149510
+ error: lastDetail
149511
+ }
149512
+ });
149513
+ return {
149514
+ kind: "refused",
149515
+ code: "camera-refused",
149516
+ detail: `clip stream never landed after 3 attempts — ${lastDetail}`,
149517
+ attempts: 3
149518
+ };
149519
+ };
149520
+ return {
149521
+ active: (hostKey) => activeByHost.get(hostKey) ?? 0,
149522
+ stream: (input) => {
149523
+ const { hostKey, deviceId } = input.device;
149524
+ const ahead = activeByHost.get(hostKey) ?? 0;
149525
+ if (ahead > 0) deps.logger.info("videoclips: a clip stream is queued behind another on this host", {
149526
+ tags: { deviceId },
149527
+ meta: {
149528
+ clipKey: input.row.clipKey,
149529
+ twin: input.twin,
149530
+ branch: "stream-queued",
149531
+ ahead
149532
+ }
149533
+ });
149534
+ activeByHost.set(hostKey, ahead + 1);
149535
+ const run = (lanes.get(hostKey) ?? Promise.resolve()).then(() => runAttempts(input)).finally(() => {
149536
+ const left = (activeByHost.get(hostKey) ?? 1) - 1;
149537
+ if (left <= 0) activeByHost.delete(hostKey);
149538
+ else activeByHost.set(hostKey, left);
149539
+ });
149540
+ lanes.set(hostKey, run.then(() => void 0, () => void 0));
149541
+ return run;
149542
+ }
149543
+ };
149544
+ }
149545
+ /**
149546
+ * One transfer: camera → decoder → ffmpeg → peer, with a cut on every hop.
149547
+ *
149548
+ * A class because the run is a state machine with a dozen fields the chunk
149549
+ * callback and the download promise both touch; passing them around as
149550
+ * closures over `let`s was less readable, not more immutable.
149551
+ */
149552
+ var ClipStreamRun = class {
149553
+ deps;
149554
+ input;
149555
+ attempt;
149556
+ accessUnits = 0;
149557
+ audioFrames = 0;
149558
+ timestampsUs = [];
149559
+ /** Access units held until the rate is probed — the frames in flight. */
149560
+ probe = [];
149561
+ mux = null;
149562
+ muxStderr = "";
149563
+ muxClosed = null;
149564
+ rate = {
149565
+ fps: null,
149566
+ fpsSource: "unmeasured",
149567
+ accessUnits: 0
149568
+ };
149569
+ videoType = null;
149570
+ begun = false;
149571
+ ended = false;
149572
+ bytesOut = 0;
149573
+ cut = null;
149574
+ stdinGate;
149575
+ peerGate;
149576
+ tags;
149577
+ constructor(deps, input, attempt) {
149578
+ this.deps = deps;
149579
+ this.input = input;
149580
+ this.attempt = attempt;
149581
+ this.tags = { deviceId: input.device.deviceId };
149582
+ this.stdinGate = createDrainGate({
149583
+ timeoutMs: deps.drainTimeoutMs,
149584
+ onStall: () => this.sever("mux-stalled", "ffmpeg stopped taking access units")
149585
+ });
149586
+ this.peerGate = createDrainGate({
149587
+ timeoutMs: deps.drainTimeoutMs,
149588
+ onStall: () => this.sever("peer-stalled", "the peer stopped taking bytes")
149589
+ });
149590
+ }
149591
+ async run() {
149592
+ const { device, row, fileName, twin, sink } = this.input;
149593
+ sink.onceClose(() => {
149594
+ if (!this.ended) this.sever("peer-closed", "the peer closed the connection");
149595
+ });
149596
+ const decoder = this.deps.createDecoder({
149597
+ onVideoAccessUnit: (unit) => this.onAccessUnit(unit),
149598
+ onAudioFrame: () => {
149599
+ this.audioFrames += 1;
149600
+ }
149601
+ });
149602
+ this.deps.logger.info("videoclips: streaming a clip file from the camera", {
149603
+ tags: this.tags,
149604
+ meta: {
149605
+ clipKey: row.clipKey,
149606
+ twin,
149607
+ fileName,
149608
+ startMs: row.startMs,
149609
+ endMs: row.endMs,
149610
+ attempt: this.attempt,
149611
+ idleTimeoutMs: 300
149612
+ }
149613
+ });
149614
+ let api;
149615
+ try {
149616
+ api = await device.getApi();
149617
+ } catch (err) {
149618
+ return {
149619
+ kind: "no-media",
149620
+ detail: describe(err)
149621
+ };
149622
+ }
149623
+ try {
149624
+ await api.fileInfoListReplayBinaryDownload({
149625
+ channel: device.channel,
149626
+ ...device.uid !== void 0 ? { uid: device.uid } : {},
149627
+ fileName,
149628
+ idleTimeoutMs: 300,
149629
+ onChunk: (chunk) => this.onChunk(decoder, chunk)
149630
+ });
149631
+ } catch (err) {
149632
+ return this.onTransferFailed(err);
149633
+ }
149634
+ return this.onTransferEnded();
149635
+ }
149636
+ /** The camera's bytes, as they arrive. A cut run THROWS here: the only stop. */
149637
+ onChunk(decoder, chunk) {
149638
+ if (this.cut !== null) throw new Error(`clip stream cut: ${this.cut.reason}`);
149639
+ try {
149640
+ decoder.push(chunk);
149641
+ } catch (err) {
149642
+ this.sever("decode-failed", describe(err));
149643
+ throw err;
149644
+ }
149645
+ }
149646
+ onAccessUnit(unit) {
149647
+ if (this.cut !== null) return;
149648
+ this.accessUnits += 1;
149649
+ this.timestampsUs.push(unit.microseconds);
149650
+ if (this.mux === null) {
149651
+ this.probe = [...this.probe, unit];
149652
+ if (this.probe.length >= 16) this.startMux();
149653
+ return;
149654
+ }
149655
+ this.feed(unit.annexB);
149656
+ }
149657
+ /** Spawn ffmpeg with the probed rate and hand it what was held. */
149658
+ startMux() {
149659
+ const first = this.probe[0];
149660
+ if (first === void 0 || this.mux !== null) return;
149661
+ this.videoType = first.videoType;
149662
+ this.rate = probeClipStreamRate(this.timestampsUs);
149663
+ const { row, twin } = this.input;
149664
+ if (this.rate.fpsSource !== "timestamps") this.deps.logger.warn("videoclips: no measured frame rate for this clip stream", {
149665
+ tags: this.tags,
149666
+ meta: {
149667
+ clipKey: row.clipKey,
149668
+ twin,
149669
+ branch: "fps-unmeasured",
149670
+ probeAccessUnits: this.rate.accessUnits
149671
+ }
149672
+ });
149673
+ let mux;
149674
+ try {
149675
+ mux = this.deps.spawnMux(buildClipStreamMuxArgs({
149676
+ videoType: this.videoType,
149677
+ fps: this.rate.fps
149678
+ }));
149679
+ } catch (err) {
149680
+ this.sever("mux-failed", `ffmpeg could not be spawned: ${describe(err)}`);
149681
+ return;
149682
+ }
149683
+ this.mux = mux;
149684
+ this.muxClosed = new Promise((resolve) => {
149685
+ mux.on("close", (code) => resolve(code));
149686
+ mux.on("error", (err) => {
149687
+ this.sever("mux-failed", `ffmpeg failed: ${err.message}`);
149688
+ resolve(null);
149689
+ });
149690
+ });
149691
+ mux.stderr?.on("data", (chunk) => {
149692
+ if (this.muxStderr.length < 2e3) this.muxStderr += chunk.toString("utf8");
149693
+ });
149694
+ mux.stdin?.on("error", (err) => this.sever("mux-failed", `ffmpeg stdin: ${err.message}`));
149695
+ mux.stdin?.on("drain", () => this.stdinGate.drained());
149696
+ mux.stdout?.on("data", (chunk) => this.onMuxOut(chunk));
149697
+ mux.stdout?.on("end", () => this.finishSink());
149698
+ this.deps.logger.info("videoclips: a clip stream is muxing", {
149699
+ tags: this.tags,
149700
+ meta: {
149701
+ clipKey: row.clipKey,
149702
+ twin,
149703
+ videoType: this.videoType,
149704
+ fps: this.rate.fps,
149705
+ fpsSource: this.rate.fpsSource,
149706
+ probeAccessUnits: this.rate.accessUnits
149707
+ }
149708
+ });
149709
+ const held = this.probe;
149710
+ this.probe = [];
149711
+ for (const unit of held) this.feed(unit.annexB);
149712
+ }
149713
+ feed(annexB) {
149714
+ if (this.cut !== null || this.mux?.stdin === null || this.mux?.stdin === void 0) return;
149715
+ this.stdinGate.wrote(this.mux.stdin.write(annexB));
149716
+ }
149717
+ /** ffmpeg's output, to the peer, honouring `write()`'s return (D447). */
149718
+ onMuxOut(chunk) {
149719
+ if (this.cut !== null) return;
149720
+ const { sink } = this.input;
149721
+ if (!this.begun) {
149722
+ this.begun = true;
149723
+ sink.begin();
149724
+ }
149725
+ const accepted = sink.write(chunk);
149726
+ this.bytesOut += chunk.byteLength;
149727
+ this.peerGate.wrote(accepted);
149728
+ if (accepted) return;
149729
+ const stdout = this.mux?.stdout;
149730
+ stdout?.pause();
149731
+ sink.onceDrain(() => {
149732
+ this.peerGate.drained();
149733
+ stdout?.resume();
149734
+ });
149735
+ }
149736
+ finishSink() {
149737
+ if (this.cut !== null || this.ended) return;
149738
+ this.ended = true;
149739
+ this.input.sink.end();
149740
+ }
149741
+ /** Sever every hop, once, and say why. */
149742
+ sever(reason, detail) {
149743
+ if (this.cut !== null) return;
149744
+ this.cut = {
149745
+ reason,
149746
+ detail
149747
+ };
149748
+ this.stdinGate.dispose();
149749
+ this.peerGate.dispose();
149750
+ this.mux?.kill("SIGKILL");
149751
+ this.deps.logger.warn("videoclips: a clip stream was cut", {
149752
+ tags: this.tags,
149753
+ meta: {
149754
+ clipKey: this.input.row.clipKey,
149755
+ twin: this.input.twin,
149756
+ branch: reason,
149757
+ detail,
149758
+ accessUnits: this.accessUnits,
149759
+ bytesOut: this.bytesOut,
149760
+ begun: this.begun
149761
+ }
149762
+ });
149763
+ if (this.begun) this.input.sink.destroy();
149764
+ }
149765
+ cutOutcome(cut) {
149766
+ if (this.begun) return {
149767
+ kind: "cut",
149768
+ reason: cut.reason,
149769
+ detail: cut.detail,
149770
+ bytesOut: this.bytesOut
149771
+ };
149772
+ return {
149773
+ kind: "refused",
149774
+ code: "fetch-failed",
149775
+ detail: `${cut.reason}: ${cut.detail}`,
149776
+ attempts: this.attempt
149777
+ };
149778
+ }
149779
+ onTransferFailed(err) {
149780
+ if (this.cut !== null) return this.cutOutcome(this.cut);
149781
+ if (this.accessUnits === 0) return {
149782
+ kind: "no-media",
149783
+ detail: describe(err)
149784
+ };
149785
+ this.sever("camera-failed", describe(err));
149786
+ return this.cutOutcome({
149787
+ reason: "camera-failed",
149788
+ detail: describe(err)
149789
+ });
149790
+ }
149791
+ async onTransferEnded() {
149792
+ if (this.cut !== null) return this.cutOutcome(this.cut);
149793
+ if (this.accessUnits === 0) return {
149794
+ kind: "no-media",
149795
+ detail: "the camera answered the transfer with no access unit"
149796
+ };
149797
+ if (this.mux === null) this.startMux();
149798
+ const cutAtStart = this.cut;
149799
+ if (cutAtStart !== null) return this.cutOutcome(cutAtStart);
149800
+ const mux = this.mux;
149801
+ if (mux === null || this.muxClosed === null) return this.cutOutcome({
149802
+ reason: "mux-failed",
149803
+ detail: "no mux after the transfer ended"
149804
+ });
149805
+ mux.stdin?.end();
149806
+ const flush = setTimeout(() => this.sever("mux-hung", `ffmpeg did not exit ${this.deps.flushTimeoutMs} ms after its input ended`), this.deps.flushTimeoutMs);
149807
+ const code = await this.muxClosed;
149808
+ clearTimeout(flush);
149809
+ const cutAfter = this.cut;
149810
+ if (cutAfter !== null) return this.cutOutcome(cutAfter);
149811
+ if (code !== 0) {
149812
+ this.sever("mux-failed", `ffmpeg exited ${String(code)}: ${this.muxStderr.slice(0, 300)}`);
149813
+ return this.cutOutcome({
149814
+ reason: "mux-failed",
149815
+ detail: this.muxStderr.slice(0, 300)
149816
+ });
149817
+ }
149818
+ this.finishSink();
149819
+ return this.served();
149820
+ }
149821
+ /** The stream landed: judge it against the row, out loud, and answer. */
149822
+ served() {
149823
+ const { row, twin } = this.input;
149824
+ const timing = estimateVideoTiming({ timestampsUs: this.timestampsUs });
149825
+ const durationSeconds = timing.fpsSource === "timestamps" ? timing.durationSeconds : null;
149826
+ const verdict = judgeClipDuration({
149827
+ expectedMs: row.endMs - row.startMs,
149828
+ decodedSeconds: durationSeconds,
149829
+ ...row.inProgress === true ? { inProgress: true } : {}
149830
+ });
149831
+ const meta = {
149832
+ clipKey: row.clipKey,
149833
+ twin,
149834
+ attempt: this.attempt,
149835
+ accessUnits: this.accessUnits,
149836
+ bytesOut: this.bytesOut,
149837
+ durationSeconds,
149838
+ expectedMs: row.endMs - row.startMs,
149839
+ fps: this.rate.fps,
149840
+ fpsSource: this.rate.fpsSource,
149841
+ videoType: this.videoType
149842
+ };
149843
+ if (verdict.kind === "short") this.deps.logger.warn("videoclips: a clip stream came up short against its row", {
149844
+ tags: this.tags,
149845
+ meta: {
149846
+ ...meta,
149847
+ branch: "stream-short",
149848
+ shortfallSeconds: verdict.shortfallSeconds,
149849
+ retried: false
149850
+ }
149851
+ });
149852
+ else if (verdict.kind === "unknown") this.deps.logger.warn("videoclips: a clip stream could not be checked for truncation", {
149853
+ tags: this.tags,
149854
+ meta: {
149855
+ ...meta,
149856
+ branch: "completeness-unverified",
149857
+ reason: verdict.reason
149858
+ }
149859
+ });
149860
+ else this.deps.logger.info("videoclips: a clip stream landed whole", {
149861
+ tags: this.tags,
149862
+ meta: {
149863
+ ...meta,
149864
+ branch: "stream-served"
149865
+ }
149866
+ });
149867
+ if (this.audioFrames > 0) this.deps.logger.warn("videoclips: a clip stream was served without sound", {
149868
+ tags: this.tags,
149869
+ meta: {
149870
+ clipKey: row.clipKey,
149871
+ twin,
149872
+ branch: "stream-video-only",
149873
+ audioFramesDropped: this.audioFrames
149874
+ }
149875
+ });
149876
+ return {
149877
+ kind: "served",
149878
+ accessUnits: this.accessUnits,
149879
+ audioFramesDropped: this.audioFrames,
149880
+ bytesOut: this.bytesOut,
149881
+ durationSeconds,
149882
+ verdict,
149883
+ rate: this.rate,
149884
+ attempts: this.attempt
149885
+ };
149886
+ }
149887
+ };
149888
+ function describe(err) {
149889
+ return err instanceof Error ? err.message : String(err);
149890
+ }
149891
+ //#endregion
149892
+ //#region src/clips/clip-stream-route.ts
149893
+ function createClipStreamRoute(deps) {
149894
+ return async (deviceId, clipKey, sink) => {
149895
+ const twin = "sub";
149896
+ const gate = await deps.gate({
149897
+ deviceId,
149898
+ clipKey,
149899
+ twin,
149900
+ path: "stream"
149901
+ });
149902
+ if (gate.kind === "refused") return {
149903
+ kind: "refused",
149904
+ code: gate.branch,
149905
+ detail: gate.detail
149906
+ };
149907
+ const row = deps.rowFor(deviceId, clipKey);
149908
+ if (row === null) {
149909
+ deps.logger.warn("videoclips: no catalog row for this clip stream", {
149910
+ tags: { deviceId },
149911
+ meta: {
149912
+ clipKey,
149913
+ twin,
149914
+ branch: "no-catalog-row"
149915
+ }
149916
+ });
149917
+ return {
149918
+ kind: "refused",
149919
+ code: "no-catalog-row",
149920
+ detail: "no-catalog-row: list this day before asking for its stream"
149921
+ };
149922
+ }
149923
+ if (row.inProgress === true) {
149924
+ deps.logger.warn("videoclips: refusing to stream a clip the camera is still writing", {
149925
+ tags: { deviceId },
149926
+ meta: {
149927
+ clipKey,
149928
+ twin,
149929
+ branch: "clip-in-progress"
149930
+ }
149931
+ });
149932
+ return {
149933
+ kind: "refused",
149934
+ code: "clip-in-progress",
149935
+ detail: "clip-in-progress: the camera is still writing this file; it has no end to stream to"
149936
+ };
149937
+ }
149938
+ const outcome = await deps.producer.stream({
149939
+ device: gate.device,
149940
+ row,
149941
+ fileName: row.subFileName,
149942
+ twin,
149943
+ sink
149944
+ });
149945
+ switch (outcome.kind) {
149946
+ case "served": return { kind: "served" };
149947
+ case "cut": return { kind: "cut" };
149948
+ case "refused": return {
149949
+ kind: "refused",
149950
+ code: outcome.code,
149951
+ detail: outcome.detail
149952
+ };
149953
+ }
149954
+ };
149955
+ }
149956
+ //#endregion
149256
149957
  //#region src/clips/clip-thumb-queue.ts
149257
149958
  /**
149258
149959
  * The provider's OWN CoverPreview lane — two queues, one in flight per host.
@@ -149432,6 +150133,59 @@ function decideClipPrefetch(input) {
149432
150133
  return { kind: "run" };
149433
150134
  }
149434
150135
  //#endregion
150136
+ //#region src/clips/clip-window.ts
150137
+ /**
150138
+ * Does this row overlap the window?
150139
+ *
150140
+ * Half-open on both ends (`start < until`, `end > since`), so a clip that ends
150141
+ * exactly when the window begins is OUT and a clip that begins exactly when it
150142
+ * ends is OUT — otherwise two adjacent windows both claim the same boundary
150143
+ * clip and a surface paging through them renders it twice.
150144
+ *
150145
+ * A row whose `endMs` is not after its `startMs` is treated as an INSTANT at
150146
+ * `startMs`. That is not defensive padding: a clip still being written carries
150147
+ * a frozen `endMs` from the camera's index (D563), and on this fleet that has
150148
+ * been observed equal to — and once behind — its own start. Judging such a row
150149
+ * by a bad end would drop a recording that is happening right now.
150150
+ */
150151
+ function clipOverlapsWindow(row, window) {
150152
+ const end = row.endMs > row.startMs ? row.endMs : row.startMs;
150153
+ return row.startMs < window.until && end > window.since;
150154
+ }
150155
+ /**
150156
+ * What the provider ANSWERS with — the one gate, so there is a single place
150157
+ * that decides what leaves this listing and a single line that reports it.
150158
+ *
150159
+ * ## Why a clip still being written is not listed
150160
+ *
150161
+ * The operator, 2026-09-22: *"Niente clips in preparazione, non ha senso
150162
+ * visualizzarle."* He is right, and the reasons had been accumulating:
150163
+ *
150164
+ * - its `endMs` is a frozen guess — the camera's index keeps whatever it held
150165
+ * when the file was opened, measured on 592 as the same `endTime` for eight
150166
+ * minutes (D563) — so the row's duration, its position on the axis and its
150167
+ * height are all wrong and all move;
150168
+ * - `readClipBytes` already refuses it by name (`clip-in-progress`): it has no
150169
+ * end to export against, so the completion rule has nothing to prove the
150170
+ * fetch finished (D568);
150171
+ * - it cannot be looped, and the surface has to explain why (`resolveClipLoop`);
150172
+ * - the player route would file a TRUNCATED copy under an immutable key, so
150173
+ * the first seconds become permanently what that clip is.
150174
+ *
150175
+ * Four different places bending around a row that cannot be played properly.
150176
+ * Not listing it deletes all four questions instead of answering them: the
150177
+ * clip appears, whole, on the next listing after the camera closes the file.
150178
+ */
150179
+ function listableClips(rows, window) {
150180
+ const inWindow = rows.filter((row) => clipOverlapsWindow(row, window));
150181
+ const kept = inWindow.filter((row) => row.inProgress !== true);
150182
+ return {
150183
+ kept,
150184
+ outOfWindow: rows.length - inWindow.length,
150185
+ inProgress: inWindow.length - kept.length
150186
+ };
150187
+ }
150188
+ //#endregion
149435
150189
  //#region src/clips/clip-identity.ts
149436
150190
  /**
149437
150191
  * A Reolink clip's IDENTITY — the id the operator's surface carries around and
@@ -150204,7 +150958,24 @@ function createReolinkVideoclipsProvider(deps) {
150204
150958
  state: "ok",
150205
150959
  catalogAsOf: deps.now()
150206
150960
  });
150207
- return rows;
150961
+ for (const row of rows) rowsByKey.set(`${device.deviceId}|${row.clipKey}`, row);
150962
+ const narrowed = listableClips(rows, {
150963
+ since,
150964
+ until
150965
+ });
150966
+ if (narrowed.outOfWindow > 0 || narrowed.inProgress > 0) deps.logger.info("videoclips: narrowed a day listing to what it can answer", {
150967
+ tags,
150968
+ meta: {
150969
+ branch: "listing-narrowed",
150970
+ listed: rows.length,
150971
+ kept: narrowed.kept.length,
150972
+ outOfWindow: narrowed.outOfWindow,
150973
+ inProgress: narrowed.inProgress,
150974
+ since,
150975
+ until
150976
+ }
150977
+ });
150978
+ return narrowed.kept;
150208
150979
  };
150209
150980
  /**
150210
150981
  * Give a day's rows a class when the FILES carried none.
@@ -151112,9 +151883,15 @@ function createClipService(deps) {
151112
151883
  * clip-export vocabulary, so the provider can hand the engine a token it
151113
151884
  * believes rather than prose it has to guess at (D572).
151114
151885
  */
151115
- const produceTwin = async (input) => {
151116
- const { deviceId, row, fileName, twin } = input;
151117
- const clipKey = row.clipKey;
151886
+ /**
151887
+ * The gates every clip byte path runs, in ONE place: the device, the
151888
+ * sleeping standalone, the uid. The file producer and the stream producer
151889
+ * both call this and neither decides any of it again — a second copy of
151890
+ * the sleeping gate is a second chance to wake a camera nobody authorised.
151891
+ * Every refusal is logged HERE, with its branch, so a caller cannot forget.
151892
+ */
151893
+ const gateClipDevice = async (input) => {
151894
+ const { deviceId, clipKey, twin, path } = input;
151118
151895
  const tags = { deviceId };
151119
151896
  const device = await deps.resolveDevice(deviceId);
151120
151897
  if (device === null) {
@@ -151123,11 +151900,13 @@ function createClipService(deps) {
151123
151900
  meta: {
151124
151901
  clipKey,
151125
151902
  twin,
151903
+ path,
151126
151904
  branch: "unknown-device"
151127
151905
  }
151128
151906
  });
151129
151907
  return {
151130
151908
  kind: "refused",
151909
+ branch: "unknown-device",
151131
151910
  code: "catalog-unreachable",
151132
151911
  detail: `device ${deviceId} is not a Reolink camera on this node`
151133
151912
  };
@@ -151139,11 +151918,13 @@ function createClipService(deps) {
151139
151918
  meta: {
151140
151919
  clipKey,
151141
151920
  twin,
151921
+ path,
151142
151922
  branch: "sleeping"
151143
151923
  }
151144
151924
  });
151145
151925
  return {
151146
151926
  kind: "refused",
151927
+ branch: "sleeping",
151147
151928
  code: "sleeping",
151148
151929
  detail: `device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it, and no operator authorisation was carried`
151149
151930
  };
@@ -151153,6 +151934,7 @@ function createClipService(deps) {
151153
151934
  meta: {
151154
151935
  clipKey,
151155
151936
  twin,
151937
+ path,
151156
151938
  branch: "wake-authorised"
151157
151939
  }
151158
151940
  });
@@ -151163,15 +151945,39 @@ function createClipService(deps) {
151163
151945
  meta: {
151164
151946
  clipKey,
151165
151947
  twin,
151948
+ path,
151166
151949
  branch: "uid-missing"
151167
151950
  }
151168
151951
  });
151169
151952
  return {
151170
151953
  kind: "refused",
151954
+ branch: "uid-missing",
151171
151955
  code: "catalog-unreachable",
151172
151956
  detail: UID_MISSING_REASON
151173
151957
  };
151174
151958
  }
151959
+ return {
151960
+ kind: "ok",
151961
+ device
151962
+ };
151963
+ };
151964
+ const produceTwin = async (input) => {
151965
+ const { deviceId, row, fileName, twin } = input;
151966
+ const clipKey = row.clipKey;
151967
+ const tags = { deviceId };
151968
+ const gate = await gateClipDevice({
151969
+ deviceId,
151970
+ clipKey,
151971
+ twin,
151972
+ path: "file",
151973
+ ...input.wake !== void 0 ? { wake: input.wake } : {}
151974
+ });
151975
+ if (gate.kind === "refused") return {
151976
+ kind: "refused",
151977
+ code: gate.code,
151978
+ detail: gate.detail
151979
+ };
151980
+ const { device } = gate;
151175
151981
  let downloaded;
151176
151982
  deps.logger.info("videoclips: fetching a clip file from the camera", {
151177
151983
  tags,
@@ -151349,6 +152155,23 @@ function createClipService(deps) {
151349
152155
  if (outcome.kind === "ok" && twin === "sub") await media.put(deviceId, row.clipKey, outcome.bytes);
151350
152156
  return outcome;
151351
152157
  };
152158
+ /**
152159
+ * The STREAM's path (`clip-stream-route.ts`): the same gates, the same row,
152160
+ * nothing written. Beside the file path, not in its place.
152161
+ */
152162
+ const streamClip = createClipStreamRoute({
152163
+ logger: deps.logger,
152164
+ gate: gateClipDevice,
152165
+ rowFor,
152166
+ producer: createClipStreamProducer({
152167
+ logger: deps.logger,
152168
+ spawnMux: (args) => (0, node_child_process.spawn)(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
152169
+ "pipe",
152170
+ "pipe",
152171
+ "pipe"
152172
+ ] })
152173
+ })
152174
+ });
151352
152175
  const provider = createReolinkVideoclipsProvider({
151353
152176
  now,
151354
152177
  cache,
@@ -151378,6 +152201,11 @@ function createClipService(deps) {
151378
152201
  hostBusy: (hostKey) => queue.busy(hostKey),
151379
152202
  thumbPrefix: CLIP_THUMB_PREFIX,
151380
152203
  mediaPrefix: CLIP_MEDIA_PREFIX,
152204
+ streamPrefix: CLIP_STREAM_PREFIX,
152205
+ streamHandler: createClipStreamHandler({
152206
+ stream: streamClip,
152207
+ logger: deps.logger
152208
+ }),
151381
152209
  thumbHandler: createClipThumbHandler({
151382
152210
  readCached: (deviceId, clipKey) => thumbs.read(deviceId, clipKey),
151383
152211
  mint: (deviceId, clipKey) => mintThumbnail(deviceId, clipKey, "interactive"),
@@ -165617,11 +166445,18 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
165617
166445
  access: "authenticated",
165618
166446
  handler: service.playbackHandler
165619
166447
  });
166448
+ const stream = await this.ctx.dataPlane?.serve({
166449
+ prefix: service.streamPrefix,
166450
+ access: "authenticated",
166451
+ handler: service.streamHandler
166452
+ });
165620
166453
  this.ctx.logger.info("Reolink clip data planes served", { meta: {
165621
166454
  thumbPath: `/addon/${REOLINK_ADDON_ID}/${service.thumbPrefix}`,
165622
166455
  mediaPath: `/addon/${REOLINK_ADDON_ID}/${service.mediaPrefix}`,
166456
+ streamPath: `/addon/${REOLINK_ADDON_ID}/${service.streamPrefix}`,
165623
166457
  thumbServed: thumb !== void 0,
165624
- mediaServed: media !== void 0
166458
+ mediaServed: media !== void 0,
166459
+ streamServed: stream !== void 0
165625
166460
  } });
165626
166461
  } catch (err) {
165627
166462
  this.ctx.logger.warn("Reolink clip data planes failed to serve — clips will not render", { meta: { error: err instanceof Error ? err.message : String(err) } });