Browse Source

Add blurhash (#10630)

* Add blurhash

* Use fallback color for spoiler when blurhash missing

* Federate the blurhash and accept it as long as it's at most 5x5

* Display unknown media attachments as blurhash placeholders

* Improve style of embed actions and spoiler button

* Change blurhash resolution from 3x3 to 4x4

* Improve dependency definitions

* Fix code style issues
Eugen Rochko 4 weeks ago
parent
commit
fba96c808d
No account linked to committer's email address

+ 1
- 0
Gemfile View File

@@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
21 21
 gem 'paperclip', '~> 6.0'
22 22
 gem 'paperclip-av-transcoder', '~> 0.6'
23 23
 gem 'streamio-ffmpeg', '~> 3.0'
24
+gem 'blurhash', '~> 0.1'
24 25
 
25 26
 gem 'active_model_serializers', '~> 0.10'
26 27
 gem 'addressable', '~> 2.6'

+ 3
- 0
Gemfile.lock View File

@@ -99,6 +99,8 @@ GEM
99 99
       rack (>= 0.9.0)
100 100
     binding_of_caller (0.8.0)
101 101
       debug_inspector (>= 0.0.1)
102
+    blurhash (0.1.2)
103
+      ffi (~> 1.10.0)
102 104
     bootsnap (1.4.4)
103 105
       msgpack (~> 1.0)
104 106
     brakeman (4.5.0)
@@ -661,6 +663,7 @@ DEPENDENCIES
661 663
   aws-sdk-s3 (~> 1.36)
662 664
   better_errors (~> 2.5)
663 665
   binding_of_caller (~> 0.7)
666
+  blurhash (~> 0.1)
664 667
   bootsnap (~> 1.4)
665 668
   brakeman (~> 4.5)
666 669
   browser

+ 69
- 27
app/javascript/mastodon/components/media_gallery.js View File

@@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
7 7
 import { isIOS } from '../is_mobile';
8 8
 import classNames from 'classnames';
9 9
 import { autoPlayGif, displayMedia } from '../initial_state';
10
+import { decode } from 'blurhash';
10 11
 
11 12
 const messages = defineMessages({
12 13
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@@ -21,6 +22,7 @@ class Item extends React.PureComponent {
21 22
     size: PropTypes.number.isRequired,
22 23
     onClick: PropTypes.func.isRequired,
23 24
     displayWidth: PropTypes.number,
25
+    visible: PropTypes.bool.isRequired,
24 26
   };
25 27
 
26 28
   static defaultProps = {
@@ -29,6 +31,10 @@ class Item extends React.PureComponent {
29 31
     size: 1,
30 32
   };
31 33
 
34
+  state = {
35
+    loaded: false,
36
+  };
37
+
32 38
   handleMouseEnter = (e) => {
33 39
     if (this.hoverToPlay()) {
34 40
       e.target.play();
@@ -62,8 +68,40 @@ class Item extends React.PureComponent {
62 68
     e.stopPropagation();
63 69
   }
64 70
 
71
+  componentDidMount () {
72
+    if (this.props.attachment.get('blurhash')) {
73
+      this._decode();
74
+    }
75
+  }
76
+
77
+  componentDidUpdate (prevProps) {
78
+    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
79
+      this._decode();
80
+    }
81
+  }
82
+
83
+  _decode () {
84
+    const hash   = this.props.attachment.get('blurhash');
85
+    const pixels = decode(hash, 32, 32);
86
+
87
+    if (pixels) {
88
+      const ctx       = this.canvas.getContext('2d');
89
+      const imageData = new ImageData(pixels, 32, 32);
90
+
91
+      ctx.putImageData(imageData, 0, 0);
92
+    }
93
+  }
94
+
95
+  setCanvasRef = c => {
96
+    this.canvas = c;
97
+  }
98
+
99
+  handleImageLoad = () => {
100
+    this.setState({ loaded: true });
101
+  }
102
+
65 103
   render () {
66
-    const { attachment, index, size, standalone, displayWidth } = this.props;
104
+    const { attachment, index, size, standalone, displayWidth, visible } = this.props;
67 105
 
68 106
     let width  = 50;
69 107
     let height = 100;
@@ -116,12 +154,20 @@ class Item extends React.PureComponent {
116 154
 
117 155
     let thumbnail = '';
118 156
 
119
-    if (attachment.get('type') === 'image') {
157
+    if (attachment.get('type') === 'unknown') {
158
+      return (
159
+        <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
160
+          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }} >
161
+            <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
162
+          </a>
163
+        </div>
164
+      );
165
+    } else if (attachment.get('type') === 'image') {
120 166
       const previewUrl   = attachment.get('preview_url');
121 167
       const previewWidth = attachment.getIn(['meta', 'small', 'width']);
122 168
 
123
-      const originalUrl    = attachment.get('url');
124
-      const originalWidth  = attachment.getIn(['meta', 'original', 'width']);
169
+      const originalUrl   = attachment.get('url');
170
+      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
125 171
 
126 172
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
127 173
 
@@ -147,6 +193,7 @@ class Item extends React.PureComponent {
147 193
             alt={attachment.get('description')}
148 194
             title={attachment.get('description')}
149 195
             style={{ objectPosition: `${x}% ${y}%` }}
196
+            onLoad={this.handleImageLoad}
150 197
           />
151 198
         </a>
152 199
       );
@@ -176,7 +223,8 @@ class Item extends React.PureComponent {
176 223
 
177 224
     return (
178 225
       <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
179
-        {thumbnail}
226
+        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
227
+        {visible && thumbnail}
180 228
       </div>
181 229
     );
182 230
   }
@@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
225 273
     if (node /*&& this.isStandaloneEligible()*/) {
226 274
       // offsetWidth triggers a layout, so only calculate when we need to
227 275
       if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
276
+
228 277
       this.setState({
229 278
         width: node.offsetWidth,
230 279
       });
@@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
242 291
 
243 292
     const width = this.state.width || defaultWidth;
244 293
 
245
-    let children;
294
+    let children, spoilerButton;
246 295
 
247 296
     const style = {};
248 297
 
@@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
256 305
       style.height = height;
257 306
     }
258 307
 
259
-    if (!visible) {
260
-      let warning;
308
+    const size = media.take(4).size;
261 309
 
262
-      if (sensitive) {
263
-        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
264
-      } else {
265
-        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
266
-      }
310
+    if (this.isStandaloneEligible()) {
311
+      children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
312
+    } else {
313
+      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} />);
314
+    }
267 315
 
268
-      children = (
269
-        <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
270
-          <span className='media-spoiler__warning'>{warning}</span>
271
-          <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
316
+    if (visible) {
317
+      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />;
318
+    } else {
319
+      spoilerButton = (
320
+        <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
321
+          <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
272 322
         </button>
273 323
       );
274
-    } else {
275
-      const size = media.take(4).size;
276
-
277
-      if (this.isStandaloneEligible()) {
278
-        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
279
-      } else {
280
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
281
-      }
282 324
     }
283 325
 
284 326
     return (
285 327
       <div className='media-gallery' style={style} ref={this.handleRef}>
286
-        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
287
-          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
328
+        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
329
+          {spoilerButton}
288 330
         </div>
289 331
 
290 332
         {children}

+ 2
- 1
app/javascript/mastodon/components/status.js View File

@@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
274 274
     if (status.get('poll')) {
275 275
       media = <PollContainer pollId={status.get('poll')} />;
276 276
     } else if (status.get('media_attachments').size > 0) {
277
-      if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
277
+      if (this.props.muted) {
278 278
         media = (
279 279
           <AttachmentList
280 280
             compact
@@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
289 289
             {Component => (
290 290
               <Component
291 291
                 preview={video.get('preview_url')}
292
+                blurhash={video.get('blurhash')}
292 293
                 src={video.get('url')}
293 294
                 alt={video.get('description')}
294 295
                 width={this.props.cachedMediaWidth}

+ 1
- 0
app/javascript/mastodon/features/report/components/status_check_box.js View File

@@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
35 35
             {Component => (
36 36
               <Component
37 37
                 preview={video.get('preview_url')}
38
+                blurhash={video.get('blurhash')}
38 39
                 src={video.get('url')}
39 40
                 alt={video.get('description')}
40 41
                 width={239}

+ 2
- 4
app/javascript/mastodon/features/status/components/detailed_status.js View File

@@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
5 5
 import DisplayName from '../../../components/display_name';
6 6
 import StatusContent from '../../../components/status_content';
7 7
 import MediaGallery from '../../../components/media_gallery';
8
-import AttachmentList from '../../../components/attachment_list';
9 8
 import { Link } from 'react-router-dom';
10 9
 import { FormattedDate, FormattedNumber } from 'react-intl';
11 10
 import Card from './card';
@@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
109 108
     if (status.get('poll')) {
110 109
       media = <PollContainer pollId={status.get('poll')} />;
111 110
     } else if (status.get('media_attachments').size > 0) {
112
-      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
113
-        media = <AttachmentList media={status.get('media_attachments')} />;
114
-      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
111
+      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
115 112
         const video = status.getIn(['media_attachments', 0]);
116 113
 
117 114
         media = (
118 115
           <Video
119 116
             preview={video.get('preview_url')}
117
+            blurhash={video.get('blurhash')}
120 118
             src={video.get('url')}
121 119
             alt={video.get('description')}
122 120
             width={300}

+ 1
- 0
app/javascript/mastodon/features/ui/components/media_modal.js View File

@@ -144,6 +144,7 @@ class MediaModal extends ImmutablePureComponent {
144 144
         return (
145 145
           <Video
146 146
             preview={image.get('preview_url')}
147
+            blurhash={image.get('blurhash')}
147 148
             src={image.get('url')}
148 149
             width={image.get('width')}
149 150
             height={image.get('height')}

+ 1
- 0
app/javascript/mastodon/features/ui/components/video_modal.js View File

@@ -20,6 +20,7 @@ export default class VideoModal extends ImmutablePureComponent {
20 20
         <div>
21 21
           <Video
22 22
             preview={media.get('preview_url')}
23
+            blurhash={media.get('blurhash')}
23 24
             src={media.get('url')}
24 25
             startTime={time}
25 26
             onCloseVideo={onClose}

+ 41
- 8
app/javascript/mastodon/features/video/index.js View File

@@ -7,6 +7,7 @@ import classNames from 'classnames';
7 7
 import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
8 8
 import { displayMedia } from '../../initial_state';
9 9
 import Icon from 'mastodon/components/icon';
10
+import { decode } from 'blurhash';
10 11
 
11 12
 const messages = defineMessages({
12 13
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -102,6 +103,7 @@ class Video extends React.PureComponent {
102 103
     inline: PropTypes.bool,
103 104
     cacheWidth: PropTypes.func,
104 105
     intl: PropTypes.object.isRequired,
106
+    blurhash: PropTypes.string,
105 107
   };
106 108
 
107 109
   state = {
@@ -139,6 +141,7 @@ class Video extends React.PureComponent {
139 141
 
140 142
   setVideoRef = c => {
141 143
     this.video = c;
144
+
142 145
     if (this.video) {
143 146
       this.setState({ volume: this.video.volume, muted: this.video.muted });
144 147
     }
@@ -152,6 +155,10 @@ class Video extends React.PureComponent {
152 155
     this.volume = c;
153 156
   }
154 157
 
158
+  setCanvasRef = c => {
159
+    this.canvas = c;
160
+  }
161
+
155 162
   handleClickRoot = e => e.stopPropagation();
156 163
 
157 164
   handlePlay = () => {
@@ -170,7 +177,6 @@ class Video extends React.PureComponent {
170 177
   }
171 178
 
172 179
   handleVolumeMouseDown = e => {
173
-
174 180
     document.addEventListener('mousemove', this.handleMouseVolSlide, true);
175 181
     document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
176 182
     document.addEventListener('touchmove', this.handleMouseVolSlide, true);
@@ -190,7 +196,6 @@ class Video extends React.PureComponent {
190 196
   }
191 197
 
192 198
   handleMouseVolSlide = throttle(e => {
193
-
194 199
     const rect = this.volume.getBoundingClientRect();
195 200
     const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
196 201
 
@@ -261,6 +266,10 @@ class Video extends React.PureComponent {
261 266
     document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
262 267
     document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
263 268
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
269
+
270
+    if (this.props.blurhash) {
271
+      this._decode();
272
+    }
264 273
   }
265 274
 
266 275
   componentWillUnmount () {
@@ -270,6 +279,24 @@ class Video extends React.PureComponent {
270 279
     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
271 280
   }
272 281
 
282
+  componentDidUpdate (prevProps) {
283
+    if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
284
+      this._decode();
285
+    }
286
+  }
287
+
288
+  _decode () {
289
+    const hash   = this.props.blurhash;
290
+    const pixels = decode(hash, 32, 32);
291
+
292
+    if (pixels) {
293
+      const ctx       = this.canvas.getContext('2d');
294
+      const imageData = new ImageData(pixels, 32, 32);
295
+
296
+      ctx.putImageData(imageData, 0, 0);
297
+    }
298
+  }
299
+
273 300
   handleFullscreenChange = () => {
274 301
     this.setState({ fullscreen: isFullscreen() });
275 302
   }
@@ -314,6 +341,7 @@ class Video extends React.PureComponent {
314 341
 
315 342
   handleOpenVideo = () => {
316 343
     const { src, preview, width, height, alt } = this.props;
344
+
317 345
     const media = fromJS({
318 346
       type: 'video',
319 347
       url: src,
@@ -351,6 +379,7 @@ class Video extends React.PureComponent {
351 379
     }
352 380
 
353 381
     let preload;
382
+
354 383
     if (startTime || fullscreen || dragging) {
355 384
       preload = 'auto';
356 385
     } else if (detailed) {
@@ -360,6 +389,7 @@ class Video extends React.PureComponent {
360 389
     }
361 390
 
362 391
     let warning;
392
+
363 393
     if (sensitive) {
364 394
       warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
365 395
     } else {
@@ -377,7 +407,9 @@ class Video extends React.PureComponent {
377 407
         onClick={this.handleClickRoot}
378 408
         tabIndex={0}
379 409
       >
380
-        <video
410
+        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
411
+
412
+        {revealed && <video
381 413
           ref={this.setVideoRef}
382 414
           src={src}
383 415
           poster={preview}
@@ -397,12 +429,13 @@ class Video extends React.PureComponent {
397 429
           onLoadedData={this.handleLoadedData}
398 430
           onProgress={this.handleProgress}
399 431
           onVolumeChange={this.handleVolumeChange}
400
-        />
432
+        />}
401 433
 
402
-        <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
403
-          <span className='video-player__spoiler__title'>{warning}</span>
404
-          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
405
-        </button>
434
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
435
+          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
436
+            <span className='spoiler-button__overlay__label'>{warning}</span>
437
+          </button>
438
+        </div>
406 439
 
407 440
         <div className={classNames('video-player__controls', { active: paused || hovered })}>
408 441
           <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>

+ 60
- 10
app/javascript/styles/mastodon/components.scss View File

@@ -2412,7 +2412,7 @@ a.account__display-name {
2412 2412
 
2413 2413
     & > div {
2414 2414
       background: rgba($base-shadow-color, 0.6);
2415
-      border-radius: 4px;
2415
+      border-radius: 8px;
2416 2416
       padding: 12px 9px;
2417 2417
       flex: 0 0 auto;
2418 2418
       display: flex;
@@ -2423,19 +2423,18 @@ a.account__display-name {
2423 2423
     button,
2424 2424
     a {
2425 2425
       display: inline;
2426
-      color: $primary-text-color;
2426
+      color: $secondary-text-color;
2427 2427
       background: transparent;
2428 2428
       border: 0;
2429
-      padding: 0 5px;
2429
+      padding: 0 8px;
2430 2430
       text-decoration: none;
2431
-      opacity: 0.6;
2432 2431
       font-size: 18px;
2433 2432
       line-height: 18px;
2434 2433
 
2435 2434
       &:hover,
2436 2435
       &:active,
2437 2436
       &:focus {
2438
-        opacity: 1;
2437
+        color: $primary-text-color;
2439 2438
       }
2440 2439
     }
2441 2440
 
@@ -2932,15 +2931,49 @@ a.status-card.compact:hover {
2932 2931
 }
2933 2932
 
2934 2933
 .spoiler-button {
2935
-  display: none;
2936
-  left: 4px;
2934
+  top: 0;
2935
+  left: 0;
2936
+  width: 100%;
2937
+  height: 100%;
2937 2938
   position: absolute;
2938
-  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
2939
-  top: 4px;
2940 2939
   z-index: 100;
2941 2940
 
2942
-  &.spoiler-button--visible {
2941
+  &--minified {
2943 2942
     display: block;
2943
+    left: 4px;
2944
+    top: 4px;
2945
+    width: auto;
2946
+    height: auto;
2947
+  }
2948
+
2949
+  &--hidden {
2950
+    display: none;
2951
+  }
2952
+
2953
+  &__overlay {
2954
+    display: block;
2955
+    background: transparent;
2956
+    width: 100%;
2957
+    height: 100%;
2958
+    border: 0;
2959
+
2960
+    &__label {
2961
+      display: inline-block;
2962
+      background: rgba($base-overlay-background, 0.5);
2963
+      border-radius: 8px;
2964
+      padding: 8px 12px;
2965
+      color: $primary-text-color;
2966
+      font-weight: 500;
2967
+      font-size: 14px;
2968
+    }
2969
+
2970
+    &:hover,
2971
+    &:focus,
2972
+    &:active {
2973
+      .spoiler-button__overlay__label {
2974
+        background: rgba($base-overlay-background, 0.8);
2975
+      }
2976
+    }
2944 2977
   }
2945 2978
 }
2946 2979
 
@@ -4313,6 +4346,8 @@ a.status-card.compact:hover {
4313 4346
   text-decoration: none;
4314 4347
   color: $secondary-text-color;
4315 4348
   line-height: 0;
4349
+  position: relative;
4350
+  z-index: 1;
4316 4351
 
4317 4352
   &,
4318 4353
   img {
@@ -4325,6 +4360,21 @@ a.status-card.compact:hover {
4325 4360
   }
4326 4361
 }
4327 4362
 
4363
+.media-gallery__preview {
4364
+  width: 100%;
4365
+  height: 100%;
4366
+  object-fit: cover;
4367
+  position: absolute;
4368
+  top: 0;
4369
+  left: 0;
4370
+  z-index: 0;
4371
+  background: $base-overlay-background;
4372
+
4373
+  &--hidden {
4374
+    display: none;
4375
+  }
4376
+}
4377
+
4328 4378
 .media-gallery__gifv {
4329 4379
   height: 100%;
4330 4380
   overflow: hidden;

+ 6
- 1
app/lib/activitypub/activity/create.rb View File

@@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
194 194
       next if attachment['url'].blank?
195 195
 
196 196
       href             = Addressable::URI.parse(attachment['url']).normalize.to_s
197
-      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
197
+      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
198 198
       media_attachments << media_attachment
199 199
 
200 200
       next if unsupported_media_type?(attachment['mediaType']) || skip_download?
@@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
369 369
     mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
370 370
   end
371 371
 
372
+  def supported_blurhash?(blurhash)
373
+    components = blurhash.blank? ? nil : Blurhash.components(blurhash)
374
+    components.present? && components.none? { |comp| comp > 5 }
375
+  end
376
+
372 377
   def skip_download?
373 378
     return @skip_download if defined?(@skip_download)
374 379
     @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?

+ 1
- 0
app/lib/activitypub/adapter.rb View File

@@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
19 19
     conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
20 20
     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
21 21
     identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
22
+    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
22 23
   }.freeze
23 24
 
24 25
   def self.default_key_transform

+ 12
- 3
app/models/media_attachment.rb View File

@@ -18,6 +18,7 @@
18 18
 #  account_id          :bigint(8)
19 19
 #  description         :text
20 20
 #  scheduled_status_id :bigint(8)
21
+#  blurhash            :string
21 22
 #
22 23
 
23 24
 class MediaAttachment < ApplicationRecord
@@ -32,6 +33,11 @@ class MediaAttachment < ApplicationRecord
32 33
   VIDEO_MIME_TYPES             = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
33 34
   VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
34 35
 
36
+  BLURHASH_OPTIONS = {
37
+    x_comp: 4,
38
+    y_comp: 4,
39
+  }.freeze
40
+
35 41
   IMAGE_STYLES = {
36 42
     original: {
37 43
       pixels: 1_638_400, # 1280x1280px
@@ -41,6 +47,7 @@ class MediaAttachment < ApplicationRecord
41 47
     small: {
42 48
       pixels: 160_000, # 400x400px
43 49
       file_geometry_parser: FastGeometryParser,
50
+      blurhash: BLURHASH_OPTIONS,
44 51
     },
45 52
   }.freeze
46 53
 
@@ -53,6 +60,8 @@ class MediaAttachment < ApplicationRecord
53 60
       },
54 61
       format: 'png',
55 62
       time: 0,
63
+      file_geometry_parser: FastGeometryParser,
64
+      blurhash: BLURHASH_OPTIONS,
56 65
     },
57 66
   }.freeze
58 67
 
@@ -166,11 +175,11 @@ class MediaAttachment < ApplicationRecord
166 175
 
167 176
     def file_processors(f)
168 177
       if f.file_content_type == 'image/gif'
169
-        [:gif_transcoder]
178
+        [:gif_transcoder, :blurhash_transcoder]
170 179
       elsif VIDEO_MIME_TYPES.include? f.file_content_type
171
-        [:video_transcoder]
180
+        [:video_transcoder, :blurhash_transcoder]
172 181
       else
173
-        [:lazy_thumbnail]
182
+        [:lazy_thumbnail, :blurhash_transcoder]
174 183
       end
175 184
     end
176 185
   end

+ 2
- 2
app/serializers/activitypub/note_serializer.rb View File

@@ -2,7 +2,7 @@
2 2
 
3 3
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
4 4
   context_extensions :atom_uri, :conversation, :sensitive,
5
-                     :hashtag, :emoji, :focal_point
5
+                     :hashtag, :emoji, :focal_point, :blurhash
6 6
 
7 7
   attributes :id, :type, :summary,
8 8
              :in_reply_to, :published, :url,
@@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
153 153
   class MediaAttachmentSerializer < ActivityPub::Serializer
154 154
     include RoutingHelper
155 155
 
156
-    attributes :type, :media_type, :url, :name
156
+    attributes :type, :media_type, :url, :name, :blurhash
157 157
     attribute :focal_point, if: :focal_point?
158 158
 
159 159
     def type

+ 1
- 1
app/serializers/rest/media_attachment_serializer.rb View File

@@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
5 5
 
6 6
   attributes :id, :type, :url, :preview_url,
7 7
              :remote_url, :text_url, :meta,
8
-             :description
8
+             :description, :blurhash
9 9
 
10 10
   def id
11 11
     object.id.to_s

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

@@ -28,7 +28,7 @@
28 28
   - elsif !status.media_attachments.empty?
29 29
     - if status.media_attachments.first.video?
30 30
       - video = status.media_attachments.first
31
-      = 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
31
+      = 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
32 32
         = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
33 33
     - else
34 34
       = 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

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

@@ -32,7 +32,7 @@
32 32
   - elsif !status.media_attachments.empty?
33 33
     - if status.media_attachments.first.video?
34 34
       - video = status.media_attachments.first
35
-      = 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
35
+      = 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
36 36
         = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
37 37
     - else
38 38
       = 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

+ 5
- 0
db/migrate/20190420025523_add_blurhash_to_media_attachments.rb View File

@@ -0,0 +1,5 @@
1
+class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2]
2
+  def change
3
+    add_column :media_attachments, :blurhash, :string
4
+  end
5
+end

+ 2
- 1
db/schema.rb View File

@@ -10,7 +10,7 @@
10 10
 #
11 11
 # It's strongly recommended that you check this file into your version control system.
12 12
 
13
-ActiveRecord::Schema.define(version: 2019_04_09_054914) do
13
+ActiveRecord::Schema.define(version: 2019_04_20_025523) do
14 14
 
15 15
   # These are extensions that must be enabled in order to support this database
16 16
   enable_extension "plpgsql"
@@ -362,6 +362,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
362 362
     t.bigint "account_id"
363 363
     t.text "description"
364 364
     t.bigint "scheduled_status_id"
365
+    t.string "blurhash"
365 366
     t.index ["account_id"], name: "index_media_attachments_on_account_id"
366 367
     t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
367 368
     t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true

+ 16
- 0
lib/paperclip/blurhash_transcoder.rb View File

@@ -0,0 +1,16 @@
1
+# frozen_string_literal: true
2
+
3
+module Paperclip
4
+  class BlurhashTranscoder < Paperclip::Processor
5
+    def make
6
+      return @file unless options[:style] == :small
7
+
8
+      pixels   = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
9
+      geometry = options.fetch(:file_geometry_parser).from_file(@file)
10
+
11
+      attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {})
12
+
13
+      @file
14
+    end
15
+  end
16
+end

+ 1
- 0
package.json View File

@@ -78,6 +78,7 @@
78 78
     "babel-plugin-react-intl": "^3.0.1",
79 79
     "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
80 80
     "babel-runtime": "^6.26.0",
81
+    "blurhash": "^1.0.0",
81 82
     "classnames": "^2.2.5",
82 83
     "compression-webpack-plugin": "^2.0.0",
83 84
     "cross-env": "^5.1.4",

+ 5
- 0
yarn.lock View File

@@ -1743,6 +1743,11 @@ bluebird@^3.5.1, bluebird@^3.5.3:
1743 1743
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
1744 1744
   integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
1745 1745
 
1746
+blurhash@^1.0.0:
1747
+  version "1.0.0"
1748
+  resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.0.0.tgz#9087bc5cc4d482f1305059d7410df4133adcab2e"
1749
+  integrity sha512-x6fpZnd6AWde4U9m7xhUB44qIvGV4W6OdTAXGabYm4oZUOOGh5K1HAEoGAQn3iG4gbbPn9RSGce3VfNgGsX/Vw==
1750
+
1746 1751
 bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
1747 1752
   version "4.11.8"
1748 1753
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"

Loading…
Cancel
Save