bingo: 1200x630 JPEG for og:image
og:image pointed at the 1200px WebP dialog variant: no declared size, a 16:9 frame that link previews crop unpredictably, and WebP isn't rendered by every preview client. The alt text was just the site name. Photo variants now include <slug>-<hex>-og.jpg, a centre crop to 1200x630, and the head declares its type and size. Migration 013 adds photo_og so rows built before this keep using the WebP until "Rebuild resized photos" is run. The alt text names the pictured datacenter and city. resize and the new og_crop share one convert call. Assisted-by: Claude:opus-5
This commit is contained in:
parent
cb2eba1a26
commit
4e074fd4bf
6 changed files with 68 additions and 19 deletions
|
|
@ -51,9 +51,8 @@ class BingoWeb < Sinatra::Base
|
|||
end
|
||||
|
||||
# Preview image: the most recent hit with a photo, else the first photo.
|
||||
def og_photo(board)
|
||||
dc = board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key)
|
||||
dc && "#{base_url}#{photo_path(dc, 1200)}"
|
||||
def og_dc(board)
|
||||
board.incidents.map(&:datacenter).find(&:photo_key) || board.cells.find(&:photo_key)
|
||||
end
|
||||
def fmt_date(d) = d.strftime('%d.%m.%Y')
|
||||
|
||||
|
|
|
|||
7
db/migrations/013_bingo_photo_og.rb
Normal file
7
db/migrations/013_bingo_photo_og.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Sequel.migration do
|
||||
change do
|
||||
# true once bingo/<slug>-<hex>-og.jpg (1200x630 og:image crop) exists;
|
||||
# rows with only the webp variants fall back to -1200.webp
|
||||
add_column :bingo_datacenters, :photo_og, TrueClass, null: false, default: false
|
||||
end
|
||||
end
|
||||
50
lib/bingo.rb
50
lib/bingo.rb
|
|
@ -113,6 +113,13 @@ module Bingo
|
|||
key = width && photo_variants ? Photo.variant_key(photo_key, width) : photo_key
|
||||
key.delete_prefix(Photo::PREFIX)
|
||||
end
|
||||
|
||||
# 1200x630 JPEG for og:image (every preview client renders it); the
|
||||
# webp dialog variant until it exists.
|
||||
def og_file
|
||||
return photo_file(1200) unless photo_key && photo_og
|
||||
Photo.og_key(photo_key).delete_prefix(Photo::PREFIX)
|
||||
end
|
||||
end
|
||||
|
||||
class Incident < Sequel::Model(:bingo_incidents)
|
||||
|
|
@ -154,8 +161,9 @@ module Bingo
|
|||
module Photo
|
||||
PREFIX = 'bingo/'.freeze
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}(?:-(?:480|1200))?\.(jpg|png|webp)\z/
|
||||
WIDTHS = [480, 1200].freeze # card cell (2x), dialog / og:image
|
||||
FILE_RE = /\A[a-z0-9-]+-[0-9a-f]{8}(?:-(?:480|1200|og))?\.(jpg|png|webp)\z/
|
||||
WIDTHS = [480, 1200].freeze # card cell (2x), dialog
|
||||
OG_SIZE = [1200, 630].freeze # og:image, cropped to the 1.91:1 card
|
||||
CONVERT = ENV.fetch('BINGO_CONVERT', 'convert') # ImageMagick
|
||||
TYPES = { 'jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' }.freeze
|
||||
|
||||
|
|
@ -189,41 +197,57 @@ module Bingo
|
|||
# without them the card falls back to the original.
|
||||
def attach(dc, bytes)
|
||||
key = store(dc.slug, bytes)
|
||||
dc.update(photo_key: key, photo_variants: build_variants(key, bytes))
|
||||
dc.update(photo_key: key, **build_variants(key, bytes))
|
||||
key
|
||||
end
|
||||
|
||||
# Re-derives the variants from the stored original.
|
||||
def rebuild(dc)
|
||||
dc.update(photo_variants: build_variants(dc.photo_key, S3.get(dc.photo_key)))
|
||||
dc.update(**build_variants(dc.photo_key, S3.get(dc.photo_key)))
|
||||
end
|
||||
|
||||
def variant_key(key, width)
|
||||
key.sub(/\.\w+\z/, "-#{width}.webp")
|
||||
end
|
||||
|
||||
def og_key(key)
|
||||
key.sub(/\.\w+\z/, '-og.jpg')
|
||||
end
|
||||
|
||||
# Column values for the datacenter row: all variants or none.
|
||||
def build_variants(key, bytes)
|
||||
bytes = bytes.b
|
||||
ext = sniff(bytes) or raise Error, 'not an image'
|
||||
WIDTHS.each { |w| S3.put(variant_key(key, w), resize(bytes, ext, w), content_type: TYPES['webp']) }
|
||||
true
|
||||
S3.put(og_key(key), og_crop(bytes, ext), content_type: TYPES['jpg'])
|
||||
{ photo_variants: true, photo_og: true }
|
||||
rescue Error => e
|
||||
$logger.warn("bingo: no variants for #{key}: #{e.message}")
|
||||
false
|
||||
{ photo_variants: false, photo_og: false }
|
||||
end
|
||||
|
||||
# Downscale (never up) to fit width x width, re-encode as WebP; -strip
|
||||
# drops metadata again. The input coder is pinned to the sniffed type and
|
||||
# memory/area limits guard against decompression bombs.
|
||||
# Downscale (never up) to fit width x width, re-encode as WebP.
|
||||
def resize(bytes, ext, width)
|
||||
coder = { 'jpg' => 'jpeg', 'png' => 'png', 'webp' => 'webp' }.fetch(ext)
|
||||
convert(bytes, ext, ['-resize', "#{width}x#{width}>", '-quality', '80'], 'webp')
|
||||
end
|
||||
|
||||
# Fill OG_SIZE and crop the overflow around the centre.
|
||||
def og_crop(bytes, ext)
|
||||
w, h = OG_SIZE
|
||||
convert(bytes, ext, ['-resize', "#{w}x#{h}^", '-gravity', 'center', '-extent', "#{w}x#{h}",
|
||||
'-quality', '82'], 'jpg')
|
||||
end
|
||||
|
||||
# -strip drops metadata again. The input coder is pinned to the sniffed
|
||||
# type and memory/area limits guard against decompression bombs.
|
||||
def convert(bytes, ext, ops, out_ext)
|
||||
coder = { 'jpg' => 'jpeg', 'png' => 'png', 'webp' => 'webp' }
|
||||
out, err, st = Open3.capture3(
|
||||
CONVERT, '-limit', 'memory', '256MiB', '-limit', 'area', '128MP',
|
||||
"#{coder}:-", '-auto-orient', '-resize', "#{width}x#{width}>",
|
||||
'-strip', '-quality', '80', 'webp:-',
|
||||
"#{coder.fetch(ext)}:-", '-auto-orient', *ops, '-strip', "#{coder.fetch(out_ext)}:-",
|
||||
stdin_data: bytes, binmode: true
|
||||
)
|
||||
raise Error, "convert: #{err.lines.first&.strip}" unless st.success? && sniff(out.b) == 'webp'
|
||||
raise Error, "convert: #{err.lines.first&.strip}" unless st.success? && sniff(out.b) == out_ext
|
||||
out.b
|
||||
rescue Errno::ENOENT
|
||||
raise Error, 'ImageMagick (convert) not installed'
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ module BingoAdmin
|
|||
|
||||
app.post '/admin/bingo/dc/:id/photo/delete' do
|
||||
dc = Bingo::Datacenter[params[:id].to_i] or halt 404
|
||||
dc.update(photo_key: nil, photo_variants: false)
|
||||
dc.update(photo_key: nil, photo_variants: false, photo_og: false)
|
||||
audit!('bingo.photo_remove', dc.slug)
|
||||
session[:bingo_flash] = 'Photo removed from card (object kept in bucket).'
|
||||
redirect "/admin/bingo/dc/#{dc.id}"
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@ class BingoTest < Minitest::Test
|
|||
assert_includes body, '1 of Russia's 25 largest'
|
||||
assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-1200.webp">'
|
||||
assert_includes body, 'src="/p/dc-4-0123abcd-480.webp"'
|
||||
refute_includes body, 'og:image:width'
|
||||
@dcs[3].update(photo_og: true)
|
||||
get '/en'
|
||||
body = last_response.body
|
||||
assert_includes body, '<meta property="og:image" content="https://example.org/p/dc-4-0123abcd-og.jpg">'
|
||||
assert_includes body, '<meta property="og:image:width" content="1200">'
|
||||
assert_includes body, '<meta property="og:image:alt" content="ЦОД 4, Moscow">'
|
||||
assert_includes body, 'og:locale:alternate" content="uk_UA"'
|
||||
ld = JSON.parse(body[%r{<script type="application/ld\+json">(.*?)</script>}m, 1])
|
||||
list = ld['@graph'].find { |n| n['@type'] == 'ItemList' }
|
||||
|
|
@ -238,6 +245,10 @@ class BingoTest < Minitest::Test
|
|||
assert_equal 'webp', Bingo::Photo.sniff(out)
|
||||
dims, = Open3.capture2(Bingo::Photo::CONVERT, 'webp:-', '-format', '%wx%h', 'info:', stdin_data: out, binmode: true)
|
||||
assert_equal '480x240', dims
|
||||
jpg = Bingo::Photo.og_crop(png.b, 'png')
|
||||
assert_equal 'jpg', Bingo::Photo.sniff(jpg)
|
||||
dims, = Open3.capture2(Bingo::Photo::CONVERT, 'jpeg:-', '-format', '%wx%h', 'info:', stdin_data: jpg, binmode: true)
|
||||
assert_equal '1200x630', dims
|
||||
end
|
||||
|
||||
def test_sniff_accepts_utf8_tagged_bytes
|
||||
|
|
@ -252,6 +263,8 @@ class BingoTest < Minitest::Test
|
|||
@dcs[0].update(photo_key: 'bingo/dc-1-0123abcd.png')
|
||||
Bingo::Photo.rebuild(@dcs[0])
|
||||
assert @dcs[0].refresh.photo_variants
|
||||
assert @dcs[0].photo_og
|
||||
assert_equal 'dc-1-0123abcd-og.jpg', @dcs[0].og_file
|
||||
ensure
|
||||
Aws.config[:s3] = { stub_responses: true }
|
||||
S3.instance_variable_set(:@client, nil)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<% base = base_url %>
|
||||
<% desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) %>
|
||||
<% og_img = og_photo(@board) %>
|
||||
<% og = og_dc(@board) %>
|
||||
<% og_img = og && "#{base}/p/#{og.og_file}" %>
|
||||
<!doctype html>
|
||||
<html lang="<%= @locale %>">
|
||||
<head>
|
||||
|
|
@ -27,7 +28,12 @@
|
|||
<% end %>
|
||||
<% if og_img %>
|
||||
<meta property="og:image" content="<%= og_img %>">
|
||||
<meta property="og:image:alt" content="<%= t(:title) %>">
|
||||
<% if og.photo_og %>
|
||||
<meta property="og:image:type" content="image/jpeg">
|
||||
<meta property="og:image:width" content="<%= Bingo::Photo::OG_SIZE[0] %>">
|
||||
<meta property="og:image:height" content="<%= Bingo::Photo::OG_SIZE[1] %>">
|
||||
<% end %>
|
||||
<meta property="og:image:alt" content="<%= og.l(:name, @locale) %>, <%= og.l(:city, @locale) %>">
|
||||
<meta name="twitter:image" content="<%= og_img %>">
|
||||
<% end %>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue