Add color and blurhash extraction for profile pictures

This commit is contained in:
Eugen Rochko 2025-04-30 20:19:43 +02:00
parent b4394ec129
commit ec6d1f678f
19 changed files with 163 additions and 85 deletions

View File

@ -12,6 +12,14 @@ export interface ApiAccountRoleJSON {
name: string; name: string;
} }
export interface ApiMetaJSON {
colors?: {
background: string;
foreground: string;
accent: string;
};
}
// See app/serializers/rest/account_serializer.rb // See app/serializers/rest/account_serializer.rb
export interface BaseApiAccountJSON { export interface BaseApiAccountJSON {
acct: string; acct: string;
@ -44,6 +52,8 @@ export interface BaseApiAccountJSON {
limited?: boolean; limited?: boolean;
memorial?: boolean; memorial?: boolean;
hide_collections: boolean; hide_collections: boolean;
blurhash?: string;
meta?: ApiMetaJSON;
} }
// See app/serializers/rest/muted_account_serializer.rb // See app/serializers/rest/muted_account_serializer.rb

View File

@ -16,7 +16,9 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
className="account__avatar-overlay-base" className="account__avatar-overlay-base"
> >
<div <div
className="account__avatar" className="account__avatar account__avatar--loading"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={ style={
{ {
"height": "36px", "height": "36px",
@ -25,7 +27,9 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
} }
> >
<img <img
alt="alice" alt=""
onError={[Function]}
onLoad={[Function]}
src="/static/alice.jpg" src="/static/alice.jpg"
/> />
</div> </div>
@ -34,7 +38,9 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
className="account__avatar-overlay-overlay" className="account__avatar-overlay-overlay"
> >
<div <div
className="account__avatar" className="account__avatar account__avatar--loading"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={ style={
{ {
"height": "24px", "height": "24px",
@ -43,7 +49,9 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
} }
> >
<img <img
alt="eve@blackhat.lair" alt=""
onError={[Function]}
onLoad={[Function]}
src="/static/eve.jpg" src="/static/eve.jpg"
/> />
</div> </div>

View File

@ -2,8 +2,9 @@ import { useState, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Blurhash } from 'mastodon/components/blurhash';
import { useHovering } from 'mastodon/hooks/useHovering'; import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif, useBlurhash } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
interface Props { interface Props {
@ -58,6 +59,14 @@ export const Avatar: React.FC<Props> = ({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
style={style} style={style}
> >
{(loading || error) && account?.blurhash && (
<Blurhash
hash={account.blurhash}
className='account__avatar__preview'
dummy={!useBlurhash}
/>
)}
{src && !error && ( {src && !error && (
<img src={src} alt='' onLoad={handleLoad} onError={handleError} /> <img src={src} alt='' onLoad={handleLoad} onError={handleError} />
)} )}

View File

@ -1,3 +1,4 @@
import { Avatar } from 'mastodon/components/avatar';
import { useHovering } from 'mastodon/hooks/useHovering'; import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
@ -19,12 +20,6 @@ export const AvatarOverlay: React.FC<Props> = ({
}) => { }) => {
const { hovering, handleMouseEnter, handleMouseLeave } = const { hovering, handleMouseEnter, handleMouseLeave } =
useHovering(autoPlayGif); useHovering(autoPlayGif);
const accountSrc = hovering
? account?.get('avatar')
: account?.get('avatar_static');
const friendSrc = hovering
? friend?.get('avatar')
: friend?.get('avatar_static');
return ( return (
<div <div
@ -34,20 +29,19 @@ export const AvatarOverlay: React.FC<Props> = ({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
<div className='account__avatar-overlay-base'> <div className='account__avatar-overlay-base'>
<div <Avatar
className='account__avatar' account={account}
style={{ width: `${baseSize}px`, height: `${baseSize}px` }} size={baseSize}
> animate={hovering || autoPlayGif}
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />} />
</div>
</div> </div>
<div className='account__avatar-overlay-overlay'> <div className='account__avatar-overlay-overlay'>
<div <Avatar
className='account__avatar' account={friend}
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }} size={overlaySize}
> animate={hovering || autoPlayGif}
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />} />
</div>
</div> </div>
</div> </div>
); );

View File

@ -74,9 +74,9 @@ export default class MediaAttachments extends ImmutablePureComponent {
width={width} width={width}
height={height} height={height}
poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])} poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={audio.getIn(['meta', 'colors', 'background'])} backgroundColor={audio.getIn(['meta', 'colors', 'background']) ?? status.getIn(['account', 'meta', 'colors', 'background'])}
foregroundColor={audio.getIn(['meta', 'colors', 'foreground'])} foregroundColor={audio.getIn(['meta', 'colors', 'foreground']) ?? status.getIn(['account', 'meta', 'colors', 'foreground'])}
accentColor={audio.getIn(['meta', 'colors', 'accent'])} accentColor={audio.getIn(['meta', 'colors', 'accent']) ?? status.getIn(['account', 'meta', 'colors', 'accent'])}
duration={audio.getIn(['meta', 'original', 'duration'], 0)} duration={audio.getIn(['meta', 'original', 'duration'], 0)}
/> />
)} )}

