Give the Learn cards a real frame instead of a gradient
CI / verify (push) Successful in 3m23s
CI / publish (push) Has been skipped

The preview cards led with a generated gradient. It was a deliberate fallback —
nothing renders a frame of a Cap embed without loading the embed, and loading
nine embeds to decorate a grid is how a page becomes unusable on a phone — but
for videos PIG serves itself the frame is right there in the file.

The poster is named after the VIDEO's content hash, not its own:
`overview.4d4581ae.mp4` -> `overview.4d4581ae.jpg`. Re-rendering a clip changes
both names together, so a thumbnail cannot outlive what it claims to show. It
needs no schema column and no manifest entry, because the name is derivable.

`learnPoster.sh` cuts the frame with `thumbnail=90` starting four seconds in
rather than taking frame 0: the first frame of a Playwright capture is often
mid-paint, and a poster of a half-rendered page is worse than no poster.

The resolver ASSERTS the poster rather than verifying it — @pig/core is pure and
has no filesystem. That is safe in both directions: a missing poster 404s, which
`<video poster>` renders exactly as it renders no poster, and which the card
falls back from via onError. Claiming a poster that is absent is free; omitting
one that exists would cost every card its thumbnail.

Cap-hosted rows are unchanged and still get the gradient, verified by there
being exactly five <img> elements on a page with nine resources.

Also widens the media allowlist to jpg/webp. The filename pattern, the
traversal rules and the symlink check are untouched and still cover them,
because extension is the only axis that changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 17:53:30 -07:00
parent 45b70b17f0
commit 19dd30acbe
5 changed files with 120 additions and 18 deletions
+9
View File
@@ -119,6 +119,9 @@ describe('self-hosted media', () => {
assert.deepEqual(resolved.ok && resolved.embed, {
kind: 'video',
src: '/media/learn/pig-tour.7f3a91c2.mp4',
// The poster is derived from the VIDEO's content hash, so a re-render
// moves both names together and a thumbnail cannot outlive its clip.
poster: '/media/learn/pig-tour.7f3a91c2.jpg',
});
// The flat field stays in step for callers written before the union.
assert.equal(resolved.ok && resolved.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4');
@@ -170,6 +173,9 @@ describe('self-hosted media', () => {
assert.deepEqual(learnEmbed('pig', 'pig-tour.7f3a91c2.mp4'), {
kind: 'video',
src: '/media/learn/pig-tour.7f3a91c2.mp4',
// The poster is derived from the VIDEO's content hash, so a re-render
// moves both names together and a thumbnail cannot outlive its clip.
poster: '/media/learn/pig-tour.7f3a91c2.jpg',
});
});
@@ -442,6 +448,9 @@ describe('a self-hosted row through /api/learn/public', () => {
assert.deepEqual(hosted?.embed, {
kind: 'video',
src: '/media/learn/pig-tour.7f3a91c2.mp4',
// The poster is derived from the VIDEO's content hash, so a re-render
// moves both names together and a thumbnail cannot outlive its clip.
poster: '/media/learn/pig-tour.7f3a91c2.jpg',
});
// Same-origin and relative, so the page needs no CSP host for it at all.
assert.equal(hosted?.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4');