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 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
- const IMAGE_LOAD_TIMEOUT_MS = 12_000;
12
- const VIDEO_STARTUP_TIMEOUT_MS = 20_000;
13
- const DSH_STRUCTURE_TIMEOUT_MS = 10_000;
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:0;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity}
71
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:0;opacity:1}
72
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:1;opacity:0}
73
- #beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
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:0}
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 = currentSlot?.querySelector?.("video") ?? null;
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 loadImage(url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
271
- const image = new Image();
272
- image.alt = "";
273
- image.decoding = "async";
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 timer = null;
322
+ let decodeStarted = false;
323
+ let decodeFallback = null;
277
324
  const cleanup = () => {
278
- if (timer) clearTimeout(timer);
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(image);
336
+ else resolve();
289
337
  };
290
- const aborted = () => {
291
- try {
292
- image.removeAttribute?.("src");
293
- } catch {
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
- finish(abortError());
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
- image.onload = () => finish();
299
- image.onerror = () => finish(new Error("图片加载失败"));
300
- timer = setTimeout(() => finish(new Error("等待图片加载超时")), timeoutMs);
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 describeVideoState(video, phase) {
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
- return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)}`;
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 canPlayState = HTMLMediaElement.HAVE_FUTURE_DATA ?? 3;
324
- if (video.readyState >= canPlayState) return Promise.resolve();
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 >= canPlayState) done();
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 >= (HTMLMediaElement.HAVE_FUTURE_DATA ?? 3);
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 >= (HTMLMediaElement.HAVE_FUTURE_DATA ?? 3)) {
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
- playbackBlocked = false;
540
- video.muted = desiredModes.muted;
541
- try {
542
- await video.play();
543
- } catch (error) {
544
- if (desiredModes.muted) throw error;
545
- playbackBlocked = true;
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 = playbackBlocked ? "true" : "false";
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 playing = createPlayingWaiter(video, signal, timeoutMs);
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
- await Promise.all([playWithPreference(video, signal), playing.promise]);
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
- playing.cancel(error);
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.onload = null;
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, image, video = null) {
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
- image.className = "beauticode-media-poster";
642
- slot.append(image);
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) return false;
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
- return payload.media !== "video" || slot.dataset.bcVideoUrl === payload.videoUrl;
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 (!currentSlot) {
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
- const aborted = () => {
701
- if (request != null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(request);
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
- request = requestAnimationFrame(() => {
706
- signal?.removeEventListener?.("abort", aborted);
707
- if (signal?.aborted) reject(abortError());
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 = currentSlot;
746
- if (previous) {
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(slot, signal);
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 && currentSlot) {
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
- globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
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") return;
807
- const structureIssue = dshStructureIssue();
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
- if (slotMatchesPayload(currentSlot, payload)) {
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
- if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
829
- playbackBlocked = false;
830
- candidate = createSlot(payload, image);
1139
+ candidate = createSlot(payload);
831
1140
  attachCandidate(candidate, payload);
832
- await commitCandidate(payload, candidate, signal);
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 = false;
1160
+ video.autoplay = true;
843
1161
  video.loop = true;
844
1162
  video.playsInline = true;
845
1163
  video.preload = "auto";
846
- video.poster = payload.imageUrl;
847
- video.src = payload.videoUrl;
848
- candidate = createSlot(payload, image, video);
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 deadline = performance.now() + VIDEO_STARTUP_TIMEOUT_MS;
852
- const remaining = () => Math.max(1, deadline - performance.now());
853
- await waitForVideo(video, signal, remaining());
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).finally(() => {
890
- if (applyController !== controller) return;
891
- applyController = null;
892
- document.documentElement.removeAttribute("data-bc-pending-generation");
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
- const video = activeVideo();
944
- if (
945
- committedPayload?.media === "video" &&
946
- activePayload === committedPayload &&
947
- renderPhase === "ready" &&
948
- video instanceof HTMLVideoElement
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: 30_000,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beauticode-dsh",
3
- "version": "1.0.16",
3
+ "version": "1.0.19",
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;