Compare commits

...

2 Commits

Author SHA1 Message Date
prosolis
04a3c41bed Fall back to ffmpeg when Go's JPEG decoder rejects a source
Go's stdlib image/jpeg refuses some valid-but-rare features such as
4:1:1 luma/chroma subsampling (ANN's CDN serves these). When the
in-process decode fails, route the bytes through ffmpeg the same way
we already do for video sources.
2026-05-25 16:27:32 -07:00
prosolis
b3002c8e12 Fix mobile horizontal scroll from overflowing channel nav
Channel pill row outgrew the viewport once more channels were added,
pushing the whole page sideways. Let the header wrap, scroll the nav
inside its own pill, and clip body overflow as a safety net.
2026-05-25 15:39:11 -07:00
4 changed files with 36 additions and 14 deletions

View File

@@ -44,6 +44,20 @@ html[data-phase="night"] {
@layer base { @layer base {
body { font-family: "Nunito", system-ui, sans-serif; } body { font-family: "Nunito", system-ui, sans-serif; }
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
/* Belt-and-braces: never let stray wide content push horizontal scroll on
the whole page (the channel nav scrolls inside itself instead). */
html, body { overflow-x: hidden; }
}
@layer components {
/* Channel pill row may exceed viewport on mobile once enough channels are
configured. Let it scroll inside its own rounded container and hide the
scrollbar chrome so it still reads as a clean pill. */
.pete-channel-nav {
scrollbar-width: none;
-ms-overflow-style: none;
}
.pete-channel-nav::-webkit-scrollbar { display: none; }
} }
@layer utilities { @layer utilities {

File diff suppressed because one or more lines are too long

View File

@@ -32,16 +32,16 @@
<canvas id="pete-weather" class="pointer-events-none fixed inset-0 -z-[5] h-full w-full" aria-hidden="true"></canvas> <canvas id="pete-weather" class="pointer-events-none fixed inset-0 -z-[5] h-full w-full" aria-hidden="true"></canvas>
<header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12"> <header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12">
<div class="flex items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-3 sm:gap-4">
<a href="/" class="flex items-center gap-3 group"> <a href="/" class="flex items-center gap-3 group min-w-0">
<img src="/static/img/pete.avif" alt="Pete" width="48" height="48" <img src="/static/img/pete.avif" alt="Pete" width="48" height="48"
class="h-12 w-12 rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 object-cover group-hover:-rotate-6 transition-transform"> class="h-12 w-12 shrink-0 rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 object-cover group-hover:-rotate-6 transition-transform">
<span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</span> <span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</span>
<span class="hidden sm:inline rounded-full bg-[color:var(--accent)]/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/70" data-phase-label>day</span> <span class="hidden sm:inline rounded-full bg-[color:var(--accent)]/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/70" data-phase-label>day</span>
</a> </a>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 min-w-0 max-w-full">
<button type="button" data-search-trigger title="Search (⌘K)" <button type="button" data-search-trigger title="Search (⌘K)"
class="flex items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition"> class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60"> stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
<circle cx="11" cy="11" r="7"></circle> <circle cx="11" cy="11" r="7"></circle>
@@ -50,11 +50,11 @@
<span class="hidden sm:inline text-[color:var(--ink)]/60 font-semibold">Search</span> <span class="hidden sm:inline text-[color:var(--ink)]/60 font-semibold">Search</span>
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd> <kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
</button> </button>
<nav class="flex items-center gap-1 sm:gap-2 rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10"> <nav class="pete-channel-nav flex min-w-0 items-center gap-1 sm:gap-2 overflow-x-auto rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
{{$active := .Active}} {{$active := .Active}}
{{range .Channels}} {{range .Channels}}
<a href="/{{.Slug}}" <a href="/{{.Slug}}"
class="rounded-full px-3 py-2 text-sm font-semibold transition class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
{{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}"> {{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
<span aria-hidden="true" class="mr-1">{{.Emoji}}</span><span class="hidden sm:inline">{{.Title}}</span> <span aria-hidden="true" class="mr-1">{{.Emoji}}</span><span class="hidden sm:inline">{{.Title}}</span>
</a> </a>

View File

@@ -178,7 +178,7 @@ func buildAvifThumb(src, outPath string) error {
} }
if urlIsVideo || isVideoContentType(resp.Header.Get("Content-Type")) { if urlIsVideo || isVideoContentType(resp.Header.Get("Content-Type")) {
return buildAvifFromVideoFrame(body, outPath) return buildAvifFromFFmpeg(body, outPath)
} }
if isAvif(body) { if isAvif(body) {
@@ -195,6 +195,13 @@ func buildAvifThumb(src, outPath string) error {
} }
src2, _, err := image.Decode(bytes.NewReader(body)) src2, _, err := image.Decode(bytes.NewReader(body))
if err != nil { if err != nil {
// Go's stdlib JPEG decoder rejects some valid-but-rare features
// (e.g. 4:1:1 luma/chroma subsampling served by ANN's CDN). Hand
// the bytes to ffmpeg as a permissive fallback — it produces the
// same shape of output as the video path.
if ffErr := buildAvifFromFFmpeg(body, outPath); ffErr == nil {
return nil
}
return fmt.Errorf("decode: %w", err) return fmt.Errorf("decode: %w", err)
} }
b := src2.Bounds() b := src2.Bounds()
@@ -265,12 +272,13 @@ func isVideoContentType(ct string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(ct)), "video/") return strings.HasPrefix(strings.ToLower(strings.TrimSpace(ct)), "video/")
} }
// buildAvifFromVideoFrame writes the downloaded video to a temp file, asks // buildAvifFromFFmpeg writes the downloaded bytes to a temp file, asks
// ffmpeg to extract a single resized frame as PNG, then hands it to avifenc. // ffmpeg to extract a single resized frame as PNG, then hands it to avifenc.
// ffmpeg is optional — if it's missing we return an error and the caller // Used both for video sources and as a permissive fallback for images whose
// falls back to redirecting to the source URL, just like any other build // JPEG features Go's stdlib decoder rejects. ffmpeg is optional — if it's
// failure. // missing we return an error and the caller falls back to redirecting to
func buildAvifFromVideoFrame(body []byte, outPath string) error { // the source URL, just like any other build failure.
func buildAvifFromFFmpeg(body []byte, outPath string) error {
if _, err := exec.LookPath("ffmpeg"); err != nil { if _, err := exec.LookPath("ffmpeg"); err != nil {
return fmt.Errorf("ffmpeg not available: %w", err) return fmt.Errorf("ffmpeg not available: %w", err)
} }