diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2018-02-22 00:35:46 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-02-22 00:35:46 +0100 |
commit | 90f12f2e5a41115a9a756f9dd38054736080d4f9 (patch) | |
tree | 72af04d32879a08a620e306c3c15e1e2f7985f37 | |
parent | d3a62d263703142250d7d59335394a8e2a599ed4 (diff) |
Focal points (#6520)
* Add focus param to media API, center thumbnails on focus point * Add UI for setting a focal point * Improve focal point icon on upload item * Use focal point in upload preview * Add focalPoint property to ActivityPub * Don't show focal point button for non-image attachments
-rw-r--r-- | app/controllers/api/v1/media_controller.rb | 2 | ||||
-rw-r--r-- | app/javascript/images/reticle.png | bin | 0 -> 3053 bytes | |||
-rw-r--r-- | app/javascript/mastodon/actions/compose.js | 4 | ||||
-rw-r--r-- | app/javascript/mastodon/components/media_gallery.js | 71 | ||||
-rw-r--r-- | app/javascript/mastodon/features/compose/components/upload.js | 20 | ||||
-rw-r--r-- | app/javascript/mastodon/features/compose/containers/upload_container.js | 7 | ||||
-rw-r--r-- | app/javascript/mastodon/features/ui/components/focal_point_modal.js | 122 | ||||
-rw-r--r-- | app/javascript/mastodon/features/ui/components/modal_root.js | 2 | ||||
-rw-r--r-- | app/javascript/mastodon/features/video/index.js | 6 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/compose.js | 2 | ||||
-rw-r--r-- | app/javascript/styles/mastodon/components.scss | 69 | ||||
-rw-r--r-- | app/lib/activitypub/activity/create.rb | 2 | ||||
-rw-r--r-- | app/lib/activitypub/adapter.rb | 1 | ||||
-rw-r--r-- | app/models/media_attachment.rb | 20 | ||||
-rw-r--r-- | app/serializers/activitypub/image_serializer.rb | 9 |
15 files changed, 307 insertions, 30 deletions
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 9f330f0df..d4e6337e7 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController private def media_params - params.permit(:file, :description) + params.permit(:file, :description, :focus) end def file_type_error diff --git a/app/javascript/images/reticle.png b/app/javascript/images/reticle.png new file mode 100644 index 000000000..998994f5c --- /dev/null +++ b/app/javascript/images/reticle.png Binary files differdiff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8a35049b3..1732ff189 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -178,11 +178,11 @@ export function uploadCompose(files) { }; }; -export function changeUploadCompose(id, description) { +export function changeUploadCompose(id, params) { return (dispatch, getState) => { dispatch(changeUploadComposeRequest()); - api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { + api(getState).put(`/api/v1/media/${id}`, params).then(response => { dispatch(changeUploadComposeSuccess(response.data)); }).catch(error => { dispatch(changeUploadComposeFail(id, error)); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index a3ffc45ea..9e1bb77c2 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -12,6 +12,26 @@ const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, }); +const shiftToPoint = (containerToImageRatio, containerSize, imageSize, focusSize, toMinus) => { + const containerCenter = Math.floor(containerSize / 2); + const focusFactor = (focusSize + 1) / 2; + const scaledImage = Math.floor(imageSize / containerToImageRatio); + + let focus = Math.floor(focusFactor * scaledImage); + + if (toMinus) focus = scaledImage - focus; + + let focusOffset = focus - containerCenter; + + const remainder = scaledImage - focus; + const containerRemainder = containerSize - containerCenter; + + if (remainder < containerRemainder) focusOffset -= containerRemainder - remainder; + if (focusOffset < 0) focusOffset = 0; + + return (focusOffset * -100 / containerSize) + '%'; +}; + class Item extends React.PureComponent { static contextTypes = { @@ -24,6 +44,8 @@ class Item extends React.PureComponent { index: PropTypes.number.isRequired, size: PropTypes.number.isRequired, onClick: PropTypes.func.isRequired, + containerWidth: PropTypes.number, + containerHeight: PropTypes.number, }; static defaultProps = { @@ -62,7 +84,7 @@ class Item extends React.PureComponent { } render () { - const { attachment, index, size, standalone } = this.props; + const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props; let width = 50; let height = 100; @@ -116,16 +138,40 @@ class Item extends React.PureComponent { let thumbnail = ''; if (attachment.get('type') === 'image') { - const previewUrl = attachment.get('preview_url'); + 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 originalHeight = attachment.getIn(['meta', 'original', 'height']); const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; - const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; + const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; + + const focusX = attachment.getIn(['meta', 'focus', 'x']); + const focusY = attachment.getIn(['meta', 'focus', 'y']); + const imageStyle = {}; + + if (originalWidth && originalHeight && containerWidth && containerHeight && focusX && focusY) { + const widthRatio = originalWidth / (containerWidth * (width / 100)); + const heightRatio = originalHeight / (containerHeight * (height / 100)); + + let hShift = 0; + let vShift = 0; + + if (widthRatio > heightRatio) { + hShift = shiftToPoint(heightRatio, (containerWidth * (width / 100)), originalWidth, focusX); + } else if(widthRatio < heightRatio) { + vShift = shiftToPoint(widthRatio, (containerHeight * (height / 100)), originalHeight, focusY, true); + } + + imageStyle.top = vShift; + imageStyle.left = hShift; + } else { + imageStyle.height = '100%'; + } thumbnail = ( <a @@ -134,7 +180,14 @@ class Item extends React.PureComponent { onClick={this.handleClick} target='_blank' > - <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> + <img + src={previewUrl} + srcSet={srcSet} + sizes={sizes} + alt={attachment.get('description')} + title={attachment.get('description')} + style={imageStyle} + /> </a> ); } else if (attachment.get('type') === 'gifv') { @@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent { } handleRef = (node) => { - if (node && this.isStandaloneEligible()) { + if (node /*&& this.isStandaloneEligible()*/) { // offsetWidth triggers a layout, so only calculate when we need to this.setState({ width: node.offsetWidth, @@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent { if (this.isStandaloneEligible()) { children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />; } else { - children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} containerWidth={width} containerHeight={height} />); } } return ( - <div className='media-gallery' style={style}> + <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> diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index 3a3d17710..61b2d19e0 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -1,15 +1,13 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import IconButton from '../../../components/icon_button'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; const messages = defineMessages({ - undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, }); @@ -21,6 +19,7 @@ export default class Upload extends ImmutablePureComponent { intl: PropTypes.object.isRequired, onUndo: PropTypes.func.isRequired, onDescriptionChange: PropTypes.func.isRequired, + onOpenFocalPoint: PropTypes.func.isRequired, }; state = { @@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent { this.props.onUndo(this.props.media.get('id')); } + handleFocalPointClick = () => { + this.props.onOpenFocalPoint(this.props.media.get('id')); + } + handleInputChange = e => { this.setState({ dirtyDescription: e.target.value }); } @@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent { const { intl, media } = this.props; const active = this.state.hovered || this.state.focused; const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; return ( <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> {({ scale }) => ( - <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> - <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> + <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> + <div className={classNames('compose-form__upload__actions', { active })}> + <button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button> + {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>} + </div> <div className={classNames('compose-form__upload-description', { active })}> <label> diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js index ca9c3b704..d6b57e5ff 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_container.js @@ -1,6 +1,7 @@ import { connect } from 'react-redux'; import Upload from '../components/upload'; import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; +import { openModal } from '../../../actions/modal'; const mapStateToProps = (state, { id }) => ({ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), @@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({ }, onDescriptionChange: (id, description) => { - dispatch(changeUploadCompose(id, description)); + dispatch(changeUploadCompose(id, { description })); + }, + + onOpenFocalPoint: id => { + dispatch(openModal('FOCAL_POINT', { id })); }, }); diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js new file mode 100644 index 000000000..ee5c791d4 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -0,0 +1,122 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; +import ImageLoader from './image_loader'; +import classNames from 'classnames'; +import { changeUploadCompose } from '../../../actions/compose'; +import { getPointerPosition } from '../../video'; + +const mapStateToProps = (state, { id }) => ({ + media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), +}); + +const mapDispatchToProps = (dispatch, { id }) => ({ + + onSave: (x, y) => { + dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` })); + }, + +}); + +@connect(mapStateToProps, mapDispatchToProps) +export default class FocalPointModal extends ImmutablePureComponent { + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + }; + + state = { + x: 0, + y: 0, + focusX: 0, + focusY: 0, + dragging: false, + }; + + componentWillMount () { + this.updatePositionFromMedia(this.props.media); + } + + componentWillReceiveProps (nextProps) { + if (this.props.media.get('id') !== nextProps.media.get('id')) { + this.updatePositionFromMedia(nextProps.media); + } + } + + componentWillUnmount () { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + } + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + + this.updatePosition(e); + this.setState({ dragging: true }); + } + + handleMouseMove = e => { + this.updatePosition(e); + } + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + this.setState({ dragging: false }); + this.props.onSave(this.state.focusX, this.state.focusY); + } + + updatePosition = e => { + const { x, y } = getPointerPosition(this.node, e); + const focusX = (x - .5) * 2; + const focusY = (y - .5) * -2; + + this.setState({ x, y, focusX, focusY }); + } + + updatePositionFromMedia = media => { + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + + if (focusX && focusY) { + const x = (focusX / 2) + .5; + const y = (focusY / -2) + .5; + + this.setState({ x, y, focusX, focusY }); + } else { + this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 }); + } + } + + setRef = c => { + this.node = c; + } + + render () { + const { media } = this.props; + const { x, y, dragging } = this.state; + + const width = media.getIn(['meta', 'original', 'width']) || null; + const height = media.getIn(['meta', 'original', 'height']) || null; + + return ( + <div className='modal-root__modal media-modal'> + <div className={classNames('media-modal__content focal-point', { dragging })} ref={this.setRef}> + <ImageLoader + previewSrc={media.get('preview_url')} + src={media.get('url')} + width={width} + height={height} + /> + + <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> + <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 5839ba40a..20bf21153 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -8,6 +8,7 @@ import MediaModal from './media_modal'; import VideoModal from './video_modal'; import BoostModal from './boost_modal'; import ConfirmationModal from './confirmation_modal'; +import FocalPointModal from './focal_point_modal'; import { OnboardingModal, MuteModal, @@ -27,6 +28,7 @@ const MODAL_COMPONENTS = { 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, 'LIST_EDITOR': ListEditor, + 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 6335d84b6..c81a5cb5f 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -30,7 +30,7 @@ const formatTime = secondsNum => { return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`; }; -const findElementPosition = el => { +export const findElementPosition = el => { let box; if (el.getBoundingClientRect && el.parentNode) { @@ -61,7 +61,7 @@ const findElementPosition = el => { }; }; -const getPointerPosition = (el, event) => { +export const getPointerPosition = (el, event) => { const position = {}; const box = findElementPosition(el); const boxW = el.offsetWidth; @@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => { pageY = event.changedTouches[0].pageY; } - position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH)); + position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH)); position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); return position; diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 186134b1a..1358fb4aa 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -265,7 +265,7 @@ export default function compose(state = initialState, action) { .set('is_submitting', false) .update('media_attachments', list => list.map(item => { if (item.get('id') === action.media.id) { - return item.set('description', action.media.description); + return fromJS(action.media); } return item; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 6a256c466..cff7078aa 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -433,6 +433,34 @@ min-width: 40%; margin: 5px; + &__actions { + background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent); + display: flex; + align-items: flex-start; + justify-content: space-between; + opacity: 0; + transition: opacity .1s ease; + + .icon-button { + flex: 0 1 auto; + color: $ui-secondary-color; + font-size: 14px; + font-weight: 500; + padding: 10px; + font-family: inherit; + + &:hover, + &:focus, + &:active { + color: lighten($ui-secondary-color, 4%); + } + } + + &.active { + opacity: 1; + } + } + &-description { position: absolute; z-index: 2; @@ -470,10 +498,6 @@ opacity: 1; } } - - .icon-button { - mix-blend-mode: difference; - } } .compose-form__upload-thumbnail { @@ -481,8 +505,9 @@ background-position: center; background-size: cover; background-repeat: no-repeat; - height: 100px; + height: 140px; width: 100%; + overflow: hidden; } } @@ -4133,8 +4158,12 @@ a.status-card { &, img { width: 100%; - height: 100%; + } + + img { + position: relative; object-fit: cover; + height: auto; } } @@ -4842,3 +4871,31 @@ noscript { margin-bottom: 0; } } + +.focal-point { + position: relative; + cursor: pointer; + overflow: hidden; + + &.dragging { + cursor: move; + } + + &__reticle { + position: absolute; + width: 100px; + height: 100px; + transform: translate(-50%, -50%); + background: url('../images/reticle.png') no-repeat 0 0; + border-radius: 50%; + box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35); + } + + &__overlay { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + } +} diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 64c429420..a7afbb859 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity next if unsupported_media_type?(attachment['mediaType']) || 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) + media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint']) media_attachments << media_attachment next if skip_download? diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 90d589d90..8198ac580 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base 'conversation' => 'ostatus:conversation', 'toot' => 'http://joinmastodon.org/ns#', 'Emoji' => 'toot:Emoji', + 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' }, }, ], }.freeze diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 38f88e9f7..a4d9cd9d1 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -91,6 +91,24 @@ class MediaAttachment < ApplicationRecord shortcode end + def focus=(point) + return if point.blank? + + x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f) + + meta = file.instance_read(:meta) || {} + meta['focus'] = { 'x' => x, 'y' => y } + + file.instance_write(:meta, meta) + end + + def focus + x = file.meta['focus']['x'] + y = file.meta['focus']['y'] + + "#{x},#{y}" + end + before_create :prepare_description, unless: :local? before_create :set_shortcode before_post_process :set_type_and_extension @@ -168,7 +186,7 @@ class MediaAttachment < ApplicationRecord end def populate_meta - meta = {} + meta = file.instance_read(:meta) || {} file.queued_for_write.each do |style, file| meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file) diff --git a/app/serializers/activitypub/image_serializer.rb b/app/serializers/activitypub/image_serializer.rb index a015c6b1b..a3ac637b6 100644 --- a/app/serializers/activitypub/image_serializer.rb +++ b/app/serializers/activitypub/image_serializer.rb @@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer include RoutingHelper attributes :type, :media_type, :url + attribute :focal_point, if: :focal_point? def type 'Image' @@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer def media_type object.content_type end + + def focal_point? + object.responds_to?(:meta) && object.meta['focus'].is_a?(Hash) + end + + def focal_point + [object.meta['focus']['x'], object.meta['focus']['y']] + end end |