get dimensions for video and refactor images (#1366)

* get dimensions for video and refactor images

* improve rendering performance

* more rendering perf enhancements
This commit is contained in:
Keyan 2024-09-06 09:34:44 -05:00 committed by GitHub
parent 3f0499b96e
commit 2f546facb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 394 additions and 392 deletions

View File

@ -78,6 +78,7 @@ IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION=200
IMGPROXY_READ_TIMEOUT=10
IMGPROXY_WRITE_TIMEOUT=10
IMGPROXY_DOWNLOAD_TIMEOUT=9
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
# IMGPROXY_ENABLE_DEBUG_HEADERS=true

View File

@ -5,7 +5,7 @@ import BootstrapForm from 'react-bootstrap/Form'
import EditImage from '@/svgs/image-edit-fill.svg'
import Moon from '@/svgs/moon-fill.svg'
import { useShowModal } from './modal'
import { ImageUpload } from './image'
import { ImageUpload } from './image-upload'
export default function Avatar ({ onSuccess }) {
const [uploading, setUploading] = useState()

View File

@ -23,7 +23,7 @@ import textAreaCaret from 'textarea-caret'
import ReactDatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import useDebounceCallback, { debounce } from './use-debounce-callback'
import { ImageUpload } from './image'
import { ImageUpload } from './image-upload'
import { AWS_S3_URL_REGEXP } from '@/lib/constants'
import { whenRange } from '@/lib/time'
import { useFeeButton } from './fee-button'

View File

@ -1,197 +1,10 @@
import styles from './text.module.css'
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef, memo } from 'react'
import { IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
import { useShowModal } from './modal'
import { useMe } from './me'
import { Dropdown } from 'react-bootstrap'
import { UNKNOWN_LINK_REL, UPLOAD_TYPES_ALLOW, MEDIA_URL } from '@/lib/constants'
import { Fragment, useCallback, forwardRef, useRef } from 'react'
import { UPLOAD_TYPES_ALLOW, MEDIA_URL } from '@/lib/constants'
import { useToast } from './toast'
import gql from 'graphql-tag'
import { useMutation } from '@apollo/client'
import piexif from 'piexifjs'
export function decodeOriginalUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/')
// base64url is not a known encoding in browsers
// so we need to replace the invalid chars
const b64Url = parts[parts.length - 1].replace(/-/g, '+').replace(/_/, '/')
const originalUrl = Buffer.from(b64Url, 'base64').toString('utf-8')
return originalUrl
}
function LinkRaw ({ href, children, src, rel, onClick, ...props }) {
const isRawURL = /^https?:\/\//.test(children?.[0])
return (
// eslint-disable-next-line
<a
target='_blank'
rel={rel ?? UNKNOWN_LINK_REL}
href={src}
>{isRawURL || !children ? src : children}
</a>
)
}
function ImageOriginal ({ src, topLevel, tab, onClick, ...props }) {
const me = useMe()
const [showImage, setShowImage] = useState(false)
const [showVideo, setShowVideo] = useState(false)
useEffect(() => {
// make sure it's not a false negative by trying to load URL as <img>
const img = new window.Image()
img.onload = () => setShowImage(true)
img.src = src
const video = document.createElement('video')
video.onloadeddata = () => setShowVideo(true)
video.src = src
return () => {
img.onload = null
img.src = ''
video.onloadeddata = null
video.src = ''
}
}, [src, showImage])
const showMedia = (tab === 'preview' || (me?.privates?.showImagesAndVideos !== false && !me?.privates?.imgproxyOnly))
if (showImage && showMedia) {
return (
<img
className={topLevel ? styles.topLevel : undefined}
src={src}
onClick={() => onClick(src)}
onError={() => setShowImage(false)}
/>
)
} else if (showVideo && showMedia) {
return <video src={src} controls />
} else {
// user is not okay with loading original url automatically or there was an error loading the image
// If element parsed by markdown is a raw URL, we use src as the text to not mislead users.
// This will not be the case if [text](url) format is used. Then we will show what was chosen as text.
return <LinkRaw src={src} {...props} />
}
}
function TrustedImage ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) {
const srcSet = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return undefined
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => {
// backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths
if (!url.startsWith('http')) {
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
}
return acc + `${url} ${wDescriptor}` + (i < arr.length - 1 ? ', ' : '')
}, '')
}, [srcSetObj])
const sizes = srcSet ? `${(topLevel ? 100 : 66)}vw` : undefined
// get source url in best resolution
const bestResSrc = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return src
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
if (!url.startsWith('http')) {
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
}
const w = Number(wDescriptor.replace(/w$/, ''))
return w > acc.w ? { w, url } : acc
}, { w: 0, url: undefined }).url
}, [srcSetObj])
const handleError = useCallback(onError, [onError])
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
return (
<Image
className={topLevel ? styles.topLevel : undefined}
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
src={bestResSrc}
srcSet={srcSet}
sizes={sizes}
width={dimensions?.width}
height={dimensions?.height}
onClick={handleClick}
onError={handleError}
/>
)
}
const Image = memo(({ className, src, srcSet, sizes, width, height, onClick, onError }) => {
const style = width && height
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width} / ${height}` }
: undefined
return (
<img
className={className}
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
src={src}
srcSet={srcSet}
sizes={sizes}
width={width}
height={height}
onClick={onClick}
onError={onError}
style={style}
/>
)
})
export default function ZoomableImage ({ src, srcSet, ...props }) {
const showModal = useShowModal()
const me = useMe()
// if `srcSet` is falsy, it means the image was not processed by worker yet
const [trustedDomain, setTrustedDomain] = useState(!!srcSet || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src))
// backwards compatibility:
// src may already be imgproxy url since we used to replace image urls with imgproxy urls
const originalUrl = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
const handleClick = useCallback((src) => showModal(close => {
return (
<div
className={styles.fullScreenContainer}
onClick={close}
>
<img className={styles.fullScreen} src={src} />
</div>
)
}, {
fullScreen: true,
overflow: (
<Dropdown.Item
href={originalUrl} target='_blank'
rel={props.rel ?? UNKNOWN_LINK_REL}
>
open original
</Dropdown.Item>)
}), [showModal, originalUrl, styles])
const handleError = useCallback(() => setTrustedDomain(false), [setTrustedDomain])
if (!src) return null
if (trustedDomain) {
if (props.tab === 'preview' || !me || me.privates.showImagesAndVideos) {
return (
<TrustedImage
src={src} srcSet={srcSet}
onClick={handleClick} onError={handleError} {...props}
/>
)
} else {
return <LinkRaw src={src} onClick={handleClick} {...props} />
}
}
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
}
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
const toaster = useToast()
ref ??= useRef(null)

View File

@ -3,7 +3,7 @@ import ItemJob from './item-job'
import Reply from './reply'
import Comment from './comment'
import Text, { SearchText } from './text'
import ZoomableImage from './image'
import MediaOrLink from './media-or-link'
import Comments from './comments'
import styles from '@/styles/item.module.css'
import itemStyles from './item.module.css'
@ -131,7 +131,7 @@ function ItemEmbed ({ item }) {
}
if (item.url?.match(IMGPROXY_URL_REGEXP)) {
return <ZoomableImage src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
return <MediaOrLink src={item.url} rel={item.rel ?? UNKNOWN_LINK_REL} />
}
return null

170
components/media-or-link.js Normal file
View File

@ -0,0 +1,170 @@
import styles from './text.module.css'
import { useState, useEffect, useMemo, useCallback, memo } from 'react'
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
import { useShowModal } from './modal'
import { useMe } from './me'
import { Dropdown } from 'react-bootstrap'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
function LinkRaw ({ href, children, src, rel }) {
const isRawURL = /^https?:\/\//.test(children?.[0])
return (
// eslint-disable-next-line
<a
target='_blank'
rel={rel ?? UNKNOWN_LINK_REL}
href={src}
>{isRawURL || !children ? src : children}
</a>
)
}
const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, height, onClick, onError, style, className, video }) {
return (
<div className={classNames(className, styles.mediaContainer)} style={style}>
{video
? <video
src={src}
preload={bestResSrc !== src ? 'metadata' : undefined}
controls
poster={bestResSrc !== src ? bestResSrc : undefined}
width={width}
height={height}
onError={onError}
/>
: <img
src={src}
srcSet={srcSet}
sizes={sizes}
width={width}
height={height}
onClick={onClick}
onError={onError}
/>}
</div>
)
})
export default function MediaOrLink (props) {
const media = useMediaHelper(props)
const [error, setError] = useState(false)
const showModal = useShowModal()
const handleClick = useCallback(() => showModal(close => {
return (
<div
className={styles.fullScreenContainer}
onClick={close}
>
<img className={styles.fullScreen} src={media.bestResSrc} />
</div>
)
}, {
fullScreen: true,
overflow: (
<Dropdown.Item
href={media.originalSrc} target='_blank'
rel={props.rel ?? UNKNOWN_LINK_REL}
>
open original
</Dropdown.Item>)
}), [showModal, media.originalSrc, styles, media.bestResSrc])
const handleError = useCallback((err) => {
console.error('Error loading media', err)
setError(true)
}, [setError])
if (!media.src) return null
if (!error && (media.image || media.video)) {
return (
<Media
{...media} onClick={handleClick} onError={handleError}
/>
)
}
return <LinkRaw {...props} />
}
// determines how the media should be displayed given the params, me settings, and editor tab
const useMediaHelper = ({ src, srcSet: { dimensions, video, ...srcSetObj } = {}, topLevel, tab }) => {
const me = useMe()
const trusted = useMemo(() => !!srcSetObj || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetObj, src])
const [isImage, setIsImage] = useState(!video && trusted)
const [isVideo, setIsVideo] = useState(video)
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
useEffect(() => {
// don't load the video at all if use doesn't want these
if (!showMedia || isVideo || isImage) return
// make sure it's not a false negative by trying to load URL as <img>
const img = new window.Image()
img.onload = () => setIsImage(true)
img.src = src
const video = document.createElement('video')
video.onloadeddata = () => setIsVideo(true)
video.src = src
return () => {
img.onload = null
img.src = ''
video.onloadeddata = null
video.src = ''
}
}, [src, setIsImage, setIsVideo, showMedia, isVideo])
const srcSet = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return undefined
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => {
// backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths
if (!url.startsWith('http')) {
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
}
return acc + `${url} ${wDescriptor}` + (i < arr.length - 1 ? ', ' : '')
}, '')
}, [srcSetObj])
const sizes = useMemo(() => srcSet ? `${(topLevel ? 100 : 66)}vw` : undefined)
// get source url in best resolution
const bestResSrc = useMemo(() => {
if (Object.keys(srcSetObj).length === 0) return src
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
if (!url.startsWith('http')) {
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
}
const w = Number(wDescriptor.replace(/w$/, ''))
return w > acc.w ? { w, url } : acc
}, { w: 0, url: undefined }).url
}, [srcSetObj])
const [style, width, height] = useMemo(() => {
if (dimensions) {
const { width, height } = dimensions
const style = {
'--height': `${height}px`,
'--width': `${width}px`,
'--aspect-ratio': `${width} / ${height}`
}
return [style, width, height]
}
return []
}, [dimensions?.width, dimensions?.height])
return {
src,
srcSet,
originalSrc: IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src,
sizes,
bestResSrc,
style,
width,
height,
className: classNames(topLevel && styles.topLevel),
image: (!me?.privates?.imgproxyOnly || trusted) && showMedia && isImage && !isVideo,
video: !me?.privates?.imgproxyOnly && showMedia && isVideo
}
}

View File

@ -12,8 +12,8 @@ import LinkIcon from '@/svgs/link.svg'
import Thumb from '@/svgs/thumb-up-fill.svg'
import { toString } from 'mdast-util-to-string'
import copy from 'clipboard-copy'
import ZoomableImage, { decodeOriginalUrl } from './image'
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl } from '@/lib/url'
import MediaOrLink from './media-or-link'
import { IMGPROXY_URL_REGEXP, parseInternalLinks, parseEmbedUrl, decodeProxyUrl } from '@/lib/url'
import reactStringReplace from 'react-string-replace'
import { rehypeInlineCodeProperty, rehypeStyler } from '@/lib/md'
import { Button } from 'react-bootstrap'
@ -82,7 +82,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
}
}, [containerRef.current, setOverflowing])
const slugger = new GithubSlugger()
const slugger = useMemo(() => new GithubSlugger(), [])
const Heading = useCallback(({ children, node, ...props }) => {
const [copied, setCopied] = useState(false)
@ -120,7 +120,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</a>}
</span>
)
}, [topLevel, noFragments, slugger.current])
}, [topLevel, noFragments, slugger])
const Table = useCallback(({ node, ...props }) =>
<span className='table-responsive'>
@ -143,181 +143,186 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
const P = useCallback(({ children, node, ...props }) => <div className={styles.p} {...props}>{children}</div>, [])
const Img = useCallback(({ node, src, ...props }) => {
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
// if outlawed, render the image link as text
const TextMediaOrLink = useCallback(({ node, src, ...props }) => {
const url = IMGPROXY_URL_REGEXP.test(src) ? decodeProxyUrl(src) : src
// if outlawed, render the media link as text
if (outlawed) {
return url
}
const srcSet = imgproxyUrls?.[url]
return <ZoomableImage srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
}, [imgproxyUrls, topLevel, tab])
const components = useMemo(() => ({
h1: Heading,
h2: Heading,
h3: Heading,
h4: Heading,
h5: Heading,
h6: Heading,
table: Table,
p: P,
li: props => {
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
},
code: Code,
a: ({ node, href, children, ...props }) => {
children = children ? Array.isArray(children) ? children : [children] : []
// don't allow zoomable images to be wrapped in links
if (children.some(e => e?.props?.node?.tagName === 'img')) {
return <>{children}</>
}
// if outlawed, render the link as text
if (outlawed) {
return href
}
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
// we don't render it as an image since it was probably a conscious choice to include text.
const text = children[0]
let url
try {
url = !href.startsWith('/') && new URL(href)
} catch {
// ignore invalid URLs
}
const internalURL = process.env.NEXT_PUBLIC_URL
if (!!text && !/^https?:\/\//.test(text)) {
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
return (
<Link
{...props}
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
href={itemId ? `${href}-${itemId}` : href}
>{text}
</Link>
)
}
if (text.startsWith?.('@')) {
// user mention might be within a markdown link like this: [@user foo bar](url)
const name = text.replace('@', '').split(' ')[0]
return (
<UserPopover name={name}>
<Link
id={props.id}
href={href}
>
{text}
</Link>
</UserPopover>
)
} else if (href.startsWith('/') || url?.origin === internalURL) {
try {
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
<Link href={href}>{text}</Link>
</ItemPopover>
)
}
} catch {
// ignore errors like invalid URLs
}
return (
<Link
id={props.id}
href={href}
>
{text}
</Link>
)
}
return (
// eslint-disable-next-line
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
)
}
try {
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
<Link href={href}>{linkText}</Link>
</ItemPopover>
)
}
} catch {
// ignore errors like invalid URLs
}
const videoWrapperStyles = {
maxWidth: topLevel ? '640px' : '320px',
margin: '0.5rem 0',
paddingRight: '15px'
}
const { provider, id, meta } = parseEmbedUrl(href)
// Youtube video embed
if (provider === 'youtube') {
return (
<div style={videoWrapperStyles}>
<YouTube
videoId={id} className={styles.videoContainer} opts={{
playerVars: {
start: meta?.start || 0
}
}}
/>
</div>
)
}
// Rumble video embed
if (provider === 'rumble') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='Rumble Video'
allowFullScreen
src={meta?.href}
sandbox='allow-scripts'
/>
</div>
</div>
)
}
if (provider === 'peertube') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='PeerTube Video'
allowFullScreen
src={meta?.href}
sandbox='allow-scripts'
/>
</div>
</div>
)
}
// assume the link is an image which will fallback to link if it's not
return <TextMediaOrLink src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</TextMediaOrLink>
},
img: TextMediaOrLink
}), [outlawed, rel, topLevel, itemId, Code, P, Heading, Table, TextMediaOrLink])
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
return (
<div className={`${styles.text} ${show ? styles.textUncontained : overflowing ? styles.textContained : ''}`} ref={containerRef}>
<ReactMarkdown
components={{
h1: Heading,
h2: Heading,
h3: Heading,
h4: Heading,
h5: Heading,
h6: Heading,
table: Table,
p: P,
li: props => {
return <li {...props} id={props.id && itemId ? `${props.id}-${itemId}` : props.id} />
},
code: Code,
a: ({ node, href, children, ...props }) => {
children = children ? Array.isArray(children) ? children : [children] : []
// don't allow zoomable images to be wrapped in links
if (children.some(e => e?.props?.node?.tagName === 'img')) {
return <>{children}</>
}
// if outlawed, render the link as text
if (outlawed) {
return href
}
// If [text](url) was parsed as <a> and text is not empty and not a link itself,
// we don't render it as an image since it was probably a conscious choice to include text.
const text = children[0]
let url
try {
url = !href.startsWith('/') && new URL(href)
} catch {
// ignore invalid URLs
}
const internalURL = process.env.NEXT_PUBLIC_URL
if (!!text && !/^https?:\/\//.test(text)) {
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
return (
<Link
{...props}
id={props.id && itemId ? `${props.id}-${itemId}` : props.id}
href={itemId ? `${href}-${itemId}` : href}
>{text}
</Link>
)
}
if (text.startsWith?.('@')) {
// user mention might be within a markdown link like this: [@user foo bar](url)
const name = text.replace('@', '').split(' ')[0]
return (
<UserPopover name={name}>
<Link
id={props.id}
href={href}
>
{text}
</Link>
</UserPopover>
)
} else if (href.startsWith('/') || url?.origin === internalURL) {
try {
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
<Link href={href}>{text}</Link>
</ItemPopover>
)
}
} catch {
// ignore errors like invalid URLs
}
return (
<Link
id={props.id}
href={href}
>
{text}
</Link>
)
}
return (
// eslint-disable-next-line
<a id={props.id} target='_blank' rel={rel ?? UNKNOWN_LINK_REL} href={href}>{text}</a>
)
}
try {
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
<Link href={href}>{linkText}</Link>
</ItemPopover>
)
}
} catch {
// ignore errors like invalid URLs
}
const videoWrapperStyles = {
maxWidth: topLevel ? '640px' : '320px',
margin: '0.5rem 0',
paddingRight: '15px'
}
const { provider, id, meta } = parseEmbedUrl(href)
// Youtube video embed
if (provider === 'youtube') {
return (
<div style={videoWrapperStyles}>
<YouTube
videoId={id} className={styles.videoContainer} opts={{
playerVars: {
start: meta?.start || 0
}
}}
/>
</div>
)
}
// Rumble video embed
if (provider === 'rumble') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='Rumble Video'
allowFullScreen
src={meta?.href}
sandbox='allow-scripts'
/>
</div>
</div>
)
}
if (provider === 'peertube') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='PeerTube Video'
allowFullScreen
src={meta?.href}
sandbox='allow-scripts'
/>
</div>
</div>
)
}
// assume the link is an image which will fallback to link if it's not
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
},
img: Img
}}
remarkPlugins={[gfm, mention, sub]}
rehypePlugins={[rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript]}
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
>
{children}
</ReactMarkdown>

View File

@ -128,24 +128,33 @@
margin-bottom: .5rem;
}
.text img, .text video {
.text .mediaContainer {
display: block;
margin-top: .5rem;
margin-bottom: .5rem;
max-width: 100%;
width: 100%;
height: auto;
overflow: hidden;
max-height: 25vh;
aspect-ratio: var(--aspect-ratio);
}
.text img {
cursor: zoom-in;
.mediaContainer img, .mediaContainer video {
display: block;
object-fit: contain;
width: auto;
max-height: inherit;
height: 100%;
aspect-ratio: var(--aspect-ratio);
}
.mediaContainer img {
cursor: zoom-in;
min-width: 30%;
object-position: left top;
}
.text img.topLevel, .text video.topLevel {
.mediaContainer.topLevel {
margin-top: .75rem;
margin-bottom: .75rem;
max-height: 35vh;

View File

@ -16,7 +16,7 @@ http:
# Configuration for your containers and service.
image:
location: ${PRIVATE_REPO}/imgproxy:v3.21.0-ml-amd64
location: docker.imgproxy.pro/imgproxy:v3.25.0-ml-amd64
# Port exposed through your container to route traffic to it.
port: 8080
@ -49,6 +49,7 @@ variables: # Pass environment variables as key value pairs.
IMGPROXY_WRITE_TIMEOUT: 10
IMGPROXY_DOWNLOAD_TIMEOUT: 9
IMGPROXY_WORKERS: 4
IMGPROXY_ENABLE_VIDEO_THUMBNAILS: 1
secrets: # Pass secrets from AWS Systems Manager (SSM) Parameter Store.
IMGPROXY_KEY: /copilot/${COPILOT_APPLICATION_NAME}/${COPILOT_ENVIRONMENT_NAME}/secrets/imgproxy_key

View File

@ -168,6 +168,15 @@ export function parseNwcUrl (walletConnectUrl) {
return params
}
export function decodeProxyUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/')
// base64url is not a known encoding in browsers
// so we need to replace the invalid chars
const b64Url = parts[parts.length - 1].replace(/-/g, '+').replace(/_/, '/')
const originalUrl = Buffer.from(b64Url, 'base64').toString('utf-8')
return originalUrl
}
// eslint-disable-next-line
export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i

View File

@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto'
import { extractUrls } from '@/lib/md.js'
import { isJob } from '@/lib/item.js'
import path from 'node:path'
import { decodeProxyUrl } from '@/lib/url'
const imgProxyEnabled = process.env.NODE_ENV === 'production' ||
(process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY)
@ -47,13 +48,6 @@ function matchUrl (matchers, url) {
}
}
function decodeOriginalUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/')
const b64Url = parts[parts.length - 1]
const originalUrl = Buffer.from(b64Url, 'base64url').toString('utf-8')
return originalUrl
}
export async function imgproxy ({ data: { id, forceFetch = false }, models }) {
if (!imgProxyEnabled) return
@ -100,18 +94,17 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
if (url.startsWith(IMGPROXY_URL)) {
console.log('[imgproxy] id:', id, '-- proxy url, decoding original url:', url)
// backwards compatibility: we used to replace image urls with imgproxy urls
url = decodeOriginalUrl(url)
url = decodeProxyUrl(url)
console.log('[imgproxy] id:', id, '-- original url:', url)
}
if (!(await isImageURL(fetchUrl, { forceFetch }))) {
if (!(await isMediaURL(fetchUrl, { forceFetch }))) {
console.log('[imgproxy] id:', id, '-- not image url:', url)
continue
}
imgproxyUrls[url] = {}
try {
imgproxyUrls[url] = {
dimensions: await getDimensions(fetchUrl)
}
imgproxyUrls[url] = await getMetadata(fetchUrl)
console.log('[imgproxy] id:', id, '-- dimensions:', imgproxyUrls[url])
} catch (err) {
console.log('[imgproxy] id:', id, '-- error getting dimensions (possibly not running imgproxy pro)', err)
}
@ -124,12 +117,13 @@ export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
return imgproxyUrls
}
const getDimensions = async (url) => {
const options = '/d:1'
const getMetadata = async (url) => {
// video metadata, dimensions, format
const options = '/vm:1/d:1/f:1'
const imgproxyUrl = new URL(createImgproxyPath({ url, options, pathname: '/info' }), IMGPROXY_URL).toString()
const res = await fetch(imgproxyUrl)
const { width, height } = await res.json()
return { width, height }
const { width, height, format, video_streams: videoStreams } = await res.json()
return { dimensions: { width, height }, format, video: !!videoStreams?.length }
}
const createImgproxyPath = ({ url, pathname = '/', options }) => {
@ -152,7 +146,7 @@ async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {})
return response
}
const isImageURL = async (url, { forceFetch }) => {
const isMediaURL = async (url, { forceFetch }) => {
if (cache.has(url)) return cache.get(url)
if (!forceFetch && matchUrl(imageUrlMatchers, url)) {
@ -162,21 +156,21 @@ const isImageURL = async (url, { forceFetch }) => {
return false
}
let isImage
let isMedia
// first run HEAD with small timeout
try {
// https://stackoverflow.com/a/68118683
const res = await fetchWithTimeout(url, { timeout: 1000, method: 'HEAD' })
const buf = await res.blob()
isImage = buf.type.startsWith('image/')
isMedia = buf.type.startsWith('image/') || buf.type.startsWith('video/')
} catch (err) {
console.log(url, err)
}
// For HEAD requests, positives are most likely true positives.
// However, negatives may be false negatives
if (isImage) {
if (isMedia) {
cache.set(url, true)
return true
}
@ -185,13 +179,13 @@ const isImageURL = async (url, { forceFetch }) => {
try {
const res = await fetchWithTimeout(url, { timeout: 10000 })
const buf = await res.blob()
isImage = buf.type.startsWith('image/')
isMedia = buf.type.startsWith('image/') || buf.type.startsWith('video/')
} catch (err) {
console.log(url, err)
}
cache.set(url, isImage)
return isImage
cache.set(url, isMedia)
return isMedia
}
const hexDecode = (hex) => Buffer.from(hex, 'hex')