beauticode-dsh 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 beautiCode contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/client.js CHANGED
@@ -8,9 +8,16 @@
8
8
  globalThis.crypto?.randomUUID?.() ||
9
9
  `bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
10
10
  const desiredModes = { fish: false, muted: true, tone: "auto" };
11
- 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 DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
20
+ const FRAME_FALLBACK_MS = 120;
14
21
  const VIDEO_STABLE_FRAMES = 3;
15
22
  const VIDEO_STABLE_PROGRESS_SEC = 0.18;
16
23
  const CROSSFADE_MS = 180;
@@ -67,10 +74,10 @@ html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-st
67
74
  html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
68
75
  html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(255,255,255,.22)}
69
76
  html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important}
70
- #beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity: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}
77
+ #beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:1;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity}
78
+ #beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
79
+ #beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
80
+ #beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
74
81
  #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
75
82
  #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
76
83
  #beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease}
@@ -127,12 +134,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
127
134
  attributeFilter: ["data-ds-dark-theme"],
128
135
  });
129
136
  }
130
- systemDarkMedia?.addEventListener("change", () => {
131
- if (desiredModes.tone === "auto") {
132
- syncDshTheme();
133
- void acknowledgeMode();
134
- }
135
- });
137
+ systemDarkMedia?.addEventListener("change", () => {
138
+ if (desiredModes.tone === "auto") {
139
+ syncDshTheme();
140
+ void acknowledgeMode().catch(() => {});
141
+ }
142
+ });
136
143
  syncDshTheme();
137
144
 
138
145
  function stage() {
@@ -184,8 +191,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
184
191
  });
185
192
  }
186
193
 
194
+ function mountedCurrentSlot() {
195
+ const node = document.getElementById("beauticode-bg-stage");
196
+ if (
197
+ !currentSlot ||
198
+ currentSlot.isConnected !== true ||
199
+ currentSlot.parentElement !== node ||
200
+ currentSlot.dataset.bcRole !== "current"
201
+ ) {
202
+ currentSlot = null;
203
+ committedPayload = null;
204
+ return null;
205
+ }
206
+ return currentSlot;
207
+ }
208
+
187
209
  function activeVideo() {
188
- const video = currentSlot?.querySelector?.("video") ?? null;
210
+ const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
189
211
  return video instanceof HTMLVideoElement ? video : null;
190
212
  }
191
213
 
@@ -195,7 +217,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
195
217
  '.beauticode-media-slot[data-bc-role="candidate"] video',
196
218
  );
197
219
  return video instanceof HTMLVideoElement ? video : null;
198
- }
220
+ }
199
221
 
200
222
  async function postAck(body) {
201
223
  await fetch("/__beauticode/ack", {
@@ -267,45 +289,212 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
267
289
  if (signal?.aborted) throw abortError();
268
290
  }
269
291
 
270
- function loadImage(url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
271
- const image = new Image();
272
- image.alt = "";
273
- image.decoding = "async";
292
+ function releaseImage(image, { remove = false } = {}) {
293
+ if (!image) return;
294
+ image.onload = null;
295
+ image.onerror = null;
296
+ try {
297
+ image.removeAttribute?.("src");
298
+ } catch {
299
+ image.src = "";
300
+ }
301
+ if (remove) image.remove?.();
302
+ }
303
+
304
+ function imageAttemptUrl(url, attempt) {
305
+ if (attempt === 0) return url;
306
+ try {
307
+ const retryUrl = new URL(url, globalThis.location?.href);
308
+ retryUrl.searchParams.set(
309
+ "bcImageRetry",
310
+ `${clientId}-${attempt}-${Date.now().toString(36)}`,
311
+ );
312
+ return retryUrl.href;
313
+ } catch {
314
+ return url;
315
+ }
316
+ }
317
+
318
+ function waitForImageAttempt(image, url, signal, timeoutMs) {
274
319
  return new Promise((resolve, reject) => {
275
320
  let settled = false;
276
- let timer = null;
321
+ let decodeStarted = false;
322
+ let decodeFallback = null;
277
323
  const cleanup = () => {
278
- if (timer) clearTimeout(timer);
324
+ clearTimeout(timer);
325
+ if (decodeFallback) clearTimeout(decodeFallback);
279
326
  signal?.removeEventListener?.("abort", aborted);
280
327
  image.onload = null;
281
328
  image.onerror = null;
282
329
  };
283
- const finish = (error) => {
330
+ const finish = (error = null) => {
284
331
  if (settled) return;
285
332
  settled = true;
286
333
  cleanup();
287
334
  if (error) reject(error);
288
- else resolve(image);
335
+ else resolve();
289
336
  };
290
- const aborted = () => {
291
- try {
292
- image.removeAttribute?.("src");
293
- } catch {
294
- image.src = "";
337
+ const acceptLoaded = () => {
338
+ if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
339
+ if (image.complete) finish(new Error("图片尺寸无效"));
340
+ return;
295
341
  }
296
- finish(abortError());
342
+ if (!decodeStarted && typeof image.decode === "function") {
343
+ decodeStarted = true;
344
+ // decode() is a second readiness signal, but the per-attempt timer
345
+ // remains authoritative. A loaded image is still accepted after a
346
+ // short decode grace period if Chromium leaves decode() pending.
347
+ decodeFallback = setTimeout(() => {
348
+ if (
349
+ image.isConnected === true &&
350
+ image.complete &&
351
+ image.naturalWidth > 0 &&
352
+ image.naturalHeight > 0
353
+ ) {
354
+ finish();
355
+ } else {
356
+ finish(new Error("图片解码未完成"));
357
+ }
358
+ }, 750);
359
+ let decodePromise;
360
+ try {
361
+ decodePromise = image.decode();
362
+ } catch {
363
+ finish(new Error("图片解码失败"));
364
+ return;
365
+ }
366
+ Promise.resolve(decodePromise).then(
367
+ () => finish(),
368
+ () => finish(new Error("图片解码失败")),
369
+ );
370
+ return;
371
+ }
372
+ finish();
297
373
  };
298
- image.onload = () => finish();
299
- image.onerror = () => finish(new Error("图片加载失败"));
300
- timer = setTimeout(() => finish(new Error("等待图片加载超时")), timeoutMs);
374
+ const failed = () => finish(new Error("图片请求失败"));
375
+ const aborted = () => finish(abortError());
376
+ const timer = setTimeout(
377
+ () => finish(new Error("等待图片请求或解码超时")),
378
+ timeoutMs,
379
+ );
380
+ image.onload = acceptLoaded;
381
+ image.onerror = failed;
301
382
  signal?.addEventListener?.("abort", aborted, { once: true });
302
383
  if (signal?.aborted) {
303
384
  aborted();
304
385
  return;
305
386
  }
306
387
  image.src = url;
388
+ if (image.complete) queueMicrotask(acceptLoaded);
307
389
  });
308
390
  }
391
+
392
+ function waitForRetryWindow(signal, timeoutMs) {
393
+ return new Promise((resolve, reject) => {
394
+ let settled = false;
395
+ const finish = (error = null) => {
396
+ if (settled) return;
397
+ settled = true;
398
+ clearTimeout(timer);
399
+ signal?.removeEventListener?.("abort", aborted);
400
+ if (error) reject(error);
401
+ else resolve();
402
+ };
403
+ const aborted = () => finish(abortError());
404
+ const timer = setTimeout(() => finish(), timeoutMs);
405
+ signal?.addEventListener?.("abort", aborted, { once: true });
406
+ if (signal?.aborted) aborted();
407
+ });
408
+ }
409
+
410
+ async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
411
+ const deadline = performance.now() + timeoutMs;
412
+ const attempts = [];
413
+ let lastError = null;
414
+
415
+ const startAttempt = (attempt) => {
416
+ throwIfAborted(signal);
417
+ const remainingMs = deadline - performance.now();
418
+ if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
419
+ const controller = new AbortController();
420
+ const parentAborted = () => controller.abort();
421
+ signal?.addEventListener?.("abort", parentAborted, { once: true });
422
+ const image = new Image();
423
+ image.alt = "";
424
+ image.decoding = "async";
425
+ image.className = "beauticode-media-poster";
426
+ slot.prepend(image);
427
+ const promise = waitForImageAttempt(
428
+ image,
429
+ imageAttemptUrl(url, attempt),
430
+ controller.signal,
431
+ Math.max(1, remainingMs),
432
+ )
433
+ .then(() => image)
434
+ .finally(() => signal?.removeEventListener?.("abort", parentAborted));
435
+ const record = { controller, image, promise };
436
+ attempts.push(record);
437
+ return record;
438
+ };
439
+
440
+ const cleanupAttempts = (winner = null) => {
441
+ for (const attempt of attempts) {
442
+ if (attempt.image === winner) continue;
443
+ attempt.controller.abort();
444
+ releaseImage(attempt.image, { remove: true });
445
+ }
446
+ };
447
+
448
+ try {
449
+ const first = startAttempt(0);
450
+ const firstWindow = await Promise.race([
451
+ first.promise.then(
452
+ (image) => ({ image }),
453
+ (error) => ({ error }),
454
+ ),
455
+ waitForRetryWindow(
456
+ signal,
457
+ Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
458
+ ).then(() => ({ retry: true })),
459
+ ]);
460
+ if (firstWindow.image) {
461
+ cleanupAttempts(firstWindow.image);
462
+ return firstWindow.image;
463
+ }
464
+ if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
465
+ lastError = firstWindow.error ?? lastError;
466
+
467
+ // Keep the original request alive. The cache-busted retry runs beside it
468
+ // so a merely slow cold decode is never destroyed to recover a hung one.
469
+ if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
470
+ let winner;
471
+ try {
472
+ winner = await Promise.any(attempts.map((attempt) => attempt.promise));
473
+ } catch (error) {
474
+ const errors = Array.isArray(error?.errors) ? error.errors : [];
475
+ lastError = errors.at(-1) ?? lastError ?? error;
476
+ throw lastError;
477
+ }
478
+ cleanupAttempts(winner);
479
+ return winner;
480
+ } catch (error) {
481
+ cleanupAttempts();
482
+ if (isAbortError(error) || signal?.aborted) throw abortError();
483
+ lastError = error ?? lastError;
484
+ const visibility = document.visibilityState || "unknown";
485
+ const online = globalThis.navigator?.onLine === false ? "offline" : "online";
486
+ const phase = lastError?.message || "unknown";
487
+ const summary =
488
+ phase === "图片请求失败"
489
+ ? "图片加载失败"
490
+ : phase.includes("超时")
491
+ ? "等待图片加载超时"
492
+ : "图片校验失败";
493
+ throw new Error(
494
+ `${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
495
+ );
496
+ }
497
+ }
309
498
 
