@camstack/addon-export-hap 1.2.121 → 1.2.123

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.
@@ -5445,7 +5445,7 @@ var ZodIssueCode = {
5445
5445
  var ZodFirstPartyTypeKind;
5446
5446
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5447
5447
  //#endregion
5448
- //#region ../types/dist/sleep-BDR76Ykr.mjs
5448
+ //#region ../types/dist/sleep-B1y0Fo1U.mjs
5449
5449
  /**
5450
5450
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5451
5451
  * window to float samples (D455).
@@ -7446,7 +7446,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7446
7446
  input: unknown()
7447
7447
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7448
7448
  //#endregion
7449
- //#region ../types/dist/canonical-hash-Ch5bSjFG.mjs
7449
+ //#region ../types/dist/canonical-hash-DBkl8q6V.mjs
7450
7450
  var AUDIO_ENCODER_BY_CODEC = {
7451
7451
  opus: "libopus",
7452
7452
  aac: "aac",
@@ -7511,8 +7511,41 @@ function buildInputArgs(input, decodeHwAccel) {
7511
7511
  args.push("-i", input.url);
7512
7512
  return args;
7513
7513
  }
7514
+ /**
7515
+ * Derive the output `-map` selectors. The three cases, in the order they are
7516
+ * decided:
7517
+ *
7518
+ * 1. **A filter graph** — the picture is the graph's declared video pad, and
7519
+ * the audio is its audio pad when it has one, otherwise input 0's mic. A
7520
+ * graph's output is not addressable any other way.
7521
+ * 2. **N inputs, no graph** — map input 0 EXPLICITLY. ffmpeg's own selection
7522
+ * picks the "best" stream across ALL inputs, so the egress would silently
7523
+ * become whichever camera happens to have the largest frame.
7524
+ * 3. **One input, no graph** — no `-map`. Anything else would change the argv
7525
+ * of every call site that exists today for no gain.
7526
+ */
7527
+ function resolveStreamMaps(selection) {
7528
+ const graph = selection.filterGraph;
7529
+ if (graph) return {
7530
+ video: `[${graph.videoOutLabel}]`,
7531
+ audio: graph.audioOutLabel ? `[${graph.audioOutLabel}]` : "0:a:0?"
7532
+ };
7533
+ if (selection.extraInputs?.length) return {
7534
+ video: "0:v:0",
7535
+ audio: "0:a:0?"
7536
+ };
7537
+ return {
7538
+ video: null,
7539
+ audio: null
7540
+ };
7541
+ }
7514
7542
  /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7515
- function buildVideoFilterArgs(scale, outputArgs) {
7543
+ function buildVideoFilterArgs(scale, outputArgs, filterGraph) {
7544
+ if (filterGraph) {
7545
+ if (scale) throw new Error("FfmpegInvocation: video.scale cannot be combined with a filter graph — scale inside the graph instead (the -vf would feed an output the graph already feeds)");
7546
+ if (outputArgs.some((a) => a === "-vf")) throw new Error("FfmpegInvocation: outputArgs carries a -vf while a filter graph feeds the same output — fold the filter into the graph");
7547
+ return [];
7548
+ }
7516
7549
  if (!scale) return [];
7517
7550
  if (outputArgs.some((a) => a === "-vf")) return [];
7518
7551
  if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
@@ -7536,14 +7569,17 @@ function buildRateControlArgs(video) {
7536
7569
  ];
7537
7570
  }
7538
7571
  /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7539
- function buildVideoArgs(video, outputArgs) {
7540
- if (video.kind === "copy") return [
7541
- "-c:v",
7542
- "copy",
7543
- ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7544
- ];
7572
+ function buildVideoArgs(video, outputArgs, filterGraph) {
7573
+ if (video.kind === "copy") {
7574
+ if (filterGraph) throw new Error("FfmpegInvocation: a filter graph produces a NEW picture, so video.kind must be `encode`");
7575
+ return [
7576
+ "-c:v",
7577
+ "copy",
7578
+ ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7579
+ ];
7580
+ }
7545
7581
  const args = [
7546
- ...buildVideoFilterArgs(video.scale, outputArgs),
7582
+ ...buildVideoFilterArgs(video.scale, outputArgs, filterGraph),
7547
7583
  "-c:v",
7548
7584
  video.encoder
7549
7585
  ];
@@ -7698,20 +7734,39 @@ function buildAudioSidecarArgs(sidecar) {
7698
7734
  * [<input.extraArgs>] │
7699
7735
  * [-fflags <flag>…] │
7700
7736
  * [-rtsp_transport tcp] │
7701
- * -i <url> ─┘
7702
- * <video block> <threads> <audio block> ─┐ OUTPUT options.
7737
+ * -i <url> ─┘ …repeated per extraInput
7738
+ * [-filter_complex <graph>] ─ global, after the LAST -i
7739
+ * [-map <video>] <video block> <threads> ─┐ OUTPUT options.
7740
+ * [-map <audio>] <audio block> │
7703
7741
  * <consumer outputArgs verbatim> │
7704
7742
  * <sink> ─┘ terminal
7743
+ *
7744
+ * The `-map`s are DERIVED (see {@link resolveStreamMaps}), never constants:
7745
+ * one input with no graph emits none at all, which is why the argv of every
7746
+ * call site that predates the N-input extension is unchanged to the byte.
7747
+ *
7748
+ * {@link FfmpegInvocation.audioSidecar} is the one map that stays literal
7749
+ * (`0:a:0?`): the sidecar is defined as the SOURCE's microphone on its own RTP
7750
+ * output, not as whatever the main output happens to carry.
7705
7751
  */
7706
7752
  function buildFfmpegArgs(inv) {
7707
- const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
7753
+ const graph = inv.filterGraph ?? null;
7754
+ const head = [
7755
+ ...logBannerArgs(inv.logLevel),
7756
+ ...buildInputArgs(inv.input, inv.decodeHwAccel),
7757
+ ...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
7758
+ ...graph ? ["-filter_complex", graph.graph] : []
7759
+ ];
7708
7760
  const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7761
+ const maps = resolveStreamMaps(inv);
7762
+ const videoMapArgs = maps.video ? ["-map", maps.video] : [];
7763
+ const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
7709
7764
  if (inv.sink.kind === "rtp-outputs") {
7710
7765
  const videoLeg = inv.sink.video ? [
7711
7766
  "-an",
7712
7767
  "-map",
7713
- "0:v:0",
7714
- ...buildVideoArgs(inv.video, inv.outputArgs),
7768
+ maps.video ?? "0:v:0",
7769
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7715
7770
  ...threadArgs,
7716
7771
  ...inv.outputArgs,
7717
7772
  ...buildRtpOutputArgs(inv.sink.video)
@@ -7719,7 +7774,7 @@ function buildFfmpegArgs(inv) {
7719
7774
  const audioLeg = inv.sink.audio ? [
7720
7775
  "-vn",
7721
7776
  "-map",
7722
- "0:a:0?",
7777
+ maps.audio ?? "0:a:0?",
7723
7778
  ...buildAudioArgs(inv.audio),
7724
7779
  ...buildRtpOutputArgs(inv.sink.audio)
7725
7780
  ] : [];
@@ -7729,15 +7784,17 @@ function buildFfmpegArgs(inv) {
7729
7784
  ...audioLeg
7730
7785
  ];
7731
7786
  }
7787
+ const mutedAudio = isElementaryVideoSink(inv.sink) || inv.audio.kind === "none";
7732
7788
  const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7733
- const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7734
7789
  return [
7735
7790
  ...head,
7736
- ...buildVideoArgs(inv.video, inv.outputArgs),
7791
+ ...videoMapArgs,
7792
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7737
7793
  ...threadArgs,
7794
+ ...mutedAudio ? [] : audioMapArgs,
7738
7795
  ...audioArgs,
7739
7796
  ...inv.outputArgs,
7740
- ...sinkArgs,
7797
+ ...buildStdoutOrRtspSinkArgs(inv.sink),
7741
7798
  ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7742
7799
  ];
7743
7800
  }
@@ -29596,14 +29653,81 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
29596
29653
  kind: "mutation",
29597
29654
  auth: "admin"
29598
29655
  });
29656
+ /**
29657
+ * What a preset id DOES on this camera.
29658
+ *
29659
+ * On several PTZ firmwares the preset namespace is shared: low ids are stored
29660
+ * positions, a reserved band triggers camera FUNCTIONS. The dispensa camera
29661
+ * (Hikvision) returns its own reserved band in the preset list, named by the
29662
+ * firmware:
29663
+ *
29664
+ * 34 Back to origin · 39 Day mode · 40 Night mode · 46 Day/Night Auto Mode
29665
+ * 92 Set manual limits · 93 Save manual limits · 94 Remote reboot
29666
+ * 95 Call OSD menu
29667
+ *
29668
+ * We used to hand all of these to the UI as ordinary presets. Saving over one
29669
+ * is what produced `PUT /ISAPI/PTZCtrl/channels/1/presets/34 -> HTTP 500`: the
29670
+ * camera refusing to let "Back to origin" be overwritten, surfaced to the
29671
+ * operator as a transport failure.
29672
+ *
29673
+ * A function preset stays fully usable as a DESTINATION -- `goToPreset('34')`
29674
+ * is the single most useful thing that camera can do. Only `savePreset` and
29675
+ * `deletePreset` are gated.
29676
+ */
29677
+ var PtzPresetFunctionSchema = _enum([
29678
+ "home",
29679
+ "day-mode",
29680
+ "night-mode",
29681
+ "day-night-auto",
29682
+ "set-limits",
29683
+ "save-limits",
29684
+ "reboot",
29685
+ "osd-menu"
29686
+ ]);
29599
29687
  var PtzPresetSchema = object({
29600
29688
  id: string(),
29601
- name: string()
29689
+ name: string(),
29690
+ /**
29691
+ * The firmware function this id performs, when the provider KNOWS the id is
29692
+ * reserved. `null` = an ordinary stored position, OR a reserved id this
29693
+ * provider does not recognise -- the two are told apart by `writable`.
29694
+ */
29695
+ fn: PtzPresetFunctionSchema.nullable(),
29696
+ /**
29697
+ * Whether `savePreset` / `deletePreset` may target this id. A provider sets
29698
+ * it false only for a band it KNOWS is reserved; where the firmware is not
29699
+ * documented it stays true and a refusal comes from the camera, named.
29700
+ */
29701
+ writable: boolean()
29602
29702
  });
29703
+ /**
29704
+ * Where the head is pointing -- per axis, and `null` when the driver cannot
29705
+ * read it.
29706
+ *
29707
+ * It used to be three REQUIRED numbers, which left a provider that cannot read
29708
+ * position no way to say so. All four said `0`:
29709
+ *
29710
+ * reolink `return { pan: 0, tilt: 0, zoom: 0 }` "until a position-query
29711
+ * path lands upstream"
29712
+ * hikvision stub, "firmware-dependent and frequently absent"
29713
+ * amcrest stub, "no generic absolute-position read for this model family"
29714
+ * onvif throws
29715
+ *
29716
+ * So every consumer asking where a camera points was told "perfectly centred"
29717
+ * by four cameras that had never looked. That is D393 -- a measurement that
29718
+ * FAILED is `null`, never `0`, and the type says so all the way to the
29719
+ * decision -- the same shape as the unreadable `statfs` folded into "0 bytes of
29720
+ * headroom", which evacuated a healthy disk.
29721
+ *
29722
+ * D393 also says to check for a narrower structural TWIN of the result type.
29723
+ * There is one: `PtzStatusSchema` extends this, so the lie had already
29724
+ * propagated into `getStatus`, which is the surface `getPosition`'s own comment
29725
+ * tells callers to migrate to.
29726
+ */
29603
29727
  var PtzPositionSchema = object({
29604
- pan: number(),
29605
- tilt: number(),
29606
- zoom: number()
29728
+ pan: number().nullable(),
29729
+ tilt: number().nullable(),
29730
+ zoom: number().nullable()
29607
29731
  });
29608
29732
  var PtzMoveCommandSchema = object({
29609
29733
  pan: number().optional(),
@@ -29622,7 +29746,72 @@ var PtzOptionsSchema = object({
29622
29746
  maxPresets: number().optional(),
29623
29747
  /** Whether the camera exposes a controllable autofocus toggle
29624
29748
  * (boolean `hasX` per the getOptions availability convention). */
29625
- hasAutofocus: boolean()
29749
+ hasAutofocus: boolean(),
29750
+ /**
29751
+ * How many distinct speeds this camera's NATIVE scale offers.
29752
+ *
29753
+ * The cap's `speed` is normalized 0..1 and each provider maps it down --
29754
+ * Baichuan 1..63, Dahua 1..8, ISAPI's percentage -100..100. The normalized
29755
+ * value hides how coarse that really is: asking an amcrest for `0.37` is
29756
+ * meaningless, because it has eight steps and three of them round to the
29757
+ * same one. A loop that tunes its own gain has to know the granularity of
29758
+ * the knob it is turning.
29759
+ *
29760
+ * `null` = the driver does not know, or the scale is continuous (ONVIF takes
29761
+ * a float). Never a made-up number.
29762
+ */
29763
+ speedSteps: number().int().positive().nullable(),
29764
+ /**
29765
+ * How long ONE `move` pulse runs on this driver, ms.
29766
+ *
29767
+ * `move` is a self-terminating burst everywhere, but every provider hardcoded
29768
+ * its own duration in private -- 200 reolink, 350 amcrest, 500 hikvision,
29769
+ * 1000 onvif -- so no caller could read it. It is the DENOMINATOR of "how far
29770
+ * did the head travel per pulse": without it a measured displacement has no
29771
+ * gain to be divided into.
29772
+ *
29773
+ * `null` = the driver cannot say.
29774
+ */
29775
+ moveImpulseMs: number().int().positive().nullable()
29776
+ });
29777
+ /**
29778
+ * Which preset `goHome` goes to, and WHO decided.
29779
+ *
29780
+ * `goHome` used to be three vendor "conventions" written in three comments --
29781
+ * Reolink preset 0, Hikvision preset '1', Dahua preset 1 -- and on this fleet
29782
+ * not one of the three cameras honours its own:
29783
+ *
29784
+ * 592 Videocamera camera Daniel (reolink) preset 0 holds "stanza"
29785
+ * 1438 Videocamera dispensa (hikvision) preset 1 holds "Credenza"
29786
+ * 3836 Videocamera studio (amcrest) preset 1 holds "Preset1"
29787
+ *
29788
+ * So `goHome` meant "go wherever the operator happened to save in slot 0 or 1",
29789
+ * and two of the three swallowed the failure in a bare `catch`. For autotrack
29790
+ * this is the HOTTEST path -- it runs every time a subject is released -- so it
29791
+ * needs an answer that is true per camera and audible when it is missing.
29792
+ *
29793
+ * `source` is what makes it honest:
29794
+ * - `operator` -- picked in the UI, stored by the provider.
29795
+ * - `firmware` -- the camera itself named a preset `fn: 'home'` (Hikvision's
29796
+ * "Back to origin"), adopted as the default with nothing to configure.
29797
+ * - `native` -- the driver homes WITHOUT a preset at all. ONVIF does:
29798
+ * `goHome` is `ptzAbsoluteMove({x:0, y:0, zoom:0})`. `presetId` is null and
29799
+ * that is not a failure -- there is nothing to pick, and the UI must offer
29800
+ * no picker.
29801
+ * - `none` -- nothing is configured and the camera names nothing.
29802
+ * `goHome` REFUSES rather than moving the head somewhere arbitrary.
29803
+ *
29804
+ * `source === 'none'` is the refusal condition, NOT `presetId === null`: the
29805
+ * native case has no preset and homes perfectly well.
29806
+ */
29807
+ var PtzHomePresetSchema = object({
29808
+ presetId: string().nullable(),
29809
+ source: _enum([
29810
+ "operator",
29811
+ "firmware",
29812
+ "native",
29813
+ "none"
29814
+ ])
29626
29815
  });
29627
29816
  DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(PtzPresetSchema)), method(object({
29628
29817
  deviceId: number(),
@@ -29640,7 +29829,13 @@ DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _
29640
29829
  }), _void(), {
29641
29830
  kind: "mutation",
29642
29831
  auth: "admin"
29643
- }), method(object({ deviceId: number() }), PtzOptionsSchema), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzPositionSchema), method(object({
29832
+ }), method(object({ deviceId: number() }), PtzOptionsSchema), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzHomePresetSchema), method(object({
29833
+ deviceId: number(),
29834
+ presetId: string().nullable()
29835
+ }), _void(), {
29836
+ kind: "mutation",
29837
+ auth: "admin"
29838
+ }), method(object({ deviceId: number() }), PtzPositionSchema), method(object({
29644
29839
  deviceId: number(),
29645
29840
  enabled: boolean()
29646
29841
  }), _void(), { kind: "mutation" });
@@ -36680,6 +36875,12 @@ Object.freeze({
36680
36875
  addonId: null,
36681
36876
  access: "delete"
36682
36877
  },
36878
+ "ptz.getHomePreset": {
36879
+ capName: "ptz",
36880
+ capScope: "device",
36881
+ addonId: null,
36882
+ access: "view"
36883
+ },
36683
36884
  "ptz.getOptions": {
36684
36885
  capName: "ptz",
36685
36886
  capScope: "device",
@@ -36728,6 +36929,12 @@ Object.freeze({
36728
36929
  addonId: null,
36729
36930
  access: "create"
36730
36931
  },
36932
+ "ptz.setHomePreset": {
36933
+ capName: "ptz",
36934
+ capScope: "device",
36935
+ addonId: null,
36936
+ access: "create"
36937
+ },
36731
36938
  "ptz.stop": {
36732
36939
  capName: "ptz",
36733
36940
  capScope: "device",
@@ -39901,6 +40108,11 @@ Object.freeze({
39901
40108
  form: "single",
39902
40109
  optional: false
39903
40110
  }],
40111
+ "ptz.getHomePreset": [{
40112
+ name: "deviceId",
40113
+ form: "single",
40114
+ optional: false
40115
+ }],
39904
40116
  "ptz.getOptions": [{
39905
40117
  name: "deviceId",
39906
40118
  form: "single",
@@ -39941,6 +40153,11 @@ Object.freeze({
39941
40153
  form: "single",
39942
40154
  optional: false
39943
40155
  }],
40156
+ "ptz.setHomePreset": [{
40157
+ name: "deviceId",
40158
+ form: "single",
40159
+ optional: false
40160
+ }],
39944
40161
  "ptz.stop": [{
39945
40162
  name: "deviceId",
39946
40163
  form: "single",
@@ -101755,10 +101972,19 @@ async function readPresets(bctx) {
101755
101972
  try {
101756
101973
  const raw = await proxy.ptz?.getPresets({});
101757
101974
  if (!Array.isArray(raw)) return [];
101758
- return raw.filter((p) => typeof p === "object" && p !== null && typeof p.id === "string" && typeof p.name === "string").map((p) => ({
101759
- id: p.id,
101760
- name: p.name
101761
- }));
101975
+ const entries = [];
101976
+ for (const p of raw) {
101977
+ if (typeof p !== "object" || p === null) continue;
101978
+ const candidate = p;
101979
+ if (typeof candidate.id !== "string" || typeof candidate.name !== "string") continue;
101980
+ const fn = candidate.fn;
101981
+ if (fn !== null && fn !== void 0 && fn !== "home") continue;
101982
+ entries.push({
101983
+ id: candidate.id,
101984
+ name: candidate.name
101985
+ });
101986
+ }
101987
+ return entries;
101762
101988
  } catch (err) {
101763
101989
  ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$8(err) } });
101764
101990
  return [];
@@ -5433,7 +5433,7 @@ var ZodIssueCode = {
5433
5433
  var ZodFirstPartyTypeKind;
5434
5434
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5435
5435
  //#endregion
5436
- //#region ../types/dist/sleep-BDR76Ykr.mjs
5436
+ //#region ../types/dist/sleep-B1y0Fo1U.mjs
5437
5437
  /**
5438
5438
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5439
5439
  * window to float samples (D455).
@@ -7434,7 +7434,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7434
7434
  input: unknown()
7435
7435
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7436
7436
  //#endregion
7437
- //#region ../types/dist/canonical-hash-Ch5bSjFG.mjs
7437
+ //#region ../types/dist/canonical-hash-DBkl8q6V.mjs
7438
7438
  var AUDIO_ENCODER_BY_CODEC = {
7439
7439
  opus: "libopus",
7440
7440
  aac: "aac",
@@ -7499,8 +7499,41 @@ function buildInputArgs(input, decodeHwAccel) {
7499
7499
  args.push("-i", input.url);
7500
7500
  return args;
7501
7501
  }
7502
+ /**
7503
+ * Derive the output `-map` selectors. The three cases, in the order they are
7504
+ * decided:
7505
+ *
7506
+ * 1. **A filter graph** — the picture is the graph's declared video pad, and
7507
+ * the audio is its audio pad when it has one, otherwise input 0's mic. A
7508
+ * graph's output is not addressable any other way.
7509
+ * 2. **N inputs, no graph** — map input 0 EXPLICITLY. ffmpeg's own selection
7510
+ * picks the "best" stream across ALL inputs, so the egress would silently
7511
+ * become whichever camera happens to have the largest frame.
7512
+ * 3. **One input, no graph** — no `-map`. Anything else would change the argv
7513
+ * of every call site that exists today for no gain.
7514
+ */
7515
+ function resolveStreamMaps(selection) {
7516
+ const graph = selection.filterGraph;
7517
+ if (graph) return {
7518
+ video: `[${graph.videoOutLabel}]`,
7519
+ audio: graph.audioOutLabel ? `[${graph.audioOutLabel}]` : "0:a:0?"
7520
+ };
7521
+ if (selection.extraInputs?.length) return {
7522
+ video: "0:v:0",
7523
+ audio: "0:a:0?"
7524
+ };
7525
+ return {
7526
+ video: null,
7527
+ audio: null
7528
+ };
7529
+ }
7502
7530
  /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7503
- function buildVideoFilterArgs(scale, outputArgs) {
7531
+ function buildVideoFilterArgs(scale, outputArgs, filterGraph) {
7532
+ if (filterGraph) {
7533
+ if (scale) throw new Error("FfmpegInvocation: video.scale cannot be combined with a filter graph — scale inside the graph instead (the -vf would feed an output the graph already feeds)");
7534
+ if (outputArgs.some((a) => a === "-vf")) throw new Error("FfmpegInvocation: outputArgs carries a -vf while a filter graph feeds the same output — fold the filter into the graph");
7535
+ return [];
7536
+ }
7504
7537
  if (!scale) return [];
7505
7538
  if (outputArgs.some((a) => a === "-vf")) return [];
7506
7539
  if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
@@ -7524,14 +7557,17 @@ function buildRateControlArgs(video) {
7524
7557
  ];
7525
7558
  }
7526
7559
  /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7527
- function buildVideoArgs(video, outputArgs) {
7528
- if (video.kind === "copy") return [
7529
- "-c:v",
7530
- "copy",
7531
- ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7532
- ];
7560
+ function buildVideoArgs(video, outputArgs, filterGraph) {
7561
+ if (video.kind === "copy") {
7562
+ if (filterGraph) throw new Error("FfmpegInvocation: a filter graph produces a NEW picture, so video.kind must be `encode`");
7563
+ return [
7564
+ "-c:v",
7565
+ "copy",
7566
+ ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7567
+ ];
7568
+ }
7533
7569
  const args = [
7534
- ...buildVideoFilterArgs(video.scale, outputArgs),
7570
+ ...buildVideoFilterArgs(video.scale, outputArgs, filterGraph),
7535
7571
  "-c:v",
7536
7572
  video.encoder
7537
7573
  ];
@@ -7686,20 +7722,39 @@ function buildAudioSidecarArgs(sidecar) {
7686
7722
  * [<input.extraArgs>] │
7687
7723
  * [-fflags <flag>…] │
7688
7724
  * [-rtsp_transport tcp] │
7689
- * -i <url> ─┘
7690
- * <video block> <threads> <audio block> ─┐ OUTPUT options.
7725
+ * -i <url> ─┘ …repeated per extraInput
7726
+ * [-filter_complex <graph>] ─ global, after the LAST -i
7727
+ * [-map <video>] <video block> <threads> ─┐ OUTPUT options.
7728
+ * [-map <audio>] <audio block> │
7691
7729
  * <consumer outputArgs verbatim> │
7692
7730
  * <sink> ─┘ terminal
7731
+ *
7732
+ * The `-map`s are DERIVED (see {@link resolveStreamMaps}), never constants:
7733
+ * one input with no graph emits none at all, which is why the argv of every
7734
+ * call site that predates the N-input extension is unchanged to the byte.
7735
+ *
7736
+ * {@link FfmpegInvocation.audioSidecar} is the one map that stays literal
7737
+ * (`0:a:0?`): the sidecar is defined as the SOURCE's microphone on its own RTP
7738
+ * output, not as whatever the main output happens to carry.
7693
7739
  */
7694
7740
  function buildFfmpegArgs(inv) {
7695
- const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
7741
+ const graph = inv.filterGraph ?? null;
7742
+ const head = [
7743
+ ...logBannerArgs(inv.logLevel),
7744
+ ...buildInputArgs(inv.input, inv.decodeHwAccel),
7745
+ ...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
7746
+ ...graph ? ["-filter_complex", graph.graph] : []
7747
+ ];
7696
7748
  const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7749
+ const maps = resolveStreamMaps(inv);
7750
+ const videoMapArgs = maps.video ? ["-map", maps.video] : [];
7751
+ const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
7697
7752
  if (inv.sink.kind === "rtp-outputs") {
7698
7753
  const videoLeg = inv.sink.video ? [
7699
7754
  "-an",
7700
7755
  "-map",
7701
- "0:v:0",
7702
- ...buildVideoArgs(inv.video, inv.outputArgs),
7756
+ maps.video ?? "0:v:0",
7757
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7703
7758
  ...threadArgs,
7704
7759
  ...inv.outputArgs,
7705
7760
  ...buildRtpOutputArgs(inv.sink.video)
@@ -7707,7 +7762,7 @@ function buildFfmpegArgs(inv) {
7707
7762
  const audioLeg = inv.sink.audio ? [
7708
7763
  "-vn",
7709
7764
  "-map",
7710
- "0:a:0?",
7765
+ maps.audio ?? "0:a:0?",
7711
7766
  ...buildAudioArgs(inv.audio),
7712
7767
  ...buildRtpOutputArgs(inv.sink.audio)
7713
7768
  ] : [];
@@ -7717,15 +7772,17 @@ function buildFfmpegArgs(inv) {
7717
7772
  ...audioLeg
7718
7773
  ];
7719
7774
  }
7775
+ const mutedAudio = isElementaryVideoSink(inv.sink) || inv.audio.kind === "none";
7720
7776
  const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7721
- const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7722
7777
  return [
7723
7778
  ...head,
7724
- ...buildVideoArgs(inv.video, inv.outputArgs),
7779
+ ...videoMapArgs,
7780
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7725
7781
  ...threadArgs,
7782
+ ...mutedAudio ? [] : audioMapArgs,
7726
7783
  ...audioArgs,
7727
7784
  ...inv.outputArgs,
7728
- ...sinkArgs,
7785
+ ...buildStdoutOrRtspSinkArgs(inv.sink),
7729
7786
  ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7730
7787
  ];
7731
7788
  }
@@ -29584,14 +29641,81 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
29584
29641
  kind: "mutation",
29585
29642
  auth: "admin"
29586
29643
  });
29644
+ /**
29645
+ * What a preset id DOES on this camera.
29646
+ *
29647
+ * On several PTZ firmwares the preset namespace is shared: low ids are stored
29648
+ * positions, a reserved band triggers camera FUNCTIONS. The dispensa camera
29649
+ * (Hikvision) returns its own reserved band in the preset list, named by the
29650
+ * firmware:
29651
+ *
29652
+ * 34 Back to origin · 39 Day mode · 40 Night mode · 46 Day/Night Auto Mode
29653
+ * 92 Set manual limits · 93 Save manual limits · 94 Remote reboot
29654
+ * 95 Call OSD menu
29655
+ *
29656
+ * We used to hand all of these to the UI as ordinary presets. Saving over one
29657
+ * is what produced `PUT /ISAPI/PTZCtrl/channels/1/presets/34 -> HTTP 500`: the
29658
+ * camera refusing to let "Back to origin" be overwritten, surfaced to the
29659
+ * operator as a transport failure.
29660
+ *
29661
+ * A function preset stays fully usable as a DESTINATION -- `goToPreset('34')`
29662
+ * is the single most useful thing that camera can do. Only `savePreset` and
29663
+ * `deletePreset` are gated.
29664
+ */
29665
+ var PtzPresetFunctionSchema = _enum([
29666
+ "home",
29667
+ "day-mode",
29668
+ "night-mode",
29669
+ "day-night-auto",
29670
+ "set-limits",
29671
+ "save-limits",
29672
+ "reboot",
29673
+ "osd-menu"
29674
+ ]);
29587
29675
  var PtzPresetSchema = object({
29588
29676
  id: string(),
29589
- name: string()
29677
+ name: string(),
29678
+ /**
29679
+ * The firmware function this id performs, when the provider KNOWS the id is
29680
+ * reserved. `null` = an ordinary stored position, OR a reserved id this
29681
+ * provider does not recognise -- the two are told apart by `writable`.
29682
+ */
29683
+ fn: PtzPresetFunctionSchema.nullable(),
29684
+ /**
29685
+ * Whether `savePreset` / `deletePreset` may target this id. A provider sets
29686
+ * it false only for a band it KNOWS is reserved; where the firmware is not
29687
+ * documented it stays true and a refusal comes from the camera, named.
29688
+ */
29689
+ writable: boolean()
29590
29690
  });
29691
+ /**
29692
+ * Where the head is pointing -- per axis, and `null` when the driver cannot
29693
+ * read it.
29694
+ *
29695
+ * It used to be three REQUIRED numbers, which left a provider that cannot read
29696
+ * position no way to say so. All four said `0`:
29697
+ *
29698
+ * reolink `return { pan: 0, tilt: 0, zoom: 0 }` "until a position-query
29699
+ * path lands upstream"
29700
+ * hikvision stub, "firmware-dependent and frequently absent"
29701
+ * amcrest stub, "no generic absolute-position read for this model family"
29702
+ * onvif throws
29703
+ *
29704
+ * So every consumer asking where a camera points was told "perfectly centred"
29705
+ * by four cameras that had never looked. That is D393 -- a measurement that
29706
+ * FAILED is `null`, never `0`, and the type says so all the way to the
29707
+ * decision -- the same shape as the unreadable `statfs` folded into "0 bytes of
29708
+ * headroom", which evacuated a healthy disk.
29709
+ *
29710
+ * D393 also says to check for a narrower structural TWIN of the result type.
29711
+ * There is one: `PtzStatusSchema` extends this, so the lie had already
29712
+ * propagated into `getStatus`, which is the surface `getPosition`'s own comment
29713
+ * tells callers to migrate to.
29714
+ */
29591
29715
  var PtzPositionSchema = object({
29592
- pan: number(),
29593
- tilt: number(),
29594
- zoom: number()
29716
+ pan: number().nullable(),
29717
+ tilt: number().nullable(),
29718
+ zoom: number().nullable()
29595
29719
  });
29596
29720
  var PtzMoveCommandSchema = object({
29597
29721
  pan: number().optional(),
@@ -29610,7 +29734,72 @@ var PtzOptionsSchema = object({
29610
29734
  maxPresets: number().optional(),
29611
29735
  /** Whether the camera exposes a controllable autofocus toggle
29612
29736
  * (boolean `hasX` per the getOptions availability convention). */
29613
- hasAutofocus: boolean()
29737
+ hasAutofocus: boolean(),
29738
+ /**
29739
+ * How many distinct speeds this camera's NATIVE scale offers.
29740
+ *
29741
+ * The cap's `speed` is normalized 0..1 and each provider maps it down --
29742
+ * Baichuan 1..63, Dahua 1..8, ISAPI's percentage -100..100. The normalized
29743
+ * value hides how coarse that really is: asking an amcrest for `0.37` is
29744
+ * meaningless, because it has eight steps and three of them round to the
29745
+ * same one. A loop that tunes its own gain has to know the granularity of
29746
+ * the knob it is turning.
29747
+ *
29748
+ * `null` = the driver does not know, or the scale is continuous (ONVIF takes
29749
+ * a float). Never a made-up number.
29750
+ */
29751
+ speedSteps: number().int().positive().nullable(),
29752
+ /**
29753
+ * How long ONE `move` pulse runs on this driver, ms.
29754
+ *
29755
+ * `move` is a self-terminating burst everywhere, but every provider hardcoded
29756
+ * its own duration in private -- 200 reolink, 350 amcrest, 500 hikvision,
29757
+ * 1000 onvif -- so no caller could read it. It is the DENOMINATOR of "how far
29758
+ * did the head travel per pulse": without it a measured displacement has no
29759
+ * gain to be divided into.
29760
+ *
29761
+ * `null` = the driver cannot say.
29762
+ */
29763
+ moveImpulseMs: number().int().positive().nullable()
29764
+ });
29765
+ /**
29766
+ * Which preset `goHome` goes to, and WHO decided.
29767
+ *
29768
+ * `goHome` used to be three vendor "conventions" written in three comments --
29769
+ * Reolink preset 0, Hikvision preset '1', Dahua preset 1 -- and on this fleet
29770
+ * not one of the three cameras honours its own:
29771
+ *
29772
+ * 592 Videocamera camera Daniel (reolink) preset 0 holds "stanza"
29773
+ * 1438 Videocamera dispensa (hikvision) preset 1 holds "Credenza"
29774
+ * 3836 Videocamera studio (amcrest) preset 1 holds "Preset1"
29775
+ *
29776
+ * So `goHome` meant "go wherever the operator happened to save in slot 0 or 1",
29777
+ * and two of the three swallowed the failure in a bare `catch`. For autotrack
29778
+ * this is the HOTTEST path -- it runs every time a subject is released -- so it
29779
+ * needs an answer that is true per camera and audible when it is missing.
29780
+ *
29781
+ * `source` is what makes it honest:
29782
+ * - `operator` -- picked in the UI, stored by the provider.
29783
+ * - `firmware` -- the camera itself named a preset `fn: 'home'` (Hikvision's
29784
+ * "Back to origin"), adopted as the default with nothing to configure.
29785
+ * - `native` -- the driver homes WITHOUT a preset at all. ONVIF does:
29786
+ * `goHome` is `ptzAbsoluteMove({x:0, y:0, zoom:0})`. `presetId` is null and
29787
+ * that is not a failure -- there is nothing to pick, and the UI must offer
29788
+ * no picker.
29789
+ * - `none` -- nothing is configured and the camera names nothing.
29790
+ * `goHome` REFUSES rather than moving the head somewhere arbitrary.
29791
+ *
29792
+ * `source === 'none'` is the refusal condition, NOT `presetId === null`: the
29793
+ * native case has no preset and homes perfectly well.
29794
+ */
29795
+ var PtzHomePresetSchema = object({
29796
+ presetId: string().nullable(),
29797
+ source: _enum([
29798
+ "operator",
29799
+ "firmware",
29800
+ "native",
29801
+ "none"
29802
+ ])
29614
29803
  });
29615
29804
  DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(PtzMoveCommandSchema.extend({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(PtzPresetSchema)), method(object({
29616
29805
  deviceId: number(),
@@ -29628,7 +29817,13 @@ DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _
29628
29817
  }), _void(), {
29629
29818
  kind: "mutation",
29630
29819
  auth: "admin"
29631
- }), method(object({ deviceId: number() }), PtzOptionsSchema), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzPositionSchema), method(object({
29820
+ }), method(object({ deviceId: number() }), PtzOptionsSchema), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), PtzHomePresetSchema), method(object({
29821
+ deviceId: number(),
29822
+ presetId: string().nullable()
29823
+ }), _void(), {
29824
+ kind: "mutation",
29825
+ auth: "admin"
29826
+ }), method(object({ deviceId: number() }), PtzPositionSchema), method(object({
29632
29827
  deviceId: number(),
29633
29828
  enabled: boolean()
29634
29829
  }), _void(), { kind: "mutation" });
@@ -36668,6 +36863,12 @@ Object.freeze({
36668
36863
  addonId: null,
36669
36864
  access: "delete"
36670
36865
  },
36866
+ "ptz.getHomePreset": {
36867
+ capName: "ptz",
36868
+ capScope: "device",
36869
+ addonId: null,
36870
+ access: "view"
36871
+ },
36671
36872
  "ptz.getOptions": {
36672
36873
  capName: "ptz",
36673
36874
  capScope: "device",
@@ -36716,6 +36917,12 @@ Object.freeze({
36716
36917
  addonId: null,
36717
36918
  access: "create"
36718
36919
  },
36920
+ "ptz.setHomePreset": {
36921
+ capName: "ptz",
36922
+ capScope: "device",
36923
+ addonId: null,
36924
+ access: "create"
36925
+ },
36719
36926
  "ptz.stop": {
36720
36927
  capName: "ptz",
36721
36928
  capScope: "device",
@@ -39889,6 +40096,11 @@ Object.freeze({
39889
40096
  form: "single",
39890
40097
  optional: false
39891
40098
  }],
40099
+ "ptz.getHomePreset": [{
40100
+ name: "deviceId",
40101
+ form: "single",
40102
+ optional: false
40103
+ }],
39892
40104
  "ptz.getOptions": [{
39893
40105
  name: "deviceId",
39894
40106
  form: "single",
@@ -39929,6 +40141,11 @@ Object.freeze({
39929
40141
  form: "single",
39930
40142
  optional: false
39931
40143
  }],
40144
+ "ptz.setHomePreset": [{
40145
+ name: "deviceId",
40146
+ form: "single",
40147
+ optional: false
40148
+ }],
39932
40149
  "ptz.stop": [{
39933
40150
  name: "deviceId",
39934
40151
  form: "single",
@@ -101743,10 +101960,19 @@ async function readPresets(bctx) {
101743
101960
  try {
101744
101961
  const raw = await proxy.ptz?.getPresets({});
101745
101962
  if (!Array.isArray(raw)) return [];
101746
- return raw.filter((p) => typeof p === "object" && p !== null && typeof p.id === "string" && typeof p.name === "string").map((p) => ({
101747
- id: p.id,
101748
- name: p.name
101749
- }));
101963
+ const entries = [];
101964
+ for (const p of raw) {
101965
+ if (typeof p !== "object" || p === null) continue;
101966
+ const candidate = p;
101967
+ if (typeof candidate.id !== "string" || typeof candidate.name !== "string") continue;
101968
+ const fn = candidate.fn;
101969
+ if (fn !== null && fn !== void 0 && fn !== "home") continue;
101970
+ entries.push({
101971
+ id: candidate.id,
101972
+ name: candidate.name
101973
+ });
101974
+ }
101975
+ return entries;
101750
101976
  } catch (err) {
101751
101977
  ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$8(err) } });
101752
101978
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-hap",
3
- "version": "1.2.121",
3
+ "version": "1.2.123",
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",