[APP-2066] Migrate from Tenor to KLIPY (#10240)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+31
-3
@@ -190,16 +190,44 @@ export async function resolveGif(
|
||||
agent: BskyAgent,
|
||||
gif: Gif,
|
||||
): Promise<ResolvedExternalLink> {
|
||||
const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`
|
||||
const gifUrl = gif.media_formats.gif.url
|
||||
const params = new URLSearchParams()
|
||||
params.set('hh', String(gif.media_formats.gif.dims[1]))
|
||||
params.set('ww', String(gif.media_formats.gif.dims[0]))
|
||||
|
||||
// For Klipy GIFs, embed video format slugs so parseKlipyGif can
|
||||
// swap to the right format per platform at render time. Klipy uses
|
||||
// different filename slugs per format (unlike Tenor where format is
|
||||
// encoded in the URL ID), so this info must travel with the URL.
|
||||
try {
|
||||
const url = new URL(gifUrl)
|
||||
if (url.hostname === 'static.klipy.com') {
|
||||
const mp4Slug = getFileSlug(gif.media_formats.mp4?.url)
|
||||
const webmSlug = getFileSlug(gif.media_formats.webm?.url)
|
||||
if (mp4Slug) params.set('mp4', mp4Slug)
|
||||
if (webmSlug) params.set('webm', webmSlug)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const uri = `${gifUrl}?${params.toString()}`
|
||||
const altText = gif.content_description || gif.title
|
||||
return {
|
||||
type: 'external',
|
||||
uri,
|
||||
title: gif.content_description,
|
||||
description: createGIFDescription(gif.content_description),
|
||||
title: altText,
|
||||
description: createGIFDescription(altText),
|
||||
thumb: await imageToThumb(gif.media_formats.preview.url),
|
||||
}
|
||||
}
|
||||
|
||||
function getFileSlug(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined
|
||||
const filename = url.split('/').pop()
|
||||
if (!filename) return undefined
|
||||
const dotIndex = filename.lastIndexOf('.')
|
||||
return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
|
||||
}
|
||||
|
||||
async function resolveExternal(
|
||||
agent: BskyAgent,
|
||||
uri: string,
|
||||
|
||||
@@ -178,6 +178,11 @@ export const GIF_SEARCH = (params: string) =>
|
||||
export const GIF_FEATURED = (params: string) =>
|
||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
||||
|
||||
export const GIF_KLIPY_SEARCH = (params: string) =>
|
||||
`${GIF_SERVICE}/klipy/v2/search?${params}`
|
||||
export const GIF_KLIPY_FEATURED = (params: string) =>
|
||||
`${GIF_SERVICE}/klipy/v2/featured?${params}`
|
||||
|
||||
export const MAX_LABELERS = 20
|
||||
|
||||
export const VIDEO_SERVICE = 'https://video.bsky.app'
|
||||
|
||||
@@ -23,6 +23,7 @@ export const embedPlayerSources = [
|
||||
'vimeo',
|
||||
'giphy',
|
||||
'tenor',
|
||||
'klipy',
|
||||
'flickr',
|
||||
'bandcamp',
|
||||
] as const
|
||||
@@ -44,6 +45,7 @@ export type EmbedPlayerType =
|
||||
| 'vimeo_video'
|
||||
| 'giphy_gif'
|
||||
| 'tenor_gif'
|
||||
| 'klipy_gif'
|
||||
| 'flickr_album'
|
||||
| 'bandcamp_album'
|
||||
| 'bandcamp_track'
|
||||
@@ -55,6 +57,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
twitch: 'Twitch',
|
||||
giphy: 'GIPHY',
|
||||
tenor: 'Tenor',
|
||||
klipy: 'KLIPY',
|
||||
spotify: 'Spotify',
|
||||
appleMusic: 'Apple Music',
|
||||
soundcloud: 'SoundCloud',
|
||||
@@ -391,6 +394,20 @@ export function parseEmbedPlayerFromUrl(
|
||||
}
|
||||
}
|
||||
|
||||
const klipyGif = parseKlipyGif(urlp)
|
||||
if (klipyGif.success) {
|
||||
const {playerUri, dimensions} = klipyGif
|
||||
|
||||
return {
|
||||
type: 'klipy_gif',
|
||||
source: 'klipy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
|
||||
// this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
|
||||
if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
|
||||
let i = urlp.pathname.length - 1
|
||||
@@ -628,3 +645,90 @@ export function isTenorGifUri(url: URL | string) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function parseKlipyGif(urlp: URL):
|
||||
| {success: false}
|
||||
| {
|
||||
success: true
|
||||
playerUri: string
|
||||
dimensions: {height: number; width: number}
|
||||
} {
|
||||
if (urlp.hostname !== 'static.klipy.com') {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
if (!urlp.pathname.startsWith('/ii/')) {
|
||||
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),
|
||||
}
|
||||
|
||||
// Validate dimensions are valid positive numbers
|
||||
if (
|
||||
isNaN(dimensions.height) ||
|
||||
isNaN(dimensions.width) ||
|
||||
dimensions.height <= 0 ||
|
||||
dimensions.width <= 0
|
||||
) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const playerUrl = new URL(urlp.href)
|
||||
playerUrl.hostname = 'k.gifs.bsky.app'
|
||||
|
||||
// On web, swap the gif filename for a video format so the <video>
|
||||
// element can play it. Klipy uses different filename slugs per
|
||||
// format (unlike Tenor's ID-based scheme), so the slugs are
|
||||
// embedded as query params at composition time by resolveGif().
|
||||
if (IS_WEB) {
|
||||
const webmSlug = playerUrl.searchParams.get('webm')
|
||||
const mp4Slug = playerUrl.searchParams.get('mp4')
|
||||
const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug
|
||||
const ext = IS_WEB_SAFARI ? 'mp4' : 'webm'
|
||||
|
||||
// Without a slug we can't produce a playable video URL on web,
|
||||
// so fall back to the link card instead of returning a broken player.
|
||||
if (!slug) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const parts = playerUrl.pathname.split('/')
|
||||
parts[parts.length - 1] = `${slug}.${ext}`
|
||||
playerUrl.pathname = parts.join('/')
|
||||
}
|
||||
|
||||
// Strip all metadata params — only the path matters for the CDN
|
||||
playerUrl.searchParams.delete('hh')
|
||||
playerUrl.searchParams.delete('ww')
|
||||
playerUrl.searchParams.delete('mp4')
|
||||
playerUrl.searchParams.delete('webm')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
playerUri: playerUrl.href,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
|
||||
export function isKlipyGifUri(url: URL | string) {
|
||||
try {
|
||||
return parseKlipyGif(typeof url === 'string' ? new URL(url) : url).success
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isGifEmbed(url: URL | string) {
|
||||
return isTenorGifUri(url) || isKlipyGifUri(url)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user