beauticode-dsh 1.0.16 → 1.0.19
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 +534 -149
- package/host-apply.mjs +3 -2
- package/index.mjs +41 -41
- package/package.json +1 -1
- package/vendor/adapter-dsh/bridge.js +12 -0
- package/vendor/adapter-dsh/index.js +1 -1
- package/vendor/adapter-dsh/session.js +2 -2
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,17 @@
|
|
|
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 VIDEO_PROBE_TIMEOUT_MS = 2_000;
|
|
20
|
+
const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
21
|
+
const FRAME_FALLBACK_MS = 120;
|
|
14
22
|
const VIDEO_STABLE_FRAMES = 3;
|
|
15
23
|
const VIDEO_STABLE_PROGRESS_SEC = 0.18;
|
|
16
24
|
const CROSSFADE_MS = 180;
|
|
@@ -67,15 +75,15 @@ html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-st
|
|
|
67
75
|
html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
|
|
68
76
|
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
77
|
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"]{
|
|
78
|
+
#beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:1;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity}
|
|
79
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
|
|
80
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
|
|
81
|
+
#beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
|
|
74
82
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
|
|
75
83
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
|
|
76
84
|
#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease}
|
|
77
85
|
#beauticode-bg-stage .beauticode-media-slot img{z-index:0;opacity:1}
|
|
78
|
-
#beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity
|
|
86
|
+
#beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:.001}
|
|
79
87
|
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0}
|
|
80
88
|
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1}
|
|
81
89
|
@media (prefers-reduced-motion:reduce){#beauticode-bg-stage .beauticode-media-slot,#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{transition:none!important}}
|
|
@@ -127,12 +135,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
127
135
|
attributeFilter: ["data-ds-dark-theme"],
|
|
128
136
|
});
|
|
129
137
|
}
|
|
130
|
-
systemDarkMedia?.addEventListener("change", () => {
|
|
131
|
-
if (desiredModes.tone === "auto") {
|
|
132
|
-
syncDshTheme();
|
|
133
|
-
void acknowledgeMode();
|
|
134
|
-
}
|
|
135
|
-
});
|
|
138
|
+
systemDarkMedia?.addEventListener("change", () => {
|
|
139
|
+
if (desiredModes.tone === "auto") {
|
|
140
|
+
syncDshTheme();
|
|
141
|
+
void acknowledgeMode().catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
136
144
|
syncDshTheme();
|
|
137
145
|
|
|
138
146
|
function stage() {
|
|
@@ -184,8 +192,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
184
192
|
});
|
|
185
193
|
}
|
|
186
194
|
|
|
195
|
+
function mountedCurrentSlot() {
|
|
196
|
+
const node = document.getElementById("beauticode-bg-stage");
|
|
197
|
+
if (
|
|
198
|
+
!currentSlot ||
|
|
199
|
+
currentSlot.isConnected !== true ||
|
|
200
|
+
currentSlot.parentElement !== node ||
|
|
201
|
+
currentSlot.dataset.bcRole !== "current"
|
|
202
|
+
) {
|
|
203
|
+
currentSlot = null;
|
|
204
|
+
committedPayload = null;
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return currentSlot;
|
|
208
|
+
}
|
|
209
|
+
|
|
187
210
|
function activeVideo() {
|
|
188
|
-
const video =
|
|
211
|
+
const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
|
|
189
212
|
return video instanceof HTMLVideoElement ? video : null;
|
|
190
213
|
}
|
|
191
214
|
|
|
@@ -195,7 +218,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
195
218
|
'.beauticode-media-slot[data-bc-role="candidate"] video',
|
|
196
219
|
);
|
|
197
220
|
return video instanceof HTMLVideoElement ? video : null;
|
|
198
|
-
}
|
|
221
|
+
}
|
|
199
222
|
|
|
200
223
|
async function postAck(body) {
|
|
201
224
|
await fetch("/__beauticode/ack", {
|
|
@@ -267,47 +290,272 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
267
290
|
if (signal?.aborted) throw abortError();
|
|
268
291
|
}
|
|
269
292
|
|
|
270
|
-
function
|
|
271
|
-
|
|
272
|
-
image.
|
|
273
|
-
image.
|
|
293
|
+
function releaseImage(image, { remove = false } = {}) {
|
|
294
|
+
if (!image) return;
|
|
295
|
+
image.onload = null;
|
|
296
|
+
image.onerror = null;
|
|
297
|
+
try {
|
|
298
|
+
image.removeAttribute?.("src");
|
|
299
|
+
} catch {
|
|
300
|
+
image.src = "";
|
|
301
|
+
}
|
|
302
|
+
if (remove) image.remove?.();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function imageAttemptUrl(url, attempt) {
|
|
306
|
+
if (attempt === 0) return url;
|
|
307
|
+
try {
|
|
308
|
+
const retryUrl = new URL(url, globalThis.location?.href);
|
|
309
|
+
retryUrl.searchParams.set(
|
|
310
|
+
"bcImageRetry",
|
|
311
|
+
`${clientId}-${attempt}-${Date.now().toString(36)}`,
|
|
312
|
+
);
|
|
313
|
+
return retryUrl.href;
|
|
314
|
+
} catch {
|
|
315
|
+
return url;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function waitForImageAttempt(image, url, signal, timeoutMs) {
|
|
274
320
|
return new Promise((resolve, reject) => {
|
|
275
321
|
let settled = false;
|
|
276
|
-
let
|
|
322
|
+
let decodeStarted = false;
|
|
323
|
+
let decodeFallback = null;
|
|
277
324
|
const cleanup = () => {
|
|
278
|
-
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
if (decodeFallback) clearTimeout(decodeFallback);
|
|
279
327
|
signal?.removeEventListener?.("abort", aborted);
|
|
280
328
|
image.onload = null;
|
|
281
329
|
image.onerror = null;
|
|
282
330
|
};
|
|
283
|
-
const finish = (error) => {
|
|
331
|
+
const finish = (error = null) => {
|
|
284
332
|
if (settled) return;
|
|
285
333
|
settled = true;
|
|
286
334
|
cleanup();
|
|
287
335
|
if (error) reject(error);
|
|
288
|
-
else resolve(
|
|
336
|
+
else resolve();
|
|
289
337
|
};
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
image.
|
|
293
|
-
|
|
294
|
-
image.src = "";
|
|
338
|
+
const acceptLoaded = () => {
|
|
339
|
+
if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
|
|
340
|
+
if (image.complete) finish(new Error("图片尺寸无效"));
|
|
341
|
+
return;
|
|
295
342
|
}
|
|
296
|
-
|
|
343
|
+
if (!decodeStarted && typeof image.decode === "function") {
|
|
344
|
+
decodeStarted = true;
|
|
345
|
+
// decode() is a second readiness signal, but the per-attempt timer
|
|
346
|
+
// remains authoritative. A loaded image is still accepted after a
|
|
347
|
+
// short decode grace period if Chromium leaves decode() pending.
|
|
348
|
+
decodeFallback = setTimeout(() => {
|
|
349
|
+
if (
|
|
350
|
+
image.isConnected === true &&
|
|
351
|
+
image.complete &&
|
|
352
|
+
image.naturalWidth > 0 &&
|
|
353
|
+
image.naturalHeight > 0
|
|
354
|
+
) {
|
|
355
|
+
finish();
|
|
356
|
+
} else {
|
|
357
|
+
finish(new Error("图片解码未完成"));
|
|
358
|
+
}
|
|
359
|
+
}, 750);
|
|
360
|
+
let decodePromise;
|
|
361
|
+
try {
|
|
362
|
+
decodePromise = image.decode();
|
|
363
|
+
} catch {
|
|
364
|
+
finish(new Error("图片解码失败"));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
Promise.resolve(decodePromise).then(
|
|
368
|
+
() => finish(),
|
|
369
|
+
() => finish(new Error("图片解码失败")),
|
|
370
|
+
);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
finish();
|
|
297
374
|
};
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
timer = setTimeout(
|
|
375
|
+
const failed = () => finish(new Error("图片请求失败"));
|
|
376
|
+
const aborted = () => finish(abortError());
|
|
377
|
+
const timer = setTimeout(
|
|
378
|
+
() => finish(new Error("等待图片请求或解码超时")),
|
|
379
|
+
timeoutMs,
|
|
380
|
+
);
|
|
381
|
+
image.onload = acceptLoaded;
|
|
382
|
+
image.onerror = failed;
|
|
301
383
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
302
384
|
if (signal?.aborted) {
|
|
303
385
|
aborted();
|
|
304
386
|
return;
|
|
305
387
|
}
|
|
306
388
|
image.src = url;
|
|
389
|
+
if (image.complete) queueMicrotask(acceptLoaded);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function waitForRetryWindow(signal, timeoutMs) {
|
|
394
|
+
return new Promise((resolve, reject) => {
|
|
395
|
+
let settled = false;
|
|
396
|
+
const finish = (error = null) => {
|
|
397
|
+
if (settled) return;
|
|
398
|
+
settled = true;
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
401
|
+
if (error) reject(error);
|
|
402
|
+
else resolve();
|
|
403
|
+
};
|
|
404
|
+
const aborted = () => finish(abortError());
|
|
405
|
+
const timer = setTimeout(() => finish(), timeoutMs);
|
|
406
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
407
|
+
if (signal?.aborted) aborted();
|
|
307
408
|
});
|
|
308
409
|
}
|
|
410
|
+
|
|
411
|
+
async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
|
|
412
|
+
const deadline = performance.now() + timeoutMs;
|
|
413
|
+
const attempts = [];
|
|
414
|
+
let lastError = null;
|
|
415
|
+
|
|
416
|
+
const startAttempt = (attempt) => {
|
|
417
|
+
throwIfAborted(signal);
|
|
418
|
+
const remainingMs = deadline - performance.now();
|
|
419
|
+
if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
|
|
420
|
+
const controller = new AbortController();
|
|
421
|
+
const parentAborted = () => controller.abort();
|
|
422
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
423
|
+
const image = new Image();
|
|
424
|
+
image.alt = "";
|
|
425
|
+
image.decoding = "async";
|
|
426
|
+
image.className = "beauticode-media-poster";
|
|
427
|
+
slot.prepend(image);
|
|
428
|
+
const promise = waitForImageAttempt(
|
|
429
|
+
image,
|
|
430
|
+
imageAttemptUrl(url, attempt),
|
|
431
|
+
controller.signal,
|
|
432
|
+
Math.max(1, remainingMs),
|
|
433
|
+
)
|
|
434
|
+
.then(() => image)
|
|
435
|
+
.finally(() => signal?.removeEventListener?.("abort", parentAborted));
|
|
436
|
+
const record = { controller, image, promise };
|
|
437
|
+
attempts.push(record);
|
|
438
|
+
return record;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const cleanupAttempts = (winner = null) => {
|
|
442
|
+
for (const attempt of attempts) {
|
|
443
|
+
if (attempt.image === winner) continue;
|
|
444
|
+
attempt.controller.abort();
|
|
445
|
+
releaseImage(attempt.image, { remove: true });
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const first = startAttempt(0);
|
|
451
|
+
const firstWindow = await Promise.race([
|
|
452
|
+
first.promise.then(
|
|
453
|
+
(image) => ({ image }),
|
|
454
|
+
(error) => ({ error }),
|
|
455
|
+
),
|
|
456
|
+
waitForRetryWindow(
|
|
457
|
+
signal,
|
|
458
|
+
Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
|
|
459
|
+
).then(() => ({ retry: true })),
|
|
460
|
+
]);
|
|
461
|
+
if (firstWindow.image) {
|
|
462
|
+
cleanupAttempts(firstWindow.image);
|
|
463
|
+
return firstWindow.image;
|
|
464
|
+
}
|
|
465
|
+
if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
|
|
466
|
+
lastError = firstWindow.error ?? lastError;
|
|
467
|
+
|
|
468
|
+
// Keep the original request alive. The cache-busted retry runs beside it
|
|
469
|
+
// so a merely slow cold decode is never destroyed to recover a hung one.
|
|
470
|
+
if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
|
|
471
|
+
let winner;
|
|
472
|
+
try {
|
|
473
|
+
winner = await Promise.any(attempts.map((attempt) => attempt.promise));
|
|
474
|
+
} catch (error) {
|
|
475
|
+
const errors = Array.isArray(error?.errors) ? error.errors : [];
|
|
476
|
+
lastError = errors.at(-1) ?? lastError ?? error;
|
|
477
|
+
throw lastError;
|
|
478
|
+
}
|
|
479
|
+
cleanupAttempts(winner);
|
|
480
|
+
return winner;
|
|
481
|
+
} catch (error) {
|
|
482
|
+
cleanupAttempts();
|
|
483
|
+
if (isAbortError(error) || signal?.aborted) throw abortError();
|
|
484
|
+
lastError = error ?? lastError;
|
|
485
|
+
const visibility = document.visibilityState || "unknown";
|
|
486
|
+
const online = globalThis.navigator?.onLine === false ? "offline" : "online";
|
|
487
|
+
const phase = lastError?.message || "unknown";
|
|
488
|
+
const summary =
|
|
489
|
+
phase === "图片请求失败"
|
|
490
|
+
? "图片加载失败"
|
|
491
|
+
: phase.includes("超时")
|
|
492
|
+
? "等待图片加载超时"
|
|
493
|
+
: "图片校验失败";
|
|
494
|
+
throw new Error(
|
|
495
|
+
`${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
309
499
|
|
|
310
|
-
function
|
|
500
|
+
function safeOrigin(value) {
|
|
501
|
+
try {
|
|
502
|
+
return new URL(value, globalThis.location?.href).origin;
|
|
503
|
+
} catch {
|
|
504
|
+
return "unknown";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function videoRequestContext(url) {
|
|
509
|
+
return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) {
|
|
513
|
+
throwIfAborted(signal);
|
|
514
|
+
const controller = new AbortController();
|
|
515
|
+
const parentAborted = () => controller.abort();
|
|
516
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
517
|
+
let timedOut = false;
|
|
518
|
+
const timer = setTimeout(() => {
|
|
519
|
+
timedOut = true;
|
|
520
|
+
controller.abort();
|
|
521
|
+
}, Math.max(1, timeoutMs));
|
|
522
|
+
try {
|
|
523
|
+
const response = await fetch(url, {
|
|
524
|
+
method: "GET",
|
|
525
|
+
headers: { Range: "bytes=0-1" },
|
|
526
|
+
cache: "no-store",
|
|
527
|
+
credentials: "omit",
|
|
528
|
+
mode: "cors",
|
|
529
|
+
signal: controller.signal,
|
|
530
|
+
});
|
|
531
|
+
if (response.status !== 206) {
|
|
532
|
+
if (response.body) {
|
|
533
|
+
await response.body.cancel().catch(() => {});
|
|
534
|
+
}
|
|
535
|
+
throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`);
|
|
536
|
+
}
|
|
537
|
+
const bytes = await response.arrayBuffer();
|
|
538
|
+
if (bytes.byteLength !== 2) {
|
|
539
|
+
throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`);
|
|
540
|
+
}
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (signal?.aborted) throw abortError();
|
|
543
|
+
const context = videoRequestContext(url);
|
|
544
|
+
if (timedOut || isAbortError(error)) {
|
|
545
|
+
throw new Error(`视频媒体 Range 探针超时;${context}`);
|
|
546
|
+
}
|
|
547
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
548
|
+
if (error instanceof TypeError) {
|
|
549
|
+
throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`);
|
|
550
|
+
}
|
|
551
|
+
throw new Error(`${detail};${context}`);
|
|
552
|
+
} finally {
|
|
553
|
+
clearTimeout(timer);
|
|
554
|
+
signal?.removeEventListener?.("abort", parentAborted);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function describeVideoState(video, phase) {
|
|
311
559
|
const mediaErrorNames = {
|
|
312
560
|
1: "MEDIA_ERR_ABORTED",
|
|
313
561
|
2: "MEDIA_ERR_NETWORK",
|
|
@@ -316,17 +564,18 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
316
564
|
};
|
|
317
565
|
const code = Number(video?.error?.code) || 0;
|
|
318
566
|
const mediaError = code ? mediaErrorNames[code] || `MEDIA_ERR_${code}` : "none";
|
|
319
|
-
|
|
567
|
+
const src = video?.currentSrc || video?.src || "";
|
|
568
|
+
return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)};${videoRequestContext(src)}`;
|
|
320
569
|
}
|
|
321
570
|
|
|
322
571
|
function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
|
|
323
|
-
const
|
|
324
|
-
if (video.readyState >=
|
|
572
|
+
const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
|
|
573
|
+
if (video.readyState >= frameReadyState) return Promise.resolve();
|
|
325
574
|
return new Promise((resolve, reject) => {
|
|
326
575
|
let settled = false;
|
|
327
576
|
const done = () => finish();
|
|
328
577
|
const check = () => {
|
|
329
|
-
if (video.readyState >=
|
|
578
|
+
if (video.readyState >= frameReadyState) done();
|
|
330
579
|
};
|
|
331
580
|
const failed = () => {
|
|
332
581
|
finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
|
|
@@ -335,7 +584,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
335
584
|
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
|
|
336
585
|
};
|
|
337
586
|
const timer = setTimeout(() => {
|
|
338
|
-
finish(new Error(describeVideoState(video, "
|
|
587
|
+
finish(new Error(describeVideoState(video, "等待视频首帧超时")));
|
|
339
588
|
}, timeoutMs);
|
|
340
589
|
const cleanup = () => {
|
|
341
590
|
clearTimeout(timer);
|
|
@@ -402,7 +651,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
402
651
|
let lastFrameTime = null;
|
|
403
652
|
let playingSeen =
|
|
404
653
|
!video.paused &&
|
|
405
|
-
video.readyState >=
|
|
654
|
+
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
406
655
|
let settled = false;
|
|
407
656
|
let frameRequest = null;
|
|
408
657
|
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
@@ -413,6 +662,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
413
662
|
video.removeEventListener("timeupdate", check);
|
|
414
663
|
video.removeEventListener("waiting", resetStableWindow);
|
|
415
664
|
video.removeEventListener("stalled", resetStableWindow);
|
|
665
|
+
video.removeEventListener("pause", resetStableWindow);
|
|
666
|
+
video.removeEventListener("seeking", resetStableWindow);
|
|
416
667
|
video.removeEventListener("error", failed);
|
|
417
668
|
video.removeEventListener("abort", aborted);
|
|
418
669
|
signal?.removeEventListener?.("abort", aborted);
|
|
@@ -465,7 +716,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
465
716
|
return;
|
|
466
717
|
}
|
|
467
718
|
observeProgress();
|
|
468
|
-
if (!video.paused && video.readyState >=
|
|
719
|
+
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
469
720
|
playingSeen = true;
|
|
470
721
|
}
|
|
471
722
|
const framesReady = hasFrameCallback
|
|
@@ -508,6 +759,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
508
759
|
video.addEventListener("timeupdate", check);
|
|
509
760
|
video.addEventListener("waiting", resetStableWindow);
|
|
510
761
|
video.addEventListener("stalled", resetStableWindow);
|
|
762
|
+
video.addEventListener("pause", resetStableWindow);
|
|
763
|
+
video.addEventListener("seeking", resetStableWindow);
|
|
511
764
|
video.addEventListener("error", failed, { once: true });
|
|
512
765
|
video.addEventListener("abort", aborted, { once: true });
|
|
513
766
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
@@ -536,62 +789,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
536
789
|
|
|
537
790
|
async function playWithPreference(video, signal = null) {
|
|
538
791
|
throwIfAborted(signal);
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
792
|
+
const requestedMuted = desiredModes.muted;
|
|
793
|
+
let blocked = false;
|
|
794
|
+
video.muted = requestedMuted;
|
|
795
|
+
try {
|
|
796
|
+
await video.play();
|
|
797
|
+
} catch (error) {
|
|
798
|
+
throwIfAborted(signal);
|
|
799
|
+
if (requestedMuted) throw error;
|
|
800
|
+
blocked = true;
|
|
546
801
|
video.muted = true;
|
|
547
802
|
await video.play();
|
|
548
803
|
}
|
|
549
804
|
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 };
|
|
805
|
+
video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
|
|
806
|
+
return blocked;
|
|
586
807
|
}
|
|
587
808
|
|
|
588
809
|
async function startCandidatePlayback(video, signal, timeoutMs) {
|
|
589
|
-
const
|
|
810
|
+
const playbackController = new AbortController();
|
|
811
|
+
const parentAborted = () => playbackController.abort();
|
|
812
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
813
|
+
let timer = null;
|
|
814
|
+
let abortListener = null;
|
|
815
|
+
const playback = playWithPreference(video, playbackController.signal);
|
|
816
|
+
const deadline = new Promise((_, reject) => {
|
|
817
|
+
timer = setTimeout(() => {
|
|
818
|
+
reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
|
|
819
|
+
playbackController.abort();
|
|
820
|
+
}, timeoutMs);
|
|
821
|
+
abortListener = () => reject(abortError());
|
|
822
|
+
playbackController.signal.addEventListener("abort", abortListener, { once: true });
|
|
823
|
+
});
|
|
824
|
+
if (signal?.aborted) parentAborted();
|
|
590
825
|
try {
|
|
591
|
-
|
|
826
|
+
// The play() promise itself resolves only after playback has started, so
|
|
827
|
+
// a second playing-event waiter would create an orphaned promise race.
|
|
828
|
+
return await Promise.race([playback, deadline]);
|
|
592
829
|
} catch (error) {
|
|
593
|
-
|
|
830
|
+
playbackController.abort();
|
|
831
|
+
try {
|
|
832
|
+
video.pause();
|
|
833
|
+
} catch {
|
|
834
|
+
/* best effort: disposeSlot will release src as the final guard */
|
|
835
|
+
}
|
|
594
836
|
throw error;
|
|
837
|
+
} finally {
|
|
838
|
+
if (timer) clearTimeout(timer);
|
|
839
|
+
signal?.removeEventListener?.("abort", parentAborted);
|
|
840
|
+
if (abortListener) {
|
|
841
|
+
playbackController.signal.removeEventListener("abort", abortListener);
|
|
842
|
+
}
|
|
595
843
|
}
|
|
596
844
|
}
|
|
597
845
|
|
|
@@ -623,14 +871,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
623
871
|
if (!slot) return;
|
|
624
872
|
for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
|
|
625
873
|
for (const image of slot.querySelectorAll?.("img") ?? []) {
|
|
626
|
-
image
|
|
627
|
-
image.onerror = null;
|
|
628
|
-
image.removeAttribute?.("src");
|
|
874
|
+
releaseImage(image);
|
|
629
875
|
}
|
|
630
876
|
slot.remove?.();
|
|
631
877
|
}
|
|
632
878
|
|
|
633
|
-
function createSlot(payload,
|
|
879
|
+
function createSlot(payload, video = null) {
|
|
634
880
|
const slot = document.createElement("div");
|
|
635
881
|
slot.className = "beauticode-media-slot";
|
|
636
882
|
slot.dataset.bcRole = "candidate";
|
|
@@ -638,8 +884,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
638
884
|
slot.dataset.bcGeneration = String(payload.generation);
|
|
639
885
|
slot.dataset.bcImageUrl = payload.imageUrl;
|
|
640
886
|
if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
|
|
641
|
-
|
|
642
|
-
|
|
887
|
+
if (payload.media === "video") {
|
|
888
|
+
const startAt = Number(payload.startAt);
|
|
889
|
+
slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
|
|
890
|
+
}
|
|
643
891
|
if (video) {
|
|
644
892
|
video.className = "beauticode-media-video";
|
|
645
893
|
slot.append(video);
|
|
@@ -648,9 +896,36 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
648
896
|
}
|
|
649
897
|
|
|
650
898
|
function slotMatchesPayload(slot, payload) {
|
|
651
|
-
if (!slot || slot.dataset.bcMedia !== payload.media)
|
|
899
|
+
if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
652
902
|
if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
|
|
653
|
-
|
|
903
|
+
if (payload.media !== "video") {
|
|
904
|
+
const image = slot.querySelector?.("img");
|
|
905
|
+
return Boolean(
|
|
906
|
+
image?.isConnected === true &&
|
|
907
|
+
image.complete &&
|
|
908
|
+
image.naturalWidth > 0 &&
|
|
909
|
+
image.naturalHeight > 0,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
|
|
913
|
+
const video = slot.querySelector?.("video");
|
|
914
|
+
if (
|
|
915
|
+
!(video instanceof HTMLVideoElement) ||
|
|
916
|
+
video.error ||
|
|
917
|
+
video.ended ||
|
|
918
|
+
video.paused ||
|
|
919
|
+
video.seeking ||
|
|
920
|
+
video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
|
921
|
+
) {
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
const requestedStartAt = Number(payload.startAt);
|
|
925
|
+
const normalizedStartAt =
|
|
926
|
+
Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
|
|
927
|
+
const appliedStartAt = Number(slot.dataset.bcStartAt);
|
|
928
|
+
return Number.isFinite(appliedStartAt) && Math.abs(appliedStartAt - normalizedStartAt) < 0.25;
|
|
654
929
|
}
|
|
655
930
|
|
|
656
931
|
function updateCommittedDom(payload) {
|
|
@@ -682,9 +957,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
682
957
|
function attachCandidate(slot, payload) {
|
|
683
958
|
const node = stage();
|
|
684
959
|
discardCandidates();
|
|
960
|
+
const previous = mountedCurrentSlot();
|
|
685
961
|
slot.dataset.bcRole = "candidate";
|
|
686
962
|
node.append(slot);
|
|
687
|
-
if (!
|
|
963
|
+
if (!previous) {
|
|
688
964
|
node.dataset.bcEmpty = "true";
|
|
689
965
|
document.documentElement.dataset.bcActive = "true";
|
|
690
966
|
document.documentElement.dataset.bcMedia =
|
|
@@ -696,17 +972,34 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
696
972
|
function nextFrame(signal) {
|
|
697
973
|
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
|
|
698
974
|
return new Promise((resolve, reject) => {
|
|
975
|
+
let settled = false;
|
|
699
976
|
let request = null;
|
|
700
|
-
|
|
701
|
-
|
|
977
|
+
let timer = null;
|
|
978
|
+
const cleanup = () => {
|
|
979
|
+
if (timer) clearTimeout(timer);
|
|
980
|
+
if (request != null && typeof cancelAnimationFrame === "function") {
|
|
981
|
+
cancelAnimationFrame(request);
|
|
982
|
+
}
|
|
702
983
|
signal?.removeEventListener?.("abort", aborted);
|
|
703
|
-
reject(abortError());
|
|
704
984
|
};
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
985
|
+
const finish = (error = null) => {
|
|
986
|
+
if (settled) return;
|
|
987
|
+
settled = true;
|
|
988
|
+
cleanup();
|
|
989
|
+
if (error) reject(error);
|
|
708
990
|
else resolve();
|
|
991
|
+
};
|
|
992
|
+
const aborted = () => {
|
|
993
|
+
finish(abortError());
|
|
994
|
+
};
|
|
995
|
+
request = requestAnimationFrame(() => {
|
|
996
|
+
request = null;
|
|
997
|
+
if (signal?.aborted) finish(abortError());
|
|
998
|
+
else finish();
|
|
709
999
|
});
|
|
1000
|
+
// Background or power-saved Chromium pages may suspend rAF entirely.
|
|
1001
|
+
// A short timer keeps the transaction bounded without skipping aborts.
|
|
1002
|
+
timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
|
|
710
1003
|
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
711
1004
|
if (signal?.aborted) aborted();
|
|
712
1005
|
});
|
|
@@ -739,16 +1032,21 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
739
1032
|
});
|
|
740
1033
|
}
|
|
741
1034
|
|
|
742
|
-
async function commitCandidate(payload, slot, signal) {
|
|
1035
|
+
async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
|
|
743
1036
|
throwIfAborted(signal);
|
|
744
1037
|
const node = stage();
|
|
745
|
-
const previous =
|
|
746
|
-
|
|
1038
|
+
const previous = mountedCurrentSlot();
|
|
1039
|
+
const canAnimate =
|
|
1040
|
+
previous &&
|
|
1041
|
+
reducedMotionMedia?.matches !== true &&
|
|
1042
|
+
document.visibilityState === "visible" &&
|
|
1043
|
+
remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
|
|
1044
|
+
if (canAnimate) {
|
|
747
1045
|
await nextFrame(signal);
|
|
748
1046
|
await nextFrame(signal);
|
|
749
1047
|
throwIfAborted(signal);
|
|
750
1048
|
node.dataset.bcTransitioning = "true";
|
|
751
|
-
await waitForCrossfade(
|
|
1049
|
+
await waitForCrossfade(previous, signal);
|
|
752
1050
|
}
|
|
753
1051
|
throwIfAborted(signal);
|
|
754
1052
|
if (previous && previous !== slot) disposeSlot(previous);
|
|
@@ -767,7 +1065,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
767
1065
|
const node = document.getElementById("beauticode-bg-stage");
|
|
768
1066
|
node?.removeAttribute("data-bc-transitioning");
|
|
769
1067
|
node?.removeAttribute("data-bc-empty");
|
|
770
|
-
if (committedPayload &&
|
|
1068
|
+
if (committedPayload && mountedCurrentSlot()) {
|
|
771
1069
|
updateCommittedDom(committedPayload);
|
|
772
1070
|
playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
|
|
773
1071
|
return;
|
|
@@ -779,12 +1077,19 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
779
1077
|
}
|
|
780
1078
|
|
|
781
1079
|
function syncGallery(payload) {
|
|
782
|
-
const on = payload?.atmosphere?.preset === "gallery";
|
|
783
|
-
|
|
784
|
-
|
|
1080
|
+
const on = payload?.atmosphere?.preset === "gallery";
|
|
1081
|
+
try {
|
|
1082
|
+
const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
|
|
1083
|
+
if (sync && typeof sync.then === "function") void sync.catch(() => {});
|
|
1084
|
+
} catch {
|
|
1085
|
+
/* Atmosphere is optional and must never invalidate a rendered background. */
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
785
1088
|
|
|
786
1089
|
async function applyBackground(payload, signal) {
|
|
787
1090
|
throwIfAborted(signal);
|
|
1091
|
+
const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
|
|
1092
|
+
const remaining = () => Math.max(1, applyDeadline - performance.now());
|
|
788
1093
|
if (payload.media === "clear") {
|
|
789
1094
|
desiredModes.fish = false;
|
|
790
1095
|
playbackBlocked = false;
|
|
@@ -803,13 +1108,20 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
803
1108
|
await acknowledgeMode();
|
|
804
1109
|
return;
|
|
805
1110
|
}
|
|
806
|
-
if (typeof payload.imageUrl !== "string")
|
|
807
|
-
|
|
808
|
-
if (structureIssue) {
|
|
809
|
-
await acknowledgeRender(payload, false, false, structureIssue);
|
|
1111
|
+
if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
|
|
1112
|
+
await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
|
|
810
1113
|
return;
|
|
811
1114
|
}
|
|
812
|
-
|
|
1115
|
+
let reusable = slotMatchesPayload(currentSlot, payload);
|
|
1116
|
+
if (reusable && payload.media === "video") {
|
|
1117
|
+
try {
|
|
1118
|
+
await waitForStablePlayback(activeVideo(), signal, Math.min(750, remaining()));
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
if (isAbortError(error) || signal?.aborted) throw error;
|
|
1121
|
+
reusable = false;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (reusable) {
|
|
813
1125
|
committedPayload = payload;
|
|
814
1126
|
currentSlot.dataset.bcGeneration = String(payload.generation);
|
|
815
1127
|
updateCommittedDom(payload);
|
|
@@ -819,17 +1131,17 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
819
1131
|
return;
|
|
820
1132
|
}
|
|
821
1133
|
let candidate = null;
|
|
1134
|
+
let candidateVideo = null;
|
|
822
1135
|
let committed = false;
|
|
823
1136
|
try {
|
|
824
|
-
await waitForDshStructure(signal);
|
|
825
|
-
const image = await loadImage(payload.imageUrl, signal);
|
|
826
|
-
throwIfAborted(signal);
|
|
1137
|
+
await waitForDshStructure(signal, remaining());
|
|
827
1138
|
if (payload.media === "image") {
|
|
828
|
-
|
|
829
|
-
playbackBlocked = false;
|
|
830
|
-
candidate = createSlot(payload, image);
|
|
1139
|
+
candidate = createSlot(payload);
|
|
831
1140
|
attachCandidate(candidate, payload);
|
|
832
|
-
await
|
|
1141
|
+
const image = await loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1142
|
+
throwIfAborted(signal);
|
|
1143
|
+
if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
|
|
1144
|
+
await commitCandidate(payload, candidate, signal, remaining);
|
|
833
1145
|
committed = true;
|
|
834
1146
|
await acknowledgeRender(payload, true, true);
|
|
835
1147
|
await acknowledgeMode();
|
|
@@ -838,33 +1150,55 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
838
1150
|
if (payload.media !== "video" || typeof payload.videoUrl !== "string") {
|
|
839
1151
|
throw new Error("视频载荷无效");
|
|
840
1152
|
}
|
|
1153
|
+
await probeVideoSource(
|
|
1154
|
+
payload.videoUrl,
|
|
1155
|
+
signal,
|
|
1156
|
+
Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
|
|
1157
|
+
);
|
|
1158
|
+
throwIfAborted(signal);
|
|
841
1159
|
const video = document.createElement("video");
|
|
842
|
-
video.autoplay =
|
|
1160
|
+
video.autoplay = true;
|
|
843
1161
|
video.loop = true;
|
|
844
1162
|
video.playsInline = true;
|
|
845
1163
|
video.preload = "auto";
|
|
846
|
-
video.
|
|
847
|
-
|
|
848
|
-
|
|
1164
|
+
video.crossOrigin = "anonymous";
|
|
1165
|
+
// Set mute before src so Chromium may start the request immediately
|
|
1166
|
+
// without waiting for an audible autoplay decision.
|
|
1167
|
+
video.defaultMuted = true;
|
|
1168
|
+
video.muted = desiredModes.muted;
|
|
1169
|
+
video.setAttribute("muted", "");
|
|
1170
|
+
candidate = createSlot(payload, video);
|
|
849
1171
|
attachCandidate(candidate, payload);
|
|
1172
|
+
candidateVideo = video;
|
|
1173
|
+
video.src = payload.videoUrl;
|
|
850
1174
|
video.load();
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
1175
|
+
const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1176
|
+
// play() is deliberately started alongside media readiness. Chromium is
|
|
1177
|
+
// allowed to ignore preload=auto for hidden/background media; waiting for
|
|
1178
|
+
// canplay before play() creates a circular stall at readyState=0.
|
|
1179
|
+
await Promise.all([
|
|
1180
|
+
imageReady,
|
|
1181
|
+
waitForVideo(video, signal, remaining()),
|
|
1182
|
+
startCandidatePlayback(video, signal, remaining()),
|
|
1183
|
+
]);
|
|
854
1184
|
throwIfAborted(signal);
|
|
855
|
-
seekVideo(video, payload.startAt);
|
|
1185
|
+
const appliedStartAt = seekVideo(video, payload.startAt);
|
|
1186
|
+
candidate.dataset.bcStartAt = String(appliedStartAt);
|
|
856
1187
|
await waitForSeek(video, signal, remaining());
|
|
857
1188
|
await waitForVideo(video, signal, remaining());
|
|
858
|
-
await startCandidatePlayback(video, signal, remaining());
|
|
859
1189
|
await waitForStablePlayback(video, signal, remaining());
|
|
860
1190
|
throwIfAborted(signal);
|
|
861
1191
|
candidate.dataset.bcVideoReady = "true";
|
|
862
|
-
await commitCandidate(payload, candidate, signal);
|
|
1192
|
+
await commitCandidate(payload, candidate, signal, remaining);
|
|
863
1193
|
committed = true;
|
|
864
1194
|
const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
|
|
865
1195
|
await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
|
|
866
1196
|
await acknowledgeMode();
|
|
867
1197
|
} catch (error) {
|
|
1198
|
+
if (candidateVideo && candidateVideo.parentElement !== candidate) {
|
|
1199
|
+
disposeVideo(candidateVideo);
|
|
1200
|
+
candidateVideo.remove?.();
|
|
1201
|
+
}
|
|
868
1202
|
if (candidate && !committed) disposeSlot(candidate);
|
|
869
1203
|
if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
|
|
870
1204
|
restoreCommittedDom();
|
|
@@ -879,6 +1213,22 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
879
1213
|
|
|
880
1214
|
function scheduleBackground(payload) {
|
|
881
1215
|
if (!Number.isSafeInteger(payload?.generation)) return;
|
|
1216
|
+
// EventSource reconnects can replay the current frame while its first
|
|
1217
|
+
// render is still in flight. Restarting the same generation would discard
|
|
1218
|
+
// a healthy cold media request and move its deadline indefinitely.
|
|
1219
|
+
if (
|
|
1220
|
+
applyController &&
|
|
1221
|
+
activePayload?.generation === payload.generation &&
|
|
1222
|
+
activePayload?.media === payload.media
|
|
1223
|
+
) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (
|
|
1227
|
+
Number.isSafeInteger(activePayload?.generation) &&
|
|
1228
|
+
payload.generation < activePayload.generation
|
|
1229
|
+
) {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
882
1232
|
applyController?.abort();
|
|
883
1233
|
discardCandidates();
|
|
884
1234
|
const controller = new AbortController();
|
|
@@ -886,11 +1236,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
886
1236
|
activePayload = payload;
|
|
887
1237
|
renderPhase = "pending";
|
|
888
1238
|
document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
|
|
889
|
-
void applyBackground(payload, controller.signal)
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1239
|
+
void applyBackground(payload, controller.signal)
|
|
1240
|
+
.catch(async (error) => {
|
|
1241
|
+
if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return;
|
|
1242
|
+
restoreCommittedDom();
|
|
1243
|
+
await acknowledgeRender(
|
|
1244
|
+
payload,
|
|
1245
|
+
false,
|
|
1246
|
+
Boolean(mountedCurrentSlot()),
|
|
1247
|
+
error instanceof Error ? error.message : String(error),
|
|
1248
|
+
);
|
|
1249
|
+
})
|
|
1250
|
+
.finally(() => {
|
|
1251
|
+
if (applyController !== controller) return;
|
|
1252
|
+
applyController = null;
|
|
1253
|
+
document.documentElement.removeAttribute("data-bc-pending-generation");
|
|
1254
|
+
})
|
|
1255
|
+
.catch(() => {});
|
|
894
1256
|
}
|
|
895
1257
|
|
|
896
1258
|
async function applyModes(payload) {
|
|
@@ -908,8 +1270,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
908
1270
|
const candidate = pendingVideo();
|
|
909
1271
|
if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
|
|
910
1272
|
if (video instanceof HTMLVideoElement) {
|
|
911
|
-
try {
|
|
912
|
-
await playWithPreference(video);
|
|
1273
|
+
try {
|
|
1274
|
+
playbackBlocked = await playWithPreference(video);
|
|
913
1275
|
} catch {
|
|
914
1276
|
playbackBlocked = desiredModes.muted === false;
|
|
915
1277
|
}
|
|
@@ -932,25 +1294,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
932
1294
|
events.onmessage = (event) => {
|
|
933
1295
|
try {
|
|
934
1296
|
const payload = JSON.parse(event.data);
|
|
935
|
-
if (payload?.type === "mode") void applyModes(payload);
|
|
1297
|
+
if (payload?.type === "mode") void applyModes(payload).catch(() => {});
|
|
936
1298
|
else if (payload?.type === "apply") scheduleBackground(payload);
|
|
937
1299
|
} catch {
|
|
938
1300
|
/* EventSource will continue with the next valid frame. */
|
|
939
1301
|
}
|
|
940
1302
|
};
|
|
941
1303
|
|
|
942
|
-
setInterval(() => {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1304
|
+
setInterval(() => {
|
|
1305
|
+
if (
|
|
1306
|
+
!committedPayload ||
|
|
1307
|
+
activePayload !== committedPayload ||
|
|
1308
|
+
renderPhase !== "ready"
|
|
1309
|
+
) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (committedPayload.media === "clear") {
|
|
1313
|
+
void acknowledgeRender(committedPayload, true, false).catch(() => {});
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const slot = mountedCurrentSlot();
|
|
1317
|
+
if (!slot) return;
|
|
1318
|
+
if (committedPayload.media === "image") {
|
|
1319
|
+
const image = slot.querySelector?.("img");
|
|
1320
|
+
if (
|
|
1321
|
+
image?.isConnected === true &&
|
|
1322
|
+
image.complete &&
|
|
1323
|
+
image.naturalWidth > 0 &&
|
|
1324
|
+
image.naturalHeight > 0
|
|
1325
|
+
) {
|
|
1326
|
+
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
const video = activeVideo();
|
|
1331
|
+
if (
|
|
1332
|
+
committedPayload.media === "video" &&
|
|
1333
|
+
video instanceof HTMLVideoElement
|
|
1334
|
+
) {
|
|
950
1335
|
// A heartbeat is observational, not a second render verdict. Playback can
|
|
951
1336
|
// briefly pause while Chromium changes modes or refills an 8K buffer; do
|
|
952
1337
|
// not downgrade an already-rendered generation or fail a pending one.
|
|
953
|
-
void acknowledgeRender(committedPayload, true, true);
|
|
1338
|
+
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
954
1339
|
}
|
|
955
1340
|
}, 1_000);
|
|
956
1341
|
})();
|
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
|
}
|
package/package.json
CHANGED
|
@@ -13,6 +13,18 @@ export function normalizeDshBaseUrl(value) {
|
|
|
13
13
|
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
14
14
|
return url;
|
|
15
15
|
}
|
|
16
|
+
export function dshTrustedOrigins(value) {
|
|
17
|
+
const baseUrl = normalizeDshBaseUrl(String(value));
|
|
18
|
+
const port = baseUrl.port ? `:${baseUrl.port}` : "";
|
|
19
|
+
return [
|
|
20
|
+
...new Set([
|
|
21
|
+
baseUrl.origin,
|
|
22
|
+
new URL(`http://127.0.0.1${port}`).origin,
|
|
23
|
+
new URL(`http://localhost${port}`).origin,
|
|
24
|
+
new URL(`http://[::1]${port}`).origin,
|
|
25
|
+
]),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
16
28
|
function delay(ms) {
|
|
17
29
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
30
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { DshHostApplier, normalizeDshBaseUrl, } from "./bridge.js";
|
|
1
|
+
export { DshHostApplier, dshTrustedOrigins, normalizeDshBaseUrl, } from "./bridge.js";
|
|
2
2
|
export { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js";
|
|
3
3
|
export { DshSession } from "./session.js";
|
|
4
4
|
export { DSH_BRIDGE_TOKEN_FILE, bridgeTokenPath, ensureBridgeToken, } from "./token.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { ApplyTransaction, BackgroundStore, MediaServerController, buildHostApplyPayload, defaultDataRoot, isLocalBackgroundSource, resolveSessionBundledThemes, resolveBackgroundImagePath, } from "../core/index.js";
|
|
4
|
-
import { DshHostApplier, normalizeDshBaseUrl } from "./bridge.js";
|
|
4
|
+
import { DshHostApplier, dshTrustedOrigins, normalizeDshBaseUrl } from "./bridge.js";
|
|
5
5
|
import { DSH_HOST_DESCRIPTOR } from "./host-descriptor.js";
|
|
6
6
|
import { acquireDshInjectorLock } from "./injector-lock.js";
|
|
7
7
|
import { ensureBridgeToken } from "./token.js";
|
|
@@ -54,7 +54,7 @@ export class DshSession {
|
|
|
54
54
|
});
|
|
55
55
|
this.media = new MediaServerController({
|
|
56
56
|
enabled: true,
|
|
57
|
-
trustedOrigins:
|
|
57
|
+
trustedOrigins: dshTrustedOrigins(this.baseUrl),
|
|
58
58
|
});
|
|
59
59
|
this.onError = opts.onError ?? null;
|
|
60
60
|
this.onStatus = opts.onStatus ?? null;
|