Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize images #323

Merged
merged 3 commits into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ gem "commonmarker", require: false
gem "invisible_captcha" # Unobtrusive and flexible spam protection for Rails apps [https://github.com/markets/invisible_captcha]
gem "color_conversion" # A ruby gem to perform color conversions [https://github.com/devrieda/color_conversion]
gem "meta-tags" # Search Engine Optimization (SEO) for Ruby on Rails applications. [https://github.com/kpumuk/meta-tags]
gem "fastimage", require: false # FastImage finds the size or type of an image given its uri by fetching as little as needed [https://github.com/sdsykes/fastimage]

gem "bootsnap", require: false # Reduces boot times through caching; required in config/boot.rb [https://github.com/Shopify/bootsnap]

Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ GEM
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
fastimage (2.3.1)
ferrum (0.15)
addressable (~> 2.5)
concurrent-ruby (~> 1.1)
Expand Down Expand Up @@ -640,6 +641,7 @@ DEPENDENCIES
erb_lint
factory_bot_rails
faker
fastimage
flipper
flipper-active_record
flipper-ui
Expand Down
Binary file not shown.
Binary file added app/assets/images/app-icons/apple-touch-icon.webp
Binary file not shown.
Binary file not shown.
Binary file added app/assets/images/app-icons/icon-192.webp
Binary file not shown.
Binary file not shown.
Binary file added app/assets/images/app-icons/icon-512.webp
Binary file not shown.
Binary file added app/assets/images/app-icons/icon-64.webp
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified app/assets/images/meta/deployment/hatchbox/hatchbox.webp
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/assets/images/screenshots/homepage.webp
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ You can also view installation instructions for various browsers below:

<%= turbo_frame_tag "pwa_installation_instructions", src: pwa_installation_instructions_path %>

If it worked, you should be able to open Joy of Rails as a standalone application. Voilà!
![Screenshot of Joy of Rails in its standalone form](articles/add-your-rails-app-to-the-home-screen/standalone.jpg)
If it worked, you should be able to open Joy of Rails as a standalone application. Voilà! ![Screenshot of Joy of Rails in its standalone form](articles/add-your-rails-app-to-the-home-screen/standalone.jpg)

## What does it mean for a web app to be installable?

Expand Down Expand Up @@ -246,7 +245,7 @@ end
As mentioned previously, you should have, at minimum, two square images to represent your app icon on install screens: `192x192` and `512x512`. If you started from a new Rails 7.2+ app, you‘ll want to remove the generated app icon from the Rails app generator and make your own.

Here are the app icons for Joy of Rails:
![Joy of Rails app icon 192x192](app-icons/icon-192.png 'Joy of Rails app icon 192x192') ![Joy of Rails app icon 512x512](app-icons/icon-512.png 'Joy of Rails app icon 512x512')
![Joy of Rails app icon 192x192](articles/add-your-rails-app-to-the-home-screen/icon-192.png 'Joy of Rails app icon 192x192') ![Joy of Rails app icon 512x512](articles/add-your-rails-app-to-the-home-screen/icon-512.png 'Joy of Rails app icon 512x512')

I used ImageMagick 7 to convert these icons from the original with a command like:

Expand All @@ -270,7 +269,7 @@ Some browsers will present the icon in a circular window that will crop a signif
![A masked icon](articles/add-your-rails-app-to-the-home-screen/masked-icon-bad.jpg 'A not-so-nice masked icon')

My approach is to use a separate set of icons with more room to breathe. Compare the two variation:
![Primary app icon](app-icons/icon-192.png 'Primary app icon') ![Maskable app icon](app-icons/icon-192-maskable.png 'Maskable app icon')
![Primary app icon](articles/add-your-rails-app-to-the-home-screen/icon-192.png 'Primary app icon') ![Maskable app icon](articles/add-your-rails-app-to-the-home-screen/icon-192-maskable.png 'Maskable app icon')

The new icon a lot better when masked:
![A masked icon](articles/add-your-rails-app-to-the-home-screen/masked-icon-good.jpg 'A nice masked icon')
Expand Down
53 changes: 53 additions & 0 deletions app/views/components/content/image.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
module Content
class Image < ApplicationComponent
attr_reader :src, :alt, :title, :attributes

def initialize(src, alt: "", title: "", **attributes)
@src = src
@alt = alt.presence || File.basename(src, ".*").humanize
@title = title
@attributes = attributes
end

def view_template
figure do
img(alt:, loading: "lazy", **image_attributes, **attributes)
figcaption { title } if title.present?
end
end

private

def asset_path(*) = helpers.asset_path(*)

def image_attributes
if webp_src?
{
src: asset_path(webp_src)
}
elsif optimized_src?
{
src: asset_path(optimized_src)
}
else
{
src: asset_path(src)
}
end
end

def webp_src? = File.exist? Rails.root.join("app", "assets", "images", webp_src)

def webp_src = "#{dirname}/#{basename}.webp"

def optimized_src? = File.exist? Rails.root.join("app", "assets", "images", optimized_src)

def optimized_src = "#{dirname}/#{basename}-opt.#{ext}"

def ext = File.extname(src)

def basename = File.basename(src, ".*")

