beauticode-dsh 1.0.16 → 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/client.js +465 -146
- 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/client.js
CHANGED
|
@@ -8,9 +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
|
-
|
|
13
|
-
|
|
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;
|
|
14
21
|
const VIDEO_STABLE_FRAMES = 3;
|
|
15
22
|
const VIDEO_STABLE_PROGRESS_SEC = 0.18;
|
|
16
23
|
const CROSSFADE_MS = 180;
|
|
@@ -67,10 +74,10 @@ html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-st
|
|
|
67
74
|
html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
|
|
68
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)}
|
|
69
76
|
html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important}
|
|
70
|
-
#beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:
|
|
71
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:
|
|
72
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:
|
|
73
|
-
#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}
|
|
74
81
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
|
|
75
82
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
|
|
76
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}
|
|
@@ -127,12 +134,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
127
134
|
attributeFilter: ["data-ds-dark-theme"],
|
|
128
135
|
});
|
|
129
136
|
}
|
|
130
|
-
systemDarkMedia?.addEventListener("change", () => {
|
|
131
|
-
if (desiredModes.tone === "auto") {
|
|
132
|
-
syncDshTheme();
|
|
133
|
-
void acknowledgeMode();
|
|
134
|
-
}
|
|
135
|
-
});
|
|
137
|
+
systemDarkMedia?.addEventListener("change", () => {
|
|
138
|
+
if (desiredModes.tone === "auto") {
|
|
139
|
+
syncDshTheme();
|
|
140
|
+
void acknowledgeMode().catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
136
143
|
syncDshTheme();
|
|
137
144
|
|
|
138
145
|
function stage() {
|
|
@@ -184,8 +191,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
184
191
|
});
|
|
185
192
|
}
|
|
186
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
|
+
|
|
187
209
|
function activeVideo() {
|
|
188
|
-
const video =
|
|
210
|
+
const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
|
|
189
211
|
return video instanceof HTMLVideoElement ? video : null;
|
|
190
212
|
}
|
|
191
213
|
|
|
@@ -195,7 +217,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
195
217
|
'.beauticode-media-slot[data-bc-role="candidate"] video',
|
|
196
218
|
);
|
|
197
219
|
return video instanceof HTMLVideoElement ? video : null;
|
|
198
|
-
}
|
|
220
|
+
}
|
|
199
221
|
|
|
200
222
|
async function postAck(body) {
|
|
201
223
|
await fetch("/__beauticode/ack", {
|
|
@@ -267,45 +289,212 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
267
289
|
if (signal?.aborted) throw abortError();
|
|
268
290
|
}
|
|
269
291
|
|
|
270
|
-
function
|
|
271
|
-
|
|
272
|
-
image.
|
|
273
|
-
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) {
|
|
274
319
|
return new Promise((resolve, reject) => {
|
|
275
320
|
let settled = false;
|
|
276
|
-
let
|
|
321
|
+
let decodeStarted = false;
|
|
322
|
+
let decodeFallback = null;
|
|
277
323
|
const cleanup = () => {
|
|
278
|
-
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
if (decodeFallback) clearTimeout(decodeFallback);
|
|
279
326
|
signal?.removeEventListener?.("abort", aborted);
|
|
280
327
|
image.onload = null;
|
|
281
328
|
image.onerror = null;
|
|
282
329
|
};
|
|
283
|
-
const finish = (error) => {
|
|
330
|
+
const finish = (error = null) => {
|
|
284
331
|
if (settled) return;
|
|
285
332
|
settled = true;
|
|
286
333
|
cleanup();
|
|
287
334
|
if (error) reject(error);
|
|
288
|
-
else resolve(
|
|
335
|
+
else resolve();
|
|
289
336
|
};
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
image.
|
|
293
|
-
|
|
294
|
-
image.src = "";
|
|
337
|
+
const acceptLoaded = () => {
|
|
338
|
+
if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
|
|
339
|
+
if (image.complete) finish(new Error("图片尺寸无效"));
|
|
340
|
+
return;
|
|
295
341
|
}
|
|
296
|
-
|
|
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();
|
|
297
373
|
};
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
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;
|
|
301
382
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
302
383
|
if (signal?.aborted) {
|
|
303
384
|
aborted();
|
|
304
385
|
return;
|
|
305
386
|
}
|
|
306
387
|
image.src = url;
|
|
388
|
+
if (image.complete) queueMicrotask(acceptLoaded);
|
|
307
389
|
});
|
|
308
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
|
+
}
|
|
309
498
|
|
|
310
499
|
function describeVideoState(video, phase) {
|
|
311
500
|
const mediaErrorNames = {
|
|
@@ -320,13 +509,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
320
509
|
}
|
|
321
510
|
|
|
322
511
|
function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
|
|
323
|
-
const
|
|
324
|
-
if (video.readyState >=
|
|
512
|
+
const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
|
|
513
|
+
if (video.readyState >= frameReadyState) return Promise.resolve();
|
|
325
514
|
return new Promise((resolve, reject) => {
|
|
326
515
|
let settled = false;
|
|
327
516
|
const done = () => finish();
|
|
328
517
|
const check = () => {
|
|
329
|
-
if (video.readyState >=
|
|
518
|
+
if (video.readyState >= frameReadyState) done();
|
|
330
519
|
};
|
|
331
520
|
const failed = () => {
|
|
332
521
|
finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
|
|
@@ -335,7 +524,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
335
524
|
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
|
|
336
525
|
};
|
|
337
526
|
const timer = setTimeout(() => {
|
|
338
|
-
finish(new Error(describeVideoState(video, "
|
|
527
|
+
finish(new Error(describeVideoState(video, "等待视频首帧超时")));
|
|
339
528
|
}, timeoutMs);
|
|
340
529
|
const cleanup = () => {
|
|
341
530
|
clearTimeout(timer);
|
|
@@ -402,7 +591,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
402
591
|
let lastFrameTime = null;
|
|
403
592
|
let playingSeen =
|
|
404
593
|
!video.paused &&
|
|
405
|
-
video.readyState >=
|
|
594
|
+
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
406
595
|
let settled = false;
|
|
407
596
|
let frameRequest = null;
|
|
408
597
|
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
@@ -413,6 +602,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
413
602
|
video.removeEventListener("timeupdate", check);
|
|
414
603
|
video.removeEventListener("waiting", resetStableWindow);
|
|
415
604
|
video.removeEventListener("stalled", resetStableWindow);
|
|
605
|
+
video.removeEventListener("pause", resetStableWindow);
|
|
606
|
+
video.removeEventListener("seeking", resetStableWindow);
|
|
416
607
|
video.removeEventListener("error", failed);
|
|
417
608
|
video.removeEventListener("abort", aborted);
|
|
418
609
|
signal?.removeEventListener?.("abort", aborted);
|
|
@@ -465,7 +656,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
465
656
|
return;
|
|
466
657
|
}
|
|
467
658
|
observeProgress();
|
|
468
|
-
if (!video.paused && video.readyState >=
|
|
659
|
+
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
469
660
|
playingSeen = true;
|
|
470
661
|
}
|
|
471
662
|
const framesReady = hasFrameCallback
|
|
@@ -508,6 +699,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
508
699
|
video.addEventListener("timeupdate", check);
|
|
509
700
|
video.addEventListener("waiting", resetStableWindow);
|
|
510
701
|
video.addEventListener("stalled", resetStableWindow);
|
|
702
|
+
video.addEventListener("pause", resetStableWindow);
|
|
703
|
+
video.addEventListener("seeking", resetStableWindow);
|
|
511
704
|
video.addEventListener("error", failed, { once: true });
|
|
512
705
|
video.addEventListener("abort", aborted, { once: true });
|
|
513
706
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
@@ -536,62 +729,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
536
729
|
|
|
537
730
|
async function playWithPreference(video, signal = null) {
|
|
538
731
|
throwIfAborted(signal);
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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;
|
|
546
741
|
video.muted = true;
|
|
547
742
|
await video.play();
|
|
548
743
|
}
|
|
549
744
|
throwIfAborted(signal);
|
|
550
|
-
video.dataset.bcPlaybackBlocked =
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
function createPlayingWaiter(video, signal, timeoutMs) {
|
|
554
|
-
let cancel = () => {};
|
|
555
|
-
const promise = new Promise((resolve, reject) => {
|
|
556
|
-
let settled = false;
|
|
557
|
-
const cleanup = () => {
|
|
558
|
-
clearTimeout(timer);
|
|
559
|
-
video.removeEventListener("playing", played);
|
|
560
|
-
video.removeEventListener("error", failed);
|
|
561
|
-
video.removeEventListener("abort", aborted);
|
|
562
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
563
|
-
};
|
|
564
|
-
const finish = (error) => {
|
|
565
|
-
if (settled) return;
|
|
566
|
-
settled = true;
|
|
567
|
-
cleanup();
|
|
568
|
-
if (error) reject(error);
|
|
569
|
-
else resolve();
|
|
570
|
-
};
|
|
571
|
-
const played = () => finish();
|
|
572
|
-
const failed = () => finish(new Error(describeVideoState(video, "视频开始播放失败")));
|
|
573
|
-
const aborted = () => finish(abortError());
|
|
574
|
-
const timer = setTimeout(
|
|
575
|
-
() => finish(new Error(describeVideoState(video, "等待 playing 事件超时"))),
|
|
576
|
-
timeoutMs,
|
|
577
|
-
);
|
|
578
|
-
cancel = (error = abortError()) => finish(error);
|
|
579
|
-
video.addEventListener("playing", played, { once: true });
|
|
580
|
-
video.addEventListener("error", failed, { once: true });
|
|
581
|
-
video.addEventListener("abort", aborted, { once: true });
|
|
582
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
583
|
-
if (signal?.aborted) aborted();
|
|
584
|
-
});
|
|
585
|
-
return { promise, cancel };
|
|
745
|
+
video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
|
|
746
|
+
return blocked;
|
|
586
747
|
}
|
|
587
748
|
|
|
588
749
|
async function startCandidatePlayback(video, signal, timeoutMs) {
|
|
589
|
-
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();
|
|
590
765
|
try {
|
|
591
|
-
|
|
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]);
|
|
592
769
|
} catch (error) {
|
|
593
|
-
|
|
770
|
+
playbackController.abort();
|
|
771
|
+
try {
|
|
772
|
+
video.pause();
|
|
773
|
+
} catch {
|
|
774
|
+
/* best effort: disposeSlot will release src as the final guard */
|
|
775
|
+
}
|
|
594
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
|
+
}
|
|
595
783
|
}
|
|
596
784
|
}
|
|
597
785
|
|
|
@@ -623,14 +811,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
623
811
|
if (!slot) return;
|
|
624
812
|
for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
|
|
625
813
|
for (const image of slot.querySelectorAll?.("img") ?? []) {
|
|
626
|
-
image
|
|
627
|
-
image.onerror = null;
|
|
628
|
-
image.removeAttribute?.("src");
|
|
814
|
+
releaseImage(image);
|
|
629
815
|
}
|
|
630
816
|
slot.remove?.();
|
|
631
817
|
}
|
|
632
818
|
|
|
633
|
-
function createSlot(payload,
|
|
819
|
+
function createSlot(payload, video = null) {
|
|
634
820
|
const slot = document.createElement("div");
|
|
635
821
|
slot.className = "beauticode-media-slot";
|
|
636
822
|
slot.dataset.bcRole = "candidate";
|
|
@@ -638,8 +824,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
638
824
|
slot.dataset.bcGeneration = String(payload.generation);
|
|
639
825
|
slot.dataset.bcImageUrl = payload.imageUrl;
|
|
640
826
|
if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
|
|
641
|
-
|
|
642
|
-
|
|
827
|
+
if (payload.media === "video") {
|
|
828
|
+
const startAt = Number(payload.startAt);
|
|
829
|
+
slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
|
|
830
|
+
}
|
|
643
831
|
if (video) {
|
|
644
832
|
video.className = "beauticode-media-video";
|
|
645
833
|
slot.append(video);
|
|
@@ -648,9 +836,36 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
648
836
|
}
|
|
649
837
|
|
|
650
838
|
function slotMatchesPayload(slot, payload) {
|
|
651
|
-
if (!slot || slot.dataset.bcMedia !== payload.media)
|
|
839
|
+
if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
652
842
|
if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
|
|
653
|
-
|
|
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;
|
|
654
869
|
}
|
|
655
870
|
|
|
656
871
|
function updateCommittedDom(payload) {
|
|
@@ -682,9 +897,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
682
897
|
function attachCandidate(slot, payload) {
|
|
683
898
|
const node = stage();
|
|
684
899
|
discardCandidates();
|
|
900
|
+
const previous = mountedCurrentSlot();
|
|
685
901
|
slot.dataset.bcRole = "candidate";
|
|
686
902
|
node.append(slot);
|
|
687
|
-
if (!
|
|
903
|
+
if (!previous) {
|
|
688
904
|
node.dataset.bcEmpty = "true";
|
|
689
905
|
document.documentElement.dataset.bcActive = "true";
|
|
690
906
|
document.documentElement.dataset.bcMedia =
|
|
@@ -696,17 +912,34 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
696
912
|
function nextFrame(signal) {
|
|
697
913
|
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
|
|
698
914
|
return new Promise((resolve, reject) => {
|
|
915
|
+
let settled = false;
|
|
699
916
|
let request = null;
|
|
700
|
-
|
|
701
|
-
|
|
917
|
+
let timer = null;
|
|
918
|
+
const cleanup = () => {
|
|
919
|
+
if (timer) clearTimeout(timer);
|
|
920
|
+
if (request != null && typeof cancelAnimationFrame === "function") {
|
|
921
|
+
cancelAnimationFrame(request);
|
|
922
|
+
}
|
|
702
923
|
signal?.removeEventListener?.("abort", aborted);
|
|
703
|
-
reject(abortError());
|
|
704
924
|
};
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
925
|
+
const finish = (error = null) => {
|
|
926
|
+
if (settled) return;
|
|
927
|
+
settled = true;
|
|
928
|
+
cleanup();
|
|
929
|
+
if (error) reject(error);
|
|
708
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();
|
|
709
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);
|
|
710
943
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
711
944
|
if (signal?.aborted) aborted();
|
|
712
945
|
});
|
|
@@ -739,16 +972,21 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
739
972
|
});
|
|
740
973
|
}
|
|
741
974
|
|
|
742
|
-
async function commitCandidate(payload, slot, signal) {
|
|
975
|
+
async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
|
|
743
976
|
throwIfAborted(signal);
|
|
744
977
|
const node = stage();
|
|
745
|
-
const previous =
|
|
746
|
-
|
|
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) {
|
|
747
985
|
await nextFrame(signal);
|
|
748
986
|
await nextFrame(signal);
|
|
749
987
|
throwIfAborted(signal);
|
|
750
988
|
node.dataset.bcTransitioning = "true";
|
|
751
|
-
await waitForCrossfade(
|
|
989
|
+
await waitForCrossfade(previous, signal);
|
|
752
990
|
}
|
|
753
991
|
throwIfAborted(signal);
|
|
754
992
|
if (previous && previous !== slot) disposeSlot(previous);
|
|
@@ -767,7 +1005,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
767
1005
|
const node = document.getElementById("beauticode-bg-stage");
|
|
768
1006
|
node?.removeAttribute("data-bc-transitioning");
|
|
769
1007
|
node?.removeAttribute("data-bc-empty");
|
|
770
|
-
if (committedPayload &&
|
|
1008
|
+
if (committedPayload && mountedCurrentSlot()) {
|
|
771
1009
|
updateCommittedDom(committedPayload);
|
|
772
1010
|
playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
|
|
773
1011
|
return;
|
|
@@ -779,12 +1017,19 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
779
1017
|
}
|
|
780
1018
|
|
|
781
1019
|
function syncGallery(payload) {
|
|
782
|
-
const on = payload?.atmosphere?.preset === "gallery";
|
|
783
|
-
|
|
784
|
-
|
|
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
|
+
}
|
|
785
1028
|
|
|
786
1029
|
async function applyBackground(payload, signal) {
|
|
787
1030
|
throwIfAborted(signal);
|
|
1031
|
+
const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
|
|
1032
|
+
const remaining = () => Math.max(1, applyDeadline - performance.now());
|
|
788
1033
|
if (payload.media === "clear") {
|
|
789
1034
|
desiredModes.fish = false;
|
|
790
1035
|
playbackBlocked = false;
|
|
@@ -803,13 +1048,20 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
803
1048
|
await acknowledgeMode();
|
|
804
1049
|
return;
|
|
805
1050
|
}
|
|
806
|
-
if (typeof payload.imageUrl !== "string")
|
|
807
|
-
|
|
808
|
-
if (structureIssue) {
|
|
809
|
-
await acknowledgeRender(payload, false, false, structureIssue);
|
|
1051
|
+
if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
|
|
1052
|
+
await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
|
|
810
1053
|
return;
|
|
811
1054
|
}
|
|
812
|
-
|
|
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) {
|
|
813
1065
|
committedPayload = payload;
|
|
814
1066
|
currentSlot.dataset.bcGeneration = String(payload.generation);
|
|
815
1067
|
updateCommittedDom(payload);
|
|
@@ -819,17 +1071,17 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
819
1071
|
return;
|
|
820
1072
|
}
|
|
821
1073
|
let candidate = null;
|
|
1074
|
+
let candidateVideo = null;
|
|
822
1075
|
let committed = false;
|
|
823
1076
|
try {
|
|
824
|
-
await waitForDshStructure(signal);
|
|
825
|
-
const image = await loadImage(payload.imageUrl, signal);
|
|
826
|
-
throwIfAborted(signal);
|
|
1077
|
+
await waitForDshStructure(signal, remaining());
|
|
827
1078
|
if (payload.media === "image") {
|
|
828
|
-
|
|
829
|
-
playbackBlocked = false;
|
|
830
|
-
candidate = createSlot(payload, image);
|
|
1079
|
+
candidate = createSlot(payload);
|
|
831
1080
|
attachCandidate(candidate, payload);
|
|
832
|
-
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);
|
|
833
1085
|
committed = true;
|
|
834
1086
|
await acknowledgeRender(payload, true, true);
|
|
835
1087
|
await acknowledgeMode();
|
|
@@ -839,32 +1091,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
839
1091
|
throw new Error("视频载荷无效");
|
|
840
1092
|
}
|
|
841
1093
|
const video = document.createElement("video");
|
|
842
|
-
video.autoplay =
|
|
1094
|
+
video.autoplay = true;
|
|
843
1095
|
video.loop = true;
|
|
844
1096
|
video.playsInline = true;
|
|
845
1097
|
video.preload = "auto";
|
|
846
|
-
video.
|
|
847
|
-
|
|
848
|
-
|
|
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);
|
|
849
1105
|
attachCandidate(candidate, payload);
|
|
1106
|
+
candidateVideo = video;
|
|
1107
|
+
video.src = payload.videoUrl;
|
|
850
1108
|
video.load();
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
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
|
+
]);
|
|
854
1118
|
throwIfAborted(signal);
|
|
855
|
-
seekVideo(video, payload.startAt);
|
|
1119
|
+
const appliedStartAt = seekVideo(video, payload.startAt);
|
|
1120
|
+
candidate.dataset.bcStartAt = String(appliedStartAt);
|
|
856
1121
|
await waitForSeek(video, signal, remaining());
|
|
857
1122
|
await waitForVideo(video, signal, remaining());
|
|
858
|
-
await startCandidatePlayback(video, signal, remaining());
|
|
859
1123
|
await waitForStablePlayback(video, signal, remaining());
|
|
860
1124
|
throwIfAborted(signal);
|
|
861
1125
|
candidate.dataset.bcVideoReady = "true";
|
|
862
|
-
await commitCandidate(payload, candidate, signal);
|
|
1126
|
+
await commitCandidate(payload, candidate, signal, remaining);
|
|
863
1127
|
committed = true;
|
|
864
1128
|
const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
|
|
865
1129
|
await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
|
|
866
1130
|
await acknowledgeMode();
|
|
867
1131
|
} catch (error) {
|
|
1132
|
+
if (candidateVideo && candidateVideo.parentElement !== candidate) {
|
|
1133
|
+
disposeVideo(candidateVideo);
|
|
1134
|
+
candidateVideo.remove?.();
|
|
1135
|
+
}
|
|
868
1136
|
if (candidate && !committed) disposeSlot(candidate);
|
|
869
1137
|
if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
|
|
870
1138
|
restoreCommittedDom();
|
|
@@ -879,6 +1147,22 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
879
1147
|
|
|
880
1148
|
function scheduleBackground(payload) {
|
|
881
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
|
+
}
|
|
882
1166
|
applyController?.abort();
|
|
883
1167
|
discardCandidates();
|
|
884
1168
|
const controller = new AbortController();
|
|
@@ -886,11 +1170,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
886
1170
|
activePayload = payload;
|
|
887
1171
|
renderPhase = "pending";
|
|
888
1172
|
document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
|
|
889
|
-
void applyBackground(payload, controller.signal)
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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(() => {});
|
|
894
1190
|
}
|
|
895
1191
|
|
|
896
1192
|
async function applyModes(payload) {
|
|
@@ -908,8 +1204,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
908
1204
|
const candidate = pendingVideo();
|
|
909
1205
|
if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
|
|
910
1206
|
if (video instanceof HTMLVideoElement) {
|
|
911
|
-
try {
|
|
912
|
-
await playWithPreference(video);
|
|
1207
|
+
try {
|
|
1208
|
+
playbackBlocked = await playWithPreference(video);
|
|
913
1209
|
} catch {
|
|
914
1210
|
playbackBlocked = desiredModes.muted === false;
|
|
915
1211
|
}
|
|
@@ -932,25 +1228,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
932
1228
|
events.onmessage = (event) => {
|
|
933
1229
|
try {
|
|
934
1230
|
const payload = JSON.parse(event.data);
|
|
935
|
-
if (payload?.type === "mode") void applyModes(payload);
|
|
1231
|
+
if (payload?.type === "mode") void applyModes(payload).catch(() => {});
|
|
936
1232
|
else if (payload?.type === "apply") scheduleBackground(payload);
|
|
937
1233
|
} catch {
|
|
938
1234
|
/* EventSource will continue with the next valid frame. */
|
|
939
1235
|
}
|
|
940
1236
|
};
|
|
941
1237
|
|
|
942
|
-
setInterval(() => {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
+
) {
|
|
950
1269
|
// A heartbeat is observational, not a second render verdict. Playback can
|
|
951
1270
|
// briefly pause while Chromium changes modes or refills an 8K buffer; do
|
|
952
1271
|
// not downgrade an already-rendered generation or fail a pending one.
|
|
953
|
-
void acknowledgeRender(committedPayload, true, true);
|
|
1272
|
+
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
954
1273
|
}
|
|
955
1274
|
}, 1_000);
|
|
956
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
|
}
|