beauticode-dsh 1.0.15 → 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/atmosphere.js CHANGED
@@ -92,8 +92,8 @@ html[data-bc-resolved-tone="light"][data-bc-gallery="true"] body{
92
92
  }
93
93
  html[data-bc-gallery="true"] #root{position:relative;z-index:1;background:transparent!important}
94
94
  html[data-bc-gallery="true"] [class*="_fade"]{display:none!important}
95
- html[data-bc-gallery="true"] #beauticode-bg-stage>img,
96
- html[data-bc-gallery="true"] #beauticode-bg-stage>video{opacity:0!important}
95
+ html[data-bc-gallery="true"] #beauticode-bg-stage img,
96
+ html[data-bc-gallery="true"] #beauticode-bg-stage video{opacity:0!important}
97
97
  html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
98
98
  `;
99
99
  return style;
package/client.js CHANGED
@@ -8,8 +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;
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;
13
21
  const VIDEO_STABLE_FRAMES = 3;
14
22
  const VIDEO_STABLE_PROGRESS_SEC = 0.18;
15
23
  const CROSSFADE_MS = 180;
@@ -66,10 +74,10 @@ html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-st
66
74
  html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
67
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)}
68
76
  html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important}
69
- #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}
70
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:0;opacity:1}
71
- #beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:1;opacity:0}
72
- #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}
73
81
  #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
74
82
  #beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
75
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}
@@ -126,12 +134,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
126
134
  attributeFilter: ["data-ds-dark-theme"],
127
135
  });
128
136
  }
129
- systemDarkMedia?.addEventListener("change", () => {
130
- if (desiredModes.tone === "auto") {
131
- syncDshTheme();
132
- void acknowledgeMode();
133
- }
134
- });
137
+ systemDarkMedia?.addEventListener("change", () => {
138
+ if (desiredModes.tone === "auto") {
139
+ syncDshTheme();
140
+ void acknowledgeMode().catch(() => {});
141
+ }
142
+ });
135
143
  syncDshTheme();
136
144
 
137
145
  function stage() {
@@ -149,15 +157,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
149
157
  * If the shell no longer exposes it, report a clear error through the
150
158
  * render ack instead of silently painting a broken page.
151
159
  */
152
- function dshStructureIssue() {
153
- if (!document.getElementById("root")) {
154
- return "DSH 页面结构不兼容:未找到 #root。";
155
- }
156
- return null;
157
- }
160
+ function dshStructureIssue() {
161
+ if (!document.getElementById("root")) {
162
+ return "DSH 页面结构不兼容:未找到 #root。";
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) {
168
+ if (!dshStructureIssue()) return Promise.resolve();
169
+ return new Promise((resolve, reject) => {
170
+ let settled = false;
171
+ const startedAt = performance.now();
172
+ const finish = (error = null) => {
173
+ if (settled) return;
174
+ settled = true;
175
+ clearInterval(timer);
176
+ signal?.removeEventListener?.("abort", aborted);
177
+ if (error) reject(error);
178
+ else resolve();
179
+ };
180
+ const aborted = () => finish(abortError());
181
+ const timer = setInterval(() => {
182
+ const issue = dshStructureIssue();
183
+ if (!issue) {
184
+ finish();
185
+ } else if (performance.now() - startedAt >= timeoutMs) {
186
+ finish(new Error(issue));
187
+ }
188
+ }, 50);
189
+ signal?.addEventListener?.("abort", aborted, { once: true });
190
+ if (signal?.aborted) aborted();
191
+ });
192
+ }
158
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
+
159
209
  function activeVideo() {
160
- const video = currentSlot?.querySelector?.("video") ?? null;
210
+ const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
161
211
  return video instanceof HTMLVideoElement ? video : null;
162
212
  }
163
213
 
@@ -167,7 +217,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
167
217
  '.beauticode-media-slot[data-bc-role="candidate"] video',
168
218
  );
169
219
  return video instanceof HTMLVideoElement ? video : null;
170
- }
220
+ }
171
221
 
172
222
  async function postAck(body) {
173
223
  await fetch("/__beauticode/ack", {
@@ -239,45 +289,212 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
239
289
  if (signal?.aborted) throw abortError();
240
290
  }
241
291
 
242
- function loadImage(url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
243
- const image = new Image();
244
- image.alt = "";
245
- 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) {
246
319
  return new Promise((resolve, reject) => {
247
320
  let settled = false;
248
- let timer = null;
321
+ let decodeStarted = false;
322
+ let decodeFallback = null;
249
323
  const cleanup = () => {
250
- if (timer) clearTimeout(timer);
324
+ clearTimeout(timer);
325
+ if (decodeFallback) clearTimeout(decodeFallback);
251
326
  signal?.removeEventListener?.("abort", aborted);
252
327
  image.onload = null;
253
328
  image.onerror = null;
254
329
  };
255
- const finish = (error) => {
330
+ const finish = (error = null) => {
256
331
  if (settled) return;
257
332
  settled = true;
258
333
  cleanup();
259
334
  if (error) reject(error);
260
- else resolve(image);
335
+ else resolve();
261
336
  };
262
- const aborted = () => {
263
- try {
264
- image.removeAttribute?.("src");
265
- } catch {
266
- image.src = "";
337
+ const acceptLoaded = () => {
338
+ if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
339
+ if (image.complete) finish(new Error("图片尺寸无效"));
340
+ return;
267
341
  }
268
- 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();
269
373
  };
270
- image.onload = () => finish();
271
- image.onerror = () => finish(new Error("图片加载失败"));
272
- 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;
273
382
  signal?.addEventListener?.("abort", aborted, { once: true });
274
383
  if (signal?.aborted) {
275
384
  aborted();
276
385
  return;
277
386
  }
278
387
  image.src = url;
388
+ if (image.complete) queueMicrotask(acceptLoaded);
279
389
  });
280
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
+ }
281
498
 
282
499
  function describeVideoState(video, phase) {
283
500
  const mediaErrorNames = {
@@ -292,13 +509,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
292
509
  }
293
510
 
294
511
  function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
295
- const canPlayState = HTMLMediaElement.HAVE_FUTURE_DATA ?? 3;
296
- if (video.readyState >= canPlayState) return Promise.resolve();
512
+ const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
513
+ if (video.readyState >= frameReadyState) return Promise.resolve();
297
514
  return new Promise((resolve, reject) => {
298
515
  let settled = false;
299
516
  const done = () => finish();
300
517
  const check = () => {
301
- if (video.readyState >= canPlayState) done();
518
+ if (video.readyState >= frameReadyState) done();
302
519
  };
303
520
  const failed = () => {
304
521
  finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
@@ -307,7 +524,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
307
524
  finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
308
525
  };
309
526
  const timer = setTimeout(() => {
310
- finish(new Error(describeVideoState(video, "等待视频可播放状态超时")));
527
+ finish(new Error(describeVideoState(video, "等待视频首帧超时")));
311
528
  }, timeoutMs);
312
529
  const cleanup = () => {
313
530
  clearTimeout(timer);
@@ -374,7 +591,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
374
591
  let lastFrameTime = null;
375
592
  let playingSeen =
376
593
  !video.paused &&
377
- video.readyState >= (HTMLMediaElement.HAVE_FUTURE_DATA ?? 3);
594
+ video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
378
595
  let settled = false;
379
596
  let frameRequest = null;
380
597
  const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
@@ -385,6 +602,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
385
602
  video.removeEventListener("timeupdate", check);
386
603
  video.removeEventListener("waiting", resetStableWindow);
387
604
  video.removeEventListener("stalled", resetStableWindow);
605
+ video.removeEventListener("pause", resetStableWindow);
606
+ video.removeEventListener("seeking", resetStableWindow);
388
607
  video.removeEventListener("error", failed);
389
608
  video.removeEventListener("abort", aborted);
390
609
  signal?.removeEventListener?.("abort", aborted);
@@ -437,7 +656,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
437
656
  return;
438
657
  }
439
658
  observeProgress();
440
- if (!video.paused && video.readyState >= (HTMLMediaElement.HAVE_FUTURE_DATA ?? 3)) {
659
+ if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
441
660
  playingSeen = true;
442
661
  }
443
662
  const framesReady = hasFrameCallback
@@ -480,6 +699,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
480
699
  video.addEventListener("timeupdate", check);
481
700
  video.addEventListener("waiting", resetStableWindow);
482
701
  video.addEventListener("stalled", resetStableWindow);
702
+ video.addEventListener("pause", resetStableWindow);
703
+ video.addEventListener("seeking", resetStableWindow);
483
704
  video.addEventListener("error", failed, { once: true });
484
705
  video.addEventListener("abort", aborted, { once: true });
485
706
  signal?.addEventListener?.("abort", aborted, { once: true });
@@ -508,62 +729,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
508
729
 
509
730
  async function playWithPreference(video, signal = null) {
510
731
  throwIfAborted(signal);
511
- playbackBlocked = false;
512
- video.muted = desiredModes.muted;
513
- try {
514
- await video.play();
515
- } catch (error) {
516
- if (desiredModes.muted) throw error;
517
- 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;
518
741
  video.muted = true;
519
742
  await video.play();
520
743
  }
521
744
  throwIfAborted(signal);
522
- video.dataset.bcPlaybackBlocked = playbackBlocked ? "true" : "false";
523
- }
524
-
525
- function createPlayingWaiter(video, signal, timeoutMs) {
526
- let cancel = () => {};
527
- const promise = new Promise((resolve, reject) => {
528
- let settled = false;
529
- const cleanup = () => {
530
- clearTimeout(timer);
531
- video.removeEventListener("playing", played);
532
- video.removeEventListener("error", failed);
533
- video.removeEventListener("abort", aborted);
534
- signal?.removeEventListener?.("abort", aborted);
535
- };
536
- const finish = (error) => {
537
- if (settled) return;
538
- settled = true;
539
- cleanup();
540
- if (error) reject(error);
541
- else resolve();
542
- };
543
- const played = () => finish();
544
- const failed = () => finish(new Error(describeVideoState(video, "视频开始播放失败")));
545
- const aborted = () => finish(abortError());
546
- const timer = setTimeout(
547
- () => finish(new Error(describeVideoState(video, "等待 playing 事件超时"))),
548
- timeoutMs,
549
- );
550
- cancel = (error = abortError()) => finish(error);
551
- video.addEventListener("playing", played, { once: true });
552
- video.addEventListener("error", failed, { once: true });
553
- video.addEventListener("abort", aborted, { once: true });
554
- signal?.addEventListener?.("abort", aborted, { once: true });
555
- if (signal?.aborted) aborted();
556
- });
557
- return { promise, cancel };
745
+ video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
746
+ return blocked;
558
747
  }
559
748
 
560
749
  async function startCandidatePlayback(video, signal, timeoutMs) {
561
- 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();
562
765
  try {
563
- 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]);
564
769
  } catch (error) {
565
- 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
+ }
566
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
+ }
567
783
  }
568
784
  }
569
785
 
@@ -595,14 +811,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
595
811
  if (!slot) return;
596
812
  for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
597
813
  for (const image of slot.querySelectorAll?.("img") ?? []) {
598
- image.onload = null;
599
- image.onerror = null;
600
- image.removeAttribute?.("src");
814
+ releaseImage(image);
601
815
  }
602
816
  slot.remove?.();
603
817
  }
604
818
 
605
- function createSlot(payload, image, video = null) {
819
+ function createSlot(payload, video = null) {
606
820
  const slot = document.createElement("div");
607
821
  slot.className = "beauticode-media-slot";
608
822
  slot.dataset.bcRole = "candidate";
@@ -610,8 +824,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
610
824
  slot.dataset.bcGeneration = String(payload.generation);
611
825
  slot.dataset.bcImageUrl = payload.imageUrl;
612
826
  if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
613
- image.className = "beauticode-media-poster";
614
- 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
+ }
615
831
  if (video) {
616
832
  video.className = "beauticode-media-video";
617
833
  slot.append(video);
@@ -620,9 +836,36 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
620
836
  }
621
837
 
622
838
  function slotMatchesPayload(slot, payload) {
623
- if (!slot || slot.dataset.bcMedia !== payload.media) return false;
839
+ if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
840
+ return false;
841
+ }
624
842
  if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
625
- 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;
626
869
  }
627
870
 
628
871
  function updateCommittedDom(payload) {
@@ -654,9 +897,10 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
654
897
  function attachCandidate(slot, payload) {
655
898
  const node = stage();
656
899
  discardCandidates();
900
+ const previous = mountedCurrentSlot();
657
901
  slot.dataset.bcRole = "candidate";
658
902
  node.append(slot);
659
- if (!currentSlot) {
903
+ if (!previous) {
660
904
  node.dataset.bcEmpty = "true";
661
905
  document.documentElement.dataset.bcActive = "true";
662
906
  document.documentElement.dataset.bcMedia =
@@ -668,17 +912,34 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
668
912
  function nextFrame(signal) {
669
913
  if (typeof requestAnimationFrame !== "function") return Promise.resolve();
670
914
  return new Promise((resolve, reject) => {
915
+ let settled = false;
671
916
  let request = null;
672
- const aborted = () => {
673
- 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
+ }
674
923
  signal?.removeEventListener?.("abort", aborted);
675
- reject(abortError());
676
924
  };
677
- request = requestAnimationFrame(() => {
678
- signal?.removeEventListener?.("abort", aborted);
679
- if (signal?.aborted) reject(abortError());
925
+ const finish = (error = null) => {
926
+ if (settled) return;
927
+ settled = true;
928
+ cleanup();
929
+ if (error) reject(error);
680
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();
681
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);
682
943
  signal?.addEventListener?.("abort", aborted, { once: true });
683
944
  if (signal?.aborted) aborted();
684
945
  });
@@ -711,16 +972,21 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
711
972
  });
712
973
  }
713
974
 
714
- async function commitCandidate(payload, slot, signal) {
975
+ async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
715
976
  throwIfAborted(signal);
716
977
  const node = stage();
717
- const previous = currentSlot;
718
- 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) {
719
985
  await nextFrame(signal);
720
986
  await nextFrame(signal);
721
987
  throwIfAborted(signal);
722
988
  node.dataset.bcTransitioning = "true";
723
- await waitForCrossfade(slot, signal);
989
+ await waitForCrossfade(previous, signal);
724
990
  }
725
991
  throwIfAborted(signal);
726
992
  if (previous && previous !== slot) disposeSlot(previous);
@@ -739,7 +1005,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
739
1005
  const node = document.getElementById("beauticode-bg-stage");
740
1006
  node?.removeAttribute("data-bc-transitioning");
741
1007
  node?.removeAttribute("data-bc-empty");
742
- if (committedPayload && currentSlot) {
1008
+ if (committedPayload && mountedCurrentSlot()) {
743
1009
  updateCommittedDom(committedPayload);
744
1010
  playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
745
1011
  return;
@@ -751,12 +1017,19 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
751
1017
  }
752
1018
 
753
1019
  function syncGallery(payload) {
754
- const on = payload?.atmosphere?.preset === "gallery";
755
- globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
756
- }
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
+ }
757
1028
 
758
1029
  async function applyBackground(payload, signal) {
759
1030
  throwIfAborted(signal);
1031
+ const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
1032
+ const remaining = () => Math.max(1, applyDeadline - performance.now());
760
1033
  if (payload.media === "clear") {
761
1034
  desiredModes.fish = false;
762
1035
  playbackBlocked = false;
@@ -775,13 +1048,20 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
775
1048
  await acknowledgeMode();
776
1049
  return;
777
1050
  }
778
- if (typeof payload.imageUrl !== "string") return;
779
- const structureIssue = dshStructureIssue();
780
- if (structureIssue) {
781
- await acknowledgeRender(payload, false, false, structureIssue);
1051
+ if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
1052
+ await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
782
1053
  return;
783
1054
  }
784
- 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) {
785
1065
  committedPayload = payload;
786
1066
  currentSlot.dataset.bcGeneration = String(payload.generation);
787
1067
  updateCommittedDom(payload);
@@ -791,16 +1071,17 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
791
1071
  return;
792
1072
  }
793
1073
  let candidate = null;
1074
+ let candidateVideo = null;
794
1075
  let committed = false;
795
1076
  try {
796
- const image = await loadImage(payload.imageUrl, signal);
797
- throwIfAborted(signal);
1077
+ await waitForDshStructure(signal, remaining());
798
1078
  if (payload.media === "image") {
799
- if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
800
- playbackBlocked = false;
801
- candidate = createSlot(payload, image);
1079
+ candidate = createSlot(payload);
802
1080
  attachCandidate(candidate, payload);
803
- 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);
804
1085
  committed = true;
805
1086
  await acknowledgeRender(payload, true, true);
806
1087
  await acknowledgeMode();
@@ -810,32 +1091,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
810
1091
  throw new Error("视频载荷无效");
811
1092
  }
812
1093
  const video = document.createElement("video");
813
- video.autoplay = false;
1094
+ video.autoplay = true;
814
1095
  video.loop = true;
815
1096
  video.playsInline = true;
816
1097
  video.preload = "auto";
817
- video.poster = payload.imageUrl;
818
- video.src = payload.videoUrl;
819
- 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);
820
1105
  attachCandidate(candidate, payload);
1106
+ candidateVideo = video;
1107
+ video.src = payload.videoUrl;
821
1108
  video.load();
822
- const deadline = performance.now() + VIDEO_STARTUP_TIMEOUT_MS;
823
- const remaining = () => Math.max(1, deadline - performance.now());
824
- 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
+ ]);
825
1118
  throwIfAborted(signal);
826
- seekVideo(video, payload.startAt);
1119
+ const appliedStartAt = seekVideo(video, payload.startAt);
1120
+ candidate.dataset.bcStartAt = String(appliedStartAt);
827
1121
  await waitForSeek(video, signal, remaining());
828
1122
  await waitForVideo(video, signal, remaining());
829
- await startCandidatePlayback(video, signal, remaining());
830
1123
  await waitForStablePlayback(video, signal, remaining());
831
1124
  throwIfAborted(signal);
832
1125
  candidate.dataset.bcVideoReady = "true";
833
- await commitCandidate(payload, candidate, signal);
1126
+ await commitCandidate(payload, candidate, signal, remaining);
834
1127
  committed = true;
835
1128
  const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
836
1129
  await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
837
1130
  await acknowledgeMode();
838
1131
  } catch (error) {
1132
+ if (candidateVideo && candidateVideo.parentElement !== candidate) {
1133
+ disposeVideo(candidateVideo);
1134
+ candidateVideo.remove?.();
1135
+ }
839
1136
  if (candidate && !committed) disposeSlot(candidate);
840
1137
  if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
841
1138
  restoreCommittedDom();
@@ -850,6 +1147,22 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
850
1147
 
851
1148
  function scheduleBackground(payload) {
852
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
+ }
853
1166
  applyController?.abort();
854
1167
  discardCandidates();
855
1168
  const controller = new AbortController();
@@ -857,11 +1170,23 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
857
1170
  activePayload = payload;
858
1171
  renderPhase = "pending";
859
1172
  document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
860
- void applyBackground(payload, controller.signal).finally(() => {
861
- if (applyController !== controller) return;
862
- applyController = null;
863
- document.documentElement.removeAttribute("data-bc-pending-generation");
864
- });
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(() => {});
865
1190
  }
866
1191
 
867
1192
  async function applyModes(payload) {
@@ -879,8 +1204,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
879
1204
  const candidate = pendingVideo();
880
1205
  if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
881
1206
  if (video instanceof HTMLVideoElement) {
882
- try {
883
- await playWithPreference(video);
1207
+ try {
1208
+ playbackBlocked = await playWithPreference(video);
884
1209
  } catch {
885
1210
  playbackBlocked = desiredModes.muted === false;
886
1211
  }
@@ -903,25 +1228,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
903
1228
  events.onmessage = (event) => {
904
1229
  try {
905
1230
  const payload = JSON.parse(event.data);
906
- if (payload?.type === "mode") void applyModes(payload);
1231
+ if (payload?.type === "mode") void applyModes(payload).catch(() => {});
907
1232
  else if (payload?.type === "apply") scheduleBackground(payload);
908
1233
  } catch {
909
1234
  /* EventSource will continue with the next valid frame. */
910
1235
  }
911
1236
  };
912
1237
 
913
- setInterval(() => {
914
- const video = activeVideo();
915
- if (
916
- committedPayload?.media === "video" &&
917
- activePayload === committedPayload &&
918
- renderPhase === "ready" &&
919
- video instanceof HTMLVideoElement
920
- ) {
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
+ ) {
921
1269
  // A heartbeat is observational, not a second render verdict. Playback can
922
1270
  // briefly pause while Chromium changes modes or refills an 8K buffer; do
923
1271
  // not downgrade an already-rendered generation or fail a pending one.
924
- void acknowledgeRender(committedPayload, true, true);
1272
+ void acknowledgeRender(committedPayload, true, true).catch(() => {});
925
1273
  }
926
1274
  }, 1_000);
927
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.15",
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",