Browse Source

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.
closed-social-glitch-2
Eugen Rochko 7 years ago
parent
commit
caf5b8e975
17 changed files with 325 additions and 137 deletions
  1. +6
    -1
      app/assets/javascripts/components/actions/accounts.jsx
  2. +21
    -0
      app/assets/javascripts/components/components/extended_video_player.jsx
  3. +153
    -79
      app/assets/javascripts/components/components/media_gallery.jsx
  4. +2
    -2
      app/assets/javascripts/components/components/status.jsx
  5. +2
    -2
      app/assets/javascripts/components/components/video_player.jsx
  6. +1
    -1
      app/assets/javascripts/components/features/status/components/detailed_status.jsx
  7. +15
    -7
      app/assets/javascripts/components/features/ui/containers/modal_container.jsx
  8. +25
    -6
      app/assets/stylesheets/stream_entries.scss
  9. +45
    -24
      app/models/media_attachment.rb
  10. +2
    -2
      app/views/api/v1/media/create.rabl
  11. +3
    -3
      app/views/stream_entries/_detailed_status.html.haml
  12. +4
    -0
      app/views/stream_entries/_media.html.haml
  13. +8
    -7
      app/views/stream_entries/_simple_status.html.haml
  14. +3
    -2
      config/application.rb
  15. +12
    -0
      db/migrate/20170304202101_add_type_to_media_attachments.rb
  16. +2
    -1
      db/schema.rb
  17. +21
    -0
      lib/paperclip/gif_transcoder.rb

+ 6
- 1
app/assets/javascripts/components/actions/accounts.jsx View File

@ -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));
});

+ 21
- 0
app/assets/javascripts/components/components/extended_video_player.jsx View File

@ -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;

+ 153
- 79
app/assets/javascripts/components/components/media_gallery.jsx View File

@ -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>
);

+ 2
- 2
app/assets/javascripts/components/components/status.jsx View File

@ -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} />;
}

+ 2
- 2
app/assets/javascripts/components/components/video_player.jsx View File

@ -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>
);
}

+ 1
- 1
app/assets/javascripts/components/features/status/components/detailed_status.jsx View File

@ -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} />;

+ 15
- 7
app/assets/javascripts/components/features/ui/containers/modal_container.jsx View File

@ -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>
);

+ 25
- 6
app/assets/stylesheets/stream_entries.scss View File

@ -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;

+ 45
- 24
app/models/media_attachment.rb View File

@ -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

+ 2
- 2
app/views/api/v1/media/create.rabl View File

@ -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) }

+ 3
- 3
app/views/stream_entries/_detailed_status.html.haml View File

@ -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 }

+ 4
- 0
app/views/stream_entries/_media.html.haml View File

@ -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 }/

+ 8
- 7
app/views/stream_entries/_simple_status.html.haml View File

@ -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 }

+ 3
- 2
config/application.rb View File

@ -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

+ 12
- 0
db/migrate/20170304202101_add_type_to_media_attachments.rb View File

@ -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

+ 2
- 1
db/schema.rb View File

@ -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

+ 21
- 0
lib/paperclip/gif_transcoder.rb View File

@ -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