@camstack/addon-export-hap 1.2.132 → 1.2.139
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/hap-export.addon.js +307 -143
- package/dist/hap-export.addon.mjs +307 -143
- package/package.json +1 -1
package/dist/hap-export.addon.js
CHANGED
|
@@ -90670,6 +90670,153 @@ function createClipThumbnailMinter(input) {
|
|
|
90670
90670
|
};
|
|
90671
90671
|
}
|
|
90672
90672
|
//#endregion
|
|
90673
|
+
//#region src/hksv/recording-active.ts
|
|
90674
|
+
/**
|
|
90675
|
+
* `true`/`false` as HomeKit holds it, or `null` when this accessory does not
|
|
90676
|
+
* offer recording at all.
|
|
90677
|
+
*/
|
|
90678
|
+
function readHomekitRecordingActive(accessories) {
|
|
90679
|
+
for (const accessory of accessories) {
|
|
90680
|
+
const service = accessory.getService(import_dist.Service.CameraRecordingManagement);
|
|
90681
|
+
if (service === void 0) continue;
|
|
90682
|
+
const value = service.getCharacteristic(import_dist.Characteristic.Active).value;
|
|
90683
|
+
if (typeof value === "number") return value === 1;
|
|
90684
|
+
if (typeof value === "boolean") return value;
|
|
90685
|
+
return null;
|
|
90686
|
+
}
|
|
90687
|
+
return null;
|
|
90688
|
+
}
|
|
90689
|
+
/**
|
|
90690
|
+
* One sentence for the settings page: what HomeKit says, or why it was never
|
|
90691
|
+
* asked.
|
|
90692
|
+
*
|
|
90693
|
+
* The cost stays in the text even though the switch is gone. It is still the
|
|
90694
|
+
* operator's to understand — a camera recording here runs a permanent ffmpeg —
|
|
90695
|
+
* and a status line that reported only the state would have quietly dropped
|
|
90696
|
+
* the one number that decides whether to turn it on.
|
|
90697
|
+
*/
|
|
90698
|
+
function describeHomekitRecording(active, lastBuild) {
|
|
90699
|
+
if (active === true) return "HomeKit is recording this camera. Turn it off in the Home app (Camera settings → “Stream”), not here — iOS owns this switch and keeps it across reboots. While it is on, this camera runs a permanent prebuffer (~0.7% of one CPU core on the copy branch).";
|
|
90700
|
+
if (active === false) return "This camera offers recording and HomeKit has it off. Turn it on in the Home app (Camera settings → “Stream and Allow Recording”); it needs iCloud+ and a home hub. Nothing runs here until you do.";
|
|
90701
|
+
if (lastBuild !== null && !lastBuild.advertised) return `Recording is not offered for this camera: ${lastBuild.detail ?? "the accessory could not advertise it"}.`;
|
|
90702
|
+
return "Not known yet — this camera’s accessory has not published its recording service in this process. It appears once the accessory is built.";
|
|
90703
|
+
}
|
|
90704
|
+
//#endregion
|
|
90705
|
+
//#region src/hksv/recording-source.ts
|
|
90706
|
+
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
90707
|
+
var MAX_RECORDING_HEIGHT = 1080;
|
|
90708
|
+
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
90709
|
+
var RECORDABLE_CODEC = "h264";
|
|
90710
|
+
/**
|
|
90711
|
+
* Pick the slot the recording child pulls.
|
|
90712
|
+
*
|
|
90713
|
+
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
90714
|
+
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
90715
|
+
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
90716
|
+
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
90717
|
+
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
90718
|
+
*/
|
|
90719
|
+
function pickRecordingSource(entries, options = {}) {
|
|
90720
|
+
const enabled = entries.filter((e) => e.enabled);
|
|
90721
|
+
if (enabled.length === 0) return {
|
|
90722
|
+
ok: false,
|
|
90723
|
+
refusal: "no-enabled-stream"
|
|
90724
|
+
};
|
|
90725
|
+
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
90726
|
+
const pool = h264.length > 0 ? h264 : options.allowTranscode === true ? enabled : [];
|
|
90727
|
+
if (pool.length === 0) return {
|
|
90728
|
+
ok: false,
|
|
90729
|
+
refusal: "no-h264-stream"
|
|
90730
|
+
};
|
|
90731
|
+
const transcode = h264.length === 0;
|
|
90732
|
+
const sized = pool.filter(hasUsableResolution);
|
|
90733
|
+
if (sized.length === 0) return {
|
|
90734
|
+
ok: false,
|
|
90735
|
+
refusal: "no-resolution"
|
|
90736
|
+
};
|
|
90737
|
+
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
90738
|
+
const within = withinCeiling.length > 0;
|
|
90739
|
+
const best = [...within ? withinCeiling : sized].sort((a, b) => within ? height(b) - height(a) : height(a) - height(b))[0];
|
|
90740
|
+
if (best === void 0 || best.resolution === void 0) return {
|
|
90741
|
+
ok: false,
|
|
90742
|
+
refusal: "no-resolution"
|
|
90743
|
+
};
|
|
90744
|
+
return {
|
|
90745
|
+
ok: true,
|
|
90746
|
+
source: {
|
|
90747
|
+
brokerId: best.brokerId,
|
|
90748
|
+
profile: best.profile ?? best.brokerId,
|
|
90749
|
+
transcode,
|
|
90750
|
+
url: best.url,
|
|
90751
|
+
mutedUrl: best.mutedUrl,
|
|
90752
|
+
width: best.resolution.width,
|
|
90753
|
+
height: best.resolution.height
|
|
90754
|
+
}
|
|
90755
|
+
};
|
|
90756
|
+
}
|
|
90757
|
+
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
90758
|
+
function refusalReason(refusal) {
|
|
90759
|
+
switch (refusal) {
|
|
90760
|
+
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
90761
|
+
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
90762
|
+
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
90763
|
+
}
|
|
90764
|
+
}
|
|
90765
|
+
function height(entry) {
|
|
90766
|
+
return entry.resolution?.height ?? 0;
|
|
90767
|
+
}
|
|
90768
|
+
function hasUsableResolution(entry) {
|
|
90769
|
+
const r = entry.resolution;
|
|
90770
|
+
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
90771
|
+
}
|
|
90772
|
+
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
90773
|
+
function normaliseCodec(codec) {
|
|
90774
|
+
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
90775
|
+
}
|
|
90776
|
+
//#endregion
|
|
90777
|
+
//#region src/hksv/recordability-token.ts
|
|
90778
|
+
/** No slot is recordable. A real answer, and a fingerprint-moving one. */
|
|
90779
|
+
var RECORDABILITY_NONE = "rec:none";
|
|
90780
|
+
/**
|
|
90781
|
+
* The slot HKSV would record from, as a fingerprint token.
|
|
90782
|
+
*
|
|
90783
|
+
* `null` when `entries` is null — the profiles could not be read, which is not
|
|
90784
|
+
* a claim about the camera.
|
|
90785
|
+
*/
|
|
90786
|
+
function recordabilityToken(entries, options = {}) {
|
|
90787
|
+
if (entries === null) return null;
|
|
90788
|
+
const choice = pickRecordingSource(entries, options);
|
|
90789
|
+
return choice.ok ? `rec:${choice.source.profile}${choice.source.transcode ? "+x" : ""}` : RECORDABILITY_NONE;
|
|
90790
|
+
}
|
|
90791
|
+
var TOKEN_SUFFIX = /\|(rec:[^|]*)$/;
|
|
90792
|
+
/**
|
|
90793
|
+
* Attach the recording token to a device-shape fingerprint.
|
|
90794
|
+
*
|
|
90795
|
+
* On an unreadable read the persisted token is carried forward — but only onto
|
|
90796
|
+
* the base it was persisted against. If the device's SHAPE also changed, that
|
|
90797
|
+
* change is a rebuild in its own right and the token has nothing to say about
|
|
90798
|
+
* it; inventing one there would advertise a recording slot nobody read.
|
|
90799
|
+
*/
|
|
90800
|
+
function withRecordabilityToken(input) {
|
|
90801
|
+
if (input.token !== null) return `${input.base}|${input.token}`;
|
|
90802
|
+
const carried = input.persisted?.match(TOKEN_SUFFIX);
|
|
90803
|
+
if (carried === null || carried === void 0) return input.base;
|
|
90804
|
+
return carried.input?.slice(0, carried.index) === input.base ? `${input.base}|${carried[1]}` : input.base;
|
|
90805
|
+
}
|
|
90806
|
+
/**
|
|
90807
|
+
* What this camera could record, as one fingerprint token.
|
|
90808
|
+
*
|
|
90809
|
+
* There used to be an `rec:off` here, for a per-camera switch of ours. That
|
|
90810
|
+
* switch is gone (D589): whether HomeKit RECORDS is HomeKit's own persisted
|
|
90811
|
+
* characteristic, it does not change the accessory's shape, and a rebuild is
|
|
90812
|
+
* about shape. What still belongs here is the CAPABILITY — which slot, and
|
|
90813
|
+
* whether serving it means running an encoder — because those are two
|
|
90814
|
+
* different accessories to build.
|
|
90815
|
+
*/
|
|
90816
|
+
function recordabilityTokenFor(input) {
|
|
90817
|
+
return recordabilityToken(input.entries, { allowTranscode: input.allowTranscode !== false });
|
|
90818
|
+
}
|
|
90819
|
+
//#endregion
|
|
90673
90820
|
//#region src/hksv/videoclips-plane.ts
|
|
90674
90821
|
/**
|
|
90675
90822
|
* The two data-plane routes behind the HomeKit clip source.
|
|
@@ -90932,8 +91079,9 @@ function createHksvClipPlanes(deps) {
|
|
|
90932
91079
|
*
|
|
90933
91080
|
* ## The source EXISTS because of HomeKit, and is not a switch
|
|
90934
91081
|
*
|
|
90935
|
-
* A camera has this source because it is exported to HomeKit
|
|
90936
|
-
*
|
|
91082
|
+
* A camera has this source because it is exported to HomeKit and HomeKit has
|
|
91083
|
+
* not said no to recording it; un-export it, or switch recording off in the
|
|
91084
|
+
* Home app, and the row is gone (D550, D569 § 5, D589).
|
|
90937
91085
|
* There is deliberately no enable control of its own — a knob over somebody
|
|
90938
91086
|
* else's decision is D62's defect. `keepClips` is NOT that gate either: it
|
|
90939
91087
|
* governs the TEE, and the clips already kept remain the truth about this
|
|
@@ -90980,7 +91128,7 @@ function createHksvVideoclipsProvider(deps) {
|
|
|
90980
91128
|
*/
|
|
90981
91129
|
async function measure(deviceId) {
|
|
90982
91130
|
const facts = deps.describeCamera(deviceId);
|
|
90983
|
-
if (!facts.exported ||
|
|
91131
|
+
if (!facts.exported || facts.homekitRecording === false) return null;
|
|
90984
91132
|
const catalog = await deps.readCatalog(deviceId);
|
|
90985
91133
|
return {
|
|
90986
91134
|
availability: availabilityFor(facts, catalog),
|
|
@@ -106335,6 +106483,72 @@ function buildRecordingOptions(input) {
|
|
|
106335
106483
|
};
|
|
106336
106484
|
}
|
|
106337
106485
|
//#endregion
|
|
106486
|
+
//#region src/hksv/recording-invocation.ts
|
|
106487
|
+
/**
|
|
106488
|
+
* The resolution the accessory may advertise, which is the one it can deliver.
|
|
106489
|
+
*
|
|
106490
|
+
* A copy is advertised at its source size whatever that is — there is no
|
|
106491
|
+
* encoder to resize with, and advertising a size we cannot produce is the
|
|
106492
|
+
* "stream that does not match its own advertisement" defect in miniature.
|
|
106493
|
+
* A transcode above the ceiling is fitted down, because with an encoder in the
|
|
106494
|
+
* pipeline the 6.35 MB 4K fragment is a choice rather than a fact, and iOS
|
|
106495
|
+
* renders HKSV clips at 1080p regardless.
|
|
106496
|
+
*/
|
|
106497
|
+
function recordingOutput(source) {
|
|
106498
|
+
if (!source.transcode || source.height <= 1080) return {
|
|
106499
|
+
width: source.width,
|
|
106500
|
+
height: source.height,
|
|
106501
|
+
scaled: false
|
|
106502
|
+
};
|
|
106503
|
+
return {
|
|
106504
|
+
width: Math.max(2, Math.round(source.width * MAX_RECORDING_HEIGHT / source.height / 2) * 2),
|
|
106505
|
+
height: MAX_RECORDING_HEIGHT,
|
|
106506
|
+
scaled: true
|
|
106507
|
+
};
|
|
106508
|
+
}
|
|
106509
|
+
/** The invocation, minus the sink and sidecar the child owns. */
|
|
106510
|
+
function buildRecordingInvocation(input) {
|
|
106511
|
+
const { source } = input;
|
|
106512
|
+
const audio = input.audioActive ? {
|
|
106513
|
+
kind: "encode",
|
|
106514
|
+
codec: "aac",
|
|
106515
|
+
bitrateKbps: 32,
|
|
106516
|
+
sampleRateHz: HKSV_AUDIO_SAMPLE_RATE_HZ,
|
|
106517
|
+
channels: 1
|
|
106518
|
+
} : { kind: "none" };
|
|
106519
|
+
const out = recordingOutput(source);
|
|
106520
|
+
const video = source.transcode ? {
|
|
106521
|
+
kind: "encode",
|
|
106522
|
+
encoder: "libx264",
|
|
106523
|
+
preset: "veryfast",
|
|
106524
|
+
profile: "main",
|
|
106525
|
+
level: "4.0",
|
|
106526
|
+
pixelFormat: "yuv420p",
|
|
106527
|
+
forceKeyFramesSeconds: Math.max(1, Math.round(input.fragmentMs / 1e3)),
|
|
106528
|
+
bf: 0,
|
|
106529
|
+
...out.scaled ? { scale: {
|
|
106530
|
+
mode: "fit",
|
|
106531
|
+
width: out.width,
|
|
106532
|
+
height: out.height
|
|
106533
|
+
} } : {}
|
|
106534
|
+
} : { kind: "copy" };
|
|
106535
|
+
return {
|
|
106536
|
+
logLevel: "error",
|
|
106537
|
+
decodeHwAccel: source.transcode ? input.decodeHwAccel : null,
|
|
106538
|
+
input: {
|
|
106539
|
+
url: input.audioActive ? source.url : source.mutedUrl,
|
|
106540
|
+
rtspTransport: "tcp",
|
|
106541
|
+
...source.transcode && input.decodeInputArgs !== void 0 && input.decodeInputArgs.length > 0 ? { extraArgs: input.decodeInputArgs } : {},
|
|
106542
|
+
analyzeDurationUs: 1e6,
|
|
106543
|
+
probeSizeBytes: 1e6
|
|
106544
|
+
},
|
|
106545
|
+
video,
|
|
106546
|
+
audio,
|
|
106547
|
+
threadCount: 0,
|
|
106548
|
+
outputArgs: []
|
|
106549
|
+
};
|
|
106550
|
+
}
|
|
106551
|
+
//#endregion
|
|
106338
106552
|
//#region src/hksv/fragment-source.ts
|
|
106339
106553
|
/**
|
|
106340
106554
|
* How far back the prebuffer ring reaches.
|
|
@@ -106502,6 +106716,7 @@ var HksvFragmentSource = class {
|
|
|
106502
106716
|
meta: {
|
|
106503
106717
|
brokerId: this.input.source.brokerId,
|
|
106504
106718
|
resolution: `${this.input.source.width}x${this.input.source.height}`,
|
|
106719
|
+
branch: this.input.source.transcode ? "transcode" : "copy",
|
|
106505
106720
|
fragmentMs: this.input.fragmentMs,
|
|
106506
106721
|
audioActive: this.audioActive,
|
|
106507
106722
|
windowMs: PREBUFFER_WINDOW_MS
|
|
@@ -106566,27 +106781,13 @@ var HksvFragmentSource = class {
|
|
|
106566
106781
|
* takes AAC only — which the same measurement priced at 0.4 % of a core.
|
|
106567
106782
|
*/
|
|
106568
106783
|
buildInvocation() {
|
|
106569
|
-
|
|
106570
|
-
|
|
106571
|
-
|
|
106572
|
-
|
|
106573
|
-
|
|
106574
|
-
|
|
106575
|
-
}
|
|
106576
|
-
return {
|
|
106577
|
-
logLevel: "error",
|
|
106578
|
-
decodeHwAccel: null,
|
|
106579
|
-
input: {
|
|
106580
|
-
url: this.audioActive ? this.input.source.url : this.input.source.mutedUrl,
|
|
106581
|
-
rtspTransport: "tcp",
|
|
106582
|
-
analyzeDurationUs: 1e6,
|
|
106583
|
-
probeSizeBytes: 1e6
|
|
106584
|
-
},
|
|
106585
|
-
video: { kind: "copy" },
|
|
106586
|
-
audio,
|
|
106587
|
-
threadCount: 0,
|
|
106588
|
-
outputArgs: []
|
|
106589
|
-
};
|
|
106784
|
+
return buildRecordingInvocation({
|
|
106785
|
+
source: this.input.source,
|
|
106786
|
+
audioActive: this.audioActive,
|
|
106787
|
+
fragmentMs: this.input.fragmentMs,
|
|
106788
|
+
decodeHwAccel: this.input.decodeHwAccel ?? null,
|
|
106789
|
+
...this.input.decodeInputArgs === void 0 ? {} : { decodeInputArgs: this.input.decodeInputArgs }
|
|
106790
|
+
});
|
|
106590
106791
|
}
|
|
106591
106792
|
};
|
|
106592
106793
|
//#endregion
|
|
@@ -106946,74 +107147,6 @@ var HksvRecordingDelegate = class {
|
|
|
106946
107147
|
}
|
|
106947
107148
|
};
|
|
106948
107149
|
//#endregion
|
|
106949
|
-
//#region src/hksv/recording-source.ts
|
|
106950
|
-
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
106951
|
-
var MAX_RECORDING_HEIGHT = 1080;
|
|
106952
|
-
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
106953
|
-
var RECORDABLE_CODEC = "h264";
|
|
106954
|
-
/**
|
|
106955
|
-
* Pick the slot the recording child pulls.
|
|
106956
|
-
*
|
|
106957
|
-
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
106958
|
-
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
106959
|
-
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
106960
|
-
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
106961
|
-
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
106962
|
-
*/
|
|
106963
|
-
function pickRecordingSource(entries) {
|
|
106964
|
-
const enabled = entries.filter((e) => e.enabled);
|
|
106965
|
-
if (enabled.length === 0) return {
|
|
106966
|
-
ok: false,
|
|
106967
|
-
refusal: "no-enabled-stream"
|
|
106968
|
-
};
|
|
106969
|
-
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
106970
|
-
if (h264.length === 0) return {
|
|
106971
|
-
ok: false,
|
|
106972
|
-
refusal: "no-h264-stream"
|
|
106973
|
-
};
|
|
106974
|
-
const sized = h264.filter(hasUsableResolution);
|
|
106975
|
-
if (sized.length === 0) return {
|
|
106976
|
-
ok: false,
|
|
106977
|
-
refusal: "no-resolution"
|
|
106978
|
-
};
|
|
106979
|
-
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
106980
|
-
const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? height(b) - height(a) : height(a) - height(b))[0];
|
|
106981
|
-
if (best === void 0 || best.resolution === void 0) return {
|
|
106982
|
-
ok: false,
|
|
106983
|
-
refusal: "no-resolution"
|
|
106984
|
-
};
|
|
106985
|
-
return {
|
|
106986
|
-
ok: true,
|
|
106987
|
-
source: {
|
|
106988
|
-
brokerId: best.brokerId,
|
|
106989
|
-
profile: best.profile ?? best.brokerId,
|
|
106990
|
-
url: best.url,
|
|
106991
|
-
mutedUrl: best.mutedUrl,
|
|
106992
|
-
width: best.resolution.width,
|
|
106993
|
-
height: best.resolution.height
|
|
106994
|
-
}
|
|
106995
|
-
};
|
|
106996
|
-
}
|
|
106997
|
-
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
106998
|
-
function refusalReason(refusal) {
|
|
106999
|
-
switch (refusal) {
|
|
107000
|
-
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
107001
|
-
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
107002
|
-
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
107003
|
-
}
|
|
107004
|
-
}
|
|
107005
|
-
function height(entry) {
|
|
107006
|
-
return entry.resolution?.height ?? 0;
|
|
107007
|
-
}
|
|
107008
|
-
function hasUsableResolution(entry) {
|
|
107009
|
-
const r = entry.resolution;
|
|
107010
|
-
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
107011
|
-
}
|
|
107012
|
-
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
107013
|
-
function normaliseCodec(codec) {
|
|
107014
|
-
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
107015
|
-
}
|
|
107016
|
-
//#endregion
|
|
107017
107150
|
//#region src/hksv/build-recording.ts
|
|
107018
107151
|
/**
|
|
107019
107152
|
* Assemble HomeKit Secure Video for one camera — the ADVERTISEMENT and the
|
|
@@ -107065,7 +107198,7 @@ async function buildHksvRecording(input) {
|
|
|
107065
107198
|
note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
|
|
107066
107199
|
return null;
|
|
107067
107200
|
}
|
|
107068
|
-
const choice = pickRecordingSource(entries);
|
|
107201
|
+
const choice = pickRecordingSource(entries, { allowTranscode: bctx.options.hapDeviceSettings?.allowTranscode !== false });
|
|
107069
107202
|
if (!choice.ok) {
|
|
107070
107203
|
log.info("export-hap: HKSV withheld — no recordable stream", { meta: {
|
|
107071
107204
|
refusal: choice.refusal,
|
|
@@ -107075,9 +107208,10 @@ async function buildHksvRecording(input) {
|
|
|
107075
107208
|
return null;
|
|
107076
107209
|
}
|
|
107077
107210
|
const source = choice.source;
|
|
107211
|
+
const decodeHwAccel = source.transcode ? await resolveDecodeHwAccel(bctx, log) : null;
|
|
107078
107212
|
const gopMs = await readSourceGopMs(bctx, source.width, source.height);
|
|
107079
|
-
const fragmentLengthMs = deriveFragmentLengthMs(gopMs);
|
|
107080
|
-
if (fragmentLengthMs === null) {
|
|
107213
|
+
const fragmentLengthMs = source.transcode ? HKSV_FRAGMENT_LENGTHS_MS[0] : deriveFragmentLengthMs(gopMs);
|
|
107214
|
+
if (fragmentLengthMs === void 0 || fragmentLengthMs === null) {
|
|
107081
107215
|
log.warn("export-hap: HKSV withheld — the camera key-frame interval is longer than any fragment length we advertise", { meta: {
|
|
107082
107216
|
gopMs,
|
|
107083
107217
|
brokerId: source.brokerId
|
|
@@ -107088,9 +107222,10 @@ async function buildHksvRecording(input) {
|
|
|
107088
107222
|
const parsedProfile = CamProfileSchema.safeParse(source.profile);
|
|
107089
107223
|
const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
|
|
107090
107224
|
const fps = resolveFps(input.fpsByProfile, source.profile);
|
|
107225
|
+
const out = recordingOutput(source);
|
|
107091
107226
|
const options = buildRecordingOptions({
|
|
107092
|
-
width:
|
|
107093
|
-
height:
|
|
107227
|
+
width: out.width,
|
|
107228
|
+
height: out.height,
|
|
107094
107229
|
fps,
|
|
107095
107230
|
fragmentLengthMs
|
|
107096
107231
|
});
|
|
@@ -107100,8 +107235,8 @@ async function buildHksvRecording(input) {
|
|
|
107100
107235
|
logger: log,
|
|
107101
107236
|
deviceId: numericDeviceId,
|
|
107102
107237
|
store: clipStore,
|
|
107103
|
-
width:
|
|
107104
|
-
height:
|
|
107238
|
+
width: out.width,
|
|
107239
|
+
height: out.height,
|
|
107105
107240
|
fragmentMs: fragmentLengthMs,
|
|
107106
107241
|
...recordedProfile === null ? {} : { profile: recordedProfile }
|
|
107107
107242
|
});
|
|
@@ -107120,14 +107255,21 @@ async function buildHksvRecording(input) {
|
|
|
107120
107255
|
spawnFn: node_child_process.spawn,
|
|
107121
107256
|
source,
|
|
107122
107257
|
fragmentMs,
|
|
107123
|
-
audioActive
|
|
107258
|
+
audioActive,
|
|
107259
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? null,
|
|
107260
|
+
...decodeHwAccel === null || decodeHwAccel.extraInputArgs.length === 0 ? {} : { decodeInputArgs: decodeHwAccel.extraInputArgs }
|
|
107124
107261
|
})
|
|
107125
107262
|
});
|
|
107126
107263
|
const advertisedResolution = options.video.resolutions[0];
|
|
107127
107264
|
log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
|
|
107128
107265
|
brokerId: source.brokerId,
|
|
107129
107266
|
profile: source.profile,
|
|
107130
|
-
resolution: `${
|
|
107267
|
+
resolution: `${out.width}x${out.height}`,
|
|
107268
|
+
branch: source.transcode ? "transcode" : "copy",
|
|
107269
|
+
...source.transcode ? {
|
|
107270
|
+
sourceResolution: `${source.width}x${source.height}`,
|
|
107271
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? "software"
|
|
107272
|
+
} : {},
|
|
107131
107273
|
measuredFps: fps,
|
|
107132
107274
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
107133
107275
|
fragmentLengthMs,
|
|
@@ -107142,6 +107284,27 @@ async function buildHksvRecording(input) {
|
|
|
107142
107284
|
dispose: () => delegate.dispose()
|
|
107143
107285
|
};
|
|
107144
107286
|
}
|
|
107287
|
+
/**
|
|
107288
|
+
* The `-hwaccel` value for a transcoding prebuffer, and the flags that travel
|
|
107289
|
+
* with it.
|
|
107290
|
+
*
|
|
107291
|
+
* Software is a legitimate answer and is always NAMED: a camera that quietly
|
|
107292
|
+
* stopped using the GPU and one that never had it look identical in a CPU
|
|
107293
|
+
* graph, and on this branch the difference is most of a core, permanently.
|
|
107294
|
+
*/
|
|
107295
|
+
async function resolveDecodeHwAccel(bctx, log) {
|
|
107296
|
+
const decision = selectHwDecode({
|
|
107297
|
+
transcode: true,
|
|
107298
|
+
reading: await probeDecoderHwaccel({
|
|
107299
|
+
source: decoderInfoSourceFromContext(bctx.ctx),
|
|
107300
|
+
log,
|
|
107301
|
+
memo: bctx.options.decodeMemos.reading
|
|
107302
|
+
}),
|
|
107303
|
+
platform: process.platform
|
|
107304
|
+
});
|
|
107305
|
+
if (decision.kind === "software") log.warn("export-hap: HKSV will transcode in SOFTWARE — this costs most of a core, permanently", { meta: { reason: decision.reason } });
|
|
107306
|
+
return decision;
|
|
107307
|
+
}
|
|
107145
107308
|
/** `cameraStreams.getProfileRtspEntries`, or `null` when the cap is unreachable. */
|
|
107146
107309
|
async function readProfileEntries(bctx) {
|
|
107147
107310
|
try {
|
|
@@ -108137,13 +108300,12 @@ async function buildCameraAccessory(input) {
|
|
|
108137
108300
|
bctx,
|
|
108138
108301
|
streamingOptions: streams.streamingOptions
|
|
108139
108302
|
}));
|
|
108140
|
-
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
108141
108303
|
let recordingAudioActive = true;
|
|
108142
|
-
const recording =
|
|
108304
|
+
const recording = await buildHksvRecording({
|
|
108143
108305
|
bctx,
|
|
108144
108306
|
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
108145
108307
|
isAudioActive: () => recordingAudioActive
|
|
108146
|
-
})
|
|
108308
|
+
});
|
|
108147
108309
|
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
108148
108310
|
delegate: streams.delegate,
|
|
108149
108311
|
streamingOptions: streams.streamingOptions,
|
|
@@ -108386,23 +108548,13 @@ function syncStateToJson(map) {
|
|
|
108386
108548
|
* capabilities bound to it — restricting coverage by type is precisely what
|
|
108387
108549
|
* confined this exporter to cameras for its first three rounds.
|
|
108388
108550
|
*/
|
|
108389
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
108390
|
-
streamPreference: "auto",
|
|
108391
|
-
hksvRecording: true
|
|
108392
|
-
};
|
|
108393
108551
|
/**
|
|
108394
|
-
*
|
|
108395
|
-
*
|
|
108396
|
-
*
|
|
108397
|
-
*
|
|
108398
|
-
* cost that made off-by-default look prudent is measured and small on the only
|
|
108399
|
-
* branch the recorder accepts (copy: 0.7 % of a core / ~30 MB RSS, D84), and a
|
|
108400
|
-
* camera the recorder cannot copy refuses recording with a logged reason
|
|
108401
|
-
* rather than paying for a transcode.
|
|
108552
|
+
* What an entry with no stored settings is read as — and what a camera
|
|
108553
|
+
* exported TODAY is built from. Exported so a spec can assert the one thing
|
|
108554
|
+
* that matters about it: it must resolve to NOT recording, or "off by default"
|
|
108555
|
+
* is true only for cameras exposed before the flip.
|
|
108402
108556
|
*/
|
|
108403
|
-
|
|
108404
|
-
return settings?.hksvRecording !== false;
|
|
108405
|
-
}
|
|
108557
|
+
var DEFAULT_DEVICE_SETTINGS = { streamPreference: "auto" };
|
|
108406
108558
|
/**
|
|
108407
108559
|
* Whether this camera offers Apple's NEW camera service alongside the classic
|
|
108408
108560
|
* one — `camera-multi-tier-rtp-stream-management` (0x8031), where HEVC and 4K
|
|
@@ -108445,11 +108597,11 @@ var EXPORT_HAP_ADDON_ID = "export-hap";
|
|
|
108445
108597
|
* stays and says the copies are off, which is D62's other half: an off switch
|
|
108446
108598
|
* is REPORTED off, never made to look like a broken camera.
|
|
108447
108599
|
*/
|
|
108448
|
-
function describeHksvCamera(entry, lastBuild) {
|
|
108600
|
+
function describeHksvCamera(entry, lastBuild, homekitRecording) {
|
|
108449
108601
|
const isCamera = entry !== null && entry.mapperKind === "camera";
|
|
108450
108602
|
return {
|
|
108451
108603
|
exported: isCamera,
|
|
108452
|
-
|
|
108604
|
+
homekitRecording: isCamera ? homekitRecording : null,
|
|
108453
108605
|
keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
|
|
108454
108606
|
lastBuild
|
|
108455
108607
|
};
|
|
@@ -108693,7 +108845,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108693
108845
|
const clipsProvider = createHksvVideoclipsProvider({
|
|
108694
108846
|
logger: this.ctx.logger.child("hksv-clips"),
|
|
108695
108847
|
addonId: EXPORT_HAP_ADDON_ID,
|
|
108696
|
-
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
|
|
108848
|
+
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId), readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? [])),
|
|
108697
108849
|
readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
|
|
108698
108850
|
locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
|
|
108699
108851
|
mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
|
|
@@ -108849,7 +109001,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108849
109001
|
},
|
|
108850
109002
|
hapDeviceSettings: {
|
|
108851
109003
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
108852
|
-
hksvRecording: resolveHksvRecording(entrySettings),
|
|
108853
109004
|
keepHomekitClips: resolveKeepHomekitClips(entrySettings),
|
|
108854
109005
|
multiTierService: resolveMultiTierService(entrySettings),
|
|
108855
109006
|
allowTranscode: entrySettings.allowTranscode !== false
|
|
@@ -108930,15 +109081,40 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108930
109081
|
features: device.features
|
|
108931
109082
|
});
|
|
108932
109083
|
const persisted = syncStateFromJson(this.config.exportSyncState).get(deviceId)?.fingerprint;
|
|
109084
|
+
const withToken = withRecordabilityToken({
|
|
109085
|
+
base: fresh,
|
|
109086
|
+
token: await this.readRecordabilityToken(deviceId, device.type),
|
|
109087
|
+
persisted
|
|
109088
|
+
});
|
|
108933
109089
|
return resolveExportFingerprint({
|
|
108934
109090
|
ready: device.probed ?? false,
|
|
108935
|
-
fresh,
|
|
109091
|
+
fresh: withToken,
|
|
108936
109092
|
persisted
|
|
108937
109093
|
});
|
|
108938
109094
|
} catch {
|
|
108939
109095
|
return null;
|
|
108940
109096
|
}
|
|
108941
109097
|
}
|
|
109098
|
+
/**
|
|
109099
|
+
* The slot HKSV would record this camera from, for the fingerprint.
|
|
109100
|
+
*
|
|
109101
|
+
* `null` — do not move the fingerprint — for anything that is not a camera
|
|
109102
|
+
* (a switch has no recording decision, and stamping a constant on it would
|
|
109103
|
+
* republish every non-camera accessory once for nothing) and for a camera
|
|
109104
|
+
* whose profiles could not be read right now.
|
|
109105
|
+
*/
|
|
109106
|
+
async readRecordabilityToken(deviceId, type) {
|
|
109107
|
+
if (pickMapperKind(type) !== "camera") return null;
|
|
109108
|
+
try {
|
|
109109
|
+
const entries = await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getProfileRtspEntries({});
|
|
109110
|
+
return recordabilityTokenFor({
|
|
109111
|
+
allowTranscode: this.findEntry(deviceId)?.settings?.allowTranscode !== false,
|
|
109112
|
+
entries: entries ?? null
|
|
109113
|
+
});
|
|
109114
|
+
} catch {
|
|
109115
|
+
return null;
|
|
109116
|
+
}
|
|
109117
|
+
}
|
|
108942
109118
|
/** Persist a device's current export fingerprint into `exportSyncState`
|
|
108943
109119
|
* (best-effort; called after a successful attach). */
|
|
108944
109120
|
async persistFingerprint(deviceId, fingerprint) {
|
|
@@ -109204,7 +109380,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109204
109380
|
const enabled = entry !== null;
|
|
109205
109381
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109206
109382
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109207
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109208
109383
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
109209
109384
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109210
109385
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
@@ -109255,17 +109430,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109255
109430
|
immediate: true
|
|
109256
109431
|
},
|
|
109257
109432
|
{
|
|
109258
|
-
type: "
|
|
109259
|
-
key:
|
|
109433
|
+
type: "info",
|
|
109434
|
+
key: `hap:${deviceId}:hksvRecordingStatus`,
|
|
109260
109435
|
label: "HomeKit recording (Secure Video)",
|
|
109261
|
-
|
|
109262
|
-
style: "switch",
|
|
109263
|
-
value: resolveHksvRecording(settings),
|
|
109436
|
+
content: describeHomekitRecording(readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? []), this.hksvBuilds.lastFor(deviceId)),
|
|
109264
109437
|
showWhen: {
|
|
109265
109438
|
field: enabledKey,
|
|
109266
109439
|
equals: true
|
|
109267
|
-
}
|
|
109268
|
-
immediate: true
|
|
109440
|
+
}
|
|
109269
109441
|
},
|
|
109270
109442
|
{
|
|
109271
109443
|
type: "boolean",
|
|
@@ -109288,7 +109460,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109288
109460
|
style: "switch",
|
|
109289
109461
|
value: resolveKeepHomekitClips(settings),
|
|
109290
109462
|
showWhen: {
|
|
109291
|
-
field:
|
|
109463
|
+
field: enabledKey,
|
|
109292
109464
|
equals: true
|
|
109293
109465
|
},
|
|
109294
109466
|
immediate: true
|
|
@@ -109388,7 +109560,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109388
109560
|
const wasEnabled = this.exposed.has(deviceIdStr);
|
|
109389
109561
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109390
109562
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109391
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109392
109563
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109393
109564
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
109394
109565
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
@@ -109397,7 +109568,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109397
109568
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
109398
109569
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
109399
109570
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
109400
|
-
const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
|
|
109401
109571
|
const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
|
|
109402
109572
|
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
109403
109573
|
const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
|
|
@@ -109409,7 +109579,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109409
109579
|
const nextSettings = {
|
|
109410
109580
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
109411
109581
|
streamPreference,
|
|
109412
|
-
hksvRecording,
|
|
109413
109582
|
useBridge,
|
|
109414
109583
|
allowTranscode,
|
|
109415
109584
|
multiTierService,
|
|
@@ -109426,7 +109595,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109426
109595
|
return { success: true };
|
|
109427
109596
|
}
|
|
109428
109597
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
109429
|
-
const currentHksv = resolveHksvRecording(current?.settings);
|
|
109430
109598
|
const currentKeepClips = resolveKeepHomekitClips(current?.settings);
|
|
109431
109599
|
const mapperKind = current?.mapperKind ?? "generic";
|
|
109432
109600
|
const wasBridged = shouldBridge({
|
|
@@ -109438,16 +109606,12 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109438
109606
|
useBridge
|
|
109439
109607
|
});
|
|
109440
109608
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
109441
|
-
if (currentPref !== streamPreference ||
|
|
109609
|
+
if (currentPref !== streamPreference || currentKeepClips !== keepClips || wasBridged !== willBridge) {
|
|
109442
109610
|
log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
|
|
109443
109611
|
streamPreference: {
|
|
109444
109612
|
from: currentPref,
|
|
109445
109613
|
to: streamPreference
|
|
109446
109614
|
},
|
|
109447
|
-
hksvRecording: {
|
|
109448
|
-
from: currentHksv,
|
|
109449
|
-
to: hksvRecording
|
|
109450
|
-
},
|
|
109451
109615
|
keepClips: {
|
|
109452
109616
|
from: currentKeepClips,
|
|
109453
109617
|
to: keepClips
|
|
@@ -109534,6 +109698,7 @@ function errMsg(err) {
|
|
|
109534
109698
|
}
|
|
109535
109699
|
//#endregion
|
|
109536
109700
|
exports.DEFAULT_CLIP_RETENTION_DAYS = DEFAULT_CLIP_RETENTION_DAYS;
|
|
109701
|
+
exports.DEFAULT_DEVICE_SETTINGS = DEFAULT_DEVICE_SETTINGS;
|
|
109537
109702
|
exports.EXPORT_HAP_ADDON_ID = EXPORT_HAP_ADDON_ID;
|
|
109538
109703
|
exports.ExportHapAddon = ExportHapAddon;
|
|
109539
109704
|
exports.default = ExportHapAddon;
|
|
@@ -109543,7 +109708,6 @@ exports.describeHksvCamera = describeHksvCamera;
|
|
|
109543
109708
|
exports.initHapStorage = initHapStorage;
|
|
109544
109709
|
exports.publishStandalone = publishStandalone;
|
|
109545
109710
|
exports.resolveClipRetentionDays = resolveClipRetentionDays;
|
|
109546
|
-
exports.resolveHksvRecording = resolveHksvRecording;
|
|
109547
109711
|
exports.resolveKeepHomekitClips = resolveKeepHomekitClips;
|
|
109548
109712
|
exports.resolveMultiTierService = resolveMultiTierService;
|
|
109549
109713
|
exports.unpublishAccessory = unpublishAccessory;
|
|
@@ -90660,6 +90660,153 @@ function createClipThumbnailMinter(input) {
|
|
|
90660
90660
|
};
|
|
90661
90661
|
}
|
|
90662
90662
|
//#endregion
|
|
90663
|
+
//#region src/hksv/recording-active.ts
|
|
90664
|
+
/**
|
|
90665
|
+
* `true`/`false` as HomeKit holds it, or `null` when this accessory does not
|
|
90666
|
+
* offer recording at all.
|
|
90667
|
+
*/
|
|
90668
|
+
function readHomekitRecordingActive(accessories) {
|
|
90669
|
+
for (const accessory of accessories) {
|
|
90670
|
+
const service = accessory.getService(import_dist.Service.CameraRecordingManagement);
|
|
90671
|
+
if (service === void 0) continue;
|
|
90672
|
+
const value = service.getCharacteristic(import_dist.Characteristic.Active).value;
|
|
90673
|
+
if (typeof value === "number") return value === 1;
|
|
90674
|
+
if (typeof value === "boolean") return value;
|
|
90675
|
+
return null;
|
|
90676
|
+
}
|
|
90677
|
+
return null;
|
|
90678
|
+
}
|
|
90679
|
+
/**
|
|
90680
|
+
* One sentence for the settings page: what HomeKit says, or why it was never
|
|
90681
|
+
* asked.
|
|
90682
|
+
*
|
|
90683
|
+
* The cost stays in the text even though the switch is gone. It is still the
|
|
90684
|
+
* operator's to understand — a camera recording here runs a permanent ffmpeg —
|
|
90685
|
+
* and a status line that reported only the state would have quietly dropped
|
|
90686
|
+
* the one number that decides whether to turn it on.
|
|
90687
|
+
*/
|
|
90688
|
+
function describeHomekitRecording(active, lastBuild) {
|
|
90689
|
+
if (active === true) return "HomeKit is recording this camera. Turn it off in the Home app (Camera settings → “Stream”), not here — iOS owns this switch and keeps it across reboots. While it is on, this camera runs a permanent prebuffer (~0.7% of one CPU core on the copy branch).";
|
|
90690
|
+
if (active === false) return "This camera offers recording and HomeKit has it off. Turn it on in the Home app (Camera settings → “Stream and Allow Recording”); it needs iCloud+ and a home hub. Nothing runs here until you do.";
|
|
90691
|
+
if (lastBuild !== null && !lastBuild.advertised) return `Recording is not offered for this camera: ${lastBuild.detail ?? "the accessory could not advertise it"}.`;
|
|
90692
|
+
return "Not known yet — this camera’s accessory has not published its recording service in this process. It appears once the accessory is built.";
|
|
90693
|
+
}
|
|
90694
|
+
//#endregion
|
|
90695
|
+
//#region src/hksv/recording-source.ts
|
|
90696
|
+
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
90697
|
+
var MAX_RECORDING_HEIGHT = 1080;
|
|
90698
|
+
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
90699
|
+
var RECORDABLE_CODEC = "h264";
|
|
90700
|
+
/**
|
|
90701
|
+
* Pick the slot the recording child pulls.
|
|
90702
|
+
*
|
|
90703
|
+
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
90704
|
+
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
90705
|
+
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
90706
|
+
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
90707
|
+
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
90708
|
+
*/
|
|
90709
|
+
function pickRecordingSource(entries, options = {}) {
|
|
90710
|
+
const enabled = entries.filter((e) => e.enabled);
|
|
90711
|
+
if (enabled.length === 0) return {
|
|
90712
|
+
ok: false,
|
|
90713
|
+
refusal: "no-enabled-stream"
|
|
90714
|
+
};
|
|
90715
|
+
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
90716
|
+
const pool = h264.length > 0 ? h264 : options.allowTranscode === true ? enabled : [];
|
|
90717
|
+
if (pool.length === 0) return {
|
|
90718
|
+
ok: false,
|
|
90719
|
+
refusal: "no-h264-stream"
|
|
90720
|
+
};
|
|
90721
|
+
const transcode = h264.length === 0;
|
|
90722
|
+
const sized = pool.filter(hasUsableResolution);
|
|
90723
|
+
if (sized.length === 0) return {
|
|
90724
|
+
ok: false,
|
|
90725
|
+
refusal: "no-resolution"
|
|
90726
|
+
};
|
|
90727
|
+
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
90728
|
+
const within = withinCeiling.length > 0;
|
|
90729
|
+
const best = [...within ? withinCeiling : sized].sort((a, b) => within ? height(b) - height(a) : height(a) - height(b))[0];
|
|
90730
|
+
if (best === void 0 || best.resolution === void 0) return {
|
|
90731
|
+
ok: false,
|
|
90732
|
+
refusal: "no-resolution"
|
|
90733
|
+
};
|
|
90734
|
+
return {
|
|
90735
|
+
ok: true,
|
|
90736
|
+
source: {
|
|
90737
|
+
brokerId: best.brokerId,
|
|
90738
|
+
profile: best.profile ?? best.brokerId,
|
|
90739
|
+
transcode,
|
|
90740
|
+
url: best.url,
|
|
90741
|
+
mutedUrl: best.mutedUrl,
|
|
90742
|
+
width: best.resolution.width,
|
|
90743
|
+
height: best.resolution.height
|
|
90744
|
+
}
|
|
90745
|
+
};
|
|
90746
|
+
}
|
|
90747
|
+
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
90748
|
+
function refusalReason(refusal) {
|
|
90749
|
+
switch (refusal) {
|
|
90750
|
+
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
90751
|
+
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
90752
|
+
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
90753
|
+
}
|
|
90754
|
+
}
|
|
90755
|
+
function height(entry) {
|
|
90756
|
+
return entry.resolution?.height ?? 0;
|
|
90757
|
+
}
|
|
90758
|
+
function hasUsableResolution(entry) {
|
|
90759
|
+
const r = entry.resolution;
|
|
90760
|
+
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
90761
|
+
}
|
|
90762
|
+
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
90763
|
+
function normaliseCodec(codec) {
|
|
90764
|
+
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
90765
|
+
}
|
|
90766
|
+
//#endregion
|
|
90767
|
+
//#region src/hksv/recordability-token.ts
|
|
90768
|
+
/** No slot is recordable. A real answer, and a fingerprint-moving one. */
|
|
90769
|
+
var RECORDABILITY_NONE = "rec:none";
|
|
90770
|
+
/**
|
|
90771
|
+
* The slot HKSV would record from, as a fingerprint token.
|
|
90772
|
+
*
|
|
90773
|
+
* `null` when `entries` is null — the profiles could not be read, which is not
|
|
90774
|
+
* a claim about the camera.
|
|
90775
|
+
*/
|
|
90776
|
+
function recordabilityToken(entries, options = {}) {
|
|
90777
|
+
if (entries === null) return null;
|
|
90778
|
+
const choice = pickRecordingSource(entries, options);
|
|
90779
|
+
return choice.ok ? `rec:${choice.source.profile}${choice.source.transcode ? "+x" : ""}` : RECORDABILITY_NONE;
|
|
90780
|
+
}
|
|
90781
|
+
var TOKEN_SUFFIX = /\|(rec:[^|]*)$/;
|
|
90782
|
+
/**
|
|
90783
|
+
* Attach the recording token to a device-shape fingerprint.
|
|
90784
|
+
*
|
|
90785
|
+
* On an unreadable read the persisted token is carried forward — but only onto
|
|
90786
|
+
* the base it was persisted against. If the device's SHAPE also changed, that
|
|
90787
|
+
* change is a rebuild in its own right and the token has nothing to say about
|
|
90788
|
+
* it; inventing one there would advertise a recording slot nobody read.
|
|
90789
|
+
*/
|
|
90790
|
+
function withRecordabilityToken(input) {
|
|
90791
|
+
if (input.token !== null) return `${input.base}|${input.token}`;
|
|
90792
|
+
const carried = input.persisted?.match(TOKEN_SUFFIX);
|
|
90793
|
+
if (carried === null || carried === void 0) return input.base;
|
|
90794
|
+
return carried.input?.slice(0, carried.index) === input.base ? `${input.base}|${carried[1]}` : input.base;
|
|
90795
|
+
}
|
|
90796
|
+
/**
|
|
90797
|
+
* What this camera could record, as one fingerprint token.
|
|
90798
|
+
*
|
|
90799
|
+
* There used to be an `rec:off` here, for a per-camera switch of ours. That
|
|
90800
|
+
* switch is gone (D589): whether HomeKit RECORDS is HomeKit's own persisted
|
|
90801
|
+
* characteristic, it does not change the accessory's shape, and a rebuild is
|
|
90802
|
+
* about shape. What still belongs here is the CAPABILITY — which slot, and
|
|
90803
|
+
* whether serving it means running an encoder — because those are two
|
|
90804
|
+
* different accessories to build.
|
|
90805
|
+
*/
|
|
90806
|
+
function recordabilityTokenFor(input) {
|
|
90807
|
+
return recordabilityToken(input.entries, { allowTranscode: input.allowTranscode !== false });
|
|
90808
|
+
}
|
|
90809
|
+
//#endregion
|
|
90663
90810
|
//#region src/hksv/videoclips-plane.ts
|
|
90664
90811
|
/**
|
|
90665
90812
|
* The two data-plane routes behind the HomeKit clip source.
|
|
@@ -90922,8 +91069,9 @@ function createHksvClipPlanes(deps) {
|
|
|
90922
91069
|
*
|
|
90923
91070
|
* ## The source EXISTS because of HomeKit, and is not a switch
|
|
90924
91071
|
*
|
|
90925
|
-
* A camera has this source because it is exported to HomeKit
|
|
90926
|
-
*
|
|
91072
|
+
* A camera has this source because it is exported to HomeKit and HomeKit has
|
|
91073
|
+
* not said no to recording it; un-export it, or switch recording off in the
|
|
91074
|
+
* Home app, and the row is gone (D550, D569 § 5, D589).
|
|
90927
91075
|
* There is deliberately no enable control of its own — a knob over somebody
|
|
90928
91076
|
* else's decision is D62's defect. `keepClips` is NOT that gate either: it
|
|
90929
91077
|
* governs the TEE, and the clips already kept remain the truth about this
|
|
@@ -90970,7 +91118,7 @@ function createHksvVideoclipsProvider(deps) {
|
|
|
90970
91118
|
*/
|
|
90971
91119
|
async function measure(deviceId) {
|
|
90972
91120
|
const facts = deps.describeCamera(deviceId);
|
|
90973
|
-
if (!facts.exported ||
|
|
91121
|
+
if (!facts.exported || facts.homekitRecording === false) return null;
|
|
90974
91122
|
const catalog = await deps.readCatalog(deviceId);
|
|
90975
91123
|
return {
|
|
90976
91124
|
availability: availabilityFor(facts, catalog),
|
|
@@ -106325,6 +106473,72 @@ function buildRecordingOptions(input) {
|
|
|
106325
106473
|
};
|
|
106326
106474
|
}
|
|
106327
106475
|
//#endregion
|
|
106476
|
+
//#region src/hksv/recording-invocation.ts
|
|
106477
|
+
/**
|
|
106478
|
+
* The resolution the accessory may advertise, which is the one it can deliver.
|
|
106479
|
+
*
|
|
106480
|
+
* A copy is advertised at its source size whatever that is — there is no
|
|
106481
|
+
* encoder to resize with, and advertising a size we cannot produce is the
|
|
106482
|
+
* "stream that does not match its own advertisement" defect in miniature.
|
|
106483
|
+
* A transcode above the ceiling is fitted down, because with an encoder in the
|
|
106484
|
+
* pipeline the 6.35 MB 4K fragment is a choice rather than a fact, and iOS
|
|
106485
|
+
* renders HKSV clips at 1080p regardless.
|
|
106486
|
+
*/
|
|
106487
|
+
function recordingOutput(source) {
|
|
106488
|
+
if (!source.transcode || source.height <= 1080) return {
|
|
106489
|
+
width: source.width,
|
|
106490
|
+
height: source.height,
|
|
106491
|
+
scaled: false
|
|
106492
|
+
};
|
|
106493
|
+
return {
|
|
106494
|
+
width: Math.max(2, Math.round(source.width * MAX_RECORDING_HEIGHT / source.height / 2) * 2),
|
|
106495
|
+
height: MAX_RECORDING_HEIGHT,
|
|
106496
|
+
scaled: true
|
|
106497
|
+
};
|
|
106498
|
+
}
|
|
106499
|
+
/** The invocation, minus the sink and sidecar the child owns. */
|
|
106500
|
+
function buildRecordingInvocation(input) {
|
|
106501
|
+
const { source } = input;
|
|
106502
|
+
const audio = input.audioActive ? {
|
|
106503
|
+
kind: "encode",
|
|
106504
|
+
codec: "aac",
|
|
106505
|
+
bitrateKbps: 32,
|
|
106506
|
+
sampleRateHz: HKSV_AUDIO_SAMPLE_RATE_HZ,
|
|
106507
|
+
channels: 1
|
|
106508
|
+
} : { kind: "none" };
|
|
106509
|
+
const out = recordingOutput(source);
|
|
106510
|
+
const video = source.transcode ? {
|
|
106511
|
+
kind: "encode",
|
|
106512
|
+
encoder: "libx264",
|
|
106513
|
+
preset: "veryfast",
|
|
106514
|
+
profile: "main",
|
|
106515
|
+
level: "4.0",
|
|
106516
|
+
pixelFormat: "yuv420p",
|
|
106517
|
+
forceKeyFramesSeconds: Math.max(1, Math.round(input.fragmentMs / 1e3)),
|
|
106518
|
+
bf: 0,
|
|
106519
|
+
...out.scaled ? { scale: {
|
|
106520
|
+
mode: "fit",
|
|
106521
|
+
width: out.width,
|
|
106522
|
+
height: out.height
|
|
106523
|
+
} } : {}
|
|
106524
|
+
} : { kind: "copy" };
|
|
106525
|
+
return {
|
|
106526
|
+
logLevel: "error",
|
|
106527
|
+
decodeHwAccel: source.transcode ? input.decodeHwAccel : null,
|
|
106528
|
+
input: {
|
|
106529
|
+
url: input.audioActive ? source.url : source.mutedUrl,
|
|
106530
|
+
rtspTransport: "tcp",
|
|
106531
|
+
...source.transcode && input.decodeInputArgs !== void 0 && input.decodeInputArgs.length > 0 ? { extraArgs: input.decodeInputArgs } : {},
|
|
106532
|
+
analyzeDurationUs: 1e6,
|
|
106533
|
+
probeSizeBytes: 1e6
|
|
106534
|
+
},
|
|
106535
|
+
video,
|
|
106536
|
+
audio,
|
|
106537
|
+
threadCount: 0,
|
|
106538
|
+
outputArgs: []
|
|
106539
|
+
};
|
|
106540
|
+
}
|
|
106541
|
+
//#endregion
|
|
106328
106542
|
//#region src/hksv/fragment-source.ts
|
|
106329
106543
|
/**
|
|
106330
106544
|
* How far back the prebuffer ring reaches.
|
|
@@ -106492,6 +106706,7 @@ var HksvFragmentSource = class {
|
|
|
106492
106706
|
meta: {
|
|
106493
106707
|
brokerId: this.input.source.brokerId,
|
|
106494
106708
|
resolution: `${this.input.source.width}x${this.input.source.height}`,
|
|
106709
|
+
branch: this.input.source.transcode ? "transcode" : "copy",
|
|
106495
106710
|
fragmentMs: this.input.fragmentMs,
|
|
106496
106711
|
audioActive: this.audioActive,
|
|
106497
106712
|
windowMs: PREBUFFER_WINDOW_MS
|
|
@@ -106556,27 +106771,13 @@ var HksvFragmentSource = class {
|
|
|
106556
106771
|
* takes AAC only — which the same measurement priced at 0.4 % of a core.
|
|
106557
106772
|
*/
|
|
106558
106773
|
buildInvocation() {
|
|
106559
|
-
|
|
106560
|
-
|
|
106561
|
-
|
|
106562
|
-
|
|
106563
|
-
|
|
106564
|
-
|
|
106565
|
-
}
|
|
106566
|
-
return {
|
|
106567
|
-
logLevel: "error",
|
|
106568
|
-
decodeHwAccel: null,
|
|
106569
|
-
input: {
|
|
106570
|
-
url: this.audioActive ? this.input.source.url : this.input.source.mutedUrl,
|
|
106571
|
-
rtspTransport: "tcp",
|
|
106572
|
-
analyzeDurationUs: 1e6,
|
|
106573
|
-
probeSizeBytes: 1e6
|
|
106574
|
-
},
|
|
106575
|
-
video: { kind: "copy" },
|
|
106576
|
-
audio,
|
|
106577
|
-
threadCount: 0,
|
|
106578
|
-
outputArgs: []
|
|
106579
|
-
};
|
|
106774
|
+
return buildRecordingInvocation({
|
|
106775
|
+
source: this.input.source,
|
|
106776
|
+
audioActive: this.audioActive,
|
|
106777
|
+
fragmentMs: this.input.fragmentMs,
|
|
106778
|
+
decodeHwAccel: this.input.decodeHwAccel ?? null,
|
|
106779
|
+
...this.input.decodeInputArgs === void 0 ? {} : { decodeInputArgs: this.input.decodeInputArgs }
|
|
106780
|
+
});
|
|
106580
106781
|
}
|
|
106581
106782
|
};
|
|
106582
106783
|
//#endregion
|
|
@@ -106936,74 +107137,6 @@ var HksvRecordingDelegate = class {
|
|
|
106936
107137
|
}
|
|
106937
107138
|
};
|
|
106938
107139
|
//#endregion
|
|
106939
|
-
//#region src/hksv/recording-source.ts
|
|
106940
|
-
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
106941
|
-
var MAX_RECORDING_HEIGHT = 1080;
|
|
106942
|
-
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
106943
|
-
var RECORDABLE_CODEC = "h264";
|
|
106944
|
-
/**
|
|
106945
|
-
* Pick the slot the recording child pulls.
|
|
106946
|
-
*
|
|
106947
|
-
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
106948
|
-
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
106949
|
-
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
106950
|
-
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
106951
|
-
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
106952
|
-
*/
|
|
106953
|
-
function pickRecordingSource(entries) {
|
|
106954
|
-
const enabled = entries.filter((e) => e.enabled);
|
|
106955
|
-
if (enabled.length === 0) return {
|
|
106956
|
-
ok: false,
|
|
106957
|
-
refusal: "no-enabled-stream"
|
|
106958
|
-
};
|
|
106959
|
-
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
106960
|
-
if (h264.length === 0) return {
|
|
106961
|
-
ok: false,
|
|
106962
|
-
refusal: "no-h264-stream"
|
|
106963
|
-
};
|
|
106964
|
-
const sized = h264.filter(hasUsableResolution);
|
|
106965
|
-
if (sized.length === 0) return {
|
|
106966
|
-
ok: false,
|
|
106967
|
-
refusal: "no-resolution"
|
|
106968
|
-
};
|
|
106969
|
-
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
106970
|
-
const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? height(b) - height(a) : height(a) - height(b))[0];
|
|
106971
|
-
if (best === void 0 || best.resolution === void 0) return {
|
|
106972
|
-
ok: false,
|
|
106973
|
-
refusal: "no-resolution"
|
|
106974
|
-
};
|
|
106975
|
-
return {
|
|
106976
|
-
ok: true,
|
|
106977
|
-
source: {
|
|
106978
|
-
brokerId: best.brokerId,
|
|
106979
|
-
profile: best.profile ?? best.brokerId,
|
|
106980
|
-
url: best.url,
|
|
106981
|
-
mutedUrl: best.mutedUrl,
|
|
106982
|
-
width: best.resolution.width,
|
|
106983
|
-
height: best.resolution.height
|
|
106984
|
-
}
|
|
106985
|
-
};
|
|
106986
|
-
}
|
|
106987
|
-
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
106988
|
-
function refusalReason(refusal) {
|
|
106989
|
-
switch (refusal) {
|
|
106990
|
-
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
106991
|
-
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
106992
|
-
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
106993
|
-
}
|
|
106994
|
-
}
|
|
106995
|
-
function height(entry) {
|
|
106996
|
-
return entry.resolution?.height ?? 0;
|
|
106997
|
-
}
|
|
106998
|
-
function hasUsableResolution(entry) {
|
|
106999
|
-
const r = entry.resolution;
|
|
107000
|
-
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
107001
|
-
}
|
|
107002
|
-
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
107003
|
-
function normaliseCodec(codec) {
|
|
107004
|
-
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
107005
|
-
}
|
|
107006
|
-
//#endregion
|
|
107007
107140
|
//#region src/hksv/build-recording.ts
|
|
107008
107141
|
/**
|
|
107009
107142
|
* Assemble HomeKit Secure Video for one camera — the ADVERTISEMENT and the
|
|
@@ -107055,7 +107188,7 @@ async function buildHksvRecording(input) {
|
|
|
107055
107188
|
note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
|
|
107056
107189
|
return null;
|
|
107057
107190
|
}
|
|
107058
|
-
const choice = pickRecordingSource(entries);
|
|
107191
|
+
const choice = pickRecordingSource(entries, { allowTranscode: bctx.options.hapDeviceSettings?.allowTranscode !== false });
|
|
107059
107192
|
if (!choice.ok) {
|
|
107060
107193
|
log.info("export-hap: HKSV withheld — no recordable stream", { meta: {
|
|
107061
107194
|
refusal: choice.refusal,
|
|
@@ -107065,9 +107198,10 @@ async function buildHksvRecording(input) {
|
|
|
107065
107198
|
return null;
|
|
107066
107199
|
}
|
|
107067
107200
|
const source = choice.source;
|
|
107201
|
+
const decodeHwAccel = source.transcode ? await resolveDecodeHwAccel(bctx, log) : null;
|
|
107068
107202
|
const gopMs = await readSourceGopMs(bctx, source.width, source.height);
|
|
107069
|
-
const fragmentLengthMs = deriveFragmentLengthMs(gopMs);
|
|
107070
|
-
if (fragmentLengthMs === null) {
|
|
107203
|
+
const fragmentLengthMs = source.transcode ? HKSV_FRAGMENT_LENGTHS_MS[0] : deriveFragmentLengthMs(gopMs);
|
|
107204
|
+
if (fragmentLengthMs === void 0 || fragmentLengthMs === null) {
|
|
107071
107205
|
log.warn("export-hap: HKSV withheld — the camera key-frame interval is longer than any fragment length we advertise", { meta: {
|
|
107072
107206
|
gopMs,
|
|
107073
107207
|
brokerId: source.brokerId
|
|
@@ -107078,9 +107212,10 @@ async function buildHksvRecording(input) {
|
|
|
107078
107212
|
const parsedProfile = CamProfileSchema.safeParse(source.profile);
|
|
107079
107213
|
const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
|
|
107080
107214
|
const fps = resolveFps(input.fpsByProfile, source.profile);
|
|
107215
|
+
const out = recordingOutput(source);
|
|
107081
107216
|
const options = buildRecordingOptions({
|
|
107082
|
-
width:
|
|
107083
|
-
height:
|
|
107217
|
+
width: out.width,
|
|
107218
|
+
height: out.height,
|
|
107084
107219
|
fps,
|
|
107085
107220
|
fragmentLengthMs
|
|
107086
107221
|
});
|
|
@@ -107090,8 +107225,8 @@ async function buildHksvRecording(input) {
|
|
|
107090
107225
|
logger: log,
|
|
107091
107226
|
deviceId: numericDeviceId,
|
|
107092
107227
|
store: clipStore,
|
|
107093
|
-
width:
|
|
107094
|
-
height:
|
|
107228
|
+
width: out.width,
|
|
107229
|
+
height: out.height,
|
|
107095
107230
|
fragmentMs: fragmentLengthMs,
|
|
107096
107231
|
...recordedProfile === null ? {} : { profile: recordedProfile }
|
|
107097
107232
|
});
|
|
@@ -107110,14 +107245,21 @@ async function buildHksvRecording(input) {
|
|
|
107110
107245
|
spawnFn: spawn,
|
|
107111
107246
|
source,
|
|
107112
107247
|
fragmentMs,
|
|
107113
|
-
audioActive
|
|
107248
|
+
audioActive,
|
|
107249
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? null,
|
|
107250
|
+
...decodeHwAccel === null || decodeHwAccel.extraInputArgs.length === 0 ? {} : { decodeInputArgs: decodeHwAccel.extraInputArgs }
|
|
107114
107251
|
})
|
|
107115
107252
|
});
|
|
107116
107253
|
const advertisedResolution = options.video.resolutions[0];
|
|
107117
107254
|
log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
|
|
107118
107255
|
brokerId: source.brokerId,
|
|
107119
107256
|
profile: source.profile,
|
|
107120
|
-
resolution: `${
|
|
107257
|
+
resolution: `${out.width}x${out.height}`,
|
|
107258
|
+
branch: source.transcode ? "transcode" : "copy",
|
|
107259
|
+
...source.transcode ? {
|
|
107260
|
+
sourceResolution: `${source.width}x${source.height}`,
|
|
107261
|
+
decodeHwAccel: decodeHwAccel?.hwaccel ?? "software"
|
|
107262
|
+
} : {},
|
|
107121
107263
|
measuredFps: fps,
|
|
107122
107264
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
107123
107265
|
fragmentLengthMs,
|
|
@@ -107132,6 +107274,27 @@ async function buildHksvRecording(input) {
|
|
|
107132
107274
|
dispose: () => delegate.dispose()
|
|
107133
107275
|
};
|
|
107134
107276
|
}
|
|
107277
|
+
/**
|
|
107278
|
+
* The `-hwaccel` value for a transcoding prebuffer, and the flags that travel
|
|
107279
|
+
* with it.
|
|
107280
|
+
*
|
|
107281
|
+
* Software is a legitimate answer and is always NAMED: a camera that quietly
|
|
107282
|
+
* stopped using the GPU and one that never had it look identical in a CPU
|
|
107283
|
+
* graph, and on this branch the difference is most of a core, permanently.
|
|
107284
|
+
*/
|
|
107285
|
+
async function resolveDecodeHwAccel(bctx, log) {
|
|
107286
|
+
const decision = selectHwDecode({
|
|
107287
|
+
transcode: true,
|
|
107288
|
+
reading: await probeDecoderHwaccel({
|
|
107289
|
+
source: decoderInfoSourceFromContext(bctx.ctx),
|
|
107290
|
+
log,
|
|
107291
|
+
memo: bctx.options.decodeMemos.reading
|
|
107292
|
+
}),
|
|
107293
|
+
platform: process.platform
|
|
107294
|
+
});
|
|
107295
|
+
if (decision.kind === "software") log.warn("export-hap: HKSV will transcode in SOFTWARE — this costs most of a core, permanently", { meta: { reason: decision.reason } });
|
|
107296
|
+
return decision;
|
|
107297
|
+
}
|
|
107135
107298
|
/** `cameraStreams.getProfileRtspEntries`, or `null` when the cap is unreachable. */
|
|
107136
107299
|
async function readProfileEntries(bctx) {
|
|
107137
107300
|
try {
|
|
@@ -108127,13 +108290,12 @@ async function buildCameraAccessory(input) {
|
|
|
108127
108290
|
bctx,
|
|
108128
108291
|
streamingOptions: streams.streamingOptions
|
|
108129
108292
|
}));
|
|
108130
|
-
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
108131
108293
|
let recordingAudioActive = true;
|
|
108132
|
-
const recording =
|
|
108294
|
+
const recording = await buildHksvRecording({
|
|
108133
108295
|
bctx,
|
|
108134
108296
|
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
108135
108297
|
isAudioActive: () => recordingAudioActive
|
|
108136
|
-
})
|
|
108298
|
+
});
|
|
108137
108299
|
const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
|
|
108138
108300
|
delegate: streams.delegate,
|
|
108139
108301
|
streamingOptions: streams.streamingOptions,
|
|
@@ -108376,23 +108538,13 @@ function syncStateToJson(map) {
|
|
|
108376
108538
|
* capabilities bound to it — restricting coverage by type is precisely what
|
|
108377
108539
|
* confined this exporter to cameras for its first three rounds.
|
|
108378
108540
|
*/
|
|
108379
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
108380
|
-
streamPreference: "auto",
|
|
108381
|
-
hksvRecording: true
|
|
108382
|
-
};
|
|
108383
108541
|
/**
|
|
108384
|
-
*
|
|
108385
|
-
*
|
|
108386
|
-
*
|
|
108387
|
-
*
|
|
108388
|
-
* cost that made off-by-default look prudent is measured and small on the only
|
|
108389
|
-
* branch the recorder accepts (copy: 0.7 % of a core / ~30 MB RSS, D84), and a
|
|
108390
|
-
* camera the recorder cannot copy refuses recording with a logged reason
|
|
108391
|
-
* rather than paying for a transcode.
|
|
108542
|
+
* What an entry with no stored settings is read as — and what a camera
|
|
108543
|
+
* exported TODAY is built from. Exported so a spec can assert the one thing
|
|
108544
|
+
* that matters about it: it must resolve to NOT recording, or "off by default"
|
|
108545
|
+
* is true only for cameras exposed before the flip.
|
|
108392
108546
|
*/
|
|
108393
|
-
|
|
108394
|
-
return settings?.hksvRecording !== false;
|
|
108395
|
-
}
|
|
108547
|
+
var DEFAULT_DEVICE_SETTINGS = { streamPreference: "auto" };
|
|
108396
108548
|
/**
|
|
108397
108549
|
* Whether this camera offers Apple's NEW camera service alongside the classic
|
|
108398
108550
|
* one — `camera-multi-tier-rtp-stream-management` (0x8031), where HEVC and 4K
|
|
@@ -108435,11 +108587,11 @@ var EXPORT_HAP_ADDON_ID = "export-hap";
|
|
|
108435
108587
|
* stays and says the copies are off, which is D62's other half: an off switch
|
|
108436
108588
|
* is REPORTED off, never made to look like a broken camera.
|
|
108437
108589
|
*/
|
|
108438
|
-
function describeHksvCamera(entry, lastBuild) {
|
|
108590
|
+
function describeHksvCamera(entry, lastBuild, homekitRecording) {
|
|
108439
108591
|
const isCamera = entry !== null && entry.mapperKind === "camera";
|
|
108440
108592
|
return {
|
|
108441
108593
|
exported: isCamera,
|
|
108442
|
-
|
|
108594
|
+
homekitRecording: isCamera ? homekitRecording : null,
|
|
108443
108595
|
keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
|
|
108444
108596
|
lastBuild
|
|
108445
108597
|
};
|
|
@@ -108683,7 +108835,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108683
108835
|
const clipsProvider = createHksvVideoclipsProvider({
|
|
108684
108836
|
logger: this.ctx.logger.child("hksv-clips"),
|
|
108685
108837
|
addonId: EXPORT_HAP_ADDON_ID,
|
|
108686
|
-
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
|
|
108838
|
+
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId), readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? [])),
|
|
108687
108839
|
readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
|
|
108688
108840
|
locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
|
|
108689
108841
|
mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
|
|
@@ -108839,7 +108991,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108839
108991
|
},
|
|
108840
108992
|
hapDeviceSettings: {
|
|
108841
108993
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
108842
|
-
hksvRecording: resolveHksvRecording(entrySettings),
|
|
108843
108994
|
keepHomekitClips: resolveKeepHomekitClips(entrySettings),
|
|
108844
108995
|
multiTierService: resolveMultiTierService(entrySettings),
|
|
108845
108996
|
allowTranscode: entrySettings.allowTranscode !== false
|
|
@@ -108920,15 +109071,40 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108920
109071
|
features: device.features
|
|
108921
109072
|
});
|
|
108922
109073
|
const persisted = syncStateFromJson(this.config.exportSyncState).get(deviceId)?.fingerprint;
|
|
109074
|
+
const withToken = withRecordabilityToken({
|
|
109075
|
+
base: fresh,
|
|
109076
|
+
token: await this.readRecordabilityToken(deviceId, device.type),
|
|
109077
|
+
persisted
|
|
109078
|
+
});
|
|
108923
109079
|
return resolveExportFingerprint({
|
|
108924
109080
|
ready: device.probed ?? false,
|
|
108925
|
-
fresh,
|
|
109081
|
+
fresh: withToken,
|
|
108926
109082
|
persisted
|
|
108927
109083
|
});
|
|
108928
109084
|
} catch {
|
|
108929
109085
|
return null;
|
|
108930
109086
|
}
|
|
108931
109087
|
}
|
|
109088
|
+
/**
|
|
109089
|
+
* The slot HKSV would record this camera from, for the fingerprint.
|
|
109090
|
+
*
|
|
109091
|
+
* `null` — do not move the fingerprint — for anything that is not a camera
|
|
109092
|
+
* (a switch has no recording decision, and stamping a constant on it would
|
|
109093
|
+
* republish every non-camera accessory once for nothing) and for a camera
|
|
109094
|
+
* whose profiles could not be read right now.
|
|
109095
|
+
*/
|
|
109096
|
+
async readRecordabilityToken(deviceId, type) {
|
|
109097
|
+
if (pickMapperKind(type) !== "camera") return null;
|
|
109098
|
+
try {
|
|
109099
|
+
const entries = await (await this.ctx.fetchDevice(deviceId)).cameraStreams?.getProfileRtspEntries({});
|
|
109100
|
+
return recordabilityTokenFor({
|
|
109101
|
+
allowTranscode: this.findEntry(deviceId)?.settings?.allowTranscode !== false,
|
|
109102
|
+
entries: entries ?? null
|
|
109103
|
+
});
|
|
109104
|
+
} catch {
|
|
109105
|
+
return null;
|
|
109106
|
+
}
|
|
109107
|
+
}
|
|
108932
109108
|
/** Persist a device's current export fingerprint into `exportSyncState`
|
|
108933
109109
|
* (best-effort; called after a successful attach). */
|
|
108934
109110
|
async persistFingerprint(deviceId, fingerprint) {
|
|
@@ -109194,7 +109370,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109194
109370
|
const enabled = entry !== null;
|
|
109195
109371
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109196
109372
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109197
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109198
109373
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
109199
109374
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109200
109375
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
@@ -109245,17 +109420,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109245
109420
|
immediate: true
|
|
109246
109421
|
},
|
|
109247
109422
|
{
|
|
109248
|
-
type: "
|
|
109249
|
-
key:
|
|
109423
|
+
type: "info",
|
|
109424
|
+
key: `hap:${deviceId}:hksvRecordingStatus`,
|
|
109250
109425
|
label: "HomeKit recording (Secure Video)",
|
|
109251
|
-
|
|
109252
|
-
style: "switch",
|
|
109253
|
-
value: resolveHksvRecording(settings),
|
|
109426
|
+
content: describeHomekitRecording(readHomekitRecordingActive(this.exposed.get(String(deviceId))?.accessories ?? []), this.hksvBuilds.lastFor(deviceId)),
|
|
109254
109427
|
showWhen: {
|
|
109255
109428
|
field: enabledKey,
|
|
109256
109429
|
equals: true
|
|
109257
|
-
}
|
|
109258
|
-
immediate: true
|
|
109430
|
+
}
|
|
109259
109431
|
},
|
|
109260
109432
|
{
|
|
109261
109433
|
type: "boolean",
|
|
@@ -109278,7 +109450,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109278
109450
|
style: "switch",
|
|
109279
109451
|
value: resolveKeepHomekitClips(settings),
|
|
109280
109452
|
showWhen: {
|
|
109281
|
-
field:
|
|
109453
|
+
field: enabledKey,
|
|
109282
109454
|
equals: true
|
|
109283
109455
|
},
|
|
109284
109456
|
immediate: true
|
|
@@ -109378,7 +109550,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109378
109550
|
const wasEnabled = this.exposed.has(deviceIdStr);
|
|
109379
109551
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
109380
109552
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
109381
|
-
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
109382
109553
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
109383
109554
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
109384
109555
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
@@ -109387,7 +109558,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109387
109558
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
109388
109559
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
109389
109560
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
109390
|
-
const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
|
|
109391
109561
|
const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
|
|
109392
109562
|
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
109393
109563
|
const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
|
|
@@ -109399,7 +109569,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109399
109569
|
const nextSettings = {
|
|
109400
109570
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
109401
109571
|
streamPreference,
|
|
109402
|
-
hksvRecording,
|
|
109403
109572
|
useBridge,
|
|
109404
109573
|
allowTranscode,
|
|
109405
109574
|
multiTierService,
|
|
@@ -109416,7 +109585,6 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109416
109585
|
return { success: true };
|
|
109417
109586
|
}
|
|
109418
109587
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
109419
|
-
const currentHksv = resolveHksvRecording(current?.settings);
|
|
109420
109588
|
const currentKeepClips = resolveKeepHomekitClips(current?.settings);
|
|
109421
109589
|
const mapperKind = current?.mapperKind ?? "generic";
|
|
109422
109590
|
const wasBridged = shouldBridge({
|
|
@@ -109428,16 +109596,12 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
109428
109596
|
useBridge
|
|
109429
109597
|
});
|
|
109430
109598
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
109431
|
-
if (currentPref !== streamPreference ||
|
|
109599
|
+
if (currentPref !== streamPreference || currentKeepClips !== keepClips || wasBridged !== willBridge) {
|
|
109432
109600
|
log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
|
|
109433
109601
|
streamPreference: {
|
|
109434
109602
|
from: currentPref,
|
|
109435
109603
|
to: streamPreference
|
|
109436
109604
|
},
|
|
109437
|
-
hksvRecording: {
|
|
109438
|
-
from: currentHksv,
|
|
109439
|
-
to: hksvRecording
|
|
109440
|
-
},
|
|
109441
109605
|
keepClips: {
|
|
109442
109606
|
from: currentKeepClips,
|
|
109443
109607
|
to: keepClips
|
|
@@ -109523,4 +109687,4 @@ function errMsg(err) {
|
|
|
109523
109687
|
return err instanceof Error ? err.message : String(err);
|
|
109524
109688
|
}
|
|
109525
109689
|
//#endregion
|
|
109526
|
-
export { DEFAULT_CLIP_RETENTION_DAYS, EXPORT_HAP_ADDON_ID, ExportHapAddon, ExportHapAddon as default, describeHksvCamera, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays,
|
|
109690
|
+
export { DEFAULT_CLIP_RETENTION_DAYS, DEFAULT_DEVICE_SETTINGS, EXPORT_HAP_ADDON_ID, ExportHapAddon, ExportHapAddon as default, describeHksvCamera, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays, resolveKeepHomekitClips, resolveMultiTierService, deriveUsername as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-export-hap",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.139",
|
|
4
4
|
"description": "HomeKit (HAP) exporter for CamStack devices. Publishes each exposed device as its own HomeKit accessory: cameras and doorbells with SRTP streaming, HomeKit Secure Video, motion, two-way audio, PTZ and battery; switches, lights, locks and sensors through a capability→service table.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|