beauticode-dsh 1.0.19 → 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,33 +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_STABLE_FRAMES = 3;
23
- const VIDEO_STABLE_PROGRESS_SEC = 0.18;
24
- const CROSSFADE_MS = 180;
25
- let activePayload = null;
26
- let committedPayload = null;
27
- let renderPhase = "idle";
28
- let playbackBlocked = false;
29
- let currentSlot = null;
30
- let applyController = null;
31
- const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
32
- const reducedMotionMedia =
33
- 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;
34
38
  let themeSyncQueued = false;
35
39
 
36
40
  const style = document.createElement("style");
@@ -68,31 +72,85 @@ html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase
68
72
  --dsw-specific-sidebar-fill:rgba(255,255,255,.78);
69
73
  }
70
74
  #beauticode-bg-stage{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:#11141b}
71
- #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}
72
76
  html[data-bc-resolved-tone="light"] #beauticode-bg-stage{background:#f8fafc}
73
77
  html[data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
74
78
  html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(0,0,0,.42)}
75
79
  html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
76
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)}
77
81
  html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important}
78
- #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}
79
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
80
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
81
- #beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
82
- #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
83
- #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
84
- #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}
85
- #beauticode-bg-stage .beauticode-media-slot img{z-index:0;opacity:1}
86
- #beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:.001}
87
- #beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0}
88
- #beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1}
89
- @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}}
90
97
  html[data-bc-active="true"] #root{position:relative;z-index:1;background:transparent!important}
91
98
  html[data-bc-active="true"] [class*="_fade"]{display:none!important}
92
99
  html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
