diff --git a/apps/api/test/learn.test.ts b/apps/api/test/learn.test.ts index 98a9ffd..299dc11 100644 --- a/apps/api/test/learn.test.ts +++ b/apps/api/test/learn.test.ts @@ -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'); diff --git a/apps/web/src/components/learn/LearnPoster.tsx b/apps/web/src/components/learn/LearnPoster.tsx index 8ab720f..5a2c3f2 100644 --- a/apps/web/src/components/learn/LearnPoster.tsx +++ b/apps/web/src/components/learn/LearnPoster.tsx @@ -1,16 +1,22 @@ /** * The 16:9 area a video card leads with. * - * There are no thumbnail images — nothing renders a frame of a Cap embed - * without loading the embed, and loading nine of them to decorate a grid is - * how a page becomes unusable on a phone. So the poster is generated: a - * 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. + * PIG-hosted videos carry a real frame, cut from the clip itself and named + * after the clip's own content hash, so it cannot go stale against what it + * claims to show. * - * 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. + * + * 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 type { LearnTrack } from '@pig/core'; import { cn } from '@/components/ui'; @@ -44,12 +50,18 @@ export function LearnPoster({ seed, track, duration, + poster, + alt, size = 'card', className, }: { seed: string; track: LearnTrack; 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. */ size?: 'card' | 'row'; className?: string; @@ -57,6 +69,12 @@ export function LearnPoster({ const Glyph = TRACK_GLYPHS[track]; 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 (
+ {showImage ? ( + <> + {alt 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. + */} +
+ + ) : null} + {/* 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 low alpha, which is what keeps them legible in both themes without a second set of values for dark. */} -
-
+ />} + {showImage ? null : ( +
+ )} {/* The track's glyph, at card size only — at thumbnail size it collides with the play button and reads as a second, broken control. */} - {compact ? null : ( + {compact || showImage ? null : ( void; }) { + const playback = learnPlayback(resource); return (
@@ -78,7 +85,8 @@ export function LearnWalkthroughList({ ) : null} - ))} + ); + })} ); } diff --git a/packages/core/src/learn.ts b/packages/core/src/learn.ts index 3dbedc9..6f9a1c8 100644 --- a/packages/core/src/learn.ts +++ b/packages/core/src/learn.ts @@ -133,6 +133,10 @@ export const LEARN_MEDIA_CONTENT_TYPES: Record = { mp4: 'video/mp4', webm: 'video/webm', 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 = { * unbounded filename is an unbounded path to `stat`. */ 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 { return LEARN_MEDIA_FILENAME_PATTERN.test(value); @@ -157,6 +161,26 @@ export function learnMediaPath(filename: string): string { 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. */ export function learnMediaContentType(filename: string): string | null { const extension = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase(); @@ -343,7 +367,19 @@ export const LEARN_EMBED_REJECTION_MESSAGES: Record /** Build the render instruction for a provider and an already-validated id. */ function embedFor(definition: LearnProviderDefinition, externalId: string): LearnEmbed { 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