From 116fef81e6ef3d17ce02547f8fd608675cf38c3b Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sun, 16 Mar 2025 15:17:08 -0500 Subject: [PATCH] Handle video and gifs, add test page --- bskyogcard/src/components/PostEmbed.tsx | 112 ++++++++- bskyogcard/src/components/icons/Play.tsx | 16 ++ bskyogcard/src/data/getImage.ts | 7 +- bskyogcard/src/data/getPostData.ts | 17 ++ .../src/util/parseEmbedPlayerFromUrl.ts | 214 ++++++++++++++++++ bskyogcard/test.html | 31 +++ 6 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 bskyogcard/src/components/icons/Play.tsx create mode 100644 bskyogcard/src/util/parseEmbedPlayerFromUrl.ts create mode 100644 bskyogcard/test.html diff --git a/bskyogcard/src/components/PostEmbed.tsx b/bskyogcard/src/components/PostEmbed.tsx index 068f83f893..585394375a 100644 --- a/bskyogcard/src/components/PostEmbed.tsx +++ b/bskyogcard/src/components/PostEmbed.tsx @@ -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 } case 'video': { - return null + return } 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 ( + + + + + GIF + + + ) + } return ( ) } +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 + } + + const img = rest.data.images.get(thumbnail) + if (img) { + return ( + + + + + + + + + ) + } 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 + } if (images.length > 0) { const imgs = images diff --git a/bskyogcard/src/components/icons/Play.tsx b/bskyogcard/src/components/icons/Play.tsx new file mode 100644 index 0000000000..364e8ff5c6 --- /dev/null +++ b/bskyogcard/src/components/icons/Play.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +import {IconProps} from './types.js' + +export function PlayFilled({size, fill}: IconProps) { + return ( + + + + ) +} diff --git a/bskyogcard/src/data/getImage.ts b/bskyogcard/src/data/getImage.ts index e58bf8e4da..b95cae3b9b 100644 --- a/bskyogcard/src/data/getImage.ts +++ b/bskyogcard/src/data/getImage.ts @@ -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), } } diff --git a/bskyogcard/src/data/getPostData.ts b/bskyogcard/src/data/getPostData.ts index 5087b48ea8..aa7e62e420 100644 --- a/bskyogcard/src/data/getPostData.ts +++ b/bskyogcard/src/data/getPostData.ts @@ -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) { }, }) } + 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': { diff --git a/bskyogcard/src/util/parseEmbedPlayerFromUrl.ts b/bskyogcard/src/util/parseEmbedPlayerFromUrl.ts new file mode 100644 index 0000000000..f35220032f --- /dev/null +++ b/bskyogcard/src/util/parseEmbedPlayerFromUrl.ts @@ -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 = { + 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 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 + } +} diff --git a/bskyogcard/test.html b/bskyogcard/test.html new file mode 100644 index 0000000000..61cee38258 --- /dev/null +++ b/bskyogcard/test.html @@ -0,0 +1,31 @@ + + + + + + + +
+ +

Base embeds

+

Image

+ +

Image w/ label

+ +

Video

+ +

Video w/ label

+ +

GIF

+ +
+ +