diff --git a/bingo_web.rb b/bingo_web.rb index dd0f020..5e2a340 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -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') diff --git a/db/migrations/013_bingo_photo_og.rb b/db/migrations/013_bingo_photo_og.rb new file mode 100644 index 0000000..0ff93f0 --- /dev/null +++ b/db/migrations/013_bingo_photo_og.rb @@ -0,0 +1,7 @@ +Sequel.migration do + change do + # true once bingo/--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 diff --git a/lib/bingo.rb b/lib/bingo.rb index 83ed540..b1fb328 100644 --- a/lib/bingo.rb +++ b/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' diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index 8d1ed85..ac443a8 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -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}" diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 893b62b..54f9a41 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -216,6 +216,13 @@ class BingoTest < Minitest::Test assert_includes body, '1 of Russia's 25 largest' assert_includes body, '' 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, '' + assert_includes body, '' + assert_includes body, '' assert_includes body, 'og:locale:alternate" content="uk_UA"' ld = JSON.parse(body[%r{}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) diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb index 1e2af6a..f559b77 100644 --- a/views/bingo/layout.erb +++ b/views/bingo/layout.erb @@ -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}" %> @@ -27,7 +28,12 @@ <% end %> <% if og_img %> - +<% if og.photo_og %> + + + +<% end %> + <% end %>