diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2020-07-05 18:28:25 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-07-05 18:28:25 +0200 |
commit | 99f3a5554074d9a12619797c474b3de4c6085f02 (patch) | |
tree | d0b190d57da39c764b0dde853f072a6479534845 /app | |
parent | 2f2ab48b750ab1fb62a9b7a3ea1c8cc52f3c7366 (diff) |
Add color extraction for audio thumbnails (#14209)
Diffstat (limited to 'app')
-rw-r--r-- | app/javascript/mastodon/components/status.js | 4 | ||||
-rw-r--r-- | app/javascript/mastodon/features/audio/index.js | 238 | ||||
-rw-r--r-- | app/javascript/mastodon/features/status/components/detailed_status.js | 4 | ||||
-rw-r--r-- | app/javascript/mastodon/features/ui/components/audio_modal.js | 4 | ||||
-rw-r--r-- | app/javascript/mastodon/features/ui/components/focal_point_modal.js | 4 | ||||
-rw-r--r-- | app/javascript/styles/mastodon/components.scss | 43 | ||||
-rw-r--r-- | app/models/media_attachment.rb | 13 | ||||
-rw-r--r-- | app/views/media/player.html.haml | 2 | ||||
-rw-r--r-- | app/views/statuses/_detailed_status.html.haml | 2 | ||||
-rw-r--r-- | app/views/statuses/_simple_status.html.haml | 2 | ||||
-rw-r--r-- | app/workers/post_process_media_worker.rb | 2 |
11 files changed, 76 insertions, 242 deletions
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 827b69500..f9f6736e6 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -353,7 +353,9 @@ class Status extends ImmutablePureComponent { src={attachment.get('url')} alt={attachment.get('description')} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} - blurhash={attachment.get('blurhash')} + backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} + foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} + accentColor={attachment.getIn(['meta', 'colors', 'accent'])} duration={attachment.getIn(['meta', 'original', 'duration'], 0)} width={this.props.cachedMediaWidth} height={110} diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index 99926e52a..686709ac3 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -5,131 +5,12 @@ import { formatTime } from 'mastodon/features/video'; import Icon from 'mastodon/components/icon'; import classNames from 'classnames'; import { throttle } from 'lodash'; -import { encode, decode } from 'blurhash'; import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video'; import { debounce } from 'lodash'; -const digitCharacters = [ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - 'G', - 'H', - 'I', - 'J', - 'K', - 'L', - 'M', - 'N', - 'O', - 'P', - 'Q', - 'R', - 'S', - 'T', - 'U', - 'V', - 'W', - 'X', - 'Y', - 'Z', - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'g', - 'h', - 'i', - 'j', - 'k', - 'l', - 'm', - 'n', - 'o', - 'p', - 'q', - 'r', - 's', - 't', - 'u', - 'v', - 'w', - 'x', - 'y', - 'z', - '#', - '$', - '%', - '*', - '+', - ',', - '-', - '.', - ':', - ';', - '=', - '?', - '@', - '[', - ']', - '^', - '_', - '{', - '|', - '}', - '~', -]; - -const decode83 = (str) => { - let value = 0; - let c, digit; - - for (let i = 0; i < str.length; i++) { - c = str[i]; - digit = digitCharacters.indexOf(c); - value = value * 83 + digit; - } - - return value; -}; - -const decodeRGB = int => ({ - r: Math.max(0, (int >> 16)), - g: Math.max(0, (int >> 8) & 255), - b: Math.max(0, (int & 255)), -}); - -const luma = ({ r, g, b }) => 0.2126 * r + 0.7152 * g + 0.0722 * b; - -const adjustColor = ({ r, g, b }, lumaThreshold = 100) => { - let delta; - - if (luma({ r, g, b }) >= lumaThreshold) { - delta = -80; - } else { - delta = 80; - } - - return { - r: r + delta, - g: g + delta, - b: b + delta, - }; +const hex2rgba = (hex, alpha = 1) => { + const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; const messages = defineMessages({ @@ -157,7 +38,9 @@ class Audio extends React.PureComponent { fullscreen: PropTypes.bool, intl: PropTypes.object.isRequired, cacheWidth: PropTypes.func, - blurhash: PropTypes.string, + backgroundColor: PropTypes.string, + foregroundColor: PropTypes.string, + accentColor: PropTypes.string, }; state = { @@ -169,7 +52,6 @@ class Audio extends React.PureComponent { muted: false, volume: 0.5, dragging: false, - color: { r: 255, g: 255, b: 255 }, }; setPlayerRef = c => { @@ -207,10 +89,6 @@ class Audio extends React.PureComponent { } } - setBlurhashCanvasRef = c => { - this.blurhashCanvas = c; - } - setCanvasRef = c => { this.canvas = c; @@ -222,41 +100,13 @@ class Audio extends React.PureComponent { componentDidMount () { window.addEventListener('scroll', this.handleScroll); window.addEventListener('resize', this.handleResize, { passive: true }); - - if (!this.props.blurhash) { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => this.handlePosterLoad(img); - img.src = this.props.poster; - } else { - this._setColorScheme(); - this._decodeBlurhash(); - } } componentDidUpdate (prevProps, prevState) { - if (prevProps.poster !== this.props.poster && !this.props.blurhash) { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => this.handlePosterLoad(img); - img.src = this.props.poster; - } - - if (prevState.blurhash !== this.state.blurhash || prevProps.blurhash !== this.props.blurhash) { - this._setColorScheme(); - this._decodeBlurhash(); + if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height) { + this._clear(); + this._draw(); } - - this._clear(); - this._draw(); - } - - _decodeBlurhash () { - const context = this.blurhashCanvas.getContext('2d'); - const pixels = decode(this.props.blurhash || this.state.blurhash, 32, 32); - const outputImageData = new ImageData(pixels, 32, 32); - - context.putImageData(outputImageData, 0, 0); } componentWillUnmount () { @@ -425,31 +275,6 @@ class Audio extends React.PureComponent { this.analyser = analyser; } - handlePosterLoad = image => { - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - - canvas.width = image.width; - canvas.height = image.height; - - context.drawImage(image, 0, 0); - - const inputImageData = context.getImageData(0, 0, image.width, image.height); - const blurhash = encode(inputImageData.data, image.width, image.height, 4, 4); - - this.setState({ blurhash }); - } - - _setColorScheme () { - const blurhash = this.props.blurhash || this.state.blurhash; - const averageColor = decodeRGB(decode83(blurhash.slice(2, 6))); - - this.setState({ - color: adjustColor(averageColor), - darkText: luma(averageColor) >= 165, - }); - } - handleDownload = () => { fetch(this.props.src).then(res => res.blob()).then(blob => { const element = document.createElement('a'); @@ -609,8 +434,8 @@ class Audio extends React.PureComponent { const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2); - const mainColor = `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`; - const lastColor = `rgba(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b}, 0)`; + const mainColor = this._getAccentColor(); + const lastColor = hex2rgba(mainColor, 0); gradient.addColorStop(0, mainColor); gradient.addColorStop(0.6, mainColor); @@ -632,17 +457,25 @@ class Audio extends React.PureComponent { return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient())); } - _getColor () { - return `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`; + _getAccentColor () { + return this.props.accentColor || '#ffffff'; + } + + _getBackgroundColor () { + return this.props.backgroundColor || '#000000'; + } + + _getForegroundColor () { + return this.props.foregroundColor || '#ffffff'; } render () { const { src, intl, alt, editable } = this.props; - const { paused, muted, volume, currentTime, duration, buffer, darkText, dragging } = this.state; + const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; const progress = (currentTime / duration) * 100; return ( - <div className={classNames('audio-player', { editable, 'with-light-background': darkText })} ref={this.setPlayerRef} style={{ width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <audio src={src} ref={this.setAudioRef} @@ -654,24 +487,15 @@ class Audio extends React.PureComponent { /> <canvas - className='audio-player__background' - onClick={this.togglePlay} - width='32' - height='32' - style={{ width: this.state.width, height: this.state.height, position: 'absolute', top: 0, left: 0 }} - ref={this.setBlurhashCanvasRef} - aria-label={alt} - title={alt} role='button' - tabIndex='0' - /> - - <canvas className='audio-player__canvas' width={this.state.width} height={this.state.height} - style={{ width: '100%', position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }} + style={{ width: '100%', position: 'absolute', top: 0, left: 0 }} ref={this.setCanvasRef} + onClick={this.togglePlay} + title={alt} + aria-label={alt} /> <img @@ -684,12 +508,12 @@ class Audio extends React.PureComponent { <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}> <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} /> - <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getColor() }} /> + <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} /> <span className={classNames('video-player__seek__handle', { active: dragging })} tabIndex='0' - style={{ left: `${progress}%`, backgroundColor: this._getColor() }} + style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }} /> </div> @@ -700,12 +524,12 @@ class Audio extends React.PureComponent { <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}> - <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getColor() }} /> + <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} /> <span className={classNames('video-player__volume__handle')} tabIndex='0' - style={{ left: `${volume * 100}%`, backgroundColor: this._getColor() }} + style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} /> </div> diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index f7d0c9bd4..b1ae0b2cc 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -126,7 +126,9 @@ class DetailedStatus extends ImmutablePureComponent { alt={attachment.get('description')} duration={attachment.getIn(['meta', 'original', 'duration'], 0)} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} - blurhash={attachment.get('blurhash')} + backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} + foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} + accentColor={attachment.getIn(['meta', 'colors', 'accent'])} height={150} /> ); diff --git a/app/javascript/mastodon/features/ui/components/audio_modal.js b/app/javascript/mastodon/features/ui/components/audio_modal.js index 1d23925ca..dc033434e 100644 --- a/app/javascript/mastodon/features/ui/components/audio_modal.js +++ b/app/javascript/mastodon/features/ui/components/audio_modal.js @@ -61,7 +61,9 @@ export default class AudioModal extends ImmutablePureComponent { duration={media.getIn(['meta', 'original', 'duration'], 0)} height={150} poster={media.get('preview_url') || status.getIn(['account', 'avatar_static'])} - blurhash={media.get('blurhash')} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} /> </div> diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 06d298205..8112e3b9e 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -329,7 +329,9 @@ class FocalPointModal extends ImmutablePureComponent { duration={media.getIn(['meta', 'original', 'duration'], 0)} height={150} poster={media.get('preview_url') || account.get('avatar_static')} - blurhash={media.get('blurhash')} + backgroundColor={media.getIn(['meta', 'colors', 'background'])} + foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} + accentColor={media.getIn(['meta', 'colors', 'accent'])} editable /> )} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 58bc0ff8b..b32247297 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5314,36 +5314,31 @@ a.status-card.compact:hover { .video-player__volume::before, .video-player__seek::before { - background: rgba($white, 0.15); + background: currentColor; + opacity: 0.15; } - &.with-light-background { - color: $black; - - .video-player__volume::before, - .video-player__seek::before { - background: rgba($black, 0.15); - } - - .video-player__seek__buffer { - background: rgba($black, 0.2); - } + .video-player__seek__buffer { + background: currentColor; + opacity: 0.2; + } - .video-player__buttons button { - color: rgba($black, 0.75); + .video-player__buttons button { + color: currentColor; + opacity: 0.75; - &:active, - &:hover, - &:focus { - color: $black; - } + &:active, + &:hover, + &:focus { + color: currentColor; + opacity: 1; } + } - .video-player__time-sep, - .video-player__time-total, - .video-player__time-current { - color: $black; - } + .video-player__time-sep, + .video-player__time-total, + .video-player__time-current { + color: currentColor; } .video-player__seek::before, diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index f67566a18..519711401 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -40,6 +40,13 @@ class MediaAttachment < ApplicationRecord VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze + META_KEYS = %i( + focus + colors + original + small + ).freeze + IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif).freeze VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze @@ -165,7 +172,7 @@ class MediaAttachment < ApplicationRecord has_attached_file :thumbnail, styles: THUMBNAIL_STYLES, - processors: [:lazy_thumbnail, :blurhash_transcoder], + processors: [:lazy_thumbnail, :blurhash_transcoder, :color_extractor], convert_options: GLOBAL_CONVERT_OPTIONS validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES @@ -216,7 +223,7 @@ class MediaAttachment < ApplicationRecord x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f) - meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small) + meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(*META_KEYS) meta['focus'] = { 'x' => x, 'y' => y } file.instance_write(:meta, meta) @@ -338,7 +345,7 @@ class MediaAttachment < ApplicationRecord end def populate_meta - meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small) + meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(*META_KEYS) file.queued_for_write.each do |style, file| meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file) diff --git a/app/views/media/player.html.haml b/app/views/media/player.html.haml index 3d308ee69..1d0374897 100644 --- a/app/views/media/player.html.haml +++ b/app/views/media/player.html.haml @@ -11,6 +11,6 @@ %video{ autoplay: 'autoplay', muted: 'muted', loop: 'loop' } %source{ src: @media_attachment.file.url(:original) } - elsif @media_attachment.audio? - = react_component :audio, src: @media_attachment.file.url(:original), poster: full_asset_url(@media_attachment.account.avatar_static_url), width: 670, height: 380, fullscreen: true, alt: @media_attachment.description, duration: @media_attachment.file.meta.dig(:original, :duration) do + = react_component :audio, src: @media_attachment.file.url(:original), poster: @media_attachment.thumbnail.present? ? @media_attachment.thumbnail.url : @media_attachment.account.avatar_static_url, backgroundColor: @media_attachment.file.meta.dig('colors', 'background'), foregroundColor: @media_attachment.file.meta.dig('colors', 'foreground'), accentColor: @media_attachment.file.meta.dig('colors', 'accent'), width: 670, height: 380, fullscreen: true, alt: @media_attachment.description, duration: @media_attachment.file.meta.dig(:original, :duration) do %audio{ controls: 'controls' } %source{ src: @media_attachment.file.url(:original) } diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index d10017db9..dce122607 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -33,7 +33,7 @@ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.media_attachments.first.audio? - audio = status.media_attachments.first - = react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do + = react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index ab09dfe45..b29e92ddc 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -39,7 +39,7 @@ = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.media_attachments.first.audio? - audio = status.media_attachments.first - = react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do + = react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do diff --git a/app/workers/post_process_media_worker.rb b/app/workers/post_process_media_worker.rb index a904f35b1..24201101c 100644 --- a/app/workers/post_process_media_worker.rb +++ b/app/workers/post_process_media_worker.rb @@ -32,7 +32,7 @@ class PostProcessMediaWorker media_attachment.file.reprocess!(:original) media_attachment.processing = :complete - media_attachment.file_meta = previous_meta.merge(media_attachment.file_meta).with_indifferent_access.slice(:focus, :original, :small) + media_attachment.file_meta = previous_meta.merge(media_attachment.file_meta).with_indifferent_access.slice(*MediaAttachment::META_KEYS) media_attachment.save rescue ActiveRecord::RecordNotFound true |