93
100
  `;
94
101
  document.head.append(style);
95
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
+
96
154
  function dshAppearance() {
97
155
  const body = document.body;
98
156
  if (body?.hasAttribute("data-ds-dark-theme")) return "dark";
@@ -135,12 +193,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
135
193
  attributeFilter: ["data-ds-dark-theme"],
136
194
  });
137
195
  }
138
- systemDarkMedia?.addEventListener("change", () => {
139
- if (desiredModes.tone === "auto") {
140
- syncDshTheme();
141
- void acknowledgeMode().catch(() => {});
142
- }
143
- });
196
+ systemDarkMedia?.addEventListener("change", () => {
197
+ if (desiredModes.tone === "auto") {
198
+ syncDshTheme();
199
+ void acknowledgeMode().catch(() => {});
200
+ }
201
+ });
144
202
  syncDshTheme();
145
203
 
146
204
  function stage() {
@@ -158,67 +216,67 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
158
216
  * If the shell no longer exposes it, report a clear error through the
159
217
  * render ack instead of silently painting a broken page.
160
218
  */
161
- function dshStructureIssue() {
162
- if (!document.getElementById("root")) {
163
- return "DSH 页面结构不兼容:未找到 #root。";
164
- }
165
- return null;
166
- }
167
-
168
- function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) {
169
- if (!dshStructureIssue()) return Promise.resolve();
170
- return new Promise((resolve, reject) => {
171
- let settled = false;
172
- const startedAt = performance.now();
173
- const finish = (error = null) => {
174
- if (settled) return;
175
- settled = true;
176
- clearInterval(timer);
177
- signal?.removeEventListener?.("abort", aborted);
178
- if (error) reject(error);
179
- else resolve();
180
- };
181
- const aborted = () => finish(abortError());
182
- const timer = setInterval(() => {
183
- const issue = dshStructureIssue();
184
- if (!issue) {
185
- finish();
186
- } else if (performance.now() - startedAt >= timeoutMs) {
187
- finish(new Error(issue));
188
- }
189
- }, 50);
190
- signal?.addEventListener?.("abort", aborted, { once: true });
191
- if (signal?.aborted) aborted();
192
- });
193
- }
194
-
195
- function mountedCurrentSlot() {
196
- const node = document.getElementById("beauticode-bg-stage");
197
- if (
198
- !currentSlot ||
199
- currentSlot.isConnected !== true ||
200
- currentSlot.parentElement !== node ||
201
- currentSlot.dataset.bcRole !== "current"
202
- ) {
203
- currentSlot = null;
204
- committedPayload = null;
205
- return null;
206
- }
207
- return currentSlot;
208
- }
209
-
210
- function activeVideo() {
211
- const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
212
- return video instanceof HTMLVideoElement ? video : null;
213
- }
214
-
215
- function pendingVideo() {
216
- const node = document.getElementById("beauticode-bg-stage");
217
- const video = node?.querySelector?.(
218
- '.beauticode-media-slot[data-bc-role="candidate"] video',
219
- );
220
- return video instanceof HTMLVideoElement ? video : null;
221
- }
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
+ }
222
280
 
223
281
  async function postAck(body) {
224
282
  await fetch("/__beauticode/ack", {
@@ -251,10 +309,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
251
309
  ok,
252
310
  visible,
253
311
  error,
254
- playback:
255
- ok && committedPayload === payload && payload.media === "video"
256
- ? playbackSnapshot(activeVideo())
257
- : null,
312
+ playback:
313
+ ok && committedPayload === payload && payload.media === "video"
314
+ ? playbackSnapshot(activeVideo())
315
+ : null,
258
316
  });
259
317
  }
260
318
 
@@ -272,290 +330,333 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
272
330
  });
273
331
  }
274
332
 
275
- function abortError() {
276
- try {
277
- return new DOMException("Background apply superseded", "AbortError");
278
- } catch {
279
- const error = new Error("Background apply superseded");
280
- error.name = "AbortError";
281
- return error;
282
- }
283
- }
284
-
285
- function isAbortError(error) {
286
- return error?.name === "AbortError";
287
- }
288
-
289
- function throwIfAborted(signal) {
290
- if (signal?.aborted) throw abortError();
291
- }
292
-
293
- function releaseImage(image, { remove = false } = {}) {
294
- if (!image) return;
295
- image.onload = null;
296
- image.onerror = null;
297
- try {
298
- image.removeAttribute?.("src");
299
- } catch {
300
- image.src = "";
301
- }
302
- if (remove) image.remove?.();
303
- }
304
-
305
- function imageAttemptUrl(url, attempt) {
306
- if (attempt === 0) return url;
307
- try {
308
- const retryUrl = new URL(url, globalThis.location?.href);
309
- retryUrl.searchParams.set(
310
- "bcImageRetry",
311
- `${clientId}-${attempt}-${Date.now().toString(36)}`,
312
- );
313
- return retryUrl.href;
314
- } catch {
315
- return url;
316
- }
317
- }
318
-
319
- function waitForImageAttempt(image, url, signal, timeoutMs) {
320
- return new Promise((resolve, reject) => {
321
- let settled = false;
322
- let decodeStarted = false;
323
- let decodeFallback = null;
324
- const cleanup = () => {
325
- clearTimeout(timer);
326
- if (decodeFallback) clearTimeout(decodeFallback);
327
- signal?.removeEventListener?.("abort", aborted);
328
- image.onload = null;
329
- image.onerror = null;
330
- };
331
- const finish = (error = null) => {
332
- if (settled) return;
333
- settled = true;
334
- cleanup();
335
- if (error) reject(error);
336
- else resolve();
337
- };
338
- const acceptLoaded = () => {
339
- if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
340
- if (image.complete) finish(new Error("图片尺寸无效"));
341
- return;
342
- }
343
- if (!decodeStarted && typeof image.decode === "function") {
344
- decodeStarted = true;
345
- // decode() is a second readiness signal, but the per-attempt timer
346
- // remains authoritative. A loaded image is still accepted after a
347
- // short decode grace period if Chromium leaves decode() pending.
348
- decodeFallback = setTimeout(() => {
349
- if (
350
- image.isConnected === true &&
351
- image.complete &&
352
- image.naturalWidth > 0 &&
353
- image.naturalHeight > 0
354
- ) {
355
- finish();
356
- } else {
357
- finish(new Error("图片解码未完成"));
358
- }
359
- }, 750);
360
- let decodePromise;
361
- try {
362
- decodePromise = image.decode();
363
- } catch {
364
- finish(new Error("图片解码失败"));
365
- return;
366
- }
367
- Promise.resolve(decodePromise).then(
368
- () => finish(),
369
- () => finish(new Error("图片解码失败")),
370
- );
371
- return;
372
- }
373
- finish();
374
- };
375
- const failed = () => finish(new Error("图片请求失败"));
376
- const aborted = () => finish(abortError());
377
- const timer = setTimeout(
378
- () => finish(new Error("等待图片请求或解码超时")),
379
- timeoutMs,
380
- );
381
- image.onload = acceptLoaded;
382
- image.onerror = failed;
383
- signal?.addEventListener?.("abort", aborted, { once: true });
384
- if (signal?.aborted) {
385
- aborted();
386
- return;
387
- }
388
- image.src = url;
389
- if (image.complete) queueMicrotask(acceptLoaded);
390
- });
391
- }
392
-
393
- function waitForRetryWindow(signal, timeoutMs) {
394
- return new Promise((resolve, reject) => {
395
- let settled = false;
396
- const finish = (error = null) => {
397
- if (settled) return;
398
- settled = true;
399
- clearTimeout(timer);
400
- signal?.removeEventListener?.("abort", aborted);
401
- if (error) reject(error);
402
- else resolve();
403
- };
404
- const aborted = () => finish(abortError());
405
- const timer = setTimeout(() => finish(), timeoutMs);
406
- signal?.addEventListener?.("abort", aborted, { once: true });
407
- if (signal?.aborted) aborted();
408
- });
409
- }
410
-
411
- async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
412
- const deadline = performance.now() + timeoutMs;
413
- const attempts = [];
414
- let lastError = null;
415
-
416
- const startAttempt = (attempt) => {
417
- throwIfAborted(signal);
418
- const remainingMs = deadline - performance.now();
419
- if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
420
- const controller = new AbortController();
421
- const parentAborted = () => controller.abort();
422
- signal?.addEventListener?.("abort", parentAborted, { once: true });
423
- const image = new Image();
424
- image.alt = "";
425
- image.decoding = "async";
426
- image.className = "beauticode-media-poster";
427
- slot.prepend(image);
428
- const promise = waitForImageAttempt(
429
- image,
430
- imageAttemptUrl(url, attempt),
431
- controller.signal,
432
- Math.max(1, remainingMs),
433
- )
434
- .then(() => image)
435
- .finally(() => signal?.removeEventListener?.("abort", parentAborted));
436
- const record = { controller, image, promise };
437
- attempts.push(record);
438
- return record;
439
- };
440
-
441
- const cleanupAttempts = (winner = null) => {
442
- for (const attempt of attempts) {
443
- if (attempt.image === winner) continue;
444
- attempt.controller.abort();
445
- releaseImage(attempt.image, { remove: true });
446
- }
447
- };
448
-
449
- try {
450
- const first = startAttempt(0);
451
- const firstWindow = await Promise.race([
452
- first.promise.then(
453
- (image) => ({ image }),
454
- (error) => ({ error }),
455
- ),
456
- waitForRetryWindow(
457
- signal,
458
- Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
459
- ).then(() => ({ retry: true })),
460
- ]);
461
- if (firstWindow.image) {
462
- cleanupAttempts(firstWindow.image);
463
- return firstWindow.image;
464
- }
465
- if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
466
- lastError = firstWindow.error ?? lastError;
467
-
468
- // Keep the original request alive. The cache-busted retry runs beside it
469
- // so a merely slow cold decode is never destroyed to recover a hung one.
470
- if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
471
- let winner;
472
- try {
473
- winner = await Promise.any(attempts.map((attempt) => attempt.promise));
474
- } catch (error) {
475
- const errors = Array.isArray(error?.errors) ? error.errors : [];
476
- lastError = errors.at(-1) ?? lastError ?? error;
477
- throw lastError;
478
- }
479
- cleanupAttempts(winner);
480
- return winner;
481
- } catch (error) {
482
- cleanupAttempts();
483
- if (isAbortError(error) || signal?.aborted) throw abortError();
484
- lastError = error ?? lastError;
485
- const visibility = document.visibilityState || "unknown";
486
- const online = globalThis.navigator?.onLine === false ? "offline" : "online";
487
- const phase = lastError?.message || "unknown";
488
- const summary =
489
- phase === "图片请求失败"
490
- ? "图片加载失败"
491
- : phase.includes("超时")
492
- ? "等待图片加载超时"
493
- : "图片校验失败";
494
- throw new Error(
495
- `${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
496
- );
497
- }
498
- }
499
-
500
- function safeOrigin(value) {
501
- try {
502
- return new URL(value, globalThis.location?.href).origin;
503
- } catch {
504
- return "unknown";
505
- }
506
- }
507
-
508
- function videoRequestContext(url) {
509
- return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`;
510
- }
511
-
512
- async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) {
513
- throwIfAborted(signal);
514
- const controller = new AbortController();
515
- const parentAborted = () => controller.abort();
516
- signal?.addEventListener?.("abort", parentAborted, { once: true });
517
- let timedOut = false;
518
- const timer = setTimeout(() => {
519
- timedOut = true;
520
- controller.abort();
521
- }, Math.max(1, timeoutMs));
522
- try {
523
- const response = await fetch(url, {
524
- method: "GET",
525
- headers: { Range: "bytes=0-1" },
526
- cache: "no-store",
527
- credentials: "omit",
528
- mode: "cors",
529
- signal: controller.signal,
530
- });
531
- if (response.status !== 206) {
532
- if (response.body) {
533
- await response.body.cancel().catch(() => {});
534
- }
535
- throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`);
536
- }
537
- const bytes = await response.arrayBuffer();
538
- if (bytes.byteLength !== 2) {
539
- throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`);
540
- }
541
- } catch (error) {
542
- if (signal?.aborted) throw abortError();
543
- const context = videoRequestContext(url);
544
- if (timedOut || isAbortError(error)) {
545
- throw new Error(`视频媒体 Range 探针超时;${context}`);
546
- }
547
- const detail = error instanceof Error ? error.message : String(error);
548
- if (error instanceof TypeError) {
549
- throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`);
550
- }
551
- throw new Error(`${detail};${context}`);
552
- } finally {
553
- clearTimeout(timer);
554
- signal?.removeEventListener?.("abort", parentAborted);
555
- }
556
- }
557
-
558
- 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) {
559
660
  const mediaErrorNames = {
560
661
  1: "MEDIA_ERR_ABORTED",
561
662
  2: "MEDIA_ERR_NETWORK",
@@ -564,112 +665,61 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
564
665
  };
565
666
  const code = Number(video?.error?.code) || 0;
566
667
  const mediaError = code ? mediaErrorNames[code] || `MEDIA_ERR_${code}` : "none";
567
- const src = video?.currentSrc || video?.src || "";
568
- 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
+ });
569
712
  }
