beauticode-dsh 1.0.20 → 1.0.21

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/client.js CHANGED
@@ -4,34 +4,37 @@
4
4
  window.__beauticodeBridgeLoaded = true;
5
5
  window.__beauticodeBridgeVersion = 4;
6
6
 
7
- const clientId =
8
- globalThis.crypto?.randomUUID?.() ||
9
- `bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
10
- const desiredModes = { fish: false, muted: true, tone: "auto" };
11
- // The DSH host owns a hard 10-second verification boundary. Keep the browser
12
- // transaction inside it so failures remain phase-specific instead of turning
13
- // into a generic host timeout.
14
- const CLIENT_APPLY_DEADLINE_MS = 8_000;
15
- const IMAGE_LOAD_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
16
- const IMAGE_ATTEMPT_TIMEOUT_MS = 3_000;
17
- const IMAGE_MAX_ATTEMPTS = 2;
18
- const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
19
- const VIDEO_PROBE_TIMEOUT_MS = 2_000;
20
- const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
21
- const FRAME_FALLBACK_MS = 120;
22
- const VIDEO_FIRST_FRAME_PROGRESS_SEC = 0.03;
23
- const VIDEO_STABLE_FRAMES = 3;
24
- const VIDEO_STABLE_PROGRESS_SEC = 0.18;
25
- const CROSSFADE_MS = 180;
26
- let activePayload = null;
27
- let committedPayload = null;
28
- let renderPhase = "idle";
29
- let playbackBlocked = false;
30
- let currentSlot = null;
31
- let applyController = null;
32
- const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
33
- const reducedMotionMedia =
34
- globalThis.matchMedia?.("(prefers-reduced-motion: reduce)") ?? null;
7
+ const clientId =
8
+ globalThis.crypto?.randomUUID?.() ||
9
+ `bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
10
+ const desiredModes = { fish: false, muted: true, tone: "auto" };
11
+ // The DSH host owns a hard 10-second verification boundary. Keep the browser
12
+ // transaction inside it so failures remain phase-specific instead of turning
13
+ // into a generic host timeout.
14
+ const CLIENT_APPLY_DEADLINE_MS = 8_000;
15
+ const IMAGE_LOAD_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
16
+ const IMAGE_ATTEMPT_TIMEOUT_MS = 3_000;
17
+ const IMAGE_MAX_ATTEMPTS = 2;
18
+ const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
19
+ const VIDEO_PROBE_TIMEOUT_MS = 2_000;
20
+ const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
21
+ const FRAME_FALLBACK_MS = 120;
22
+ const VIDEO_FIRST_FRAME_PROGRESS_SEC = 0.03;
23
+ const VIDEO_STABLE_FRAMES = 3;
24
+ const VIDEO_STABLE_PROGRESS_SEC = 0.18;
25
+ const CROSSFADE_MS = 180;
26
+ // A 1486-byte H.264 black frame (faststart). Playing it once on init makes
27
+ // Chromium build its media pipeline before the user's first real import.
28
+ const WARMUP_VIDEO_DATA_URI = `data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMQbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAH0AAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAjp0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAH0AAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAB9AAAAAAABAAAAAAGybWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAACABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABXW1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAR1zdGJsAAAAuXN0c2QAAAAAAAAAAQAAAKlhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAAL2F2Y0MBQsAN/+EAF2dCwA3ZBCbARAAAAwAEAAADAEA8UKkgAQAFaMuDyyAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAACjgAAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAQAACAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACjgAAAAEAAAAUc3RjbwAAAAAAAAABAAADQAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWlsc3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNjIuMTIuMTAyAAAACGZyZWUAAAKWbWRhdAAAAnAGBf//bNxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjUgcjMyMjMgMDQ4MGNiMCAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjUgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0wIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHl6ZT0weDE6MHgxMTEgbWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTAgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz0yIGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0wIHdlaWdodHA9MCBrZXlpbnQ9MjUwIGtleWludF9taW49OCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDBAIAAAAAWZYiEDvJigAC+/JycnXXXXXXXXXXXXg==`;
29
+ let activePayload = null;
30
+ let committedPayload = null;
31
+ let renderPhase = "idle";
32
+ let playbackBlocked = false;
33
+ let currentSlot = null;
34
+ let applyController = null;
35
+ const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
36
+ const reducedMotionMedia =
37
+ globalThis.matchMedia?.("(prefers-reduced-motion: reduce)") ?? null;
35
38
  let themeSyncQueued = false;
36
39
 
37
40
  const style = document.createElement("style");
@@ -69,34 +72,85 @@ html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase
69
72
  --dsw-specific-sidebar-fill:rgba(255,255,255,.78);
70
73
  }
71
74
  #beauticode-bg-stage{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:#11141b}
72
- #beauticode-bg-stage::after{content:"";position:absolute;inset:0;z-index:3;background:transparent;pointer-events:none}
75
+ #beauticode-bg-stage::after{content:"";position:absolute;inset:0;z-index:3;background:transparent;pointer-events:none}
73
76
  html[data-bc-resolved-tone="light"] #beauticode-bg-stage{background:#f8fafc}
74
77
  html[data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
75
78
  html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(0,0,0,.42)}
76
79
  html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
77
80
  html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(255,255,255,.22)}
78
81
  html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important}
