Give a video its shape before it has any data
CI / Typecheck, test, build (pull_request) Successful in 26s

A Reddit video sat in the wrong box until you pressed play, for two reasons
that looked like one.

The renderer put only `aspect-ratio` on the `<video>`. A video with no data
has a natural size of 300x150, and WebKit sizes a replaced element from that
rather than from the ratio, so a portrait video got a squat landscape box and
kept it until playback supplied real dimensions. Chromium stretch-fits
instead and gets it right, which is why this only showed on Safari. A
video's size before its data arrives is its poster's, so one with no poster
of its own now gets an empty SVG of the right shape as a stand-in: a data
URI, so it costs no request. Measured in both engines across portrait,
landscape, square and small.

And Reddit's videos with sound had no poster to be sized by, because
`fromRedditVideo` attached the still from `preview.images` to the MP4 branch
alone. The still belongs to the video, not to the format it is served in, so
every post with sound showed an empty box where a silent one showed a frame.
It now goes on both.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PLkmgp1fWbA4XbarxKdRKt
This commit is contained in:
2026-08-30 23:37:59 -03:00
co-authored by Claude Opus 5
parent 4669fe0b6a
commit 5b4378a838
5 changed files with 57 additions and 9 deletions
+8 -2
View File
@@ -96,6 +96,10 @@ Things worth knowing before editing:
`src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN `src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN
demands. `Range` is forwarded — without it the native video scrubber cannot seek. demands. `Range` is forwarded — without it the native video scrubber cannot seek.
The exception is HLS (`direct: true`), because proxying would mean rewriting playlists. The exception is HLS (`direct: true`), because proxying would mean rewriting playlists.
- **A video is sized by its poster**, not by the ratio the renderer puts on it. A
`<video>` with no data has a natural size of 300x150, and WebKit sizes it from that —
so one with no poster of its own gets an empty SVG of the right shape as a stand-in,
without which a portrait video sits in a squat landscape box until you press play.
- **Never a bare error page.** A failed resolve renders a card carrying the platform, the - **Never a bare error page.** A failed resolve renders a card carrying the platform, the
original URL and the copy button. A broken adapter must still leave the link one tap original URL and the copy button. A broken adapter must still leave the link one tap
away. away.
@@ -144,8 +148,10 @@ Things worth knowing before editing:
Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a
post with sound has to use `hls_url`, direct and unproxied; a silent one gets the post with sound has to use `hls_url`, direct and unproxied; a silent one gets the
proxied MP4. `scrubber_media_url` is not a poster — it is a second MP4 for the proxied MP4. `scrubber_media_url` is not a poster — it is a second MP4 for the
timeline thumbnails, and the still is in `preview.images`. A gallery's pictures are in timeline thumbnails, and the still is in `preview.images` — which belongs to both
`media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment forms: the HLS one used to go without, and showed an empty box where a silent post
showed a frame. A gallery's pictures are in `media_metadata`, keyed and unordered;
their order is only in `gallery_data`. Comment
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
back the constructs we chose to support, never `body_html`. An image in a comment is back the constructs we chose to support, never `body_html`. An image in a comment is
written as a token rather than an address — `![gif](giphy|Ve7wX45)`, written as a token rather than an address — `![gif](giphy|Ve7wX45)`,
+5 -6
View File
@@ -104,6 +104,10 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
const common = { const common = {
...sized(video.width, video.height), ...sized(video.width, video.height),
...(video.duration ? { durationSec: video.duration } : {}), ...(video.duration ? { durationSec: video.duration } : {}),
// The still belongs to the video and not to the format it is served in.
// The HLS branch used to drop it, which is why a post with sound showed
// an empty box where every silent one showed a frame.
...(poster ? { poster: { url: poster } } : {}),
}; };
if (video.has_audio !== false && video.hls_url) { if (video.has_audio !== false && video.hls_url) {
@@ -114,12 +118,7 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
if (url === video.hls_url) { if (url === video.hls_url) {
return [{ kind: 'video', url, hls: true, direct: true, ...common }]; return [{ kind: 'video', url, hls: true, direct: true, ...common }];
} }
return [{ return [{ kind: 'video', url, ...common }];
kind: 'video',
url,
...(poster ? { poster: { url: poster } } : {}),
...common,
}];
} }
/** One entry of a gallery post. Reddit keeps the pictures somewhere other /** One entry of a gallery post. Reddit keeps the pictures somewhere other
+19 -1
View File
@@ -14,11 +14,29 @@ function aspect(item: Media): string {
return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : ''; return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : '';
} }
/**
* A stand-in poster carrying nothing but the video's shape.
*
* The ratio above is not enough on its own before the video has any data: a
* `<video>` in that state has a natural size of 300x150, and WebKit sizes it
* from that rather than from the ratio, so a portrait video sat in a squat
* landscape box until you pressed play and it snapped to shape. A video's
* size before its data arrives is its poster's, which makes an empty SVG of
* the right shape enough to put the box right, and as a data URI it costs no
* request. Only for a video the platform gave no poster for, since a real one
* already says the same thing.
*/
function placeholderPoster(item: Media): string | undefined {
if (!item.width || !item.height) return undefined;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${item.width}" height="${item.height}"/>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function renderItem(item: Media, index: number): Raw { function renderItem(item: Media, index: number): Raw {
const src = proxyUrlFor(item); const src = proxyUrlFor(item);
if (item.kind === 'video') { if (item.kind === 'video') {
const poster = item.poster ? proxyUrlFor(item.poster) : undefined; const poster = item.poster ? proxyUrlFor(item.poster) : placeholderPoster(item);
return html`<figure class="item item--video" data-index="${index}"> return html`<figure class="item item--video" data-index="${index}">
<video <video
controls controls
+4
View File
@@ -56,6 +56,7 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
duration: 42, duration: 42,
}, },
}, },
preview: { images: [{ source: { url: 'https://external-preview.redd.it/still.png', width: 1920, height: 1080 } }] },
}); });
assert.deepEqual(media, [ assert.deepEqual(media, [
@@ -67,6 +68,9 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
width: 1920, width: 1920,
height: 1080, height: 1080,
durationSec: 42, durationSec: 42,
// The still is the video's, not the MP4's. Dropping it here left every
// post with sound showing an empty box where a silent one showed a frame.
poster: { url: 'https://external-preview.redd.it/still.png' },
}, },
]); ]);
}); });
+21
View File
@@ -90,6 +90,27 @@ test('a video gets native controls and a source, not an iframe', () => {
assert.ok(!page.includes('<iframe')); assert.ok(!page.includes('<iframe'));
}); });
test('a video with no poster still carries its shape, so the box is right before play', () => {
// WebKit sizes a video from its natural size, which without data or a
// poster is 300x150 — a portrait video sat in a landscape box until you
// pressed play. The poster is where the shape comes from until then.
const page = renderPost(withMedia([
{ kind: 'video', url: 'https://video.example/p.m3u8', hls: true, direct: true, width: 720, height: 1280 },
]));
assert.ok(page.includes('aspect-ratio: 720 / 1280;'));
assert.match(page, /poster="data:image\/svg\+xml,[^"]*width%3D%22720%22[^"]*height%3D%221280%22/);
});
test('a real poster is left in place, and an unmeasured video gets none', () => {
const withPoster = renderPost(withMedia([
{ kind: 'video', url: 'https://cdn/v.mp4', width: 720, height: 1280, poster: { url: 'https://cdn/p.jpg' } },
]));
assert.match(withPoster, /poster="\/m\//);
assert.ok(!withPoster.includes('data:image/svg'));
assert.ok(!renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }])).includes('poster='));
});
test('the copy button carries the clean original URL, and so does the page text', () => { test('the copy button carries the clean original URL, and so does the page text', () => {
const page = renderPost(post()); const page = renderPost(post());
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"')); assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));