beauticode-dsh 1.0.18 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +188 -12
- 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/client.js
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
const IMAGE_ATTEMPT_TIMEOUT_MS = 3_000;
|
|
17
17
|
const IMAGE_MAX_ATTEMPTS = 2;
|
|
18
18
|
const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
19
|
+
const VIDEO_PROBE_TIMEOUT_MS = 2_000;
|
|
19
20
|
const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
20
21
|
const FRAME_FALLBACK_MS = 120;
|
|
22
|
+
const VIDEO_FIRST_FRAME_PROGRESS_SEC = 0.03;
|
|
21
23
|
const VIDEO_STABLE_FRAMES = 3;
|
|
22
24
|
const VIDEO_STABLE_PROGRESS_SEC = 0.18;
|
|
23
25
|
const CROSSFADE_MS = 180;
|
|
@@ -81,8 +83,11 @@ html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!imp
|
|
|
81
83
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
|
|
82
84
|
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
|
|
83
85
|
#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease}
|
|
84
|
-
#beauticode-bg-stage .beauticode-media-slot img{z-index:
|
|
85
|
-
|
|
86
|
+
#beauticode-bg-stage .beauticode-media-slot img{z-index:2;opacity:1}
|
|
87
|
+
/* Keep cold candidate video paintable. Chromium may defer decoding media that
|
|
88
|
+
is nearly transparent, which deadlocks the first-frame gate. The poster
|
|
89
|
+
covers it until data-bc-video-ready is committed. */
|
|
90
|
+
#beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:1}
|
|
86
91
|
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0}
|
|
87
92
|
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1}
|
|
88
93
|
@media (prefers-reduced-motion:reduce){#beauticode-bg-stage .beauticode-media-slot,#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{transition:none!important}}
|
|
@@ -496,7 +501,65 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
496
501
|
}
|
|
497
502
|
}
|
|
498
503
|
|
|
499
|
-
function
|
|
504
|
+
function safeOrigin(value) {
|
|
505
|
+
try {
|
|
506
|
+
return new URL(value, globalThis.location?.href).origin;
|
|
507
|
+
} catch {
|
|
508
|
+
return "unknown";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function videoRequestContext(url) {
|
|
513
|
+
return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) {
|
|
517
|
+
throwIfAborted(signal);
|
|
518
|
+
const controller = new AbortController();
|
|
519
|
+
const parentAborted = () => controller.abort();
|
|
520
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
521
|
+
let timedOut = false;
|
|
522
|
+
const timer = setTimeout(() => {
|
|
523
|
+
timedOut = true;
|
|
524
|
+
controller.abort();
|
|
525
|
+
}, Math.max(1, timeoutMs));
|
|
526
|
+
try {
|
|
527
|
+
const response = await fetch(url, {
|
|
528
|
+
method: "GET",
|
|
529
|
+
headers: { Range: "bytes=0-1" },
|
|
530
|
+
cache: "no-store",
|
|
531
|
+
credentials: "omit",
|
|
532
|
+
mode: "cors",
|
|
533
|
+
signal: controller.signal,
|
|
534
|
+
});
|
|
535
|
+
if (response.status !== 206) {
|
|
536
|
+
if (response.body) {
|
|
537
|
+
await response.body.cancel().catch(() => {});
|
|
538
|
+
}
|
|
539
|
+
throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`);
|
|
540
|
+
}
|
|
541
|
+
const bytes = await response.arrayBuffer();
|
|
542
|
+
if (bytes.byteLength !== 2) {
|
|
543
|
+
throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`);
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (signal?.aborted) throw abortError();
|
|
547
|
+
const context = videoRequestContext(url);
|
|
548
|
+
if (timedOut || isAbortError(error)) {
|
|
549
|
+
throw new Error(`视频媒体 Range 探针超时;${context}`);
|
|
550
|
+
}
|
|
551
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
552
|
+
if (error instanceof TypeError) {
|
|
553
|
+
throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`);
|
|
554
|
+
}
|
|
555
|
+
throw new Error(`${detail};${context}`);
|
|
556
|
+
} finally {
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
signal?.removeEventListener?.("abort", parentAborted);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function describeVideoState(video, phase) {
|
|
500
563
|
const mediaErrorNames = {
|
|
501
564
|
1: "MEDIA_ERR_ABORTED",
|
|
502
565
|
2: "MEDIA_ERR_NETWORK",
|
|
@@ -505,7 +568,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
505
568
|
};
|
|
506
569
|
const code = Number(video?.error?.code) || 0;
|
|
507
570
|
const mediaError = code ? mediaErrorNames[code] || `MEDIA_ERR_${code}` : "none";
|
|
508
|
-
|
|
571
|
+
const src = video?.currentSrc || video?.src || "";
|
|
572
|
+
return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)};${videoRequestContext(src)}`;
|
|
509
573
|
}
|
|
510
574
|
|
|
511
575
|
function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
|
|
@@ -581,6 +645,89 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
581
645
|
});
|
|
582
646
|
}
|
|
583
647
|
|
|
648
|
+
function waitForPresentedFrame(video, signal, timeoutMs) {
|
|
649
|
+
return new Promise((resolve, reject) => {
|
|
650
|
+
const startedAt = performance.now();
|
|
651
|
+
const initialTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
|
|
652
|
+
let frameSeen = false;
|
|
653
|
+
let playingSeen =
|
|
654
|
+
!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
655
|
+
let settled = false;
|
|
656
|
+
let frameRequest = null;
|
|
657
|
+
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
658
|
+
|
|
659
|
+
const cleanup = () => {
|
|
660
|
+
clearInterval(timer);
|
|
661
|
+
video.removeEventListener("playing", onPlaying);
|
|
662
|
+
video.removeEventListener("timeupdate", check);
|
|
663
|
+
video.removeEventListener("loadeddata", check);
|
|
664
|
+
video.removeEventListener("canplay", check);
|
|
665
|
+
video.removeEventListener("error", failed);
|
|
666
|
+
video.removeEventListener("abort", aborted);
|
|
667
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
668
|
+
if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
669
|
+
video.cancelVideoFrameCallback(frameRequest);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
const finish = (error) => {
|
|
673
|
+
if (settled) return;
|
|
674
|
+
settled = true;
|
|
675
|
+
cleanup();
|
|
676
|
+
if (error) reject(error);
|
|
677
|
+
else resolve();
|
|
678
|
+
};
|
|
679
|
+
const failed = () => {
|
|
680
|
+
finish(new Error(describeVideoState(video, "视频解码器报告失败")));
|
|
681
|
+
};
|
|
682
|
+
const aborted = () => {
|
|
683
|
+
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
|
|
684
|
+
};
|
|
685
|
+
const check = () => {
|
|
686
|
+
if (settled) return;
|
|
687
|
+
if (video.error) {
|
|
688
|
+
failed();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
692
|
+
playingSeen = true;
|
|
693
|
+
}
|
|
694
|
+
const current = Number.isFinite(video.currentTime) ? video.currentTime : initialTime;
|
|
695
|
+
const progressed = current >= initialTime + VIDEO_FIRST_FRAME_PROGRESS_SEC;
|
|
696
|
+
if (
|
|
697
|
+
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
|
698
|
+
!video.paused &&
|
|
699
|
+
playingSeen &&
|
|
700
|
+
(frameSeen || progressed)
|
|
701
|
+
) {
|
|
702
|
+
finish();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (performance.now() - startedAt >= timeoutMs) {
|
|
706
|
+
finish(new Error(describeVideoState(video, "视频未在首帧窗口内完成呈现")));
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
const onPlaying = () => {
|
|
710
|
+
playingSeen = true;
|
|
711
|
+
check();
|
|
712
|
+
};
|
|
713
|
+
const onFrame = () => {
|
|
714
|
+
frameSeen = true;
|
|
715
|
+
check();
|
|
716
|
+
};
|
|
717
|
+
const timer = setInterval(check, 80);
|
|
718
|
+
video.addEventListener("playing", onPlaying);
|
|
719
|
+
video.addEventListener("timeupdate", check);
|
|
720
|
+
video.addEventListener("loadeddata", check);
|
|
721
|
+
video.addEventListener("canplay", check);
|
|
722
|
+
video.addEventListener("error", failed, { once: true });
|
|
723
|
+
video.addEventListener("abort", aborted, { once: true });
|
|
724
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
725
|
+
if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
|
|
726
|
+
if (signal?.aborted) aborted();
|
|
727
|
+
check();
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
584
731
|
function waitForStablePlayback(video, signal, timeoutMs) {
|
|
585
732
|
return new Promise((resolve, reject) => {
|
|
586
733
|
const startedAt = performance.now();
|
|
@@ -839,8 +986,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
839
986
|
if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
|
|
840
987
|
return false;
|
|
841
988
|
}
|
|
842
|
-
if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
|
|
843
989
|
if (payload.media !== "video") {
|
|
990
|
+
if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
|
|
844
991
|
const image = slot.querySelector?.("img");
|
|
845
992
|
return Boolean(
|
|
846
993
|
image?.isConnected === true &&
|
|
@@ -861,11 +1008,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
861
1008
|
) {
|
|
862
1009
|
return false;
|
|
863
1010
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
return Number.isFinite(appliedStartAt) && Math.abs(appliedStartAt - normalizedStartAt) < 0.25;
|
|
1011
|
+
// A local re-import can keep the exact same video handle while producing a
|
|
1012
|
+
// new poster token and resetting startAt. Reuse the live decoder; the
|
|
1013
|
+
// caller updates the poster metadata and seeks in place when needed.
|
|
1014
|
+
return true;
|
|
869
1015
|
}
|
|
870
1016
|
|
|
871
1017
|
function updateCommittedDom(payload) {
|
|
@@ -1055,7 +1201,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1055
1201
|
let reusable = slotMatchesPayload(currentSlot, payload);
|
|
1056
1202
|
if (reusable && payload.media === "video") {
|
|
1057
1203
|
try {
|
|
1058
|
-
|
|
1204
|
+
const reusableVideo = activeVideo();
|
|
1205
|
+
const requestedStartAt = Number(payload.startAt);
|
|
1206
|
+
const normalizedStartAt =
|
|
1207
|
+
Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
|
|
1208
|
+
const appliedStartAt = Number(currentSlot.dataset.bcStartAt);
|
|
1209
|
+
if (
|
|
1210
|
+
!Number.isFinite(appliedStartAt) ||
|
|
1211
|
+
Math.abs(appliedStartAt - normalizedStartAt) >= 0.25
|
|
1212
|
+
) {
|
|
1213
|
+
const nextStartAt = seekVideo(reusableVideo, normalizedStartAt);
|
|
1214
|
+
currentSlot.dataset.bcStartAt = String(nextStartAt);
|
|
1215
|
+
await waitForSeek(reusableVideo, signal, remaining());
|
|
1216
|
+
await waitForVideo(reusableVideo, signal, remaining());
|
|
1217
|
+
await waitForPresentedFrame(reusableVideo, signal, remaining());
|
|
1218
|
+
} else {
|
|
1219
|
+
await waitForStablePlayback(reusableVideo, signal, Math.min(750, remaining()));
|
|
1220
|
+
}
|
|
1059
1221
|
} catch (error) {
|
|
1060
1222
|
if (isAbortError(error) || signal?.aborted) throw error;
|
|
1061
1223
|
reusable = false;
|
|
@@ -1064,6 +1226,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1064
1226
|
if (reusable) {
|
|
1065
1227
|
committedPayload = payload;
|
|
1066
1228
|
currentSlot.dataset.bcGeneration = String(payload.generation);
|
|
1229
|
+
currentSlot.dataset.bcImageUrl = payload.imageUrl;
|
|
1230
|
+
if (payload.videoUrl) currentSlot.dataset.bcVideoUrl = payload.videoUrl;
|
|
1067
1231
|
updateCommittedDom(payload);
|
|
1068
1232
|
syncGallery(payload);
|
|
1069
1233
|
await acknowledgeRender(payload, true, true);
|
|
@@ -1106,11 +1270,20 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1106
1270
|
candidateVideo = video;
|
|
1107
1271
|
video.src = payload.videoUrl;
|
|
1108
1272
|
video.load();
|
|
1273
|
+
// Start the real media request before the diagnostic Range probe. During
|
|
1274
|
+
// a cold Chromium/decoder start, serial probing used part of the same
|
|
1275
|
+
// eight-second budget without advancing the frame that users can see.
|
|
1276
|
+
const sourceProbe = probeVideoSource(
|
|
1277
|
+
payload.videoUrl,
|
|
1278
|
+
signal,
|
|
1279
|
+
Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
|
|
1280
|
+
);
|
|
1109
1281
|
const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1110
1282
|
// play() is deliberately started alongside media readiness. Chromium is
|
|
1111
1283
|
// allowed to ignore preload=auto for hidden/background media; waiting for
|
|
1112
1284
|
// canplay before play() creates a circular stall at readyState=0.
|
|
1113
1285
|
await Promise.all([
|
|
1286
|
+
sourceProbe,
|
|
1114
1287
|
imageReady,
|
|
1115
1288
|
waitForVideo(video, signal, remaining()),
|
|
1116
1289
|
startCandidatePlayback(video, signal, remaining()),
|
|
@@ -1120,7 +1293,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1120
1293
|
candidate.dataset.bcStartAt = String(appliedStartAt);
|
|
1121
1294
|
await waitForSeek(video, signal, remaining());
|
|
1122
1295
|
await waitForVideo(video, signal, remaining());
|
|
1123
|
-
|
|
1296
|
+
// Transaction success means one frame was actually presented. Longer
|
|
1297
|
+
// three-frame stability remains useful for reusing an existing slot, but
|
|
1298
|
+
// must not turn a healthy cold decoder into a false first-frame failure.
|
|
1299
|
+
await waitForPresentedFrame(video, signal, remaining());
|
|
1124
1300
|
throwIfAborted(signal);
|
|
1125
1301
|
candidate.dataset.bcVideoReady = "true";
|
|
1126
1302
|
await commitCandidate(payload, candidate, signal, remaining);
|
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;
|