@7365admin1/layer-common 4.57.1 → 4.57.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.57.3
4
+
5
+ ### Patch Changes
6
+
7
+ - c23d427: Robot live view: the PadBot S2's EZVIZ camera puts an H.264 access-unit delimiter in front of every H.265 frame, which made Chrome refuse every frame (decode error, black picture). The player now neutralises those units in each segment before playback, so the picture shows. The "couldn't be decoded" message no longer blames EZVIZ encryption (disproved).
8
+
9
+ ## 4.57.2
10
+
11
+ ### Patch Changes
12
+
13
+ - e901907: Robots: live view says honestly when the camera video cannot be decoded (MEDIA_ERR_DECODE, e.g. EZVIZ video encryption switched on) instead of retrying: at most two hls.js recoveries, then a plain message with Retry. The message no longer blames the browser when the decoder reports support.
14
+
3
15
  ## 4.57.1
4
16
 
5
17
  ### Patch Changes
@@ -1599,11 +1611,11 @@
1599
1611
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1600
1612
  own rule from two endpoints the console already calls, unprojected:
1601
1613
 
1602
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1603
- GET /api/roles/id/:role that membership's role document
1614
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1615
+ GET /api/roles/id/:role that membership's role document
1604
1616
 
1605
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1606
- staff = member.type === "admin" && role.type === "admin"
1617
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1618
+ staff = member.type === "admin" && role.type === "admin"
1607
1619
 
1608
1620
  `role.default` is the marker because it is the only property of a platform
