Consideremos una vista con imágenes subidas por usuarios, donde tenemos menos control sobre lo que suben. Si las mostramos tal cuál fueron subidas con un <img>
y nada más, podríamos tener una carga no muy atractiva, algo así:
Hay varios problemas con esto:
-
Mientras las imágenes no se han cargado se ve un espacio vacío
-
Cuando se van cargando hay un momento que se ven a la mitad
-
Se demoran harto en cargar todas
En esta sección de la guía vamos a ver algunas cosas que se pueden hacer con Shrine para que potencialmente la carga sea más amigable:
Las cosas que veremos acá, también fueron implementadas en Potassium, ahí en el PR puedes ver más detalles también.
Del video anterior, probablemente lo que más llama la atención son esas versiones borrosas de las imágenes que aparecen antes de que las imágenes mismas se muestren. Eso se logra con Blurhash: una herramienta que permite representar una imagen como un string que luego se decodifica en el frontend y se obtiene algo que se pueden pintar en un <canvas>
rápidamente.
Con esto se evita el espacio en blanco y la carga por partes que se ven en el primer video.
Para usar blurhash con Shrine, necesitamos procesar la imagen para obtener el string y guardarlo en la metadata del archivo. Usaremos el plugin add_metadata para agregar la metadata. Para procesar la imagen, usaremos ruby-vips un procesador de imágenes alternativo a ImageMagick que ha mostrado tener mejor performance y menor uso de memoria. Esto se ve así:
class ImageUploader < Shrine
add_metadata :blurhash do |io, derivative: nil, **|
if derivative.nil?
Shrine.with_file(io) do |file|
image = Vips::Image.new_from_file(file.path, access: :sequential)
# Transformamos el archivo a un tamaño más pequeño para acelerar el procesamiento
image = image.resize(100.0 / image.width)
# image.to_a entrega un arreglo con los pixeles en formato rgba, Blurhash requiere solo rgb
flat_rgb_pixels = []
image.to_a.each do |row|
row.each { |pixel| flat_rgb_pixels.concat(pixel[0..2]) }
end
# Finalmente obtenemos el string, que se guarda en file.metadata['blurhash']
Blurhash.encode(image.width, image.height, flat_rgb_pixels)
end
end
end
end
Con esto ya tenemos un código que podemos usar en el front para renderear un placeholder mientras se carga la imagen.
Hasta ahora podemos evitar que nuestra vista tenga espacios vacíos en un principio y que luego las imágenes se empiecen a cargar por partes, pero todavía nos gustaría que el usuario pueda verlas lo más rápido posible. Para esto usaremos derivatives para disminuir el peso de las imágenes, de dos maneras:
-
Tener distintos tamaños. Así el front puede usar el tamaño que más se adecúe al uso que se le dará en una vista y no tiene que cargar una imagen de mayor peso. Esto ya lo vimos en los ejemplos de Shrine
-
Tener imágenes en formato
webp
, un formato más liviano quepng
ojpg
con muy poca perdida de calidad
Para esto usaremos nuevamente vips
. Si quisieramos tener variaciones de tamaño pequeño, tanto en el formato original como en formato webp
, tendríamos que hacer lo siguiente:
class ImageUploader < Shrine
Attacher.derivatives do |original|
vips = ImageProcessing::Vips.source(original)
{
sm: vips.resize_to_limit!(426, 240),
webp_sm: vips.convert('webp').resize_to_limit!(426, 240),
}
end
end
En Potassium agregamos algunos archivos y configuraciones para facilitar el uso de estas cosas. La idea es que sirva como un ejemplo y base que se pueda customizar según sea necesario.
El procesamiento de una imagen para sacar sus derivatives puede ser costoso en recursos y tiempo. Por esto, usamos el plugin de backgrounding
para que este procesamiento se encole en un job.
Hacer esto resulta en que hay un momento entre que se sube una imagen y que esta se procesa, en que las derivatives no existen. Por lo tanto, si se pidiera una url para ellas en una vista, habría un problema. Para evitar esto, usamos una combinación de dos plugins para tener un fallback:
-
Derivation Endpoint: nos permite generar una url on-the-fly con una variación redimensionada de la imagen según parámetros de
height
ywidth
. Usado acá -
Default Url: Nos permite definir una url por defecto cuando el archivo solicitado no existe. En nuestro caso, lo usamos para entregar una url generada con el derivation endpoint anterior cuando se pide una derivative
Uploader de ejemplo que incluye todo lo que ya habíamos mencionado:
-
Inclusión de blurhash a la metadata
-
Derivatives de 3 tamaños distintos, cada uno en
jpg
y enwebp
-
Url default para derivatives faltantes usando la derivation url
Concern de serializer incluido en el BaseSerializer
. Agrega un método add_image_handling_attributes
que permite a cualquier serializador agregar attributes
para el blurhash y para las urls de las derivatives de una imagen.
Dado un attachment de nombre image
que usa el CoverImageUploader
, se puede usar así:
add_image_handling_attributes(
attachment_name: :image,
derivatives: CoverImageUploader::DERIVATIVES.keys,
include_original_image: true
)
Se usa la constante CoverImageUploader::DERIVATIVES
para indicar que quiero agregar al objeto serializado todas las derivatives.
Si se tuviera solo una derivative sm
, esto agregaría algo así al json resultante:
{
# ...
image_blurhash: 'LUG%rif*rwayI:jZ#qju0~azS_oe',
image: {
sm: { url: 'someurl.com/bla' },
original: { url: 'someurl.com/ble' }
}
}
Plugin custom de Shrine que se usa en el CoverImageUploader
. Agrega algunos métodos de instancia y de clase al modelo que use ese uploader. Para un attachment de nombre image
, estos son:
-
image_blurhash
: retorna el blurhash sacado de la metadata -
generate_image_derivatives
: genera todas las derivatives definidas en el Uploader. Si el archivo ya tenía algunas derivatives definidas, estas serán reemplazadas.Puede ser útil si se agregan derivatives a un uploader que no tenía, o si se cambia la definición de estas en uno que sí tenía. En estos casos se tendría que reprocesar los archivos ya existentes, por ejemplo, llamando a este método en un job.
Método de clase asociado:
generate_all_image_derivatives
-
generate_image_metadata
: genera toda la metadata del attachment.Útil si se agrega metadata nueva que se necesita en records existentes.
Método de clase asociado:
generate_all_image_metadata
-
generate_image_derivatives_and_metadata
: lo mismo que ambos métodos anteriores juntos, pero la gracia es que se preocupa de abrir el archivo solo una vez para procesar derivatives y metadata.Método de clase asociado:
generate_all_image_derivatives_and_metadata
Hasta ahora hemos visto cómo usar Shrine para generar estas cosas que nos pueden ayudar a mejorar nuestra experiencia de carga de imágenes. Lo que sigue es cómo usar esto en nuestras vistas. Esta parte no viene en Potassium, pero acá se explicará que se necesitaría para implementarlo con Vue 3.
Para mostrar la imagen borrosa asociada al blurhash, necesitamos un componenete que maneje la decodificación del código y el pintado de los pixeles resultantes en un canvas. Para esto necesitamos agregar el paquete de blurhash:
yarn add blurhash
Luego, el componente se vería así:
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { decode } from 'blurhash';
interface Props {
blurhash: string,
}
const props = defineProps<Props>();
const INITIAL_CANVAS_SIZE = 32;
const canvas = ref<HTMLCanvasElement | null>(null);
onMounted(() => {
const pixels = decode(props.blurhash, INITIAL_CANVAS_SIZE, INITIAL_CANVAS_SIZE);
const imageData = new ImageData(pixels, INITIAL_CANVAS_SIZE, INITIAL_CANVAS_SIZE);
const context = canvas.value?.getContext('2d');
context?.putImageData(imageData, 0, 0);
});
</script>
<template>
<canvas
ref="canvas"
:width="INITIAL_CANVAS_SIZE"
:height="INITIAL_CANVAS_SIZE"
/>
</template>
💡 Notar que estamos seteando un tamaño fijo de 32px para el canvas. Esto es porque se necesita un tamaño explícito para decodificar el blurhash y renderearlo en el canvas, pero de todas maneras después se puede cambiar su tamaño con css. Por otro lado, usar un canvas pequeño ayuda con el performance. Esto es lo que recomienda blurhash
Para determinar cuando pasar del canvas con el blurhash a la imagen, necesitamos un componente que renderee ambos en el mismo espacio, pero mostrando solo el blurhash. Luego, cuando la imagen esté totalmente cargada, intercambie la opacidad de ambos elementos. Para determinar cuándo hacer esto, usamos el evento @load
de <img>
.
También necesitamos considerar el formato webp. Tiene buena compatibilidad con browsers modernos, pero para mayor cobertura usaremos un <picture>
que permita usar el formato webp solo si el browser lo permite. En el caso contrario, se usaría como fallback un src jpg
:
<script setup lang="ts">
import { ref } from '@vue/reactivity';
import BlurhashCanvas from './blurhash-canvas.vue';
interface Props {
src: string,
webpSrc?: string,
blurhash?: string,
}
withDefaults(defineProps<Props>(), {
webpSrc: undefined,
blurhash: undefined,
});
const isLoaded = ref(false);
</script>
<template>
<div
class="relative"
>
<blurhash-canvas
v-if="blurhash"
:blurhash="blurhash"
class="absolute w-full h-full transition-opacity duration-500"
:class="isLoaded ? 'opacity-0' : 'opacity-100'"
/>
<picture>
<source
ref="webpSource"
type="image/webp"
:srcset="webpSrc"
>
<source
ref="jpegSource"
type="image/jpeg"
:srcset="src"
>
<img
ref="image"
class="w-full h-full transition-opacity duration-500"
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
v-bind="$attrs"
:src="src"
loading="lazy"
@load="isLoaded = true"
>
</picture>
</div>
</template>
💡 El
loading="lazy"
es un atributo del<img>
que le dice al browser que solo cargue la imagen si es actualmente visible, o está cerca de serlo. Acá más información sobre lazy loading
Con esto ya podemos usar este componente para renderear nuestras imágenes de una manera más amigable.