animated gif resizing

This commit is contained in:
Dylan Knutson
2025-03-02 23:28:19 +00:00
parent 1a84b885f2
commit 720a2ab1b8
9 changed files with 205 additions and 51 deletions

View File

@@ -36,3 +36,4 @@
- [ ] Do PCA on user factors table to display a 2D plot of users
- [ ] Use links found in descriptions to indicate re-scanning a post? (e.g. for comic next/prev links)
- [ ] fix for IDs that have a dot in them - e.g. https://refurrer.com/users/fa@jakke.
- [ ] Rich inline links to e621 e.g. https://refurrer.com/posts/fa@60070060

View File

@@ -5,16 +5,27 @@ class BlobEntriesController < ApplicationController
sig { void }
def show
thumb = params[:thumb]
raise("invalid thumb #{thumb}") if thumb.present? && !thumb_params(thumb)
if thumb.present? && !thumb_params(thumb)
raise ActionController::BadRequest.new("invalid thumbnail #{thumb}")
end
expires_dur = 1.year
if thumb.present?
expires_dur = 1.week
else
expires_dur = 1.year
end
response.headers["Expires"] = expires_dur.from_now.httpdate
expires_in expires_dur, public: true
sha256 = params[:sha256]
etag = sha256
etag += "-#{thumb}" if thumb
return unless stale?(last_modified: Time.at(0), strong_etag: etag)
unless stale?(
last_modified: Time.at(0),
strong_etag: strong_etag_for_request,
)
return
end
sha256 = T.let(params[:sha256], String)
raise ActionController::BadRequest.new("no file specified") if sha256.blank?
if show_blob_file(sha256, thumb)
return
@@ -30,15 +41,21 @@ class BlobEntriesController < ApplicationController
sig { params(sha256: String, thumb: T.nilable(String)).returns(T::Boolean) }
def show_blob_file(sha256, thumb)
if thumb
filename = "thumb-#{thumb}-#{sha256}"
filename = T.must(filename[..File.extname(filename).length])
filename += ".jpeg"
cache_key = "vips:thumbnail:#{sha256}:#{thumb}"
thumb_params = thumb_params(thumb)
if thumb_params.nil?
raise ActionController::BadRequest.new("invalid thumbnail: #{thumb}")
end
# if the requested format is gif, and the thumbnail type is content-container, we want to
# thumbnail the gif into another gif. Else, always thumbnail into a jpeg.
file_ext = "jpeg"
if params[:format] == "gif" && thumb == "content-container"
file_ext = "gif"
end
width, height = thumb_params
filename = "thumb-#{sha256}-#{thumb}.#{file_ext}"
cache_key = "vips:#{filename}"
thumb_data =
Rack::MiniProfiler.step("vips: load from cache") do
Rails
@@ -49,9 +66,9 @@ class BlobEntriesController < ApplicationController
content_type =
blob_file.content_type || "application/octet-stream"
if helpers.is_renderable_video_type?(content_type)
thumbnail_video_file(blob_file, width, height)
thumbnail_video_file(blob_file, width, height, file_ext)
elsif helpers.is_renderable_image_type?(content_type)
thumbnail_image_file(blob_file, width, height)
thumbnail_image_file(blob_file, width, height, file_ext)
end
end
end
@@ -63,8 +80,8 @@ class BlobEntriesController < ApplicationController
end
send_data(
thumb_data,
type: "image/jpg",
thumb_data[0],
type: thumb_data[1],
disposition: "inline",
filename: filename,
)
@@ -84,11 +101,14 @@ class BlobEntriesController < ApplicationController
end
sig do
params(blob_file: BlobFile, width: Integer, height: Integer).returns(
T.nilable(String),
)
params(
blob_file: BlobFile,
width: Integer,
height: Integer,
thumb: String,
).returns(T.nilable([String, String]))
end
def thumbnail_video_file(blob_file, width, height)
def thumbnail_video_file(blob_file, width, height, thumb)
video_file = blob_file.absolute_file_path
temp_thumb_file = Tempfile.new(%w[video-thumb .png])
process_result =
@@ -111,26 +131,62 @@ class BlobEntriesController < ApplicationController
thumb_data_tmp = File.read(T.must(temp_thumb_file.path), mode: "rb")
temp_thumb_file.unlink
thumb_data_tmp
[thumb_data_tmp, "image/jpeg"]
end
# Returns a tuple of the thumbnail data and the content type
sig do
params(blob_file: BlobFile, width: Integer, height: Integer).returns(
T.nilable(String),
)
params(
blob_file: BlobFile,
width: Integer,
height: Integer,
file_ext: String,
).returns(T.nilable([String, String]))
end
def thumbnail_image_file(blob_file, width, height)
image_buffer =
Rack::MiniProfiler.step("vips: load image") do
T.unsafe(Vips::Image).thumbnail(
blob_file.absolute_file_path,
width,
height: height,
)
end
def thumbnail_image_file(blob_file, width, height, file_ext)
if file_ext == "gif"
Rack::MiniProfiler.step("vips: load gif") do
# Use libvips' gifload with n=-1 to load all frames
image = Vips::Image.gifload(blob_file.absolute_file_path, n: -1)
num_frames = image.get("n-pages")
image_width, image_height = image.width, (image.height / num_frames)
Rack::MiniProfiler.step("vips: thumbnail image") do
image_buffer.jpegsave_buffer
if width >= image_width && height >= image_height
logger.info("gif is already smaller than requested thumbnail size")
return [
File.read(blob_file.absolute_file_path, mode: "rb"),
"image/gif"
]
end
Rack::MiniProfiler.step("vips: thumbnail gif") do
image = image.thumbnail_image(width, height: height)
image_buffer =
image.gifsave_buffer(
dither: 1,
effort: 1,
interframe_maxerror: 16,
interpalette_maxerror: 10,
interlace: true,
)
[image_buffer, "image/gif"]
end
end
else
# Original static image thumbnailing logic
image_buffer =
Rack::MiniProfiler.step("vips: load image") do
T.unsafe(Vips::Image).thumbnail(
blob_file.absolute_file_path,
width,
height: height,
)
end
Rack::MiniProfiler.step("vips: thumbnail image") do
logger.info("rendering thumbnail as jpeg")
[image_buffer.jpegsave_buffer(interlace: true, Q: 95), "image/jpeg"]
end
end
end
@@ -148,7 +204,14 @@ class BlobEntriesController < ApplicationController
when "medium"
[800, 600]
when "content-container"
[2048, 2048]
[768, 2048]
# [2048, 2048]
# [128, 128]
end
end
sig { returns(String) }
def strong_etag_for_request
[params[:sha256], params[:thumb], params[:format]].compact.join("-")
end
end