570
713
 
571
- function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
572
- const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
573
- if (video.readyState >= frameReadyState) return Promise.resolve();
574
- return new Promise((resolve, reject) => {
575
- let settled = false;
576
- const done = () => finish();
577
- const check = () => {
578
- if (video.readyState >= frameReadyState) done();
579
- };
580
- const failed = () => {
581
- finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
582
- };
583
- const aborted = () => {
584
- finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
585
- };
586
- const timer = setTimeout(() => {
587
- finish(new Error(describeVideoState(video, "等待视频首帧超时")));
588
- }, timeoutMs);
589
- const cleanup = () => {
590
- clearTimeout(timer);
591
- video.removeEventListener("loadeddata", check);
592
- video.removeEventListener("canplay", done);
593
- video.removeEventListener("error", failed);
594
- video.removeEventListener("abort", aborted);
595
- signal?.removeEventListener?.("abort", aborted);
596
- };
597
- const finish = (error) => {
598
- if (settled) return;
599
- settled = true;
600
- cleanup();
601
- if (error) reject(error);
602
- else resolve();
603
- };
604
- video.addEventListener("loadeddata", check);
605
- video.addEventListener("canplay", done, { once: true });
606
- video.addEventListener("error", failed, { once: true });
607
- video.addEventListener("abort", aborted, { once: true });
608
- signal?.addEventListener?.("abort", aborted, { once: true });
609
- if (signal?.aborted) aborted();
610
- });
611
- }
612
-
613
- function waitForSeek(video, signal, timeoutMs) {
614
- if (!video.seeking) return Promise.resolve();
615
- return new Promise((resolve, reject) => {
616
- let settled = false;
617
- const cleanup = () => {
618
- clearTimeout(timer);
619
- video.removeEventListener("seeked", done);
620
- video.removeEventListener("error", failed);
621
- signal?.removeEventListener?.("abort", aborted);
622
- };
623
- const finish = (error) => {
624
- if (settled) return;
625
- settled = true;
626
- cleanup();
627
- if (error) reject(error);
628
- else resolve();
629
- };
630
- const done = () => finish();
631
- const failed = () => finish(new Error(describeVideoState(video, "视频跳转失败")));
632
- const aborted = () => finish(abortError());
633
- const timer = setTimeout(
634
- () => finish(new Error(describeVideoState(video, "等待视频跳转超时"))),
635
- timeoutMs,
636
- );
637
- video.addEventListener("seeked", done, { once: true });
638
- video.addEventListener("error", failed, { once: true });
639
- signal?.addEventListener?.("abort", aborted, { once: true });
640
- if (signal?.aborted) aborted();
641
- });
642
- }
643
-
644
- function waitForStablePlayback(video, signal, timeoutMs) {
645
- return new Promise((resolve, reject) => {
646
- const startedAt = performance.now();
647
- let lastTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
648
- let accumulatedProgress = 0;
649
- let advancingSamples = 0;
650
- let stableFrames = 0;
651
- let lastFrameTime = null;
652
- let playingSeen =
653
- !video.paused &&
654
- 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("waiting", resetStableWindow);
664
- video.removeEventListener("stalled", resetStableWindow);
665
- video.removeEventListener("pause", resetStableWindow);
666
- video.removeEventListener("seeking", resetStableWindow);
667
- video.removeEventListener("error", failed);
668
- video.removeEventListener("abort", aborted);
669
- signal?.removeEventListener?.("abort", aborted);
670
- if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
671
- video.cancelVideoFrameCallback(frameRequest);
672
- }
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);
673
723
  };
