Render giphy tokens, and give a video its shape before it plays #11
@@ -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 — ``,
|
written as a token rather than an address — ``,
|
||||||
``, `` — and in every case the token is
|
``, `` — 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.
|
||||||
|
|||||||
+34
-17
@@ -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 ``, ``
|
* Reddit writes them as ``, ``
|
||||||
* or `` — a token rather than an address. In every case
|
* or `` — 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 ? `` : whole;
|
if (url) return ``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const giphy = GIPHY_TOKEN.exec(token)?.[1];
|
||||||
|
return giphy ? `` : whole;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -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
@@ -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('', GIPHY), '');
|
// 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('', GIPHY), '');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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('', undefined),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveInlineImages('', GIPHY),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the metadata still wins where there is any, being what Reddit will serve', () => {
|
||||||
|
assert.match(resolveInlineImages('', GIPHY), /redd\.it/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a variant name after the id is dropped, not all of them being served', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveInlineImages('', undefined),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(``, undefined), ``);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {
|
||||||
|
|||||||
@@ -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/"'));
|
||||||
|
|||||||
Reference in New Issue
Block a user