View File

@@ -27,24 +27,49 @@ module LogEntriesHelper
end
sig { params(content_type: String).returns(T.nilable(String)) }
def ext_for_content_type(content_type)
case content_type
when "image/jpeg"
"jpeg"
when "image/jpg"
"jpg"
when "image/png"
"png"
when "image/gif"
def thumbnail_extension_for_content_type(content_type)
return nil unless is_thumbable_content_type?(content_type)
extension = extension_for_content_type(content_type)
if extension == "gif"
"gif"
when "video/webm"
else
"jpeg"
end
end
sig { params(content_type: String).returns(T.nilable(String)) }
def extension_for_content_type(content_type)
content_type = content_type.split(";")[0]
return nil unless content_type
extension = Rack::Mime::MIME_TYPES.invert[content_type]
return extension[1..] if extension
case content_type
when %r{image/jpeg}
"jpeg"
when %r{image/jpg}
"jpg"
when %r{image/png}
"png"
when %r{image/gif}
"gif"
when %r{video/webm}
"webm"
when "audio/mpeg"
when %r{audio/mpeg}
"mp3"
when "audio/mp3"
when %r{audio/mp3}
"mp3"
when "audio/wav"
when %r{audio/wav}
"wav"
when %r{application/pdf}
"pdf"
when %r{application/rtf}
"rtf"
when %r{application/msword}
"doc"
when %r{application/vnd\.openxmlformats-officedocument\.wordprocessingml\.document}
"docx"
else
nil
end

View File

@@ -1,4 +1,7 @@
<% path = blob_path(HexUtil.bin2hex(log_entry.response_sha256)) %>
<% path = blob_path(
HexUtil.bin2hex(log_entry.response_sha256),
format: extension_for_content_type(log_entry.content_type),
) %>
<section class="flex grow justify-center overflow-clip">
<% if is_renderable_image_type?(log_entry.content_type) %>
<%= render partial: "log_entries/renderers/image", locals: { log_entry:, path: } %>

View File

@@ -1,2 +1,8 @@
<% path = blob_path(HexUtil.bin2hex(log_entry.response_sha256), format: "jpg", thumb: "content-container") %>
<img alt="image" src="<%= path %>" class="md:rounded-md" />
<% content_container_thumb_path = blob_path(
HexUtil.bin2hex(log_entry.response_sha256),
format: thumbnail_extension_for_content_type(log_entry.content_type),
thumb: "content-container",
) %>
<%= link_to path do %>
<img alt="image" src="<%= content_container_thumb_path %>" class="md:rounded-md" />
<% end %>

View File

@@ -0,0 +1,18 @@
# typed: strict
class ActiveSupport::Cache::Store
extend T::Sig
extend T::Helpers
sig do
type_parameters(:T)
.params(
key: T.untyped,
options: T.untyped,
block: T.proc.returns(T.type_parameter(:T)),
)
.returns(T.type_parameter(:T))
end
def fetch(key, options = nil, &block)
end
end

View File

@@ -0,0 +1,14 @@
# typed: strict
class Rack::MiniProfiler
extend T::Sig
extend T::Helpers
sig do
type_parameters(:T)
.params(name: String, block: T.proc.returns(T.type_parameter(:T)))
.returns(T.type_parameter(:T))
end
def self.step(name, &block)
end
end

24
sorbet/rbi/shims/vips.rbi Normal file
View File

@@ -0,0 +1,24 @@
# typed: strict
class Vips::Image
sig { params(name: String, opts: T.untyped).returns(Vips::Image) }
def self.new_from_file(name, **opts)
end
sig { params(name: String, n: T.nilable(Integer)).returns(Vips::Image) }
def self.gifload(name, n: nil)
end
sig { params(scale: T.untyped).returns(Vips::Image) }
def resize(scale)
end
sig { params(opts: T.untyped).returns(String) }
def gifsave_buffer(**opts)
end
sig do
params(width: Integer, height: T.nilable(Integer)).returns(Vips::Image)
end
def thumbnail_image(width, height: nil)
end
end

View File

@@ -79,7 +79,7 @@ RSpec.describe BlobEntriesController, type: :controller do
it "generates thumbnail for valid size" do
get :show, params: { sha256: sha256_hex, thumb: "tiny" }
expect(response).to have_http_status(:success)
expect(response.content_type).to eq("image/jpg")
expect(response.content_type).to eq("image/jpeg")
expect(response.headers["Content-Disposition"]).to include("inline")
expect(response.body).to_not be_nil
end