Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/bluesky

src/thumbnail.ts

115 lines · 3.4 KB · TypeScript
export const MAX_BLOB_BYTES = 1_000_000
const CLOUDINARY_FIT = 'w_1280,c_limit,q_auto:good,f_jpg'

export type PreparedExternalCardThumbnail = {
  bytes: Uint8Array
  mimeType: string
  originalUrl: string
  sourceUrl: string
  originalBytes: number
  uploadBytes: number
  fitted: boolean
}

export function withCloudinaryFit(url: string): string | null {
  let parsed: URL
  try {
    parsed = new URL(url)
  } catch {
    return null
  }
  if (parsed.hostname !== 'res.cloudinary.com') return null
  const segments = parsed.pathname.split('/').filter(Boolean)
  if (segments.length < 4) return null
  const [cloudName, resourceType, deliveryType, ...rest] = segments
  if (resourceType !== 'image') return null
  if (deliveryType !== 'upload' && deliveryType !== 'fetch') return null
  const existingTransform = looksLikeCloudinaryTransform(rest[0]) ? rest.shift() : null
  const merged = existingTransform ? `${existingTransform},${CLOUDINARY_FIT}` : CLOUDINARY_FIT
  parsed.pathname = `/${[cloudName, resourceType, deliveryType, merged, ...rest].join('/')}`
  return parsed.toString()
}

function looksLikeCloudinaryTransform(segment?: string) {
  if (!segment) return false
  if (/^v\d+$/u.test(segment)) return false
  return /(?:^|,)[a-z]+_/iu.test(segment)
}

async function fetchImage(url: string) {
  const imageResponse = await fetch(url)
  if (!imageResponse.ok) {
    throw new Error(
      `Could not fetch external card thumbnail: ${imageResponse.status} ${imageResponse.statusText}`,
    )
  }
  const mimeType = String(imageResponse.headers.get('content-type') || '')
    .split(';')[0]
    .trim()
    .toLowerCase()
  if (!mimeType.startsWith('image/')) {
    throw new Error(
      `External card thumbnail must be an image, received ${mimeType || 'unknown content type'}`,
    )
  }
  const bytes = new Uint8Array(await imageResponse.arrayBuffer())
  return { bytes, mimeType }
}

function preparedFromBytes(
  originalUrl: string,
  sourceUrl: string,
  originalBytes: number,
  image: { bytes: Uint8Array; mimeType: string },
  fitted: boolean,
): PreparedExternalCardThumbnail {
  return {
    bytes: image.bytes,
    mimeType: image.mimeType,
    originalUrl,
    sourceUrl,
    originalBytes,
    uploadBytes: image.bytes.byteLength,
    fitted,
  }
}

export function thumbnailUploadPreview(prepared: PreparedExternalCardThumbnail) {
  return {
    type: 'external-card-thumbnail',
    url: prepared.sourceUrl,
    originalUrl: prepared.originalUrl,
    sourceUrl: prepared.sourceUrl,
    originalBytes: prepared.originalBytes,
    uploadBytes: prepared.uploadBytes,
    mimeType: prepared.mimeType,
    fitted: prepared.fitted,
  }
}

export async function prepareExternalCardThumbnail(
  thumbnailUrl: string,
): Promise<PreparedExternalCardThumbnail> {
  const original = await fetchImage(thumbnailUrl)
  if (original.bytes.byteLength <= MAX_BLOB_BYTES) {
    return preparedFromBytes(thumbnailUrl, thumbnailUrl, original.bytes.byteLength, original, false)
  }

  const cloudinaryUrl = withCloudinaryFit(thumbnailUrl)
  if (cloudinaryUrl && cloudinaryUrl !== thumbnailUrl) {
    const fitted = await fetchImage(cloudinaryUrl)
    if (fitted.bytes.byteLength <= MAX_BLOB_BYTES) {
      return preparedFromBytes(
        thumbnailUrl,
        cloudinaryUrl,
        original.bytes.byteLength,
        fitted,
        true,
      )
    }
  }

  throw new Error(
    `External card thumbnail is ${original.bytes.byteLength} bytes; Bluesky allows at most ${MAX_BLOB_BYTES}`,
  )
}