@camstack/system 1.2.265 → 1.2.267

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.
@@ -1,4 +1,5 @@
1
1
  import { CamProfile } from '@camstack/types';
2
+ import { NormalizedRect } from './grid-filter-graph.js';
2
3
  /**
3
4
  * One id, for every grid, forever.
4
5
  *
@@ -20,15 +21,33 @@ export declare const GRID_CAM_STREAM_ID = "grid";
20
21
  export declare const GRID_OUTPUT_PROFILE: CamProfile;
21
22
  /** The cheapest stream this source serves, or `null` when it serves none. */
22
23
  export declare function lowestServedProfile(served: readonly CamProfile[]): CamProfile | null;
24
+ /** One stream a camera serves, with the size that decides whether it fits. */
25
+ export interface ServedProfile {
26
+ readonly profile: CamProfile;
27
+ /** `0` means the broker stated no size — see `gridSourceProfiles`. */
28
+ readonly width: number;
29
+ readonly height: number;
30
+ }
31
+ /** One cell, as the two rectangles that decide what its source must carry. */
32
+ export interface GridSourceCell {
33
+ readonly deviceId: number;
34
+ /** The part of the source it shows, normalized against the SOURCE. */
35
+ readonly source: NormalizedRect;
36
+ /** Where it lands, normalized against the CANVAS. */
37
+ readonly cell: NormalizedRect;
38
+ }
23
39
  export interface GridSourceProfilesInput {
24
- /** Every camera the composition takes a cell from, deduplicated. */
25
- readonly sourceDeviceIds: readonly number[];
40
+ readonly canvas: {
41
+ readonly width: number;
42
+ readonly height: number;
43
+ };
44
+ readonly cells: readonly GridSourceCell[];
26
45
  /**
27
- * Which profiles this source can serve, or `null` when nobody could say.
46
+ * Which streams this source can serve, or `null` when nobody could say.
28
47
  * `null` is not an empty list: an unanswerable question is a refusal, and
29
48
  * saying so separately keeps "we did not ask" out of the catalog.
30
49
  */
31
- readonly profilesServedBy: (deviceId: number) => readonly CamProfile[] | null;
50
+ readonly profilesServedBy: (deviceId: number) => readonly ServedProfile[] | null;
32
51
  }
