beauticode-dsh 1.0.21 → 1.0.22

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.
Files changed (3) hide show
  1. package/client.js +101 -47
  2. package/index.mjs +57 -43
  3. package/package.json +1 -1
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;
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.22",
4
4
  "description": "Cordis plugin: image/video backgrounds for DeepSeek Harness web.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",