gem 'paperclip', '~> 6.0'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'streamio-ffmpeg', '~> 3.0'
+gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.6'
rack (>= 0.9.0)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
+ blurhash (0.1.2)
+ ffi (~> 1.10.0)
bootsnap (1.4.4)
msgpack (~> 1.0)
brakeman (4.5.0)
aws-sdk-s3 (~> 1.36)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
+ blurhash (~> 0.1)
bootsnap (~> 1.4)
brakeman (~> 4.5)
browser
import { isIOS } from '../is_mobile';
import classNames from 'classnames';
import { autoPlayGif, displayMedia } from '../initial_state';
+import { decode } from 'blurhash';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number,
+ visible: PropTypes.bool.isRequired,
};
static defaultProps = {
size: 1,
};
+ state = {
+ loaded: false,
+ };
+
handleMouseEnter = (e) => {
if (this.hoverToPlay()) {
e.target.play();
e.stopPropagation();
}
+ componentDidMount () {
+ if (this.props.attachment.get('blurhash')) {
+ this._decode();
+ }
+ }
+
+ componentDidUpdate (prevProps) {
+ if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
+ this._decode();
+ }
+ }
+
+ _decode () {
+ const hash = this.props.attachment.get('blurhash');
+ const pixels = decode(hash, 32, 32);
+
+ if (pixels) {
+ const ctx = this.canvas.getContext('2d');
+ const imageData = new ImageData(pixels, 32, 32);
+
+ ctx.putImageData(imageData, 0, 0);
+ }
+ }
+
+ setCanvasRef = c => {
+ this.canvas = c;
+ }
+
+ handleImageLoad = () => {
+ this.setState({ loaded: true });
+ }
+
render () {
- const { attachment, index, size, standalone, displayWidth } = this.props;
+ const { attachment, index, size, standalone, displayWidth, visible } = this.props;
let width = 50;
let height = 100;
let thumbnail = '';
- if (attachment.get('type') === 'image') {
+ if (attachment.get('type') === 'unknown') {
+ return (
+ <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+ <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} >
+ <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
+ </a>
+ </div>
+ );
+ } else if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
- const originalUrl = attachment.get('url');
- const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+ const originalUrl = attachment.get('url');
+ const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
alt={attachment.get('description')}
title={attachment.get('description')}
style={{ objectPosition: `${x}% ${y}%` }}
+ onLoad={this.handleImageLoad}
/>
</a>
);
return (
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
- {thumbnail}
+ <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
+ {visible && thumbnail}
</div>
);
}
if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
+
this.setState({
width: node.offsetWidth,
});
const width = this.state.width || defaultWidth;
- let children;
+ let children, spoilerButton;
const style = {};
style.height = height;
}
- if (!visible) {
- let warning;
+ const size = media.take(4).size;
- if (sensitive) {
- warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
- } else {
- warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
- }
+ if (this.isStandaloneEligible()) {
+ children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
+ } else {
+ children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
+ }
- children = (
- <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
- <span className='media-spoiler__warning'>{warning}</span>
- <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+ if (visible) {
+ spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />;
+ } else {
+ spoilerButton = (
+ <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
+ <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
</button>
);
- } else {
- const size = media.take(4).size;
-
- if (this.isStandaloneEligible()) {
- children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
- } else {
- children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
- }
}
return (
<div className='media-gallery' style={style} ref={this.handleRef}>
- <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
- <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
+ <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
+ {spoilerButton}
</div>
{children}
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
- if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+ if (this.props.muted) {
media = (
<AttachmentList
compact
{Component => (
<Component
preview={video.get('preview_url')}
+ blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={this.props.cachedMediaWidth}
{Component => (
<Component
preview={video.get('preview_url')}
+ blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={239}
import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
-import AttachmentList from '../../../components/attachment_list';
import { Link } from 'react-router-dom';
import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card';
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
- if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
- media = <AttachmentList media={status.get('media_attachments')} />;
- } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Video
preview={video.get('preview_url')}
+ blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={300}
return (
<Video
preview={image.get('preview_url')}
+ blurhash={image.get('blurhash')}
src={image.get('url')}
width={image.get('width')}
height={image.get('height')}
<div>
<Video
preview={media.get('preview_url')}
+ blurhash={media.get('blurhash')}
src={media.get('url')}
startTime={time}
onCloseVideo={onClose}
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
import { displayMedia } from '../../initial_state';
import Icon from 'mastodon/components/icon';
+import { decode } from 'blurhash';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
inline: PropTypes.bool,
cacheWidth: PropTypes.func,
intl: PropTypes.object.isRequired,
+ blurhash: PropTypes.string,
};
state = {
setVideoRef = c => {
this.video = c;
+
if (this.video) {
this.setState({ volume: this.video.volume, muted: this.video.muted });
}
this.volume = c;
}
+ setCanvasRef = c => {
+ this.canvas = c;
+ }
+
handleClickRoot = e => e.stopPropagation();
handlePlay = () => {
}
handleVolumeMouseDown = e => {
-
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
}
handleMouseVolSlide = throttle(e => {
-
const rect = this.volume.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+ if (this.props.blurhash) {
+ this._decode();
+ }
}
componentWillUnmount () {
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
}
+ componentDidUpdate (prevProps) {
+ if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
+ this._decode();
+ }
+ }
+
+ _decode () {
+ const hash = this.props.blurhash;
+ const pixels = decode(hash, 32, 32);
+
+ if (pixels) {
+ const ctx = this.canvas.getContext('2d');
+ const imageData = new ImageData(pixels, 32, 32);
+
+ ctx.putImageData(imageData, 0, 0);
+ }
+ }
+
handleFullscreenChange = () => {
this.setState({ fullscreen: isFullscreen() });
}
handleOpenVideo = () => {
const { src, preview, width, height, alt } = this.props;
+
const media = fromJS({
type: 'video',
url: src,
}
let preload;
+
if (startTime || fullscreen || dragging) {
preload = 'auto';
} else if (detailed) {
}
let warning;
+
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
onClick={this.handleClickRoot}
tabIndex={0}
>
- <video
+ <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
+
+ {revealed && <video
ref={this.setVideoRef}
src={src}
poster={preview}
onLoadedData={this.handleLoadedData}
onProgress={this.handleProgress}
onVolumeChange={this.handleVolumeChange}
- />
+ />}
- <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
- <span className='video-player__spoiler__title'>{warning}</span>
- <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
- </button>
+ <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
+ <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
+ <span className='spoiler-button__overlay__label'>{warning}</span>
+ </button>
+ </div>
<div className={classNames('video-player__controls', { active: paused || hovered })}>
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
& > div {
background: rgba($base-shadow-color, 0.6);
- border-radius: 4px;
+ border-radius: 8px;
padding: 12px 9px;
flex: 0 0 auto;
display: flex;
button,
a {
display: inline;
- color: $primary-text-color;
+ color: $secondary-text-color;
background: transparent;
border: 0;
- padding: 0 5px;
+ padding: 0 8px;
text-decoration: none;
- opacity: 0.6;
font-size: 18px;
line-height: 18px;
&:hover,
&:active,
&:focus {
- opacity: 1;
+ color: $primary-text-color;
}
}
}
.spoiler-button {
- display: none;
- left: 4px;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
position: absolute;
- text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
- top: 4px;
z-index: 100;
- &.spoiler-button--visible {
+ &--minified {
display: block;
+ left: 4px;
+ top: 4px;
+ width: auto;
+ height: auto;
+ }
+
+ &--hidden {
+ display: none;
+ }
+
+ &__overlay {
+ display: block;
+ background: transparent;
+ width: 100%;
+ height: 100%;
+ border: 0;
+
+ &__label {
+ display: inline-block;
+ background: rgba($base-overlay-background, 0.5);
+ border-radius: 8px;
+ padding: 8px 12px;
+ color: $primary-text-color;
+ font-weight: 500;
+ font-size: 14px;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ .spoiler-button__overlay__label {
+ background: rgba($base-overlay-background, 0.8);
+ }
+ }
}
}
text-decoration: none;
color: $secondary-text-color;
line-height: 0;
+ position: relative;
+ z-index: 1;
&,
img {
}
}
+.media-gallery__preview {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 0;
+ background: $base-overlay-background;
+
+ &--hidden {
+ display: none;
+ }
+}
+
.media-gallery__gifv {
height: 100%;
overflow: hidden;
next if attachment['url'].blank?
href = Addressable::URI.parse(attachment['url']).normalize.to_s
- media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
+ media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
media_attachments << media_attachment
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
end
+ def supported_blurhash?(blurhash)
+ components = blurhash.blank? ? nil : Blurhash.components(blurhash)
+ components.present? && components.none? { |comp| comp > 5 }
+ end
+
def skip_download?
return @skip_download if defined?(@skip_download)
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
+ blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
}.freeze
def self.default_key_transform
# account_id :bigint(8)
# description :text
# scheduled_status_id :bigint(8)
+# blurhash :string
#
class MediaAttachment < ApplicationRecord
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
+ BLURHASH_OPTIONS = {
+ x_comp: 4,
+ y_comp: 4,
+ }.freeze
+
IMAGE_STYLES = {
original: {
pixels: 1_638_400, # 1280x1280px
small: {
pixels: 160_000, # 400x400px
file_geometry_parser: FastGeometryParser,
+ blurhash: BLURHASH_OPTIONS,
},
}.freeze
},
format: 'png',
time: 0,
+ file_geometry_parser: FastGeometryParser,
+ blurhash: BLURHASH_OPTIONS,
},
}.freeze
def file_processors(f)
if f.file_content_type == 'image/gif'
- [:gif_transcoder]
+ [:gif_transcoder, :blurhash_transcoder]
elsif VIDEO_MIME_TYPES.include? f.file_content_type
- [:video_transcoder]
+ [:video_transcoder, :blurhash_transcoder]
else
- [:lazy_thumbnail]
+ [:lazy_thumbnail, :blurhash_transcoder]
end
end
end
class ActivityPub::NoteSerializer < ActivityPub::Serializer
context_extensions :atom_uri, :conversation, :sensitive,
- :hashtag, :emoji, :focal_point
+ :hashtag, :emoji, :focal_point, :blurhash
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
class MediaAttachmentSerializer < ActivityPub::Serializer
include RoutingHelper
- attributes :type, :media_type, :url, :name
+ attributes :type, :media_type, :url, :name, :blurhash
attribute :focal_point, if: :focal_point?
def type
attributes :id, :type, :url, :preview_url,
:remote_url, :text_url, :meta,
- :description
+ :description, :blurhash
def id
object.id.to_s
- elsif !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
- = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
+ = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
- elsif !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
- = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
+ = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
= render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
--- /dev/null
+class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2]
+ def change
+ add_column :media_attachments, :blurhash, :string
+ end
+end
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2019_04_09_054914) do
+ActiveRecord::Schema.define(version: 2019_04_20_025523) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
t.bigint "account_id"
t.text "description"
t.bigint "scheduled_status_id"
+ t.string "blurhash"
t.index ["account_id"], name: "index_media_attachments_on_account_id"
t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
--- /dev/null
+# frozen_string_literal: true
+
+module Paperclip
+ class BlurhashTranscoder < Paperclip::Processor
+ def make
+ return @file unless options[:style] == :small
+
+ pixels = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
+ geometry = options.fetch(:file_geometry_parser).from_file(@file)
+
+ attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {})
+
+ @file
+ end
+ end
+end
"babel-plugin-react-intl": "^3.0.1",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"babel-runtime": "^6.26.0",
+ "blurhash": "^1.0.0",
"classnames": "^2.2.5",
"compression-webpack-plugin": "^2.0.0",
"cross-env": "^5.1.4",
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
+blurhash@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.0.0.tgz#9087bc5cc4d482f1305059d7410df4133adcab2e"
+ integrity sha512-x6fpZnd6AWde4U9m7xhUB44qIvGV4W6OdTAXGabYm4oZUOOGh5K1HAEoGAQn3iG4gbbPn9RSGce3VfNgGsX/Vw==
+
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
version "4.11.8"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"