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.
- package/client.js +101 -47
- package/index.mjs +57 -43
- 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
|
|
1022
|
+
let acceptTimer = null;
|
|
998
1023
|
let abortListener = null;
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
return
|
|
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
|
-
|
|
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")
|
|
1126
|
-
|
|
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
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|