674
724
  const finish = (error) => {
675
725
  if (settled) return;
@@ -678,57 +728,191 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
678
728
  if (error) reject(error);
679
729
  else resolve();
680
730
  };
681
- const failed = () => {
682
- finish(new Error(describeVideoState(video, "视频解码器报告失败")));
683
- };
684
- const aborted = () => {
685
- finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
686
- };
687
- const observeProgress = () => {
688
- const current = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
689
- let delta = current - lastTime;
690
- if (
691
- delta < 0 &&
692
- Number.isFinite(video.duration) &&
693
- video.duration > 0 &&
694
- lastTime > video.duration - 1 &&
695
- current < 1
696
- ) {
697
- delta = video.duration - lastTime + current;
698
- }
699
- if (delta > 0.003 && delta < 2) {
700
- accumulatedProgress += delta;
701
- advancingSamples += 1;
702
- }
703
- lastTime = current;
704
- };
705
- const resetStableWindow = () => {
706
- playingSeen = false;
707
- stableFrames = 0;
708
- advancingSamples = 0;
709
- accumulatedProgress = 0;
710
- lastTime = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
711
- };
712
- const check = () => {
713
- if (settled) return;
714
- if (video.error) {
715
- failed();
716
- return;
717
- }
718
- observeProgress();
719
- if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
720
- playingSeen = true;
721
- }
722
- const framesReady = hasFrameCallback
723
- ? stableFrames >= VIDEO_STABLE_FRAMES
724
- : advancingSamples >= VIDEO_STABLE_FRAMES;
725
- if (
726
- video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
727
- !video.paused &&
728
- playingSeen &&
729
- framesReady &&
730
- accumulatedProgress >= VIDEO_STABLE_PROGRESS_SEC
731
- ) {
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
+ ) {
732
916
  finish();
733
917
  return;
734
918
  }
@@ -736,39 +920,39 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
736
920
  finish(new Error(describeVideoState(video, "视频未在稳定窗口内输出首帧")));
737
921
  }
738
922
  };
739
- const onPlaying = () => {
740
- playingSeen = true;
741
- check();
742
- };
743
- const onFrame = (_now, metadata) => {
744
- if (settled) return;
745
- const mediaTime = Number.isFinite(metadata?.mediaTime)
746
- ? metadata.mediaTime
747
- : video.currentTime;
748
- if (lastFrameTime == null || mediaTime > lastFrameTime + 0.001) {
749
- stableFrames += 1;
750
- } else {
751
- stableFrames = 0;
752
- }
753
- lastFrameTime = mediaTime;
754
- check();
755
- if (!settled) frameRequest = video.requestVideoFrameCallback(onFrame);
756
- };
757
- const timer = setInterval(check, 80);
758
- video.addEventListener("playing", onPlaying);
759
- video.addEventListener("timeupdate", check);
760
- video.addEventListener("waiting", resetStableWindow);
761
- video.addEventListener("stalled", resetStableWindow);
762
- video.addEventListener("pause", resetStableWindow);
763
- video.addEventListener("seeking", resetStableWindow);
764
- video.addEventListener("error", failed, { once: true });
765
- video.addEventListener("abort", aborted, { once: true });
766
- signal?.addEventListener?.("abort", aborted, { once: true });
767
- if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
768
- if (signal?.aborted) aborted();
769
- check();
770
- });
771
- }
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
+ }
772
956
 
