@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.mjs
CHANGED
|
@@ -5385,7 +5385,7 @@ var ZodIssueCode = {
|
|
|
5385
5385
|
var ZodFirstPartyTypeKind;
|
|
5386
5386
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5387
5387
|
//#endregion
|
|
5388
|
-
//#region ../types/dist/sleep-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
151111
|
-
|
|
151112
|
-
|
|
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) } });
|