@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 +843 -8
- package/dist/addon.mjs +843 -8
- package/package.json +2 -2
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
151116
|
-
|
|
151117
|
-
|
|
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) } });
|