79
- #beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:1;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity}
80
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
81
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
82
- #beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
83
- #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
84
- #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
85
- #beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease}
86
- #beauticode-bg-stage .beauticode-media-slot img{z-index:2;opacity:1}
87
- /* Keep cold candidate video paintable. Chromium may defer decoding media that
88
- is nearly transparent, which deadlocks the first-frame gate. The poster
89
- covers it until data-bc-video-ready is committed. */
90
- #beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:1}
91
- #beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0}
92
- #beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1}
93
- @media (prefers-reduced-motion:reduce){#beauticode-bg-stage .beauticode-media-slot,#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{transition:none!important}}
82
+ #beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:1;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity}
83
+ #beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
84
+ #beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
85
+ #beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
86
+ #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
87
+ #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
88
+ #beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease}
89
+ #beauticode-bg-stage .beauticode-media-slot img{z-index:2;opacity:1}
90
+ /* Keep cold candidate video paintable. Chromium may defer decoding media that
91
+ is nearly transparent, which deadlocks the first-frame gate. The poster
92
+ covers it until data-bc-video-ready is committed. */
93
+ #beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:1}
94
+ #beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0}
95
+ #beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1}
96
+ @media (prefers-reduced-motion:reduce){#beauticode-bg-stage .beauticode-media-slot,#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{transition:none!important}}
94
97
  html[data-bc-active="true"] #root{position:relative;z-index:1;background:transparent!important}
95
98
  html[data-bc-active="true"] [class*="_fade"]{display:none!important}
96
99
  html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
97
100
  `;
98
101
  document.head.append(style);
99
102
 
103
+ // Chromium builds its media stack lazily; the first <video> of a fresh
104
+ // profile pays decoder/GPU/audio init inside the first import's verify
105
+ // deadline. One muted playback of the bundled frame pays that cost early.
106
+ // Everything is best-effort: a warmup failure must never affect the page.
107
+ function warmUpMediaStack() {
108
+ try {
109
+ const mount = document.documentElement;
110
+ if (typeof mount?.append !== "function") return;
111
+ if (mount.dataset.bcMediaWarmup === "done") return;
112
+ mount.dataset.bcMediaWarmup = "running";
113
+ const video = document.createElement("video");
114
+ let cleaned = false;
115
+ const cleanup = () => {
116
+ if (cleaned) return;
117
+ cleaned = true;
118
+ clearTimeout(timer);
119
+ try {
120
+ video.removeAttribute("src");
121
+ video.load();
122
+ } catch {}
123
+ video.remove?.();
124
+ mount.dataset.bcMediaWarmup = "done";
125
+ };
126
+ const timer = setTimeout(cleanup, 15_000);
127
+ video.muted = true;
128
+ video.defaultMuted = true;
129
+ video.playsInline = true;
130
+ video.autoplay = true;
131
+ video.setAttribute?.("muted", "");
132
+ video.setAttribute?.("disablepictureinpicture", "");
133
+ video.setAttribute?.("aria-hidden", "true");
134
+ video.tabIndex = -1;
135
+ // Nearly transparent or hidden media can be decode-deferred (see the
136
+ // first-frame fix); a 4x4 fully painted square under every layer is
137
+ // imperceptible while remaining decodable.
138
+ video.style.cssText =
139
+ "position:fixed;left:0;top:0;width:4px;height:4px;opacity:1;pointer-events:none;z-index:-2147483647;border:0;margin:0;padding:0";
140
+ video.addEventListener?.("ended", cleanup, { once: true });
141
+ video.addEventListener?.("error", cleanup, { once: true });
142
+ video.src = WARMUP_VIDEO_DATA_URI;
143
+ mount.append(video);
144
+ const playback = video.play?.();
145
+ playback?.catch?.(() => {});
146
+ } catch {
147
+ try {
148
+ document.documentElement.dataset.bcMediaWarmup = "done";
149
+ } catch {}
150
+ }
151
+ }
152
+ warmUpMediaStack();
153
+
100
154
  function dshAppearance() {
101
155
  const body = document.body;
102
156
  if (body?.hasAttribute("data-ds-dark-theme")) return "dark";
@@ -139,12 +193,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
139
193
  attributeFilter: ["data-ds-dark-theme"],
140
194
  });
141
195
  }
142
- systemDarkMedia?.addEventListener("change", () => {
143
- if (desiredModes.tone === "auto") {
144
- syncDshTheme();
145
- void acknowledgeMode().catch(() => {});
146
- }
147
- });
196
+ systemDarkMedia?.addEventListener("change", () => {
197
+ if (desiredModes.tone === "auto") {
198
+ syncDshTheme();
199
+ void acknowledgeMode().catch(() => {});
200
+ }
201
+ });
148
202
  syncDshTheme();
149
203
 
150
204
  function stage() {
@@ -162,67 +216,67 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
162
216
  * If the shell no longer exposes it, report a clear error through the
163
217
  * render ack instead of silently painting a broken page.
164
218
  */
165
- function dshStructureIssue() {
166
- if (!document.getElementById("root")) {
167
- return "DSH 页面结构不兼容:未找到 #root。";
168
- }
169
- return null;
170
- }
171
-
172
- function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) {
173
- if (!dshStructureIssue()) return Promise.resolve();
174
- return new Promise((resolve, reject) => {
175
- let settled = false;
176
- const startedAt = performance.now();
177
- const finish = (error = null) => {
178
- if (settled) return;
179
- settled = true;
180
- clearInterval(timer);
181
- signal?.removeEventListener?.("abort", aborted);
182
- if (error) reject(error);
183
- else resolve();
184
- };
185
- const aborted = () => finish(abortError());
186
- const timer = setInterval(() => {
187
- const issue = dshStructureIssue();
188
- if (!issue) {
189
- finish();
190
- } else if (performance.now() - startedAt >= timeoutMs) {
191
- finish(new Error(issue));
192
- }
193
- }, 50);
194
- signal?.addEventListener?.("abort", aborted, { once: true });
195
- if (signal?.aborted) aborted();
196
- });
197
- }
198
-
199
- function mountedCurrentSlot() {
200
- const node = document.getElementById("beauticode-bg-stage");
201
- if (
202
- !currentSlot ||
203
- currentSlot.isConnected !== true ||
204
- currentSlot.parentElement !== node ||
205
- currentSlot.dataset.bcRole !== "current"
206
- ) {
207
- currentSlot = null;
208
- committedPayload = null;
209
- return null;
210
- }
211
- return currentSlot;
212
- }
213
-
214
- function activeVideo() {
215
- const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
216
- return video instanceof HTMLVideoElement ? video : null;
217
- }
218
-
219
- function pendingVideo() {
220
- const node = document.getElementById("beauticode-bg-stage");
221
- const video = node?.querySelector?.(
222
- '.beauticode-media-slot[data-bc-role="candidate"] video',
223
- );
224
- return video instanceof HTMLVideoElement ? video : null;
225
- }
219
+ function dshStructureIssue() {
220
+ if (!document.getElementById("root")) {
221
+ return "DSH 页面结构不兼容:未找到 #root。";
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) {
227
+ if (!dshStructureIssue()) return Promise.resolve();
228
+ return new Promise((resolve, reject) => {
229
+ let settled = false;
230
+ const startedAt = performance.now();
231
+ const finish = (error = null) => {
232
+ if (settled) return;
233
+ settled = true;
234
+ clearInterval(timer);
235
+ signal?.removeEventListener?.("abort", aborted);
236
+ if (error) reject(error);
237
+ else resolve();
238
+ };
239
+ const aborted = () => finish(abortError());
240
+ const timer = setInterval(() => {
241
+ const issue = dshStructureIssue();
242
+ if (!issue) {
243
+ finish();
244
+ } else if (performance.now() - startedAt >= timeoutMs) {
245
+ finish(new Error(issue));
246
+ }
247
+ }, 50);
248
+ signal?.addEventListener?.("abort", aborted, { once: true });
249
+ if (signal?.aborted) aborted();
250
+ });
251
+ }
252
+
253
+ function mountedCurrentSlot() {
254
+ const node = document.getElementById("beauticode-bg-stage");
255
+ if (
256
+ !currentSlot ||
257
+ currentSlot.isConnected !== true ||
258
+ currentSlot.parentElement !== node ||
259
+ currentSlot.dataset.bcRole !== "current"
260
+ ) {
261
+ currentSlot = null;
262
+ committedPayload = null;
263
+ return null;
264
+ }
265
+ return currentSlot;
266
+ }
267
+
268
+ function activeVideo() {
269
+ const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
270
+ return video instanceof HTMLVideoElement ? video : null;
271
+ }
272
+
273
+ function pendingVideo() {
274
+ const node = document.getElementById("beauticode-bg-stage");
275
+ const video = node?.querySelector?.(
276
+ '.beauticode-media-slot[data-bc-role="candidate"] video',
277
+ );
278
+ return video instanceof HTMLVideoElement ? video : null;
279
+ }
226
280
 
227
281
  async function postAck(body) {
228
282
  await fetch("/__beauticode/ack", {
@@ -255,10 +309,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
255
309
  ok,
256
310
  visible,
257
311
  error,
258
- playback:
259
- ok && committedPayload === payload && payload.media === "video"
260
- ? playbackSnapshot(activeVideo())
261
- : null,
312
+ playback:
313
+ ok && committedPayload === payload && payload.media === "video"
314
+ ? playbackSnapshot(activeVideo())
315
+ : null,
262
316
  });
263
317
  }
264
318
 
@@ -276,290 +330,333 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
276
330
  });
277
331
  }
278
332
 
279
- function abortError() {
280
- try {
281
- return new DOMException("Background apply superseded", "AbortError");
282
- } catch {
283
- const error = new Error("Background apply superseded");
284
- error.name = "AbortError";
285
- return error;
286
- }
287
- }
288
-
289
- function isAbortError(error) {
290
- return error?.name === "AbortError";
291
- }
292
-
293
- function throwIfAborted(signal) {
294
- if (signal?.aborted) throw abortError();
295
- }
296
-
297
- function releaseImage(image, { remove = false } = {}) {
298
- if (!image) return;
299
- image.onload = null;
300
- image.onerror = null;
301
- try {
302
- image.removeAttribute?.("src");
303
- } catch {
304
- image.src = "";
305
- }
306
- if (remove) image.remove?.();
307
- }
308
-
309
- function imageAttemptUrl(url, attempt) {
310
- if (attempt === 0) return url;
311
- try {
312
- const retryUrl = new URL(url, globalThis.location?.href);
313
- retryUrl.searchParams.set(
314
- "bcImageRetry",
315
- `${clientId}-${attempt}-${Date.now().toString(36)}`,
316
- );
317
- return retryUrl.href;
318
- } catch {
319
- return url;
320
- }
321
- }
322
-
323
- function waitForImageAttempt(image, url, signal, timeoutMs) {
324
- return new Promise((resolve, reject) => {
325
- let settled = false;
326
- let decodeStarted = false;
327
- let decodeFallback = null;
328
- const cleanup = () => {
329
- clearTimeout(timer);
330
- if (decodeFallback) clearTimeout(decodeFallback);
331
- signal?.removeEventListener?.("abort", aborted);
332
- image.onload = null;
333
- image.onerror = null;
334
- };
335
- const finish = (error = null) => {
336
- if (settled) return;
337
- settled = true;
338
- cleanup();
339
- if (error) reject(error);
340
- else resolve();
341
- };
342
- const acceptLoaded = () => {
343
- if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
344
- if (image.complete) finish(new Error("图片尺寸无效"));
345
- return;
346
- }
347
- if (!decodeStarted && typeof image.decode === "function") {
348
- decodeStarted = true;
349
- // decode() is a second readiness signal, but the per-attempt timer
350
- // remains authoritative. A loaded image is still accepted after a
351
- // short decode grace period if Chromium leaves decode() pending.
352
- decodeFallback = setTimeout(() => {
353
- if (
354
- image.isConnected === true &&
355
- image.complete &&
356
- image.naturalWidth > 0 &&
357
- image.naturalHeight > 0
358
- ) {
359
- finish();
360
- } else {
361
- finish(new Error("图片解码未完成"));
362
- }
363
- }, 750);
364
- let decodePromise;
365
- try {
366
- decodePromise = image.decode();
367
- } catch {
368
- finish(new Error("图片解码失败"));
369
- return;
370
- }
371
- Promise.resolve(decodePromise).then(
372
- () => finish(),
373
- () => finish(new Error("图片解码失败")),
374
- );
375
- return;
376
- }
377
- finish();
378
- };
379
- const failed = () => finish(new Error("图片请求失败"));
380
- const aborted = () => finish(abortError());
381
- const timer = setTimeout(
382
- () => finish(new Error("等待图片请求或解码超时")),
383
- timeoutMs,
384
- );
385
- image.onload = acceptLoaded;
386
- image.onerror = failed;
387
- signal?.addEventListener?.("abort", aborted, { once: true });
388
- if (signal?.aborted) {
389
- aborted();
390
- return;
391
- }
392
- image.src = url;
393
- if (image.complete) queueMicrotask(acceptLoaded);
394
- });
395
- }
396
-
397
- function waitForRetryWindow(signal, timeoutMs) {
398
- return new Promise((resolve, reject) => {
399
- let settled = false;
400
- const finish = (error = null) => {
401
- if (settled) return;
402
- settled = true;
403
- clearTimeout(timer);
404
- signal?.removeEventListener?.("abort", aborted);
405
- if (error) reject(error);
406
- else resolve();
407
- };
408
- const aborted = () => finish(abortError());
409
- const timer = setTimeout(() => finish(), timeoutMs);
410
- signal?.addEventListener?.("abort", aborted, { once: true });
411
- if (signal?.aborted) aborted();
412
- });
413
- }
414
-
415
- async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
416
- const deadline = performance.now() + timeoutMs;
417
- const attempts = [];
418
- let lastError = null;
419
-
420
- const startAttempt = (attempt) => {
421
- throwIfAborted(signal);
422
- const remainingMs = deadline - performance.now();
423
- if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
424
- const controller = new AbortController();
425
- const parentAborted = () => controller.abort();
426
- signal?.addEventListener?.("abort", parentAborted, { once: true });
427
- const image = new Image();
428
- image.alt = "";
429
- image.decoding = "async";
430
- image.className = "beauticode-media-poster";
431
- slot.prepend(image);
432
- const promise = waitForImageAttempt(
433
- image,
434
- imageAttemptUrl(url, attempt),
435
- controller.signal,
436
- Math.max(1, remainingMs),
437
- )
438
- .then(() => image)
439
- .finally(() => signal?.removeEventListener?.("abort", parentAborted));
440
- const record = { controller, image, promise };
441
- attempts.push(record);
442
- return record;
443
- };
444
-
445
- const cleanupAttempts = (winner = null) => {
446
- for (const attempt of attempts) {
447
- if (attempt.image === winner) continue;
448
- attempt.controller.abort();
449
- releaseImage(attempt.image, { remove: true });
450
- }
451
- };
452
-
453
- try {
454
- const first = startAttempt(0);
455
- const firstWindow = await Promise.race([
456
- first.promise.then(
457
- (image) => ({ image }),
458
- (error) => ({ error }),
459
- ),
460
- waitForRetryWindow(
461
- signal,
462
- Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
463
- ).then(() => ({ retry: true })),
464
- ]);
465
- if (firstWindow.image) {
466
- cleanupAttempts(firstWindow.image);
467
- return firstWindow.image;
468
- }
469
- if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
470
- lastError = firstWindow.error ?? lastError;
471
-
472
- // Keep the original request alive. The cache-busted retry runs beside it
473
- // so a merely slow cold decode is never destroyed to recover a hung one.
474
- if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
475
- let winner;
476
- try {
477
- winner = await Promise.any(attempts.map((attempt) => attempt.promise));
478
- } catch (error) {
479
- const errors = Array.isArray(error?.errors) ? error.errors : [];
480
- lastError = errors.at(-1) ?? lastError ?? error;
481
- throw lastError;
482
- }
483
- cleanupAttempts(winner);
484
- return winner;
485
- } catch (error) {
486
- cleanupAttempts();
487
- if (isAbortError(error) || signal?.aborted) throw abortError();
488
- lastError = error ?? lastError;
489
- const visibility = document.visibilityState || "unknown";
490
- const online = globalThis.navigator?.onLine === false ? "offline" : "online";
491
- const phase = lastError?.message || "unknown";
492
- const summary =
493
- phase === "图片请求失败"
494
- ? "图片加载失败"
495
- : phase.includes("超时")
496
- ? "等待图片加载超时"
497
- : "图片校验失败";
498
- throw new Error(
499
- `${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
500
- );
501
- }
502
- }
503
-
504
- function safeOrigin(value) {
505
- try {
506
- return new URL(value, globalThis.location?.href).origin;
507
- } catch {
508
- return "unknown";
509
- }
510
- }
511
-
512
- function videoRequestContext(url) {
513
- return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`;
514
- }
515
-
516
- async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) {
517
- throwIfAborted(signal);
518
- const controller = new AbortController();
519
- const parentAborted = () => controller.abort();
520
- signal?.addEventListener?.("abort", parentAborted, { once: true });
521
- let timedOut = false;
522
- const timer = setTimeout(() => {
523
- timedOut = true;
524
- controller.abort();
525
- }, Math.max(1, timeoutMs));
526
- try {
527
- const response = await fetch(url, {
528
- method: "GET",
529
- headers: { Range: "bytes=0-1" },
530
- cache: "no-store",
531
- credentials: "omit",
532
- mode: "cors",
533
- signal: controller.signal,
534
- });
535
- if (response.status !== 206) {
536
- if (response.body) {
537
- await response.body.cancel().catch(() => {});
538
- }
539
- throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`);
540
- }
541
- const bytes = await response.arrayBuffer();
542
- if (bytes.byteLength !== 2) {
543
- throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`);
544
- }
545
- } catch (error) {
546
- if (signal?.aborted) throw abortError();
547
- const context = videoRequestContext(url);
548
- if (timedOut || isAbortError(error)) {
549
- throw new Error(`视频媒体 Range 探针超时;${context}`);
550
- }
551
- const detail = error instanceof Error ? error.message : String(error);
552
- if (error instanceof TypeError) {
553
- throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`);
554
- }
555
- throw new Error(`${detail};${context}`);
556
- } finally {
557
- clearTimeout(timer);
558
- signal?.removeEventListener?.("abort", parentAborted);
559
- }
560
- }
561
-
562
- function describeVideoState(video, phase) {
333
+ function abortError() {
334
+ try {
335
+ return new DOMException("Background apply superseded", "AbortError");
336
+ } catch {
337
+ const error = new Error("Background apply superseded");
338
+ error.name = "AbortError";
339
+ return error;
340
+ }
341
+ }
342
+
343
+ function isAbortError(error) {
344
+ return error?.name === "AbortError";
345
+ }
346
+
347
+ function throwIfAborted(signal) {
348
+ if (signal?.aborted) throw abortError();
349
+ }
350
+
351
+ function releaseImage(image, { remove = false } = {}) {
352
+ if (!image) return;
353
+ image.onload = null;
354
+ image.onerror = null;
355
+ try {
356
+ image.removeAttribute?.("src");
357
+ } catch {
358
+ image.src = "";
359
+ }
360
+ if (remove) image.remove?.();
361
+ }
362
+
363
+ function imageAttemptUrl(url, attempt) {
364
+ if (attempt === 0) return url;
365
+ try {
366
+ const retryUrl = new URL(url, globalThis.location?.href);
367
+ retryUrl.searchParams.set(
368
+ "bcImageRetry",
369
+ `${clientId}-${attempt}-${Date.now().toString(36)}`,
370
+ );
371
+ return retryUrl.href;
372
+ } catch {
373
+ return url;
374
+ }
375
+ }
376
+
377
+ function waitForImageAttempt(image, url, signal, timeoutMs) {
378
+ return new Promise((resolve, reject) => {
379
+ let settled = false;
380
+ let decodeStarted = false;
381
+ let decodeFallback = null;
382
+ const cleanup = () => {
383
+ clearTimeout(timer);
384
+ if (decodeFallback) clearTimeout(decodeFallback);
385
+ signal?.removeEventListener?.("abort", aborted);
386
+ image.onload = null;
387
+ image.onerror = null;
388
+ };
389
+ const finish = (error = null) => {
390
+ if (settled) return;
391
+ settled = true;
392
+ cleanup();
393
+ if (error) reject(error);
394
+ else resolve();
395
+ };
396
+ const acceptLoaded = () => {
397
+ if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
398
+ if (image.complete) finish(new Error("图片尺寸无效"));
399
+ return;
400
+ }
401
+ if (!decodeStarted && typeof image.decode === "function") {
402
+ decodeStarted = true;
403
+ // decode() is a second readiness signal, but the per-attempt timer
404
+ // remains authoritative. A loaded image is still accepted after a
405
+ // short decode grace period if Chromium leaves decode() pending.
406
+ decodeFallback = setTimeout(() => {
407
+ if (
408
+ image.isConnected === true &&
409
+ image.complete &&
410
+ image.naturalWidth > 0 &&
411
+ image.naturalHeight > 0
412
+ ) {
413
+ finish();
414
+ } else {
415
+ finish(new Error("图片解码未完成"));
416
+ }
417
+ }, 750);
418
+ let decodePromise;
419
+ try {
420
+ decodePromise = image.decode();
421
+ } catch {
422
+ finish(new Error("图片解码失败"));
423
+ return;
424
+ }
425
+ Promise.resolve(decodePromise).then(
426
+ () => finish(),
427
+ () => finish(new Error("图片解码失败")),
428
+ );
429
+ return;
430
+ }
431
+ finish();
432
+ };
433
+ const failed = () => finish(new Error("图片请求失败"));
434
+ const aborted = () => finish(abortError());
435
+ const timer = setTimeout(
436
+ () => finish(new Error("等待图片请求或解码超时")),
437
+ timeoutMs,
438
+ );
439
+ image.onload = acceptLoaded;
440
+ image.onerror = failed;
441
+ signal?.addEventListener?.("abort", aborted, { once: true });
442
+ if (signal?.aborted) {
443
+ aborted();
444
+ return;
445
+ }
446
+ image.src = url;
447
+ if (image.complete) queueMicrotask(acceptLoaded);
448
+ });
449
+ }
450
+
451
+ function waitForRetryWindow(signal, timeoutMs) {
452
+ return new Promise((resolve, reject) => {
453
+ let settled = false;
454
+ const finish = (error = null) => {
455
+ if (settled) return;
456
+ settled = true;
457
+ clearTimeout(timer);
458
+ signal?.removeEventListener?.("abort", aborted);
459
+ if (error) reject(error);
460
+ else resolve();
461
+ };
462
+ const aborted = () => finish(abortError());
463
+ const timer = setTimeout(() => finish(), timeoutMs);
464
+ signal?.addEventListener?.("abort", aborted, { once: true });
465
+ if (signal?.aborted) aborted();
466
+ });
467
+ }
468
+
469
+ async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
470
+ const deadline = performance.now() + timeoutMs;
471
+ const attempts = [];
472
+ let lastError = null;
473
+
474
+ const startAttempt = (attempt) => {
475
+ throwIfAborted(signal);
476
+ const remainingMs = deadline - performance.now();
477
+ if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
478
+ const controller = new AbortController();
479
+ const parentAborted = () => controller.abort();
480
+ signal?.addEventListener?.("abort", parentAborted, { once: true });
481
+ const image = new Image();
482
+ image.alt = "";
483
+ image.decoding = "async";
484
+ image.className = "beauticode-media-poster";
485
+ slot.prepend(image);
486
+ const promise = waitForImageAttempt(
487
+ image,
488
+ imageAttemptUrl(url, attempt),
489
+ controller.signal,
490
+ Math.max(1, remainingMs),
491
+ )
492
+ .then(() => image)
493
+ .finally(() => signal?.removeEventListener?.("abort", parentAborted));
494
+ const record = { controller, image, promise };
495
+ attempts.push(record);
496
+ return record;
497
+ };
498
+
499
+ const cleanupAttempts = (winner = null) => {
500
+ for (const attempt of attempts) {
501
+ if (attempt.image === winner) continue;
502
+ attempt.controller.abort();
503
+ releaseImage(attempt.image, { remove: true });
504
+ }
505
+ };
506
+
507
+ try {
508
+ const first = startAttempt(0);
509
+ const firstWindow = await Promise.race([
510
+ first.promise.then(
511
+ (image) => ({ image }),
512
+ (error) => ({ error }),
513
+ ),
514
+ waitForRetryWindow(
515
+ signal,
516
+ Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
517
+ ).then(() => ({ retry: true })),
518
+ ]);
519
+ if (firstWindow.image) {
520
+ cleanupAttempts(firstWindow.image);
521
+ return firstWindow.image;
522
+ }
523
+ if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
524
+ lastError = firstWindow.error ?? lastError;
525
+
526
+ // Keep the original request alive. The cache-busted retry runs beside it
527
+ // so a merely slow cold decode is never destroyed to recover a hung one.
528
+ if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
529
+ let winner;
530
+ try {
531
+ winner = await Promise.any(attempts.map((attempt) => attempt.promise));
532
+ } catch (error) {
533
+ const errors = Array.isArray(error?.errors) ? error.errors : [];
534
+ lastError = errors.at(-1) ?? lastError ?? error;
535
+ throw lastError;
536
+ }
537
+ cleanupAttempts(winner);
538
+ return winner;
539
+ } catch (error) {
540
+ cleanupAttempts();
541
+ if (isAbortError(error) || signal?.aborted) throw abortError();
542
+ lastError = error ?? lastError;
543
+ const visibility = document.visibilityState || "unknown";
544
+ const online = globalThis.navigator?.onLine === false ? "offline" : "online";
545
+ const phase = lastError?.message || "unknown";
546
+ const summary =
547
+ phase === "图片请求失败"
548
+ ? "图片加载失败"
549
+ : phase.includes("超时")
550
+ ? "等待图片加载超时"
551
+ : "图片校验失败";
552
+ throw new Error(
553
+ `${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
554
+ );
555
+ }
556
+ }
557
+
558
+ function safeOrigin(value) {
559
+ try {
560
+ return new URL(value, globalThis.location?.href).origin;
561
+ } catch {
562
+ return "unknown";
563
+ }
564
+ }
565
+
566
+ function videoRequestContext(url) {
567
+ return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`;
568
+ }
569
+
570
+ async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) {
571
+ throwIfAborted(signal);
572
+ const controller = new AbortController();
573
+ const parentAborted = () => controller.abort();
574
+ signal?.addEventListener?.("abort", parentAborted, { once: true });
575
+ let timedOut = false;
576
+ const timer = setTimeout(() => {
577
+ timedOut = true;
578
+ controller.abort();
579
+ }, Math.max(1, timeoutMs));
580
+ try {
581
+ const response = await fetch(url, {
582
+ method: "GET",
583
+ headers: { Range: "bytes=0-1" },
584
+ cache: "no-store",
585
+ credentials: "omit",
586
+ mode: "cors",
587
+ signal: controller.signal,
588
+ });
589
+ if (response.status !== 206) {
590
+ if (response.body) {
591
+ await response.body.cancel().catch(() => {});
592
+ }
593
+ throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`);
594
+ }
595
+ const bytes = await response.arrayBuffer();
596
+ if (bytes.byteLength !== 2) {
597
+ throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`);
598
+ }
599
+ } catch (error) {
600
+ if (signal?.aborted) throw abortError();
601
+ const context = videoRequestContext(url);
602
+ if (timedOut || isAbortError(error)) {
603
+ throw new Error(`视频媒体 Range 探针超时;${context}`);
604
+ }
605
+ const detail = error instanceof Error ? error.message : String(error);
606
+ if (error instanceof TypeError) {
607
+ throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`);
608
+ }
609
+ throw new Error(`${detail};${context}`);
610
+ } finally {
611
+ clearTimeout(timer);
612
+ signal?.removeEventListener?.("abort", parentAborted);
613
+ }
614
+ }
615
+
616
+ const videoEventLog = new WeakMap();
617
+ const VIDEO_DIAGNOSTIC_EVENTS = [
618
+ "loadstart",
619
+ "durationchange",
620
+ "loadedmetadata",
621
+ "loadeddata",
622
+ "progress",
623
+ "canplay",
624
+ "canplaythrough",
625
+ "playing",
626
+ "waiting",
627
+ "stalled",
628
+ "suspend",
629
+ "emptied",
630
+ "error",
631
+ "abort",
632
+ ];
633
+
634
+ // Field reports only used to show the final readyState. Recording when
635
+ // each media event fired turns a bare timeout into a stage timeline: no
636
+ // events at all means the request never started, loadstart-then-nothing
637
+ // means the first range reads stalled (antivirus / cold cache).
638
+ function trackVideoDiagnostics(video) {
639
+ if (!video || typeof video.addEventListener !== "function") return;
640
+ if (videoEventLog.has(video)) return;
641
+ const log = [];
642
+ videoEventLog.set(video, log);
643
+ for (const name of VIDEO_DIAGNOSTIC_EVENTS) {
644
+ video.addEventListener(name, () => {
645
+ log.push(`${name}@${Math.round(performance.now())}ms`);
646
+ if (log.length > 14) log.splice(0, log.length - 14);
647
+ });
648
+ }
649
+ }
650
+
651
+ function describeVideoDiagnostics(video) {
652
+ const log = videoEventLog.get(video);
653
+ if (!Array.isArray(log) || log.length === 0) {
654
+ return "媒体事件=无(媒体请求未开始)";
655
+ }
656
+ return `媒体事件=${log.join(",")}`;
657
+ }
658
+
659
+ function describeVideoState(video, phase) {
563
660
  const mediaErrorNames = {
564
661
  1: "MEDIA_ERR_ABORTED",
565
662
  2: "MEDIA_ERR_NETWORK",
@@ -568,195 +665,61 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
568
665
  };
569
666
  const code = Number(video?.error?.code) || 0;
570
667
  const mediaError = code ? mediaErrorNames[code] || `MEDIA_ERR_${code}` : "none";
571
- const src = video?.currentSrc || video?.src || "";
572
- return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)};${videoRequestContext(src)}`;
668
+ const src = video?.currentSrc || video?.src || "";
669
+ return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)};${videoRequestContext(src)};${describeVideoDiagnostics(video)}`;
670
+ }
671
+
672
+ function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
673
+ const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
674
+ if (video.readyState >= frameReadyState) return Promise.resolve();
675
+ return new Promise((resolve, reject) => {
676
+ let settled = false;
677
+ const done = () => finish();
678
+ const check = () => {
679
+ if (video.readyState >= frameReadyState) done();
680
+ };
681
+ const failed = () => {
682
+ finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
683
+ };
684
+ const aborted = () => {
685
+ finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
686
+ };
687
+ const timer = setTimeout(() => {
688
+ finish(new Error(describeVideoState(video, "等待视频首帧超时")));
689
+ }, timeoutMs);
690
+ const cleanup = () => {
691
+ clearTimeout(timer);
692
+ video.removeEventListener("loadeddata", check);
693
+ video.removeEventListener("canplay", done);
694
+ video.removeEventListener("error", failed);
695
+ video.removeEventListener("abort", aborted);
696
+ signal?.removeEventListener?.("abort", aborted);
697
+ };
698
+ const finish = (error) => {
699
+ if (settled) return;
700
+ settled = true;
701
+ cleanup();
702
+ if (error) reject(error);
703
+ else resolve();
704
+ };
705
+ video.addEventListener("loadeddata", check);
706
+ video.addEventListener("canplay", done, { once: true });
707
+ video.addEventListener("error", failed, { once: true });
708
+ video.addEventListener("abort", aborted, { once: true });
709
+ signal?.addEventListener?.("abort", aborted, { once: true });
710
+ if (signal?.aborted) aborted();
711
+ });
573
712
  }
574
713
 
575
- function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
576
- const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
577
- if (video.readyState >= frameReadyState) return Promise.resolve();
578
- return new Promise((resolve, reject) => {
579
- let settled = false;
580
- const done = () => finish();
581
- const check = () => {
582
- if (video.readyState >= frameReadyState) done();
583
- };
584
- const failed = () => {
585
- finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
586
- };
587
- const aborted = () => {
588
- finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
589
- };
590
- const timer = setTimeout(() => {
591
- finish(new Error(describeVideoState(video, "等待视频首帧超时")));
592
- }, timeoutMs);
593
- const cleanup = () => {
594
- clearTimeout(timer);
595
- video.removeEventListener("loadeddata", check);
596
- video.removeEventListener("canplay", done);
597
- video.removeEventListener("error", failed);
598
- video.removeEventListener("abort", aborted);
599
- signal?.removeEventListener?.("abort", aborted);
600
- };
601
- const finish = (error) => {
602
- if (settled) return;
603
- settled = true;
604
- cleanup();
605
- if (error) reject(error);
606
- else resolve();
607
- };
608
- video.addEventListener("loadeddata", check);
609
- video.addEventListener("canplay", done, { once: true });
610
- video.addEventListener("error", failed, { once: true });
611
- video.addEventListener("abort", aborted, { once: true });
612
- signal?.addEventListener?.("abort", aborted, { once: true });
613
- if (signal?.aborted) aborted();
614
- });
615
- }
616
-
617
- function waitForSeek(video, signal, timeoutMs) {
618
- if (!video.seeking) return Promise.resolve();
619
- return new Promise((resolve, reject) => {
620
- let settled = false;
621
- const cleanup = () => {
622
- clearTimeout(timer);
623
- video.removeEventListener("seeked", done);
624
- video.removeEventListener("error", failed);
625
- signal?.removeEventListener?.("abort", aborted);
626
- };
627
- const finish = (error) => {
628
- if (settled) return;
629
- settled = true;
630
- cleanup();
631
- if (error) reject(error);
632
- else resolve();
633
- };
634
- const done = () => finish();
635
- const failed = () => finish(new Error(describeVideoState(video, "视频跳转失败")));
636
- const aborted = () => finish(abortError());
637
- const timer = setTimeout(
638
- () => finish(new Error(describeVideoState(video, "等待视频跳转超时"))),
639
- timeoutMs,
640
- );
641
- video.addEventListener("seeked", done, { once: true });
642
- video.addEventListener("error", failed, { once: true });
643
- signal?.addEventListener?.("abort", aborted, { once: true });
644
- if (signal?.aborted) aborted();
645
- });
646
- }
647
-
648
- function waitForPresentedFrame(video, signal, timeoutMs) {
649
- return new Promise((resolve, reject) => {
650
- const startedAt = performance.now();
651
- const initialTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
652
- let frameSeen = false;
653
- let playingSeen =
654
- !video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
655
- let settled = false;
656
- let frameRequest = null;
657
- const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
658
-
659
- const cleanup = () => {
660
- clearInterval(timer);
661
- video.removeEventListener("playing", onPlaying);
662
- video.removeEventListener("timeupdate", check);
663
- video.removeEventListener("loadeddata", check);
664
- video.removeEventListener("canplay", check);
665
- video.removeEventListener("error", failed);
666
- video.removeEventListener("abort", aborted);
667
- signal?.removeEventListener?.("abort", aborted);
668
- if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
669
- video.cancelVideoFrameCallback(frameRequest);
670
- }
671
- };
672
- const finish = (error) => {
673
- if (settled) return;
674
- settled = true;
675
- cleanup();
676
- if (error) reject(error);
677
- else resolve();
678
- };
679
- const failed = () => {
680
- finish(new Error(describeVideoState(video, "视频解码器报告失败")));
681
- };
682
- const aborted = () => {
683
- finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
684
- };
685
- const check = () => {
686
- if (settled) return;
687
- if (video.error) {
688
- failed();
689
- return;
690
- }
691
- if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
692
- playingSeen = true;
693
- }
694
- const current = Number.isFinite(video.currentTime) ? video.currentTime : initialTime;
695
- const progressed = current >= initialTime + VIDEO_FIRST_FRAME_PROGRESS_SEC;
696
- if (
697
- video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
698
- !video.paused &&
699
- playingSeen &&
700
- (frameSeen || progressed)
701
- ) {
702
- finish();
703
- return;
704
- }
705
- if (performance.now() - startedAt >= timeoutMs) {
706
- finish(new Error(describeVideoState(video, "视频未在首帧窗口内完成呈现")));
707
- }
708
- };
709
- const onPlaying = () => {
710
- playingSeen = true;
711
- check();
712
- };
713
- const onFrame = () => {
714
- frameSeen = true;
715
- check();
716
- };
717
- const timer = setInterval(check, 80);
718
- video.addEventListener("playing", onPlaying);
719
- video.addEventListener("timeupdate", check);
720
- video.addEventListener("loadeddata", check);
721
- video.addEventListener("canplay", check);
722
- video.addEventListener("error", failed, { once: true });
723
- video.addEventListener("abort", aborted, { once: true });
724
- signal?.addEventListener?.("abort", aborted, { once: true });
725
- if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
726
- if (signal?.aborted) aborted();
727
- check();
728
- });
729
- }
730
-
731
- function waitForStablePlayback(video, signal, timeoutMs) {
732
- return new Promise((resolve, reject) => {
733
- const startedAt = performance.now();
734
- let lastTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
735
- let accumulatedProgress = 0;
736
- let advancingSamples = 0;
737
- let stableFrames = 0;
738
- let lastFrameTime = null;
739
- let playingSeen =
740
- !video.paused &&
741
- video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
742
- let settled = false;
743
- let frameRequest = null;
744
- const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
745
-
746
- const cleanup = () => {
747
- clearInterval(timer);
748
- video.removeEventListener("playing", onPlaying);
749
- video.removeEventListener("timeupdate", check);
750
- video.removeEventListener("waiting", resetStableWindow);
751
- video.removeEventListener("stalled", resetStableWindow);
752
- video.removeEventListener("pause", resetStableWindow);
753
- video.removeEventListener("seeking", resetStableWindow);
754
- video.removeEventListener("error", failed);
755
- video.removeEventListener("abort", aborted);
756
- signal?.removeEventListener?.("abort", aborted);
757
- if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
758
- video.cancelVideoFrameCallback(frameRequest);
759
- }
714
+ function waitForSeek(video, signal, timeoutMs) {
715
+ if (!video.seeking) return Promise.resolve();
716
+ return new Promise((resolve, reject) => {
717
+ let settled = false;
718
+ const cleanup = () => {
719
+ clearTimeout(timer);
720
+ video.removeEventListener("seeked", done);
721
+ video.removeEventListener("error", failed);
722
+ signal?.removeEventListener?.("abort", aborted);
760
723
  };
761
724
  const finish = (error) => {
762
725
  if (settled) return;
@@ -765,57 +728,191 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
765
728
  if (error) reject(error);
766
729
  else resolve();
767
730
  };
768
- const failed = () => {
769
- finish(new Error(describeVideoState(video, "视频解码器报告失败")));
770
- };
771
- const aborted = () => {
772
- finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
773
- };
774
- const observeProgress = () => {
775
- const current = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
776
- let delta = current - lastTime;
777
- if (
778
- delta < 0 &&
779
- Number.isFinite(video.duration) &&
780
- video.duration > 0 &&
781
- lastTime > video.duration - 1 &&
782
- current < 1
783
- ) {
784
- delta = video.duration - lastTime + current;
785
- }
786
- if (delta > 0.003 && delta < 2) {
787
- accumulatedProgress += delta;
788
- advancingSamples += 1;
789
- }
790
- lastTime = current;
791
- };
792
- const resetStableWindow = () => {
793
- playingSeen = false;
794
- stableFrames = 0;
795
- advancingSamples = 0;
796
- accumulatedProgress = 0;
797
- lastTime = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
798
- };
799
- const check = () => {
800
- if (settled) return;
801
- if (video.error) {
802
- failed();
803
- return;
804
- }
805
- observeProgress();
806
- if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
807
- playingSeen = true;
808
- }
809
- const framesReady = hasFrameCallback
810
- ? stableFrames >= VIDEO_STABLE_FRAMES
811
- : advancingSamples >= VIDEO_STABLE_FRAMES;
812
- if (
813
- video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
814
- !video.paused &&
815
- playingSeen &&
816
- framesReady &&
817
- accumulatedProgress >= VIDEO_STABLE_PROGRESS_SEC
818
- ) {
731
+ const done = () => finish();
732
+ const failed = () => finish(new Error(describeVideoState(video, "视频跳转失败")));
733
+ const aborted = () => finish(abortError());
734
+ const timer = setTimeout(
735
+ () => finish(new Error(describeVideoState(video, "等待视频跳转超时"))),
736
+ timeoutMs,
737
+ );
738
+ video.addEventListener("seeked", done, { once: true });
739
+ video.addEventListener("error", failed, { once: true });
740
+ signal?.addEventListener?.("abort", aborted, { once: true });
741
+ if (signal?.aborted) aborted();
742
+ });
743
+ }
744
+
745
+ function waitForPresentedFrame(video, signal, timeoutMs) {
746
+ return new Promise((resolve, reject) => {
747
+ const startedAt = performance.now();
748
+ const initialTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
749
+ let frameSeen = false;
750
+ let playingSeen =
751
+ !video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
752
+ let settled = false;
753
+ let frameRequest = null;
754
+ const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
755
+
756
+ const cleanup = () => {
757
+ clearInterval(timer);
758
+ video.removeEventListener("playing", onPlaying);
759
+ video.removeEventListener("timeupdate", check);
760
+ video.removeEventListener("loadeddata", check);
761
+ video.removeEventListener("canplay", check);
762
+ video.removeEventListener("error", failed);
763
+ video.removeEventListener("abort", aborted);
764
+ signal?.removeEventListener?.("abort", aborted);
765
+ if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
766
+ video.cancelVideoFrameCallback(frameRequest);
767
+ }
768
+ };
769
+ const finish = (error) => {
770
+ if (settled) return;
771
+ settled = true;
772
+ cleanup();
773
+ if (error) reject(error);
774
+ else resolve();
775
+ };
776
+ const failed = () => {
777
+ finish(new Error(describeVideoState(video, "视频解码器报告失败")));
778
+ };
779
+ const aborted = () => {
780
+ finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
781
+ };
782
+ const check = () => {
783
+ if (settled) return;
784
+ if (video.error) {
785
+ failed();
786
+ return;
787
+ }
788
+ if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
789
+ playingSeen = true;
790
+ }
791
+ const current = Number.isFinite(video.currentTime) ? video.currentTime : initialTime;
792
+ const progressed = current >= initialTime + VIDEO_FIRST_FRAME_PROGRESS_SEC;
793
+ if (
794
+ video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
795
+ !video.paused &&
796
+ playingSeen &&
797
+ (frameSeen || progressed)
798
+ ) {
799
+ finish();
800
+ return;
801
+ }
802
+ if (performance.now() - startedAt >= timeoutMs) {
803
+ finish(new Error(describeVideoState(video, "视频未在首帧窗口内完成呈现")));
804
+ }
805
+ };
806
+ const onPlaying = () => {
807
+ playingSeen = true;
808
+ check();
809
+ };
810
+ const onFrame = () => {
811
+ frameSeen = true;
812
+ check();
813
+ };
814
+ const timer = setInterval(check, 80);
815
+ video.addEventListener("playing", onPlaying);
816
+ video.addEventListener("timeupdate", check);
817
+ video.addEventListener("loadeddata", check);
818
+ video.addEventListener("canplay", check);
819
+ video.addEventListener("error", failed, { once: true });
820
+ video.addEventListener("abort", aborted, { once: true });
821
+ signal?.addEventListener?.("abort", aborted, { once: true });
822
+ if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
823
+ if (signal?.aborted) aborted();
824
+ check();
825
+ });
826
+ }
827
+
828
+ function waitForStablePlayback(video, signal, timeoutMs) {
829
+ return new Promise((resolve, reject) => {
830
+ const startedAt = performance.now();
831
+ let lastTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
832
+ let accumulatedProgress = 0;
833
+ let advancingSamples = 0;
834
+ let stableFrames = 0;
835
+ let lastFrameTime = null;
836
+ let playingSeen =
837
+ !video.paused &&
838
+ video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
839
+ let settled = false;
840
+ let frameRequest = null;
841
+ const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
842
+
843
+ const cleanup = () => {
844
+ clearInterval(timer);
845
+ video.removeEventListener("playing", onPlaying);
846
+ video.removeEventListener("timeupdate", check);
847
+ video.removeEventListener("waiting", resetStableWindow);
848
+ video.removeEventListener("stalled", resetStableWindow);
849
+ video.removeEventListener("pause", resetStableWindow);
850
+ video.removeEventListener("seeking", resetStableWindow);
851
+ video.removeEventListener("error", failed);
852
+ video.removeEventListener("abort", aborted);
853
+ signal?.removeEventListener?.("abort", aborted);
854
+ if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
855
+ video.cancelVideoFrameCallback(frameRequest);
856
+ }
857
+ };
858
+ const finish = (error) => {
859
+ if (settled) return;
860
+ settled = true;
861
+ cleanup();
862
+ if (error) reject(error);
863
+ else resolve();
864
+ };
865
+ const failed = () => {
866
+ finish(new Error(describeVideoState(video, "视频解码器报告失败")));
867
+ };
868
+ const aborted = () => {
869
+ finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
870
+ };
871
+ const observeProgress = () => {
872
+ const current = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
873
+ let delta = current - lastTime;
874
+ if (
875
+ delta < 0 &&
876
+ Number.isFinite(video.duration) &&
877
+ video.duration > 0 &&
878
+ lastTime > video.duration - 1 &&
879
+ current < 1
880
+ ) {
881
+ delta = video.duration - lastTime + current;
882
+ }
883
+ if (delta > 0.003 && delta < 2) {
884
+ accumulatedProgress += delta;
885
+ advancingSamples += 1;
886
+ }
887
+ lastTime = current;
888
+ };
889
+ const resetStableWindow = () => {
890
+ playingSeen = false;
891
+ stableFrames = 0;
892
+ advancingSamples = 0;
893
+ accumulatedProgress = 0;
894
+ lastTime = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
895
+ };
896
+ const check = () => {
897
+ if (settled) return;
898
+ if (video.error) {
899
+ failed();
900
+ return;
901
+ }
902
+ observeProgress();
903
+ if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
904
+ playingSeen = true;
905
+ }
906
+ const framesReady = hasFrameCallback
907
+ ? stableFrames >= VIDEO_STABLE_FRAMES
908
+ : advancingSamples >= VIDEO_STABLE_FRAMES;
909
+ if (
910
+ video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
911
+ !video.paused &&
912
+ playingSeen &&
913
+ framesReady &&
914
+ accumulatedProgress >= VIDEO_STABLE_PROGRESS_SEC
915
+ ) {
819
916
  finish();
820
917
  return;
821
918
  }
@@ -823,39 +920,39 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
823
920
  finish(new Error(describeVideoState(video, "视频未在稳定窗口内输出首帧")));
824
921
  }
825
922
  };
826
- const onPlaying = () => {
827
- playingSeen = true;
828
- check();
829
- };
830
- const onFrame = (_now, metadata) => {
831
- if (settled) return;
832
- const mediaTime = Number.isFinite(metadata?.mediaTime)
833
- ? metadata.mediaTime
834
- : video.currentTime;
835
- if (lastFrameTime == null || mediaTime > lastFrameTime + 0.001) {
836
- stableFrames += 1;
837
- } else {
838
- stableFrames = 0;
839
- }
840
- lastFrameTime = mediaTime;
841
- check();
842
- if (!settled) frameRequest = video.requestVideoFrameCallback(onFrame);
843
- };
844
- const timer = setInterval(check, 80);
845
- video.addEventListener("playing", onPlaying);
846
- video.addEventListener("timeupdate", check);
847
- video.addEventListener("waiting", resetStableWindow);
848
- video.addEventListener("stalled", resetStableWindow);
849
- video.addEventListener("pause", resetStableWindow);
850
- video.addEventListener("seeking", resetStableWindow);
851
- video.addEventListener("error", failed, { once: true });
852
- video.addEventListener("abort", aborted, { once: true });
853
- signal?.addEventListener?.("abort", aborted, { once: true });
854
- if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
855
- if (signal?.aborted) aborted();
856
- check();
857
- });
858
- }
923
+ const onPlaying = () => {
924
+ playingSeen = true;
925
+ check();
926
+ };
927
+ const onFrame = (_now, metadata) => {
928
+ if (settled) return;
929
+ const mediaTime = Number.isFinite(metadata?.mediaTime)
930
+ ? metadata.mediaTime
931
+ : video.currentTime;
932
+ if (lastFrameTime == null || mediaTime > lastFrameTime + 0.001) {
933
+ stableFrames += 1;
934
+ } else {
935
+ stableFrames = 0;
936
+ }
937
+ lastFrameTime = mediaTime;
938
+ check();
939
+ if (!settled) frameRequest = video.requestVideoFrameCallback(onFrame);
940
+ };
941
+ const timer = setInterval(check, 80);
942
+ video.addEventListener("playing", onPlaying);
943
+ video.addEventListener("timeupdate", check);
944
+ video.addEventListener("waiting", resetStableWindow);
945
+ video.addEventListener("stalled", resetStableWindow);
946
+ video.addEventListener("pause", resetStableWindow);
947
+ video.addEventListener("seeking", resetStableWindow);
948
+ video.addEventListener("error", failed, { once: true });
949
+ video.addEventListener("abort", aborted, { once: true });
950
+ signal?.addEventListener?.("abort", aborted, { once: true });
951
+ if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
952
+ if (signal?.aborted) aborted();
953
+ check();
954
+ });
955
+ }
859
956
 
860
957
  function seekVideo(video, value) {
861
958
  const requested = Number(value);
@@ -866,504 +963,506 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
866
963
  (!Number.isFinite(duration) || duration <= 0 || requested < duration)
867
964
  ? requested
868
965
  : 0;
869
- try {
870
- video.currentTime = safe;
966
+ try {
967
+ video.currentTime = safe;
968
+ } catch {
969
+ video.currentTime = 0;
970
+ }
971
+ return safe;
972
+ }
973
+
974
+ async function playWithPreference(video, signal = null) {
975
+ throwIfAborted(signal);
976
+ const requestedMuted = desiredModes.muted;
977
+ let blocked = false;
978
+ video.muted = requestedMuted;
979
+ try {
980
+ await video.play();
981
+ } catch (error) {
982
+ throwIfAborted(signal);
983
+ if (requestedMuted) throw error;
984
+ blocked = true;
985
+ video.muted = true;
986
+ await video.play();
987
+ }
988
+ throwIfAborted(signal);
989
+ video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
990
+ return blocked;
991
+ }
992
+
993
+ async function startCandidatePlayback(video, signal, timeoutMs) {
994
+ const playbackController = new AbortController();
995
+ const parentAborted = () => playbackController.abort();
996
+ signal?.addEventListener?.("abort", parentAborted, { once: true });
997
+ let timer = null;
998
+ let abortListener = null;
999
+ const playback = playWithPreference(video, playbackController.signal);
1000
+ const deadline = new Promise((_, reject) => {
1001
+ timer = setTimeout(() => {
1002
+ reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
1003
+ playbackController.abort();
1004
+ }, timeoutMs);
1005
+ abortListener = () => reject(abortError());
1006
+ playbackController.signal.addEventListener("abort", abortListener, { once: true });
1007
+ });
1008
+ if (signal?.aborted) parentAborted();
1009
+ try {
1010
+ // The play() promise itself resolves only after playback has started, so
1011
+ // a second playing-event waiter would create an orphaned promise race.
1012
+ return await Promise.race([playback, deadline]);
1013
+ } catch (error) {
1014
+ playbackController.abort();
1015
+ try {
1016
+ video.pause();
1017
+ } catch {
1018
+ /* best effort: disposeSlot will release src as the final guard */
1019
+ }
1020
+ throw error;
1021
+ } finally {
1022
+ if (timer) clearTimeout(timer);
1023
+ signal?.removeEventListener?.("abort", parentAborted);
1024
+ if (abortListener) {
1025
+ playbackController.signal.removeEventListener("abort", abortListener);
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ function disposeVideo(video) {
1031
+ if (!(video instanceof HTMLVideoElement)) return;
1032
+ try {
1033
+ video.pause();
871
1034
  } catch {
872
- video.currentTime = 0;
873
- }
874
- return safe;
875
- }
876
-
877
- async function playWithPreference(video, signal = null) {
878
- throwIfAborted(signal);
879
- const requestedMuted = desiredModes.muted;
880
- let blocked = false;
881
- video.muted = requestedMuted;
882
- try {
883
- await video.play();
884
- } catch (error) {
885
- throwIfAborted(signal);
886
- if (requestedMuted) throw error;
887
- blocked = true;
888
- video.muted = true;
889
- await video.play();
890
- }
891
- throwIfAborted(signal);
892
- video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
893
- return blocked;
894
- }
895
-
896
- async function startCandidatePlayback(video, signal, timeoutMs) {
897
- const playbackController = new AbortController();
898
- const parentAborted = () => playbackController.abort();
899
- signal?.addEventListener?.("abort", parentAborted, { once: true });
900
- let timer = null;
901
- let abortListener = null;
902
- const playback = playWithPreference(video, playbackController.signal);
903
- const deadline = new Promise((_, reject) => {
904
- timer = setTimeout(() => {
905
- reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
906
- playbackController.abort();
907
- }, timeoutMs);
908
- abortListener = () => reject(abortError());
909
- playbackController.signal.addEventListener("abort", abortListener, { once: true });
910
- });
911
- if (signal?.aborted) parentAborted();
912
- try {
913
- // The play() promise itself resolves only after playback has started, so
914
- // a second playing-event waiter would create an orphaned promise race.
915
- return await Promise.race([playback, deadline]);
916
- } catch (error) {
917
- playbackController.abort();
918
- try {
919
- video.pause();
920
- } catch {
921
- /* best effort: disposeSlot will release src as the final guard */
922
- }
923
- throw error;
924
- } finally {
925
- if (timer) clearTimeout(timer);
926
- signal?.removeEventListener?.("abort", parentAborted);
927
- if (abortListener) {
928
- playbackController.signal.removeEventListener("abort", abortListener);
929
- }
930
- }
931
- }
932
-
933
- function disposeVideo(video) {
934
- if (!(video instanceof HTMLVideoElement)) return;
935
- try {
936
- video.pause();
937
- } catch {
938
- /* best effort */
939
- }
940
- const objectUrl = video.dataset?.bcObjectUrl;
941
- if (
942
- objectUrl &&
943
- typeof URL !== "undefined" &&
944
- typeof URL.revokeObjectURL === "function"
945
- ) {
946
- URL.revokeObjectURL(objectUrl);
947
- }
948
- try {
949
- video.removeAttribute("src");
950
- video.srcObject = null;
951
- video.load();
952
- } catch {
953
- /* detached or already released */
954
- }
955
- }
956
-
957
- function disposeSlot(slot) {
958
- if (!slot) return;
959
- for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
960
- for (const image of slot.querySelectorAll?.("img") ?? []) {
961
- releaseImage(image);
962
- }
963
- slot.remove?.();
964
- }
965
-
966
- function createSlot(payload, video = null) {
967
- const slot = document.createElement("div");
968
- slot.className = "beauticode-media-slot";
969
- slot.dataset.bcRole = "candidate";
970
- slot.dataset.bcMedia = payload.media;
971
- slot.dataset.bcGeneration = String(payload.generation);
972
- slot.dataset.bcImageUrl = payload.imageUrl;
973
- if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
974
- if (payload.media === "video") {
975
- const startAt = Number(payload.startAt);
976
- slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
977
- }
978
- if (video) {
979
- video.className = "beauticode-media-video";
980
- slot.append(video);
981
- }
982
- return slot;
983
- }
984
-
985
- function slotMatchesPayload(slot, payload) {
986
- if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
987
- return false;
988
- }
989
- if (payload.media !== "video") {
990
- if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
991
- const image = slot.querySelector?.("img");
992
- return Boolean(
993
- image?.isConnected === true &&
994
- image.complete &&
995
- image.naturalWidth > 0 &&
996
- image.naturalHeight > 0,
997
- );
998
- }
999
- if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
1000
- const video = slot.querySelector?.("video");
1001
- if (
1002
- !(video instanceof HTMLVideoElement) ||
1003
- video.error ||
1004
- video.ended ||
1005
- video.paused ||
1006
- video.seeking ||
1007
- video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
1008
- ) {
1009
- return false;
1010
- }
1011
- // A local re-import can keep the exact same video handle while producing a
1012
- // new poster token and resetting startAt. Reuse the live decoder; the
1013
- // caller updates the poster metadata and seeks in place when needed.
1014
- return true;
1015
- }
1016
-
1017
- function updateCommittedDom(payload) {
1018
- document.documentElement.dataset.bcGeneration = String(payload.generation);
1019
- document.documentElement.removeAttribute("data-bc-pending-generation");
1020
- if (payload.media === "clear") {
1021
- document.documentElement.removeAttribute("data-bc-active");
1022
- document.documentElement.removeAttribute("data-bc-media");
1023
- document.documentElement.removeAttribute("data-bc-video-ready");
1024
- return;
1025
- }
1026
- document.documentElement.dataset.bcActive = "true";
1027
- document.documentElement.dataset.bcMedia = payload.media;
1028
- if (payload.media === "video") document.documentElement.dataset.bcVideoReady = "true";
1029
- else document.documentElement.removeAttribute("data-bc-video-ready");
1030
- }
1031
-
1032
- function discardCandidates() {
1033
- const node = document.getElementById("beauticode-bg-stage");
1034
- if (!node) return;
1035
- node.removeAttribute("data-bc-transitioning");
1036
- node.removeAttribute("data-bc-empty");
1037
- for (const slot of
1038
- node.querySelectorAll?.('.beauticode-media-slot[data-bc-role="candidate"]') ?? []) {
1039
- disposeSlot(slot);
1040
- }
1041
- }
1042
-
1043
- function attachCandidate(slot, payload) {
1044
- const node = stage();
1045
- discardCandidates();
1046
- const previous = mountedCurrentSlot();
1047
- slot.dataset.bcRole = "candidate";
1048
- node.append(slot);
1049
- if (!previous) {
1050
- node.dataset.bcEmpty = "true";
1051
- document.documentElement.dataset.bcActive = "true";
1052
- document.documentElement.dataset.bcMedia =
1053
- payload.media === "video" ? "video-pending" : payload.media;
1054
- document.documentElement.removeAttribute("data-bc-video-ready");
1055
- }
1056
- }
1057
-
1058
- function nextFrame(signal) {
1059
- if (typeof requestAnimationFrame !== "function") return Promise.resolve();
1060
- return new Promise((resolve, reject) => {
1061
- let settled = false;
1062
- let request = null;
1063
- let timer = null;
1064
- const cleanup = () => {
1065
- if (timer) clearTimeout(timer);
1066
- if (request != null && typeof cancelAnimationFrame === "function") {
1067
- cancelAnimationFrame(request);
1068
- }
1069
- signal?.removeEventListener?.("abort", aborted);
1070
- };
1071
- const finish = (error = null) => {
1072
- if (settled) return;
1073
- settled = true;
1074
- cleanup();
1075
- if (error) reject(error);
1076
- else resolve();
1077
- };
1078
- const aborted = () => {
1079
- finish(abortError());
1080
- };
1081
- request = requestAnimationFrame(() => {
1082
- request = null;
1083
- if (signal?.aborted) finish(abortError());
1084
- else finish();
1085
- });
1086
- // Background or power-saved Chromium pages may suspend rAF entirely.
1087
- // A short timer keeps the transaction bounded without skipping aborts.
1088
- timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
1089
- signal?.addEventListener?.("abort", aborted, { once: true });
1090
- if (signal?.aborted) aborted();
1091
- });
1092
- }
1093
-
1094
- function waitForCrossfade(slot, signal) {
1095
- if (reducedMotionMedia?.matches) return Promise.resolve();
1096
- return new Promise((resolve, reject) => {
1097
- let settled = false;
1098
- const cleanup = () => {
1099
- clearTimeout(timer);
1100
- slot.removeEventListener("transitionend", ended);
1101
- signal?.removeEventListener?.("abort", aborted);
1102
- };
1103
- const finish = (error) => {
1104
- if (settled) return;
1105
- settled = true;
1106
- cleanup();
1107
- if (error) reject(error);
1108
- else resolve();
1109
- };
1110
- const ended = (event) => {
1111
- if (event.target === slot && (!event.propertyName || event.propertyName === "opacity")) finish();
1112
- };
1113
- const aborted = () => finish(abortError());
1114
- const timer = setTimeout(() => finish(), CROSSFADE_MS + 80);
1115
- slot.addEventListener("transitionend", ended);
1116
- signal?.addEventListener?.("abort", aborted, { once: true });
1117
- if (signal?.aborted) aborted();
1118
- });
1119
- }
1120
-
1121
- async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
1122
- throwIfAborted(signal);
1123
- const node = stage();
1124
- const previous = mountedCurrentSlot();
1125
- const canAnimate =
1126
- previous &&
1127
- reducedMotionMedia?.matches !== true &&
1128
- document.visibilityState === "visible" &&
1129
- remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
1130
- if (canAnimate) {
1131
- await nextFrame(signal);
1132
- await nextFrame(signal);
1133
- throwIfAborted(signal);
1134
- node.dataset.bcTransitioning = "true";
1135
- await waitForCrossfade(previous, signal);
1136
- }
1137
- throwIfAborted(signal);
1138
- if (previous && previous !== slot) disposeSlot(previous);
1139
- slot.dataset.bcRole = "current";
1140
- node.removeAttribute("data-bc-transitioning");
1141
- node.removeAttribute("data-bc-empty");
1142
- currentSlot = slot;
1143
- committedPayload = payload;
1144
- const video = activeVideo();
1145
- playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true";
1146
- updateCommittedDom(payload);
1147
- syncGallery(payload);
1148
- }
1149
-
1150
- function restoreCommittedDom() {
1151
- const node = document.getElementById("beauticode-bg-stage");
1152
- node?.removeAttribute("data-bc-transitioning");
1153
- node?.removeAttribute("data-bc-empty");
1154
- if (committedPayload && mountedCurrentSlot()) {
1155
- updateCommittedDom(committedPayload);
1156
- playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
1157
- return;
1158
- }
1159
- node?.remove();
1160
- document.documentElement.removeAttribute("data-bc-active");
1161
- document.documentElement.removeAttribute("data-bc-media");
1162
- document.documentElement.removeAttribute("data-bc-video-ready");
1163
- }
1164
-
1165
- function syncGallery(payload) {
1166
- const on = payload?.atmosphere?.preset === "gallery";
1167
- try {
1168
- const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
1169
- if (sync && typeof sync.then === "function") void sync.catch(() => {});
1170
- } catch {
1171
- /* Atmosphere is optional and must never invalidate a rendered background. */
1172
- }
1173
- }
1174
-
1175
- async function applyBackground(payload, signal) {
1176
- throwIfAborted(signal);
1177
- const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
1178
- const remaining = () => Math.max(1, applyDeadline - performance.now());
1179
- if (payload.media === "clear") {
1180
- desiredModes.fish = false;
1181
- playbackBlocked = false;
1182
- document.documentElement.removeAttribute("data-bc-fish");
1183
- discardCandidates();
1184
- disposeSlot(currentSlot);
1185
- currentSlot = null;
1186
- committedPayload = payload;
1187
- document.getElementById("beauticode-bg-stage")?.remove();
1188
- updateCommittedDom(payload);
1189
- syncGallery(payload);
1190
- if (document.documentElement.dataset.bcGallery === "true") {
1191
- document.documentElement.dataset.bcActive = "true";
1192
- }
1035
+ /* best effort */
1036
+ }
1037
+ const objectUrl = video.dataset?.bcObjectUrl;
1038
+ if (
1039
+ objectUrl &&
1040
+ typeof URL !== "undefined" &&
1041
+ typeof URL.revokeObjectURL === "function"
1042
+ ) {
1043
+ URL.revokeObjectURL(objectUrl);
1044
+ }
1045
+ try {
1046
+ video.removeAttribute("src");
1047
+ video.srcObject = null;
1048
+ video.load();
1049
+ } catch {
1050
+ /* detached or already released */
1051
+ }
1052
+ }
1053
+
1054
+ function disposeSlot(slot) {
1055
+ if (!slot) return;
1056
+ for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
1057
+ for (const image of slot.querySelectorAll?.("img") ?? []) {
1058
+ releaseImage(image);
1059
+ }
1060
+ slot.remove?.();
1061
+ }
1062
+
1063
+ function createSlot(payload, video = null) {
1064
+ const slot = document.createElement("div");
1065
+ slot.className = "beauticode-media-slot";
1066
+ slot.dataset.bcRole = "candidate";
1067
+ slot.dataset.bcMedia = payload.media;
1068
+ slot.dataset.bcGeneration = String(payload.generation);
1069
+ slot.dataset.bcImageUrl = payload.imageUrl;
1070
+ if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
1071
+ if (payload.media === "video") {
1072
+ const startAt = Number(payload.startAt);
1073
+ slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
1074
+ }
1075
+ if (video) {
1076
+ video.className = "beauticode-media-video";
1077
+ slot.append(video);
1078
+ }
1079
+ return slot;
1080
+ }
1081
+
1082
+ function slotMatchesPayload(slot, payload) {
1083
+ if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
1084
+ return false;
1085
+ }
1086
+ if (payload.media !== "video") {
1087
+ if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
1088
+ const image = slot.querySelector?.("img");
1089
+ return Boolean(
1090
+ image?.isConnected === true &&
1091
+ image.complete &&
1092
+ image.naturalWidth > 0 &&
1093
+ image.naturalHeight > 0,
1094
+ );
1095
+ }
1096
+ if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
1097
+ const video = slot.querySelector?.("video");
1098
+ if (
1099
+ !(video instanceof HTMLVideoElement) ||
1100
+ video.error ||
1101
+ video.ended ||
1102
+ video.paused ||
1103
+ video.seeking ||
1104
+ video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
1105
+ ) {
1106
+ return false;
1107
+ }
1108
+ // A local re-import can keep the exact same video handle while producing a
1109
+ // new poster token and resetting startAt. Reuse the live decoder; the
1110
+ // caller updates the poster metadata and seeks in place when needed.
1111
+ return true;
1112
+ }
1113
+
1114
+ function updateCommittedDom(payload) {
1115
+ document.documentElement.dataset.bcGeneration = String(payload.generation);
1116
+ document.documentElement.removeAttribute("data-bc-pending-generation");
1117
+ if (payload.media === "clear") {
1118
+ document.documentElement.removeAttribute("data-bc-active");
1119
+ document.documentElement.removeAttribute("data-bc-media");
1120
+ document.documentElement.removeAttribute("data-bc-video-ready");
1121
+ return;
1122
+ }
1123
+ document.documentElement.dataset.bcActive = "true";
1124
+ document.documentElement.dataset.bcMedia = payload.media;
1125
+ if (payload.media === "video") document.documentElement.dataset.bcVideoReady = "true";
1126
+ else document.documentElement.removeAttribute("data-bc-video-ready");
1127
+ }
1128
+
1129
+ function discardCandidates() {
1130
+ const node = document.getElementById("beauticode-bg-stage");
1131
+ if (!node) return;
1132
+ node.removeAttribute("data-bc-transitioning");
1133
+ node.removeAttribute("data-bc-empty");
1134
+ for (const slot of
1135
+ node.querySelectorAll?.('.beauticode-media-slot[data-bc-role="candidate"]') ?? []) {
1136
+ disposeSlot(slot);
1137
+ }
1138
+ }
1139
+
1140
+ function attachCandidate(slot, payload) {
1141
+ const node = stage();
1142
+ discardCandidates();
1143
+ const previous = mountedCurrentSlot();
1144
+ slot.dataset.bcRole = "candidate";
1145
+ node.append(slot);
1146
+ if (!previous) {
1147
+ node.dataset.bcEmpty = "true";
1148
+ document.documentElement.dataset.bcActive = "true";
1149
+ document.documentElement.dataset.bcMedia =
1150
+ payload.media === "video" ? "video-pending" : payload.media;
1151
+ document.documentElement.removeAttribute("data-bc-video-ready");
1152
+ }
1153
+ }
1154
+
1155
+ function nextFrame(signal) {
1156
+ if (typeof requestAnimationFrame !== "function") return Promise.resolve();
1157
+ return new Promise((resolve, reject) => {
1158
+ let settled = false;
1159
+ let request = null;
1160
+ let timer = null;
1161
+ const cleanup = () => {
1162
+ if (timer) clearTimeout(timer);
1163
+ if (request != null && typeof cancelAnimationFrame === "function") {
1164
+ cancelAnimationFrame(request);
1165
+ }
1166
+ signal?.removeEventListener?.("abort", aborted);
1167
+ };
1168
+ const finish = (error = null) => {
1169
+ if (settled) return;
1170
+ settled = true;
1171
+ cleanup();
1172
+ if (error) reject(error);
1173
+ else resolve();
1174
+ };
1175
+ const aborted = () => {
1176
+ finish(abortError());
1177
+ };
1178
+ request = requestAnimationFrame(() => {
1179
+ request = null;
1180
+ if (signal?.aborted) finish(abortError());
1181
+ else finish();
1182
+ });
1183
+ // Background or power-saved Chromium pages may suspend rAF entirely.
1184
+ // A short timer keeps the transaction bounded without skipping aborts.
1185
+ timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
1186
+ signal?.addEventListener?.("abort", aborted, { once: true });
1187
+ if (signal?.aborted) aborted();
1188
+ });
1189
+ }
1190
+
1191
+ function waitForCrossfade(slot, signal) {
1192
+ if (reducedMotionMedia?.matches) return Promise.resolve();
1193
+ return new Promise((resolve, reject) => {
1194
+ let settled = false;
1195
+ const cleanup = () => {
1196
+ clearTimeout(timer);
1197
+ slot.removeEventListener("transitionend", ended);
1198
+ signal?.removeEventListener?.("abort", aborted);
1199
+ };
1200
+ const finish = (error) => {
1201
+ if (settled) return;
1202
+ settled = true;
1203
+ cleanup();
1204
+ if (error) reject(error);
1205
+ else resolve();
1206
+ };
1207
+ const ended = (event) => {
1208
+ if (event.target === slot && (!event.propertyName || event.propertyName === "opacity")) finish();
1209
+ };
1210
+ const aborted = () => finish(abortError());
1211
+ const timer = setTimeout(() => finish(), CROSSFADE_MS + 80);
1212
+ slot.addEventListener("transitionend", ended);
1213
+ signal?.addEventListener?.("abort", aborted, { once: true });
1214
+ if (signal?.aborted) aborted();
1215
+ });
1216
+ }
1217
+
1218
+ async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
1219
+ throwIfAborted(signal);
1220
+ const node = stage();
1221
+ const previous = mountedCurrentSlot();
1222
+ const canAnimate =
1223
+ previous &&
1224
+ reducedMotionMedia?.matches !== true &&
1225
+ document.visibilityState === "visible" &&
1226
+ remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
1227
+ if (canAnimate) {
1228
+ await nextFrame(signal);
1229
+ await nextFrame(signal);
1230
+ throwIfAborted(signal);
1231
+ node.dataset.bcTransitioning = "true";
1232
+ await waitForCrossfade(previous, signal);
1233
+ }
1234
+ throwIfAborted(signal);
1235
+ if (previous && previous !== slot) disposeSlot(previous);
1236
+ slot.dataset.bcRole = "current";
1237
+ node.removeAttribute("data-bc-transitioning");
1238
+ node.removeAttribute("data-bc-empty");
1239
+ currentSlot = slot;
1240
+ committedPayload = payload;
1241
+ const video = activeVideo();
1242
+ playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true";
1243
+ updateCommittedDom(payload);
1244
+ syncGallery(payload);
1245
+ }
1246
+
1247
+ function restoreCommittedDom() {
1248
+ const node = document.getElementById("beauticode-bg-stage");
1249
+ node?.removeAttribute("data-bc-transitioning");
1250
+ node?.removeAttribute("data-bc-empty");
1251
+ if (committedPayload && mountedCurrentSlot()) {
1252
+ updateCommittedDom(committedPayload);
1253
+ playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
1254
+ return;
1255
+ }
1256
+ node?.remove();
1257
+ document.documentElement.removeAttribute("data-bc-active");
1258
+ document.documentElement.removeAttribute("data-bc-media");
1259
+ document.documentElement.removeAttribute("data-bc-video-ready");
1260
+ }
1261
+
1262
+ function syncGallery(payload) {
1263
+ const on = payload?.atmosphere?.preset === "gallery";
1264
+ try {
1265
+ const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
1266
+ if (sync && typeof sync.then === "function") void sync.catch(() => {});
1267
+ } catch {
1268
+ /* Atmosphere is optional and must never invalidate a rendered background. */
1269
+ }
1270
+ }
1271
+
1272
+ async function applyBackground(payload, signal) {
1273
+ throwIfAborted(signal);
1274
+ const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
1275
+ const remaining = () => Math.max(1, applyDeadline - performance.now());
1276
+ if (payload.media === "clear") {
1277
+ desiredModes.fish = false;
1278
+ playbackBlocked = false;
1279
+ document.documentElement.removeAttribute("data-bc-fish");
1280
+ discardCandidates();
1281
+ disposeSlot(currentSlot);
1282
+ currentSlot = null;
1283
+ committedPayload = payload;
1284
+ document.getElementById("beauticode-bg-stage")?.remove();
1285
+ updateCommittedDom(payload);
1286
+ syncGallery(payload);
1287
+ if (document.documentElement.dataset.bcGallery === "true") {
1288
+ document.documentElement.dataset.bcActive = "true";
1289
+ }
1193
1290
  await acknowledgeRender(payload, true, false);
1194
1291
  await acknowledgeMode();
1195
1292
  return;
1196
1293
  }
1197
- if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
1198
- await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
1199
- return;
1200
- }
1201
- let reusable = slotMatchesPayload(currentSlot, payload);
1202
- if (reusable && payload.media === "video") {
1203
- try {
1204
- const reusableVideo = activeVideo();
1205
- const requestedStartAt = Number(payload.startAt);
1206
- const normalizedStartAt =
1207
- Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
1208
- const appliedStartAt = Number(currentSlot.dataset.bcStartAt);
1209
- if (
1210
- !Number.isFinite(appliedStartAt) ||
1211
- Math.abs(appliedStartAt - normalizedStartAt) >= 0.25
1212
- ) {
1213
- const nextStartAt = seekVideo(reusableVideo, normalizedStartAt);
1214
- currentSlot.dataset.bcStartAt = String(nextStartAt);
1215
- await waitForSeek(reusableVideo, signal, remaining());
1216
- await waitForVideo(reusableVideo, signal, remaining());
1217
- await waitForPresentedFrame(reusableVideo, signal, remaining());
1218
- } else {
1219
- await waitForStablePlayback(reusableVideo, signal, Math.min(750, remaining()));
1220
- }
1221
- } catch (error) {
1222
- if (isAbortError(error) || signal?.aborted) throw error;
1223
- reusable = false;
1224
- }
1225
- }
1226
- if (reusable) {
1227
- committedPayload = payload;
1228
- currentSlot.dataset.bcGeneration = String(payload.generation);
1229
- currentSlot.dataset.bcImageUrl = payload.imageUrl;
1230
- if (payload.videoUrl) currentSlot.dataset.bcVideoUrl = payload.videoUrl;
1231
- updateCommittedDom(payload);
1232
- syncGallery(payload);
1233
- await acknowledgeRender(payload, true, true);
1234
- await acknowledgeMode();
1235
- return;
1236
- }
1237
- let candidate = null;
1238
- let candidateVideo = null;
1239
- let committed = false;
1240
- try {
1241
- await waitForDshStructure(signal, remaining());
1242
- if (payload.media === "image") {
1243
- candidate = createSlot(payload);
1244
- attachCandidate(candidate, payload);
1245
- const image = await loadImage(candidate, payload.imageUrl, signal, remaining());
1246
- throwIfAborted(signal);
1247
- if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
1248
- await commitCandidate(payload, candidate, signal, remaining);
1249
- committed = true;
1250
- await acknowledgeRender(payload, true, true);
1251
- await acknowledgeMode();
1252
- return;
1253
- }
1254
- if (payload.media !== "video" || typeof payload.videoUrl !== "string") {
1255
- throw new Error("视频载荷无效");
1256
- }
1257
- const video = document.createElement("video");
1258
- video.autoplay = true;
1259
- video.loop = true;
1260
- video.playsInline = true;
1261
- video.preload = "auto";
1262
- video.crossOrigin = "anonymous";
1263
- // Set mute before src so Chromium may start the request immediately
1264
- // without waiting for an audible autoplay decision.
1265
- video.defaultMuted = true;
1266
- video.muted = desiredModes.muted;
1267
- video.setAttribute("muted", "");
1268
- candidate = createSlot(payload, video);
1269
- attachCandidate(candidate, payload);
1270
- candidateVideo = video;
1271
- video.src = payload.videoUrl;
1272
- video.load();
1273
- // Start the real media request before the diagnostic Range probe. During
1274
- // a cold Chromium/decoder start, serial probing used part of the same
1275
- // eight-second budget without advancing the frame that users can see.
1276
- const sourceProbe = probeVideoSource(
1277
- payload.videoUrl,
1278
- signal,
1279
- Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
1280
- );
1281
- const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
1282
- // play() is deliberately started alongside media readiness. Chromium is
1283
- // allowed to ignore preload=auto for hidden/background media; waiting for
1284
- // canplay before play() creates a circular stall at readyState=0.
1285
- await Promise.all([
1286
- sourceProbe,
1287
- imageReady,
1288
- waitForVideo(video, signal, remaining()),
1289
- startCandidatePlayback(video, signal, remaining()),
1290
- ]);
1291
- throwIfAborted(signal);
1292
- const appliedStartAt = seekVideo(video, payload.startAt);
1293
- candidate.dataset.bcStartAt = String(appliedStartAt);
1294
- await waitForSeek(video, signal, remaining());
1295
- await waitForVideo(video, signal, remaining());
1296
- // Transaction success means one frame was actually presented. Longer
1297
- // three-frame stability remains useful for reusing an existing slot, but
1298
- // must not turn a healthy cold decoder into a false first-frame failure.
1299
- await waitForPresentedFrame(video, signal, remaining());
1300
- throwIfAborted(signal);
1301
- candidate.dataset.bcVideoReady = "true";
1302
- await commitCandidate(payload, candidate, signal, remaining);
1303
- committed = true;
1304
- const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
1305
- await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
1306
- await acknowledgeMode();
1307
- } catch (error) {
1308
- if (candidateVideo && candidateVideo.parentElement !== candidate) {
1309
- disposeVideo(candidateVideo);
1310
- candidateVideo.remove?.();
1311
- }
1312
- if (candidate && !committed) disposeSlot(candidate);
1313
- if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
1314
- restoreCommittedDom();
1315
- await acknowledgeRender(
1316
- payload,
1317
- false,
1318
- Boolean(currentSlot),
1319
- error instanceof Error ? error.message : String(error),
1320
- );
1321
- }
1322
- }
1323
-
1324
- function scheduleBackground(payload) {
1325
- if (!Number.isSafeInteger(payload?.generation)) return;
1326
- // EventSource reconnects can replay the current frame while its first
1327
- // render is still in flight. Restarting the same generation would discard
1328
- // a healthy cold media request and move its deadline indefinitely.
1329
- if (
1330
- applyController &&
1331
- activePayload?.generation === payload.generation &&
1332
- activePayload?.media === payload.media
1333
- ) {
1334
- return;
1335
- }
1336
- if (
1337
- Number.isSafeInteger(activePayload?.generation) &&
1338
- payload.generation < activePayload.generation
1339
- ) {
1340
- return;
1341
- }
1342
- applyController?.abort();
1343
- discardCandidates();
1344
- const controller = new AbortController();
1345
- applyController = controller;
1346
- activePayload = payload;
1347
- renderPhase = "pending";
1348
- document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
1349
- void applyBackground(payload, controller.signal)
1350
- .catch(async (error) => {
1351
- if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return;
1352
- restoreCommittedDom();
1353
- await acknowledgeRender(
1354
- payload,
1355
- false,
1356
- Boolean(mountedCurrentSlot()),
1357
- error instanceof Error ? error.message : String(error),
1358
- );
1359
- })
1360
- .finally(() => {
1361
- if (applyController !== controller) return;
1362
- applyController = null;
1363
- document.documentElement.removeAttribute("data-bc-pending-generation");
1364
- })
1365
- .catch(() => {});
1366
- }
1294
+ if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
1295
+ await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
1296
+ return;
1297
+ }
1298
+ let reusable = slotMatchesPayload(currentSlot, payload);
1299
+ if (reusable && payload.media === "video") {
1300
+ try {
1301
+ const reusableVideo = activeVideo();
1302
+ trackVideoDiagnostics(reusableVideo);
1303
+ const requestedStartAt = Number(payload.startAt);
1304
+ const normalizedStartAt =
1305
+ Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
1306
+ const appliedStartAt = Number(currentSlot.dataset.bcStartAt);
1307
+ if (
1308
+ !Number.isFinite(appliedStartAt) ||
1309
+ Math.abs(appliedStartAt - normalizedStartAt) >= 0.25
1310
+ ) {
1311
+ const nextStartAt = seekVideo(reusableVideo, normalizedStartAt);
1312
+ currentSlot.dataset.bcStartAt = String(nextStartAt);
1313
+ await waitForSeek(reusableVideo, signal, remaining());
1314
+ await waitForVideo(reusableVideo, signal, remaining());
1315
+ await waitForPresentedFrame(reusableVideo, signal, remaining());
1316
+ } else {
1317
+ await waitForStablePlayback(reusableVideo, signal, Math.min(750, remaining()));
1318
+ }
1319
+ } catch (error) {
1320
+ if (isAbortError(error) || signal?.aborted) throw error;
1321
+ reusable = false;
1322
+ }
1323
+ }
1324
+ if (reusable) {
1325
+ committedPayload = payload;
1326
+ currentSlot.dataset.bcGeneration = String(payload.generation);
1327
+ currentSlot.dataset.bcImageUrl = payload.imageUrl;
1328
+ if (payload.videoUrl) currentSlot.dataset.bcVideoUrl = payload.videoUrl;
1329
+ updateCommittedDom(payload);
1330
+ syncGallery(payload);
1331
+ await acknowledgeRender(payload, true, true);
1332
+ await acknowledgeMode();
1333
+ return;
1334
+ }
1335
+ let candidate = null;
1336
+ let candidateVideo = null;
1337
+ let committed = false;
1338
+ try {
1339
+ await waitForDshStructure(signal, remaining());
1340
+ if (payload.media === "image") {
1341
+ candidate = createSlot(payload);
1342
+ attachCandidate(candidate, payload);
1343
+ const image = await loadImage(candidate, payload.imageUrl, signal, remaining());
1344
+ throwIfAborted(signal);
1345
+ if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
1346
+ await commitCandidate(payload, candidate, signal, remaining);
1347
+ committed = true;
1348
+ await acknowledgeRender(payload, true, true);
1349
+ await acknowledgeMode();
1350
+ return;
1351
+ }
1352
+ if (payload.media !== "video" || typeof payload.videoUrl !== "string") {
1353
+ throw new Error("视频载荷无效");
1354
+ }
1355
+ const video = document.createElement("video");
1356
+ trackVideoDiagnostics(video);
1357
+ video.autoplay = true;
1358
+ video.loop = true;
1359
+ video.playsInline = true;
1360
+ video.preload = "auto";
1361
+ video.crossOrigin = "anonymous";
1362
+ // Set mute before src so Chromium may start the request immediately
1363
+ // without waiting for an audible autoplay decision.
1364
+ video.defaultMuted = true;
1365
+ video.muted = desiredModes.muted;
1366
+ video.setAttribute("muted", "");
1367
+ candidate = createSlot(payload, video);
1368
+ attachCandidate(candidate, payload);
1369
+ candidateVideo = video;
1370
+ video.src = payload.videoUrl;
1371
+ video.load();
1372
+ // Start the real media request before the diagnostic Range probe. During
1373
+ // a cold Chromium/decoder start, serial probing used part of the same
1374
+ // eight-second budget without advancing the frame that users can see.
1375
+ const sourceProbe = probeVideoSource(
1376
+ payload.videoUrl,
1377
+ signal,
1378
+ Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
1379
+ );
1380
+ const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
1381
+ // play() is deliberately started alongside media readiness. Chromium is
1382
+ // allowed to ignore preload=auto for hidden/background media; waiting for
1383
+ // canplay before play() creates a circular stall at readyState=0.
1384
+ await Promise.all([
1385
+ sourceProbe,
1386
+ imageReady,
1387
+ waitForVideo(video, signal, remaining()),
1388
+ startCandidatePlayback(video, signal, remaining()),
1389
+ ]);
1390
+ throwIfAborted(signal);
1391
+ const appliedStartAt = seekVideo(video, payload.startAt);
1392
+ candidate.dataset.bcStartAt = String(appliedStartAt);
1393
+ await waitForSeek(video, signal, remaining());
1394
+ await waitForVideo(video, signal, remaining());
1395
+ // Transaction success means one frame was actually presented. Longer
1396
+ // three-frame stability remains useful for reusing an existing slot, but
1397
+ // must not turn a healthy cold decoder into a false first-frame failure.
1398
+ await waitForPresentedFrame(video, signal, remaining());
1399
+ throwIfAborted(signal);
1400
+ candidate.dataset.bcVideoReady = "true";
1401
+ await commitCandidate(payload, candidate, signal, remaining);
1402
+ committed = true;
1403
+ const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
1404
+ await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
1405
+ await acknowledgeMode();
1406
+ } catch (error) {
1407
+ if (candidateVideo && candidateVideo.parentElement !== candidate) {
1408
+ disposeVideo(candidateVideo);
1409
+ candidateVideo.remove?.();
1410
+ }
1411
+ if (candidate && !committed) disposeSlot(candidate);
1412
+ if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
1413
+ restoreCommittedDom();
1414
+ await acknowledgeRender(
1415
+ payload,
1416
+ false,
1417
+ Boolean(currentSlot),
1418
+ error instanceof Error ? error.message : String(error),
1419
+ );
1420
+ }
1421
+ }
1422
+
1423
+ function scheduleBackground(payload) {
1424
+ if (!Number.isSafeInteger(payload?.generation)) return;
1425
+ // EventSource reconnects can replay the current frame while its first
1426
+ // render is still in flight. Restarting the same generation would discard
1427
+ // a healthy cold media request and move its deadline indefinitely.
1428
+ if (
1429
+ applyController &&
1430
+ activePayload?.generation === payload.generation &&
1431
+ activePayload?.media === payload.media
1432
+ ) {
1433
+ return;
1434
+ }
1435
+ if (
1436
+ Number.isSafeInteger(activePayload?.generation) &&
1437
+ payload.generation < activePayload.generation
1438
+ ) {
1439
+ return;
1440
+ }
1441
+ applyController?.abort();
1442
+ discardCandidates();
1443
+ const controller = new AbortController();
1444
+ applyController = controller;
1445
+ activePayload = payload;
1446
+ renderPhase = "pending";
1447
+ document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
1448
+ void applyBackground(payload, controller.signal)
1449
+ .catch(async (error) => {
1450
+ if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return;
1451
+ restoreCommittedDom();
1452
+ await acknowledgeRender(
1453
+ payload,
1454
+ false,
1455
+ Boolean(mountedCurrentSlot()),
1456
+ error instanceof Error ? error.message : String(error),
1457
+ );
1458
+ })
1459
+ .finally(() => {
1460
+ if (applyController !== controller) return;
1461
+ applyController = null;
1462
+ document.documentElement.removeAttribute("data-bc-pending-generation");
1463
+ })
1464
+ .catch(() => {});
1465
+ }
1367
1466
 
1368
1467
  async function applyModes(payload) {
1369
1468
  if (typeof payload.fish === "boolean") desiredModes.fish = payload.fish;
@@ -1373,24 +1472,24 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1373
1472
  syncDshTheme();
1374
1473
  if (desiredModes.fish && document.documentElement.dataset.bcActive === "true") {
1375
1474
  document.documentElement.dataset.bcFish = "true";
1376
- } else {
1377
- document.documentElement.removeAttribute("data-bc-fish");
1378
- }
1379
- const video = activeVideo();
1380
- const candidate = pendingVideo();
1381
- if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
1382
- if (video instanceof HTMLVideoElement) {
1383
- try {
1384
- playbackBlocked = await playWithPreference(video);
1475
+ } else {
1476
+ document.documentElement.removeAttribute("data-bc-fish");
1477
+ }
1478
+ const video = activeVideo();
1479
+ const candidate = pendingVideo();
1480
+ if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
1481
+ if (video instanceof HTMLVideoElement) {
1482
+ try {
1483
+ playbackBlocked = await playWithPreference(video);
1385
1484
  } catch {
1386
1485
  playbackBlocked = desiredModes.muted === false;
1387
1486
  }
1388
- if (
1389
- committedPayload?.media === "video" &&
1390
- activePayload === committedPayload &&
1391
- renderPhase === "ready"
1392
- ) {
1393
- await acknowledgeRender(committedPayload, true, true);
1487
+ if (
1488
+ committedPayload?.media === "video" &&
1489
+ activePayload === committedPayload &&
1490
+ renderPhase === "ready"
1491
+ ) {
1492
+ await acknowledgeRender(committedPayload, true, true);
1394
1493
  }
1395
1494
  } else {
1396
1495
  playbackBlocked = false;
@@ -1403,49 +1502,49 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1403
1502
  );
1404
1503
  events.onmessage = (event) => {
1405
1504
  try {
1406
- const payload = JSON.parse(event.data);
1407
- if (payload?.type === "mode") void applyModes(payload).catch(() => {});
1408
- else if (payload?.type === "apply") scheduleBackground(payload);
1505
+ const payload = JSON.parse(event.data);
1506
+ if (payload?.type === "mode") void applyModes(payload).catch(() => {});
1507
+ else if (payload?.type === "apply") scheduleBackground(payload);
1409
1508
  } catch {
1410
1509
  /* EventSource will continue with the next valid frame. */
1411
1510
  }
1412
1511
  };
1413
1512
 
1414
- setInterval(() => {
1415
- if (
1416
- !committedPayload ||
1417
- activePayload !== committedPayload ||
1418
- renderPhase !== "ready"
1419
- ) {
1420
- return;
1421
- }
1422
- if (committedPayload.media === "clear") {
1423
- void acknowledgeRender(committedPayload, true, false).catch(() => {});
1424
- return;
1425
- }
1426
- const slot = mountedCurrentSlot();
1427
- if (!slot) return;
1428
- if (committedPayload.media === "image") {
1429
- const image = slot.querySelector?.("img");
1430
- if (
1431
- image?.isConnected === true &&
1432
- image.complete &&
1433
- image.naturalWidth > 0 &&
1434
- image.naturalHeight > 0
1435
- ) {
1436
- void acknowledgeRender(committedPayload, true, true).catch(() => {});
1437
- }
1438
- return;
1439
- }
1440
- const video = activeVideo();
1441
- if (
1442
- committedPayload.media === "video" &&
1443
- video instanceof HTMLVideoElement
1444
- ) {
1513
+ setInterval(() => {
1514
+ if (
1515
+ !committedPayload ||
1516
+ activePayload !== committedPayload ||
1517
+ renderPhase !== "ready"
1518
+ ) {
1519
+ return;
1520
+ }
1521
+ if (committedPayload.media === "clear") {
1522
+ void acknowledgeRender(committedPayload, true, false).catch(() => {});
1523
+ return;
1524
+ }
1525
+ const slot = mountedCurrentSlot();
1526
+ if (!slot) return;
1527
+ if (committedPayload.media === "image") {
1528
+ const image = slot.querySelector?.("img");
1529
+ if (
1530
+ image?.isConnected === true &&
1531
+ image.complete &&
1532
+ image.naturalWidth > 0 &&
1533
+ image.naturalHeight > 0
1534
+ ) {
1535
+ void acknowledgeRender(committedPayload, true, true).catch(() => {});
1536
+ }
1537
+ return;
1538
+ }
1539
+ const video = activeVideo();
1540
+ if (
1541
+ committedPayload.media === "video" &&
1542
+ video instanceof HTMLVideoElement
1543
+ ) {
1445
1544
  // A heartbeat is observational, not a second render verdict. Playback can
1446
1545
  // briefly pause while Chromium changes modes or refills an 8K buffer; do
1447
1546
  // not downgrade an already-rendered generation or fail a pending one.
1448
- void acknowledgeRender(committedPayload, true, true).catch(() => {});
1547
+ void acknowledgeRender(committedPayload, true, true).catch(() => {});
1449
1548
  }
1450
1549
  }, 1_000);
1451
1550
  })();