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:
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>}
|
||||||
<div
|
{showImage ? null : (
|
||||||
className="absolute inset-0 bg-[radial-gradient(circle_at_28%_18%,hsl(var(--surface)/0.8),transparent_62%)]"
|
<div
|
||||||
aria-hidden
|
className="absolute inset-0 bg-[radial-gradient(circle_at_28%_18%,hsl(var(--surface)/0.8),transparent_62%)]"
|
||||||
/>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user