v3: journal now shows the generated still for video dumps, like the hot/new feeds already did
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s

The hot/new feeds go through FilePreview, which asks for
GET /api/thumbnails/:dumpId unconditionally on a video mime and lets VideoThumb
fall back to an icon if it 404s. The journal instead only recognised image files
and dump.thumbnailMime — and that field maps to custom_thumbnail_mime, i.e. a
thumbnail somebody uploaded by hand. Nothing on the Dump object advertises the
ffmpeg still the route generates on demand, so hasThumbnail() returned false,
mode degraded to text, and the card rendered a 🎬.

hasThumbnail() and JournalCard's thumbnail resolution now both treat a video
file as art, resolving to the same route. Since hasThumbnail() also gates grid
footprints, video dumps can now claim feature/tall/wide slots — the mosaic has
noticeably more image cards as a result, which is the point.

ThumbnailPlaceholder gains `glyph` and `seed`: a host without ffmpeg gets a 404
and lands on the placeholder, where the mime emoji says more about the dump than
an initial taken from its filename, and the hue is seeded from the filename
since there's no hostname to hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-08-11 20:04:09 +00:00
parent 7303509b6f
commit 89314b1b6e
4 changed files with 40 additions and 17 deletions

View File

@@ -41,12 +41,17 @@ export function JournalCard(
navigate(dumpUrl(dump)); navigate(dumpUrl(dump));
} }
const thumbnailUrl = // Mirrors FilePreview (the hot/new feeds) so a video shows its generated
dump.kind === "file" && dump.fileMime?.startsWith("image/") // still here too, rather than degrading to a text card with a 🎬.
const thumbnailUrl = dump.thumbnailMime
? dumpThumbnailUrl(dump, token)
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, token) ? dumpFileUrl(dump, token)
: dump.thumbnailMime : dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, token) ? dumpThumbnailUrl(dump, token)
: (dump.richContent?.thumbnailUrl ?? null); : null)
: (dump.richContent?.thumbnailUrl ?? null);
// Content mode is independent of grid footprint: a thumbnailed dump reads as // Content mode is independent of grid footprint: a thumbnailed dump reads as
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and // an image card, a thumbnail-less dump with a note becomes a pull-quote, and
@@ -151,12 +156,16 @@ export function JournalCard(
<div className="journal-card-image"> <div className="journal-card-image">
<Thumbnail <Thumbnail
src={thumbnailUrl ?? undefined} src={thumbnailUrl ?? undefined}
placeholder={{ placeholder={dump.kind === "file"
url: dump.url, // No ffmpeg on the host means no still — the mime glyph says more
accentColor: dump.richContent?.accentColor, // about the dump than an initial taken from its filename.
faviconUrl: dump.richContent?.faviconUrl, ? { seed: dump.fileName ?? dump.id, glyph: fallbackIcon }
siteName: dump.richContent?.siteName, : {
}} url: dump.url,
accentColor: dump.richContent?.accentColor,
faviconUrl: dump.richContent?.faviconUrl,
siteName: dump.richContent?.siteName,
}}
/> />
{embedUrl && ( {embedUrl && (
<span className="rich-content-play-overlay" aria-hidden="true"> <span className="rich-content-play-overlay" aria-hidden="true">

View File

@@ -5,12 +5,16 @@ import { initialsFor, tintFor } from "../utils/thumbnailTint.ts";
interface ThumbnailPlaceholderProps { interface ThumbnailPlaceholderProps {
/** The dumped URL — seeds the fallback hue and the initial. */ /** The dumped URL — seeds the fallback hue and the initial. */
url?: string; url?: string;
/** Explicit hue seed for dumps with no URL to hash (file dumps). */
seed?: string;
/** The target page's declared brand color, if it had one. */ /** The target page's declared brand color, if it had one. */
accentColor?: string; accentColor?: string;
/** The target page's icon, drawn contained over the tint. */ /** The target page's icon, drawn contained over the tint. */
faviconUrl?: string; faviconUrl?: string;
/** Preferred source for the initial when there's no favicon. */ /** Preferred source for the initial when there's no favicon. */
siteName?: string; siteName?: string;
/** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */
glyph?: string;
className?: string; className?: string;
} }
@@ -23,7 +27,7 @@ interface ThumbnailPlaceholderProps {
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile. * compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
*/ */
export default function ThumbnailPlaceholder( export default function ThumbnailPlaceholder(
{ url, accentColor, faviconUrl, siteName, className }: { url, seed, accentColor, faviconUrl, siteName, glyph, className }:
ThumbnailPlaceholderProps, ThumbnailPlaceholderProps,
) { ) {
const [iconFailed, setIconFailed] = useState(false); const [iconFailed, setIconFailed] = useState(false);
@@ -36,7 +40,7 @@ export default function ThumbnailPlaceholder(
setIconFailed(false); setIconFailed(false);
} }
const style = { "--thumb-tint": tintFor({ accentColor, url }) } as const style = { "--thumb-tint": tintFor({ accentColor, url, seed }) } as
React.CSSProperties; React.CSSProperties;
return ( return (
@@ -58,7 +62,7 @@ export default function ThumbnailPlaceholder(
) )
: ( : (
<span className="thumb-placeholder-initials"> <span className="thumb-placeholder-initials">
{initialsFor(siteName, url)} {glyph ?? initialsFor(siteName, url)}
</span> </span>
)} )}
</div> </div>

View File

@@ -17,7 +17,12 @@ export interface JournalEntry {
/** A dump that can carry a real preview image (file image or rich thumbnail). */ /** A dump that can carry a real preview image (file image or rich thumbnail). */
export function hasThumbnail(dump: Dump): boolean { export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true; if (dump.kind === "file") {
const mime = dump.fileMime ?? "";
// Videos count: GET /api/thumbnails/:dumpId grabs a still with ffmpeg on
// first request and caches it, so there's art to show without any upload.
if (mime.startsWith("image/") || mime.startsWith("video/")) return true;
}
if (dump.thumbnailMime) return true; if (dump.thumbnailMime) return true;
return !!dump.richContent?.thumbnailUrl; return !!dump.richContent?.thumbnailUrl;
} }

View File

@@ -43,13 +43,18 @@ function hostnameOf(url: string | undefined): string | undefined {
/** /**
* The page's own brand color when it declared a usable one, otherwise a hue * The page's own brand color when it declared a usable one, otherwise a hue
* derived from its hostname — so every card gets a tint, including rows saved * derived from its hostname — so every card gets a tint, including rows saved
* before accent extraction existed. * before accent extraction existed. File dumps have no hostname to hash and
* pass `seed` (their filename) instead.
*/ */
export function tintFor( export function tintFor(
{ accentColor, url }: { accentColor?: string; url?: string }, { accentColor, url, seed }: {
accentColor?: string;
url?: string;
seed?: string;
},
): string { ): string {
if (isSafeHex(accentColor)) return accentColor; if (isSafeHex(accentColor)) return accentColor;
return hueFromString(hostnameOf(url) ?? url ?? ""); return hueFromString(seed ?? hostnameOf(url) ?? url ?? "");
} }
/** The letter drawn when there's no favicon to show. */ /** The letter drawn when there's no favicon to show. */