@camstack/types 1.2.41 → 1.2.43
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 +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/canonical-hash-7nfBbEqR.mjs +35 -0
- package/dist/canonical-hash-BcZHRHIx.js +40 -0
- package/dist/cap-call-context.d.ts +26 -0
- package/dist/capabilities/index.d.ts +5 -5
- package/dist/capabilities/notification-rules.cap.d.ts +41 -0
- package/dist/capabilities/pipeline-analytics.cap.d.ts +92 -4
- package/dist/capabilities/pipeline-orchestrator.cap.d.ts +149 -0
- package/dist/capabilities/pipeline-runner.cap.d.ts +119 -1
- package/dist/capabilities/platform-probe.cap.d.ts +3 -3
- package/dist/capabilities/privacy-mask.cap.d.ts +69 -9
- package/dist/capabilities/recording.cap.d.ts +30 -0
- package/dist/capabilities/snapshot.cap.d.ts +1 -1
- package/dist/capabilities/stream-broker.cap.d.ts +304 -0
- package/dist/capabilities/stream-params.cap.d.ts +8 -4
- package/dist/device/device-profile.d.ts +12 -4
- package/dist/device/system-mirror.d.ts +11 -0
- package/dist/encode-profile.d.ts +2 -0
- package/dist/ffmpeg/encode-defaults.d.ts +107 -0
- package/dist/ffmpeg/hwaccel.d.ts +98 -0
- package/dist/ffmpeg/invocation.d.ts +348 -0
- package/dist/ffmpeg/process.d.ts +135 -0
- package/dist/ffmpeg/sharing-key.d.ts +91 -0
- package/dist/generated/addon-api.d.ts +92 -4
- package/dist/generated/device-proxy.d.ts +2 -2
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/generated/system-proxy.d.ts +2 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2069 -42
- package/dist/index.mjs +2006 -43
- package/dist/interfaces/camera-switches.d.ts +375 -0
- package/dist/interfaces/inference-engine.d.ts +24 -3
- package/dist/interfaces/ops-log.d.ts +4 -0
- package/dist/interfaces/pipeline-runner-capability.d.ts +9 -1
- package/dist/node.d.ts +2 -0
- package/dist/node.js +270 -36
- package/dist/node.mjs +269 -36
- package/dist/pipeline/native-lease.d.ts +150 -0
- package/dist/{sleep-DTce7-ch.js → sleep-Bx9IIoT0.js} +43 -1
- package/dist/{sleep-CXimb854.mjs → sleep-DtstvzWm.mjs} +38 -2
- package/dist/utils/addon-id.d.ts +30 -0
- package/package.json +1 -1
package/dist/node.js
CHANGED
|
@@ -21,7 +21,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
21
21
|
enumerable: true
|
|
22
22
|
}) : target, mod));
|
|
23
23
|
//#endregion
|
|
24
|
+
const require_canonical_hash = require("./canonical-hash-BcZHRHIx.js");
|
|
24
25
|
const require_err_msg = require("./err-msg-COpsHMw2.js");
|
|
26
|
+
let node_crypto = require("node:crypto");
|
|
25
27
|
let node_fs = require("node:fs");
|
|
26
28
|
node_fs = __toESM(node_fs);
|
|
27
29
|
let node_path = require("node:path");
|
|
@@ -29,7 +31,6 @@ node_path = __toESM(node_path);
|
|
|
29
31
|
let node_stream_promises = require("node:stream/promises");
|
|
30
32
|
let node_stream = require("node:stream");
|
|
31
33
|
let node_child_process = require("node:child_process");
|
|
32
|
-
let node_crypto = require("node:crypto");
|
|
33
34
|
//#region src/deps/binary-downloader.ts
|
|
34
35
|
/**
|
|
35
36
|
* Recursively find the first file named exactly `name` under `dir`. Used as the
|
|
@@ -600,39 +601,6 @@ var FilesystemStorageProvider = class {
|
|
|
600
601
|
}
|
|
601
602
|
};
|
|
602
603
|
//#endregion
|
|
603
|
-
//#region src/utils/canonical-hash.ts
|
|
604
|
-
/**
|
|
605
|
-
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
606
|
-
* canonical form sorts object keys alphabetically at every depth so two
|
|
607
|
-
* structurally-equal inputs with different key insertion orders produce
|
|
608
|
-
* the same hash. Returns a 64-char lowercase hex digest.
|
|
609
|
-
*
|
|
610
|
-
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
611
|
-
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
612
|
-
* the last applied state — preventing user-visible "re-discovery"
|
|
613
|
-
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
614
|
-
* `DeviceBindingsChanged` for every cap registration, which without
|
|
615
|
-
* this guard would propagate redundant pushes.
|
|
616
|
-
*
|
|
617
|
-
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
618
|
-
* subscription. The proper fix is a single "device ready" lifecycle
|
|
619
|
-
* barrier so exports react only when the full cap set has landed —
|
|
620
|
-
* tracked separately for post-HA-integration work.
|
|
621
|
-
*/
|
|
622
|
-
function canonicalHash(value) {
|
|
623
|
-
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
624
|
-
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
625
|
-
}
|
|
626
|
-
function replaceWithSortedKeys(_key, value) {
|
|
627
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
628
|
-
const obj = value;
|
|
629
|
-
const out = {};
|
|
630
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
631
|
-
return out;
|
|
632
|
-
}
|
|
633
|
-
return value;
|
|
634
|
-
}
|
|
635
|
-
//#endregion
|
|
636
604
|
//#region src/utils/export-reconciler.ts
|
|
637
605
|
/**
|
|
638
606
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
@@ -641,7 +609,7 @@ function replaceWithSortedKeys(_key, value) {
|
|
|
641
609
|
*/
|
|
642
610
|
function canonicalDeviceFingerprint(shape) {
|
|
643
611
|
const features = [...new Set(shape.features)].toSorted();
|
|
644
|
-
return canonicalHash({
|
|
612
|
+
return require_canonical_hash.canonicalHash({
|
|
645
613
|
deviceType: shape.deviceType,
|
|
646
614
|
features
|
|
647
615
|
});
|
|
@@ -689,11 +657,277 @@ function resolveExportFingerprint(input) {
|
|
|
689
657
|
return input.persisted ?? input.fresh;
|
|
690
658
|
}
|
|
691
659
|
//#endregion
|
|
660
|
+
//#region src/ffmpeg/process.ts
|
|
661
|
+
/**
|
|
662
|
+
* `FfmpegProcess` — the ONE spawn/lifecycle wrapper for a live-media ffmpeg.
|
|
663
|
+
*
|
|
664
|
+
* Extracted from `TranscodeEgress.spawnAttempt`, which was already the most
|
|
665
|
+
* complete of the repo's hand-rolled lifecycles: first-data deadline, hardware
|
|
666
|
+
* →software retry, SIGTERM-then-SIGKILL. This generalises it and adds the two
|
|
667
|
+
* things every copy was missing — a `tags: { deviceId }` on every line, and a
|
|
668
|
+
* bounded restart — so a consumer gets them by construction instead of by
|
|
669
|
+
* remembering.
|
|
670
|
+
*
|
|
671
|
+
* ## What it owns
|
|
672
|
+
*
|
|
673
|
+
* - spawn, with the argv from the ONE builder (`./invocation.js`);
|
|
674
|
+
* - a FIRST-DATA deadline: an ffmpeg that starts but never emits is dead, and
|
|
675
|
+
* nothing downstream can tell that apart from a slow camera;
|
|
676
|
+
* - HARDWARE→SOFTWARE retry, announced at `warn`. A silent downgrade on this
|
|
677
|
+
* hub is a flow bug, not a capability limit — see `docs/design/decode-path.md`;
|
|
678
|
+
* - exit classification (`ok` / `signalled-by-us` / `crashed` / `no-output`);
|
|
679
|
+
* - bounded restart with backoff, and a terminal give-up (never an infinite
|
|
680
|
+
* loop — the same rule `CrashSupervisor` enforces for runners, D6);
|
|
681
|
+
* - SIGTERM then SIGKILL after a grace, gated on the child not having already
|
|
682
|
+
* exited.
|
|
683
|
+
*
|
|
684
|
+
* ## What it does NOT own
|
|
685
|
+
*
|
|
686
|
+
* The output PLUMBING. A consumer attaches to `stdout` / `stderr` itself,
|
|
687
|
+
* because what the bytes mean is the consumer's business: the broker deframes
|
|
688
|
+
* Annex-B into a restreamer, the WebRTC leg regroups access units, HomeKit
|
|
689
|
+
* writes nothing to stdout at all (its output is two RTP sockets). A wrapper
|
|
690
|
+
* that also owned the bytes would need a mode per consumer, which is the same
|
|
691
|
+
* mistake as one builder per consumer.
|
|
692
|
+
*/
|
|
693
|
+
var DEFAULT_FIRST_DATA_TIMEOUT_MS = 8e3;
|
|
694
|
+
var DEFAULT_RESTART_DELAY_MS = 1e3;
|
|
695
|
+
var DEFAULT_STABLE_RUN_MS = 3e4;
|
|
696
|
+
var DEFAULT_KILL_GRACE_MS = 500;
|
|
697
|
+
var STDERR_TAIL_LINES = 12;
|
|
698
|
+
var FfmpegProcess = class {
|
|
699
|
+
opts;
|
|
700
|
+
child = null;
|
|
701
|
+
stopped = false;
|
|
702
|
+
producedOutput = false;
|
|
703
|
+
consecutiveFailures = 0;
|
|
704
|
+
startedAtMs = 0;
|
|
705
|
+
stderrTail = [];
|
|
706
|
+
activeHwAccel;
|
|
707
|
+
triedSoftwareFallback = false;
|
|
708
|
+
firstDataTimer = null;
|
|
709
|
+
constructor(opts) {
|
|
710
|
+
this.opts = opts;
|
|
711
|
+
this.activeHwAccel = opts.decodeHwAccel;
|
|
712
|
+
}
|
|
713
|
+
/** Queryable tags on every line — `deviceId` is never optional. */
|
|
714
|
+
get logTags() {
|
|
715
|
+
return {
|
|
716
|
+
deviceId: this.opts.deviceId,
|
|
717
|
+
...this.opts.tags
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
get now() {
|
|
721
|
+
return (this.opts.now ?? Date.now)();
|
|
722
|
+
}
|
|
723
|
+
/** `true` while a child is running. */
|
|
724
|
+
isRunning() {
|
|
725
|
+
return this.child !== null && !this.stopped;
|
|
726
|
+
}
|
|
727
|
+
/** The backend the CURRENT child decodes with (`null` ⇒ software). */
|
|
728
|
+
activeDecodeHwAccel() {
|
|
729
|
+
return this.activeHwAccel;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Spawn the first child. Resolves as soon as it produces output; rejects if
|
|
733
|
+
* it dies or stays silent past the deadline AFTER the software retry has
|
|
734
|
+
* also been exhausted. A caller that wants fire-and-forget can ignore the
|
|
735
|
+
* promise — the restart loop runs regardless.
|
|
736
|
+
*/
|
|
737
|
+
start() {
|
|
738
|
+
return new Promise((resolve, reject) => {
|
|
739
|
+
this.spawnAttempt(resolve, reject);
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
spawnAttempt(onLive, onDead) {
|
|
743
|
+
if (this.stopped) return;
|
|
744
|
+
const args = [...this.opts.buildArgs(this.activeHwAccel)];
|
|
745
|
+
const spawnFn = this.opts.spawnFn ?? node_child_process.spawn;
|
|
746
|
+
const setTimeoutImpl = this.opts.setTimeoutFn ?? setTimeout;
|
|
747
|
+
this.opts.logger.info(`ffmpeg ${this.opts.role}: spawning`, {
|
|
748
|
+
tags: this.logTags,
|
|
749
|
+
meta: {
|
|
750
|
+
decodeHwAccel: this.activeHwAccel ?? "software",
|
|
751
|
+
attempt: this.consecutiveFailures + 1
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
let child;
|
|
755
|
+
try {
|
|
756
|
+
child = spawnFn(this.opts.binaryPath, args, { stdio: this.opts.stdio ?? [
|
|
757
|
+
"ignore",
|
|
758
|
+
"pipe",
|
|
759
|
+
"pipe"
|
|
760
|
+
] });
|
|
761
|
+
} catch (err) {
|
|
762
|
+
this.handleFailure("crashed", null, null, err, onLive, onDead);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
this.child = child;
|
|
766
|
+
this.startedAtMs = this.now;
|
|
767
|
+
this.producedOutput = false;
|
|
768
|
+
this.stderrTail = [];
|
|
769
|
+
let settled = false;
|
|
770
|
+
const timeoutMs = this.opts.firstDataTimeoutMs ?? DEFAULT_FIRST_DATA_TIMEOUT_MS;
|
|
771
|
+
if (timeoutMs > 0) this.firstDataTimer = setTimeoutImpl(() => {
|
|
772
|
+
if (settled || this.producedOutput || this.stopped) return;
|
|
773
|
+
settled = true;
|
|
774
|
+
this.opts.logger.warn(`ffmpeg ${this.opts.role}: no output within ${timeoutMs}ms`, {
|
|
775
|
+
tags: this.logTags,
|
|
776
|
+
meta: { decodeHwAccel: this.activeHwAccel ?? "software" }
|
|
777
|
+
});
|
|
778
|
+
this.killChild();
|
|
779
|
+
this.handleFailure("no-output", null, null, null, onLive, onDead);
|
|
780
|
+
}, timeoutMs);
|
|
781
|
+
child.stderr?.setEncoding("utf8");
|
|
782
|
+
child.stderr?.on("data", (line) => {
|
|
783
|
+
const text = String(line).trim();
|
|
784
|
+
if (text.length === 0) return;
|
|
785
|
+
this.stderrTail.push(text);
|
|
786
|
+
if (this.stderrTail.length > STDERR_TAIL_LINES) this.stderrTail.shift();
|
|
787
|
+
this.opts.logger.debug(`ffmpeg ${this.opts.role}`, {
|
|
788
|
+
tags: this.logTags,
|
|
789
|
+
meta: { line: text }
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
child.once("error", (err) => {
|
|
793
|
+
if (settled || this.stopped) return;
|
|
794
|
+
settled = true;
|
|
795
|
+
this.handleFailure("crashed", null, null, err, onLive, onDead);
|
|
796
|
+
});
|
|
797
|
+
child.once("exit", (code, signal) => {
|
|
798
|
+
this.clearFirstDataTimer();
|
|
799
|
+
if (this.child === child) this.child = null;
|
|
800
|
+
if (this.stopped) {
|
|
801
|
+
this.report("stopped", code, signal);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (settled && this.producedOutput) {
|
|
805
|
+
this.report("crashed", code, signal);
|
|
806
|
+
this.scheduleRestart(onLive, onDead);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (settled) return;
|
|
810
|
+
settled = true;
|
|
811
|
+
this.handleFailure(this.producedOutput ? "crashed" : "no-output", code, signal, null, onLive, onDead);
|
|
812
|
+
});
|
|
813
|
+
this.opts.onChild(child);
|
|
814
|
+
child.stdout?.once("data", () => {
|
|
815
|
+
this.producedOutput = true;
|
|
816
|
+
this.clearFirstDataTimer();
|
|
817
|
+
if (settled) return;
|
|
818
|
+
settled = true;
|
|
819
|
+
this.opts.logger.info(`ffmpeg ${this.opts.role}: live`, {
|
|
820
|
+
tags: this.logTags,
|
|
821
|
+
meta: { decodeHwAccel: this.activeHwAccel ?? "software" }
|
|
822
|
+
});
|
|
823
|
+
onLive();
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* A child failed before going live. Try SOFTWARE once if it was decoding in
|
|
828
|
+
* hardware — loudly — then fall through to the bounded restart.
|
|
829
|
+
*/
|
|
830
|
+
handleFailure(classification, code, signal, err, onLive, onDead) {
|
|
831
|
+
this.report(classification, code, signal, err);
|
|
832
|
+
if (this.activeHwAccel !== null && !this.triedSoftwareFallback && !this.stopped) {
|
|
833
|
+
this.triedSoftwareFallback = true;
|
|
834
|
+
this.opts.logger.warn(`ffmpeg ${this.opts.role}: hardware decode produced no output — retrying in SOFTWARE`, {
|
|
835
|
+
tags: this.logTags,
|
|
836
|
+
meta: {
|
|
837
|
+
decodeHwAccel: this.activeHwAccel,
|
|
838
|
+
classification,
|
|
839
|
+
code,
|
|
840
|
+
signal,
|
|
841
|
+
stderrTail: this.stderrTail
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
this.activeHwAccel = null;
|
|
845
|
+
this.spawnAttempt(onLive, onDead);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
this.consecutiveFailures += 1;
|
|
849
|
+
const maxRestarts = this.opts.maxRestarts ?? 0;
|
|
850
|
+
if (this.consecutiveFailures > maxRestarts || this.stopped) {
|
|
851
|
+
const reason = `ffmpeg ${this.opts.role} gave up after ${this.consecutiveFailures} attempt(s) (${classification}, code=${code} signal=${signal})`;
|
|
852
|
+
this.opts.logger.error(reason, {
|
|
853
|
+
tags: this.logTags,
|
|
854
|
+
meta: { stderrTail: this.stderrTail }
|
|
855
|
+
});
|
|
856
|
+
onDead(new Error(reason));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
this.scheduleRestart(onLive, onDead);
|
|
860
|
+
}
|
|
861
|
+
scheduleRestart(onLive, onDead) {
|
|
862
|
+
if (this.stopped) return;
|
|
863
|
+
const maxRestarts = this.opts.maxRestarts ?? 0;
|
|
864
|
+
if (maxRestarts === 0) return;
|
|
865
|
+
if (this.producedOutput && this.now - this.startedAtMs >= (this.opts.stableRunMs ?? DEFAULT_STABLE_RUN_MS)) this.consecutiveFailures = 0;
|
|
866
|
+
if (this.consecutiveFailures > maxRestarts) return;
|
|
867
|
+
(this.opts.setTimeoutFn ?? setTimeout)(() => {
|
|
868
|
+
if (this.stopped) return;
|
|
869
|
+
this.spawnAttempt(onLive, onDead);
|
|
870
|
+
}, (this.opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS) * Math.min(this.consecutiveFailures + 1, 8));
|
|
871
|
+
}
|
|
872
|
+
report(classification, code, signal, err) {
|
|
873
|
+
const exit = {
|
|
874
|
+
classification,
|
|
875
|
+
code,
|
|
876
|
+
signal,
|
|
877
|
+
stderrTail: [...this.stderrTail],
|
|
878
|
+
decodeHwAccel: this.activeHwAccel
|
|
879
|
+
};
|
|
880
|
+
if (classification === "crashed" || classification === "no-output") this.opts.logger.warn(`ffmpeg ${this.opts.role} exited: ${classification}`, {
|
|
881
|
+
tags: this.logTags,
|
|
882
|
+
meta: {
|
|
883
|
+
code,
|
|
884
|
+
signal,
|
|
885
|
+
decodeHwAccel: this.activeHwAccel ?? "software",
|
|
886
|
+
error: err instanceof Error ? err.message : err === void 0 ? void 0 : String(err),
|
|
887
|
+
stderrTail: exit.stderrTail
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
this.opts.onExit?.(exit);
|
|
891
|
+
}
|
|
892
|
+
clearFirstDataTimer() {
|
|
893
|
+
if (this.firstDataTimer !== null) {
|
|
894
|
+
clearTimeout(this.firstDataTimer);
|
|
895
|
+
this.firstDataTimer = null;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/** Terminate for good. Idempotent; no restart follows. */
|
|
899
|
+
stop() {
|
|
900
|
+
if (this.stopped) return;
|
|
901
|
+
this.stopped = true;
|
|
902
|
+
this.clearFirstDataTimer();
|
|
903
|
+
this.killChild();
|
|
904
|
+
}
|
|
905
|
+
killChild() {
|
|
906
|
+
const child = this.child;
|
|
907
|
+
this.child = null;
|
|
908
|
+
if (!child) return;
|
|
909
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
910
|
+
try {
|
|
911
|
+
child.kill("SIGTERM");
|
|
912
|
+
} catch {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const grace = (this.opts.setTimeoutFn ?? setTimeout)(() => {
|
|
916
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
917
|
+
try {
|
|
918
|
+
child.kill("SIGKILL");
|
|
919
|
+
} catch {}
|
|
920
|
+
}, this.opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS);
|
|
921
|
+
if (typeof grace === "object" && grace !== null && "unref" in grace) grace.unref();
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
//#endregion
|
|
925
|
+
exports.FfmpegProcess = FfmpegProcess;
|
|
692
926
|
exports.FilesystemStorageProvider = FilesystemStorageProvider;
|
|
693
927
|
exports.PYTHON_VERSION = PYTHON_VERSION;
|
|
694
928
|
exports.buildBinaryPath = buildBinaryPath;
|
|
695
929
|
exports.canonicalDeviceFingerprint = canonicalDeviceFingerprint;
|
|
696
|
-
exports.canonicalHash = canonicalHash;
|
|
930
|
+
exports.canonicalHash = require_canonical_hash.canonicalHash;
|
|
697
931
|
exports.diffExportTargets = diffExportTargets;
|
|
698
932
|
exports.downloadBinary = downloadBinary;
|
|
699
933
|
exports.ensureBinary = ensureBinary;
|
package/dist/node.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import { t as canonicalHash } from "./canonical-hash-7nfBbEqR.mjs";
|
|
1
2
|
import { t as errMsg } from "./err-msg-IQTHeDzc.mjs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
2
4
|
import * as fs from "node:fs";
|
|
3
5
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
6
|
import * as path from "node:path";
|
|
5
7
|
import { basename, join } from "node:path";
|
|
6
8
|
import { pipeline } from "node:stream/promises";
|
|
7
9
|
import { Readable } from "node:stream";
|
|
8
|
-
import { execFileSync } from "node:child_process";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
10
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
10
11
|
//#region src/deps/binary-downloader.ts
|
|
11
12
|
/**
|
|
12
13
|
* Recursively find the first file named exactly `name` under `dir`. Used as the
|
|
@@ -577,39 +578,6 @@ var FilesystemStorageProvider = class {
|
|
|
577
578
|
}
|
|
578
579
|
};
|
|
579
580
|
//#endregion
|
|
580
|
-
//#region src/utils/canonical-hash.ts
|
|
581
|
-
/**
|
|
582
|
-
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
583
|
-
* canonical form sorts object keys alphabetically at every depth so two
|
|
584
|
-
* structurally-equal inputs with different key insertion orders produce
|
|
585
|
-
* the same hash. Returns a 64-char lowercase hex digest.
|
|
586
|
-
*
|
|
587
|
-
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
588
|
-
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
589
|
-
* the last applied state — preventing user-visible "re-discovery"
|
|
590
|
-
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
591
|
-
* `DeviceBindingsChanged` for every cap registration, which without
|
|
592
|
-
* this guard would propagate redundant pushes.
|
|
593
|
-
*
|
|
594
|
-
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
595
|
-
* subscription. The proper fix is a single "device ready" lifecycle
|
|
596
|
-
* barrier so exports react only when the full cap set has landed —
|
|
597
|
-
* tracked separately for post-HA-integration work.
|
|
598
|
-
*/
|
|
599
|
-
function canonicalHash(value) {
|
|
600
|
-
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
601
|
-
return createHash("sha256").update(canonical ?? "").digest("hex");
|
|
602
|
-
}
|
|
603
|
-
function replaceWithSortedKeys(_key, value) {
|
|
604
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
605
|
-
const obj = value;
|
|
606
|
-
const out = {};
|
|
607
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
608
|
-
return out;
|
|
609
|
-
}
|
|
610
|
-
return value;
|
|
611
|
-
}
|
|
612
|
-
//#endregion
|
|
613
581
|
//#region src/utils/export-reconciler.ts
|
|
614
582
|
/**
|
|
615
583
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
@@ -666,4 +634,269 @@ function resolveExportFingerprint(input) {
|
|
|
666
634
|
return input.persisted ?? input.fresh;
|
|
667
635
|
}
|
|
668
636
|
//#endregion
|
|
669
|
-
|
|
637
|
+
//#region src/ffmpeg/process.ts
|
|
638
|
+
/**
|
|
639
|
+
* `FfmpegProcess` — the ONE spawn/lifecycle wrapper for a live-media ffmpeg.
|
|
640
|
+
*
|
|
641
|
+
* Extracted from `TranscodeEgress.spawnAttempt`, which was already the most
|
|
642
|
+
* complete of the repo's hand-rolled lifecycles: first-data deadline, hardware
|
|
643
|
+
* →software retry, SIGTERM-then-SIGKILL. This generalises it and adds the two
|
|
644
|
+
* things every copy was missing — a `tags: { deviceId }` on every line, and a
|
|
645
|
+
* bounded restart — so a consumer gets them by construction instead of by
|
|
646
|
+
* remembering.
|
|
647
|
+
*
|
|
648
|
+
* ## What it owns
|
|
649
|
+
*
|
|
650
|
+
* - spawn, with the argv from the ONE builder (`./invocation.js`);
|
|
651
|
+
* - a FIRST-DATA deadline: an ffmpeg that starts but never emits is dead, and
|
|
652
|
+
* nothing downstream can tell that apart from a slow camera;
|
|
653
|
+
* - HARDWARE→SOFTWARE retry, announced at `warn`. A silent downgrade on this
|
|
654
|
+
* hub is a flow bug, not a capability limit — see `docs/design/decode-path.md`;
|
|
655
|
+
* - exit classification (`ok` / `signalled-by-us` / `crashed` / `no-output`);
|
|
656
|
+
* - bounded restart with backoff, and a terminal give-up (never an infinite
|
|
657
|
+
* loop — the same rule `CrashSupervisor` enforces for runners, D6);
|
|
658
|
+
* - SIGTERM then SIGKILL after a grace, gated on the child not having already
|
|
659
|
+
* exited.
|
|
660
|
+
*
|
|
661
|
+
* ## What it does NOT own
|
|
662
|
+
*
|
|
663
|
+
* The output PLUMBING. A consumer attaches to `stdout` / `stderr` itself,
|
|
664
|
+
* because what the bytes mean is the consumer's business: the broker deframes
|
|
665
|
+
* Annex-B into a restreamer, the WebRTC leg regroups access units, HomeKit
|
|
666
|
+
* writes nothing to stdout at all (its output is two RTP sockets). A wrapper
|
|
667
|
+
* that also owned the bytes would need a mode per consumer, which is the same
|
|
668
|
+
* mistake as one builder per consumer.
|
|
669
|
+
*/
|
|
670
|
+
var DEFAULT_FIRST_DATA_TIMEOUT_MS = 8e3;
|
|
671
|
+
var DEFAULT_RESTART_DELAY_MS = 1e3;
|
|
672
|
+
var DEFAULT_STABLE_RUN_MS = 3e4;
|
|
673
|
+
var DEFAULT_KILL_GRACE_MS = 500;
|
|
674
|
+
var STDERR_TAIL_LINES = 12;
|
|
675
|
+
var FfmpegProcess = class {
|
|
676
|
+
opts;
|
|
677
|
+
child = null;
|
|
678
|
+
stopped = false;
|
|
679
|
+
producedOutput = false;
|
|
680
|
+
consecutiveFailures = 0;
|
|
681
|
+
startedAtMs = 0;
|
|
682
|
+
stderrTail = [];
|
|
683
|
+
activeHwAccel;
|
|
684
|
+
triedSoftwareFallback = false;
|
|
685
|
+
firstDataTimer = null;
|
|
686
|
+
constructor(opts) {
|
|
687
|
+
this.opts = opts;
|
|
688
|
+
this.activeHwAccel = opts.decodeHwAccel;
|
|
689
|
+
}
|
|
690
|
+
/** Queryable tags on every line — `deviceId` is never optional. */
|
|
691
|
+
get logTags() {
|
|
692
|
+
return {
|
|
693
|
+
deviceId: this.opts.deviceId,
|
|
694
|
+
...this.opts.tags
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
get now() {
|
|
698
|
+
return (this.opts.now ?? Date.now)();
|
|
699
|
+
}
|
|
700
|
+
/** `true` while a child is running. */
|
|
701
|
+
isRunning() {
|
|
702
|
+
return this.child !== null && !this.stopped;
|
|
703
|
+
}
|
|
704
|
+
/** The backend the CURRENT child decodes with (`null` ⇒ software). */
|
|
705
|
+
activeDecodeHwAccel() {
|
|
706
|
+
return this.activeHwAccel;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Spawn the first child. Resolves as soon as it produces output; rejects if
|
|
710
|
+
* it dies or stays silent past the deadline AFTER the software retry has
|
|
711
|
+
* also been exhausted. A caller that wants fire-and-forget can ignore the
|
|
712
|
+
* promise — the restart loop runs regardless.
|
|
713
|
+
*/
|
|
714
|
+
start() {
|
|
715
|
+
return new Promise((resolve, reject) => {
|
|
716
|
+
this.spawnAttempt(resolve, reject);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
spawnAttempt(onLive, onDead) {
|
|
720
|
+
if (this.stopped) return;
|
|
721
|
+
const args = [...this.opts.buildArgs(this.activeHwAccel)];
|
|
722
|
+
const spawnFn = this.opts.spawnFn ?? spawn;
|
|
723
|
+
const setTimeoutImpl = this.opts.setTimeoutFn ?? setTimeout;
|
|
724
|
+
this.opts.logger.info(`ffmpeg ${this.opts.role}: spawning`, {
|
|
725
|
+
tags: this.logTags,
|
|
726
|
+
meta: {
|
|
727
|
+
decodeHwAccel: this.activeHwAccel ?? "software",
|
|
728
|
+
attempt: this.consecutiveFailures + 1
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
let child;
|
|
732
|
+
try {
|
|
733
|
+
child = spawnFn(this.opts.binaryPath, args, { stdio: this.opts.stdio ?? [
|
|
734
|
+
"ignore",
|
|
735
|
+
"pipe",
|
|
736
|
+
"pipe"
|
|
737
|
+
] });
|
|
738
|
+
} catch (err) {
|
|
739
|
+
this.handleFailure("crashed", null, null, err, onLive, onDead);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
this.child = child;
|
|
743
|
+
this.startedAtMs = this.now;
|
|
744
|
+
this.producedOutput = false;
|
|
745
|
+
this.stderrTail = [];
|
|
746
|
+
let settled = false;
|
|
747
|
+
const timeoutMs = this.opts.firstDataTimeoutMs ?? DEFAULT_FIRST_DATA_TIMEOUT_MS;
|
|
748
|
+
if (timeoutMs > 0) this.firstDataTimer = setTimeoutImpl(() => {
|
|
749
|
+
if (settled || this.producedOutput || this.stopped) return;
|
|
750
|
+
settled = true;
|
|
751
|
+
this.opts.logger.warn(`ffmpeg ${this.opts.role}: no output within ${timeoutMs}ms`, {
|
|
752
|
+
tags: this.logTags,
|
|
753
|
+
meta: { decodeHwAccel: this.activeHwAccel ?? "software" }
|
|
754
|
+
});
|
|
755
|
+
this.killChild();
|
|
756
|
+
this.handleFailure("no-output", null, null, null, onLive, onDead);
|
|
757
|
+
}, timeoutMs);
|
|
758
|
+
child.stderr?.setEncoding("utf8");
|
|
759
|
+
child.stderr?.on("data", (line) => {
|
|
760
|
+
const text = String(line).trim();
|
|
761
|
+
if (text.length === 0) return;
|
|
762
|
+
this.stderrTail.push(text);
|
|
763
|
+
if (this.stderrTail.length > STDERR_TAIL_LINES) this.stderrTail.shift();
|
|
764
|
+
this.opts.logger.debug(`ffmpeg ${this.opts.role}`, {
|
|
765
|
+
tags: this.logTags,
|
|
766
|
+
meta: { line: text }
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
child.once("error", (err) => {
|
|
770
|
+
if (settled || this.stopped) return;
|
|
771
|
+
settled = true;
|
|
772
|
+
this.handleFailure("crashed", null, null, err, onLive, onDead);
|
|
773
|
+
});
|
|
774
|
+
child.once("exit", (code, signal) => {
|
|
775
|
+
this.clearFirstDataTimer();
|
|
776
|
+
if (this.child === child) this.child = null;
|
|
777
|
+
if (this.stopped) {
|
|
778
|
+
this.report("stopped", code, signal);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (settled && this.producedOutput) {
|
|
782
|
+
this.report("crashed", code, signal);
|
|
783
|
+
this.scheduleRestart(onLive, onDead);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (settled) return;
|
|
787
|
+
settled = true;
|
|
788
|
+
this.handleFailure(this.producedOutput ? "crashed" : "no-output", code, signal, null, onLive, onDead);
|
|
789
|
+
});
|
|
790
|
+
this.opts.onChild(child);
|
|
791
|
+
child.stdout?.once("data", () => {
|
|
792
|
+
this.producedOutput = true;
|
|
793
|
+
this.clearFirstDataTimer();
|
|
794
|
+
if (settled) return;
|
|
795
|
+
settled = true;
|
|
796
|
+
this.opts.logger.info(`ffmpeg ${this.opts.role}: live`, {
|
|
797
|
+
tags: this.logTags,
|
|
798
|
+
meta: { decodeHwAccel: this.activeHwAccel ?? "software" }
|
|
799
|
+
});
|
|
800
|
+
onLive();
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* A child failed before going live. Try SOFTWARE once if it was decoding in
|
|
805
|
+
* hardware — loudly — then fall through to the bounded restart.
|
|
806
|
+
*/
|
|
807
|
+
handleFailure(classification, code, signal, err, onLive, onDead) {
|
|
808
|
+
this.report(classification, code, signal, err);
|
|
809
|
+
if (this.activeHwAccel !== null && !this.triedSoftwareFallback && !this.stopped) {
|
|
810
|
+
this.triedSoftwareFallback = true;
|
|
811
|
+
this.opts.logger.warn(`ffmpeg ${this.opts.role}: hardware decode produced no output — retrying in SOFTWARE`, {
|
|
812
|
+
tags: this.logTags,
|
|
813
|
+
meta: {
|
|
814
|
+
decodeHwAccel: this.activeHwAccel,
|
|
815
|
+
classification,
|
|
816
|
+
code,
|
|
817
|
+
signal,
|
|
818
|
+
stderrTail: this.stderrTail
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
this.activeHwAccel = null;
|
|
822
|
+
this.spawnAttempt(onLive, onDead);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
this.consecutiveFailures += 1;
|
|
826
|
+
const maxRestarts = this.opts.maxRestarts ?? 0;
|
|
827
|
+
if (this.consecutiveFailures > maxRestarts || this.stopped) {
|
|
828
|
+
const reason = `ffmpeg ${this.opts.role} gave up after ${this.consecutiveFailures} attempt(s) (${classification}, code=${code} signal=${signal})`;
|
|
829
|
+
this.opts.logger.error(reason, {
|
|
830
|
+
tags: this.logTags,
|
|
831
|
+
meta: { stderrTail: this.stderrTail }
|
|
832
|
+
});
|
|
833
|
+
onDead(new Error(reason));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
this.scheduleRestart(onLive, onDead);
|
|
837
|
+
}
|
|
838
|
+
scheduleRestart(onLive, onDead) {
|
|
839
|
+
if (this.stopped) return;
|
|
840
|
+
const maxRestarts = this.opts.maxRestarts ?? 0;
|
|
841
|
+
if (maxRestarts === 0) return;
|
|
842
|
+
if (this.producedOutput && this.now - this.startedAtMs >= (this.opts.stableRunMs ?? DEFAULT_STABLE_RUN_MS)) this.consecutiveFailures = 0;
|
|
843
|
+
if (this.consecutiveFailures > maxRestarts) return;
|
|
844
|
+
(this.opts.setTimeoutFn ?? setTimeout)(() => {
|
|
845
|
+
if (this.stopped) return;
|
|
846
|
+
this.spawnAttempt(onLive, onDead);
|
|
847
|
+
}, (this.opts.restartDelayMs ?? DEFAULT_RESTART_DELAY_MS) * Math.min(this.consecutiveFailures + 1, 8));
|
|
848
|
+
}
|
|
849
|
+
report(classification, code, signal, err) {
|
|
850
|
+
const exit = {
|
|
851
|
+
classification,
|
|
852
|
+
code,
|
|
853
|
+
signal,
|
|
854
|
+
stderrTail: [...this.stderrTail],
|
|
855
|
+
decodeHwAccel: this.activeHwAccel
|
|
856
|
+
};
|
|
857
|
+
if (classification === "crashed" || classification === "no-output") this.opts.logger.warn(`ffmpeg ${this.opts.role} exited: ${classification}`, {
|
|
858
|
+
tags: this.logTags,
|
|
859
|
+
meta: {
|
|
860
|
+
code,
|
|
861
|
+
signal,
|
|
862
|
+
decodeHwAccel: this.activeHwAccel ?? "software",
|
|
863
|
+
error: err instanceof Error ? err.message : err === void 0 ? void 0 : String(err),
|
|
864
|
+
stderrTail: exit.stderrTail
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
this.opts.onExit?.(exit);
|
|
868
|
+
}
|
|
869
|
+
clearFirstDataTimer() {
|
|
870
|
+
if (this.firstDataTimer !== null) {
|
|
871
|
+
clearTimeout(this.firstDataTimer);
|
|
872
|
+
this.firstDataTimer = null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/** Terminate for good. Idempotent; no restart follows. */
|
|
876
|
+
stop() {
|
|
877
|
+
if (this.stopped) return;
|
|
878
|
+
this.stopped = true;
|
|
879
|
+
this.clearFirstDataTimer();
|
|
880
|
+
this.killChild();
|
|
881
|
+
}
|
|
882
|
+
killChild() {
|
|
883
|
+
const child = this.child;
|
|
884
|
+
this.child = null;
|
|
885
|
+
if (!child) return;
|
|
886
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
887
|
+
try {
|
|
888
|
+
child.kill("SIGTERM");
|
|
889
|
+
} catch {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const grace = (this.opts.setTimeoutFn ?? setTimeout)(() => {
|
|
893
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
894
|
+
try {
|
|
895
|
+
child.kill("SIGKILL");
|
|
896
|
+
} catch {}
|
|
897
|
+
}, this.opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS);
|
|
898
|
+
if (typeof grace === "object" && grace !== null && "unref" in grace) grace.unref();
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
//#endregion
|
|
902
|
+
export { FfmpegProcess, FilesystemStorageProvider, PYTHON_VERSION, buildBinaryPath, canonicalDeviceFingerprint, canonicalHash, diffExportTargets, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements, resolveExportFingerprint };
|