Fix #431 - convert gif to webm during upload. Web UI treats them like it did

before. In the API, attachments now can be either image, video or gifv. Gifv
is to be treated like images in terms of behaviour, but are videos by file
type.
feature/8296-invite_api
Eugen Rochko 6 years ago
parent 4cbeb9a7eb
commit caf5b8e975
  1. 7
      app/assets/javascripts/components/actions/accounts.jsx
  2. 21
      app/assets/javascripts/components/components/extended_video_player.jsx
  3. 232
      app/assets/javascripts/components/components/media_gallery.jsx
  4. 4
      app/assets/javascripts/components/components/status.jsx
  5. 4
      app/assets/javascripts/components/components/video_player.jsx
  6. 2
      app/assets/javascripts/components/features/status/components/detailed_status.jsx
  7. 22
      app/assets/javascripts/components/features/ui/containers/modal_container.jsx
  8. 31
      app/assets/stylesheets/stream_entries.scss
  9. 69
      app/models/media_attachment.rb
  10. 4
      app/views/api/v1/media/create.rabl
  11. 6
      app/views/stream_entries/_detailed_status.html.haml
  12. 4
      app/views/stream_entries/_media.html.haml
  13. 15
      app/views/stream_entries/_simple_status.html.haml
  14. 5
      config/application.rb
  15. 12
      db/migrate/20170304202101_add_type_to_media_attachments.rb
  16. 3
      db/schema.rb
  17. 21
      lib/paperclip/gif_transcoder.rb

@ -75,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchRelationships([id]));
if (getState().getIn(['accounts', id], null) !== null) {
return;
}
dispatch(fetchAccountRequest(id));
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
dispatch(fetchAccountSuccess(response.data));
dispatch(fetchRelationships([id]));
}).catch(error => {
dispatch(fetchAccountFail(id, error));
});

@ -0,0 +1,21 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
const ExtendedVideoPlayer = React.createClass({
propTypes: {
src: React.PropTypes.string.isRequired
},
mixins: [PureRenderMixin],
render () {
return (
<div>
<video src={this.props.src} autoPlay muted loop />
</div>
);
},
});
export default ExtendedVideoPlayer;