33
52
  export interface GridSourcePlan {
34
53
  /** True when every source can be read — the only case a grid is on offer. */
@@ -956,18 +956,59 @@ var CHEAPEST_FIRST = [
956
956
  function lowestServedProfile(served) {
957
957
  return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
958
958
  }
959
+ /** The pixels a cell needs BEHIND it, before the crop takes its share. */
960
+ function requiredSourceSize(cell, canvas) {
961
+ const tileWidth = cell.cell.width * canvas.width;
962
+ const tileHeight = cell.cell.height * canvas.height;
963
+ const cropWidth = cell.source.width > 0 ? cell.source.width : 1;
964
+ const cropHeight = cell.source.height > 0 ? cell.source.height : 1;
965
+ return {
966
+ width: tileWidth / cropWidth,
967
+ height: tileHeight / cropHeight
968
+ };
969
+ }
970
+ /**
971
+ * The cheapest stream that covers `need`, or the BIGGEST when none does.
972
+ *
973
+ * Soft beats absent: a camera whose best stream cannot fill its cell still
974
+ * composes, at the best it has. Refusing would take the whole grid down over
975
+ * one cell being drawn larger than its source.
976
+ *
977
+ * A stream that states NO size cannot be compared, so it is ordered as cheapest
978
+ * rather than discarded — an unmeasurable stream is still a stream, and the
979
+ * picture is what the operator asked for.
980
+ */
981
+ function bestFit(served, need) {
982
+ if (served.length === 0) return null;
983
+ const order = new Map(CHEAPEST_FIRST.map((profile, index) => [profile, index]));
984
+ const cheapestFirst = [...served].sort((a, b) => (order.get(a.profile) ?? 0) - (order.get(b.profile) ?? 0));
985
+ const unmeasurable = cheapestFirst.find((s) => s.width <= 0 || s.height <= 0);
986
+ if (unmeasurable !== void 0) return unmeasurable.profile;
987
+ const covering = cheapestFirst.find((s) => s.width >= need.width && s.height >= need.height);
988
+ if (covering !== void 0) return covering.profile;
989
+ return cheapestFirst.reduce((best, s) => s.width * s.height > best.width * best.height ? s : best).profile;
990
+ }
959
991
  /** Which stream to read each source at, and who cannot be read at all. */
960
992
  function gridSourceProfiles(input) {
993
+ const neededBySource = /* @__PURE__ */ new Map();
994
+ for (const cell of input.cells) {
995
+ const need = requiredSourceSize(cell, input.canvas);
996
+ const current = neededBySource.get(cell.deviceId);
997
+ neededBySource.set(cell.deviceId, {
998
+ width: Math.max(current?.width ?? 0, need.width),
999
+ height: Math.max(current?.height ?? 0, need.height)
1000
+ });
1001
+ }
961
1002
  const profileBySource = /* @__PURE__ */ new Map();
962
1003
  const missingSources = [];
963
- for (const deviceId of input.sourceDeviceIds) {
1004
+ for (const [deviceId, need] of neededBySource) {
964
1005
  const served = input.profilesServedBy(deviceId);
965
- const chosen = served === null ? null : lowestServedProfile(served);
1006
+ const chosen = served === null ? null : bestFit(served, need);
966
1007
  if (chosen === null) missingSources.push(deviceId);
967
1008
  else profileBySource.set(deviceId, chosen);
968
1009
  }
969
1010
  return {
970
- offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
1011
+ offered: neededBySource.size > 0 && missingSources.length === 0,
971
1012
  profileBySource,
972
1013
  missingSources
973
1014
  };
@@ -2243,6 +2284,25 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2243
2284
  };
2244
2285
  let encoderIn = null;
2245
2286
  let encoderOut = null;
2287
+ let draining = false;
2288
+ let tick = null;
2289
+ const compositor = new GridCompositor({
2290
+ plan: tilePlan,
2291
+ write: (frame) => {
2292
+ const sink = encoderIn;
2293
+ if (sink === null || draining || sink.destroyed) return false;
2294
+ const accepted = sink.write(frame);
2295
+ if (!accepted) draining = true;
2296
+ return accepted;
2297
+ }
2298
+ });
2299
+ const startTick = () => {
2300
+ if (tick !== null) return;
2301
+ tick = setInterval(() => {
2302
+ compositor.tick();
2303
+ }, Math.max(1, Math.round(1e3 / plan.fps)));
2304
+ tick.unref();
2305
+ };
2246
2306
  const encoder = new _camstack_types_node.FfmpegProcess({
2247
2307
  binaryPath: ctx.binaryPath,
2248
2308
  buildArgs: () => require_dist.buildFfmpegArgs(gridCanvasEncoderInvocation({
@@ -2266,34 +2326,26 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2266
2326
  onChild: (child) => {
2267
2327
  encoderIn = child.stdin;
2268
2328
  if (child.stdout) encoderOut = child.stdout;
2329
+ child.stdin?.on("drain", () => {
2330
+ draining = false;
2331
+ });
2332
+ startTick();
2269
2333
  child.stderr?.on("data", () => {});
2270
2334
  child.stdin?.on("error", () => {});
2271
2335
  },
2272
2336
  onExit: (exit) => end(`encoder:${exit.classification}`)
2273
2337
  });
2274
- await encoder.start();
2338
+ try {
2339
+ await encoder.start();
2340
+ } catch (error) {
2341
+ if (tick !== null) clearInterval(tick);
2342
+ compositor.close();
2343
+ throw error;
2344
+ }
2275
2345
  if (encoderIn === null || encoderOut === null) {
2276
2346
  await encoder.stop();
2277
2347
  throw new Error("camera-grid: the canvas encoder exposed no stdin/stdout to compose into");
2278
2348
  }
2279
- let draining = false;
2280
- const sink = encoderIn;
2281
- sink.on("drain", () => {
2282
- draining = false;
2283
- });
2284
- const compositor = new GridCompositor({
2285
- plan: tilePlan,
2286
- write: (frame) => {
2287
- if (draining || sink.destroyed) return false;
2288
- const accepted = sink.write(frame);
2289
- if (!accepted) draining = true;
2290
- return accepted;
2291
- }
2292
- });
2293
- const tick = setInterval(() => {
2294
- compositor.tick();
2295
- }, Math.max(1, Math.round(1e3 / plan.fps)));
2296
- tick.unref();
2297
2349
  const urlByDeviceId = /* @__PURE__ */ new Map();
2298
2350
  for (const [index, deviceId] of plan.sourceDeviceIds.entries()) {
2299
2351
  const url = sources[index]?.url;
@@ -2333,7 +2385,7 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2333
2385
  await decoder.start();
2334
2386
  };
2335
2387
  const stopAll = async () => {
2336
- clearInterval(tick);
2388
+ if (tick !== null) clearInterval(tick);
2337
2389
  compositor.close();
2338
2390
  await Promise.allSettled(decoders.map((d) => d.stop()));
2339
2391
  await encoder.stop();
@@ -2677,12 +2729,24 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2677
2729
  for (const slot of slots) {
2678
2730
  if (slot.sourceCamStreamId === null) continue;
2679
2731
  const profiles = servedBy.get(slot.deviceId) ?? [];
2680
- profiles.push(slot.profile);
2732
+ profiles.push({
2733
+ profile: slot.profile,
2734
+ width: slot.resolution?.width ?? 0,
2735
+ height: slot.resolution?.height ?? 0
2736
+ });
2681
2737
  servedBy.set(slot.deviceId, profiles);
2682
2738
  }
2683
2739
  for (const instance of instances) {
2684
2740
  const plan = gridSourceProfiles({
2685
- sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
2741
+ canvas: {
2742
+ width: instance.width,
2743
+ height: instance.height
2744
+ },
2745
+ cells: instance.cells.map((cell) => ({
2746
+ deviceId: cell.deviceId,
2747
+ source: cell.source,
2748
+ cell: cell.cell
2749
+ })),
2686
2750
  profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
2687
2751
  });
2688
2752
  const previous = this.planByInstanceId.get(instance.id);
@@ -951,18 +951,59 @@ var CHEAPEST_FIRST = [
951
951
  function lowestServedProfile(served) {
952
952
  return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
953
953
  }
954
+ /** The pixels a cell needs BEHIND it, before the crop takes its share. */
955
+ function requiredSourceSize(cell, canvas) {
956
+ const tileWidth = cell.cell.width * canvas.width;
957
+ const tileHeight = cell.cell.height * canvas.height;
958
+ const cropWidth = cell.source.width > 0 ? cell.source.width : 1;
959
+ const cropHeight = cell.source.height > 0 ? cell.source.height : 1;
960
+ return {
961
+ width: tileWidth / cropWidth,
962
+ height: tileHeight / cropHeight
963
+ };
964
+ }
965
+ /**
966
+ * The cheapest stream that covers `need`, or the BIGGEST when none does.
967
+ *
968
+ * Soft beats absent: a camera whose best stream cannot fill its cell still
969
+ * composes, at the best it has. Refusing would take the whole grid down over
970
+ * one cell being drawn larger than its source.
971
+ *
972
+ * A stream that states NO size cannot be compared, so it is ordered as cheapest
973
+ * rather than discarded — an unmeasurable stream is still a stream, and the
974
+ * picture is what the operator asked for.
975
+ */
976
+ function bestFit(served, need) {
977
+ if (served.length === 0) return null;
978
+ const order = new Map(CHEAPEST_FIRST.map((profile, index) => [profile, index]));
979
+ const cheapestFirst = [...served].sort((a, b) => (order.get(a.profile) ?? 0) - (order.get(b.profile) ?? 0));
980
+ const unmeasurable = cheapestFirst.find((s) => s.width <= 0 || s.height <= 0);
981
+ if (unmeasurable !== void 0) return unmeasurable.profile;
982
+ const covering = cheapestFirst.find((s) => s.width >= need.width && s.height >= need.height);
983
+ if (covering !== void 0) return covering.profile;
984
+ return cheapestFirst.reduce((best, s) => s.width * s.height > best.width * best.height ? s : best).profile;
985
+ }
954
986
  /** Which stream to read each source at, and who cannot be read at all. */
955
987
  function gridSourceProfiles(input) {
988
+ const neededBySource = /* @__PURE__ */ new Map();
989
+ for (const cell of input.cells) {
990
+ const need = requiredSourceSize(cell, input.canvas);
991
+ const current = neededBySource.get(cell.deviceId);
992
+ neededBySource.set(cell.deviceId, {
993
+ width: Math.max(current?.width ?? 0, need.width),
994
+ height: Math.max(current?.height ?? 0, need.height)
995
+ });
996
+ }
956
997
  const profileBySource = /* @__PURE__ */ new Map();
957
998
  const missingSources = [];
958
- for (const deviceId of input.sourceDeviceIds) {
999
+ for (const [deviceId, need] of neededBySource) {
959
1000
  const served = input.profilesServedBy(deviceId);
960
- const chosen = served === null ? null : lowestServedProfile(served);
1001
+ const chosen = served === null ? null : bestFit(served, need);
961
1002
  if (chosen === null) missingSources.push(deviceId);
962
1003
  else profileBySource.set(deviceId, chosen);
963
1004
  }
964
1005
  return {
965
- offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
1006
+ offered: neededBySource.size > 0 && missingSources.length === 0,
966
1007
  profileBySource,
967
1008
  missingSources
968
1009
  };
@@ -2238,6 +2279,25 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2238
2279
  };
2239
2280
  let encoderIn = null;
2240
2281
  let encoderOut = null;
2282
+ let draining = false;
2283
+ let tick = null;
2284
+ const compositor = new GridCompositor({
2285
+ plan: tilePlan,
2286
+ write: (frame) => {
2287
+ const sink = encoderIn;
2288
+ if (sink === null || draining || sink.destroyed) return false;
2289
+ const accepted = sink.write(frame);
2290
+ if (!accepted) draining = true;
2291
+ return accepted;
2292
+ }
2293
+ });
2294
+ const startTick = () => {
2295
+ if (tick !== null) return;
2296
+ tick = setInterval(() => {
2297
+ compositor.tick();
2298
+ }, Math.max(1, Math.round(1e3 / plan.fps)));
2299
+ tick.unref();
2300
+ };
2241
2301
  const encoder = new FfmpegProcess({
2242
2302
  binaryPath: ctx.binaryPath,
2243
2303
  buildArgs: () => buildFfmpegArgs(gridCanvasEncoderInvocation({
@@ -2261,34 +2321,26 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2261
2321
  onChild: (child) => {
2262
2322
  encoderIn = child.stdin;
2263
2323
  if (child.stdout) encoderOut = child.stdout;
2324
+ child.stdin?.on("drain", () => {
2325
+ draining = false;
2326
+ });
2327
+ startTick();
2264
2328
  child.stderr?.on("data", () => {});
2265
2329
  child.stdin?.on("error", () => {});
2266
2330
  },
2267
2331
  onExit: (exit) => end(`encoder:${exit.classification}`)
2268
2332
  });
2269
- await encoder.start();
2333
+ try {
2334
+ await encoder.start();
2335
+ } catch (error) {
2336
+ if (tick !== null) clearInterval(tick);
2337
+ compositor.close();
2338
+ throw error;
2339
+ }
2270
2340
  if (encoderIn === null || encoderOut === null) {
2271
2341
  await encoder.stop();
2272
2342
  throw new Error("camera-grid: the canvas encoder exposed no stdin/stdout to compose into");
2273
2343
  }
2274
- let draining = false;
2275
- const sink = encoderIn;
2276
- sink.on("drain", () => {
2277
- draining = false;
2278
- });
2279
- const compositor = new GridCompositor({
2280
- plan: tilePlan,
2281
- write: (frame) => {
2282
- if (draining || sink.destroyed) return false;
2283
- const accepted = sink.write(frame);
2284
- if (!accepted) draining = true;
2285
- return accepted;
2286
- }
2287
- });
2288
- const tick = setInterval(() => {
2289
- compositor.tick();
2290
- }, Math.max(1, Math.round(1e3 / plan.fps)));
2291
- tick.unref();
2292
2344
  const urlByDeviceId = /* @__PURE__ */ new Map();
2293
2345
  for (const [index, deviceId] of plan.sourceDeviceIds.entries()) {
2294
2346
  const url = sources[index]?.url;
@@ -2328,7 +2380,7 @@ async function startGridCompositorChild(ctx, plan, sources, onEnded) {
2328
2380
  await decoder.start();
2329
2381
  };
2330
2382
  const stopAll = async () => {
2331
- clearInterval(tick);
2383
+ if (tick !== null) clearInterval(tick);
2332
2384
  compositor.close();
2333
2385
  await Promise.allSettled(decoders.map((d) => d.stop()));
2334
2386
  await encoder.stop();
@@ -2672,12 +2724,24 @@ var CameraGridAddon = class extends BaseAddon {
2672
2724
  for (const slot of slots) {
2673
2725
  if (slot.sourceCamStreamId === null) continue;
2674
2726
  const profiles = servedBy.get(slot.deviceId) ?? [];
2675
- profiles.push(slot.profile);
2727
+ profiles.push({
2728
+ profile: slot.profile,
2729
+ width: slot.resolution?.width ?? 0,
2730
+ height: slot.resolution?.height ?? 0
2731
+ });
2676
2732
  servedBy.set(slot.deviceId, profiles);
2677
2733
  }
2678
2734
  for (const instance of instances) {
2679
2735
  const plan = gridSourceProfiles({
2680
- sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
2736
+ canvas: {
2737
+ width: instance.width,
2738
+ height: instance.height
2739
+ },
2740
+ cells: instance.cells.map((cell) => ({
2741
+ deviceId: cell.deviceId,
2742
+ source: cell.source,
2743
+ cell: cell.cell
2744
+ })),
2681
2745
  profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
2682
2746
  });
2683
2747
  const previous = this.planByInstanceId.get(instance.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.265",
3
+ "version": "1.2.267",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",