1609
1621
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -69,6 +69,7 @@ import {
69
69
  LIVE_DOWN,
70
70
  LIVE_NO_PICTURE,
71
71
  LIVE_RETRY_LIMIT,
72
+ LIVE_UNDECODABLE,
72
73
  liveRetryDelayMs,
73
74
  liveVideoKind,
74
75
  liveViewActive,
@@ -168,8 +169,9 @@ watch([videoEl, url], ([el, src]) => {
168
169
  });
169
170
  function onPlayerFail(message: string) {
170
171
  stopPlayer = undefined;
171
- if (message === LIVE_NO_PICTURE) {
172
- // The browser can't show H.265: no silent retries behind the message; Retry is the user's.
172
+ if (message === LIVE_NO_PICTURE || message === LIVE_UNDECODABLE) {
173
+ // Can't be shown (browser codec / encrypted camera video): a fresh URL won't help,
174
+ // so no silent retries behind the message; Retry is the user's.
173
175
  gen++;
174
176
  clearTimeout(timer);
175
177
  error.value = message;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.57.1",
5
+ "version": "4.57.3",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -36,6 +36,15 @@ export function remintDelayMs(expiresAt: unknown, now: number = Date.now()): num
36
36
  export const LIVE_NO_PICTURE =
37
37
  "This robot's camera sends H.265 video, which this browser can't show. Try Microsoft Edge or Safari, or the PadBot app.";
38
38
  export const LIVE_DOWN = "The robot's camera isn't available right now.";
39
+ /**
40
+ * The decoder says it supports the codec but the frames still won't decode
41
+ * (MEDIA_ERR_DECODE). On the PadBot S2 (EZVIZ H3) that was the stray H.264
42
+ * delimiters `neutraliseStrayAud` now removes (NOT encryption - disproved
43
+ * 2026-09-26 by a software decode of the real stream); kept for anything else.
44
+ */
45
+ export const LIVE_UNDECODABLE =
46
+ "The robot's camera video couldn't be decoded. Press Retry. If it keeps happening, contact Seven365 support.";
47
+ const MEDIA_ERR_DECODE = 3;
39
48
 
40
49
  /** Wait before automatic reconnect attempt n (0-based): 2 s, 4 s, 8 s, 16 s, then 30 s. */
41
50
  export const liveRetryDelayMs = (n: number) => Math.min(30_000, 2_000 * 2 ** n);
@@ -50,7 +59,62 @@ type THls = {
50
59
  swapAudioCodec(): void;
51
60
  destroy(): void;
52
61
  };
53
- export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, unknown>): THls };
62
+ export type THlsCtor = { isSupported(): boolean; new (config?: Record<string, unknown>): THls; DefaultConfig?: { loader?: any } };
63
+
64
+ /**
65
+ * The EZVIZ camera on the PadBot S2 puts an H.264 access-unit delimiter
66
+ * (00 00 01 09 xx) in front of every H.265 frame. Read as H.265 that is a
67
+ * "TSA_N" slice with temporal id 0 - invalid. ffmpeg skips it; hls.js packs it
68
+ * into the frame and Chrome's decoder then refuses every frame
69
+ * (PIPELINE_ERROR_DECODE, 0 frames). Proven on the real stream 2026-09-26:
70
+ * with these units removed Chrome's own decoder decodes 40/40 frames.
71
+ *
72
+ * Rewrites each such header in place, inside the TS packets of video PIDs
73
+ * only, to an "unspecified" H.265 unit (type 48) that hls.js and decoders
74
+ * ignore. Same length, so the TS stays intact. A valid single-layer H.265
75
+ * header never matches (its temporal id is never 0). Returns the count.
76
+ */
77
+ export function neutraliseStrayAud(data: ArrayBuffer | Uint8Array): number {
78
+ const b = data instanceof Uint8Array ? data : new Uint8Array(data);
79
+ const video = new Set<number>();
80
+ let n = 0;
81
+ for (let i = 0; i + 188 <= b.length; i += 188) {
82
+ if (b[i] !== 0x47) continue;
83
+ const afc = (b[i + 3]! >> 4) & 3;
84
+ if (!(afc & 1)) continue;
85
+ const pid = ((b[i + 1]! & 0x1f) << 8) | b[i + 2]!;
86
+ let p = i + 4;
87
+ if (afc & 2) p += 1 + b[p]!;
88
+ // A PES start names its stream: 0xE0-0xEF = video.
89
+ if (b[i + 1]! & 0x40 && b[p] === 0 && b[p + 1] === 0 && b[p + 2] === 1 && (b[p + 3]! & 0xf0) === 0xe0) video.add(pid);
90
+ if (!video.has(pid)) continue;
91
+ for (let k = p; k + 4 < i + 188; k++) {
92
+ if (b[k] === 0 && b[k + 1] === 0 && b[k + 2] === 1 && b[k + 3] === 0x09 && (b[k + 4]! & 7) === 0) {
93
+ b[k + 3] = 0x60;
94
+ b[k + 4] = 0x01;
95
+ n++;
96
+ k += 4;
97
+ }
98
+ }
99
+ }
100
+ return n;
101
+ }
102
+
103
+ /** hls.js fragment loader that runs `neutraliseStrayAud` on every loaded segment. */
104
+ function strayAudLoader(Base: any) {
105
+ return class extends Base {
106
+ load(context: any, config: any, callbacks: any) {
107
+ const ok = callbacks.onSuccess;
108
+ super.load(context, config, {
109
+ ...callbacks,
110
+ onSuccess: (response: any, ...rest: any[]) => {
111
+ if (response?.data instanceof ArrayBuffer) neutraliseStrayAud(response.data);
112
+ ok(response, ...rest);
113
+ },
114
+ });
115
+ }
116
+ };
117
+ }
54
118
 
55
119
  /** The inline panel streams only while it is switched on, the robot is online, the tab is visible and the panel is near the screen. */
56
120
  export const liveViewActive = (s: { on: boolean; offline?: boolean; visible: boolean; inView: boolean }) =>
@@ -88,6 +152,8 @@ const CODEC_ERRORS = new Set(["manifestIncompatibleCodecsError", "bufferAddCodec
88
152
  * `onFail` fires once, with LIVE_NO_PICTURE when the stream plays but no
89
153
  * picture ever decodes (Chrome's native HLS takes H.265-in-TS audio but shows
90
154
  * no video, videoWidth stays 0) or the codec is refused; LIVE_DOWN for
155
+ * LIVE_UNDECODABLE when the video element reported MEDIA_ERR_DECODE (after at
156
+ * most two hls.js recoveries - no endless retrying); LIVE_DOWN for
91
157
  * everything else (network, expired URL, repeated media errors, a stall) -
92
158
  * the caller fetches a fresh URL and starts again.
93
159
  */
@@ -113,6 +179,7 @@ export function startLivePlayer(
113
179
  let pictured = false;
114
180
  let lastTime = -1;
115
181
  let still = 0;
182
+ let decodeFailed = false; // the video element reported MEDIA_ERR_DECODE at some point
116
183
 
117
184
  const fail = (message: string) => {
118
185
  if (stopped) return;
@@ -128,7 +195,8 @@ export function startLivePlayer(
128
195
  opts.onPicture?.();
129
196
  } else if (waited >= noPictureMs) {
130
197
  // Time moving with no picture = the codec; nothing moving = no stream.
131
- return fail(video.currentTime > 0 ? LIVE_NO_PICTURE : LIVE_DOWN);
198
+ noteDecode();
199
+ return fail(decodeFailed ? LIVE_UNDECODABLE : video.currentTime > 0 ? LIVE_NO_PICTURE : LIVE_DOWN);
132
200
  }
133
201
  return;
134
202
  }
@@ -138,12 +206,19 @@ export function startLivePlayer(
138
206
  still = 0;
139
207
  } else if ((still += tickMs) >= stallMs) fail(LIVE_DOWN);
140
208
  }, tickMs);
141
- const onError = () => fail(LIVE_DOWN);
209
+ const noteDecode = () => {
210
+ if (video.error?.code === MEDIA_ERR_DECODE) decodeFailed = true;
211
+ };
212
+ const onError = () => {
213
+ noteDecode();
214
+ fail(decodeFailed ? LIVE_UNDECODABLE : LIVE_DOWN);
215
+ };
142
216
 
143
217
  function stop() {
144
218
  stopped = true;
145
219
  clearInterval(tick);
146
220
  video.removeEventListener("error", onError);
221
+ video.removeEventListener("error", noteDecode);
147
222
  hls?.destroy();
148
223
  hls = undefined;
149
224
  video.removeAttribute("src");
@@ -165,9 +240,12 @@ export function startLivePlayer(
165
240
  .then((Hls) => {
166
241
  if (stopped) return;
167
242
  if (!Hls.isSupported()) return native();
243
+ video.addEventListener("error", noteDecode);
168
244
  let recoveries = 0;
169
- const h = (hls = new Hls({ enableWorker: true, lowLatencyMode: false }));
245
+ const Base = Hls.DefaultConfig?.loader;
246
+ const h = (hls = new Hls({ enableWorker: true, lowLatencyMode: false, ...(Base ? { fLoader: strayAudLoader(Base) } : {}) }));
170
247
  h.on("hlsError", (_e, data) => {
248
+ noteDecode();
171
249
  if (!data.fatal) return;
172
250
  if (CODEC_ERRORS.has(data.details ?? "")) return fail(LIVE_NO_PICTURE);
173
251
  // The standard hls.js ladder: recover, then swap audio codec and recover, then give up.
@@ -175,7 +253,7 @@ export function startLivePlayer(
175
253
  if (recoveries++ === 1) h.swapAudioCodec();
176
254
  return h.recoverMediaError();
177
255
  }
178
- fail(LIVE_DOWN);
256
+ fail(decodeFailed ? LIVE_UNDECODABLE : LIVE_DOWN);
179
257
  });
180
258
  h.loadSource(url);
181
259
  h.attachMedia(video);