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 +1312 -1103
- package/package.json +1 -1
- package/vendor/core/apply-transaction.js +24 -2
- package/vendor/core/background-store.js +5 -1
- package/vendor/core/media-validation.js +33 -0
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
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
let
|
|
30
|
-
let
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
try {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
video
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
slot
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
if (
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
};
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
)
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
video
|
|
1174
|
-
video.
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
);
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
) {
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
})();
|