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