From 738bace91bcd17c0ab8ba920fe2f0765f6504fc8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 7 Oct 2026 16:16:28 -0700 Subject: Videos: posters for clips that came without one, and an H.264 copy for a clip this browser can't picture (an iPhone's HEVC on Chrome for Linux played sound over black) — vmedia container on cs (bun + ffmpeg, /vmedia/), switch on zero-width metadata or an HEVC header from the probe, a line saying why if the copy fails too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/features/messages.js | 68 +++++++++++++++++++++++++++++++++++++++++------- src/i18n.js | 2 ++ src/style.css | 8 ++++++ 3 files changed, 69 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 4f12812..12d928e 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -5847,7 +5847,10 @@ export function messagesFeature(ctx) { const tw = u32(end - 8) >>> 16, th = u32(end - 4) >>> 16; if (tw && th) { w = tw; h = th; } } - return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}) }; + // an iPhone's HEVC (hvc1/hev1 in the sample description) + let hevc = false; + for (let j = 0; j + 4 <= b.length && !hevc; j++) if (b[j] === 0x68 && (type(j) === 'hvc1' || type(j) === 'hev1')) hevc = true; + return { faststart, ...(moovEnd ? { moovEnd } : {}), ...(w ? { w, h } : {}), ...(hevc ? { hevc } : {}) }; } const videoProbes = new Map(); let videoProbing = 0; @@ -5901,7 +5904,7 @@ export function messagesFeature(ctx) { let o = 0; for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; } const head = readMp4Head(buf); - const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}) }; + const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}) }; if (total > 0) d.size = total; else { // a server that hides Content-Range from scripts (Primal's) still @@ -6738,21 +6741,67 @@ export function messagesFeature(ctx) { cover.remove(); onPlay && onPlay(); const v = box.querySelector('video'); - if (v) { if (!v.getAttribute('src')) v.setAttribute('src', url); v.preload = 'auto'; v.play().catch(() => {}); } + if (v) { if (!v.getAttribute('src')) v.setAttribute('src', playSrc(url)); v.preload = 'auto'; v.play().catch(() => {}); } } }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url))); box.append(cover); } + // ---- clips this browser can't picture, posters a post didn't give ------- + // The server (vmedia/server.js) makes a still for a clip that came without + // one — a clip waiting for a tap was a black box — and an H.264 copy of + // one whose picture this browser can't decode: an iPhone's HEVC on Chrome + // for Linux plays its sound over black. + const VMEDIA = APP_BASE + '/vmedia/'; + const vmediaOk = (url) => /^https:\/\//i.test(url || ''); + const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?u=' + encodeURIComponent(url) : undefined); + const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); + const noPicture = new Set(), h264Failed = new Set(); + let hevcPlays; + const canHevc = () => hevcPlays ??= (() => { + try { return !!document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"'); } catch { return true; } + })(); + // what a player for this clip loads: the copy, once the original is known + // not to show here + const playSrc = (url) => vmediaOk(url) && !h264Failed.has(url) + && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; + // A player whose clip turns out to have no picture it can show (its + // metadata says zero width) switches to the copy; if the copy fails too, + // the original's sound plays under a line saying why, with the file. + function guardPicture(v, url) { + if (!vmediaOk(url)) return; + const onCopy = () => (v.getAttribute('src') || '').startsWith(VMEDIA); + v.addEventListener('loadedmetadata', () => { + if (v.videoWidth || onCopy() || h264Failed.has(url)) return; + noPicture.add(url); + const want = !v.paused; + v.setAttribute('src', h264Of(url)); + if (want) v.play().catch(() => {}); + }); + v.addEventListener('error', () => { + if (!onCopy()) return; + h264Failed.add(url); + const want = !v.paused; + v.setAttribute('src', url); + if (want) v.play().catch(() => {}); + const box = v.parentElement; + if (box && !box.querySelector('.vid-nopic')) { + box._skipMorph = true; + box.append(h('div', { class: 'vid-nopic' }, h('span', {}, t('videoNoPicture')), + h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', onClick: (e) => e.stopPropagation() }, t('videoOpenFile')))); + } + }); + } function videoNode(url, { stable = false } = {}) { const loud = unmutedClips.has(url); // Nothing is fetched until the clip is about to play (preload none): a // window of rows used to pull every clip's metadata, which for a file // with its index at the end can be most of the file. - const v = h('video', { src: url, class: 'note-video', controls: true, poster: videoThumbs.get(url) || (videoInfo.get(url) || {}).image || undefined, + const v = h('video', { src: playSrc(url), class: 'note-video', controls: true, poster: videoThumbs.get(url) || (videoInfo.get(url) || {}).image || serverThumb(url), preload: 'none', playsinline: true, muted: loud ? undefined : true, style: stable ? videoBoxStyle(feedMedia(url)) : undefined, // learned now, used by the next paint of any row with this clip onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight), - onError: (e) => { if (!stable) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); + onError: (e) => { if (!stable && !(e.target.getAttribute('src') || '').startsWith(VMEDIA)) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); + guardPicture(v, url); v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted const box = h('div', { class: 'note-video-box' }, v); if (loud) box._skipMorph = true; @@ -6782,7 +6831,7 @@ export function messagesFeature(ctx) { box._inView = true; clearTimeout(box._release); if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) { - v.setAttribute('src', url); + v.setAttribute('src', playSrc(url)); v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true }); } // a clip over the budget waits for a tap, with what it costs on it @@ -10627,7 +10676,7 @@ export function messagesFeature(ctx) { const video = pager.kind === 'videos'; const media = video ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', - poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || undefined }) + poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) }) : h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' }); const caption = mediaCaption(ev); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; @@ -10659,6 +10708,7 @@ export function messagesFeature(ctx) { }) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' }))))); slide._ev = ev; if (video) { + guardPicture(media, url); media.muted = !pagerLoud; media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's } @@ -10666,7 +10716,7 @@ export function messagesFeature(ctx) { } function pagerAttach(slide) { const m = slide.querySelector('.mp-media'); - if (m && !m.getAttribute('src')) m.setAttribute('src', m.getAttribute('data-src')); + if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src')); } function pagerRelease(slide) { const m = slide.querySelector('video.mp-media'); @@ -10741,7 +10791,7 @@ export function messagesFeature(ctx) { } function startSlide(slide) { const v = slide.querySelector('video.mp-media'); - if (/#t=[\d.]+$/.test(v.getAttribute('src') || '')) v.setAttribute('src', v.getAttribute('data-src')); // the still's src: from the start now + if (/#t=[\d.]+$/.test(v.getAttribute('src') || '')) v.setAttribute('src', playSrc(v.getAttribute('data-src'))); // the still's src: from the start now v.preload = 'auto'; pagerAttach(slide); v.muted = !pagerLoud; diff --git a/src/i18n.js b/src/i18n.js index 425b059..61a97dd 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -258,6 +258,8 @@ const STR = { playVideo: 'Play', videoUnmute: 'Unmute', videoTapToPlay: "Tap to play", + videoNoPicture: "This browser can’t show this video’s picture", + videoOpenFile: "Open the file", videoLargeTitle: "Videos", videoLargeToggle: "Autoplay large videos", videoLargeHelp: "Videos over {mb} MB or above 1080p wait for a tap, so the feed doesn’t eat your data. The limit is lower on a slow or data-saving connection.", diff --git a/src/style.css b/src/style.css index 5acd9ea..7fb4c0a 100644 --- a/src/style.css +++ b/src/style.css @@ -1494,6 +1494,14 @@ body::after { display: flex; align-items: center; justify-content: center; font-size: 22px; padding-left: 4px; } .note-video-box .vid-heavy { min-height: 120px; } +/* a clip whose picture this browser can't decode, even as the server's copy: + its sound plays, this says why the box is black */ +.vid-nopic { + position: absolute; left: 0; right: 0; top: 40%; z-index: 2; padding: 0 16px; + display: flex; flex-direction: column; align-items: center; gap: 6px; pointer-events: none; + color: #fff; font-size: 14px; text-align: center; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); +} +.vid-nopic a { color: #fff; text-decoration: underline; pointer-events: auto; } .media-tile-cost { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, .8); font-size: 12px; font-weight: 600; background: #16181d; -- cgit v1.2.3