@ -43,6 +43,141 @@ const spoilerButtonStyle = {
zIndex: '100'
};
const itemStyle = {
boxSizing: 'border-box',
position: 'relative',
float: 'left',
border: 'none',
display: 'block'
};
const thumbStyle = {
display: 'block',
width: '100%',
height: '100%',
textDecoration: 'none',
backgroundSize: 'cover',
cursor: 'zoom-in'
};
const gifvThumbStyle = {
position: 'relative',
zIndex: '1',
width: '100%',
height: '100%',
objectFit: 'cover',
top: '50%',
transform: 'translateY(-50%)',
cursor: 'zoom-in'
};
const Item = React.createClass({
propTypes: {
attachment: ImmutablePropTypes.map.isRequired,
index: React.PropTypes.number.isRequired,
size: React.PropTypes.number.isRequired,
onClick: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleClick (e) {
const { index, onClick } = this.props;
if (e.button === 0) {
e.preventDefault();
onClick(index);
}
e.stopPropagation();
},
render () {
const { attachment, index, size } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
let thumbnail = '';
if (attachment.get('type') === 'image') {
thumbnail = (
<a
href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')}
onClick={this.handleClick}
target='_blank'
style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }}
/>
);
} else if (attachment.get('type') === 'gifv') {
thumbnail = (
<video
src={attachment.get('url')}
onClick={this.handleClick}
autoPlay={true}
loop={true}
muted={true}
style={gifvThumbStyle}
/>
);
}
return (
<div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
</div>
);
}
});
const MediaGallery = React.createClass({
getInitialState () {
@ -61,17 +196,12 @@ const MediaGallery = React.createClass({
mixins: [PureRenderMixin],
handleClick (index, e) {
if (e.button === 0) {
e.preventDefault();
this.props.onOpenMedia(this.props.media, index);
}
e.stopPropagation();
handleOpen (e) {
this.setState({ visible: !this.state.visible });
},
handleOpen () {
this.setState({ visible: !this.state.visible });
handleClick (index) {
this.props.onOpenMedia(this.props.media, index);
},
render () {
@ -80,87 +210,31 @@ const MediaGallery = React.createClass({
let children;
if (!this.state.visible) {
let warning;
if (sensitive) {
children = (
<div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
children = (
<div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
children = (
<div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
<span style={spoilerSpanStyle}>{warning}</span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
const size = media.take(4).size;
children = media.take(4).map((attachment, i) => {
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && i > 0)) {
height = 50;
}
if (size === 2) {
if (i === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (i === 0) {
right = '2px';
} else if (i > 0) {
left = '2px';
}
if (i === 1) {
bottom = '2px';
} else if (i > 1) {
top = '2px';
}
} else if (size === 4) {
if (i === 0 || i === 2) {
right = '2px';
}
if (i === 1 || i === 3) {
left = '2px';
}
if (i < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
return (
<div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
<a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
</div>
);
});
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
}
return (
<div style={{ ...outerStyle, height: `${this.props.height}px` }}>
<div style={spoilerButtonStyle} >
<div style={spoilerButtonStyle}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
</div>
{children}
</div>
);

@ -74,8 +74,8 @@ const Status = React.createClass({
}
if (status.get('media_attachments').size > 0 && !this.props.muted) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />;
if (status.getIn(['media_attachments', 0, 'type']) === 'video' || (status.get('media_attachments').size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'gifv')) {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} autoplay={status.getIn(['media_attachments', 0, 'type']) === 'gifv'} sensitive={status.get('sensitive')} />;
} else {
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />;
}

@ -175,7 +175,7 @@ const VideoPlayer = React.createClass({
);
} else {
return (
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleOpen}>
<div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
{spoilerButton}
<span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@ -197,7 +197,7 @@ const VideoPlayer = React.createClass({
<div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
{spoilerButton}
{muteButton}
<video ref={this.setRef} src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
<video ref={this.setRef} src={media.get('url')} autoPlay={true} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
</div>
);
}

@ -38,7 +38,7 @@ const DetailedStatus = React.createClass({
let applicationLink = '';
if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
if (status.getIn(['media_attachments', 0, 'type']) === 'video' || (status.get('media_attachments').size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'gifv')) {
media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />;
} else {
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;

@ -9,6 +9,7 @@ import ImageLoader from 'react-imageloader';
import LoadingIndicator from '../../../components/loading_indicator';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ExtendedVideoPlayer from '../../../components/extended_video_player';
const mapStateToProps = state => ({
media: state.getIn(['modal', 'media']),
@ -131,27 +132,34 @@ const Modal = React.createClass({
return null;
}
const url = media.get(index).get('url');
const attachment = media.get(index);
const url = attachment.get('url');
let leftNav, rightNav;
let leftNav, rightNav, content;
leftNav = rightNav = '';
leftNav = rightNav = content = '';
if (media.size > 1) {
leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
}
return (
<Lightbox {...other}>
{leftNav}
if (attachment.get('type') === 'image') {
content = (
<ImageLoader
src={url}
preloader={preloader}
imgProps={{ style: imageStyle }}
/>
);
} else if (attachment.get('type') === 'gifv') {
content = <ExtendedVideoPlayer src={url} />;
}
return (
<Lightbox {...other}>
{leftNav}
{content}
{rightNav}
</Lightbox>
);

@ -104,8 +104,12 @@
overflow: hidden;
width: 100%;
box-sizing: border-box;
height: 110px;
display: flex;
position: relative;
.status__attachments__inner {
display: flex;
height: 214px;
}
}
}
@ -184,8 +188,12 @@
overflow: hidden;
width: 100%;
box-sizing: border-box;
height: 300px;
display: flex;
position: relative;
.status__attachments__inner {
display: flex;
height: 360px;
}
}
.video-player {
@ -231,11 +239,19 @@
text-decoration: none;
cursor: zoom-in;
}
video {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
top: 50%;
transform: translateY(-50%);
}
}
.video-item {
max-width: 196px;
a {
cursor: pointer;
}
@ -258,6 +274,9 @@
width: 100%;
height: 100%;
cursor: pointer;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;

@ -1,15 +1,32 @@
# frozen_string_literal: true
class MediaAttachment < ApplicationRecord
self.inheritance_column = nil
enum type: [:image, :gifv, :video]
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
VIDEO_STYLES = {
small: {
convert_options: {
output: {
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
},
},
format: 'png',
time: 0,
},
}.freeze
belongs_to :account, inverse_of: :media_attachments
belongs_to :status, inverse_of: :media_attachments
has_attached_file :file,
styles: -> (f) { file_styles f },
processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
styles: ->(f) { file_styles f },
processors: ->(f) { file_processors f },
convert_options: { all: '-quality 90 -strip' }
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
validates_attachment_size :file, less_than: 8.megabytes
@ -27,45 +44,45 @@ class MediaAttachment < ApplicationRecord
self.file = URI.parse(url)
end
def image?
IMAGE_MIME_TYPES.include? file_content_type
end
def video?
VIDEO_MIME_TYPES.include? file_content_type
end
def type
image? ? 'image' : 'video'
end
def to_param
shortcode
end
before_create :set_shortcode
before_post_process :set_type
class << self
private
def file_styles(f)
if f.instance.image?
if f.instance.file_content_type == 'image/gif'
{
original: '1280x1280>',
small: '400x400>',
}
else
{
small: {
small: IMAGE_STYLES[:small],
original: {
format: 'webm',
convert_options: {
output: {
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
'c:v' => 'libvpx',
'crf' => 6,
'b:v' => '500K',
},
},
format: 'png',
time: 1,
},
}
elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
IMAGE_STYLES
else
VIDEO_STYLES
end
end
def file_processors(f)
if f.file_content_type == 'image/gif'
[:gif_transcoder]
elsif VIDEO_MIME_TYPES.include? f.file_content_type
[:transcoder]
else
[:thumbnail]
end
end
end
@ -80,4 +97,8 @@ class MediaAttachment < ApplicationRecord
break if MediaAttachment.find_by(shortcode: shortcode).nil?
end
end
def set_type
self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
end
end

@ -1,5 +1,5 @@
object @media
attribute :id, :type
node(:url) { |media| full_asset_url(media.file.url( :original)) }
node(:preview_url) { |media| full_asset_url(media.file.url( :small)) }
node(:url) { |media| full_asset_url(media.file.url(:original)) }
node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
node(:text_url) { |media| medium_url(media) }

@ -22,9 +22,9 @@
.detailed-status__attachments
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
- status.media_attachments.each do |media|
.media-item
= link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
.status__attachments__inner
- status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media }
%div.detailed-status__meta
%data.dt-published{ value: status.created_at.to_time.iso8601 }

@ -0,0 +1,4 @@
.media-item
= link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : "", target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
- unless media.image?
%video{ src: media.file.url(:original), autoplay: true, loop: true }/

@ -22,11 +22,12 @@
- if status.sensitive?
= render partial: 'stream_entries/content_spoiler'
- if status.media_attachments.first.video?
.video-item
= link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
.video-item__play
= fa_icon('play')
.status__attachments__inner
.video-item
= link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
.video-item__play
= fa_icon('play')
- else
- status.media_attachments.each do |media|
.media-item
= link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
.status__attachments__inner
- status.media_attachments.each do |media|
= render partial: 'stream_entries/media', locals: { media: media }

@ -2,12 +2,13 @@ require_relative 'boot'
require 'rails/all'
require_relative '../app/lib/exceptions'
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
require_relative '../app/lib/exceptions'
require_relative '../lib/paperclip/gif_transcoder'
Dotenv::Railtie.load
module Mastodon

@ -0,0 +1,12 @@
class AddTypeToMediaAttachments < ActiveRecord::Migration[5.0]
def up
add_column :media_attachments, :type, :integer, default: 0, null: false
MediaAttachment.where(file_content_type: MediaAttachment::IMAGE_MIME_TYPES).update_all(type: MediaAttachment.types[:image])
MediaAttachment.where(file_content_type: MediaAttachment::VIDEO_MIME_TYPES).update_all(type: MediaAttachment.types[:video])
end
def down
remove_column :media_attachments, :type
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170303212857) do
ActiveRecord::Schema.define(version: 20170304202101) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20170303212857) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "shortcode"
t.integer "type", default: 0, null: false
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
end

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Paperclip
# This transcoder is only to be used for the MediaAttachment model
# to convert animated gifs to webm
class GifTranscoder < Paperclip::Processor
def make
num_frames = identify('-format %n :file', file: file.path).to_i
return file unless options[:style] == :original && num_frames > 1
final_file = Paperclip::Transcoder.make(file, options, attachment)
attachment.instance.file_file_name = 'media.webm'
attachment.instance.file_content_type = 'video/webm'
attachment.instance.type = MediaAttachment.types[:gifv]
final_file
end
end
end
Loading…
Cancel
Save