When avatar/header are GIF, generate static versions (#1428)

* When avatar/header are GIF, generate static versions.
Account API returns "avatar"/"avatar_static", "header"/"header_static"
Static version is the same as original for other cases
Web UI de-animates avatars in toots, lists of users

Fix #441, fix #596, prerequisite for #1064

* Fix JS test

* Add rake task to generate static avatars/headers from GIF ones, add test
This commit is contained in:
Eugen 2017-04-11 00:38:58 +02:00 committed by GitHub
parent b57eed4584
commit 12f72e1740
15 changed files with 108 additions and 138 deletions

View File

@ -65,7 +65,7 @@ const Account = React.createClass({
<div className='account'> <div className='account'>
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div> <div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} staticSrc={status.getIn(['account', 'avatar_static'])} size={36} /></div>
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>

View File

@ -1,103 +1,18 @@
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
// From: http://stackoverflow.com/a/18320662
const resample = (canvas, width, height, resize_canvas) => {
let width_source = canvas.width;
let height_source = canvas.height;
width = Math.round(width);
height = Math.round(height);
let ratio_w = width_source / width;
let ratio_h = height_source / height;
let ratio_w_half = Math.ceil(ratio_w / 2);
let ratio_h_half = Math.ceil(ratio_h / 2);
let ctx = canvas.getContext("2d");
let img = ctx.getImageData(0, 0, width_source, height_source);
let img2 = ctx.createImageData(width, height);
let data = img.data;
let data2 = img2.data;
for (let j = 0; j < height; j++) {
for (let i = 0; i < width; i++) {
let x2 = (i + j * width) * 4;
let weight = 0;
let weights = 0;
let weights_alpha = 0;
let gx_r = 0;
let gx_g = 0;
let gx_b = 0;
let gx_a = 0;
let center_y = (j + 0.5) * ratio_h;
let yy_start = Math.floor(j * ratio_h);
let yy_stop = Math.ceil((j + 1) * ratio_h);
for (let yy = yy_start; yy < yy_stop; yy++) {
let dy = Math.abs(center_y - (yy + 0.5)) / ratio_h_half;
let center_x = (i + 0.5) * ratio_w;
let w0 = dy * dy; //pre-calc part of w
let xx_start = Math.floor(i * ratio_w);
let xx_stop = Math.ceil((i + 1) * ratio_w);
for (let xx = xx_start; xx < xx_stop; xx++) {
let dx = Math.abs(center_x - (xx + 0.5)) / ratio_w_half;
let w = Math.sqrt(w0 + dx * dx);
if (w >= 1) {
// pixel too far
continue;
}
// hermite filter
weight = 2 * w * w * w - 3 * w * w + 1;
let pos_x = 4 * (xx + yy * width_source);
// alpha
gx_a += weight * data[pos_x + 3];
weights_alpha += weight;
// colors
if (data[pos_x + 3] < 255)
weight = weight * data[pos_x + 3] / 250;
gx_r += weight * data[pos_x];
gx_g += weight * data[pos_x + 1];
gx_b += weight * data[pos_x + 2];
weights += weight;
}
}
data2[x2] = gx_r / weights;
data2[x2 + 1] = gx_g / weights;
data2[x2 + 2] = gx_b / weights;
data2[x2 + 3] = gx_a / weights_alpha;
}
}
// clear and resize canvas
if (resize_canvas === true) {
canvas.width = width;
canvas.height = height;
} else {
ctx.clearRect(0, 0, width_source, height_source);
}
// draw
ctx.putImageData(img2, 0, 0);
};
const Avatar = React.createClass({ const Avatar = React.createClass({
propTypes: { propTypes: {
src: React.PropTypes.string.isRequired, src: React.PropTypes.string.isRequired,
staticSrc: React.PropTypes.string,
size: React.PropTypes.number.isRequired, size: React.PropTypes.number.isRequired,
style: React.PropTypes.object, style: React.PropTypes.object,
animated: React.PropTypes.bool animate: React.PropTypes.bool
}, },
getDefaultProps () { getDefaultProps () {
return { return {
animated: true animate: false
}; };
}, },
@ -117,38 +32,30 @@ const Avatar = React.createClass({
this.setState({ hovering: false }); this.setState({ hovering: false });
}, },
handleLoad () {
this.canvas.width = this.image.naturalWidth;
this.canvas.height = this.image.naturalHeight;
this.canvas.getContext('2d').drawImage(this.image, 0, 0);
resample(this.canvas, this.props.size * window.devicePixelRatio, this.props.size * window.devicePixelRatio, true);
},
setImageRef (c) {
this.image = c;
},
setCanvasRef (c) {
this.canvas = c;
},
render () { render () {
const { src, size, staticSrc, animate } = this.props;
const { hovering } = this.state; const { hovering } = this.state;
if (this.props.animated) { const style = {
return ( ...this.props.style,
<div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}> width: `${size}px`,
<img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ borderRadius: '4px' }} /> height: `${size}px`,
</div> backgroundSize: `${size}px ${size}px`
); };
if (hovering || animate) {
style.backgroundImage = `url(${src})`;
} else {
style.backgroundImage = `url(${staticSrc})`;
} }
return ( return (
<div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}> <div
<img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', opacity: hovering ? '1' : '0', borderRadius: '4px' }} /> className='avatar'
<canvas ref={this.setCanvasRef} style={{ borderRadius: '4px', width: this.props.size, height: this.props.size, opacity: hovering ? '0' : '1' }} /> onMouseEnter={this.handleMouseEnter}
</div> onMouseLeave={this.handleMouseLeave}
style={style}
/>
); );
} }

View File

@ -90,7 +90,7 @@ const Status = React.createClass({
<a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px' }}> <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px' }}>
<div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}> <div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
<Avatar src={status.getIn(['account', 'avatar'])} size={48} /> <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
</div> </div>
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />

View File

@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
const AutosuggestAccount = ({ account }) => ( const AutosuggestAccount = ({ account }) => (
<div style={{ overflow: 'hidden' }} className='autosuggest-account'> <div style={{ overflow: 'hidden' }} className='autosuggest-account'>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div> <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} staticSrc={status.getIn(['account', 'avatar_static'])} size={18} /></div>
<DisplayName account={account} /> <DisplayName account={account} />
</div> </div>
); );

