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 ? (
+ <>
+ 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 (
{/* break-words, not truncate: a title is the only way to tell two
diff --git a/apps/web/src/components/learn/LearnWalkthroughList.tsx b/apps/web/src/components/learn/LearnWalkthroughList.tsx
index 2400773..a728e21 100644
--- a/apps/web/src/components/learn/LearnWalkthroughList.tsx
+++ b/apps/web/src/components/learn/LearnWalkthroughList.tsx
@@ -11,6 +11,7 @@ import { ChevronRight } from 'lucide-react';
import { formatLearnDuration } from '@pig/core';
import { Badge, Card } from '@/components/ui';
import { ArchiveControl } from './ArchiveControl';
+import { learnPlayback } from './model';
import { LearnPoster } from './LearnPoster';
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,
and a cap here would fight one of them. */
- {ordered.map((resource, index) => (
+ {ordered.map((resource, index) => {
+ 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