View File

@ -480,9 +480,9 @@ class Status extends ImmutablePureComponent {
alt={description} alt={description}
lang={language} lang={language}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} backgroundColor={attachment.getIn(['meta', 'colors', 'background']) ?? status.getIn(['account', 'meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} foregroundColor={attachment.getIn(['meta', 'colors', 'foreground']) ?? status.getIn(['account', 'meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])} accentColor={attachment.getIn(['meta', 'colors', 'accent']) ?? status.getIn(['account', 'meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)} duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}

View File

@ -218,12 +218,19 @@ const Preview: React.FC<{
} }
duration={media.getIn(['meta', 'original', 'duration'], 0) as number} duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
backgroundColor={ backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string (media.getIn(['meta', 'colors', 'background']) as
| string
| undefined) ?? account?.meta.colors?.background
} }
foregroundColor={ foregroundColor={
media.getIn(['meta', 'colors', 'foreground']) as string (media.getIn(['meta', 'colors', 'foreground']) as
| string
| undefined) ?? account?.meta.colors?.foreground
}
accentColor={
(media.getIn(['meta', 'colors', 'accent']) as string | undefined) ??
account?.meta.colors?.accent
} }
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
editable editable
/> />
); );

View File

@ -193,9 +193,18 @@ export const DetailedStatus: React.FC<{
status.getIn(['account', 'avatar_static']) status.getIn(['account', 'avatar_static'])
} }
duration={attachment.getIn(['meta', 'original', 'duration'], 0)} duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} backgroundColor={
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} attachment.getIn(['meta', 'colors', 'background']) ??
accentColor={attachment.getIn(['meta', 'colors', 'accent'])} status.getIn(['account', 'meta', 'colors', 'background'])
}
foregroundColor={
attachment.getIn(['meta', 'colors', 'foreground']) ??
status.getIn(['account', 'meta', 'colors', 'foreground'])
}
accentColor={
attachment.getIn(['meta', 'colors', 'accent']) ??
status.getIn(['account', 'meta', 'colors', 'accent'])
}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
visible={showMedia} visible={showMedia}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}

View File

