Handle video and gifs, add test page

This commit is contained in:
Eric Bailey
2025-03-16 15:17:08 -05:00
parent 110c74c610
commit 116fef81e6
6 changed files with 394 additions and 3 deletions
+110 -2
View File
@@ -7,16 +7,20 @@ import {
import {ModeratorData} from '../data/getModeratorData.js'
import {Image as ImageSource, PostData} from '../data/getPostData.js'
import {atoms as a, theme as t} from '../theme/index.js'
import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {Embed, EmbedType, parseEmbed} from '../util/parseEmbed.js'
import {parseEmbedPlayerFromUrl} from '../util/parseEmbedPlayerFromUrl.js'
import {sanitizeHandle} from '../util/sanitizeHandle.js'
import {Box} from './Box.js'
import {FeedCard} from './FeedCard.js'
import * as Grid from './Grid.js'
import {PlayFilled as Play} from './icons/Play.js'
import {StarterPack} from './icons/StarterPack.js'
import {Image, SquareImage} from './Image.js'
import {LinkCard} from './LinkCard.js'
import {ListCard} from './ListCard.js'
import {ModeratedEmbed} from './ModeratedEmbed.js'
import {NotQuotePost, QuotePost} from './PostEmbed/QuotePost.js'
import {Text} from './Text.js'
@@ -76,7 +80,7 @@ export function MediaEmbeds({
return <LinkEmbed embed={embed} {...rest} />
}
case 'video': {
return null
return <VideoEmbed embed={embed} {...rest} />
}
default:
return null
@@ -231,19 +235,123 @@ export function LinkEmbed({
const {title, description, uri, thumb} = embed.view.external
if (!thumb) return null
const image = rest.data.images.get(thumb)
const player = parseEmbedPlayerFromUrl(uri)
const gif = rest.data.images.get(player?.playerUri)
if (gif) {
return (
<Box cx={[a.rounded_sm, a.overflow_hidden]}>
<Image image={gif} />
<Box
cx={[
a.absolute,
a.inset_0,
{
top: '75%',
backgroundImage:
'linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.75))',
},
]}
/>
<Box
cx={[
a.absolute,
a.rounded_xs,
a.px_xs,
a.py_2xs,
{
backgroundColor: t.palette.contrast_25,
bottom: a.p_md.padding,
right: a.p_md.padding,
},
]}>
<Text cx={[t.atoms.text, a.font_heavy, a.text_sm]}>GIF</Text>
</Box>
</Box>
)
}
return (
<LinkCard image={image} title={title} description={description} uri={uri} />
)
}
export function VideoEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'video'>
}) {
const {thumbnail} = embed.view
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
const img = rest.data.images.get(thumbnail)
if (img) {
return (
<Box cx={[a.relative, a.rounded_sm, a.w_full, a.overflow_hidden]}>
<Image image={img} />
<Box
cx={[
a.absolute,
a.inset_0,
a.rounded_full,
{
margin: 'auto',
width: '48px',
height: '48px',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
]}>
<Box
cx={[
a.align_center,
a.justify_center,
a.absolute,
a.inset_0,
a.rounded_full,
{
margin: 'auto',
width: '48px',
height: '48px',
backgroundColor: t.palette.contrast_25,
opacity: 0.7,
},
]}>
<Play size={32} />
</Box>
</Box>
</Box>
)
} else {
return null
}
}
export function ImagesEmbed({
embed,
...rest
}: CommonProps & {
embed: EmbedType<'images'>
}) {
const {images} = embed.view
const gutter = a.p_2xs.padding
const {images} = embed.view
const modui = rest.moderation.ui('contentMedia')
const info = getModerationCauseInfo({
cause: modui.blurs.at(0),
moderatorData: rest.moderatorData,
})
if (info) {
return <ModeratedEmbed info={info} />
}
if (images.length > 0) {
const imgs = images
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
import {IconProps} from './types.js'
export function PlayFilled({size, fill}: IconProps) {
return (
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
<path
fill={fill}
fillRule="evenodd"
clipRule="evenodd"
d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z"
/>
</svg>
)
}
+6 -1
View File
@@ -5,8 +5,13 @@ export async function getImage(url: string) {
const mimeType = new MIMEType(response.headers.get('content-type') ?? '')
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
if (response.status !== 200) return null
// TODO suss
const overwriteOctectStream =
url.endsWith('.jpg') && mimeType.essence === 'application/octet-stream'
return {
mime: mimeType.essence as string | undefined,
mime: overwriteOctectStream
? 'image/jpeg'
: (mimeType.essence as string | undefined),
image: Buffer.from(arrayBuf),
}
}
+17
View File
@@ -4,6 +4,7 @@ import {Vibrant} from 'node-vibrant/node'
import {httpLogger} from '../logger.js'
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
import {Embed, parseEmbed} from '../util/parseEmbed.js'
import {parseEmbedPlayerFromUrl} from '../util/parseEmbedPlayerFromUrl.js'
import {getImage} from './getImage.js'
export type Metadata = {
@@ -61,6 +62,22 @@ export function getEmbedData(embed: Embed, images: Map<string, Metadata>) {
},
})
}
const player = parseEmbedPlayerFromUrl(embed.view.external.uri)
if (player.type === 'giphy_gif') {
images.set(player.playerUri, {
aspectRatio: {
width: 1000,
height: 1000,
},
})
} else if (player.type === 'tenor_gif') {
images.set(player.playerUri, {
aspectRatio: {
width: 1000,
height: 1000,
},
})
}
break
}
case 'video': {
@@ -0,0 +1,214 @@
export const embedPlayerSources = ['giphy', 'tenor'] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
export type EmbedPlayerType = 'giphy_gif' | 'tenor_gif'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
giphy: 'GIPHY',
tenor: 'Tenor',
}
export interface EmbedPlayerParams {
type: EmbedPlayerType
playerUri: string
isGif?: boolean
source: EmbedPlayerSource
metaUri?: string
hideDetails?: boolean
dimensions?: {
height: number
width: number
}
}
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
export function parseEmbedPlayerFromUrl(
url: string,
): EmbedPlayerParams | undefined {
let urlp
try {
urlp = new URL(url)
} catch (e) {
return undefined
}
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
const [_, gifs, nameAndId] = urlp.pathname.split('/')
/*
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
* We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
* use it in an <Image> component
*/
if (gifs === 'gifs' && nameAndId) {
const gifId = nameAndId.split('-').pop()
if (gifId) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
}
}
}
}
// There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
if (giphyRegex.test(urlp.hostname)) {
// We can link directly to the gif, if its a proper link
const [_, media, trackingOrId, idOrFilename, filename] =
urlp.pathname.split('/')
if (media === 'media') {
if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`,
}
} else if (filename && gifFilenameRegex.test(filename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`,
}
}
}
}
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
// be .webp
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
if (mediaOrFilename === 'media' && filename) {
const gifId = filename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
}
} else if (mediaOrFilename) {
const gifId = mediaOrFilename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${
mediaOrFilename.split('.')[0]
}/200.webp`,
}
}
}
const tenorGif = parseTenorGif(urlp)
if (tenorGif.success) {
const {playerUri, dimensions} = tenorGif
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri,
dimensions,
}
}
}
export function getGifDims(
originalHeight: number,
originalWidth: number,
viewWidth: number,
) {
const scaledHeight = (originalHeight / originalWidth) * viewWidth
return {
height: scaledHeight > 250 ? 250 : scaledHeight,
width: (250 / scaledHeight) * viewWidth,
}
}
export function getGiphyMetaUri(url: URL) {
if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
const params = parseEmbedPlayerFromUrl(url.toString())
if (params && params.type === 'giphy_gif') {
return params.metaUri
}
}
}
export function parseTenorGif(urlp: URL):
| {success: false}
| {
success: true
playerUri: string
dimensions: {height: number; width: number}
} {
if (urlp.hostname !== 'media.tenor.com') {
return {success: false}
}
let [_, id, filename] = urlp.pathname.split('/')
if (!id || !filename) {
return {success: false}
}
if (!id.includes('AAAAC')) {
return {success: false}
}
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
if (!h || !w) {
return {success: false}
}
const dimensions = {
height: Number(h),
width: Number(w),
}
// if (isWeb) {
// id = id.replace('AAAAC', 'AAAP3')
// filename = filename.replace('.gif', '.webm')
// } else {
id = id.replace('AAAAC', 'AAAAM')
// }
return {
success: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
}
export function isTenorGifUri(url: URL | string) {
try {
return parseTenorGif(typeof url === 'string' ? new URL(url) : url).success
} catch {
// Invalid URL
return false
}
}
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html>
<head>
<link
rel="stylesheet"
href="https://unpkg.com/svbstrate@5.1.0/svbstrate.css" />
<style>
img {
width: 100%;
}
</style>
</head>
<body class="p12">
<div
class="mxa w f"
style="max-width: 400px; flex-direction: column; gap: 1rem">
<h1>Base embeds</h1>
<h3>Image</h3>
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkch7rnmqs23?mat=1" />
<h3>Image w/ label</h3>
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchac5lbs23?mat=1" />
<h3>Video</h3>
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchd5laes23?mat=1" />
<h3>Video w/ label</h3>
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchdnqmzc23?mat=1" />
<h3>GIF</h3>
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchef5c2c23?mat=1" />
</div>
</body>
</html>