From 90f12f2e5a41115a9a756f9dd38054736080d4f9 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Thu, 22 Feb 2018 00:35:46 +0100
Subject: [PATCH] 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
---
 app/controllers/api/v1/media_controller.rb    |   2 +-
 app/javascript/images/reticle.png             | Bin 0 -> 3053 bytes
 app/javascript/mastodon/actions/compose.js    |   4 +-
 .../mastodon/components/media_gallery.js      |  71 ++++++++--
 .../features/compose/components/upload.js     |  20 ++-
 .../compose/containers/upload_container.js    |   7 +-
 .../ui/components/focal_point_modal.js        | 122 ++++++++++++++++++
 .../features/ui/components/modal_root.js      |   2 +
 .../mastodon/features/video/index.js          |   6 +-
 app/javascript/mastodon/reducers/compose.js   |   2 +-
 .../styles/mastodon/components.scss           |  69 +++++++++-
 app/lib/activitypub/activity/create.rb        |   2 +-
 app/lib/activitypub/adapter.rb                |   1 +
 app/models/media_attachment.rb                |  20 ++-
 .../activitypub/image_serializer.rb           |   9 ++
 15 files changed, 307 insertions(+), 30 deletions(-)
 create mode 100644 app/javascript/images/reticle.png
 create mode 100644 app/javascript/mastodon/features/ui/components/focal_point_modal.js

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 0000000000000000000000000000000000000000..998994f5c00a37ee9b422af049ba1320e98f1ce9
GIT binary patch
literal 3053
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
ZL7pthq3q3=v{?KBcX#!q7rTTU{ttCeA~OI0

literal 0
HcmV?d00001

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