Give the Learn cards a real frame instead of a gradient
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:
@@ -133,6 +133,10 @@ export const LEARN_MEDIA_CONTENT_TYPES: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
* 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<LearnEmbedRejection, string>
|
||||
/** 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 <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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user