773
957
  function seekVideo(video, value) {
774
958
  const requested = Number(value);
@@ -779,481 +963,506 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
779
963
  (!Number.isFinite(duration) || duration <= 0 || requested < duration)
780
964
  ? requested
781
965
  : 0;
782
- try {
783
- 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();
784
1034
  } catch {
785
- video.currentTime = 0;
786
- }
787
- return safe;
788
- }
789
-
790
- async function playWithPreference(video, signal = null) {
791
- throwIfAborted(signal);
792
- const requestedMuted = desiredModes.muted;
793
- let blocked = false;
794
- video.muted = requestedMuted;
795
- try {
796
- await video.play();
797
- } catch (error) {
798
- throwIfAborted(signal);
799
- if (requestedMuted) throw error;
800
- blocked = true;
801
- video.muted = true;
802
- await video.play();
803
- }
804
- throwIfAborted(signal);
805
- video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
806
- return blocked;
807
- }
808
-
809
- async function startCandidatePlayback(video, signal, timeoutMs) {
810
- const playbackController = new AbortController();
811
- const parentAborted = () => playbackController.abort();
812
- signal?.addEventListener?.("abort", parentAborted, { once: true });
813
- let timer = null;
814
- let abortListener = null;
815
- const playback = playWithPreference(video, playbackController.signal);
816
- const deadline = new Promise((_, reject) => {
817
- timer = setTimeout(() => {
818
- reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
819
- playbackController.abort();
820
- }, timeoutMs);
821
- abortListener = () => reject(abortError());
822
- playbackController.signal.addEventListener("abort", abortListener, { once: true });
823
- });
824
- if (signal?.aborted) parentAborted();
825
- try {
826
- // The play() promise itself resolves only after playback has started, so
827
- // a second playing-event waiter would create an orphaned promise race.
828
- return await Promise.race([playback, deadline]);
829
- } catch (error) {
830
- playbackController.abort();
831
- try {
832
- video.pause();
833
- } catch {
834
- /* best effort: disposeSlot will release src as the final guard */
835
- }
836
- throw error;
837
- } finally {
838
- if (timer) clearTimeout(timer);
839
- signal?.removeEventListener?.("abort", parentAborted);
840
- if (abortListener) {
841
- playbackController.signal.removeEventListener("abort", abortListener);
842
- }
843
- }
844
- }
845
-
846
- function disposeVideo(video) {
847
- if (!(video instanceof HTMLVideoElement)) return;
848
- try {
849
- video.pause();
850
- } catch {
851
- /* best effort */
852
- }
853
- const objectUrl = video.dataset?.bcObjectUrl;
854
- if (
855
- objectUrl &&
856
- typeof URL !== "undefined" &&
857
- typeof URL.revokeObjectURL === "function"
858
- ) {
859
- URL.revokeObjectURL(objectUrl);
860
- }
861
- try {
862
- video.removeAttribute("src");
863
- video.srcObject = null;
864
- video.load();
865
- } catch {
866
- /* detached or already released */
867
- }
868
- }
869
-
870
- function disposeSlot(slot) {
871
- if (!slot) return;
872
- for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
873
- for (const image of slot.querySelectorAll?.("img") ?? []) {
874
- releaseImage(image);
875
- }
876
- slot.remove?.();
877
- }
878
-
879
- function createSlot(payload, video = null) {
880
- const slot = document.createElement("div");
881
- slot.className = "beauticode-media-slot";
882
- slot.dataset.bcRole = "candidate";
883
- slot.dataset.bcMedia = payload.media;
884
- slot.dataset.bcGeneration = String(payload.generation);
885
- slot.dataset.bcImageUrl = payload.imageUrl;
886
- if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
887
- if (payload.media === "video") {
888
- const startAt = Number(payload.startAt);
889
- slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
890
- }
891
- if (video) {
892
- video.className = "beauticode-media-video";
893
- slot.append(video);
894
- }
895
- return slot;
896
- }
897
-
898
- function slotMatchesPayload(slot, payload) {
899
- if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
900
- return false;
901
- }
902
- if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
903
- if (payload.media !== "video") {
904
- const image = slot.querySelector?.("img");
905
- return Boolean(
906
- image?.isConnected === true &&
907
- image.complete &&
908
- image.naturalWidth > 0 &&
909
- image.naturalHeight > 0,
910
- );
911
- }
912
- if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
913
- const video = slot.querySelector?.("video");
914
- if (
915
- !(video instanceof HTMLVideoElement) ||
916
- video.error ||
917
- video.ended ||
918
- video.paused ||
919
- video.seeking ||
920
- video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
921
- ) {
922
- return false;
923
- }
924
- const requestedStartAt = Number(payload.startAt);
925
- const normalizedStartAt =
926
- Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
927
- const appliedStartAt = Number(slot.dataset.bcStartAt);
928
- return Number.isFinite(appliedStartAt) && Math.abs(appliedStartAt - normalizedStartAt) < 0.25;
929
- }
930
-
931
- function updateCommittedDom(payload) {
932
- document.documentElement.dataset.bcGeneration = String(payload.generation);
933
- document.documentElement.removeAttribute("data-bc-pending-generation");
934
- if (payload.media === "clear") {
935
- document.documentElement.removeAttribute("data-bc-active");
936
- document.documentElement.removeAttribute("data-bc-media");
937
- document.documentElement.removeAttribute("data-bc-video-ready");
938
- return;
939
- }
940
- document.documentElement.dataset.bcActive = "true";
941
- document.documentElement.dataset.bcMedia = payload.media;
942
- if (payload.media === "video") document.documentElement.dataset.bcVideoReady = "true";
943
- else document.documentElement.removeAttribute("data-bc-video-ready");
944
- }
945
-
946
- function discardCandidates() {
947
- const node = document.getElementById("beauticode-bg-stage");
948
- if (!node) return;
949
- node.removeAttribute("data-bc-transitioning");
950
- node.removeAttribute("data-bc-empty");
951
- for (const slot of
952
- node.querySelectorAll?.('.beauticode-media-slot[data-bc-role="candidate"]') ?? []) {
953
- disposeSlot(slot);
954
- }
955
- }
956
-
957
- function attachCandidate(slot, payload) {
958
- const node = stage();
959
- discardCandidates();
960
- const previous = mountedCurrentSlot();
961
- slot.dataset.bcRole = "candidate";
962
- node.append(slot);
963
- if (!previous) {
964
- node.dataset.bcEmpty = "true";
965
- document.documentElement.dataset.bcActive = "true";
966
- document.documentElement.dataset.bcMedia =
967
- payload.media === "video" ? "video-pending" : payload.media;
968
- document.documentElement.removeAttribute("data-bc-video-ready");
969
- }
970
- }
971
-
972
- function nextFrame(signal) {
973
- if (typeof requestAnimationFrame !== "function") return Promise.resolve();
974
- return new Promise((resolve, reject) => {
975
- let settled = false;
976
- let request = null;
977
- let timer = null;
978
- const cleanup = () => {
979
- if (timer) clearTimeout(timer);
980
- if (request != null && typeof cancelAnimationFrame === "function") {
981
- cancelAnimationFrame(request);
982
- }
983
- signal?.removeEventListener?.("abort", aborted);
984
- };
985
- const finish = (error = null) => {
986
- if (settled) return;
987
- settled = true;
988
- cleanup();
989
- if (error) reject(error);
990
- else resolve();
991
- };
992
- const aborted = () => {
993
- finish(abortError());
994
- };
995
- request = requestAnimationFrame(() => {
996
- request = null;
997
- if (signal?.aborted) finish(abortError());
998
- else finish();
999
- });
1000
- // Background or power-saved Chromium pages may suspend rAF entirely.
1001
- // A short timer keeps the transaction bounded without skipping aborts.
1002
- timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
1003
- signal?.addEventListener?.("abort", aborted, { once: true });
1004
- if (signal?.aborted) aborted();
1005
- });
1006
- }
1007
-
1008
- function waitForCrossfade(slot, signal) {
1009
- if (reducedMotionMedia?.matches) return Promise.resolve();
1010
- return new Promise((resolve, reject) => {
1011
- let settled = false;
1012
- const cleanup = () => {
1013
- clearTimeout(timer);
1014
- slot.removeEventListener("transitionend", ended);
1015
- signal?.removeEventListener?.("abort", aborted);
1016
- };
1017
- const finish = (error) => {
1018
- if (settled) return;
1019
- settled = true;
1020
- cleanup();
1021
- if (error) reject(error);
1022
- else resolve();
1023
- };
1024
- const ended = (event) => {
1025
- if (event.target === slot && (!event.propertyName || event.propertyName === "opacity")) finish();
1026
- };
1027
- const aborted = () => finish(abortError());
1028
- const timer = setTimeout(() => finish(), CROSSFADE_MS + 80);
1029
- slot.addEventListener("transitionend", ended);
1030
- signal?.addEventListener?.("abort", aborted, { once: true });
1031
- if (signal?.aborted) aborted();
1032
- });
1033
- }
1034
-
1035
- async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
1036
- throwIfAborted(signal);
1037
- const node = stage();
1038
- const previous = mountedCurrentSlot();
1039
- const canAnimate =
1040
- previous &&
1041
- reducedMotionMedia?.matches !== true &&
1042
- document.visibilityState === "visible" &&
1043
- remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
1044
- if (canAnimate) {
1045
- await nextFrame(signal);
1046
- await nextFrame(signal);
1047
- throwIfAborted(signal);
1048
- node.dataset.bcTransitioning = "true";
1049
- await waitForCrossfade(previous, signal);
1050
- }
1051
- throwIfAborted(signal);
1052
- if (previous && previous !== slot) disposeSlot(previous);
1053
- slot.dataset.bcRole = "current";
1054
- node.removeAttribute("data-bc-transitioning");
1055
- node.removeAttribute("data-bc-empty");
1056
- currentSlot = slot;
1057
- committedPayload = payload;
1058
- const video = activeVideo();
1059
- playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true";
1060
- updateCommittedDom(payload);
1061
- syncGallery(payload);
1062
- }
1063
-
1064
- function restoreCommittedDom() {
1065
- const node = document.getElementById("beauticode-bg-stage");
1066
- node?.removeAttribute("data-bc-transitioning");
1067
- node?.removeAttribute("data-bc-empty");
1068
- if (committedPayload && mountedCurrentSlot()) {
1069
- updateCommittedDom(committedPayload);
1070
- playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
1071
- return;
1072
- }
1073
- node?.remove();
1074
- document.documentElement.removeAttribute("data-bc-active");
1075
- document.documentElement.removeAttribute("data-bc-media");
1076
- document.documentElement.removeAttribute("data-bc-video-ready");
1077
- }
1078
-
1079
- function syncGallery(payload) {
1080
- const on = payload?.atmosphere?.preset === "gallery";
1081
- try {
1082
- const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
1083
- if (sync && typeof sync.then === "function") void sync.catch(() => {});
1084
- } catch {
1085
- /* Atmosphere is optional and must never invalidate a rendered background. */
1086
- }
1087
- }
1088
-
1089
- async function applyBackground(payload, signal) {
1090
- throwIfAborted(signal);
1091
- const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
1092
- const remaining = () => Math.max(1, applyDeadline - performance.now());
1093
- if (payload.media === "clear") {
1094
- desiredModes.fish = false;
1095
- playbackBlocked = false;
1096
- document.documentElement.removeAttribute("data-bc-fish");
1097
- discardCandidates();
1098
- disposeSlot(currentSlot);
1099
- currentSlot = null;
1100
- committedPayload = payload;
1101
- document.getElementById("beauticode-bg-stage")?.remove();
1102
- updateCommittedDom(payload);
1103
- syncGallery(payload);
1104
- if (document.documentElement.dataset.bcGallery === "true") {
1105
- document.documentElement.dataset.bcActive = "true";
1106
- }
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
+ }
1107
1290
  await acknowledgeRender(payload, true, false);
1108
1291
  await acknowledgeMode();
1109
1292
  return;
1110
1293
  }
