beauticode-dsh 1.0.15 → 1.0.18
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/LICENSE +21 -0
- package/atmosphere.js +2 -2
- package/client.js +498 -150
- package/host-apply.mjs +3 -2
- package/index.mjs +41 -41
- package/package.json +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 beautiCode contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/atmosphere.js
CHANGED
|
@@ -92,8 +92,8 @@ html[data-bc-resolved-tone="light"][data-bc-gallery="true"] body{
|
|
|
92
92
|
}
|
|
93
93
|
html[data-bc-gallery="true"] #root{position:relative;z-index:1;background:transparent!important}
|
|
94
94
|
html[data-bc-gallery="true"] [class*="_fade"]{display:none!important}
|
|
95
|
-
html[data-bc-gallery="true"] #beauticode-bg-stage
|
|
96
|
-
html[data-bc-gallery="true"] #beauticode-bg-stage
|
|
95
|
+
html[data-bc-gallery="true"] #beauticode-bg-stage img,
|
|
96
|
+
html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important}
|
|
97
97
|
html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
|
|
98
98
|
`;
|
|
99
99
|
return style;
|
package/client.js
CHANGED
|
@@ -8,8 +8,16 @@
|
|
|
8
8
|
globalThis.crypto?.randomUUID?.() ||
|
|
9
9
|
`bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
10
10
|
const desiredModes = { fish: false, muted: true, tone: "auto" };
|
|
11
|
-
|
|
12
|
-
|
|
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 DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
20
|
+
const FRAME_FALLBACK_MS = 120;
|
|
13
21
|
const VIDEO_STABLE_FRAMES = 3;
|
|
14
22
|
const VIDEO_STABLE_PROGRESS_SEC = 0.18;
|
|
15
23
|
const CROSSFADE_MS = 180;
|
|
@@ -66,10 +74,10 @@ html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-st
|
|
|
66
74
|
html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
|
|
67
75
|
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)}
|
|
68
76
|
html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important}
|
|
69
|
-
#beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:
|
|
70
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:
|
|
71
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:
|
|
72
|
-
#beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{
|
|
77
|
+
#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}
|
|
78
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
|
|
79
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
|
|
80
|
+
#beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
|
|
73
81
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
|
|
74
82
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
|
|
75
83
|
#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}
|
|
@@ -126,12 +134,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
126
134
|
attributeFilter: ["data-ds-dark-theme"],
|
|
127
135
|
});
|
|
128
136
|
}
|
|
129
|
-
systemDarkMedia?.addEventListener("change", () => {
|
|
130
|
-
if (desiredModes.tone === "auto") {
|
|
131
|
-
syncDshTheme();
|
|
132
|
-
void acknowledgeMode();
|
|
133
|
-
}
|
|
134
|
-
});
|
|
137
|
+
systemDarkMedia?.addEventListener("change", () => {
|
|
138
|
+
if (desiredModes.tone === "auto") {
|
|
139
|
+
syncDshTheme();
|
|
140
|
+
void acknowledgeMode().catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
135
143
|
syncDshTheme();
|
|
136
144
|
|
|
137
145
|
function stage() {
|
|
@@ -149,15 +157,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
149
157
|
* If the shell no longer exposes it, report a clear error through the
|
|
150
158
|
* render ack instead of silently painting a broken page.
|
|
151
159
|
*/
|
|
152
|
-
function dshStructureIssue() {
|
|
153
|
-
if (!document.getElementById("root")) {
|
|
154
|
-
return "DSH 页面结构不兼容:未找到 #root。";
|
|
155
|
-
}
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
160
|
+
function dshStructureIssue() {
|
|
161
|
+
if (!document.getElementById("root")) {
|
|
162
|
+
return "DSH 页面结构不兼容:未找到 #root。";
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) {
|
|
168
|
+
if (!dshStructureIssue()) return Promise.resolve();
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
let settled = false;
|
|
171
|
+
const startedAt = performance.now();
|
|
172
|
+
const finish = (error = null) => {
|
|
173
|
+
if (settled) return;
|
|
174
|
+
settled = true;
|
|
175
|
+
clearInterval(timer);
|
|
176
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
177
|
+
if (error) reject(error);
|
|
178
|
+
else resolve();
|
|
179
|
+
};
|
|
180
|
+
const aborted = () => finish(abortError());
|
|
181
|
+
const timer = setInterval(() => {
|
|
182
|
+
const issue = dshStructureIssue();
|
|
183
|
+
if (!issue) {
|
|
184
|
+
finish();
|
|
185
|
+
} else if (performance.now() - startedAt >= timeoutMs) {
|
|
186
|
+
finish(new Error(issue));
|
|
187
|
+
}
|
|
188
|
+
}, 50);
|
|
189
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
190
|
+
if (signal?.aborted) aborted();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
158
193
|
|
|
194
|
+
function mountedCurrentSlot() {
|
|
195
|
+
const node = document.getElementById("beauticode-bg-stage");
|
|
196
|
+
if (
|
|
197
|
+
!currentSlot ||
|
|
198
|
+
currentSlot.isConnected !== true ||
|
|
199
|
+
currentSlot.parentElement !== node ||
|
|
200
|
+
currentSlot.dataset.bcRole !== "current"
|
|
201
|
+
) {
|
|
202
|
+
currentSlot = null;
|
|
203
|
+
committedPayload = null;
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return currentSlot;
|
|
207
|
+
}
|
|
208
|
+
|
|
159
209
|
function activeVideo() {
|
|
160
|
-
const video =
|
|
210
|
+
const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
|
|
161
211
|
return video instanceof HTMLVideoElement ? video : null;
|
|
162
212
|
}
|
|
163
213
|
|
|
@@ -167,7 +217,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
167
217
|
'.beauticode-media-slot[data-bc-role="candidate"] video',
|
|
168
218
|
);
|
|
169
219
|
return video instanceof HTMLVideoElement ? video : null;
|
|
170
|
-
}
|
|
220
|
+
}
|
|
171
221
|
|
|
172
222
|
async function postAck(body) {
|
|
173
223
|
await fetch("/__beauticode/ack", {
|
|
@@ -239,45 +289,212 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
239
289
|
if (signal?.aborted) throw abortError();
|
|
240
290
|
}
|
|
241
291
|
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
image.
|
|
245
|
-
image.
|
|
292
|
+
function releaseImage(image, { remove = false } = {}) {
|
|
293
|
+
if (!image) return;
|
|
294
|
+
image.onload = null;
|
|
295
|
+
image.onerror = null;
|
|
296
|
+
try {
|
|
297
|
+
image.removeAttribute?.("src");
|
|
298
|
+
} catch {
|
|
299
|
+
image.src = "";
|
|
300
|
+
}
|
|
301
|
+
if (remove) image.remove?.();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function imageAttemptUrl(url, attempt) {
|
|
305
|
+
if (attempt === 0) return url;
|
|
306
|
+
try {
|
|
307
|
+
const retryUrl = new URL(url, globalThis.location?.href);
|
|
308
|
+
retryUrl.searchParams.set(
|
|
309
|
+
"bcImageRetry",
|
|
310
|
+
`${clientId}-${attempt}-${Date.now().toString(36)}`,
|
|
311
|
+
);
|
|
312
|
+
return retryUrl.href;
|
|
313
|
+
} catch {
|
|
314
|
+
return url;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function waitForImageAttempt(image, url, signal, timeoutMs) {
|
|
246
319
|
return new Promise((resolve, reject) => {
|
|
247
320
|
let settled = false;
|
|
248
|
-
let
|
|
321
|
+
let decodeStarted = false;
|
|
322
|
+
let decodeFallback = null;
|
|
249
323
|
const cleanup = () => {
|
|
250
|
-
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
if (decodeFallback) clearTimeout(decodeFallback);
|
|
251
326
|
signal?.removeEventListener?.("abort", aborted);
|
|
252
327
|
image.onload = null;
|
|
253
328
|
image.onerror = null;
|
|
254
329
|
};
|
|
255
|
-
const finish = (error) => {
|
|
330
|
+
const finish = (error = null) => {
|
|
256
331
|
if (settled) return;
|
|
257
332
|
settled = true;
|
|
258
333
|
cleanup();
|
|
259
334
|
if (error) reject(error);
|
|
260
|
-
else resolve(
|
|
335
|
+
else resolve();
|
|
261
336
|
};
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
image.
|
|
265
|
-
|
|
266
|
-
image.src = "";
|
|
337
|
+
const acceptLoaded = () => {
|
|
338
|
+
if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
|
|
339
|
+
if (image.complete) finish(new Error("图片尺寸无效"));
|
|
340
|
+
return;
|
|
267
341
|
}
|
|
268
|
-
|
|
342
|
+
if (!decodeStarted && typeof image.decode === "function") {
|
|
343
|
+
decodeStarted = true;
|
|
344
|
+
// decode() is a second readiness signal, but the per-attempt timer
|
|
345
|
+
// remains authoritative. A loaded image is still accepted after a
|
|
346
|
+
// short decode grace period if Chromium leaves decode() pending.
|
|
347
|
+
decodeFallback = setTimeout(() => {
|
|
348
|
+
if (
|
|
349
|
+
image.isConnected === true &&
|
|
350
|
+
image.complete &&
|
|
351
|
+
image.naturalWidth > 0 &&
|
|
352
|
+
image.naturalHeight > 0
|
|
353
|
+
) {
|
|
354
|
+
finish();
|
|
355
|
+
} else {
|
|
356
|
+
finish(new Error("图片解码未完成"));
|
|
357
|
+
}
|
|
358
|
+
}, 750);
|
|
359
|
+
let decodePromise;
|
|
360
|
+
try {
|
|
361
|
+
decodePromise = image.decode();
|
|
362
|
+
} catch {
|
|
363
|
+
finish(new Error("图片解码失败"));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
Promise.resolve(decodePromise).then(
|
|
367
|
+
() => finish(),
|
|
368
|
+
() => finish(new Error("图片解码失败")),
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
finish();
|
|
269
373
|
};
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
timer = setTimeout(
|
|
374
|
+
const failed = () => finish(new Error("图片请求失败"));
|
|
375
|
+
const aborted = () => finish(abortError());
|
|
376
|
+
const timer = setTimeout(
|
|
377
|
+
() => finish(new Error("等待图片请求或解码超时")),
|
|
378
|
+
timeoutMs,
|
|
379
|
+
);
|
|
380
|
+
image.onload = acceptLoaded;
|
|
381
|
+
image.onerror = failed;
|
|
273
382
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
274
383
|
if (signal?.aborted) {
|
|
275
384
|
aborted();
|
|
276
385
|
return;
|
|
277
386
|
}
|
|
278
387
|
image.src = url;
|
|
388
|
+
if (image.complete) queueMicrotask(acceptLoaded);
|
|
279
389
|
});
|
|
280
390
|
}
|
|
391
|
+
|
|
392
|
+
function waitForRetryWindow(signal, timeoutMs) {
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
let settled = false;
|
|
395
|
+
const finish = (error = null) => {
|
|
396
|
+
if (settled) return;
|
|
397
|
+
settled = true;
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
400
|
+
if (error) reject(error);
|
|
401
|
+
else resolve();
|
|
402
|
+
};
|
|
403
|
+
const aborted = () => finish(abortError());
|
|
404
|
+
const timer = setTimeout(() => finish(), timeoutMs);
|
|
405
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
406
|
+
if (signal?.aborted) aborted();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
|
|
411
|
+
const deadline = performance.now() + timeoutMs;
|
|
412
|
+
const attempts = [];
|
|
413
|
+
let lastError = null;
|
|
414
|
+
|
|
415
|
+
const startAttempt = (attempt) => {
|
|
416
|
+
throwIfAborted(signal);
|
|
417
|
+
const remainingMs = deadline - performance.now();
|
|
418
|
+
if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
|
|
419
|
+
const controller = new AbortController();
|
|
420
|
+
const parentAborted = () => controller.abort();
|
|
421
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
422
|
+
const image = new Image();
|
|
423
|
+
image.alt = "";
|
|
424
|
+
image.decoding = "async";
|
|
425
|
+
image.className = "beauticode-media-poster";
|
|
426
|
+
slot.prepend(image);
|
|
427
|
+
const promise = waitForImageAttempt(
|
|
428
|
+
image,
|
|
429
|
+
imageAttemptUrl(url, attempt),
|
|
430
|
+
controller.signal,
|
|
431
|
+
Math.max(1, remainingMs),
|
|
432
|
+
)
|
|
433
|
+
.then(() => image)
|
|
434
|
+
.finally(() => signal?.removeEventListener?.("abort", parentAborted));
|
|
435
|
+
const record = { controller, image, promise };
|
|
436
|
+
attempts.push(record);
|
|
437
|
+
return record;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const cleanupAttempts = (winner = null) => {
|
|
441
|
+
for (const attempt of attempts) {
|
|
442
|
+
if (attempt.image === winner) continue;
|
|
443
|
+
attempt.controller.abort();
|
|
444
|
+
releaseImage(attempt.image, { remove: true });
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const first = startAttempt(0);
|
|
450
|
+
const firstWindow = await Promise.race([
|
|
451
|
+
first.promise.then(
|
|
452
|
+
(image) => ({ image }),
|
|
453
|
+
(error) => ({ error }),
|
|
454
|
+
),
|
|
455
|
+
waitForRetryWindow(
|
|
456
|
+
signal,
|
|
457
|
+
Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
|
|
458
|
+
).then(() => ({ retry: true })),
|
|
459
|
+
]);
|
|
460
|
+
if (firstWindow.image) {
|
|
461
|
+
cleanupAttempts(firstWindow.image);
|
|
462
|
+
return firstWindow.image;
|
|
463
|
+
}
|
|
464
|
+
if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
|
|
465
|
+
lastError = firstWindow.error ?? lastError;
|
|
466
|
+
|
|
467
|
+
// Keep the original request alive. The cache-busted retry runs beside it
|
|
468
|
+
// so a merely slow cold decode is never destroyed to recover a hung one.
|
|
469
|
+
if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
|
|
470
|
+
let winner;
|
|
471
|
+
try {
|
|
472
|
+
winner = await Promise.any(attempts.map((attempt) => attempt.promise));
|
|
473
|
+
} catch (error) {
|
|
474
|
+
const errors = Array.isArray(error?.errors) ? error.errors : [];
|
|
475
|
+
lastError = errors.at(-1) ?? lastError ?? error;
|
|
476
|
+
throw lastError;
|
|
477
|
+
}
|
|
478
|
+
cleanupAttempts(winner);
|
|
479
|
+
return winner;
|
|
480
|
+
} catch (error) {
|
|
481
|
+
cleanupAttempts();
|
|
482
|
+
if (isAbortError(error) || signal?.aborted) throw abortError();
|
|
483
|
+
lastError = error ?? lastError;
|
|
484
|
+
const visibility = document.visibilityState || "unknown";
|
|
485
|
+
const online = globalThis.navigator?.onLine === false ? "offline" : "online";
|
|
486
|
+
const phase = lastError?.message || "unknown";
|
|
487
|
+
const summary =
|
|
488
|
+
phase === "图片请求失败"
|
|
489
|
+
? "图片加载失败"
|
|
490
|
+
: phase.includes("超时")
|
|
491
|
+
? "等待图片加载超时"
|
|
492
|
+
: "图片校验失败";
|
|
493
|
+
throw new Error(
|
|
494
|
+
`${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
281
498
|
|
|
282
499
|
function describeVideoState(video, phase) {
|
|
283
500
|
const mediaErrorNames = {
|
|
@@ -292,13 +509,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
292
509
|
}
|
|
293
510
|
|
|
294
511
|
function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
|
|
295
|
-
const
|
|
296
|
-
if (video.readyState >=
|
|
512
|
+
const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
|
|
513
|
+
if (video.readyState >= frameReadyState) return Promise.resolve();
|
|
297
514
|
return new Promise((resolve, reject) => {
|
|
298
515
|
let settled = false;
|
|
299
516
|
const done = () => finish();
|
|
300
517
|
const check = () => {
|
|
301
|
-
if (video.readyState >=
|
|
518
|
+
if (video.readyState >= frameReadyState) done();
|
|
302
519
|
};
|
|
303
520
|
const failed = () => {
|
|
304
521
|
finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
|
|
@@ -307,7 +524,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
307
524
|
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
|
|
308
525
|
};
|
|
309
526
|
const timer = setTimeout(() => {
|
|
310
|
-
finish(new Error(describeVideoState(video, "
|
|
527
|
+
finish(new Error(describeVideoState(video, "等待视频首帧超时")));
|
|
311
528
|
}, timeoutMs);
|
|
312
529
|
const cleanup = () => {
|
|
313
530
|
clearTimeout(timer);
|
|
@@ -374,7 +591,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
374
591
|
let lastFrameTime = null;
|
|
375
592
|
let playingSeen =
|
|
376
593
|
!video.paused &&
|
|
377
|
-
video.readyState >=
|
|
594
|
+
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
378
595
|
let settled = false;
|
|
379
596
|
let frameRequest = null;
|
|
380
597
|
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
@@ -385,6 +602,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
385
602
|
video.removeEventListener("timeupdate", check);
|
|
386
603
|
video.removeEventListener("waiting", resetStableWindow);
|
|
387
604
|
video.removeEventListener("stalled", resetStableWindow);
|
|
605
|
+
video.removeEventListener("pause", resetStableWindow);
|
|
606
|
+
video.removeEventListener("seeking", resetStableWindow);
|
|
388
607
|
video.removeEventListener("error", failed);
|
|
389
608
|
video.removeEventListener("abort", aborted);
|
|
390
609
|
signal?.removeEventListener?.("abort", aborted);
|
|
@@ -437,7 +656,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
437
656
|
return;
|
|
438
657
|
}
|
|
439
658
|
observeProgress();
|
|
440
|
-
if (!video.paused && video.readyState >=
|
|
659
|
+
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
441
660
|
playingSeen = true;
|
|
442
661
|
}
|
|
443
662
|
const framesReady = hasFrameCallback
|
|
@@ -480,6 +699,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
480
699
|
video.addEventListener("timeupdate", check);
|
|
481
700
|
video.addEventListener("waiting", resetStableWindow);
|
|
482
701
|
video.addEventListener("stalled", resetStableWindow);
|
|
702
|
+
video.addEventListener("pause", resetStableWindow);
|
|
703
|
+
video.addEventListener("seeking", resetStableWindow);
|
|
483
704
|
video.addEventListener("error", failed, { once: true });
|
|
484
705
|
video.addEventListener("abort", aborted, { once: true });
|
|
485
706
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
@@ -508,62 +729,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
508
729
|
|
|
509
730
|
async function playWithPreference(video, signal = null) {
|
|
510
731
|
throwIfAborted(signal);
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
732
|
+
const requestedMuted = desiredModes.muted;
|
|
733
|
+
let blocked = false;
|
|
734
|
+
video.muted = requestedMuted;
|
|
735
|
+
try {
|
|
736
|
+
await video.play();
|
|
737
|
+
} catch (error) {
|
|
738
|
+
throwIfAborted(signal);
|
|
739
|
+
if (requestedMuted) throw error;
|
|
740
|
+
blocked = true;
|
|
518
741
|
video.muted = true;
|
|
519
742
|
await video.play();
|
|
520
743
|
}
|
|
521
744
|
throwIfAborted(signal);
|
|
522
|
-
video.dataset.bcPlaybackBlocked =
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
function createPlayingWaiter(video, signal, timeoutMs) {
|
|
526
|
-
let cancel = () => {};
|
|
527
|
-
const promise = new Promise((resolve, reject) => {
|
|
528
|
-
let settled = false;
|
|
529
|
-
const cleanup = () => {
|
|
530
|
-
clearTimeout(timer);
|
|
531
|
-
video.removeEventListener("playing", played);
|
|
532
|
-
video.removeEventListener("error", failed);
|
|
533
|
-
video.removeEventListener("abort", aborted);
|
|
534
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
535
|
-
};
|
|
536
|
-
const finish = (error) => {
|
|
537
|
-
if (settled) return;
|
|
538
|
-
settled = true;
|
|
539
|
-
cleanup();
|
|
540
|
-
if (error) reject(error);
|
|
541
|
-
else resolve();
|
|
542
|
-
};
|
|
543
|
-
const played = () => finish();
|
|
544
|
-
const failed = () => finish(new Error(describeVideoState(video, "视频开始播放失败")));
|
|
545
|
-
const aborted = () => finish(abortError());
|
|
546
|
-
const timer = setTimeout(
|
|
547
|
-
() => finish(new Error(describeVideoState(video, "等待 playing 事件超时"))),
|
|
548
|
-
timeoutMs,
|
|
549
|
-
);
|
|
550
|
-
cancel = (error = abortError()) => finish(error);
|
|
551
|
-
video.addEventListener("playing", played, { once: true });
|
|
552
|
-
video.addEventListener("error", failed, { once: true });
|
|
553
|
-
video.addEventListener("abort", aborted, { once: true });
|
|
554
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
555
|
-
if (signal?.aborted) aborted();
|
|
556
|
-
});
|
|
557
|
-
return { promise, cancel };
|
|
745
|
+
video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
|
|
746
|
+
return blocked;
|
|
558
747
|
}
|
|
559
748
|
|
|
560
749
|
async function startCandidatePlayback(video, signal, timeoutMs) {
|
|
561
|
-
const
|
|
750
|
+
const playbackController = new AbortController();
|
|
751
|
+
const parentAborted = () => playbackController.abort();
|
|
752
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
753
|
+
let timer = null;
|
|
754
|
+
let abortListener = null;
|
|
755
|
+
const playback = playWithPreference(video, playbackController.signal);
|
|
756
|
+
const deadline = new Promise((_, reject) => {
|
|
757
|
+
timer = setTimeout(() => {
|
|
758
|
+
reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
|
|
759
|
+
playbackController.abort();
|
|
760
|
+
}, timeoutMs);
|
|
761
|
+
abortListener = () => reject(abortError());
|
|
762
|
+
playbackController.signal.addEventListener("abort", abortListener, { once: true });
|
|
763
|
+
});
|
|
764
|
+
if (signal?.aborted) parentAborted();
|
|
562
765
|
try {
|
|
563
|
-
|
|
766
|
+
// The play() promise itself resolves only after playback has started, so
|
|
767
|
+
// a second playing-event waiter would create an orphaned promise race.
|
|
768
|
+
return await Promise.race([playback, deadline]);
|
|
564
769
|
} catch (error) {
|
|
565
|
-
|
|
770
|
+
playbackController.abort();
|
|
771
|
+
try {
|
|
772
|
+
video.pause();
|
|
773
|
+
} catch {
|
|
774
|
+
/* best effort: disposeSlot will release src as the final guard */
|
|
775
|
+
}
|
|
566
776
|
throw error;
|
|
777
|
+
} finally {
|
|
778
|
+
if (timer) clearTimeout(timer);
|
|
779
|
+
signal?.removeEventListener?.("abort", parentAborted);
|
|
780
|
+
if (abortListener) {
|
|
781
|
+
playbackController.signal.removeEventListener("abort", abortListener);
|
|
782
|
+
}
|
|
567
783
|
}
|
|
568
784
|
}
|
|
569
785
|
|
|
@@ -595,14 +811,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
595
811
|
if (!slot) return;
|
|
596
812
|
for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
|
|
597
813
|
for (const image of slot.querySelectorAll?.("img") ?? []) {
|
|
598
|
-
image
|
|
599
|
-
image.onerror = null;
|
|
600
|
-
image.removeAttribute?.("src");
|
|
814
|
+
releaseImage(image);
|
|
601
815
|
}
|
|
602
816
|
slot.remove?.();
|
|
603
817
|
}
|
|
604
818
|
|
|
605
|
-
function createSlot(payload,
|
|
819
|
+
function createSlot(payload, video = null) {
|
|
606
820
|
const slot = document.createElement("div");
|
|
607
821
|
slot.className = "beauticode-media-slot";
|
|
608
822
|
slot.dataset.bcRole = "candidate";
|
|
@@ -610,8 +824,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
610
824
|
slot.dataset.bcGeneration = String(payload.generation);
|
|
611
825
|
slot.dataset.bcImageUrl = payload.imageUrl;
|
|
612
826
|
if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
|
|
613
|
-
|
|
614
|
-
|
|
827
|
+
if (payload.media === "video") {
|
|
828
|
+
const startAt = Number(payload.startAt);
|
|
829
|
+
slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
|
|
830
|
+
}
|
|
615
831
|
if (video) {
|
|
616
832
|
video.className = "beauticode-media-video";
|
|
617
833
|
slot.append(video);
|
|
@@ -620,9 +836,36 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
620
836
|
}
|
|
621
837
|
|
|
622
838
|
function slotMatchesPayload(slot, payload) {
|
|
623
|
-
if (!slot || slot.dataset.bcMedia !== payload.media)
|
|
839
|
+
if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
624
842
|
if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
|
|
625
|
-
|
|
843
|
+
if (payload.media !== "video") {
|
|
844
|
+
const image = slot.querySelector?.("img");
|
|
845
|
+
return Boolean(
|
|
846
|
+
image?.isConnected === true &&
|
|
847
|
+
image.complete &&
|
|
848
|
+
image.naturalWidth > 0 &&
|
|
849
|
+
image.naturalHeight > 0,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
|
|
853
|
+
const video = slot.querySelector?.("video");
|
|
854
|
+
if (
|
|
855
|
+
!(video instanceof HTMLVideoElement) ||
|
|
856
|
+
video.error ||
|
|
857
|
+
video.ended ||
|
|
858
|
+
video.paused ||
|
|
859
|
+
video.seeking ||
|
|
860
|
+
video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
|
861
|
+
) {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
const requestedStartAt = Number(payload.startAt);
|
|
865
|
+
const normalizedStartAt =
|
|
866
|
+
Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
|
|
867
|
+
const appliedStartAt = Number(slot.dataset.bcStartAt);
|
|
868
|
+
return Number.isFinite(appliedStartAt) && Math.abs(appliedStartAt - normalizedStartAt) < 0.25;
|
|
626
869
|
}
|
|
627
870
|
|
|
628
871
|
function updateCommittedDom(payload) {
|
|
@@ -654,9 +897,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
654
897
|
function attachCandidate(slot, payload) {
|
|
655
898
|
const node = stage();
|
|
656
899
|
discardCandidates();
|
|
900
|
+
const previous = mountedCurrentSlot();
|
|
657
901
|
slot.dataset.bcRole = "candidate";
|
|
658
902
|
node.append(slot);
|
|
659
|
-
if (!
|
|
903
|
+
if (!previous) {
|
|
660
904
|
node.dataset.bcEmpty = "true";
|
|
661
905
|
document.documentElement.dataset.bcActive = "true";
|
|
662
906
|
document.documentElement.dataset.bcMedia =
|
|
@@ -668,17 +912,34 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
668
912
|
function nextFrame(signal) {
|
|
669
913
|
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
|
|
670
914
|
return new Promise((resolve, reject) => {
|
|
915
|
+
let settled = false;
|
|
671
916
|
let request = null;
|
|
672
|
-
|
|
673
|
-
|
|
917
|
+
let timer = null;
|
|
918
|
+
const cleanup = () => {
|
|
919
|
+
if (timer) clearTimeout(timer);
|
|
920
|
+
if (request != null && typeof cancelAnimationFrame === "function") {
|
|
921
|
+
cancelAnimationFrame(request);
|
|
922
|
+
}
|
|
674
923
|
signal?.removeEventListener?.("abort", aborted);
|
|
675
|
-
reject(abortError());
|
|
676
924
|
};
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
925
|
+
const finish = (error = null) => {
|
|
926
|
+
if (settled) return;
|
|
927
|
+
settled = true;
|
|
928
|
+
cleanup();
|
|
929
|
+
if (error) reject(error);
|
|
680
930
|
else resolve();
|
|
931
|
+
};
|
|
932
|
+
const aborted = () => {
|
|
933
|
+
finish(abortError());
|
|
934
|
+
};
|
|
935
|
+
request = requestAnimationFrame(() => {
|
|
936
|
+
request = null;
|
|
937
|
+
if (signal?.aborted) finish(abortError());
|
|
938
|
+
else finish();
|
|
681
939
|
});
|
|
940
|
+
// Background or power-saved Chromium pages may suspend rAF entirely.
|
|
941
|
+
// A short timer keeps the transaction bounded without skipping aborts.
|
|
942
|
+
timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
|
|
682
943
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
683
944
|
if (signal?.aborted) aborted();
|
|
684
945
|
});
|
|
@@ -711,16 +972,21 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
711
972
|
});
|
|
712
973
|
}
|
|
713
974
|
|
|
714
|
-
async function commitCandidate(payload, slot, signal) {
|
|
975
|
+
async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
|
|
715
976
|
throwIfAborted(signal);
|
|
716
977
|
const node = stage();
|
|
717
|
-
const previous =
|
|
718
|
-
|
|
978
|
+
const previous = mountedCurrentSlot();
|
|
979
|
+
const canAnimate =
|
|
980
|
+
previous &&
|
|
981
|
+
reducedMotionMedia?.matches !== true &&
|
|
982
|
+
document.visibilityState === "visible" &&
|
|
983
|
+
remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
|
|
984
|
+
if (canAnimate) {
|
|
719
985
|
await nextFrame(signal);
|
|
720
986
|
await nextFrame(signal);
|
|
721
987
|
throwIfAborted(signal);
|
|
722
988
|
node.dataset.bcTransitioning = "true";
|
|
723
|
-
await waitForCrossfade(
|
|
989
|
+
await waitForCrossfade(previous, signal);
|
|
724
990
|
}
|
|
725
991
|
throwIfAborted(signal);
|
|
726
992
|
if (previous && previous !== slot) disposeSlot(previous);
|
|
@@ -739,7 +1005,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
739
1005
|
const node = document.getElementById("beauticode-bg-stage");
|
|
740
1006
|
node?.removeAttribute("data-bc-transitioning");
|
|
741
1007
|
node?.removeAttribute("data-bc-empty");
|
|
742
|
-
if (committedPayload &&
|
|
1008
|
+
if (committedPayload && mountedCurrentSlot()) {
|
|
743
1009
|
updateCommittedDom(committedPayload);
|
|
744
1010
|
playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
|
|
745
1011
|
return;
|
|
@@ -751,12 +1017,19 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
751
1017
|
}
|
|
752
1018
|
|
|
753
1019
|
function syncGallery(payload) {
|
|
754
|
-
const on = payload?.atmosphere?.preset === "gallery";
|
|
755
|
-
|
|
756
|
-
|
|
1020
|
+
const on = payload?.atmosphere?.preset === "gallery";
|
|
1021
|
+
try {
|
|
1022
|
+
const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
|
|
1023
|
+
if (sync && typeof sync.then === "function") void sync.catch(() => {});
|
|
1024
|
+
} catch {
|
|
1025
|
+
/* Atmosphere is optional and must never invalidate a rendered background. */
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
757
1028
|
|
|
758
1029
|
async function applyBackground(payload, signal) {
|
|
759
1030
|
throwIfAborted(signal);
|
|
1031
|
+
const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
|
|
1032
|
+
const remaining = () => Math.max(1, applyDeadline - performance.now());
|
|
760
1033
|
if (payload.media === "clear") {
|
|
761
1034
|
desiredModes.fish = false;
|
|
762
1035
|
playbackBlocked = false;
|
|
@@ -775,13 +1048,20 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
775
1048
|
await acknowledgeMode();
|
|
776
1049
|
return;
|
|
777
1050
|
}
|
|
778
|
-
if (typeof payload.imageUrl !== "string")
|
|
779
|
-
|
|
780
|
-
if (structureIssue) {
|
|
781
|
-
await acknowledgeRender(payload, false, false, structureIssue);
|
|
1051
|
+
if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
|
|
1052
|
+
await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
|
|
782
1053
|
return;
|
|
783
1054
|
}
|
|
784
|
-
|
|
1055
|
+
let reusable = slotMatchesPayload(currentSlot, payload);
|
|
1056
|
+
if (reusable && payload.media === "video") {
|
|
1057
|
+
try {
|
|
1058
|
+
await waitForStablePlayback(activeVideo(), signal, Math.min(750, remaining()));
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
if (isAbortError(error) || signal?.aborted) throw error;
|
|
1061
|
+
reusable = false;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (reusable) {
|
|
785
1065
|
committedPayload = payload;
|
|
786
1066
|
currentSlot.dataset.bcGeneration = String(payload.generation);
|
|
787
1067
|
updateCommittedDom(payload);
|
|
@@ -791,16 +1071,17 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
791
1071
|
return;
|
|
792
1072
|
}
|
|
793
1073
|
let candidate = null;
|
|
1074
|
+
let candidateVideo = null;
|
|
794
1075
|
let committed = false;
|
|
795
1076
|
try {
|
|
796
|
-
|
|
797
|
-
throwIfAborted(signal);
|
|
1077
|
+
await waitForDshStructure(signal, remaining());
|
|
798
1078
|
if (payload.media === "image") {
|
|
799
|
-
|
|
800
|
-
playbackBlocked = false;
|
|
801
|
-
candidate = createSlot(payload, image);
|
|
1079
|
+
candidate = createSlot(payload);
|
|
802
1080
|
attachCandidate(candidate, payload);
|
|
803
|
-
await
|
|
1081
|
+
const image = await loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1082
|
+
throwIfAborted(signal);
|
|
1083
|
+
if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
|
|
1084
|
+
await commitCandidate(payload, candidate, signal, remaining);
|
|
804
1085
|
committed = true;
|
|
805
1086
|
await acknowledgeRender(payload, true, true);
|
|
806
1087
|
await acknowledgeMode();
|
|
@@ -810,32 +1091,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
810
1091
|
throw new Error("视频载荷无效");
|
|
811
1092
|
}
|
|
812
1093
|
const video = document.createElement("video");
|
|
813
|
-
video.autoplay =
|
|
1094
|
+
video.autoplay = true;
|
|
814
1095
|
video.loop = true;
|
|
815
1096
|
video.playsInline = true;
|
|
816
1097
|
video.preload = "auto";
|
|
817
|
-
video.
|
|
818
|
-
|
|
819
|
-
|
|
1098
|
+
video.crossOrigin = "anonymous";
|
|
1099
|
+
// Set mute before src so Chromium may start the request immediately
|
|
1100
|
+
// without waiting for an audible autoplay decision.
|
|
1101
|
+
video.defaultMuted = true;
|
|
1102
|
+
video.muted = desiredModes.muted;
|
|
1103
|
+
video.setAttribute("muted", "");
|
|
1104
|
+
candidate = createSlot(payload, video);
|
|
820
1105
|
attachCandidate(candidate, payload);
|
|
1106
|
+
candidateVideo = video;
|
|
1107
|
+
video.src = payload.videoUrl;
|
|
821
1108
|
video.load();
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
1109
|
+
const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1110
|
+
// play() is deliberately started alongside media readiness. Chromium is
|
|
1111
|
+
// allowed to ignore preload=auto for hidden/background media; waiting for
|
|
1112
|
+
// canplay before play() creates a circular stall at readyState=0.
|
|
1113
|
+
await Promise.all([
|
|
1114
|
+
imageReady,
|
|
1115
|
+
waitForVideo(video, signal, remaining()),
|
|
1116
|
+
startCandidatePlayback(video, signal, remaining()),
|
|
1117
|
+
]);
|
|
825
1118
|
throwIfAborted(signal);
|
|
826
|
-
seekVideo(video, payload.startAt);
|
|
1119
|
+
const appliedStartAt = seekVideo(video, payload.startAt);
|
|
1120
|
+
candidate.dataset.bcStartAt = String(appliedStartAt);
|
|
827
1121
|
await waitForSeek(video, signal, remaining());
|
|
828
1122
|
await waitForVideo(video, signal, remaining());
|
|
829
|
-
await startCandidatePlayback(video, signal, remaining());
|
|
830
1123
|
await waitForStablePlayback(video, signal, remaining());
|
|
831
1124
|
throwIfAborted(signal);
|
|
832
1125
|
candidate.dataset.bcVideoReady = "true";
|
|
833
|
-
await commitCandidate(payload, candidate, signal);
|
|
1126
|
+
await commitCandidate(payload, candidate, signal, remaining);
|
|
834
1127
|
committed = true;
|
|
835
1128
|
const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
|
|
836
1129
|
await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
|
|
837
1130
|
await acknowledgeMode();
|
|
838
1131
|
} catch (error) {
|
|
1132
|
+
if (candidateVideo && candidateVideo.parentElement !== candidate) {
|
|
1133
|
+
disposeVideo(candidateVideo);
|
|
1134
|
+
candidateVideo.remove?.();
|
|
1135
|
+
}
|
|
839
1136
|
if (candidate && !committed) disposeSlot(candidate);
|
|
840
1137
|
if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
|
|
841
1138
|
restoreCommittedDom();
|
|
@@ -850,6 +1147,22 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
850
1147
|
|
|
851
1148
|
function scheduleBackground(payload) {
|
|
852
1149
|
if (!Number.isSafeInteger(payload?.generation)) return;
|
|
1150
|
+
// EventSource reconnects can replay the current frame while its first
|
|
1151
|
+
// render is still in flight. Restarting the same generation would discard
|
|
1152
|
+
// a healthy cold media request and move its deadline indefinitely.
|
|
1153
|
+
if (
|
|
1154
|
+
applyController &&
|
|
1155
|
+
activePayload?.generation === payload.generation &&
|
|
1156
|
+
activePayload?.media === payload.media
|
|
1157
|
+
) {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (
|
|
1161
|
+
Number.isSafeInteger(activePayload?.generation) &&
|
|
1162
|
+
payload.generation < activePayload.generation
|
|
1163
|
+
) {
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
853
1166
|
applyController?.abort();
|
|
854
1167
|
discardCandidates();
|
|
855
1168
|
const controller = new AbortController();
|
|
@@ -857,11 +1170,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
857
1170
|
activePayload = payload;
|
|
858
1171
|
renderPhase = "pending";
|
|
859
1172
|
document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
|
|
860
|
-
void applyBackground(payload, controller.signal)
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1173
|
+
void applyBackground(payload, controller.signal)
|
|
1174
|
+
.catch(async (error) => {
|
|
1175
|
+
if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return;
|
|
1176
|
+
restoreCommittedDom();
|
|
1177
|
+
await acknowledgeRender(
|
|
1178
|
+
payload,
|
|
1179
|
+
false,
|
|
1180
|
+
Boolean(mountedCurrentSlot()),
|
|
1181
|
+
error instanceof Error ? error.message : String(error),
|
|
1182
|
+
);
|
|
1183
|
+
})
|
|
1184
|
+
.finally(() => {
|
|
1185
|
+
if (applyController !== controller) return;
|
|
1186
|
+
applyController = null;
|
|
1187
|
+
document.documentElement.removeAttribute("data-bc-pending-generation");
|
|
1188
|
+
})
|
|
1189
|
+
.catch(() => {});
|
|
865
1190
|
}
|
|
866
1191
|
|
|
867
1192
|
async function applyModes(payload) {
|
|
@@ -879,8 +1204,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
879
1204
|
const candidate = pendingVideo();
|
|
880
1205
|
if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
|
|
881
1206
|
if (video instanceof HTMLVideoElement) {
|
|
882
|
-
try {
|
|
883
|
-
await playWithPreference(video);
|
|
1207
|
+
try {
|
|
1208
|
+
playbackBlocked = await playWithPreference(video);
|
|
884
1209
|
} catch {
|
|
885
1210
|
playbackBlocked = desiredModes.muted === false;
|
|
886
1211
|
}
|
|
@@ -903,25 +1228,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
903
1228
|
events.onmessage = (event) => {
|
|
904
1229
|
try {
|
|
905
1230
|
const payload = JSON.parse(event.data);
|
|
906
|
-
if (payload?.type === "mode") void applyModes(payload);
|
|
1231
|
+
if (payload?.type === "mode") void applyModes(payload).catch(() => {});
|
|
907
1232
|
else if (payload?.type === "apply") scheduleBackground(payload);
|
|
908
1233
|
} catch {
|
|
909
1234
|
/* EventSource will continue with the next valid frame. */
|
|
910
1235
|
}
|
|
911
1236
|
};
|
|
912
1237
|
|
|
913
|
-
setInterval(() => {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1238
|
+
setInterval(() => {
|
|
1239
|
+
if (
|
|
1240
|
+
!committedPayload ||
|
|
1241
|
+
activePayload !== committedPayload ||
|
|
1242
|
+
renderPhase !== "ready"
|
|
1243
|
+
) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (committedPayload.media === "clear") {
|
|
1247
|
+
void acknowledgeRender(committedPayload, true, false).catch(() => {});
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const slot = mountedCurrentSlot();
|
|
1251
|
+
if (!slot) return;
|
|
1252
|
+
if (committedPayload.media === "image") {
|
|
1253
|
+
const image = slot.querySelector?.("img");
|
|
1254
|
+
if (
|
|
1255
|
+
image?.isConnected === true &&
|
|
1256
|
+
image.complete &&
|
|
1257
|
+
image.naturalWidth > 0 &&
|
|
1258
|
+
image.naturalHeight > 0
|
|
1259
|
+
) {
|
|
1260
|
+
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
1261
|
+
}
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const video = activeVideo();
|
|
1265
|
+
if (
|
|
1266
|
+
committedPayload.media === "video" &&
|
|
1267
|
+
video instanceof HTMLVideoElement
|
|
1268
|
+
) {
|
|
921
1269
|
// A heartbeat is observational, not a second render verdict. Playback can
|
|
922
1270
|
// briefly pause while Chromium changes modes or refills an 8K buffer; do
|
|
923
1271
|
// not downgrade an already-rendered generation or fail a pending one.
|
|
924
|
-
void acknowledgeRender(committedPayload, true, true);
|
|
1272
|
+
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
925
1273
|
}
|
|
926
1274
|
}, 1_000);
|
|
927
1275
|
})();
|
package/host-apply.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
} from "./control-client.mjs";
|
|
8
8
|
import { canvasImagePath } from "./presets.mjs";
|
|
9
9
|
|
|
10
|
-
const sessions = new Map();
|
|
10
|
+
const sessions = new Map();
|
|
11
|
+
const DSH_VERIFY_DEADLINE_MS = 10_000;
|
|
11
12
|
|
|
12
13
|
export const ENGINE_MISSING_MESSAGE =
|
|
13
14
|
"beautiCode 插件未能加载本机导入引擎。请执行 npx beauticode-dsh,或从完整安装目录加载桥接。";
|
|
@@ -92,7 +93,7 @@ export async function ensureInProcessSession(options) {
|
|
|
92
93
|
const session = new adapter.DshSession({
|
|
93
94
|
dataRoot,
|
|
94
95
|
baseUrl,
|
|
95
|
-
verifyDeadlineMs:
|
|
96
|
+
verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS,
|
|
96
97
|
bundledGalleryImagePath: canvasImagePath() || undefined,
|
|
97
98
|
});
|
|
98
99
|
try {
|
package/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { registerAgentSurfaces } from "./agent.mjs";
|
|
7
7
|
import { resolvePluginBaseUrl } from "./host-apply.mjs";
|
|
8
8
|
import { createBeauticodeUi } from "./ui-host.mjs";
|
|
@@ -12,10 +12,10 @@ export const name = "beauticode-bridge";
|
|
|
12
12
|
export const inject = ["webServer"];
|
|
13
13
|
export const bridgeProtocol = 4;
|
|
14
14
|
|
|
15
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
17
|
-
const REVISION_PATTERN = /^[a-f0-9]{64}$/;
|
|
18
|
-
const MAX_BODY_BYTES = 64 * 1024;
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
17
|
+
const REVISION_PATTERN = /^[a-f0-9]{64}$/;
|
|
18
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
19
19
|
|
|
20
20
|
async function readBridgeIdentity() {
|
|
21
21
|
try {
|
|
@@ -111,7 +111,7 @@ function isSameOrigin(req) {
|
|
|
111
111
|
return req.headers["sec-fetch-site"] === "same-origin";
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
function validLoopbackMediaUrl(value) {
|
|
114
|
+
function validLoopbackMediaUrl(value) {
|
|
115
115
|
if (typeof value !== "string") return false;
|
|
116
116
|
try {
|
|
117
117
|
const url = new URL(value);
|
|
@@ -123,9 +123,9 @@ function validLoopbackMediaUrl(value) {
|
|
|
123
123
|
} catch {
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function validApplyPayload(body) {
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validApplyPayload(body) {
|
|
129
129
|
if (!Number.isSafeInteger(body.generation) || body.generation < 0) return false;
|
|
130
130
|
if (!["image", "video", "clear"].includes(body.media)) return false;
|
|
131
131
|
if (body.atmosphere != null && !normalizeAtmosphere(body.atmosphere)) return false;
|
|
@@ -192,10 +192,10 @@ function publicStatus(current, modes, clients, clientStates) {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
export function apply(ctx, config = {}) {
|
|
195
|
-
const tokenFile = path.resolve(config.tokenFile || defaultTokenFile());
|
|
196
|
-
const clients = new Map();
|
|
197
|
-
const clientStates = new Map();
|
|
198
|
-
let current = null;
|
|
195
|
+
const tokenFile = path.resolve(config.tokenFile || defaultTokenFile());
|
|
196
|
+
const clients = new Map();
|
|
197
|
+
const clientStates = new Map();
|
|
198
|
+
let current = null;
|
|
199
199
|
const modes = { fish: false, muted: true, tone: "auto" };
|
|
200
200
|
const dataRoot = path.dirname(tokenFile);
|
|
201
201
|
const baseUrl = resolvePluginBaseUrl(ctx);
|
|
@@ -308,9 +308,9 @@ export function apply(ctx, config = {}) {
|
|
|
308
308
|
res.end(req.method === "HEAD" ? undefined : source);
|
|
309
309
|
},
|
|
310
310
|
}),
|
|
311
|
-
ctx.webServer.register({
|
|
312
|
-
kind: "exact",
|
|
313
|
-
path: "/__beauticode/client.js",
|
|
311
|
+
ctx.webServer.register({
|
|
312
|
+
kind: "exact",
|
|
313
|
+
path: "/__beauticode/client.js",
|
|
314
314
|
handler: async (req, res) => {
|
|
315
315
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
316
316
|
res.writeHead(405).end();
|
|
@@ -322,10 +322,10 @@ export function apply(ctx, config = {}) {
|
|
|
322
322
|
"cache-control": "no-store",
|
|
323
323
|
"content-length": source.length,
|
|
324
324
|
});
|
|
325
|
-
res.end(req.method === "HEAD" ? undefined : source);
|
|
326
|
-
},
|
|
327
|
-
}),
|
|
328
|
-
ctx.webServer.register({
|
|
325
|
+
res.end(req.method === "HEAD" ? undefined : source);
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
ctx.webServer.register({
|
|
329
329
|
kind: "exact",
|
|
330
330
|
path: "/__beauticode/gallery.js",
|
|
331
331
|
handler: async (req, res) => {
|
|
@@ -442,8 +442,8 @@ export function apply(ctx, config = {}) {
|
|
|
442
442
|
clients.get(clientId)?.destroy();
|
|
443
443
|
clients.set(clientId, res);
|
|
444
444
|
clientStates.set(clientId, { render: null, mode: null });
|
|
445
|
-
if (current) {
|
|
446
|
-
res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`);
|
|
445
|
+
if (current) {
|
|
446
|
+
res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`);
|
|
447
447
|
}
|
|
448
448
|
res.write(`data: ${JSON.stringify({ type: "mode", ...modes })}\n\n`);
|
|
449
449
|
ui.scheduleRestore();
|
|
@@ -472,18 +472,18 @@ export function apply(ctx, config = {}) {
|
|
|
472
472
|
sendJson(res, 400, { ok: false, error: "背景载荷无效。" });
|
|
473
473
|
return;
|
|
474
474
|
}
|
|
475
|
-
current = {
|
|
476
|
-
generation: body.generation,
|
|
477
|
-
media: body.media,
|
|
478
|
-
imageUrl: body.media === "clear" ? null : body.imageUrl,
|
|
479
|
-
videoUrl: body.media === "video" ? body.videoUrl : null,
|
|
480
|
-
startAt: body.media === "video" ? body.startAt ?? null : null,
|
|
481
|
-
};
|
|
482
|
-
const atmosphere = normalizeAtmosphere(body.atmosphere);
|
|
483
|
-
if (atmosphere) current.atmosphere = atmosphere;
|
|
484
|
-
if (body.media === "clear") modes.fish = false;
|
|
485
|
-
for (const state of clientStates.values()) state.render = null;
|
|
486
|
-
broadcast({ type: "apply", ...current });
|
|
475
|
+
current = {
|
|
476
|
+
generation: body.generation,
|
|
477
|
+
media: body.media,
|
|
478
|
+
imageUrl: body.media === "clear" ? null : body.imageUrl,
|
|
479
|
+
videoUrl: body.media === "video" ? body.videoUrl : null,
|
|
480
|
+
startAt: body.media === "video" ? body.startAt ?? null : null,
|
|
481
|
+
};
|
|
482
|
+
const atmosphere = normalizeAtmosphere(body.atmosphere);
|
|
483
|
+
if (atmosphere) current.atmosphere = atmosphere;
|
|
484
|
+
if (body.media === "clear") modes.fish = false;
|
|
485
|
+
for (const state of clientStates.values()) state.render = null;
|
|
486
|
+
broadcast({ type: "apply", ...current });
|
|
487
487
|
if (body.media === "clear") broadcast({ type: "mode", ...modes });
|
|
488
488
|
sendJson(res, 200, { ok: true });
|
|
489
489
|
},
|
|
@@ -621,10 +621,10 @@ export function apply(ctx, config = {}) {
|
|
|
621
621
|
return () => {
|
|
622
622
|
disposeTap();
|
|
623
623
|
for (const dispose of disposers) dispose();
|
|
624
|
-
for (const response of clients.values()) response.destroy();
|
|
625
|
-
clients.clear();
|
|
626
|
-
clientStates.clear();
|
|
627
|
-
void ui.dispose();
|
|
624
|
+
for (const response of clients.values()) response.destroy();
|
|
625
|
+
clients.clear();
|
|
626
|
+
clientStates.clear();
|
|
627
|
+
void ui.dispose();
|
|
628
628
|
};
|
|
629
629
|
}, "beauticode-bridge: routes and browser injection");
|
|
630
630
|
}
|