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:
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -65,23 +83,48 @@ export function LearnPoster({
|
||||
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
|
||||
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.
|
||||
*/}
|
||||
<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)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_28%_18%,hsl(var(--surface)/0.8),transparent_62%)]"
|
||||
aria-hidden
|
||||
/>
|
||||
/>}
|
||||
{showImage ? null : (
|
||||
<div
|
||||
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
|
||||
with the play button and reads as a second, broken control. */}
|
||||
{compact ? null : (
|
||||
{compact || showImage ? null : (
|
||||
<Glyph
|
||||
className="absolute -bottom-6 -right-4 size-32 text-fg/[0.06]"
|
||||
strokeWidth={1.25}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { formatLearnDuration } from '@pig/core';
|
||||
import { Badge, Card } from '@/components/ui';
|
||||
import { ArchiveControl } from './ArchiveControl';
|
||||
import { learnPlayback } from './model';
|
||||
import { LearnPoster } from './LearnPoster';
|
||||
import type { LearnResourceView } from './model';
|
||||
|
||||
@@ -21,6 +22,7 @@ export function LearnVideoCard({
|
||||
managing: boolean;
|
||||
onPlay: (resource: LearnResourceView) => void;
|
||||
}) {
|
||||
const playback = learnPlayback(resource);
|
||||
return (
|
||||
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md">
|
||||
<button
|
||||
@@ -34,6 +36,10 @@ export function LearnVideoCard({
|
||||
seed={resource.id}
|
||||
track={resource.track}
|
||||
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">
|
||||
{/* 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 { 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. */
|
||||
<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">
|
||||
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row">
|
||||
<button
|
||||
@@ -43,6 +46,10 @@ export function LearnWalkthroughList({
|
||||
seed={resource.id}
|
||||
track={resource.track}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -78,7 +85,8 @@ export function LearnWalkthroughList({
|
||||
) : null}
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user