1111
- if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
1112
- await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
1113
- return;
1114
- }
1115
- let reusable = slotMatchesPayload(currentSlot, payload);
1116
- if (reusable && payload.media === "video") {
1117
- try {
1118
- await waitForStablePlayback(activeVideo(), signal, Math.min(750, remaining()));
1119
- } catch (error) {
1120
- if (isAbortError(error) || signal?.aborted) throw error;
1121
- reusable = false;
1122
- }
1123
- }
1124
- if (reusable) {
1125
- committedPayload = payload;
1126
- currentSlot.dataset.bcGeneration = String(payload.generation);
1127
- updateCommittedDom(payload);
1128
- syncGallery(payload);
1129
- await acknowledgeRender(payload, true, true);
1130
- await acknowledgeMode();
1131
- return;
1132
- }
1133
- let candidate = null;
1134
- let candidateVideo = null;
1135
- let committed = false;
1136
- try {
1137
- await waitForDshStructure(signal, remaining());
1138
- if (payload.media === "image") {
1139
- candidate = createSlot(payload);
1140
- attachCandidate(candidate, payload);
1141
- const image = await loadImage(candidate, payload.imageUrl, signal, remaining());
1142
- throwIfAborted(signal);
1143
- if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
1144
- await commitCandidate(payload, candidate, signal, remaining);
1145
- committed = true;
1146
- await acknowledgeRender(payload, true, true);
1147
- await acknowledgeMode();
1148
- return;
1149
- }
1150
- if (payload.media !== "video" || typeof payload.videoUrl !== "string") {
1151
- throw new Error("视频载荷无效");
1152
- }
1153
- await probeVideoSource(
1154
- payload.videoUrl,
1155
- signal,
1156
- Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
1157
- );
1158
- throwIfAborted(signal);
1159
- const video = document.createElement("video");
1160
- video.autoplay = true;
1161
- video.loop = true;
1162
- video.playsInline = true;
1163
- video.preload = "auto";
1164
- video.crossOrigin = "anonymous";
1165
- // Set mute before src so Chromium may start the request immediately
1166
- // without waiting for an audible autoplay decision.
1167
- video.defaultMuted = true;
1168
- video.muted = desiredModes.muted;
1169
- video.setAttribute("muted", "");
1170
- candidate = createSlot(payload, video);
1171
- attachCandidate(candidate, payload);
1172
- candidateVideo = video;
1173
- video.src = payload.videoUrl;
1174
- video.load();
1175
- const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
1176
- // play() is deliberately started alongside media readiness. Chromium is
1177
- // allowed to ignore preload=auto for hidden/background media; waiting for
1178
- // canplay before play() creates a circular stall at readyState=0.
1179
- await Promise.all([
1180
- imageReady,
1181
- waitForVideo(video, signal, remaining()),
1182
- startCandidatePlayback(video, signal, remaining()),
1183
- ]);
1184
- throwIfAborted(signal);
1185
- const appliedStartAt = seekVideo(video, payload.startAt);
1186
- candidate.dataset.bcStartAt = String(appliedStartAt);
1187
- await waitForSeek(video, signal, remaining());
1188
- await waitForVideo(video, signal, remaining());
1189
- await waitForStablePlayback(video, signal, remaining());
1190
- throwIfAborted(signal);
1191
- candidate.dataset.bcVideoReady = "true";
1192
- await commitCandidate(payload, candidate, signal, remaining);
1193
- committed = true;
1194
- const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
1195
- await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
1196
- await acknowledgeMode();
1197
- } catch (error) {
1198
- if (candidateVideo && candidateVideo.parentElement !== candidate) {
1199
- disposeVideo(candidateVideo);
1200
- candidateVideo.remove?.();
1201
- }
1202
- if (candidate && !committed) disposeSlot(candidate);
1203
- if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
1204
- restoreCommittedDom();
1205
- await acknowledgeRender(
1206
- payload,
1207
- false,
1208
- Boolean(currentSlot),
1209
- error instanceof Error ? error.message : String(error),
1210
- );
1211
- }
1212
- }
1213
-
1214
- function scheduleBackground(payload) {
1215
- if (!Number.isSafeInteger(payload?.generation)) return;
1216
- // EventSource reconnects can replay the current frame while its first
1217
- // render is still in flight. Restarting the same generation would discard
1218
- // a healthy cold media request and move its deadline indefinitely.
1219
- if (
1220
- applyController &&
1221
- activePayload?.generation === payload.generation &&
1222
- activePayload?.media === payload.media
1223
- ) {
1224
- return;
1225
- }
1226
- if (
1227
- Number.isSafeInteger(activePayload?.generation) &&
1228
- payload.generation < activePayload.generation
1229
- ) {
1230
- return;
1231
- }
1232
- applyController?.abort();
1233
- discardCandidates();
1234
- const controller = new AbortController();
1235
- applyController = controller;
1236
- activePayload = payload;
1237
- renderPhase = "pending";
1238
- document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
1239
- void applyBackground(payload, controller.signal)
1240
- .catch(async (error) => {
1241
- if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return;
1242
- restoreCommittedDom();
1243
- await acknowledgeRender(
1244
- payload,
1245
- false,
1246
- Boolean(mountedCurrentSlot()),
1247
- error instanceof Error ? error.message : String(error),
1248
- );
1249
- })
1250
- .finally(() => {
1251
- if (applyController !== controller) return;
1252
- applyController = null;
1253
- document.documentElement.removeAttribute("data-bc-pending-generation");
1254
- })
1255
- .catch(() => {});
1256
- }
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
+ }
1257
1466
 
