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