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,3 +19,8 @@ uploads/
19 # local raw asset library (persona/clothes/musicvideo/withclothes source 19 # local raw asset library (persona/clothes/musicvideo/withclothes source
20 # material, ~11GB) — way too large for git, lives on disk only 20 # material, ~11GB) — way too large for git, lives on disk only
21 H3assest/ 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,7 +62,16 @@ function startMockH3() {
62 res.end(JSON.stringify({ id })); 62 res.end(JSON.stringify({ id }));
63 return; 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 if (pollMatch) { 75 if (pollMatch) {
67 const id = pollMatch[1]; 76 const id = pollMatch[1];
68 const seen = (videoPollCounts.get(id) || 0) + 1; 77 const seen = (videoPollCounts.get(id) || 0) + 1;
dance.html
@@ -47,7 +47,6 @@ @@ -47,7 +47,6 @@
47 <div class="now-playing-box" id="nowPlayingBox" hidden> 47 <div class="now-playing-box" id="nowPlayingBox" hidden>
48 <span class="np-nick" id="nowPlayingNick"></span> 48 <span class="np-nick" id="nowPlayingNick"></span>
49 <span class="np-time" id="nowPlayingTime"></span> 49 <span class="np-time" id="nowPlayingTime"></span>
50 - <div class="np-text" id="nowPlayingText">加载中…</div>  
51 <div><button class="like-btn" id="likeBtn" hidden>❤️ <span id="likeCount">0</span></button></div> 50 <div><button class="like-btn" id="likeBtn" hidden>❤️ <span id="likeCount">0</span></button></div>
52 </div> 51 </div>
53 <video id="player" autoplay playsinline></video> 52 <video id="player" autoplay playsinline></video>
@@ -142,7 +141,6 @@ const roomTitle = document.getElementById(&quot;roomTitle&quot;); @@ -142,7 +141,6 @@ const roomTitle = document.getElementById(&quot;roomTitle&quot;);
142 const nowPlayingBox = document.getElementById("nowPlayingBox"); 141 const nowPlayingBox = document.getElementById("nowPlayingBox");
143 const nowPlayingNick = document.getElementById("nowPlayingNick"); 142 const nowPlayingNick = document.getElementById("nowPlayingNick");
144 const nowPlayingTime = document.getElementById("nowPlayingTime"); 143 const nowPlayingTime = document.getElementById("nowPlayingTime");
145 -const nowPlayingText = document.getElementById("nowPlayingText");  
146 const likeBtn = document.getElementById("likeBtn"); 144 const likeBtn = document.getElementById("likeBtn");
147 const likeCount = document.getElementById("likeCount"); 145 const likeCount = document.getElementById("likeCount");
148 const watchingCount = document.getElementById("watchingCount"); 146 const watchingCount = document.getElementById("watchingCount");
@@ -393,7 +391,14 @@ muteBtn.addEventListener(&quot;click&quot;, () =&gt; { @@ -393,7 +391,14 @@ muteBtn.addEventListener(&quot;click&quot;, () =&gt; {
393 // 有声自动播放,unmuted play() 被拒绝就退回静音自动播放,再靠下面的点击进入 391 // 有声自动播放,unmuted play() 被拒绝就退回静音自动播放,再靠下面的点击进入
394 // 蒙版拿到那次手势,切回有声。 392 // 蒙版拿到那次手势,切回有声。
395 function tryPlay() { 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 player.muted = true; 402 player.muted = true;
398 muteBtn.textContent = "🔇 静音"; 403 muteBtn.textContent = "🔇 静音";
399 player.play().catch(() => {}); 404 player.play().catch(() => {});
@@ -404,8 +409,14 @@ splash.addEventListener(&quot;click&quot;, () =&gt; { @@ -404,8 +409,14 @@ splash.addEventListener(&quot;click&quot;, () =&gt; {
404 splash.remove(); 409 splash.remove();
405 const desktop = matchMedia("(min-width: 769px)").matches; 410 const desktop = matchMedia("(min-width: 769px)").matches;
406 player.muted = !desktop; // 手机进入后仍保持静音,同 live.html 的理由 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 muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声"; 421 muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声";
411 }); 422 });
@@ -719,13 +730,12 @@ async function voteQueueItem(id, btn) { @@ -719,13 +730,12 @@ async function voteQueueItem(id, btn) {
719 } 730 }
720 731
721 // ---- 播放器:跟 live.html 一样,服务端 endAt 是权威时钟,轮询只是纠偏 ---- 732 // ---- 播放器:跟 live.html 一样,服务端 endAt 是权威时钟,轮询只是纠偏 ----
722 -function setNowPlaying(nick, time, text, clip) { 733 +function setNowPlaying(nick, time, clip) {
723 nowPlayingBox.hidden = false; 734 nowPlayingBox.hidden = false;
724 nowPlayingNick.hidden = !nick; 735 nowPlayingNick.hidden = !nick;
725 nowPlayingNick.textContent = nick || ""; 736 nowPlayingNick.textContent = nick || "";
726 nowPlayingTime.hidden = !time; 737 nowPlayingTime.hidden = !time;
727 nowPlayingTime.textContent = time || ""; 738 nowPlayingTime.textContent = time || "";
728 - nowPlayingText.textContent = text;  
729 likeBtn.hidden = !clip; 739 likeBtn.hidden = !clip;
730 if (clip) { 740 if (clip) {
731 likeCount.textContent = clip.likes || 0; 741 likeCount.textContent = clip.likes || 0;
@@ -736,6 +746,16 @@ function setNowPlaying(nick, time, text, clip) { @@ -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 function switchToClip(clip, offsetSec, requestedBy) { 759 function switchToClip(clip, offsetSec, requestedBy) {
740 currentClipId = clip.id; 760 currentClipId = clip.id;
741 player.src = `/lib/video/${clip.id}`; 761 player.src = `/lib/video/${clip.id}`;
@@ -749,7 +769,7 @@ function switchToClip(clip, offsetSec, requestedBy) { @@ -749,7 +769,7 @@ function switchToClip(clip, offsetSec, requestedBy) {
749 ); 769 );
750 } 770 }
751 tryPlay(); 771 tryPlay();
752 - setNowPlaying(requestedBy, fmtClock(Date.now()), clip.prompt, clip); 772 + setNowPlaying(requestedBy, fmtClock(Date.now()), clip);
753 } 773 }
754 774
755 async function pollState() { 775 async function pollState() {
@@ -781,8 +801,7 @@ async function pollState() { @@ -781,8 +801,7 @@ async function pollState() {
781 advanceTimer = setTimeout(() => switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy), Math.max(0, msUntilEnd)); 801 advanceTimer = setTimeout(() => switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy), Math.max(0, msUntilEnd));
782 } 802 }
783 } else if (!currentClipId) { 803 } else if (!currentClipId) {
784 - nowPlayingText.textContent = "还没有可播放的内容,去右边选一件衣服开播吧";  
785 - nowPlayingBox.hidden = false; 804 + setNowPlaying("还没有可播放的内容,去右边选一件衣服开播吧", null, null);
786 } 805 }
787 } catch { 806 } catch {
788 // transient — next poll retries 807 // transient — next poll retries
server.js
@@ -930,6 +930,93 @@ function h3ConsoleFetch(pathAndQuery, options) { @@ -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 // timeoutMs default matches the original text-only prompt-optimize use — 1020 // timeoutMs default matches the original text-only prompt-optimize use —
934 // the QC caller (multi-image, much bigger payload) passes a longer one. 1021 // the QC caller (multi-image, much bigger payload) passes a longer one.
935 function glmChat(messages, timeoutMs = 15000) { 1022 function glmChat(messages, timeoutMs = 15000) {
@@ -2879,13 +2966,22 @@ http @@ -2879,13 +2966,22 @@ http
2879 const cur = room.schedule[idx]; 2966 const cur = room.schedule[idx];
2880 const lib = getLibrary(); 2967 const lib = getLibrary();
2881 const joinClip = (videoId) => lib.find((c) => c.id === videoId) || null; 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 res.writeHead(200, { "Content-Type": "application/json" }); 2978 res.writeHead(200, { "Content-Type": "application/json" });
2883 res.end( 2979 res.end(
2884 JSON.stringify({ 2980 JSON.stringify({
2885 now, 2981 now,
2886 watching: getRoomViewers(characterId).size, 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 queue: room.queue 2985 queue: room.queue
2890 .filter((q) => ["generating", "failed"].includes(q.status)) 2986 .filter((q) => ["generating", "failed"].includes(q.status))
2891 .slice() 2987 .slice()
@@ -3821,39 +3917,7 @@ http @@ -3821,39 +3917,7 @@ http
3821 res.end("Forbidden"); 3917 res.end("Forbidden");
3822 return; 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 return; 3921 return;
3858 } 3922 }
3859 3923
@@ -3865,6 +3929,15 @@ http @@ -3865,6 +3929,15 @@ http
3865 res.end(); 3929 res.end();
3866 return; 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 if (!H3_CONSOLE_URL) { 3941 if (!H3_CONSOLE_URL) {
3869 res.writeHead(500, { "Content-Type": "application/json" }); 3942 res.writeHead(500, { "Content-Type": "application/json" });
3870 res.end(JSON.stringify({ error: "H3_CONSOLE_BASE not configured" })); 3943 res.end(JSON.stringify({ error: "H3_CONSOLE_BASE not configured" }));