responsive-image-event.ts raw

   1  /**
   2   * Creates NIP-94 File Metadata events (kind 1063) for responsive image sets
   3   *
   4   * Follows NIP-XX Responsive Image Variants specification, extending NIP-94
   5   * with multiple imeta tags per the NIP-71 video variants pattern.
   6   */
   7  
   8  import { TDraftEvent } from '@/types'
   9  import dayjs from 'dayjs'
  10  import { ImageVariant } from './image-scaler'
  11  
  12  /** Kind 1063 = File Metadata (NIP-94) */
  13  export const FILE_METADATA_KIND = 1063
  14  
  15  /**
  16   * Uploaded variant information from Blossom
  17   */
  18  export type UploadedVariant = {
  19    variant: ImageVariant
  20    url: string
  21    sha256: string
  22    width: number
  23    height: number
  24    mimeType: string
  25    size?: number
  26    blurhash?: string
  27  }
  28  
  29  /**
  30   * Options for creating a responsive image event
  31   */
  32  export type ResponsiveImageEventOptions = {
  33    /** Description/caption for the image */
  34    description?: string
  35    /** Alt text for accessibility */
  36    alt?: string
  37  }
  38  
  39  /**
  40   * Build an imeta tag for a single variant
  41   *
  42   * Format per NIP-92/94:
  43   * ["imeta", "url <url>", "x <sha256>", "m <mime>", "dim <WxH>", "variant <name>", ...]
  44   */
  45  function buildImetaTag(variant: UploadedVariant): string[] {
  46    const tag = ['imeta']
  47  
  48    // Required fields
  49    tag.push(`url ${variant.url}`)
  50    tag.push(`x ${variant.sha256}`)
  51    tag.push(`m ${variant.mimeType}`)
  52    tag.push(`dim ${variant.width}x${variant.height}`)
  53    tag.push(`variant ${variant.variant}`)
  54  
  55    // Optional fields
  56    if (variant.size !== undefined) {
  57      tag.push(`size ${variant.size}`)
  58    }
  59  
  60    // Blurhash is especially useful for thumbnails
  61    if (variant.blurhash) {
  62      tag.push(`blurhash ${variant.blurhash}`)
  63    }
  64  
  65    return tag
  66  }
  67  
  68  /**
  69   * Create a NIP-94 File Metadata draft event for a responsive image set
  70   *
  71   * @param variants - Array of uploaded variants (should include original + scaled versions)
  72   * @param options - Optional description and alt text
  73   * @returns TDraftEvent ready for signing and publishing
  74   */
  75  export function createResponsiveImageEvent(
  76    variants: UploadedVariant[],
  77    options?: ResponsiveImageEventOptions
  78  ): TDraftEvent {
  79    if (variants.length === 0) {
  80      throw new Error('At least one variant is required')
  81    }
  82  
  83    // Sort variants from smallest to largest for consistent ordering per NIP-XX
  84    const variantOrder: ImageVariant[] = ['thumb', 'mobile-sm', 'mobile-lg', 'desktop-sm', 'desktop-md', 'desktop-lg', 'original']
  85    const sortedVariants = [...variants].sort(
  86      (a, b) => variantOrder.indexOf(a.variant) - variantOrder.indexOf(b.variant)
  87    )
  88  
  89    // Build tags array
  90    const tags: string[][] = []
  91  
  92    // Add imeta tag for each variant
  93    for (const variant of sortedVariants) {
  94      tags.push(buildImetaTag(variant))
  95    }
  96  
  97    // Add separate x tags for each variant hash (enables NIP-01 tag queries)
  98    for (const variant of sortedVariants) {
  99      tags.push(['x', variant.sha256])
 100    }
 101  
 102    // Add alt tag if provided (for accessibility)
 103    if (options?.alt) {
 104      tags.push(['alt', options.alt])
 105    }
 106  
 107    return {
 108      kind: FILE_METADATA_KIND,
 109      content: options?.description ?? '',
 110      tags,
 111      created_at: dayjs().unix()
 112    }
 113  }
 114  
 115  /**
 116   * Parse a kind 1063 event to extract variant information
 117   *
 118   * @param event - A kind 1063 event with imeta tags
 119   * @returns Array of parsed variants
 120   */
 121  export function parseResponsiveImageEvent(event: { kind: number; tags: string[][] }): UploadedVariant[] {
 122    if (event.kind !== FILE_METADATA_KIND) {
 123      throw new Error(`Expected kind ${FILE_METADATA_KIND}, got ${event.kind}`)
 124    }
 125  
 126    const variants: UploadedVariant[] = []
 127  
 128    for (const tag of event.tags) {
 129      if (tag[0] !== 'imeta') continue
 130  
 131      const fields = new Map<string, string>()
 132      for (let i = 1; i < tag.length; i++) {
 133        const part = tag[i]
 134        const spaceIndex = part.indexOf(' ')
 135        if (spaceIndex > 0) {
 136          const key = part.substring(0, spaceIndex)
 137          const value = part.substring(spaceIndex + 1)
 138          fields.set(key, value)
 139        }
 140      }
 141  
 142      // Required fields
 143      const url = fields.get('url')
 144      const sha256 = fields.get('x')
 145      const mimeType = fields.get('m')
 146      const dim = fields.get('dim')
 147      const variant = fields.get('variant') as ImageVariant | undefined
 148  
 149      if (!url || !sha256 || !mimeType || !dim) continue
 150  
 151      // Parse dimensions
 152      const dimMatch = dim.match(/^(\d+)x(\d+)$/)
 153      if (!dimMatch) continue
 154  
 155      const width = parseInt(dimMatch[1], 10)
 156      const height = parseInt(dimMatch[2], 10)
 157  
 158      // Build variant object
 159      const parsed: UploadedVariant = {
 160        variant: variant ?? 'original',
 161        url,
 162        sha256,
 163        width,
 164        height,
 165        mimeType
 166      }
 167  
 168      // Optional fields
 169      const size = fields.get('size')
 170      if (size) {
 171        parsed.size = parseInt(size, 10)
 172      }
 173  
 174      const blurhash = fields.get('blurhash')
 175      if (blurhash) {
 176        parsed.blurhash = blurhash
 177      }
 178  
 179      variants.push(parsed)
 180    }
 181  
 182    return variants
 183  }
 184  
 185  /**
 186   * Select the best variant for a given viewport width
 187   *
 188   * @param variants - Available variants
 189   * @param viewportWidth - Current viewport width in pixels
 190   * @param pixelRatio - Device pixel ratio (default 1)
 191   * @returns The most appropriate variant, or undefined if none available
 192   */
 193  export function selectVariantForViewport(
 194    variants: UploadedVariant[],
 195    viewportWidth: number,
 196    pixelRatio: number = 1
 197  ): UploadedVariant | undefined {
 198    if (variants.length === 0) return undefined
 199  
 200    const targetWidth = viewportWidth * pixelRatio
 201  
 202    // Sort by width ascending
 203    const sorted = [...variants].sort((a, b) => a.width - b.width)
 204  
 205    // Find smallest variant >= target width
 206    for (const variant of sorted) {
 207      if (variant.width >= targetWidth) {
 208        return variant
 209      }
 210    }
 211  
 212    // If none large enough, return largest available
 213    return sorted[sorted.length - 1]
 214  }
 215  
 216  /**
 217   * Get the thumbnail variant from a set of variants
 218   */
 219  export function getThumbnailVariant(variants: UploadedVariant[]): UploadedVariant | undefined {
 220    return variants.find((v) => v.variant === 'thumb')
 221  }
 222  
 223  /**
 224   * Get the original variant from a set of variants
 225   */
 226  export function getOriginalVariant(variants: UploadedVariant[]): UploadedVariant | undefined {
 227    return variants.find((v) => v.variant === 'original')
 228  }
 229