View File

@ -17,7 +17,7 @@ const NavigationBar = React.createClass({
render () { render () {
return ( return (
<div className='navigation-bar'> <div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} animate size={40} /></Permalink>
<div style={{ flex: '1 1 auto', marginLeft: '8px' }}> <div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
<strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong> <strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>

View File

@ -50,7 +50,7 @@ const ReplyIndicator = React.createClass({
<div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div> <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div> <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div>
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</a> </a>
</div> </div>

View File

@ -33,7 +33,7 @@ const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => {
<div> <div>
<div style={outerStyle}> <div style={outerStyle}>
<Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}> <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={48} /></div> <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>

View File

@ -54,7 +54,7 @@ const DetailedStatus = React.createClass({
return ( return (
<div style={{ padding: '14px 10px' }} className='detailed-status'> <div style={{ padding: '14px 10px' }} className='detailed-status'>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}> <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
<div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div> <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
<DisplayName account={status.get('account')} /> <DisplayName account={status.get('account')} />
</a> </a>

View File

@ -1,7 +1,7 @@
@import 'variables'; @import 'variables';
.app-body{ .app-body{
-ms-overflow-style: -ms-autohiding-scrollbar; -ms-overflow-style: -ms-autohiding-scrollbar;
} }
.button { .button {
@ -165,6 +165,14 @@
} }
} }
.avatar {
border-radius: 4px;
background: transparent no-repeat;
background-position: 50%;
background-clip: padding-box;
position: relative;
}
.lightbox .icon-button { .lightbox .icon-button {
color: $color1; color: $color1;
} }

View File

@ -12,12 +12,12 @@ class Account < ApplicationRecord
validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?' validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?'
# Avatar upload # Avatar upload
has_attached_file :avatar, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-quality 80 -strip' }
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
validates_attachment_size :avatar, less_than: 2.megabytes validates_attachment_size :avatar, less_than: 2.megabytes
# Header upload # Header upload
has_attached_file :header, styles: { original: '700x335#' }, convert_options: { all: '-quality 80 -strip' } has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-quality 80 -strip' }
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
validates_attachment_size :header, less_than: 2.megabytes validates_attachment_size :header, less_than: 2.megabytes
@ -158,6 +158,22 @@ class Account < ApplicationRecord
save! save!
end end
def avatar_original_url
avatar.url(:original)
end
def avatar_static_url
avatar_content_type == 'image/gif' ? avatar.url(:static) : avatar_original_url
end
def header_original_url
header.url(:original)
end
def header_static_url
header_content_type == 'image/gif' ? header.url(:static) : header_original_url
end
def avatar_remote_url=(url) def avatar_remote_url=(url)
parsed_url = URI.parse(url) parsed_url = URI.parse(url)
@ -292,6 +308,18 @@ class Account < ApplicationRecord
def follow_mapping(query, field) def follow_mapping(query, field)
query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping } query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping }
end end
def avatar_styles(file)
styles = { original: '120x120#' }
styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles
end
def header_styles(file)
styles = { original: '700x335#' }
styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
styles
end
end end
before_create do before_create do

View File

@ -4,8 +4,9 @@ attributes :id, :username, :acct, :display_name, :locked, :created_at
node(:note) { |account| Formatter.instance.simplified_format(account) } node(:note) { |account| Formatter.instance.simplified_format(account) }
node(:url) { |account| TagManager.instance.url_for(account) } node(:url) { |account| TagManager.instance.url_for(account) }
node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) } node(:avatar) { |account| full_asset_url(account.avatar_original_url) }
node(:header) { |account| full_asset_url(account.header.url(:original)) } node(:avatar_static) { |account| full_asset_url(account.avatar_static_url) }
node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count } node(:header) { |account| full_asset_url(account.header_original_url) }
node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count } node(:header_static) { |account| full_asset_url(account.header_static_url) }
node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : account.statuses_count }
attributes :followers_count, :following_count, :statuses_count

View File

@ -92,5 +92,17 @@ namespace :mastodon do
Rails.logger.debug 'Done!' Rails.logger.debug 'Done!'
end end
desc 'Generate static versions of GIF avatars/headers'
task add_static_avatars: :environment do
Rails.logger.debug 'Generating static avatars/headers for GIF ones...'
Account.unscoped.where(avatar_content_type: 'image/gif').or(Account.unscoped.where(header_content_type: 'image/gif')).find_each do |account|
account.avatar.reprocess!
account.header.reprocess!
end
Rails.logger.debug 'Done!'
end
end end
end end

BIN
spec/fixtures/files/avatar.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -6,16 +6,10 @@ import Avatar from '../../../app/assets/javascripts/components/components/avatar
describe('<Avatar />', () => { describe('<Avatar />', () => {
const src = '/path/to/image.jpg'; const src = '/path/to/image.jpg';
const size = 100; const size = 100;
const wrapper = render(<Avatar src={src} size={size} />); const wrapper = render(<Avatar src={src} animate size={size} />);
it('renders an img element with the given src', () => { it('renders a div element with the given src as background', () => {
expect(wrapper.find('img')).to.have.attr('src', `${src}`); expect(wrapper.find('div')).to.have.style('background-image', `url(${src})`);
});
it('renders an img element of the given size', () => {
['width', 'height'].map((attr) => {
expect(wrapper.find('img')).to.have.attr(attr, `${size}`);
});
}); });
it('renders a div element of the given size', () => { it('renders a div element of the given size', () => {

View File

@ -421,4 +421,24 @@ RSpec.describe Account, type: :model do
end end
end end
end end
describe 'static avatars' do
describe 'when GIF' do
it 'creates a png static style' do
subject.avatar = attachment_fixture('avatar.gif')
subject.save
expect(subject.avatar_static_url).to_not eq subject.avatar_original_url
end
end
describe 'when non-GIF' do
it 'does not create extra static style' do
subject.avatar = attachment_fixture('attachment.jpg')
subject.save
expect(subject.avatar_static_url).to eq subject.avatar_original_url
end
end
end
end end