beauticode-dsh 1.0.21 → 1.0.23

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/agent.mjs CHANGED
@@ -124,6 +124,23 @@ function unwrapApply(result, fallbackMode, message) {
124
124
  };
125
125
  }
126
126
 
127
+ // The raw manifest exposes a local source's absolute on-disk path. The status
128
+ // tool result is surfaced to the model, so only forward a safe summary.
129
+ function sanitizeBackground(background) {
130
+ if (!background) return null;
131
+ const safe = {
132
+ type: background.type,
133
+ ...(background.effects ? { effects: background.effects } : {}),
134
+ };
135
+ if (background.source) {
136
+ safe.source =
137
+ background.source.kind === "local"
138
+ ? { kind: "local" }
139
+ : { kind: "managed", file: background.source.file };
140
+ }
141
+ return safe;
142
+ }
143
+
127
144
  function presentStatus(status) {
128
145
  const background = status.manifest?.background ?? status.background ?? null;
129
146
  return {
@@ -133,7 +150,7 @@ function presentStatus(status) {
133
150
  fish: status.fish === true,
134
151
  muted: status.muted !== false,
135
152
  tone: status.tone ?? "dark",
136
- background,
153
+ background: sanitizeBackground(background),
137
154
  sourceMode: background
138
155
  ? background.source?.kind === "local"
139
156
  ? "local"
@@ -350,33 +367,36 @@ export function createBeauticodeActions(dataRootOrOptions) {
350
367
  const name = String(input?.name ?? "").trim();
351
368
  const imagePath = String(input?.imagePath ?? "").trim();
352
369
  if (!name || !imagePath) fail("导入皮肤必须提供名称和图片。");
353
- const body = {
354
- name,
355
- imagePath,
356
- };
357
- if (typeof input.videoPath === "string" && input.videoPath.trim()) {
358
- body.videoPath = input.videoPath.trim();
359
- }
360
- if (input.effects) body.effects = input.effects;
361
- if (input.source) body.source = input.source;
370
+ const source = input?.source === "managed" ? "managed" : "local";
371
+ const hasVideo = typeof input.videoPath === "string" && Boolean(input.videoPath.trim());
372
+ const themeInput = hasVideo
373
+ ? {
374
+ type: "video",
375
+ videoPath: input.videoPath.trim(),
376
+ imagePath,
377
+ source,
378
+ }
379
+ : { type: "image", imagePath, source };
380
+ if (!hasVideo && input.effects) themeInput.effects = input.effects;
362
381
  const resolved = await backend();
363
- if (resolved.kind === "tray") {
364
- const result = await request({
365
- method: "POST",
366
- path: "/theme/import",
367
- body,
368
- signal,
369
- timeoutMs: 30 * 60 * 1000,
370
- });
371
- if (!result || result.ok === false) fail(result?.error || "导入皮肤失败。");
372
- return {
373
- ok: true,
374
- theme: result.theme,
375
- message: `已保存皮肤「${result.theme.name}」。`,
376
- };
377
- }
378
- const theme = await resolved.session.importSavedTheme(body);
379
- return { ok: true, theme, message: `已保存皮肤「${theme.name}」。` };
382
+ // Gallery import applies-and-saves through the same atomic transaction as
383
+ // the normal image/video flow. There is no separate import-only endpoint:
384
+ // the tray exposes /theme/apply and DshSession exposes applyAndSaveTheme.
385
+ const result =
386
+ resolved.kind === "tray"
387
+ ? await request({
388
+ method: "POST",
389
+ path: "/theme/apply",
390
+ body: { name, input: themeInput },
391
+ signal,
392
+ timeoutMs: 30 * 60 * 1000,
393
+ })
394
+ : await resolved.session.applyAndSaveTheme(themeInput, name);
395
+ return unwrapApplyResult(
396
+ result,
397
+ hasVideo ? "video" : "image",
398
+ `已保存皮肤「${name}」。`,
399
+ );
380
400
  },
381
401
 
382
402
  async listThemes(signal) {
package/atmosphere.js CHANGED
@@ -1,4 +1,4 @@
1
- (() => {
1
+ (() => {
2
2
  "use strict";
3
3
 
4
4
  const STYLE_ID = "beauticode-atmosphere-style";
@@ -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/cli.js CHANGED
@@ -298,14 +298,26 @@ async function sameLinkTarget(link, dest) {
298
298
  return false;
299
299
  }
300
300
 
301
+ // A dangling symlink has a live directory entry even though fs.existsSync
302
+ // (which follows the link) reports false. Use lstat so leftover plugin links
303
+ // are detected and cleaned instead of making fsp.symlink fail with EEXIST.
304
+ async function entryExists(p) {
305
+ try {
306
+ await fsp.lstat(p);
307
+ return true;
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+
301
313
  async function linkPluginIntoProfile(webProfile, dest) {
302
314
  const link = pluginLinkPath(webProfile);
303
315
  const legacy = legacyPluginLinkPath(webProfile);
304
- if (fs.existsSync(legacy)) {
316
+ if (await entryExists(legacy)) {
305
317
  await fsp.rm(legacy, { recursive: true, force: true });
306
318
  }
307
319
  await fsp.mkdir(path.dirname(link), { recursive: true });
308
- if (fs.existsSync(link)) {
320
+ if (await entryExists(link)) {
309
321
  if (await sameLinkTarget(link, dest)) return;
310
322
  await fsp.rm(link, { recursive: true, force: true });
311
323
  }
@@ -403,7 +415,7 @@ async function uninstall(opts) {
403
415
  removed.push("web package.json");
404
416
  }
405
417
  for (const link of [pluginLinkPath(webProfile), legacyPluginLinkPath(webProfile)]) {
406
- if (fs.existsSync(link)) {
418
+ if (await entryExists(link)) {
407
419
  await fsp.rm(link, { recursive: true, force: true });
408
420
  removed.push(link);
409
421
  }
package/client.js CHANGED
@@ -17,6 +17,13 @@
17
17
  const IMAGE_MAX_ATTEMPTS = 2;
18
18
  const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
19
19
  const VIDEO_PROBE_TIMEOUT_MS = 2_000;
20
+ // Phase-one playback acceptance window. A muted play() that is still pending
21
+ // after this only means the decoder is warming; the settle phase owns it.
22
+ const PLAY_ACCEPT_TIMEOUT_MS = 1_500;
23
+ // Phase-two budget for the committed video to present its first frame. It is
24
+ // deliberately far beyond any host deadline: a poster already committed, so
25
+ // slow first frames upgrade in place instead of failing the transaction.
26
+ const VIDEO_SETTLE_TIMEOUT_MS = 60_000;
20
27
  const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
21
28
  const FRAME_FALLBACK_MS = 120;
22
29
  const VIDEO_FIRST_FRAME_PROGRESS_SEC = 0.03;
@@ -32,6 +39,7 @@
32
39
  let playbackBlocked = false;
33
40
  let currentSlot = null;
34
41
  let applyController = null;
42
+ let videoSettleController = null;
35
43
  const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
36
44
  const reducedMotionMedia =
37
45
  globalThis.matchMedia?.("(prefers-reduced-motion: reduce)") ?? null;
@@ -298,10 +306,14 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
298
306
  };
299
307
  }
300
308
 
301
- async function acknowledgeRender(payload, ok, visible, error = null) {
309
+ async function acknowledgeRender(payload, ok, visible, error = null, extra = {}) {
302
310
  if (activePayload === payload) {
303
311
  renderPhase = ok ? "ready" : "failed";
304
312
  }
313
+ // A committed video apply may still be settling: ok=true with
314
+ // videoReady=false means the poster is live and the first frame upgrades
315
+ // in place. Only ok=false is a renderer verdict the host will act on.
316
+ const videoReady = extra.videoReady !== false;
305
317
  await postAck({
306
318
  kind: "render",
307
319
  generation: payload.generation,
@@ -309,8 +321,9 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
309
321
  ok,
310
322
  visible,
311
323
  error,
324
+ ...(payload.media === "video" ? { videoReady } : {}),
312
325
  playback:
313
- ok && committedPayload === payload && payload.media === "video"
326
+ ok && committedPayload === payload && payload.media === "video" && videoReady
314
327
  ? playbackSnapshot(activeVideo())
315
328
  : null,
316
329
  });
@@ -672,6 +685,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
672
685
  function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
673
686
  const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
674
687
  if (video.readyState >= frameReadyState) return Promise.resolve();
688
+ // A decoder error may fire before this waiter attaches — most notably
689
+ // between the poster commit's crossfade and the settle start. Honoring an
690
+ // already-set error keeps the settle phase from waiting out its full
691
+ // budget on media that has already failed.
692
+ if (video.error) {
693
+ return Promise.reject(new Error(describeVideoState(video, "MP4 加载或解码失败")));
694
+ }
675
695
  return new Promise((resolve, reject) => {
676
696
  let settled = false;
677
697
  const done = () => finish();
@@ -990,36 +1010,32 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
990
1010
  return blocked;
991
1011
  }
992
1012
 
1013
+ // Phase one only needs playback initiated. On a cold machine the muted
1014
+ // play() promise can stay pending well past the apply deadline while the
1015
+ // decoder warms; that is not a failure — the poster-first commit tolerates
1016
+ // it and the settle phase owns the eventual outcome. Only an outright
1017
+ // rejection (autoplay policy, detached element) fails the apply.
993
1018
  async function startCandidatePlayback(video, signal, timeoutMs) {
994
1019
  const playbackController = new AbortController();
995
1020
  const parentAborted = () => playbackController.abort();
996
1021
  signal?.addEventListener?.("abort", parentAborted, { once: true });
997
- let timer = null;
1022
+ let acceptTimer = null;
998
1023
  let abortListener = null;
999
- const playback = playWithPreference(video, playbackController.signal);
1000
- const deadline = new Promise((_, reject) => {
1001
- timer = setTimeout(() => {
1002
- reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
1003
- playbackController.abort();
1004
- }, timeoutMs);
1005
- abortListener = () => reject(abortError());
1024
+ const accepted = new Promise((resolve) => {
1025
+ acceptTimer = setTimeout(() => resolve("accepted"), Math.max(1, timeoutMs));
1026
+ abortListener = () => resolve("aborted");
1006
1027
  playbackController.signal.addEventListener("abort", abortListener, { once: true });
1007
1028
  });
1008
1029
  if (signal?.aborted) parentAborted();
1030
+ const playback = playWithPreference(video, playbackController.signal);
1031
+ // The settle phase owns late outcomes of an accepted-but-unresolved play().
1032
+ void playback.catch?.(() => {});
1009
1033
  try {
1010
- // The play() promise itself resolves only after playback has started, so
1011
- // a second playing-event waiter would create an orphaned promise race.
1012
- return await Promise.race([playback, deadline]);
1013
- } catch (error) {
1014
- playbackController.abort();
1015
- try {
1016
- video.pause();
1017
- } catch {
1018
- /* best effort: disposeSlot will release src as the final guard */
1019
- }
1020
- throw error;
1034
+ const outcome = await Promise.race([playback.then(() => "started"), accepted]);
1035
+ if (outcome === "aborted") throw abortError();
1036
+ return outcome;
1021
1037
  } finally {
1022
- if (timer) clearTimeout(timer);
1038
+ clearTimeout(acceptTimer);
1023
1039
  signal?.removeEventListener?.("abort", parentAborted);
1024
1040
  if (abortListener) {
1025
1041
  playbackController.signal.removeEventListener("abort", abortListener);
@@ -1111,7 +1127,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1111
1127
  return true;
1112
1128
  }
1113
1129
 
1114
- function updateCommittedDom(payload) {
1130
+ function updateCommittedDom(payload, videoReady = true) {
1115
1131
  document.documentElement.dataset.bcGeneration = String(payload.generation);
1116
1132
  document.documentElement.removeAttribute("data-bc-pending-generation");
1117
1133
  if (payload.media === "clear") {
@@ -1122,8 +1138,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1122
1138
  }
1123
1139
  document.documentElement.dataset.bcActive = "true";
1124
1140
  document.documentElement.dataset.bcMedia = payload.media;
1125
- if (payload.media === "video") document.documentElement.dataset.bcVideoReady = "true";
1126
- else document.documentElement.removeAttribute("data-bc-video-ready");
1141
+ if (payload.media === "video") {
1142
+ // "false" keeps the poster as the committed visual until the settle
1143
+ // phase flips it after the first presented frame.
1144
+ document.documentElement.dataset.bcVideoReady = videoReady ? "true" : "false";
1145
+ } else {
1146
+ document.documentElement.removeAttribute("data-bc-video-ready");
1147
+ }
1127
1148
  }
1128
1149
 
1129
1150
  function discardCandidates() {
@@ -1240,7 +1261,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1240
1261
  committedPayload = payload;
1241
1262
  const video = activeVideo();
1242
1263
  playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true";
1243
- updateCommittedDom(payload);
1264
+ updateCommittedDom(payload, slot.dataset.bcVideoReady === "true");
1244
1265
  syncGallery(payload);
1245
1266
  }
1246
1267
 
@@ -1249,7 +1270,7 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1249
1270
  node?.removeAttribute("data-bc-transitioning");
1250
1271
  node?.removeAttribute("data-bc-empty");
1251
1272
  if (committedPayload && mountedCurrentSlot()) {
1252
- updateCommittedDom(committedPayload);
1273
+ updateCommittedDom(committedPayload, currentSlot.dataset.bcVideoReady === "true");
1253
1274
  playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
1254
1275
  return;
1255
1276
  }
@@ -1369,40 +1390,29 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1369
1390
  candidateVideo = video;
1370
1391
  video.src = payload.videoUrl;
1371
1392
  video.load();
1372
- // Start the real media request before the diagnostic Range probe. During
1373
- // a cold Chromium/decoder start, serial probing used part of the same
1374
- // eight-second budget without advancing the frame that users can see.
1393
+ // Phase one gates only what the poster commit needs: the media source
1394
+ // answers Range requests, the poster decodes, and playback was
1395
+ // initiated. A cold decoder no longer fails the transaction here — the
1396
+ // video upgrades from the committed poster in the settle phase, so a
1397
+ // fresh machine that merely loads slowly can never time out.
1375
1398
  const sourceProbe = probeVideoSource(
1376
1399
  payload.videoUrl,
1377
1400
  signal,
1378
1401
  Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
1379
1402
  );
1380
1403
  const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
1381
- // play() is deliberately started alongside media readiness. Chromium is
1382
- // allowed to ignore preload=auto for hidden/background media; waiting for
1383
- // canplay before play() creates a circular stall at readyState=0.
1384
1404
  await Promise.all([
1385
1405
  sourceProbe,
1386
1406
  imageReady,
1387
- waitForVideo(video, signal, remaining()),
1388
- startCandidatePlayback(video, signal, remaining()),
1407
+ startCandidatePlayback(video, signal, Math.min(PLAY_ACCEPT_TIMEOUT_MS, remaining())),
1389
1408
  ]);
1390
1409
  throwIfAborted(signal);
1391
- const appliedStartAt = seekVideo(video, payload.startAt);
1392
- candidate.dataset.bcStartAt = String(appliedStartAt);
1393
- await waitForSeek(video, signal, remaining());
1394
- await waitForVideo(video, signal, remaining());
1395
- // Transaction success means one frame was actually presented. Longer
1396
- // three-frame stability remains useful for reusing an existing slot, but
1397
- // must not turn a healthy cold decoder into a false first-frame failure.
1398
- await waitForPresentedFrame(video, signal, remaining());
1399
- throwIfAborted(signal);
1400
- candidate.dataset.bcVideoReady = "true";
1401
1410
  await commitCandidate(payload, candidate, signal, remaining);
1402
1411
  committed = true;
1403
- const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
1404
- await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
1412
+ await acknowledgeRender(payload, true, true, null, { videoReady: false });
1405
1413
  await acknowledgeMode();
1414
+ settleCommittedVideo(video, candidate, payload);
1415
+ return;
1406
1416
  } catch (error) {
1407
1417
  if (candidateVideo && candidateVideo.parentElement !== candidate) {
1408
1418
  disposeVideo(candidateVideo);
@@ -1420,6 +1430,48 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1420
1430
  }
1421
1431
  }
1422
1432
 
1433
+ // Phase two of a video apply. The poster is already committed and acked, so
1434
+ // nothing here can roll the transaction back: a decoder that needs longer
1435
+ // than any host deadline simply keeps the poster until the first frame
1436
+ // presents, then upgrades the committed slot in place without a flash.
1437
+ function settleCommittedVideo(video, slot, payload) {
1438
+ videoSettleController?.abort();
1439
+ const controller = new AbortController();
1440
+ videoSettleController = controller;
1441
+ const stillSettling = () => !controller.signal.aborted && mountedCurrentSlot() === slot;
1442
+ void (async () => {
1443
+ try {
1444
+ await waitForVideo(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
1445
+ throwIfAborted(controller.signal);
1446
+ if (!stillSettling()) return;
1447
+ const appliedStartAt = seekVideo(video, payload.startAt);
1448
+ slot.dataset.bcStartAt = String(appliedStartAt);
1449
+ await waitForSeek(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
1450
+ await waitForVideo(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
1451
+ await waitForPresentedFrame(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
1452
+ if (!stillSettling()) return;
1453
+ slot.dataset.bcVideoReady = "true";
1454
+ updateCommittedDom(payload, true);
1455
+ playbackBlocked = video.dataset?.bcPlaybackBlocked === "true";
1456
+ await acknowledgeRender(payload, true, true, null, { videoReady: true });
1457
+ } catch (error) {
1458
+ if (isAbortError(error) || controller.signal.aborted || !stillSettling()) return;
1459
+ // Keep the committed poster; drop the stalled decoder so a later apply
1460
+ // or re-import starts from a clean media element.
1461
+ disposeVideo(video);
1462
+ video.remove?.();
1463
+ slot.removeAttribute("data-bc-video-ready");
1464
+ await acknowledgeRender(
1465
+ payload,
1466
+ true,
1467
+ true,
1468
+ `视频预热未完成(已保留封面):${error instanceof Error ? error.message : String(error)}`,
1469
+ { videoReady: false },
1470
+ );
1471
+ }
1472
+ })();
1473
+ }
1474
+
1423
1475
  function scheduleBackground(payload) {
1424
1476
  if (!Number.isSafeInteger(payload?.generation)) return;
1425
1477
  // EventSource reconnects can replay the current frame while its first
@@ -1439,6 +1491,8 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1439
1491
  return;
1440
1492
  }
1441
1493
  applyController?.abort();
1494
+ videoSettleController?.abort();
1495
+ videoSettleController = null;
1442
1496
  discardCandidates();
1443
1497
  const controller = new AbortController();
1444
1498
  applyController = controller;
@@ -1489,7 +1543,9 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1489
1543
  activePayload === committedPayload &&
1490
1544
  renderPhase === "ready"
1491
1545
  ) {
1492
- await acknowledgeRender(committedPayload, true, true);
1546
+ await acknowledgeRender(committedPayload, true, true, null, {
1547
+ videoReady: mountedCurrentSlot()?.dataset.bcVideoReady === "true",
1548
+ });
1493
1549
  }
1494
1550
  } else {
1495
1551
  playbackBlocked = false;
@@ -1544,7 +1600,11 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
1544
1600
  // A heartbeat is observational, not a second render verdict. Playback can
1545
1601
  // briefly pause while Chromium changes modes or refills an 8K buffer; do
1546
1602
  // not downgrade an already-rendered generation or fail a pending one.
1547
- void acknowledgeRender(committedPayload, true, true).catch(() => {});
1603
+ // Report the real first-frame state: a committed video that still only
1604
+ // shows its poster must not be acked as video-ready (see settleCommittedVideo).
1605
+ void acknowledgeRender(committedPayload, true, true, null, {
1606
+ videoReady: slot.dataset.bcVideoReady === "true",
1607
+ }).catch(() => {});
1548
1608
  }
1549
1609
  }, 1_000);
1550
1610
  })();
package/console.js CHANGED
@@ -128,12 +128,15 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
128
128
 
129
129
  function place() {
130
130
  const settings = findSettingsTrigger();
131
- if (!settings || !settings.parentElement) {
131
+ const row = settings?.parentElement;
132
+ const settingsArea = row?.parentElement;
133
+ const footArea = settingsArea?.parentElement;
134
+ if (!settings || !settingsArea || !footArea) {
132
135
  if (host.parentElement) host.remove();
133
136
  return;
134
137
  }
135
- if (host.parentElement !== settings.parentElement || host.nextElementSibling !== settings) {
136
- settings.parentElement.insertBefore(host, settings);
138
+ if (host.parentElement !== footArea || host.nextElementSibling !== settingsArea) {
139
+ footArea.insertBefore(host, settingsArea);
137
140
  }
138
141
  host.classList.toggle("rail", settings.getBoundingClientRect().width <= 40);
139
142
  if (!pop.hidden) placePop();
@@ -252,7 +255,12 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
252
255
  return body;
253
256
  } catch (error) {
254
257
  if (controller?.signal.aborted) {
255
- throw new Error("背景操作超时,控件已恢复。原背景保持不变,请重试。");
258
+ // The browser only cancels its own fetch; a slow backend transaction
259
+ // (managed-video copy or verify) may still commit afterwards. Don't
260
+ // claim the previous background was preserved — report honestly.
261
+ throw new Error(
262
+ "背景操作超时,控件已恢复。操作可能仍在后台进行,请先查看当前背景状态再重试。",
263
+ );
256
264
  }
257
265
  throw error;
258
266
  } finally {
@@ -427,7 +435,9 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color:
427
435
  event.stopPropagation();
428
436
  setOpen(false);
429
437
  if (window.BeauticodeGallery) {
430
- void window.BeauticodeGallery.open();
438
+ window.BeauticodeGallery.open().catch((error) => {
439
+ showMessage(error instanceof Error ? error.message : String(error));
440
+ });
431
441
  return;
432
442
  }
433
443
  showMessage("皮肤中心脚本尚未加载。");
package/gallery-host.mjs CHANGED
@@ -12,6 +12,9 @@ const SKIN_ID = /^skin-[a-z0-9]{8,40}$/;
12
12
  const MAX_IMAGE_BYTES = 18 * 1024 * 1024;
13
13
  const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
14
14
  const INSTALL_TIMEOUT_MS = 30 * 60 * 1000;
15
+ // Small catalog/metadata requests get a tight budget; the media download uses
16
+ // INSTALL_TIMEOUT_MS so a large video on a slow link can still finish.
17
+ const REQUEST_TIMEOUT_MS = 30_000;
15
18
  const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1"]);
16
19
 
17
20
  export function isSafeSkinId(id) {
@@ -58,12 +61,19 @@ export function skinUrl(center, id, part = "") {
58
61
  return part ? `${origin}/api/skins/${id}/${part}` : `${origin}/api/skins/${id}`;
59
62
  }
60
63
 
61
- export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress } = {}) {
64
+ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onProgress, signal } = {}) {
62
65
  const expected = new URL(url);
63
66
  if (expectedOrigin && expected.origin !== expectedOrigin) {
64
67
  throw new Error("Skin media download host mismatch.");
65
68
  }
66
- const response = await fetch(url, { redirect: "follow" });
69
+ // Bound every media download; a stalling skin center must not leave the
70
+ // gallery request open forever. Callers may pass an AbortSignal to cancel
71
+ // early when the browser client disconnects.
72
+ const timeoutSignal = AbortSignal.timeout(INSTALL_TIMEOUT_MS);
73
+ const response = await fetch(url, {
74
+ redirect: "follow",
75
+ signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
76
+ });
67
77
  if (!response.ok || !response.body) {
68
78
  throw new Error("Skin media download failed.");
69
79
  }
@@ -77,6 +87,8 @@ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onPr
77
87
  }
78
88
  await fsp.mkdir(path.dirname(dest), { recursive: true });
79
89
  let size = 0;
90
+ let lastReportedAt = 0;
91
+ let lastReportedBytes = 0;
80
92
  const limiter = new Transform({
81
93
  transform(chunk, _enc, callback) {
82
94
  size += chunk.length;
@@ -84,12 +96,26 @@ export async function downloadToFile(url, dest, { maxBytes, expectedOrigin, onPr
84
96
  callback(new Error("Skin media download exceeded the size limit."));
85
97
  return;
86
98
  }
87
- onProgress?.(size, Number.isFinite(length) ? length : 0);
99
+ // Throttle progress frames: one every 250 ms or every 1 MiB, whichever
100
+ // comes first. A chunk-level frame floods the NDJSON stream on large
101
+ // videos and forces a DOM write per chunk in the browser client.
102
+ const now = Date.now();
103
+ if (now - lastReportedAt >= 250 || size - lastReportedBytes >= 1024 * 1024) {
104
+ lastReportedAt = now;
105
+ lastReportedBytes = size;
106
+ onProgress?.(size, Number.isFinite(length) ? length : 0);
107
+ }
88
108
  callback(null, chunk);
89
109
  },
110
+ flush(callback) {
111
+ if (size !== lastReportedBytes) {
112
+ onProgress?.(size, Number.isFinite(length) ? length : 0);
113
+ }
114
+ callback();
115
+ },
90
116
  });
91
117
  await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(dest));
92
- return { bytes: size };
118
+ return { bytes: size, contentType: response.headers.get("content-type") ?? null };
93
119
  }
94
120
 
95
121
  function extensionOf(url, fallback) {
@@ -102,6 +128,38 @@ function extensionOf(url, fallback) {
102
128
  return fallback;
103
129
  }
104
130
 
131
+ // Theme names are persisted via saveCurrentTheme, which rejects characters that
132
+ // are illegal in filenames. Normalize gallery skin display names the same way
133
+ // local filenames are normalized so a name like "Cyberpunk: Neon" still installs.
134
+ function normalizeThemeName(value, fallback = "主题") {
135
+ const name = String(value ?? "")
136
+ .replace(/[<>:"/\\|?*]/g, " ")
137
+ .replace(/[\u0000-\u001f]/g, "")
138
+ .replace(/\s+/g, " ")
139
+ .trim()
140
+ .slice(0, 80);
141
+ return name || fallback;
142
+ }
143
+
144
+ // The /image endpoint is extensionless; derive the on-disk suffix from the
145
+ // response content-type so JPEG/WebP are not mislabeled as .png (which the
146
+ // image validator would then reject on a magic-byte mismatch).
147
+ function extensionForContentType(contentType) {
148
+ const mime = String(contentType ?? "").split(";")[0].trim().toLowerCase();
149
+ switch (mime) {
150
+ case "image/jpeg":
151
+ return ".jpg";
152
+ case "image/png":
153
+ return ".png";
154
+ case "image/webp":
155
+ return ".webp";
156
+ case "image/avif":
157
+ return ".avif";
158
+ default:
159
+ return null;
160
+ }
161
+ }
162
+
105
163
  export function createGalleryHandlers({ dataRoot, actions }) {
106
164
  async function importTheme(input) {
107
165
  if (typeof actions.importTheme === "function") {
@@ -143,9 +201,24 @@ export function createGalleryHandlers({ dataRoot, actions }) {
143
201
  return;
144
202
  }
145
203
  const incoming = new URL(req.url || "/", "http://127.0.0.1");
146
- const target = new URL("/api/catalog", `${center}/`);
204
+ // Resolve relative to the center's own path so a base like
205
+ // https://host/beauticode keeps its prefix (skinUrl already does).
206
+ const target = new URL("api/catalog", `${center}/`);
147
207
  target.search = incoming.search;
148
- const response = await fetch(target, { headers: { accept: "application/json" } });
208
+ let response;
209
+ try {
210
+ response = await fetch(target, {
211
+ headers: { accept: "application/json" },
212
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
213
+ });
214
+ } catch (error) {
215
+ sendJson(res, 502, {
216
+ ok: false,
217
+ error: "无法连接皮肤中心,请稍后重试。",
218
+ skins: [],
219
+ });
220
+ return;
221
+ }
149
222
  const body = await response.json().catch(() => null);
150
223
  if (!response.ok || !body || body.ok === false) {
151
224
  sendJson(res, 422, {
@@ -192,9 +265,18 @@ export function createGalleryHandlers({ dataRoot, actions }) {
192
265
  if (!res.writableEnded) res.write(`${JSON.stringify(payload)}\n`);
193
266
  };
194
267
  const tmpDir = path.join(dataRoot, "tmp", "gallery", `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
268
+ // Cancel the download when the browser client disconnects (e.g. the UI
269
+ // timeout aborts its fetch); otherwise a slow skin download keeps going
270
+ // and can still commit a background after the controls recovered.
271
+ const clientAbort = new AbortController();
272
+ const onClose = () => clientAbort.abort(new Error("客户端已断开。"));
273
+ res.once("close", onClose);
195
274
  try {
196
275
  write({ phase: "fetch" });
197
- const metaRes = await fetch(skinUrl(center, id), { headers: { accept: "application/json" } });
276
+ const metaRes = await fetch(skinUrl(center, id), {
277
+ headers: { accept: "application/json" },
278
+ signal: AbortSignal.any([clientAbort.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
279
+ });
198
280
  const meta = await metaRes.json().catch(() => null);
199
281
  const skin = meta?.skin;
200
282
  if (!metaRes.ok || !skin || skin.status && skin.status !== "approved") {
@@ -202,13 +284,19 @@ export function createGalleryHandlers({ dataRoot, actions }) {
202
284
  }
203
285
  await fsp.mkdir(tmpDir, { recursive: true });
204
286
  const imageUrl = skinUrl(center, id, "image");
205
- const imagePath = path.join(tmpDir, `image${extensionOf(imageUrl, ".png")}`);
287
+ const imageTmp = path.join(tmpDir, "image.download");
206
288
  write({ phase: "download", part: "image" });
207
- await downloadToFile(imageUrl, imagePath, {
289
+ const imageDownload = await downloadToFile(imageUrl, imageTmp, {
208
290
  maxBytes: MAX_IMAGE_BYTES,
209
291
  expectedOrigin: origin,
292
+ signal: clientAbort.signal,
210
293
  onProgress: (done, total) => write({ phase: "download", part: "image", done, total }),
211
294
  });
295
+ const imagePath = path.join(
296
+ tmpDir,
297
+ `image${extensionForContentType(imageDownload.contentType) ?? extensionOf(imageUrl, ".png")}`,
298
+ );
299
+ await fsp.rename(imageTmp, imagePath);
212
300
  let videoPath;
213
301
  if (skin.type === "video") {
214
302
  const videoUrl = skinUrl(center, id, "video");
@@ -217,25 +305,34 @@ export function createGalleryHandlers({ dataRoot, actions }) {
217
305
  await downloadToFile(videoUrl, videoPath, {
218
306
  maxBytes: MAX_VIDEO_BYTES,
219
307
  expectedOrigin: origin,
308
+ signal: clientAbort.signal,
220
309
  onProgress: (done, total) => write({ phase: "download", part: "video", done, total }),
221
310
  });
222
311
  }
223
- write({ phase: "import" });
312
+ // Skin-center media is downloaded into the store, so it is a managed
313
+ // import. importTheme applies-and-saves atomically; the media is copied
314
+ // before it returns, so tmpDir is safe to remove in finally.
315
+ write({ phase: "apply" });
316
+ // The apply/save step is atomic and cannot be interrupted; refuse to
317
+ // start it when the client already went away so a disconnected UI does
318
+ // not still commit and apply a background after the controls recovered.
319
+ if (clientAbort.signal.aborted) throw new Error("客户端已断开。");
224
320
  const imported = await importTheme({
225
- name: String(skin.name || id).slice(0, 80),
321
+ name: normalizeThemeName(skin.name || id),
226
322
  imagePath,
227
323
  ...(videoPath ? { videoPath } : {}),
228
324
  ...(skin.effects ? { effects: skin.effects } : {}),
229
- source: { kind: "skin-center", skinId: id, centerUrl: center },
325
+ source: "managed",
230
326
  });
231
- write({ phase: "apply" });
232
- const applied = await actions.useTheme(imported.theme?.id || imported.id, undefined);
233
- fetch(skinUrl(center, id, "download"), { method: "POST" }).catch(() => {});
327
+ fetch(skinUrl(center, id, "download"), {
328
+ method: "POST",
329
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
330
+ }).catch(() => {});
234
331
  write({
235
332
  ok: true,
236
333
  phase: "done",
237
334
  theme: imported.theme || imported,
238
- message: applied.message || `已安装并应用「${skin.name}」。`,
335
+ message: imported.message || `已安装并应用「${skin.name}」。`,
239
336
  });
240
337
  } catch (error) {
241
338
  write({
@@ -243,6 +340,7 @@ export function createGalleryHandlers({ dataRoot, actions }) {
243
340
  error: error instanceof Error ? error.message : String(error),
244
341
  });
245
342
  } finally {
343
+ res.off("close", onClose);
246
344
  await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
247
345
  res.end();
248
346
  }
package/gallery.js CHANGED
@@ -48,6 +48,10 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
48
48
  let centerUrl = "";
49
49
  let busy = false;
50
50
 
51
+ // Mirrors gallery-host.mjs::isSafeSkinId — catalog ids are only safe to
52
+ // render when they match the skin-<hex> shape the backend already validates.
53
+ const SKIN_ID = /^skin-[a-z0-9]{8,40}$/;
54
+
51
55
  function escapeText(value) {
52
56
  return String(value ?? "")
53
57
  .replaceAll("&", "&amp;")
@@ -55,6 +59,12 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
55
59
  .replaceAll(">", "&gt;");
56
60
  }
57
61
 
62
+ // Attribute context additionally needs quote escaping; escapeText alone lets
63
+ // a malicious catalog id break out of data-id/src and inject handlers.
64
+ function escapeAttr(value) {
65
+ return escapeText(value).replaceAll('"', "&quot;").replaceAll("'", "&#39;");
66
+ }
67
+
58
68
  async function request(path, init) {
59
69
  const response = await fetch(path, init);
60
70
  if ((response.headers.get("content-type") || "").includes("ndjson")) {
@@ -98,36 +108,48 @@ body:not([data-ds-dark-theme]) #beauticode-gallery .bcg-panel{background:#fff;co
98
108
 
99
109
  async function load() {
100
110
  msg.textContent = "正在读取目录…";
101
- const params = new URLSearchParams();
102
- if (queryInput.value.trim()) params.set("q", queryInput.value.trim());
103
- if (typeSelect.value) params.set("type", typeSelect.value);
104
- const data = await request(`/__beauticode/ui/gallery/catalog?${params}`);
105
- centerUrl = data.url || centerUrl;
106
- grid.innerHTML = (data.skins || [])
107
- .map(
108
- (skin) =>
109
- `<button type="button" class="bcg-card" data-id="${escapeText(skin.id)}">` +
110
- `<img alt="" src="${escapeText(centerUrl)}/api/skins/${escapeText(skin.id)}/card">` +
111
- `<span>${escapeText(skin.name)}${skin.type === "video" ? " · 视频" : ""}</span></button>`,
112
- )
113
- .join("");
114
- msg.textContent = data.skins?.length ? "" : "目录是空的。";
115
- foot.innerHTML = centerUrl
116
- ? `上传与审核在 <a href="${escapeText(centerUrl)}" target="_blank" rel="noreferrer">皮肤中心网站</a>。安装会下载到本机后再应用。`
117
- : "未配置皮肤中心地址。在插件的 skin-center.json 或环境变量 BEAUTICODE_SKIN_CENTER 里填入你的域名。";
111
+ try {
112
+ const params = new URLSearchParams();
113
+ if (queryInput.value.trim()) params.set("q", queryInput.value.trim());
114
+ if (typeSelect.value) params.set("type", typeSelect.value);
115
+ const data = await request(`/__beauticode/ui/gallery/catalog?${params}`);
116
+ centerUrl = data.url || centerUrl;
117
+ grid.innerHTML = (data.skins || [])
118
+ .filter((skin) => SKIN_ID.test(String(skin.id ?? "")))
119
+ .map(
120
+ (skin) =>
121
+ `<button type="button" class="bcg-card" data-id="${escapeAttr(skin.id)}">` +
122
+ `<img alt="" src="${escapeAttr(centerUrl)}/api/skins/${escapeAttr(skin.id)}/card">` +
123
+ `<span>${escapeText(skin.name)}${skin.type === "video" ? " · 视频" : ""}</span></button>`,
124
+ )
125
+ .join("");
126
+ msg.textContent = data.skins?.length ? "" : "目录是空的。";
127
+ foot.innerHTML = centerUrl
128
+ ? `上传与审核在 <a href="${escapeAttr(centerUrl)}" target="_blank" rel="noreferrer">皮肤中心网站</a>。安装会下载到本机后再应用。`
129
+ : "未配置皮肤中心地址。在插件的 skin-center.json 或环境变量 BEAUTICODE_SKIN_CENTER 里填入你的域名。";
130
+ } catch (error) {
131
+ grid.innerHTML = "";
132
+ msg.textContent = error instanceof Error ? error.message : String(error);
133
+ }
118
134
  }
119
135
 
120
136
  async function open() {
121
137
  host.hidden = false;
122
- const config = await request("/__beauticode/ui/gallery/config");
123
- centerUrl = config.url || "";
124
- if (!config.enabled) {
138
+ try {
139
+ const config = await request("/__beauticode/ui/gallery/config");
140
+ centerUrl = config.url || "";
141
+ if (!config.enabled) {
142
+ grid.innerHTML = "";
143
+ msg.textContent = "尚未配置皮肤中心地址。";
144
+ foot.textContent = "设置 BEAUTICODE_SKIN_CENTER,或在 skin-center.json 填写站点 URL。";
145
+ return;
146
+ }
147
+ await load();
148
+ } catch (error) {
125
149
  grid.innerHTML = "";
126
- msg.textContent = "尚未配置皮肤中心地址。";
127
- foot.textContent = "设置 BEAUTICODE_SKIN_CENTER,或在 skin-center.json 填写站点 URL。";
128
- return;
150
+ msg.textContent = error instanceof Error ? error.message : String(error);
151
+ throw error;
129
152
  }
130
- await load();
131
153
  }
132
154
 
133
155
  function close() {
package/host-apply.mjs CHANGED
@@ -7,8 +7,8 @@ import {
7
7
  } from "./control-client.mjs";
8
8
  import { canvasImagePath } from "./presets.mjs";
9
9
 
10
- const sessions = new Map();
11
- const DSH_VERIFY_DEADLINE_MS = 10_000;
10
+ const sessions = new Map();
11
+ const DSH_VERIFY_DEADLINE_MS = 10_000;
12
12
 
13
13
  export const ENGINE_MISSING_MESSAGE =
14
14
  "beautiCode 插件未能加载本机导入引擎。请执行 npx beauticode-dsh,或从完整安装目录加载桥接。";
@@ -93,7 +93,7 @@ export async function ensureInProcessSession(options) {
93
93
  const session = new adapter.DshSession({
94
94
  dataRoot,
95
95
  baseUrl,
96
- verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS,
96
+ verifyDeadlineMs: DSH_VERIFY_DEADLINE_MS,
97
97
  bundledGalleryImagePath: canvasImagePath() || undefined,
98
98
  });
99
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;
@@ -173,12 +173,22 @@ function publicStatus(current, modes, clients, clientStates) {
173
173
  const failedAcks = activeAcks.filter(
174
174
  (ack) => ack.ok === false && typeof ack.error === "string" && ack.error,
175
175
  );
176
+ const readyAcks = activeAcks.filter((ack) => ack.ok === true);
177
+ // Poster-first video commits ack ok while the first frame still settles.
178
+ const videoPendingAcks = readyAcks.filter(
179
+ (ack) => ack.media === "video" && ack.videoReady === false,
180
+ );
176
181
  return {
177
182
  ok: true,
178
183
  connectedClients: clients.size,
179
184
  current,
180
- readyClients: activeAcks.filter((ack) => ack.ok === true).length,
185
+ readyClients: readyAcks.length,
181
186
  failedClients: failedAcks.length,
187
+ videoReadyClients: readyAcks.length - videoPendingAcks.length,
188
+ videoPendingClients: videoPendingAcks.length,
189
+ lastVideoError:
190
+ videoPendingAcks.find((ack) => typeof ack.error === "string" && ack.error)?.error ??
191
+ null,
182
192
  lastRenderError:
183
193
  failedAcks.find((ack) => typeof ack.error === "string" && ack.error)?.error ??
184
194
  null,
@@ -192,10 +202,10 @@ function publicStatus(current, modes, clients, clientStates) {
192
202
  }
193
203
 
194
204
  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;
205
+ const tokenFile = path.resolve(config.tokenFile || defaultTokenFile());
206
+ const clients = new Map();
207
+ const clientStates = new Map();
208
+ let current = null;
199
209
  const modes = { fish: false, muted: true, tone: "auto" };
200
210
  const dataRoot = path.dirname(tokenFile);
201
211
  const baseUrl = resolvePluginBaseUrl(ctx);
@@ -308,9 +318,9 @@ export function apply(ctx, config = {}) {
308
318
  res.end(req.method === "HEAD" ? undefined : source);
309
319
  },
310
320
  }),
311
- ctx.webServer.register({
312
- kind: "exact",
313
- path: "/__beauticode/client.js",
321
+ ctx.webServer.register({
322
+ kind: "exact",
323
+ path: "/__beauticode/client.js",
314
324
  handler: async (req, res) => {
315
325
  if (req.method !== "GET" && req.method !== "HEAD") {
316
326
  res.writeHead(405).end();
@@ -322,10 +332,10 @@ export function apply(ctx, config = {}) {
322
332
  "cache-control": "no-store",
323
333
  "content-length": source.length,
324
334
  });
325
- res.end(req.method === "HEAD" ? undefined : source);
326
- },
327
- }),
328
- ctx.webServer.register({
335
+ res.end(req.method === "HEAD" ? undefined : source);
336
+ },
337
+ }),
338
+ ctx.webServer.register({
329
339
  kind: "exact",
330
340
  path: "/__beauticode/gallery.js",
331
341
  handler: async (req, res) => {
@@ -442,8 +452,8 @@ export function apply(ctx, config = {}) {
442
452
  clients.get(clientId)?.destroy();
443
453
  clients.set(clientId, res);
444
454
  clientStates.set(clientId, { render: null, mode: null });
445
- if (current) {
446
- res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`);
455
+ if (current) {
456
+ res.write(`data: ${JSON.stringify({ type: "apply", ...current })}\n\n`);
447
457
  }
448
458
  res.write(`data: ${JSON.stringify({ type: "mode", ...modes })}\n\n`);
449
459
  ui.scheduleRestore();
@@ -472,18 +482,18 @@ export function apply(ctx, config = {}) {
472
482
  sendJson(res, 400, { ok: false, error: "背景载荷无效。" });
473
483
  return;
474
484
  }
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 });
485
+ current = {
486
+ generation: body.generation,
487
+ media: body.media,
488
+ imageUrl: body.media === "clear" ? null : body.imageUrl,
489
+ videoUrl: body.media === "video" ? body.videoUrl : null,
490
+ startAt: body.media === "video" ? body.startAt ?? null : null,
491
+ };
492
+ const atmosphere = normalizeAtmosphere(body.atmosphere);
493
+ if (atmosphere) current.atmosphere = atmosphere;
494
+ if (body.media === "clear") modes.fish = false;
495
+ for (const state of clientStates.values()) state.render = null;
496
+ broadcast({ type: "apply", ...current });
487
497
  if (body.media === "clear") broadcast({ type: "mode", ...modes });
488
498
  sendJson(res, 200, { ok: true });
489
499
  },
@@ -555,7 +565,8 @@ export function apply(ctx, config = {}) {
555
565
  body.generation !== current.generation ||
556
566
  body.media !== current.media ||
557
567
  typeof body.ok !== "boolean" ||
558
- typeof body.visible !== "boolean"
568
+ typeof body.visible !== "boolean" ||
569
+ (body.videoReady != null && typeof body.videoReady !== "boolean")
559
570
  ) {
560
571
  sendJson(res, 400, { ok: false, error: "渲染回执无效。" });
561
572
  return;
@@ -587,6 +598,9 @@ export function apply(ctx, config = {}) {
587
598
  ok: body.ok,
588
599
  visible: body.visible,
589
600
  error: typeof body.error === "string" ? body.error.slice(0, 300) : null,
601
+ // Poster-first commits ack ok with videoReady=false while the
602
+ // first frame still settles; absent means a legacy ready client.
603
+ videoReady: body.media !== "video" || body.videoReady !== false,
590
604
  playback,
591
605
  };
592
606
  } else if (body.kind === "mode") {
@@ -621,10 +635,10 @@ export function apply(ctx, config = {}) {
621
635
  return () => {
622
636
  disposeTap();
623
637
  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();
638
+ for (const response of clients.values()) response.destroy();
639
+ clients.clear();
640
+ clientStates.clear();
641
+ void ui.dispose();
628
642
  };
629
643
  }, "beauticode-bridge: routes and browser injection");
630
644
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beauticode-dsh",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -1,9 +1,9 @@
1
- # Internal / Infernal atmosphere assets
2
-
3
- `bg-internal.jpg`, `bg-infernal.jpg`, `bg-canvas.png`, and the local
4
- `bg-canvas-4k.png` (Real-ESRGAN 4x then 3840×2160) are visual materials
5
- from [Internal Beyond](https://github.com/Sui-IB/InternalBeyond) by Sui.
6
-
7
- They remain under that project's CC BY-NC-SA 4.0 terms for visual assets.
8
- BeautiCode ships them as bundled workspace themes for local, non-commercial use.
9
- The project name, logo, and character identity are not licensed here.
1
+ # Internal / Infernal atmosphere assets
2
+
3
+ `bg-internal.jpg`, `bg-infernal.jpg`, `bg-canvas.png`, and the local
4
+ `bg-canvas-4k.png` (Real-ESRGAN 4x then 3840×2160) are visual materials
5
+ from [Internal Beyond](https://github.com/Sui-IB/InternalBeyond) by Sui.
6
+
7
+ They remain under that project's CC BY-NC-SA 4.0 terms for visual assets.
8
+ BeautiCode ships them as bundled workspace themes for local, non-commercial use.
9
+ The project name, logo, and character identity are not licensed here.
package/ui-host.mjs CHANGED
@@ -20,6 +20,10 @@ const NATIVE_PICKER_UNAVAILABLE = "native_picker_unavailable";
20
20
  const NATIVE_PICKER_REQUIRED = "native_picker_required";
21
21
  const PICKER_REQUEST_ABORTED = "picker_request_aborted";
22
22
  const IMPORT_TIMING_LOG = "import-timing.jsonl";
23
+ // /reapply runs a full apply transaction (import + stage + verify + commit),
24
+ // which has no total deadline of its own; give it headroom beyond the 30s that
25
+ // the two-verify video path can already approach (~26s).
26
+ const REAPPLY_TIMEOUT_MS = 60_000;
23
27
 
24
28
  function elapsedMs(startedAt) {
25
29
  return Math.round((performance.now() - startedAt) * 10) / 10;
@@ -369,7 +373,7 @@ export function createBeauticodeUi({
369
373
  method: "POST",
370
374
  path: "/reapply",
371
375
  body: {},
372
- timeoutMs: 30_000,
376
+ timeoutMs: REAPPLY_TIMEOUT_MS,
373
377
  });
374
378
  }
375
379
  if (!(await canReachBridge(options.baseUrl))) return;
@@ -379,7 +383,7 @@ export function createBeauticodeUi({
379
383
  method: "POST",
380
384
  path: "/reapply",
381
385
  body: {},
382
- timeoutMs: 30_000,
386
+ timeoutMs: REAPPLY_TIMEOUT_MS,
383
387
  });
384
388
  }
385
389
  return resolved.session.reapply();
@@ -145,7 +145,33 @@ export class DshSession {
145
145
  const result = await tx.run(input, themeName
146
146
  ? {
147
147
  beforeFinalize: async () => {
148
- saved.theme = await this.store.saveCurrentTheme(themeName);
148
+ // Persist the initial seek so a restore before the periodic
149
+ // progress writer runs still resumes at the requested position.
150
+ let videoPositionSec = null;
151
+ if (input.type === "video") {
152
+ if (this.host) {
153
+ try {
154
+ const position = await this.host.getPlaybackPosition();
155
+ if (position.ok &&
156
+ position.hasVideo &&
157
+ Number.isFinite(position.currentTime)) {
158
+ videoPositionSec = position.currentTime;
159
+ }
160
+ }
161
+ catch {
162
+ /* fall back to input.startAt below */
163
+ }
164
+ }
165
+ const startAt = typeof input.startAt === "number" && Number.isFinite(input.startAt)
166
+ ? input.startAt
167
+ : null;
168
+ if (videoPositionSec == null && startAt != null) {
169
+ videoPositionSec = Math.max(0, startAt);
170
+ }
171
+ }
172
+ saved.theme = await this.store.saveCurrentTheme(themeName, {
173
+ ...(videoPositionSec != null ? { videoPositionSec } : {}),
174
+ });
149
175
  },
150
176
  onRollback: async () => {
151
177
  if (saved.theme) {
@@ -155,7 +181,10 @@ export class DshSession {
155
181
  }
156
182
  : undefined);
157
183
  if (result.ok) {
158
- this.activeThemeId = saved.theme?.type === "video" ? saved.theme.id : null;
184
+ // Retain the saved theme id for image themes too: status.themeId marks
185
+ // the current selection in the console, and video-position tracking is
186
+ // already gated on hasVideo / "Not a video theme" downstream.
187
+ this.activeThemeId = saved.theme?.id ?? null;
159
188
  this.lastProgressWriteSec = -1;
160
189
  if (input.type === "clear") {
161
190
  this.fishMode = false;
@@ -297,6 +326,7 @@ export class DshSession {
297
326
  fish: this.fishMode,
298
327
  muted: this.videoMuted,
299
328
  tone: this.backgroundTone,
329
+ themeId: this.activeThemeId,
300
330
  };
301
331
  }
302
332
  async saveCurrentTheme(name) {
@@ -313,13 +343,8 @@ export class DshSession {
313
343
  }
314
344
  }
315
345
  const theme = await this.store.saveCurrentTheme(name, { videoPositionSec });
316
- if (theme.type === "video") {
317
- this.activeThemeId = theme.id;
318
- this.lastProgressWriteSec = -1;
319
- }
320
- else {
321
- this.activeThemeId = null;
322
- }
346
+ this.activeThemeId = theme.id;
347
+ this.lastProgressWriteSec = -1;
323
348
  return theme;
324
349
  }
325
350
  async listSavedThemes() {
@@ -338,7 +363,7 @@ export class DshSession {
338
363
  const saved = await this.store.loadSavedTheme(themeId);
339
364
  const result = await this.apply(saved.input);
340
365
  if (result.ok) {
341
- this.activeThemeId = saved.input.type === "video" ? saved.themeId : null;
366
+ this.activeThemeId = saved.themeId;
342
367
  this.lastProgressWriteSec = -1;
343
368
  }
344
369
  return result;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { MAX_INLINE_DATA_URL_BYTES } from "./constants.js";
3
+ import { MAX_INLINE_DATA_URL_BYTES, MAX_VIDEO_POSTER_INLINE_BYTES, } from "./constants.js";
4
4
  import { isLocalBackgroundSource, resolveBackgroundImagePath, resolveBackgroundVideoPath, } from "./media-source.js";
5
5
  import { detectImageMime } from "./media-validation.js";
6
6
  // Cold-first-access symptoms reported by the renderer while the OS is still
@@ -286,7 +286,13 @@ export async function buildHostApplyPayload(store, manifest, staged, cssText, vi
286
286
  const imagePath = resolveBackgroundImagePath(store.paths.activeDir, manifest.background);
287
287
  if (!imagePath)
288
288
  throw new Error("Background has no image source.");
289
- const imageDataUrl = opts.includeImageDataUrl === false ? null : await fileToDataUrl(imagePath);
289
+ const imageStat = await fs.stat(imagePath).catch(() => null);
290
+ const skipInlinePoster = manifest.background.type === "video" &&
291
+ imageStat != null &&
292
+ imageStat.size > MAX_VIDEO_POSTER_INLINE_BYTES;
293
+ const imageDataUrl = opts.includeImageDataUrl === false || skipInlinePoster
294
+ ? null
295
+ : await fileToDataUrl(imagePath);
290
296
  const imageUrl = staged?.image?.srcUrl ?? null;
291
297
  if (manifest.background.type === "image") {
292
298
  return {
@@ -9,6 +9,11 @@ export const MAX_VIDEO_BYTES = 800 * 1024 * 1024;
9
9
  * Keep this equal to MAX_IMAGE_BYTES so every accepted image can publish.
10
10
  */
11
11
  export const MAX_INLINE_DATA_URL_BYTES = MAX_IMAGE_BYTES;
12
+ /**
13
+ * Keep a large video poster out of the Codex Runtime.evaluate payload.
14
+ * The first decoded video frame replaces the poster after the blob attaches.
15
+ */
16
+ export const MAX_VIDEO_POSTER_INLINE_BYTES = 1024 * 1024;
12
17
  export const COMMIT_MARKER_NAME = ".beauticode-commit-in-progress";
13
18
  export const COMMIT_MARKER_STALE_MS = 120_000;
14
19
  export const MEDIA_TOKEN_HEADER = "x-beauticode-media-token";
@@ -10,4 +10,5 @@ export * from "./error-message.js";
10
10
  export * from "./background-store.js";
11
11
  export * from "./bundled-gallery.js";
12
12
  export * from "./apply-transaction.js";
13
+ export * from "./process-liveness.js";
13
14
  //# sourceMappingURL=index.js.map
@@ -1,8 +1,10 @@
1
1
  import path from "node:path";
2
+ import { assertSafeBasename } from "./media-validation.js";
2
3
  /** Resolve a manifest source against the directory that owns managed files. */
3
4
  export function resolveMediaSource(source, ownerDir) {
4
5
  if (source.kind === "local")
5
6
  return path.resolve(source.path);
7
+ assertSafeBasename(source.file, "background.source.file");
6
8
  return path.join(ownerDir, source.file);
7
9
  }
8
10
  /** Resolve the primary image source, including legacy v1 manifests. */
@@ -0,0 +1,81 @@
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * How close a process's actual start time must be to the recorded start time
4
+ * before we treat them as the same process. PID reuse yields a completely
5
+ * different start time, so a generous window is safe while still rejecting
6
+ * recycled PIDs.
7
+ */
8
+ const START_TIME_TOLERANCE_MS = 30_000;
9
+ /**
10
+ * True when `pid` refers to a process that still exists. Mirrors the PID check
11
+ * callers perform before consulting a recorded process identity.
12
+ */
13
+ function isPidAlive(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ return (Boolean(error) &&
20
+ typeof error === "object" &&
21
+ error.code === "EPERM");
22
+ }
23
+ }
24
+ /**
25
+ * Resolve the actual process start time as an epoch millisecond value.
26
+ * Returns null when the process does not exist or the platform helper fails.
27
+ */
28
+ function processStartTimeMs(pid) {
29
+ return new Promise((resolve) => {
30
+ if (process.platform === "win32") {
31
+ const script = `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().ToString('o')`;
32
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 3_000, windowsHide: true }, (error, stdout) => {
33
+ if (error)
34
+ return resolve(null);
35
+ const value = Date.parse(String(stdout).trim());
36
+ resolve(Number.isFinite(value) ? value : null);
37
+ });
38
+ return;
39
+ }
40
+ // Linux / macOS: ps -o lstart= prints the start time in local time with no
41
+ // timezone suffix, which Date.parse interprets as the host's local time.
42
+ execFile("ps", ["-o", "lstart=", "-p", String(pid)], { timeout: 3_000 }, (error, stdout) => {
43
+ if (error)
44
+ return resolve(null);
45
+ const value = Date.parse(String(stdout).trim());
46
+ resolve(Number.isFinite(value) ? value : null);
47
+ });
48
+ });
49
+ }
50
+ /**
51
+ * True when `pid` is alive AND its real start time matches `recordedIso` (the
52
+ * startedAt value a control/claim file captured when the process launched).
53
+ *
54
+ * Falls back to optimistic `true` when there is no recorded timestamp or the
55
+ * platform helper is unavailable, so an unverifiable check never marks a live
56
+ * tray/session as dead. Returns `false` when the PID no longer exists or its
57
+ * start time clearly differs (PID reuse).
58
+ */
59
+ export async function isRecordedPidLive(pid, recordedIso) {
60
+ if (!Number.isInteger(pid) || pid <= 0)
61
+ return false;
62
+ if (!isPidAlive(pid))
63
+ return false;
64
+ if (typeof recordedIso !== "string" || !recordedIso)
65
+ return true;
66
+ const recordedMs = Date.parse(recordedIso);
67
+ if (!Number.isFinite(recordedMs))
68
+ return true;
69
+ let actualMs;
70
+ try {
71
+ actualMs = await processStartTimeMs(pid);
72
+ }
73
+ catch {
74
+ actualMs = null;
75
+ }
76
+ // No helper result means "cannot verify" — keep the optimistic PID check.
77
+ if (actualMs == null)
78
+ return true;
79
+ return Math.abs(actualMs - recordedMs) <= START_TIME_TOLERANCE_MS;
80
+ }
81
+ //# sourceMappingURL=process-liveness.js.map