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, { assert.deepEqual(resolved.ok && resolved.embed, {
kind: 'video', kind: 'video',
src: '/media/learn/pig-tour.7f3a91c2.mp4', 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. // The flat field stays in step for callers written before the union.
assert.equal(resolved.ok && resolved.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4'); 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'), { assert.deepEqual(learnEmbed('pig', 'pig-tour.7f3a91c2.mp4'), {
kind: 'video', kind: 'video',
src: '/media/learn/pig-tour.7f3a91c2.mp4', 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, { assert.deepEqual(hosted?.embed, {
kind: 'video', kind: 'video',
src: '/media/learn/pig-tour.7f3a91c2.mp4', 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. // 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'); assert.equal(hosted?.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4');
+53 -10
View File
@@ -1,16 +1,22 @@
/** /**
* The 16:9 area a video card leads with. * The 16:9 area a video card leads with.
* *
* There are no thumbnail images — nothing renders a frame of a Cap embed * PIG-hosted videos carry a real frame, cut from the clip itself and named
* without loading the embed, and loading nine of them to decorate a grid is * after the clip's own content hash, so it cannot go stale against what it
* how a page becomes unusable on a phone. So the poster is generated: a * claims to show.
* gradient picked deterministically from the resource id, a watermark glyph
* for the track, and the two things a reader actually needs on it — an
* unmistakable play affordance and the duration.
* *
* Deterministic, not random, because a card that re-tints on every render * Everything else falls back to a generated poster, and deliberately: 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. The
* generated version is a gradient picked deterministically from the resource
* id — deterministic, not random, because a card that re-tints on every render
* reads as a bug and destroys the sense that these are distinct objects. * reads as a bug and destroys the sense that these are distinct objects.
*
* The fallback is also the error path. A poster is asserted by the resolver
* rather than verified on disk, so a 404 here is an ordinary condition and must
* degrade to the gradient rather than to a broken-image glyph.
*/ */
import { useEffect, useState } from 'react';
import { BookOpen, LineChart, MonitorPlay, Play } from 'lucide-react'; import { BookOpen, LineChart, MonitorPlay, Play } from 'lucide-react';
import type { LearnTrack } from '@pig/core'; import type { LearnTrack } from '@pig/core';
import { cn } from '@/components/ui'; import { cn } from '@/components/ui';
@@ -44,12 +50,18 @@ export function LearnPoster({
seed, seed,
track, track,
duration, duration,
poster,
alt,
size = 'card', size = 'card',
className, className,
}: { }: {
seed: string; seed: string;
track: LearnTrack; track: LearnTrack;
duration: string | null; duration: string | null;
/** A real frame, when the video is one PIG serves itself. */
poster?: string | null;
/** Only used when a real frame is shown; the generated poster is decorative. */
alt?: string;
/** `row` drops the ornament and shrinks the play button for a list thumbnail. */ /** `row` drops the ornament and shrinks the play button for a list thumbnail. */
size?: 'card' | 'row'; size?: 'card' | 'row';
className?: string; className?: string;
@@ -57,6 +69,12 @@ export function LearnPoster({
const Glyph = TRACK_GLYPHS[track]; const Glyph = TRACK_GLYPHS[track];
const compact = size === 'row'; const compact = size === 'row';
const [imageFailed, setImageFailed] = useState(false);
// Reset when the card is reused for a different resource, or one missing
// poster would suppress the next card's working one.
useEffect(() => setImageFailed(false), [poster]);
const showImage = Boolean(poster) && !imageFailed;
return ( return (
<div <div
className={cn( className={cn(
@@ -65,23 +83,48 @@ export function LearnPoster({
className, className,
)} )}
> >
{showImage ? (
<>
<img
src={poster as string}
alt={alt ?? ''}
loading="lazy"
decoding="async"
className="absolute inset-0 size-full object-cover object-top"
onError={() => setImageFailed(true)}
/>
{/*
A screenshot is mostly near-white, and the play button and duration
badge have to stay legible on top of it in both themes. A scrim at
the corners costs nothing and removes the need to restyle either
control per-poster.
*/}
<div
className="absolute inset-0 bg-gradient-to-t from-black/35 via-transparent to-black/10"
aria-hidden
/>
</>
) : null}
{/* {/*
Texture, so a generated poster reads as an image rather than as a card Texture, so a generated poster reads as an image rather than as a card
that failed to load. All three layers are the palette's own tokens at that failed to load. All three layers are the palette's own tokens at
low alpha, which is what keeps them legible in both themes without a low alpha, which is what keeps them legible in both themes without a
second set of values for dark. second set of values for dark.
*/} */}
<div {showImage ? null : <div
className="absolute inset-0 bg-[repeating-linear-gradient(135deg,hsl(var(--fg)/0.04)_0px,hsl(var(--fg)/0.04)_1px,transparent_1px,transparent_10px)]" className="absolute inset-0 bg-[repeating-linear-gradient(135deg,hsl(var(--fg)/0.04)_0px,hsl(var(--fg)/0.04)_1px,transparent_1px,transparent_10px)]"
aria-hidden aria-hidden
/> />}
{showImage ? null : (
<div <div
className="absolute inset-0 bg-[radial-gradient(circle_at_28%_18%,hsl(var(--surface)/0.8),transparent_62%)]" className="absolute inset-0 bg-[radial-gradient(circle_at_28%_18%,hsl(var(--surface)/0.8),transparent_62%)]"
aria-hidden aria-hidden
/> />
)}
{/* The track's glyph, at card size only — at thumbnail size it collides {/* The track's glyph, at card size only — at thumbnail size it collides
with the play button and reads as a second, broken control. */} with the play button and reads as a second, broken control. */}
{compact ? null : ( {compact || showImage ? null : (
<Glyph <Glyph
className="absolute -bottom-6 -right-4 size-32 text-fg/[0.06]" className="absolute -bottom-6 -right-4 size-32 text-fg/[0.06]"
strokeWidth={1.25} strokeWidth={1.25}
@@ -9,6 +9,7 @@
import { formatLearnDuration } from '@pig/core'; import { formatLearnDuration } from '@pig/core';
import { Badge, Card } from '@/components/ui'; import { Badge, Card } from '@/components/ui';
import { ArchiveControl } from './ArchiveControl'; import { ArchiveControl } from './ArchiveControl';
import { learnPlayback } from './model';
import { LearnPoster } from './LearnPoster'; import { LearnPoster } from './LearnPoster';
import type { LearnResourceView } from './model'; import type { LearnResourceView } from './model';
@@ -21,6 +22,7 @@ export function LearnVideoCard({
managing: boolean; managing: boolean;
onPlay: (resource: LearnResourceView) => void; onPlay: (resource: LearnResourceView) => void;
}) { }) {
const playback = learnPlayback(resource);
return ( return (
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md"> <Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md">
<button <button
@@ -34,6 +36,10 @@ export function LearnVideoCard({
seed={resource.id} seed={resource.id}
track={resource.track} track={resource.track}
duration={formatLearnDuration(resource.durationSeconds)} duration={formatLearnDuration(resource.durationSeconds)}
// Only a PIG-hosted clip has a real frame; a Cap embed resolves to
// an iframe and falls back to the generated poster.
poster={playback?.kind === 'video' ? playback.poster : null}
alt={`Still from ${resource.title}`}
/> />
<div className="flex min-w-0 flex-1 flex-col gap-1.5 p-4"> <div className="flex min-w-0 flex-1 flex-col gap-1.5 p-4">
{/* break-words, not truncate: a title is the only way to tell two {/* break-words, not truncate: a title is the only way to tell two
@@ -11,6 +11,7 @@ import { ChevronRight } from 'lucide-react';
import { formatLearnDuration } from '@pig/core'; import { formatLearnDuration } from '@pig/core';
import { Badge, Card } from '@/components/ui'; import { Badge, Card } from '@/components/ui';
import { ArchiveControl } from './ArchiveControl'; import { ArchiveControl } from './ArchiveControl';
import { learnPlayback } from './model';
import { LearnPoster } from './LearnPoster'; import { LearnPoster } from './LearnPoster';
import { bySortOrder, type LearnResourceView } from './model'; import { bySortOrder, type LearnResourceView } from './model';
@@ -30,7 +31,9 @@ export function LearnWalkthroughList({
for a code-holder and in a capped column inside the shell for a member, for a code-holder and in a capped column inside the shell for a member,
and a cap here would fight one of them. */ and a cap here would fight one of them. */
<ol className="flex min-w-0 list-none flex-col gap-3"> <ol className="flex min-w-0 list-none flex-col gap-3">
{ordered.map((resource, index) => ( {ordered.map((resource, index) => {
const playback = learnPlayback(resource);
return (
<li key={resource.id} className="min-w-0"> <li key={resource.id} className="min-w-0">
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row"> <Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row">
<button <button
@@ -43,6 +46,10 @@ export function LearnWalkthroughList({
seed={resource.id} seed={resource.id}
track={resource.track} track={resource.track}
duration={formatLearnDuration(resource.durationSeconds)} duration={formatLearnDuration(resource.durationSeconds)}
// Only a PIG-hosted clip has a real frame; a Cap embed resolves to
// an iframe and falls back to the generated poster.
poster={playback?.kind === 'video' ? playback.poster : null}
alt={`Still from ${resource.title}`}
size="row" size="row"
/> />
</div> </div>
@@ -78,7 +85,8 @@ export function LearnWalkthroughList({
) : null} ) : null}
</Card> </Card>
</li> </li>
))} );
})}
</ol> </ol>
); );
} }
+38 -2
View File
@@ -133,6 +133,10 @@ export const LEARN_MEDIA_CONTENT_TYPES: Record<string, string> = {
mp4: 'video/mp4', mp4: 'video/mp4',
webm: 'video/webm', webm: 'video/webm',
m4v: 'video/x-m4v', m4v: 'video/x-m4v',
// Poster frames. Served from the same directory and the same route as the
// video they were cut from — see `learnPosterFilename`.
jpg: 'image/jpeg',
webp: 'image/webp',
}; };
/** /**
@@ -146,7 +150,7 @@ export const LEARN_MEDIA_CONTENT_TYPES: Record<string, string> = {
* unbounded filename is an unbounded path to `stat`. * unbounded filename is an unbounded path to `stat`.
*/ */
export const LEARN_MEDIA_FILENAME_PATTERN = export const LEARN_MEDIA_FILENAME_PATTERN =
/^(?=.{1,120}$)[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.(?:mp4|webm|m4v)$/; /^(?=.{1,120}$)[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.(?:mp4|webm|m4v|jpg|webp)$/;
export function isLearnMediaFilename(value: string): boolean { export function isLearnMediaFilename(value: string): boolean {
return LEARN_MEDIA_FILENAME_PATTERN.test(value); return LEARN_MEDIA_FILENAME_PATTERN.test(value);
@@ -157,6 +161,26 @@ export function learnMediaPath(filename: string): string {
return `${LEARN_MEDIA_PATH_PREFIX}${filename}`; return `${LEARN_MEDIA_PATH_PREFIX}${filename}`;
} }
/**
* The poster that belongs to a video file.
*
* Derived by swapping the extension, which keeps the VIDEO's content hash in
* the poster's name: `overview.4d4581ae.mp4` -> `overview.4d4581ae.jpg`.
* Re-rendering a video changes both names together, so a poster can never go
* stale against the clip it claims to show — which a separately hashed or
* hand-named thumbnail would eventually do.
*
* Returns null for a filename that is not a video, so a poster cannot acquire
* a poster of its own.
*/
export function learnPosterFilename(videoFilename: string): string | null {
const dot = videoFilename.lastIndexOf('.');
if (dot <= 0) return null;
const extension = videoFilename.slice(dot + 1).toLowerCase();
if (!LEARN_MEDIA_CONTENT_TYPES[extension]?.startsWith('video/')) return null;
return `${videoFilename.slice(0, dot)}.jpg`;
}
/** The Content-Type for a validated filename, or null if it has no known one. */ /** The Content-Type for a validated filename, or null if it has no known one. */
export function learnMediaContentType(filename: string): string | null { export function learnMediaContentType(filename: string): string | null {
const extension = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase(); const extension = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
@@ -343,7 +367,19 @@ export const LEARN_EMBED_REJECTION_MESSAGES: Record<LearnEmbedRejection, string>
/** Build the render instruction for a provider and an already-validated id. */ /** Build the render instruction for a provider and an already-validated id. */
function embedFor(definition: LearnProviderDefinition, externalId: string): LearnEmbed { function embedFor(definition: LearnProviderDefinition, externalId: string): LearnEmbed {
const src = definition.embed(externalId); const src = definition.embed(externalId);
return definition.kind === 'video' ? { kind: 'video', src } : { kind: 'iframe', src }; if (definition.kind !== 'video') return { kind: 'iframe', src };
/*
* The poster is asserted, not verified — this module is pure and has no
* filesystem. A poster that was never generated 404s, which a <video poster>
* renders exactly as it renders no poster at all, and which the card falls
* back from. Claiming a missing image is therefore free; omitting one that
* exists would cost every card its thumbnail.
*/
const posterFile = learnPosterFilename(externalId);
return posterFile
? { kind: 'video', src, poster: learnMediaPath(posterFile) }
: { kind: 'video', src };
} }
/** /**