Crop avatars with Imgproxy (#2074)
* cropPhoto mutation, crop avatars with Imgproxy * cropjob logging, conditional uploads url * comment typo * use public Imgproxy URL to re-upload cropped pic * fix avatar in dev --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
c33b62abb4
commit
b6f6cc821c
@ -85,6 +85,7 @@ IMGPROXY_READ_TIMEOUT=10
|
|||||||
IMGPROXY_WRITE_TIMEOUT=10
|
IMGPROXY_WRITE_TIMEOUT=10
|
||||||
IMGPROXY_DOWNLOAD_TIMEOUT=9
|
IMGPROXY_DOWNLOAD_TIMEOUT=9
|
||||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
|
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
|
||||||
|
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
|
||||||
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
|
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
|
||||||
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
|
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import assertApiKeyNotPermitted from './apiKey'
|
|||||||
import { hashEmail } from '@/lib/crypto'
|
import { hashEmail } from '@/lib/crypto'
|
||||||
import { isMuted } from '@/lib/user'
|
import { isMuted } from '@/lib/user'
|
||||||
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
||||||
|
import { processCrop } from '@/worker/imgproxy'
|
||||||
|
|
||||||
const contributors = new Set()
|
const contributors = new Set()
|
||||||
|
|
||||||
@ -727,6 +728,18 @@ export default {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
cropPhoto: async (parent, { photoId, cropData }, { me, models }) => {
|
||||||
|
if (!me) {
|
||||||
|
throw new GqlAuthenticationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const croppedUrl = await processCrop({ photoId: Number(photoId), cropData })
|
||||||
|
if (!croppedUrl) {
|
||||||
|
throw new GqlInputError('can\'t crop photo')
|
||||||
|
}
|
||||||
|
|
||||||
|
return croppedUrl
|
||||||
|
},
|
||||||
setPhoto: async (parent, { photoId }, { me, models }) => {
|
setPhoto: async (parent, { photoId }, { me, models }) => {
|
||||||
if (!me) {
|
if (!me) {
|
||||||
throw new GqlAuthenticationError()
|
throw new GqlAuthenticationError()
|
||||||
|
@ -29,9 +29,20 @@ export default gql`
|
|||||||
users: [User!]!
|
users: [User!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input CropData {
|
||||||
|
x: Float!
|
||||||
|
y: Float!
|
||||||
|
width: Float!
|
||||||
|
height: Float!
|
||||||
|
originalWidth: Int!
|
||||||
|
originalHeight: Int!
|
||||||
|
scale: Float!
|
||||||
|
}
|
||||||
|
|
||||||
extend type Mutation {
|
extend type Mutation {
|
||||||
setName(name: String!): String
|
setName(name: String!): String
|
||||||
setSettings(settings: SettingsInput!): User
|
setSettings(settings: SettingsInput!): User
|
||||||
|
cropPhoto(photoId: ID!, cropData: CropData): String!
|
||||||
setPhoto(photoId: ID!): Int!
|
setPhoto(photoId: ID!): Int!
|
||||||
upsertBio(text: String!): ItemPaidAction!
|
upsertBio(text: String!): ItemPaidAction!
|
||||||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||||
|
@ -6,12 +6,18 @@ import EditImage from '@/svgs/image-edit-fill.svg'
|
|||||||
import Moon from '@/svgs/moon-fill.svg'
|
import Moon from '@/svgs/moon-fill.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { FileUpload } from './file-upload'
|
import { FileUpload } from './file-upload'
|
||||||
|
import { gql, useMutation } from '@apollo/client'
|
||||||
|
|
||||||
export default function Avatar ({ onSuccess }) {
|
export default function Avatar ({ onSuccess }) {
|
||||||
|
const [cropPhoto] = useMutation(gql`
|
||||||
|
mutation cropPhoto($photoId: ID!, $cropData: CropData) {
|
||||||
|
cropPhoto(photoId: $photoId, cropData: $cropData)
|
||||||
|
}
|
||||||
|
`)
|
||||||
const [uploading, setUploading] = useState()
|
const [uploading, setUploading] = useState()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
|
||||||
const Body = ({ onClose, file, upload }) => {
|
const Body = ({ onClose, file, onSave }) => {
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const ref = useRef()
|
const ref = useRef()
|
||||||
|
|
||||||
@ -34,13 +40,21 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
/>
|
/>
|
||||||
</BootstrapForm.Group>
|
</BootstrapForm.Group>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
const rect = ref.current.getCroppingRect()
|
||||||
if (blob) {
|
const img = new window.Image()
|
||||||
upload(blob)
|
img.onload = async () => {
|
||||||
onClose()
|
const cropData = {
|
||||||
|
...rect,
|
||||||
|
originalWidth: img.width,
|
||||||
|
originalHeight: img.height,
|
||||||
|
scale
|
||||||
}
|
}
|
||||||
}, 'image/jpeg')
|
// upload original to S3 along with crop data
|
||||||
|
await onSave(cropData)
|
||||||
|
}
|
||||||
|
img.src = URL.createObjectURL(file)
|
||||||
|
onClose()
|
||||||
}}
|
}}
|
||||||
>save
|
>save
|
||||||
</Button>
|
</Button>
|
||||||
@ -48,6 +62,45 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startCrop = async (file, upload) => {
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
showModal(onClose => (
|
||||||
|
<Body
|
||||||
|
onClose={() => {
|
||||||
|
onClose()
|
||||||
|
resolve()
|
||||||
|
}}
|
||||||
|
file={file}
|
||||||
|
onSave={async (cropData) => {
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
// upload original to S3
|
||||||
|
const photoId = await upload(file)
|
||||||
|
|
||||||
|
// crop it
|
||||||
|
const { data } = await cropPhoto({ variables: { photoId, cropData } })
|
||||||
|
const res = await fetch(data.cropPhoto)
|
||||||
|
const blob = await res.blob()
|
||||||
|
|
||||||
|
// create a file from the blob
|
||||||
|
const croppedImage = new File([blob], 'avatar.jpg', { type: 'image/jpeg' })
|
||||||
|
|
||||||
|
// upload the imgproxy cropped image
|
||||||
|
const croppedPhotoId = await upload(croppedImage)
|
||||||
|
|
||||||
|
onSuccess?.(croppedPhotoId)
|
||||||
|
setUploading(false)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setUploading(false)
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileUpload
|
<FileUpload
|
||||||
allow='image/*'
|
allow='image/*'
|
||||||
@ -56,26 +109,7 @@ export default function Avatar ({ onSuccess }) {
|
|||||||
console.log(e)
|
console.log(e)
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}}
|
}}
|
||||||
onSelect={(file, upload) => {
|
onSelect={startCrop}
|
||||||
return new Promise((resolve, reject) =>
|
|
||||||
showModal(onClose => (
|
|
||||||
<Body
|
|
||||||
onClose={() => {
|
|
||||||
onClose()
|
|
||||||
resolve()
|
|
||||||
}}
|
|
||||||
file={file}
|
|
||||||
upload={async (blob) => {
|
|
||||||
await upload(blob)
|
|
||||||
resolve(blob)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)))
|
|
||||||
}}
|
|
||||||
onSuccess={({ id }) => {
|
|
||||||
onSuccess?.(id)
|
|
||||||
setUploading(false)
|
|
||||||
}}
|
|
||||||
onUpload={() => {
|
onUpload={() => {
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
}}
|
}}
|
||||||
|
@ -28,9 +28,11 @@ import { hexToBech32 } from '@/lib/nostr'
|
|||||||
import NostrIcon from '@/svgs/nostr.svg'
|
import NostrIcon from '@/svgs/nostr.svg'
|
||||||
import GithubIcon from '@/svgs/github-fill.svg'
|
import GithubIcon from '@/svgs/github-fill.svg'
|
||||||
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
||||||
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import ItemPopover from './item-popover'
|
import ItemPopover from './item-popover'
|
||||||
|
|
||||||
|
const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
|
||||||
|
|
||||||
export default function UserHeader ({ user }) {
|
export default function UserHeader ({ user }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
@ -185,3 +185,30 @@ const sign = (target) => {
|
|||||||
hmac.update(target)
|
hmac.update(target)
|
||||||
return hmac.digest('base64url')
|
return hmac.digest('base64url')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function processCrop ({ photoId, cropData }) {
|
||||||
|
const { x, y, width, height, originalWidth, originalHeight, scale } = cropData
|
||||||
|
const cropWidth = Math.round(originalWidth * width)
|
||||||
|
const cropHeight = Math.round(originalHeight * height)
|
||||||
|
|
||||||
|
const centerX = x + width / scale
|
||||||
|
const centerY = y + height / scale
|
||||||
|
|
||||||
|
const size = 200 // 200px avatar size
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
`/crop:${cropWidth}:${cropHeight}`,
|
||||||
|
`/gravity:fp:${centerX}:${centerY}`,
|
||||||
|
`/rs:fill:${size}:${size}`
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const uploadsUrl = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL
|
||||||
|
const url = uploadsUrl + `/${photoId}`
|
||||||
|
console.log('[imgproxy - cropjob] id:', photoId, '-- url:', url)
|
||||||
|
|
||||||
|
const pathname = '/'
|
||||||
|
const path = createImgproxyPath({ url, pathname, options })
|
||||||
|
const publicImgproxyUrl = process.env.NEXT_PUBLIC_IMGPROXY_URL
|
||||||
|
|
||||||
|
return new URL(path, publicImgproxyUrl).toString()
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user