310
499
  function describeVideoState(video, phase) {
311
500
  const mediaErrorNames = {
@@ -320,13 +509,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
320
509
  }
321
510
 
322
511
  function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
323
- const canPlayState = HTMLMediaElement.HAVE_FUTURE_DATA ?? 3;
324
- if (video.readyState >= canPlayState) return Promise.resolve();
512
+ const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
513
+ if (video.readyState >= frameReadyState) return Promise.resolve();
325
514
  return new Promise((resolve, reject) => {
326
515
  let settled = false;
327
516
  const done = () => finish();
328
517
  const check = () => {
329
- if (video.readyState >= canPlayState) done();
518
+ if (video.readyState >= frameReadyState) done();
330
519
  };
331
520
  const failed = () => {
332
521
  finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
@@ -335,7 +524,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
335
524
  finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
336
525
  };
337
526
  const timer = setTimeout(() => {
338
- finish(new Error(describeVideoState(video, "等待视频可播放状态超时")));
527
+ finish(new Error(describeVideoState(video, "等待视频首帧超时")));
339
528
  }, timeoutMs);
340
529
  const cleanup = () => {
341
530
  clearTimeout(timer);
@@ -402,7 +591,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
402
591
  let lastFrameTime = null;
403
592
  let playingSeen =
404
593
  !video.paused &&
405
- video.readyState >= (HTMLMediaElement.HAVE_FUTURE_DATA ?? 3);
594
+ video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
406
595
  let settled = false;
407
596
  let frameRequest = null;
408
597
  const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
@@ -413,6 +602,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
413
602
  video.removeEventListener("timeupdate", check);
414
603
  video.removeEventListener("waiting", resetStableWindow);
415
604
  video.removeEventListener("stalled", resetStableWindow);
605
+ video.removeEventListener("pause", resetStableWindow);
606
+ video.removeEventListener("seeking", resetStableWindow);
416
607
  video.removeEventListener("error", failed);
417
608
  video.removeEventListener("abort", aborted);
418
609
  signal?.removeEventListener?.("abort", aborted);
@@ -465,7 +656,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
465
656
  return;
466
657
  }
467
658
  observeProgress();
468
- if (!video.paused && video.readyState >= (HTMLMediaElement.HAVE_FUTURE_DATA ?? 3)) {
659
+ if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
469
660
  playingSeen = true;
470
661
  }
471
662
  const framesReady = hasFrameCallback
@@ -508,6 +699,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
508
699
  video.addEventListener("timeupdate", check);
509
700
  video.addEventListener("waiting", resetStableWindow);
510
701
  video.addEventListener("stalled", resetStableWindow);
702
+ video.addEventListener("pause", resetStableWindow);
703
+ video.addEventListener("seeking", resetStableWindow);
511
704
  video.addEventListener("error", failed, { once: true });
512
705
  video.addEventListener("abort", aborted, { once: true });
513
706
  signal?.addEventListener?.("abort", aborted, { once: true });
@@ -536,62 +729,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
536
729
 
537
730
  async function playWithPreference(video, signal = null) {
538
731
  throwIfAborted(signal);
539
- 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;
732
+ const requestedMuted = desiredModes.muted;
733
+ let blocked = false;
734
+ video.muted = requestedMuted;
735
+ try {
736
+ await video.play();
737
+ } catch (error) {
738
+ throwIfAborted(signal);
739
+ if (requestedMuted) throw error;
740
+ blocked = true;
546
741
  video.muted = true;
547
742
  await video.play();
548
743
  }
549
744
  throwIfAborted(signal);
550
- video.dataset.bcPlaybackBlocked = 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 };
745
+ video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
746
+ return blocked;
586
747
  }
587
748
 
588
749
  async function startCandidatePlayback(video, signal, timeoutMs) {
589
- const playing = createPlayingWaiter(video, signal, timeoutMs);
750
+ const playbackController = new AbortController();
751
+ const parentAborted = () => playbackController.abort();
752
+ signal?.addEventListener?.("abort", parentAborted, { once: true });
753
+ let timer = null;
754
+ let abortListener = null;
755
+ const playback = playWithPreference(video, playbackController.signal);
756
+ const deadline = new Promise((_, reject) => {
757
+ timer = setTimeout(() => {
758
+ reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
759
+ playbackController.abort();
760
+ }, timeoutMs);
761
+ abortListener = () => reject(abortError());
762
+ playbackController.signal.addEventListener("abort", abortListener, { once: true });
763
+ });
764
+ if (signal?.aborted) parentAborted();
590
765
  try {
591
- await Promise.all([playWithPreference(video, signal), playing.promise]);
766
+ // The play() promise itself resolves only after playback has started, so
767
+ // a second playing-event waiter would create an orphaned promise race.
768
+ return await Promise.race([playback, deadline]);
592
769
  } catch (error) {
593
- playing.cancel(error);
770
+ playbackController.abort();
771
+ try {
772
+ video.pause();
773
+ } catch {
774
+ /* best effort: disposeSlot will release src as the final guard */
775
+ }
594
776
  throw error;
777
+ } finally {
778
+ if (timer) clearTimeout(timer);
779
+ signal?.removeEventListener?.("abort", parentAborted);
780
+ if (abortListener) {
781
+ playbackController.signal.removeEventListener("abort", abortListener);
782
+ }
595
783
  }
596
784
  }
597
785
 
@@ -623,14 +811,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
623
811
  if (!slot) return;
624
812
  for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
625
813
  for (const image of slot.querySelectorAll?.("img") ?? []) {
626
- image.onload = null;
627
- image.onerror = null;
628
- image.removeAttribute?.("src");
814
+ releaseImage(image);
629
815
  }
630
816
  slot.remove?.();
631
817
  }
632
818
 
633
- function createSlot(payload, image, video = null) {
819
+ function createSlot(payload, video = null) {
634
820
  const slot = document.createElement("div");
635
821
  slot.className = "beauticode-media-slot";
636
822
  slot.dataset.bcRole = "candidate";
@@ -638,8 +824,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
638
824
  slot.dataset.bcGeneration = String(payload.generation);
639
825
  slot.dataset.bcImageUrl = payload.imageUrl;
640
826
  if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
641
- image.className = "beauticode-media-poster";
642
- slot.append(image);
827
+ if (payload.media === "video") {
828
+ const startAt = Number(payload.startAt);
829
+ slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
830
+ }
643
831
  if (video) {
644
832
  video.className = "beauticode-media-video";
645
833
  slot.append(video);
@@ -648,9 +836,36 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
648
836
  }
649
837
 
650
838
  function slotMatchesPayload(slot, payload) {
651
- if (!slot || slot.dataset.bcMedia !== payload.media) return false;
839
+ if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
840
+ return false;
841
+ }
652
842
  if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
653
- return payload.media !== "video" || slot.dataset.bcVideoUrl === payload.videoUrl;
843
+ if (payload.media !== "video") {
844
+ const image = slot.querySelector?.("img");
845
+ return Boolean(
846
+ image?.isConnected === true &&
847
+ image.complete &&
848
+ image.naturalWidth > 0 &&
849
+ image.naturalHeight > 0,
850
+ );
851
+ }
852
+ if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
853
+ const video = slot.querySelector?.("video");
854
+ if (
855
+ !(video instanceof HTMLVideoElement) ||
856
+ video.error ||
857
+ video.ended ||
858
+ video.paused ||
859
+ video.seeking ||
860
+ video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
861
+ ) {
862
+ return false;
863
+ }
864
+ const requestedStartAt = Number(payload.startAt);
865
+ const normalizedStartAt =
866
+ Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
867
+ const appliedStartAt = Number(slot.dataset.bcStartAt);
868
+ return Number.isFinite(appliedStartAt) && Math.abs(appliedStartAt - normalizedStartAt) < 0.25;
654
869
  }
655
870
 
656
871
  function updateCommittedDom(payload) {
@@ -682,9 +897,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
682
897
  function attachCandidate(slot, payload) {
683
898
  const node = stage();
684
899
  discardCandidates();
900
+ const previous = mountedCurrentSlot();
685
901
  slot.dataset.bcRole = "candidate";
686
902
  node.append(slot);
687
- if (!currentSlot) {
903
+ if (!previous) {
688
904
  node.dataset.bcEmpty = "true";
689
905
  document.documentElement.dataset.bcActive = "true";
690
906
  document.documentElement.dataset.bcMedia =
@@ -696,17 +912,34 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
696
912
  function nextFrame(signal) {
697
913
  if (typeof requestAnimationFrame !== "function") return Promise.resolve();
698
914
  return new Promise((resolve, reject) => {
915
+ let settled = false;
699
916
  let request = null;
700
- const aborted = () => {
701
- if (request != null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(request);
917
+ let timer = null;
918
+ const cleanup = () => {
919
+ if (timer) clearTimeout(timer);
920
+ if (request != null && typeof cancelAnimationFrame === "function") {
921
+ cancelAnimationFrame(request);
922
+ }
702
923
  signal?.removeEventListener?.("abort", aborted);
703
- reject(abortError());
704
924
  };
705
- request = requestAnimationFrame(() => {
706
- signal?.removeEventListener?.("abort", aborted);
707
- if (signal?.aborted) reject(abortError());
925
+ const finish = (error = null) => {
926
+ if (settled) return;
927
+ settled = true;
928
+ cleanup();
929
+ if (error) reject(error);
708
930
  else resolve();
931
+ };
932
+ const aborted = () => {
933
+ finish(abortError());
934
+ };
935
+ request = requestAnimationFrame(() => {
936
+ request = null;
937
+ if (signal?.aborted) finish(abortError());
938
+ else finish();
709
939
  });
940
+ // Background or power-saved Chromium pages may suspend rAF entirely.
941
+ // A short timer keeps the transaction bounded without skipping aborts.
942
+ timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
710
943
  signal?.addEventListener?.("abort", aborted, { once: true });
711
944
  if (signal?.aborted) aborted();
712
945
  });
@@ -739,16 +972,21 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
739
972
  });
740
973
  }
741
974
 
742
- async function commitCandidate(payload, slot, signal) {
975
+ async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
743
976
  throwIfAborted(signal);
744
977
  const node = stage();
745
- const previous = currentSlot;
746
- if (previous) {
978
+ const previous = mountedCurrentSlot();
979
+ const canAnimate =
980
+ previous &&
981
+ reducedMotionMedia?.matches !== true &&
982
+ document.visibilityState === "visible" &&
983
+ remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
984
+ if (canAnimate) {
747
985
  await nextFrame(signal);
748
986
  await nextFrame(signal);
749
987
  throwIfAborted(signal);
750
988
  node.dataset.bcTransitioning = "true";
751
- await waitForCrossfade(slot, signal);
989
+ await waitForCrossfade(previous, signal);
752
990
  }
753
991
  throwIfAborted(signal);
754
992
  if (previous && previous !== slot) disposeSlot(previous);
@@ -767,7 +1005,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
767
1005
  const node = document.getElementById("beauticode-bg-stage");
768
1006
  node?.removeAttribute("data-bc-transitioning");
769
1007
  node?.removeAttribute("data-bc-empty");
770
- if (committedPayload && currentSlot) {
1008
+ if (committedPayload && mountedCurrentSlot()) {
771
1009
  updateCommittedDom(committedPayload);
772
1010
  playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
773
1011
  return;
@@ -779,12 +1017,19 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
779
1017
  }
780
1018
 
781
1019
  function syncGallery(payload) {
782
- const on = payload?.atmosphere?.preset === "gallery";
783
- globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
784
- }
1020
+ const on = payload?.atmosphere?.preset === "gallery";
1021
+ try {
1022
+ const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
1023
+ if (sync && typeof sync.then === "function") void sync.catch(() => {});
1024
+ } catch {
1025
+ /* Atmosphere is optional and must never invalidate a rendered background. */
1026
+ }
1027
+ }
785
1028
 
786
1029
  async function applyBackground(payload, signal) {
787
1030
  throwIfAborted(signal);
1031
+ const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
1032
+ const remaining = () => Math.max(1, applyDeadline - performance.now());
788
1033
  if (payload.media === "clear") {
789
1034
  desiredModes.fish = false;
790
1035
  playbackBlocked = false;
@@ -803,13 +1048,20 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
803
1048
  await acknowledgeMode();
804
1049
  return;
805
1050
  }
806
- if (typeof payload.imageUrl !== "string") return;
807
- const structureIssue = dshStructureIssue();
808
- if (structureIssue) {
809
- await acknowledgeRender(payload, false, false, structureIssue);
1051
+ if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
1052
+ await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
810
1053
  return;
811
1054
  }
812
- if (slotMatchesPayload(currentSlot, payload)) {
1055
+ let reusable = slotMatchesPayload(currentSlot, payload);
1056
+ if (reusable && payload.media === "video") {
1057
+ try {
1058
+ await waitForStablePlayback(activeVideo(), signal, Math.min(750, remaining()));
1059
+ } catch (error) {
1060
+ if (isAbortError(error) || signal?.aborted) throw error;
1061
+ reusable = false;
1062
+ }
1063
+ }
1064
+ if (reusable) {
813
1065
  committedPayload = payload;
814
1066
  currentSlot.dataset.bcGeneration = String(payload.generation);
815
1067
  updateCommittedDom(payload);
@@ -819,17 +1071,17 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
819
1071
  return;
820
1072
  }
821
1073
  let candidate = null;
1074
+ let candidateVideo = null;
822
1075
  let committed = false;
823
1076
  try {
824
- await waitForDshStructure(signal);
825
- const image = await loadImage(payload.imageUrl, signal);
826
- throwIfAborted(signal);
1077
+ await waitForDshStructure(signal, remaining());
827
1078
  if (payload.media === "image") {
828
- if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
829
- playbackBlocked = false;
830
- candidate = createSlot(payload, image);
1079
+ candidate = createSlot(payload);
831
1080
  attachCandidate(candidate, payload);
832
- await commitCandidate(payload, candidate, signal);
1081
+ const image = await loadImage(candidate, payload.imageUrl, signal, remaining());
1082
+ throwIfAborted(signal);
1083
+ if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
1084
+ await commitCandidate(payload, candidate, signal, remaining);
833
1085
  committed = true;
834
1086
  await acknowledgeRender(payload, true, true);
835
1087
  await acknowledgeMode();
@@ -839,32 +1091,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
839
1091
  throw new Error("视频载荷无效");
840
1092
  }
841
1093
  const video = document.createElement("video");
842
- video.autoplay = false;
1094
+ video.autoplay = true;
843
1095
  video.loop = true;
844
1096
  video.playsInline = true;
845
1097
  video.preload = "auto";
846
- video.poster = payload.imageUrl;
847
- video.src = payload.videoUrl;
848
- candidate = createSlot(payload, image, video);
1098
+ video.crossOrigin = "anonymous";
1099
+ // Set mute before src so Chromium may start the request immediately
1100
+ // without waiting for an audible autoplay decision.
1101
+ video.defaultMuted = true;
1102
+ video.muted = desiredModes.muted;
1103
+ video.setAttribute("muted", "");
1104
+ candidate = createSlot(payload, video);
849
1105
  attachCandidate(candidate, payload);
1106
+ candidateVideo = video;
1107
+ video.src = payload.videoUrl;
850
1108
  video.load();
851
- const deadline = performance.now() + VIDEO_STARTUP_TIMEOUT_MS;
852
- const remaining = () => Math.max(1, deadline - performance.now());
853
- await waitForVideo(video, signal, remaining());
1109
+ const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
1110
+ // play() is deliberately started alongside media readiness. Chromium is
1111
+ // allowed to ignore preload=auto for hidden/background media; waiting for
1112
+ // canplay before play() creates a circular stall at readyState=0.
1113
+ await Promise.all([
1114
+ imageReady,
1115
+ waitForVideo(video, signal, remaining()),
1116
+ startCandidatePlayback(video, signal, remaining()),
1117
+ ]);
854
1118
  throwIfAborted(signal);
855
- seekVideo(video, payload.startAt);
1119
+ const appliedStartAt = seekVideo(video, payload.startAt);
1120
+ candidate.dataset.bcStartAt = String(appliedStartAt);
856
1121
  await waitForSeek(video, signal, remaining());
857
1122
  await waitForVideo(video, signal, remaining());
858
- await startCandidatePlayback(video, signal, remaining());
859
1123
  await waitForStablePlayback(video, signal, remaining());
860
1124
  throwIfAborted(signal);
861
1125
  candidate.dataset.bcVideoReady = "true";
862
- await commitCandidate(payload, candidate, signal);
1126
+ await commitCandidate(payload, candidate, signal, remaining);
863
1127
  committed = true;
864
1128
  const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
865
1129
  await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
866
1130
  await acknowledgeMode();
867
1131
  } catch (error) {
1132
+ if (candidateVideo && candidateVideo.parentElement !== candidate) {
1133
+ disposeVideo(candidateVideo);
1134
+ candidateVideo.remove?.();
1135
+ }
868
1136
  if (candidate && !committed) disposeSlot(candidate);
869
1137
  if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
870
1138
  restoreCommittedDom();
@@ -879,6 +1147,22 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
879
1147
 
880
1148
  function scheduleBackground(payload) {
881
1149
  if (!Number.isSafeInteger(payload?.generation)) return;
1150
+ // EventSource reconnects can replay the current frame while its first
1151
+ // render is still in flight. Restarting the same generation would discard
1152
+ // a healthy cold media request and move its deadline indefinitely.
1153
+ if (
1154
+ applyController &&
1155
+ activePayload?.generation === payload.generation &&
1156
+ activePayload?.media === payload.media
1157
+ ) {
1158
+ return;
1159
+ }
1160
+ if (
1161
+ Number.isSafeInteger(activePayload?.generation) &&
1162
+ payload.generation < activePayload.generation
1163
+ ) {
1164
+ return;
1165
+ }
882
1166
  applyController?.abort();
883
1167
  discardCandidates();
884
1168
  const controller = new AbortController();
@@ -886,11 +1170,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
886
1170
  activePayload = payload;
887
1171
  renderPhase = "pending";
888
1172
  document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
889
- void applyBackground(payload, controller.signal).finally(() => {
890
- if (applyController !== controller) return;
891
- applyController = null;
892
- document.documentElement.removeAttribute("data-bc-pending-generation");
893
- });
1173
+ void applyBackground(payload, controller.signal)
1174
+ .catch(async (error) => {
1175
+ if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return;
1176
+ restoreCommittedDom();
1177
+ await acknowledgeRender(
1178
+ payload,
1179
+ false,
1180
+ Boolean(mountedCurrentSlot()),
1181
+ error instanceof Error ? error.message : String(error),
1182
+ );
1183
+ })
1184
+ .finally(() => {
1185
+ if (applyController !== controller) return;
1186
+ applyController = null;
1187
+ document.documentElement.removeAttribute("data-bc-pending-generation");
1188
+ })
1189
+ .catch(() => {});
894
1190
  }
895
1191
 
896
1192
  async function applyModes(payload) {
@@ -908,8 +1204,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
908
1204
  const candidate = pendingVideo();
909
1205
  if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
910
1206
  if (video instanceof HTMLVideoElement) {
911
- try {
912
- await playWithPreference(video);
1207
+ try {
1208
+ playbackBlocked = await playWithPreference(video);
913
1209
  } catch {
914
1210
  playbackBlocked = desiredModes.muted === false;
915
1211
  }
@@ -932,25 +1228,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
932
1228
  events.onmessage = (event) => {
933
1229
  try {
934
1230
  const payload = JSON.parse(event.data);
935
- if (payload?.type === "mode") void applyModes(payload);
1231
+ if (payload?.type === "mode") void applyModes(payload).catch(() => {});
936
1232
  else if (payload?.type === "apply") scheduleBackground(payload);
937
1233
  } catch {
938
1234
  /* EventSource will continue with the next valid frame. */
939
1235
  }
940
1236
  };
941
1237
 
942
- setInterval(() => {
943
- const video = activeVideo();
944
- if (
945
- committedPayload?.media === "video" &&
946
- activePayload === committedPayload &&
947
- renderPhase === "ready" &&
948
- video instanceof HTMLVideoElement
949
- ) {
1238
+ setInterval(() => {
1239
+ if (
1240
+ !committedPayload ||
1241
+ activePayload !== committedPayload ||
1242
+ renderPhase !== "ready"
1243
+ ) {
1244
+ return;
1245
+ }
1246
+ if (committedPayload.media === "clear") {
1247
+ void acknowledgeRender(committedPayload, true, false).catch(() => {});
1248
+ return;
1249
+ }
1250
+ const slot = mountedCurrentSlot();
1251
+ if (!slot) return;
1252
+ if (committedPayload.media === "image") {
1253
+ const image = slot.querySelector?.("img");
1254
+ if (
1255
+ image?.isConnected === true &&
1256
+ image.complete &&
1257
+ image.naturalWidth > 0 &&
1258
+ image.naturalHeight > 0
1259
+ ) {
1260
+ void acknowledgeRender(committedPayload, true, true).catch(() => {});
1261
+ }
1262
+ return;
1263
+ }
1264
+ const video = activeVideo();
1265
+ if (
1266
+ committedPayload.media === "video" &&
1267
+ video instanceof HTMLVideoElement
1268
+ ) {
950
1269
  // A heartbeat is observational, not a second render verdict. Playback can
951
1270
  // briefly pause while Chromium changes modes or refills an 8K buffer; do
952
1271
  // not downgrade an already-rendered generation or fail a pending one.
953
- void acknowledgeRender(committedPayload, true, true);
1272
+ void acknowledgeRender(committedPayload, true, true).catch(() => {});
954
1273
  }
955
1274
  }, 1_000);
956
1275
  })();
package/host-apply.mjs CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  } from "./control-client.mjs";
8
8
  import { canvasImagePath } from "./presets.mjs";
9
9
 
10
- const sessions = new Map();
10
+ const sessions = new Map();
11
+ const DSH_VERIFY_DEADLINE_MS = 10_000;
11
12
 
12
13
  export const ENGINE_MISSING_MESSAGE =
13
14
  "beautiCode 插件未能加载本机导入引擎。请执行 npx beauticode-dsh,或从完整安装目录加载桥接。";
@@ -92,7 +93,7 @@ export async function ensureInProcessSession(options) {
92
93
  const session = new adapter.DshSession({
93
94
  dataRoot,
94
95
  baseUrl,
95
- verifyDeadlineMs: 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.18",
4
4
  "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",