Commit 3c2c0c4222d472aafb2cfbb2f779791a31fd0be8

Authored by yibo
1 parent 9a84fd7b

Fix dance.html playback: slow H3 video proxy left rooms stuck/muted/choppy

/lib/video/:id proxied every clip live through H3 console's file endpoint,
which is slow even for already-finished files (~35KB/s, confirmed 2026-09) —
the 10s server-clock playlist kept advancing before a clip ever finished
loading, so rooms looked dead, sound silently got re-muted on transient
play() rejections, and a failed load just froze forever.

- server.js: local disk cache (video-cache/) for H3-proxied clips, warmed
  ahead of time by /api/dance/live/state for the current + next-up clip;
  /lib/video/:id serves cache hits instantly with Range support
- dance.html: only fall back to muted autoplay on a real NotAllowedError,
  not any play() rejection (e.g. AbortError from a mid-switch load); auto-skip
  to the next clip on a video load error instead of freezing; drop the
  now-playing garment-prompt text under the video
- dance-channel.test.js: mock H3 server no longer conflates the new cache
  warm-up request with job-status polling

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHPGtozYFzo39AX2CdV5ZY
.gitignore
... ... @@ -19,3 +19,8 @@ uploads/
19 19 # local raw asset library (persona/clothes/musicvideo/withclothes source
20 20 # material, ~11GB) — way too large for git, lives on disk only
21 21 H3assest/
  22 +
  23 +# server.js's disk cache of H3-console-proxied dance clip bytes
  24 +# (ensureVideoCached) — fully rebuildable from the console on demand, not
  25 +# source
  26 +video-cache/
... ...
dance-channel.test.js
... ... @@ -62,7 +62,16 @@ function startMockH3() {
62 62 res.end(JSON.stringify({ id }));
63 63 return;
64 64 }
65   - const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
  65 + // /lib/video/:id's cache-warming (ensureVideoCached, server.js) hits
  66 + // this same mock's GET /v1/videos/:id/file — must not match the
  67 + // status-poll regex below, or its background requests get counted
  68 + // as extra "job polls" and trip the dedupe test's job-count assert.
  69 + if (req.method === "GET" && /^\/v1\/videos\/[^/]+\/file$/.test(req.url)) {
  70 + res.writeHead(200, { "Content-Type": "video/mp4" });
  71 + res.end("fake-mp4-bytes");
  72 + return;
  73 + }
  74 + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/([^/]+)$/);
