Add Open Graph link previews to photo pages
Shared links now unfurl with an image in WhatsApp, Telegram and anything else that reads Open Graph, instead of showing the bare URL. A dedicated og/ rendition is generated per photo: 1200px longest side, quality laddered down until it fits under ~250 KB. WhatsApp drops the preview entirely once the image passes ~300 KB, and the medium rendition can exceed that, so it is not reused. Dimensions for og:image:width and og:image:height are read back from the encoded file with DecodeConfig, so they are emitted even when the rendition itself was skipped as up to date. The og:image and og:url values are absolute via BaseURL; both crawlers ignore relative image paths. Previews therefore only work against the deployed base URL, not http://localhost.
This commit is contained in:
+96
-7
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"html/template"
|
||||
"image"
|
||||
@@ -25,8 +26,19 @@ const (
|
||||
thumbSize = 600
|
||||
mediumMaxSize = 1600
|
||||
defaultPageSize = 60
|
||||
|
||||
// Open Graph preview image, shared to WhatsApp/Telegram/etc. Kept to a
|
||||
// 1200px longest side and squeezed under ogTargetBytes: WhatsApp is widely
|
||||
// reported to drop the preview entirely once the image passes ~300 KB, so
|
||||
// the medium rendition (which can exceed that) is not reused here.
|
||||
ogMaxSize = 1200
|
||||
ogTargetBytes = 250 * 1024
|
||||
)
|
||||
|
||||
// ogQualityLadder is walked from best to worst until the encoded OG image
|
||||
// fits under ogTargetBytes. The last entry is used as-is even if still over.
|
||||
var ogQualityLadder = []int{85, 78, 70, 62}
|
||||
|
||||
// PhotoMeta is the structure of a .toml sidecar file.
|
||||
type PhotoMeta struct {
|
||||
Caption string `toml:"caption"`
|
||||
@@ -35,12 +47,15 @@ type PhotoMeta struct {
|
||||
|
||||
// Photo is the in-memory representation of one image.
|
||||
type Photo struct {
|
||||
Slug string
|
||||
File string // relative path under public/, e.g. "images/foo.jpg"
|
||||
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
|
||||
Medium string // relative path under public/, e.g. "medium/foo.jpg"
|
||||
Caption string
|
||||
Date time.Time
|
||||
Slug string
|
||||
File string // relative path under public/, e.g. "images/foo.jpg"
|
||||
Thumb string // relative path under public/, e.g. "thumbs/foo.jpg"
|
||||
Medium string // relative path under public/, e.g. "medium/foo.jpg"
|
||||
OG string // relative path under public/, e.g. "og/foo.jpg"
|
||||
OGWidth int // pixel dimensions of the OG image, for og:image:width/height
|
||||
OGHeight int
|
||||
Caption string
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// DayGroup is a run of photos sharing the same calendar day, for the timeline.
|
||||
@@ -106,7 +121,7 @@ func (g *Generator) Build() error {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, d := range []string{"images", "thumbs", "medium", "photo", "grid", "day", "static"} {
|
||||
for _, d := range []string{"images", "thumbs", "medium", "og", "photo", "grid", "day", "static"} {
|
||||
if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -191,6 +206,7 @@ func (g *Generator) loadPhotos() ([]Photo, error) {
|
||||
File: "images/" + name,
|
||||
Thumb: "thumbs/" + slug + ".jpg",
|
||||
Medium: "medium/" + slug + ".jpg",
|
||||
OG: "og/" + slug + ".jpg",
|
||||
Caption: meta.Caption,
|
||||
Date: date,
|
||||
})
|
||||
@@ -210,6 +226,7 @@ func (g *Generator) processImage(p *Photo) error {
|
||||
dstImg := filepath.Join(g.outputDir, p.File)
|
||||
dstThumb := filepath.Join(g.outputDir, p.Thumb)
|
||||
dstMedium := filepath.Join(g.outputDir, p.Medium)
|
||||
dstOG := filepath.Join(g.outputDir, p.OG)
|
||||
|
||||
if !upToDate(src, dstImg) {
|
||||
if err := copyFile(src, dstImg); err != nil {
|
||||
@@ -226,9 +243,36 @@ func (g *Generator) processImage(p *Photo) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !upToDate(src, dstOG) {
|
||||
if err := generateOG(src, dstOG, ogMaxSize); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// og:image:width/height need the OG image's real dimensions, and they must
|
||||
// be known even when the file above was skipped as up to date. DecodeConfig
|
||||
// reads only the JPEG header, so this is cheap.
|
||||
if cfg, err := decodeConfigFile(dstOG); err == nil {
|
||||
p.OGWidth = cfg.Width
|
||||
p.OGHeight = cfg.Height
|
||||
} else {
|
||||
log.Printf("og dimensions %s: %v", p.Slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeConfigFile reads just the image header of path and returns its config
|
||||
// (dimensions, colour model) without decoding the pixels.
|
||||
func decodeConfigFile(path string) (image.Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return image.Config{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
// upToDate reports whether dst exists and is at least as new as src.
|
||||
func upToDate(src, dst string) bool {
|
||||
si, err := os.Stat(src)
|
||||
@@ -318,6 +362,51 @@ func generateMedium(src, dst string, maxDim int) error {
|
||||
return jpeg.Encode(out, resized, &jpeg.Options{Quality: 88})
|
||||
}
|
||||
|
||||
// generateOG reads src, resizes it so its longest side is at most maxDim, and
|
||||
// writes a JPEG small enough for social link previews. It walks ogQualityLadder
|
||||
// from best to worst, stopping at the first quality whose output is under
|
||||
// ogTargetBytes, and falls back to the lowest quality if none fits.
|
||||
func generateOG(src, dst string, maxDim int) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
nw, nh := w, h
|
||||
if w > maxDim || h > maxDim {
|
||||
if w >= h {
|
||||
nw = maxDim
|
||||
nh = h * maxDim / w
|
||||
} else {
|
||||
nh = maxDim
|
||||
nw = w * maxDim / h
|
||||
}
|
||||
}
|
||||
|
||||
resized := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
xdraw.CatmullRom.Scale(resized, resized.Bounds(), img, b, xdraw.Over, nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
for i, q := range ogQualityLadder {
|
||||
buf.Reset()
|
||||
if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: q}); err != nil {
|
||||
return err
|
||||
}
|
||||
if buf.Len() <= ogTargetBytes || i == len(ogQualityLadder)-1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return os.WriteFile(dst, buf.Bytes(), 0644)
|
||||
}
|
||||
|
||||
// renderGridPages writes the paginated square-grid view under public/grid/:
|
||||
// grid/index.html and grid/page/N/index.html, chunking photos into pageSize
|
||||
// slices for progressive pagination. The grid is the secondary view; the
|
||||
|
||||
Reference in New Issue
Block a user