@ -18,8 +18,8 @@ const AudioModal: React.FC<{
}> = ({ media, statusId, options, onClose, onChangeBackgroundColor }) => { }> = ({ media, statusId, options, onClose, onChangeBackgroundColor }) => {
const status = useAppSelector((state) => state.statuses.get(statusId)); const status = useAppSelector((state) => state.statuses.get(statusId));
const accountId = status?.get('account') as string | undefined; const accountId = status?.get('account') as string | undefined;
const accountStaticAvatar = useAppSelector((state) => const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId)?.avatar_static : undefined, accountId ? state.accounts.get(accountId) : undefined,
); );
useEffect(() => { useEffect(() => {
@ -47,16 +47,24 @@ const AudioModal: React.FC<{
alt={description} alt={description}
lang={language} lang={language}
poster={ poster={
(media.get('preview_url') as string | null) ?? accountStaticAvatar (media.get('preview_url') as string | null) ??
account?.avatar_static
} }
duration={media.getIn(['meta', 'original', 'duration'], 0) as number} duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
backgroundColor={ backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string (media.getIn(['meta', 'colors', 'background']) as
| string
| undefined) ?? account?.meta.colors?.background
} }
foregroundColor={ foregroundColor={
media.getIn(['meta', 'colors', 'foreground']) as string (media.getIn(['meta', 'colors', 'foreground']) as
| string
| undefined) ?? account?.meta.colors?.foreground
}
accentColor={
(media.getIn(['meta', 'colors', 'accent']) as string | undefined) ??
account?.meta.colors?.accent
} }
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
startPlaying={options.autoPlay} startPlaying={options.autoPlay}
/> />
</div> </div>

View File

@ -94,6 +94,8 @@ export const accountDefaultValues: AccountShape = {
limited: false, limited: false,
moved: null, moved: null,
hide_collections: false, hide_collections: false,
blurhash: '',
meta: {},
// This comes from `ApiMutedAccountJSON`, but we should eventually // This comes from `ApiMutedAccountJSON`, but we should eventually
// store that in a different object. // store that in a different object.
mute_expires_at: null, mute_expires_at: null,

View File

@ -2100,6 +2100,16 @@ body > [data-popper-placement] {
display: inline-block; // to not show broken images display: inline-block; // to not show broken images
} }
&__preview {
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
border-radius: var(--avatar-border-radius);
object-fit: cover;
}
&--loading { &--loading {
background-color: var(--surface-background-color); background-color: var(--surface-background-color);
} }

View File

@ -5,52 +5,54 @@
# Table name: accounts # Table name: accounts
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# username :string default(""), not null # actor_type :string
# domain :string # also_known_as :string is an Array
# private_key :text # attribution_domains :string default([]), is an Array
# public_key :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# note :text default(""), not null
# display_name :string default(""), not null
# uri :string default(""), not null
# url :string
# avatar_file_name :string
# avatar_content_type :string # avatar_content_type :string
# avatar_file_name :string
# avatar_file_size :integer # avatar_file_size :integer
# avatar_updated_at :datetime
# header_file_name :string
# header_content_type :string
# header_file_size :integer
# header_updated_at :datetime
# avatar_remote_url :string # avatar_remote_url :string
# locked :boolean default(FALSE), not null # avatar_storage_schema_version :integer
# header_remote_url :string default(""), not null # avatar_updated_at :datetime
# last_webfingered_at :datetime # blurhash :string
# inbox_url :string default(""), not null # discoverable :boolean
# outbox_url :string default(""), not null # display_name :string default(""), not null
# shared_inbox_url :string default(""), not null # domain :string
# followers_url :string default(""), not null
# protocol :integer default("ostatus"), not null
# memorial :boolean default(FALSE), not null
# moved_to_account_id :bigint(8)
# featured_collection_url :string # featured_collection_url :string
# fields :jsonb # fields :jsonb
# actor_type :string # followers_url :string default(""), not null
# discoverable :boolean # header_content_type :string
# also_known_as :string is an Array # header_file_name :string
# header_file_size :integer
# header_remote_url :string default(""), not null
# header_storage_schema_version :integer
# header_updated_at :datetime
# hide_collections :boolean
# inbox_url :string default(""), not null
# indexable :boolean default(FALSE), not null
# last_webfingered_at :datetime
# locked :boolean default(FALSE), not null
# memorial :boolean default(FALSE), not null
# meta :json
# note :text default(""), not null
# outbox_url :string default(""), not null
# private_key :text
# protocol :integer default("ostatus"), not null
# public_key :text default(""), not null
# requested_review_at :datetime
# reviewed_at :datetime
# sensitized_at :datetime
# shared_inbox_url :string default(""), not null
# silenced_at :datetime # silenced_at :datetime
# suspended_at :datetime # suspended_at :datetime
# hide_collections :boolean
# avatar_storage_schema_version :integer
# header_storage_schema_version :integer
# suspension_origin :integer # suspension_origin :integer
# sensitized_at :datetime
# trendable :boolean # trendable :boolean
# reviewed_at :datetime # uri :string default(""), not null
# requested_review_at :datetime # url :string
# indexable :boolean default(FALSE), not null # username :string default(""), not null
# attribution_domains :string default([]), is an Array # created_at :datetime not null
# updated_at :datetime not null
# moved_to_account_id :bigint(8)
# #
class Account < ApplicationRecord class Account < ApplicationRecord

View File

@ -11,7 +11,13 @@ module Account::Avatar
class_methods do class_methods do
def avatar_styles(file) def avatar_styles(file)
styles = { original: { geometry: "#{AVATAR_GEOMETRY}#", file_geometry_parser: FastGeometryParser } } styles = { original: { geometry: "#{AVATAR_GEOMETRY}#", file_geometry_parser: FastGeometryParser } }
styles[:static] = { geometry: "#{AVATAR_GEOMETRY}#", format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
if file.content_type == 'image/gif'
styles[:static] = { geometry: "#{AVATAR_GEOMETRY}#", format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser, blurhash: { x_comp: 3, y_comp: 3 }, extract_colors: { meta_attribute_name: :meta } }
else
styles[:original].merge!(blurhash: { x_comp: 3, y_comp: 3 }, extract_colors: { meta_attribute_name: :meta })
end
styles styles
end end
@ -20,7 +26,7 @@ module Account::Avatar
included do included do
# Avatar upload # Avatar upload
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail] has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '+profile "!icc,*" +set date:modify +set date:create +set date:timestamp' }, processors: [:lazy_thumbnail, :blurhash_transcoder, :color_extractor]
validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES validates_attachment_content_type :avatar, content_type: AVATAR_IMAGE_MIME_TYPES
validates_attachment_size :avatar, less_than: AVATAR_LIMIT validates_attachment_size :avatar, less_than: AVATAR_LIMIT
remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false remotable_attachment :avatar, AVATAR_LIMIT, suppress_errors: false

View File

@ -165,7 +165,7 @@ class MediaAttachment < ApplicationRecord
}.freeze }.freeze
THUMBNAIL_STYLES = { THUMBNAIL_STYLES = {
original: IMAGE_STYLES[:small].freeze, original: IMAGE_STYLES[:small].merge(extract_colors: { meta_attribute_name: :file_meta }.freeze).freeze,
}.freeze }.freeze
DEFAULT_STYLES = [:original].freeze DEFAULT_STYLES = [:original].freeze

View File

@ -8,7 +8,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at, attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at,
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static, :note, :url, :uri, :avatar, :avatar_static, :header, :header_static,
:followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections :followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections, :meta, :blurhash
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddAvatarMetaToAccounts < ActiveRecord::Migration[8.0]
def change
safety_assured { add_column :accounts, :meta, :json }
add_column :accounts, :blurhash, :string
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_04_28_095029) do ActiveRecord::Schema[8.0].define(version: 2025_04_30_151215) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -198,6 +198,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_28_095029) do
t.datetime "requested_review_at", precision: nil t.datetime "requested_review_at", precision: nil
t.boolean "indexable", default: false, null: false t.boolean "indexable", default: false, null: false
t.string "attribution_domains", default: [], array: true t.string "attribution_domains", default: [], array: true
t.json "meta"
t.string "blurhash"
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["domain", "id"], name: "index_accounts_on_domain_and_id" t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"

View File

@ -3,7 +3,7 @@
module Paperclip module Paperclip
class BlurhashTranscoder < Paperclip::Processor class BlurhashTranscoder < Paperclip::Processor
def make def make
return @file unless options[:style] == :small || options[:blurhash] return @file unless options[:blurhash]
width, height, data = blurhash_params width, height, data = blurhash_params
# Guard against segfaults if data has unexpected size # Guard against segfaults if data has unexpected size

View File

@ -10,6 +10,8 @@ module Paperclip
BINS = 10 BINS = 10
def make def make
return @file unless options.key?(:extract_colors)
background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick
background_color = background_palette.first || foreground_palette.first background_color = background_palette.first || foreground_palette.first
@ -66,7 +68,8 @@ module Paperclip
}, },
} }
attachment.instance.file.instance_write(:meta, (attachment.instance.file.instance_read(:meta) || {}).merge(meta)) meta_attribute_name = options[:extract_colors][:meta_attribute_name] || "#{attachment.name}_meta"
attachment.instance.public_send("#{meta_attribute_name}=", (attachment.instance.public_send(meta_attribute_name) || {}).merge(meta))
@file @file
rescue Vips::Error => e rescue Vips::Error => e