1258
1467
  async function applyModes(payload) {
1259
1468
  if (typeof payload.fish === "boolean") desiredModes.fish = payload.fish;
@@ -1263,24 +1472,24 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1263
1472
  syncDshTheme();
1264
1473
  if (desiredModes.fish && document.documentElement.dataset.bcActive === "true") {
1265
1474
  document.documentElement.dataset.bcFish = "true";
1266
- } else {
1267
- document.documentElement.removeAttribute("data-bc-fish");
1268
- }
1269
- const video = activeVideo();
1270
- const candidate = pendingVideo();
1271
- if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
1272
- if (video instanceof HTMLVideoElement) {
1273
- try {
1274
- 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);
1275
1484
  } catch {
1276
1485
  playbackBlocked = desiredModes.muted === false;
1277
1486
  }
1278
- if (
1279
- committedPayload?.media === "video" &&
1280
- activePayload === committedPayload &&
1281
- renderPhase === "ready"
1282
- ) {
1283
- await acknowledgeRender(committedPayload, true, true);
1487
+ if (
1488
+ committedPayload?.media === "video" &&
1489
+ activePayload === committedPayload &&
1490
+ renderPhase === "ready"
1491
+ ) {
1492
+ await acknowledgeRender(committedPayload, true, true);
1284
1493
  }
1285
1494
  } else {
1286
1495
  playbackBlocked = false;
@@ -1293,49 +1502,49 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1293
1502
  );
1294
1503
  events.onmessage = (event) => {
1295
1504
  try {
1296
- const payload = JSON.parse(event.data);
1297
- if (payload?.type === "mode") void applyModes(payload).catch(() => {});
1298
- 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);
1299
1508
  } catch {
1300
1509
  /* EventSource will continue with the next valid frame. */
1301
1510
  }
1302
1511
  };
