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 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:0;opacity:1}
85
- #beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:0}
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 describeVideoState(video, phase) {
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
- return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)}`;
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
- 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;
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
- await waitForStablePlayback(activeVideo(), signal, Math.min(750, remaining()));
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
- await waitForStablePlayback(video, signal, remaining());
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beauticode-dsh",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -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: [this.baseUrl.origin],
57
+ trustedOrigins: dshTrustedOrigins(this.baseUrl),
58
58
  });
59
59
  this.onError = opts.onError ?? null;
60
60
  this.onStatus = opts.onStatus ?? null;