def dirname = File.dirname(src)
end
end
6 changes: 2 additions & 4 deletions app/views/components/markdown/article.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ def code_block(source, metadata = "", **attributes)

def image(src, alt: "", title: "")
title, json_attributes = parse_text_and_metadata(title, separator: "|")
figure(**json_attributes) do
image_tag(src, alt: alt, title: title, loading: "lazy")
figcaption { title }
end

render ::Content::Image.new(src, alt: alt, title: title, **json_attributes)
end

private
Expand Down
12 changes: 6 additions & 6 deletions app/views/components/pages/card.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ def initialize(title:, description:, image:, request_path:)
def view_template
article(class: "") do
a(href: request_path, class: "block") do
figure(class: "page-card--image w-full") do
image_tag @image || "https://placehold.co/640x360?text=#{title}",
alt: "#{File.basename(@image, ".*").humanize} illustration",
class: "w-full rounded-2xl object-cover aspect-[16/9]",
loading: "lazy"
end
render Content::Image.new \
image || "https://placehold.co/640x360?text=#{title}",
alt: "#{File.basename(image, ".*").humanize} illustration",
title: nil,
loading: "lazy",
class: "w-full rounded-2xl object-cover aspect-[16/9]"
end
div(class: "max-w-xl") do
h3(
Expand Down
10 changes: 5 additions & 5 deletions app/views/components/pages/summary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ def content(**)

def figure_image(**)
div(**) do
figure(class: "page-summary--image") do
image_tag image,
alt: "#{File.basename(image, ".*").humanize} illustration",
class: "w-full object-cover aspect-[2/1] lg:aspect-[3/2]"
end
render Content::Image.new(image,
alt: "#{File.basename(image, ".*").humanize} illustration",
title: nil,
loading: nil,
class: "w-full object-cover aspect-[2/1] lg:aspect-[3/2]")
rescue
ActionView::Template::Error
end
Expand Down
126 changes: 120 additions & 6 deletions script/convert-webp
Original file line number Diff line number Diff line change
@@ -1,13 +1,127 @@
#!/usr/bin/env ruby

# ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)

# bundle_binstub = File.expand_path("bundle", __dir__)

# if File.file?(bundle_binstub)
# if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
# load(bundle_binstub)
# else
# abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
# Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
# end
# end

# require "rubygems"
# require "bundler/setup"

require "fastimage"

force = false

class ImageFile
def exists?
File.exist?(file)
end

attr_reader :file
def initialize(file)
@file = file
end

def to_s = file

def to_str = file

def as(*suffixes)
ext = suffixes.pop
ImageFile.new("#{dirname}/#{([basename] + suffixes).join("-")}.#{ext}")
end

def as_webp = as("webp")

def as_opt_jpg = as("opt", "jpg")

def ext = File.extname(file)

def basename = File.basename(file, ".*")

def dirname = File.dirname(file)
end

module Cmd
def self.run(cmd)
puts cmd
system cmd
end

def self.webp(img, force: false, quality: 90, tries: 0)
dest = img.as_webp.to_s

if force || !File.exist?(dest)
cmd = "magick #{img}"

opts = ["-define webp:method=6", "-quality #{quality}"]

# Resize if width is specified and image is larger
opts << "-resize 1200x" if FastImage.size(img.to_s)[0] > 1200

# Lossless compression for images < 96kB
if File.size?(img) && File.size(img) < 98_304 # 96kB
opts << "-define webp:lossless=true"
end

opts << dest

run "#{cmd} #{opts.join(" ")}"

if File.size(dest) > 131_072 && # 128kB
tries < 8
webp(img, force: true, quality: quality - 5, tries: tries + 1)
end
end
end

def self.jpg(img, force: false, quality: 90, tries: 0)
dest = img.as_opt_jpg.to_s
if force || !File.exist?(dest)
run "magick #{img} -strip -interlace Plane -quality #{quality}% #{dest}"

if File.size(dest) > 131_072 && # 128kB
tries < 8
jpg(img, force: true, quality: quality - 5, tries: tries + 1)
end
end
end
end

ARGV.each do |file|
basename = File.basename(file, ".*")
dir = File.dirname(file)
Dir.glob(file).each do |file|
img = ImageFile.new(file)
next unless %w[.jpg .jpeg .png].include?(img.ext)
next if img.to_s.include?("-opt.jpg")
Cmd.webp img, force: force
end

Dir.glob(file).each do |file|
img = ImageFile.new(file)
next unless %w[.jpg .jpeg].include?(img.ext)
next if img.to_s.include?("-opt.jpg")
next if File.size(img) < 32_768 # 32kB
Cmd.jpg img, force: force
end

webp = "#{dir}/#{basename}.webp"
Dir.glob(file).each do |file|
img = ImageFile.new(file)

cmd = "magick #{file} -quality 50 -define webp:lossless=true -resize 960x480 #{webp}"
files = [img.as_webp.to_s, img.as_opt_jpg.to_s].filter { |f| File.exist?(f) }

puts cmd
system cmd
smallest = files.min { |a, b| File.size(a) <=> File.size(b) }
files.each do |f|
if f != smallest || File.size(f) > File.size(img.to_s)
File.delete(f)
end
end
end
end
Loading