66 75 if (pollMatch) {
67 76 const id = pollMatch[1];
68 77 const seen = (videoPollCounts.get(id) || 0) + 1;
... ...
dance.html
... ... @@ -47,7 +47,6 @@
47 47 <div class="now-playing-box" id="nowPlayingBox" hidden>
48 48 <span class="np-nick" id="nowPlayingNick"></span>
49 49 <span class="np-time" id="nowPlayingTime"></span>
50   - <div class="np-text" id="nowPlayingText">加载中…</div>
51 50 <div><button class="like-btn" id="likeBtn" hidden>❤️ <span id="likeCount">0</span></button></div>
52 51 </div>
53 52 <video id="player" autoplay playsinline></video>
... ... @@ -142,7 +141,6 @@ const roomTitle = document.getElementById(&quot;roomTitle&quot;);
142 141 const nowPlayingBox = document.getElementById("nowPlayingBox");
143 142 const nowPlayingNick = document.getElementById("nowPlayingNick");
144 143 const nowPlayingTime = document.getElementById("nowPlayingTime");
145   -const nowPlayingText = document.getElementById("nowPlayingText");
146 144 const likeBtn = document.getElementById("likeBtn");
147 145 const likeCount = document.getElementById("likeCount");
148 146 const watchingCount = document.getElementById("watchingCount");
... ... @@ -393,7 +391,14 @@ muteBtn.addEventListener(&quot;click&quot;, () =&gt; {
393 391 // 有声自动播放,unmuted play() 被拒绝就退回静音自动播放,再靠下面的点击进入
394 392 // 蒙版拿到那次手势,切回有声。
395 393 function tryPlay() {
396   - player.play().catch(() => {
  394 + player.play().catch((err) => {
  395 + // Only fall back to muted autoplay for an actual autoplay-policy
  396 + // rejection. tryPlay() re-runs on every clip switch (every ~10s), and a
  397 + // switch mid-load commonly throws AbortError ("interrupted by a new
  398 + // load request") — treating that the same as a policy block used to
  399 + // silently flip sound back off even after the viewer had explicitly
  400 + // unmuted via the splash click or 🔈 button.
  401 + if (err && err.name !== "NotAllowedError") return;
397 402 player.muted = true;
398 403 muteBtn.textContent = "🔇 静音";
399 404 player.play().catch(() => {});
... ... @@ -404,8 +409,14 @@ splash.addEventListener(&quot;click&quot;, () =&gt; {
404 409 splash.remove();
405 410 const desktop = matchMedia("(min-width: 769px)").matches;
406 411 player.muted = !desktop; // 手机进入后仍保持静音,同 live.html 的理由
407   - player.play().catch(() => {
408   - player.muted = true;
  412 + player.play().catch((err) => {
  413 + // Same guard as tryPlay(): only a genuine autoplay-policy rejection
  414 + // should force muted — this click IS the user gesture, so a rejection
  415 + // here is almost always something else (e.g. the clip's src still
  416 + // mid-switch from a just-arrived pollState) and shouldn't silently
  417 + // override the sound-on choice this click just made.
  418 + if (!err || err.name === "NotAllowedError") player.muted = true;
  419 + muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声";
409 420 });
410 421 muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声";
411 422 });
... ... @@ -719,13 +730,12 @@ async function voteQueueItem(id, btn) {
719 730 }
720 731  
721 732 // ---- 播放器:跟 live.html 一样,服务端 endAt 是权威时钟,轮询只是纠偏 ----
722   -function setNowPlaying(nick, time, text, clip) {
  733 +function setNowPlaying(nick, time, clip) {
723 734 nowPlayingBox.hidden = false;
724 735 nowPlayingNick.hidden = !nick;
725 736 nowPlayingNick.textContent = nick || "";
726 737 nowPlayingTime.hidden = !time;
727 738 nowPlayingTime.textContent = time || "";
728   - nowPlayingText.textContent = text;
729 739 likeBtn.hidden = !clip;
730 740 if (clip) {
731 741 likeCount.textContent = clip.likes || 0;
... ... @@ -736,6 +746,16 @@ function setNowPlaying(nick, time, text, clip) {
736 746 }
737 747 }
738 748  
  749 +// A clip whose /lib/video request errors out (e.g. a transient 401/502 from
  750 +// the upstream H3 console) would otherwise just sit there frozen until the
  751 +// server-clock advanceTimer eventually fires — skip ahead immediately
  752 +// instead of leaving the room looking dead.
  753 +player.addEventListener("error", () => {
  754 + if (player.currentSrc && currentClipId && player.currentSrc.endsWith(`/lib/video/${currentClipId}`) && upNextCache.length) {
  755 + switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy);
  756 + }
  757 +});
  758 +
739 759 function switchToClip(clip, offsetSec, requestedBy) {
740 760 currentClipId = clip.id;
741 761 player.src = `/lib/video/${clip.id}`;
... ... @@ -749,7 +769,7 @@ function switchToClip(clip, offsetSec, requestedBy) {
749 769 );
750 770 }
751 771 tryPlay();
752   - setNowPlaying(requestedBy, fmtClock(Date.now()), clip.prompt, clip);
  772 + setNowPlaying(requestedBy, fmtClock(Date.now()), clip);
753 773 }
754 774  
755 775 async function pollState() {
... ... @@ -781,8 +801,7 @@ async function pollState() {
781 801 advanceTimer = setTimeout(() => switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy), Math.max(0, msUntilEnd));
782 802 }
783 803 } else if (!currentClipId) {
784   - nowPlayingText.textContent = "还没有可播放的内容,去右边选一件衣服开播吧";
785   - nowPlayingBox.hidden = false;
  804 + setNowPlaying("还没有可播放的内容,去右边选一件衣服开播吧", null, null);
786 805 }
787 806 } catch {
788 807 // transient — next poll retries
... ...
server.js
... ... @@ -930,6 +930,93 @@ function h3ConsoleFetch(pathAndQuery, options) {
930 930 });
931 931 }
932 932  
  933 +// Local disk cache for H3-console-proxied clip video bytes. The shared H3
  934 +// cluster is slow enough at serving already-finished files (confirmed
  935 +// 2026-09: ~5s to first byte, ~50s total for a 1.7MB/10s clip, ~35KB/s) that
  936 +// a dance room's <video> tag can never finish loading one clip before the
  937 +// server-clock-driven playlist already advances to the next — the room
  938 +// looks stuck/muted/choppy because nothing ever actually finishes buffering.
  939 +// Caching once and reusing forever sidesteps that; /api/dance/live/state
  940 +// warms this ahead of time for the current + next-up clip (see below).
  941 +const VIDEO_CACHE_DIR = path.join(__dirname, "video-cache");
  942 +fs.mkdirSync(VIDEO_CACHE_DIR, { recursive: true });
  943 +const videoCacheInFlight = new Map(); // clipId -> in-flight Promise, dedupes concurrent fetches of the same clip
  944 +
  945 +function cachedVideoPath(id) {
  946 + return path.join(VIDEO_CACHE_DIR, `${id}.mp4`);
  947 +}
  948 +
  949 +function ensureVideoCached(id) {
  950 + if (fs.existsSync(cachedVideoPath(id))) return Promise.resolve();
  951 + if (videoCacheInFlight.has(id)) return videoCacheInFlight.get(id);
  952 + if (!H3_CONSOLE_URL) return Promise.reject(new Error("H3_CONSOLE_BASE not configured"));
  953 + const tmpPath = `${cachedVideoPath(id)}.tmp-${crypto.randomUUID()}`;
  954 + const promise = new Promise((resolve, reject) => {
  955 + const proxyReq = http.request(
  956 + {
  957 + hostname: H3_CONSOLE_URL.hostname,
  958 + port: H3_CONSOLE_URL.port || 80,
  959 + path: `${H3_CONSOLE_URL.pathname}/v1/videos/${id}/file`,
  960 + method: "GET",
  961 + headers: { Authorization: `Bearer ${H3_CONSOLE_TOKEN}` },
  962 + },
  963 + (proxyRes) => {
  964 + if (proxyRes.statusCode !== 200) {
  965 + proxyRes.resume();
  966 + reject(new Error(`H3 console returned HTTP ${proxyRes.statusCode} for video ${id}`));
  967 + return;
  968 + }
  969 + const out = fs.createWriteStream(tmpPath);
  970 + proxyRes.pipe(out);
  971 + out.on("finish", () => fs.rename(tmpPath, cachedVideoPath(id), (err) => (err ? reject(err) : resolve())));
  972 + out.on("error", reject);
  973 + }
  974 + );
  975 + proxyReq.on("error", reject);
  976 + proxyReq.end();
  977 + }).finally(() => {
  978 + videoCacheInFlight.delete(id);
  979 + fs.rm(tmpPath, { force: true }, () => {});
  980 + });
  981 + videoCacheInFlight.set(id, promise);
  982 + return promise;
  983 +}
  984 +
  985 +// Shared by /lib/video/:id (cache hit) and /uploads/:path — Range support is
  986 +// required, not optional: <video> elements probe duration/seekability with a
  987 +// Range request, and without a 206 reply (plus Content-Length) Chrome's
  988 +// media pipeline can get stuck at readyState 0 forever.
  989 +function serveFileWithRange(req, res, absPath, contentType) {
  990 + fs.stat(absPath, (err, stat) => {
  991 + if (err || !stat.isFile()) {
  992 + res.writeHead(404);
  993 + res.end("Not found");
  994 + return;
  995 + }
  996 + const range = req.headers.range;
  997 + const match = range && /^bytes=(\d*)-(\d*)$/.exec(range);
  998 + if (match) {
  999 + const start = match[1] ? parseInt(match[1], 10) : 0;
  1000 + const end = match[2] ? parseInt(match[2], 10) : stat.size - 1;
  1001 + if (start > end || end >= stat.size) {
  1002 + res.writeHead(416, { "Content-Range": `bytes */${stat.size}` });
  1003 + res.end();
  1004 + return;
  1005 + }
  1006 + res.writeHead(206, {
  1007 + "Content-Type": contentType,
  1008 + "Content-Length": end - start + 1,
  1009 + "Content-Range": `bytes ${start}-${end}/${stat.size}`,
  1010 + "Accept-Ranges": "bytes",
  1011 + });
  1012 + fs.createReadStream(absPath, { start, end }).pipe(res);
  1013 + return;
  1014 + }
  1015 + res.writeHead(200, { "Content-Type": contentType, "Content-Length": stat.size, "Accept-Ranges": "bytes" });
  1016 + fs.createReadStream(absPath).pipe(res);
  1017 + });
  1018 +}
  1019 +
933 1020 // timeoutMs default matches the original text-only prompt-optimize use —
934 1021 // the QC caller (multi-image, much bigger payload) passes a longer one.
935 1022 function glmChat(messages, timeoutMs = 15000) {
... ... @@ -2879,13 +2966,22 @@ http
2879 2966 const cur = room.schedule[idx];
2880 2967 const lib = getLibrary();
2881 2968 const joinClip = (videoId) => lib.find((c) => c.id === videoId) || null;
  2969 + const currentClip = cur ? joinClip(cur.videoId) : null;
  2970 + const upNextList = room.schedule.slice(idx + 1, idx + 4).map((s) => ({ ...(joinClip(s.videoId) || {}), requestedBy: s.requestedBy || null }));
  2971 + // Warm the video cache ahead of playback: by the time a clip becomes
  2972 + // "current" it should already be sitting on disk, not being fetched
  2973 + // for the first time against H3's slow file endpoint (see
  2974 + // ensureVideoCached's comment).
  2975 + for (const c of [currentClip, upNextList[0]]) {
  2976 + if (c && c.source === "h3console" && !c.videoUrl) ensureVideoCached(c.id).catch(() => {});
  2977 + }
2882 2978 res.writeHead(200, { "Content-Type": "application/json" });
2883 2979 res.end(
2884 2980 JSON.stringify({
2885 2981 now,
2886 2982 watching: getRoomViewers(characterId).size,
2887   - current: cur ? { clip: joinClip(cur.videoId), startAt: cur.startAt, endAt: cur.endAt, requestedBy: cur.requestedBy || null } : null,
2888   - upNext: room.schedule.slice(idx + 1, idx + 4).map((s) => ({ ...(joinClip(s.videoId) || {}), requestedBy: s.requestedBy || null })),
  2983 + current: cur ? { clip: currentClip, startAt: cur.startAt, endAt: cur.endAt, requestedBy: cur.requestedBy || null } : null,
  2984 + upNext: upNextList,
2889 2985 queue: room.queue
2890 2986 .filter((q) => ["generating", "failed"].includes(q.status))
2891 2987 .slice()
... ... @@ -3821,39 +3917,7 @@ http
3821 3917 res.end("Forbidden");
3822 3918 return;
3823 3919 }
3824   - // Range support is required here, not optional: <video> elements probe
3825   - // duration/seekability with a Range request, and without a 206 reply
3826   - // (plus Content-Length) Chrome's media pipeline can get stuck at
3827   - // readyState 0 forever — the video never becomes playable.
3828   - fs.stat(abs, (err, stat) => {
3829   - if (err || !stat.isFile()) {
3830   - res.writeHead(404);
3831   - res.end("Not found");
3832   - return;
3833   - }
3834   - const contentType = MIME[path.extname(abs)] || "application/octet-stream";
3835   - const range = req.headers.range;
3836   - const match = range && /^bytes=(\d*)-(\d*)$/.exec(range);
3837   - if (match) {
3838   - const start = match[1] ? parseInt(match[1], 10) : 0;
3839   - const end = match[2] ? parseInt(match[2], 10) : stat.size - 1;
3840   - if (start > end || end >= stat.size) {
3841   - res.writeHead(416, { "Content-Range": `bytes */${stat.size}` });
3842   - res.end();
3843   - return;
3844   - }
3845   - res.writeHead(206, {
3846   - "Content-Type": contentType,
3847   - "Content-Length": end - start + 1,
3848   - "Content-Range": `bytes ${start}-${end}/${stat.size}`,
3849   - "Accept-Ranges": "bytes",
3850   - });
3851   - fs.createReadStream(abs, { start, end }).pipe(res);
3852   - return;
3853   - }
3854   - res.writeHead(200, { "Content-Type": contentType, "Content-Length": stat.size, "Accept-Ranges": "bytes" });
3855   - fs.createReadStream(abs).pipe(res);
3856   - });
  3920 + serveFileWithRange(req, res, abs, MIME[path.extname(abs)] || "application/octet-stream");
3857 3921 return;
3858 3922 }
3859 3923  
... ... @@ -3865,6 +3929,15 @@ http
3865 3929 res.end();
3866 3930 return;
3867 3931 }
  3932 + if (fs.existsSync(cachedVideoPath(id))) {
  3933 + serveFileWithRange(req, res, cachedVideoPath(id), "video/mp4");
  3934 + return;
  3935 + }
  3936 + // Cache miss — warm it in the background for next time (dedup'd by
  3937 + // ensureVideoCached itself) without making this request wait for the
  3938 + // whole file; fall through to the direct (slow) proxy below so this
  3939 + // viewer still gets the clip, just at H3's own pace.
  3940 + ensureVideoCached(id).catch(() => {});
3868 3941 if (!H3_CONSOLE_URL) {
3869 3942 res.writeHead(500, { "Content-Type": "application/json" });
3870 3943 res.end(JSON.stringify({ error: "H3_CONSOLE_BASE not configured" }));
... ...