5 Commits
Author SHA1 Message Date
thatguygriff 3afdf6ffe2 Merge pull request 'Render giphy tokens, and give a video its shape before it plays' (#11) from giphy-tokens-and-video-posters into main
CI / Typecheck, test, build (push) Successful in 12s
Publish / Build and push (push) Successful in 1m51s
Publish / Move the working version on (push) Skipped
Reviewed-on: #11
2026-08-31 02:41:07 +00:00
thatguygriffandClaude Opus 5 5b4378a838 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
2026-08-30 23:37:59 -03:00
thatguygriffandClaude Opus 5 4669fe0b6a Resolve a giphy token with no metadata to look it up in
`![gif](giphy|zUW23b6FmzB5e)` is a token, not an address, and the only thing
that turned it into one was a lookup in the comment's own `media_metadata`.
Reddit ships plenty of comments carrying such a token and no `media_metadata`
at all, and with nothing to look it up in the token itself was what the
comment showed.

Giphy is the one of the three token kinds whose id means something off
Reddit, so that one can be resolved without the lookup. A variant name after
the id is dropped: Giphy does not serve every variant of every gif, but the
full one is always there.

The other two still resolve only through the metadata. An emote id and an
upload id name nothing outside Reddit, so with no entry for them there is
still nothing to point them at.

Closes #10

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PLkmgp1fWbA4XbarxKdRKt
2026-08-30 23:36:53 -03:00
thatguygriff ebfcc9ebf2 Merge pull request 'Set the working version to 1.2.3' (#9) from release/bump-1.2.3 into main
CI / Typecheck, test, build (push) Successful in 12s
Publish / Build and push (push) Successful in 1m50s
Publish / Move the working version on (push) Skipped
Reviewed-on: #9
2026-08-29 23:08:21 +00:00
Release bot 8fc7a83f8e Set the working version to 1.2.3 2026-08-29 22:56:36 +00:00
7 changed files with 150 additions and 30 deletions
+17 -7
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,16 +148,22 @@ 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)`,
`![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and in every case the token is `![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and the token is usually a key
a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup
lookup rather than three special cases. A bare `preview.redd.it` address pasted into a rather than three special cases. Usually: plenty of comments carry a Giphy token and
comment is in there too, keyed by the id inside the URL. Prefer `s.gif` over `s.mp4` no `media_metadata` at all, and Giphy is the one of the three whose id means something
for an animated one: a GIF moves in an `<img>` and an MP4 needs a player. An address off Reddit, so that token alone falls back to `i.giphy.com/media/<id>/giphy.gif`. A
variant name after the id is dropped — Giphy does not serve every variant of every
gif. A bare `preview.redd.it` address pasted into a comment is in there too, keyed by
the id inside the URL. Prefer `s.gif` over `s.mp4` for an animated one: a GIF moves in
an `<img>` and an MP4 needs a player. An address
typed without a scheme counts as well, but only when it ends in an image extension — typed without a scheme counts as well, but only when it ends in an image extension —
the rule wants a host, a path *and* that extension, because comments are full of the rule wants a host, a path *and* that extension, because comments are full of
dotted, slashed prose that must not turn into links. dotted, slashed prose that must not turn into links.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.2", "version": "1.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "antisocial", "name": "antisocial",
"version": "1.2.2", "version": "1.2.3",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fastify/static": "10.1.3", "@fastify/static": "10.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "antisocial", "name": "antisocial",
"version": "1.2.2", "version": "1.2.3",
"private": true, "private": true,
"description": "Reads social posts back to you without the app.", "description": "Reads social posts back to you without the app.",
"license": "UNLICENSED", "license": "UNLICENSED",
+34 -17
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
@@ -192,14 +191,30 @@ function bodyOf(link: Link): string | undefined {
/** The whole of `![...](...)`, with the target captured. */ /** The whole of `![...](...)`, with the target captured. */
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g; const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
/**
* A Giphy token, which unlike the others says what it points at.
*
* Reddit writes a variant name after the id on some of them
* (`giphy|abc123|downsized`); only the id is kept, because Giphy does not
* serve every variant for every gif but always serves the full one.
*/
const GIPHY_TOKEN = /^giphy\|([A-Za-z0-9]+)(?:\|[a-z_]+)?$/;
/** /**
* Point a comment's inline images at something fetchable. * Point a comment's inline images at something fetchable.
* *
* Reddit writes them as `![gif](giphy|Ve7wX45)`, `![img](emote|t5_2th52|4358)` * Reddit writes them as `![gif](giphy|Ve7wX45)`, `![img](emote|t5_2th52|4358)`
* or `![img](jo8gf0ca92zd1)` — a token rather than an address. In every case * or `![img](jo8gf0ca92zd1)` — a token rather than an address. Usually the
* the token is a key in that same comment's `media_metadata`, which is where * token is a key in that same comment's `media_metadata`, which is where the
* the real URL is, so one lookup covers all three and none of them needs * real URL is, so one lookup covers all three and none of them needs naming
* naming here. * here.
*
* Usually, not always: Reddit ships plenty of comments carrying a Giphy token
* and no `media_metadata` at all, and with nothing to look the token up in
* those showed the token itself where the gif should have been. Giphy is the
* one kind that can be resolved without the lookup, the id in it being Giphy's
* own, so it falls back to Giphy's address for that id. The other two cannot:
* their ids mean nothing off Reddit.
* *
* A target that is already an address is not a key, so it falls through * A target that is already an address is not a key, so it falls through
* untouched. * untouched.
@@ -208,15 +223,17 @@ export function resolveInlineImages(
body: string, body: string,
meta: Record<string, MediaMeta> | undefined, meta: Record<string, MediaMeta> | undefined,
): string { ): string {
if (!meta) return body;
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => { return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
const entry = meta[token]; const entry = meta?.[token];
if (!entry || entry.status !== 'valid') return whole; if (entry?.status === 'valid') {
// An animated one has both; the GIF plays in an `<img>` on its own, which // An animated one has both; the GIF plays in an `<img>` on its own,
// an MP4 does not. // which an MP4 does not.
const url = entry.s?.gif ?? entry.s?.u; const url = entry.s?.gif ?? entry.s?.u;
return url ? `![${alt}](${url})` : whole; if (url) return `![${alt}](${url})`;
}
const giphy = GIPHY_TOKEN.exec(token)?.[1];
return giphy ? `![${alt}](https://i.giphy.com/media/${giphy}/giphy.gif)` : whole;
}); });
} }
+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
+55 -1
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' },
}, },
]); ]);
}); });
@@ -224,13 +228,63 @@ test('a target that is already an address is left alone', () => {
}); });
test('a token with no entry, or a broken one, is not invented', () => { test('a token with no entry, or a broken one, is not invented', () => {
assert.equal(resolveInlineImages('![gif](giphy|missing)', GIPHY), '![gif](giphy|missing)'); // An upload id and an emote id mean nothing off Reddit, so with no entry to
// look them up in there is nothing to point them at.
assert.equal(resolveInlineImages('![img](missing)', GIPHY), '![img](missing)');
assert.equal( assert.equal(
resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }), resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }),
'![img](gone)', '![img](gone)',
); );
}); });
test('a giphy token resolves even when the comment carried no metadata', () => {
// Reddit ships plenty of these with no `media_metadata` at all. The id in
// the token is Giphy's own, so it does not need Reddit to be readable.
assert.equal(
resolveInlineImages('![gif](giphy|zUW23b6FmzB5e)', undefined),
'![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)',
);
assert.equal(
resolveInlineImages('![gif](giphy|missing)', GIPHY),
'![gif](https://i.giphy.com/media/missing/giphy.gif)',
);
});
test('the metadata still wins where there is any, being what Reddit will serve', () => {
assert.match(resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY), /redd\.it/);
});
test('a variant name after the id is dropped, not all of them being served', () => {
assert.equal(
resolveInlineImages('![gif](giphy|zUW23b6FmzB5e|downsized)', undefined),
'![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)',
);
});
test('only a giphy token is guessed at, and only a well-formed one', () => {
for (const token of ['emote|t5_2th52|4358', 'giphy|', 'giphy|../evil', 'giphy|a|b|c']) {
assert.equal(resolveInlineImages(`![x](${token})`, undefined), `![x](${token})`);
}
});
test('a real comment carrying a giphy token gets the gif', () => {
// Captured from the post itself: the comment has the token and no
// `media_metadata`, which is the shape that used to show the token instead.
const post = toPost(fixture('reddit/video.json'), URL_);
const all: string[] = [];
const walk = (list: typeof post.comments) => {
for (const comment of list ?? []) {
if (comment.text) all.push(comment.text);
walk(comment.replies);
}
};
walk(post.comments);
const gif = all.find((text) => text.includes('giphy'));
assert.ok(gif, 'the fixture should still carry a giphy comment');
assert.match(gif, /!\[gif\]\(https:\/\/i\.giphy\.com\/media\/QfzMP70zmNQiDf5sGP\/giphy\.gif\)/);
});
test('inline images survive the walk into the comment tree', () => { test('inline images survive the walk into the comment tree', () => {
const { comments } = commentsFrom({ const { comments } = commentsFrom({
data: { data: {
+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/"'));