ソースを参照

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.
master
Eugen Rochko 7年前
コミット
caf5b8e975
17個のファイルの変更325行の追加137行の削除
  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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

@@ -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 ファイルの表示

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

読み込み中…
キャンセル
保存