Commit 3c2c0c4222d472aafb2cfbb2f779791a31fd0be8
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
Showing
4 changed files
with
152 additions
and
46 deletions
.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("roomTitle"); |
| 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("click", () => { |
| 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("click", () => { |
| 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" })); | ... | ... |