[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:
Spence Pope
2026-04-17 08:43:13 -04:00
committed by GitHub
parent a97b15b204
commit a77b6e3525
12 changed files with 411 additions and 50 deletions
+65
View File
@@ -8,6 +8,7 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
@@ -450,6 +451,13 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
'https://static.klipy.com',
]
const outputs = [
@@ -845,6 +853,35 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
undefined,
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
// With video slug params — on native (test env), keeps gif filename,
// strips mp4/webm params. On web, would swap to video filename.
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -1049,3 +1086,31 @@ describe('tenorUrlToBskyGifUrl', () => {
},
)
})
describe('klipyUrlToBskyGifUrl', () => {
const inputs = [
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
]
it.each(inputs)(
'returns url with k.gifs.bsky.app as hostname for input url',
input => {
const out = klipyUrlToBskyGifUrl(input)
expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true)
},
)
it('preserves the path and query params when rewriting', () => {
const out = klipyUrlToBskyGifUrl(
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
expect(out).toEqual(
'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
})
it('returns empty string for invalid URLs', () => {
expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('')
})
})
+1 -1
View File
@@ -13,7 +13,7 @@ export enum Features {
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
AATest = 'aa-test',
}
+2 -2
View File
@@ -3,7 +3,7 @@ import {Image} from 'expo-image'
import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {isTenorGifUri} from '#/lib/strings/embed-player'
import {isGifEmbed} from '#/lib/strings/embed-player'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography'
@@ -38,7 +38,7 @@ export function Embed({
)
} else if (e.type === 'link') {
if (!e.view.external.thumb) return null
if (!isTenorGifUri(e.view.external.uri)) return null
if (!isGifEmbed(e.view.external.uri)) return null
return (
<Outer style={style}>
<GifItem
@@ -59,7 +59,10 @@ export const ExternalEmbed = ({
}
}, [link.uri, playHaptic])
if (embedPlayerParams?.source === 'tenor') {
if (
embedPlayerParams?.source === 'tenor' ||
embedPlayerParams?.source === 'klipy'
) {
const parsedAlt = parseAltFromGIFDescription(link.description)
return (
<View style={style}>
+44 -32
View File
@@ -8,16 +8,18 @@ import {
import {type TextInput, View} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {cleanError} from '#/lib/strings/errors'
import {
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
useGifSearchQuery as useKlipyGifSearchQuery,
} from '#/state/queries/klipy'
import {
type Gif,
tenorUrlToBskyGifUrl,
useFeaturedGifsQuery,
useGifSearchQuery,
gifPreviewUrl,
useTenorFeaturedGifsQuery,
useTenorGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
@@ -85,7 +87,8 @@ function GifList({
control: Dialog.DialogControlProps
onSelectGif: (gif: Gif) => void
}) {
const {_} = useLingui()
const ax = useAnalytics()
const {t: l} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
@@ -93,11 +96,14 @@ function GifList({
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const {height} = useWindowDimensions()
const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable)
const isSearching = search.length > 0
const trendingQuery = useFeaturedGifsQuery()
const searchQuery = useGifSearchQuery(search)
const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled})
const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled})
const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled})
const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled})
const {
data,
@@ -108,7 +114,13 @@ function GifList({
isPending,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
} = klipyEnabled
? isSearching
? klipySearch
: klipyTrending
: isSearching
? tenorSearch
: tenorTrending
const flattenedData = useMemo(() => {
return data?.pages.flatMap(page => page.results) || []
@@ -158,7 +170,7 @@ function GifList({
color="secondary"
shape="round"
onPress={() => control.close()}
label={_(msg`Close GIF dialog`)}>
label={l`Close GIF dialog`}>
<ButtonIcon icon={Arrow} size="md" />
</Button>
)}
@@ -166,8 +178,8 @@ function GifList({
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
<TextField.Icon icon={Search} />
<TextField.Input
label={_(msg`Search GIFs`)}
placeholder={_(msg`Search Tenor`)}
label={l`Search GIFs`}
placeholder={klipyEnabled ? l`Search KLIPY` : l`Search Tenor`}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
@@ -185,7 +197,7 @@ function GifList({
</TextField.Root>
</View>
)
}, [gtMobile, t.atoms.bg, _, control])
}, [gtMobile, t.atoms.bg, l, control, klipyEnabled])
return (
<>
@@ -212,14 +224,18 @@ function GifList({
emptyType="results"
sideBorders={false}
topBorder={false}
errorTitle={_(msg`Failed to load GIFs`)}
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
errorTitle={l`Failed to load GIFs`}
errorMessage={
klipyEnabled
? l`There was an issue connecting to KLIPY.`
: l`There was an issue connecting to Tenor.`
}
emptyMessage={
isSearching
? _(msg`No search results found for "${search}".`)
: _(
msg`No featured GIFs found. There may be an issue with Tenor.`,
)
? l`No search results found for "${search}".`
: klipyEnabled
? l`No featured GIFs found. There may be an issue with KLIPY.`
: l`No featured GIFs found. There may be an issue with Tenor.`
}
/>
)}
@@ -246,23 +262,19 @@ function GifList({
}
function DialogError({details}: {details?: string}) {
const {_} = useLingui()
const {t: l} = useLingui()
const control = Dialog.useDialogContext()
return (
<Dialog.ScrollableInner
style={a.gap_md}
label={_(msg`An error has occurred`)}>
<Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}>
<Dialog.Close />
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
title={l`Oh no!`}
message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
label={l`Close dialog`}
onPress={() => control.close()}
color="primary"
size="large"
@@ -284,7 +296,7 @@ export function GifPreview({
}) {
const ax = useAnalytics()
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
@@ -294,7 +306,7 @@ export function GifPreview({
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
label={l`Select GIF "${gif.title}"`}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
@@ -308,7 +320,7 @@ export function GifPreview({
t.atoms.bg_contrast_25,
]}
source={{
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
uri: gifPreviewUrl(gif.media_formats.tinygif.url),
}}
contentFit="cover"
accessibilityLabel={gif.title}
+31 -3
View File
@@ -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,
+5
View File
@@ -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'
+104
View File
@@ -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)
}
+1
View File
@@ -98,6 +98,7 @@ const schema = z.object({
.object({
giphy: z.enum(externalEmbedOptions).optional(),
tenor: z.enum(externalEmbedOptions).optional(),
klipy: z.enum(externalEmbedOptions).optional(),
youtube: z.enum(externalEmbedOptions).optional(),
youtubeShorts: z.enum(externalEmbedOptions).optional(),
twitch: z.enum(externalEmbedOptions).optional(),
+108
View File
@@ -0,0 +1,108 @@
import {Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
import {logger} from '#/logger'
import {type Gif} from '#/state/queries/tenor'
export const RQKEY_ROOT = 'klipy-gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
const getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED)
const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH)
export function useFeaturedGifsQuery(options?: {enabled?: boolean}) {
return useInfiniteQuery({
queryKey: RQKEY_FEATURED,
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next,
enabled: options?.enabled,
})
}
export function useGifSearchQuery(
query: string,
options?: {enabled?: boolean},
) {
return useInfiniteQuery({
queryKey: RQKEY_SEARCH(query),
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next,
enabled: !!query && options?.enabled !== false,
placeholderData: keepPreviousData,
})
}
function createKlipyApi<Input extends object>(
urlFn: (params: string) => string,
): (input: Input & {pos?: string}) => Promise<{
next: string
results: Gif[]
}> {
return async input => {
const params = new URLSearchParams()
params.set(
'client_key',
Platform.select({
ios: 'bluesky-ios',
android: 'bluesky-android',
default: 'bluesky-web',
}),
)
// 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
params.set('limit', '30')
params.set('contentfilter', 'high')
const locale = getLocales?.()?.[0]
if (locale) {
params.set('locale', locale.languageTag.replace('-', '_'))
}
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
params.set(key, String(value))
}
}
const res = await fetch(urlFn(params.toString()), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!res.ok) {
throw new Error(`Failed to fetch KLIPY API (status ${res.status})`)
}
const body: {next: string; results: Gif[]} = await res.json()
return {
next: body.next,
results: body.results,
}
}
}
/**
* Rewrites a KLIPY static CDN URL through the bsky proxy
* (k.gifs.bsky.app). Mirrors `tenorUrlToBskyGifUrl`, but uses a
* separate hostname from Tenor's t.gifs.bsky.app so the two
* upstreams can be routed independently.
*/
export function klipyUrlToBskyGifUrl(klipyUrl: string) {
let url
try {
url = new URL(klipyUrl)
} catch (e) {
logger.debug('invalid url passed to klipyUrlToBskyGifUrl()')
return ''
}
url.hostname = 'k.gifs.bsky.app'
return url.href
}
+42 -10
View File
@@ -13,22 +13,26 @@ const getTrendingGifs = createTenorApi(GIF_FEATURED)
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
export function useFeaturedGifsQuery() {
export function useTenorFeaturedGifsQuery(options?: {enabled?: boolean}) {
return useInfiniteQuery({
queryKey: RQKEY_FEATURED,
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next,
enabled: options?.enabled,
})
}
export function useGifSearchQuery(query: string) {
export function useTenorGifSearchQuery(
query: string,
options?: {enabled?: boolean},
) {
return useInfiniteQuery({
queryKey: RQKEY_SEARCH(query),
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next,
enabled: !!query,
enabled: !!query && options?.enabled !== false,
placeholderData: keepPreviousData,
})
}
@@ -99,6 +103,29 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
return url.href
}
/**
* Returns the appropriate URL for a GIF preview image.
* Tenor URLs (media.tenor.com) are routed through t.gifs.bsky.app;
* KLIPY URLs (static.klipy.com) are routed through k.gifs.bsky.app.
*/
export function gifPreviewUrl(gifUrl: string) {
try {
const url = new URL(gifUrl)
if (url.hostname === 'media.tenor.com') {
url.hostname = 't.gifs.bsky.app'
return url.href
}
if (url.hostname === 'static.klipy.com') {
url.hostname = 'k.gifs.bsky.app'
return url.href
}
return gifUrl
} catch (e) {
logger.debug('invalid url passed to gifPreviewUrl()')
return ''
}
}
export type Gif = {
/**
* A Unix timestamp that represents when this post was created.
@@ -116,7 +143,8 @@ export type Gif = {
/**
* A dictionary with a content format as the key and a Media Object as the value.
*/
media_formats: Record<ContentFormats, MediaObject>
media_formats: Record<BaseContentFormats, MediaObject> &
Partial<Record<VideoContentFormats, MediaObject>>
/**
* An array of tags for the post
*/
@@ -171,16 +199,20 @@ type MediaObject = {
size: number
}
type ContentFormats =
type BaseContentFormats =
| 'preview'
| 'gif'
// | 'mediumgif'
| 'tinygif'
// | 'nanogif'
// | 'mp4'
// | 'loopedmp4'
// | 'tinymp4'
// | 'nanomp4'
// | 'webm'
type VideoContentFormats =
| 'mp4'
// | 'loopedmp4'
// | 'tinymp4'
// | 'nanomp4'
| 'webm'
// | 'tinywebm'
// | 'nanowebm'
type ContentFormats = BaseContentFormats | VideoContentFormats
+4 -1
View File
@@ -26,6 +26,7 @@ import {type DraftPostDisplay, type DraftSummary} from './schema'
import * as storage from './storage'
const TENOR_HOSTNAME = 'media.tenor.com'
const KLIPY_HOSTNAME = 'static.klipy.com'
/**
* Video data from a draft that needs to be restored by re-processing.
@@ -379,7 +380,7 @@ function parseGifFromUrl(
): {url: string; width: number; height: number; alt: string} | undefined {
try {
const url = new URL(uri)
if (url.hostname !== TENOR_HOSTNAME) {
if (url.hostname !== TENOR_HOSTNAME && url.hostname !== KLIPY_HOSTNAME) {
return undefined
}
@@ -396,6 +397,8 @@ function parseGifFromUrl(
url.searchParams.delete('ww')
url.searchParams.delete('hh')
url.searchParams.delete('alt')
url.searchParams.delete('mp4')
url.searchParams.delete('webm')
return {url: url.toString(), width, height, alt}
} catch {