1303
1512
 
1304
- setInterval(() => {
1305
- if (
1306
- !committedPayload ||
1307
- activePayload !== committedPayload ||
1308
- renderPhase !== "ready"
1309
- ) {
1310
- return;
1311
- }
1312
- if (committedPayload.media === "clear") {
1313
- void acknowledgeRender(committedPayload, true, false).catch(() => {});
1314
- return;
1315
- }
1316
- const slot = mountedCurrentSlot();
1317
- if (!slot) return;
1318
- if (committedPayload.media === "image") {
1319
- const image = slot.querySelector?.("img");
1320
- if (
1321
- image?.isConnected === true &&
1322
- image.complete &&
1323
- image.naturalWidth > 0 &&
1324
- image.naturalHeight > 0
1325
- ) {
1326
- void acknowledgeRender(committedPayload, true, true).catch(() => {});
1327
- }
1328
- return;
1329
- }
1330
- const video = activeVideo();
1331
- if (
1332
- committedPayload.media === "video" &&
1333
- video instanceof HTMLVideoElement
1334
- ) {
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
+ ) {
1335
1544
  // A heartbeat is observational, not a second render verdict. Playback can
1336
1545
  // briefly pause while Chromium changes modes or refills an 8K buffer; do
1337
1546
  // not downgrade an already-rendered generation or fail a pending one.
1338
- void acknowledgeRender(committedPayload, true, true).catch(() => {});
1547
+ void acknowledgeRender(committedPayload, true, true).catch(() => {});
1339
1548
  }
1340
1549
  }, 1_000);
1341
1550
  })();