event.ts raw

   1  /**
   2   * Responsive Image Variants - Event Creation/Parsing
   3   *
   4   * Creates and parses NIP-94 File Metadata events (kind 1063)
   5   * for responsive image sets per NIP-XX specification.
   6   */
   7  
   8  import { ImageVariant, VARIANT_ORDER, FILE_METADATA_KIND } from './constants'
   9  
  10  /** Uploaded variant information from Blossom */
  11  export type UploadedVariant = {
  12    variant: ImageVariant
  13    url: string
  14    sha256: string
  15    width: number
  16    height: number
  17    mimeType: string
  18    size?: number
  19    blurhash?: string
  20  }
  21  
  22  /** Options for creating a responsive image event */
  23  export type ResponsiveImageEventOptions = {
  24    /** Description/caption for the image */
  25    description?: string
  26    /** Alt text for accessibility */
  27    alt?: string
  28  }
  29  
  30  /** Draft event structure (unsigned) */
  31  export type DraftEvent = {
  32    kind: number
  33    content: string
  34    tags: string[][]
  35    created_at: number
  36  }
  37  
  38  /**
  39   * Build an imeta tag for a single variant
  40   *
  41   * Format per NIP-92/94:
  42   * ["imeta", "url <url>", "x <sha256>", "m <mime>", "dim <WxH>", "variant <name>", ...]
  43   */
  44  function buildImetaTag(variant: UploadedVariant): string[] {
  45    const tag = ['imeta']
  46  
  47    tag.push(`url ${variant.url}`)
  48    tag.push(`x ${variant.sha256}`)
  49    tag.push(`m ${variant.mimeType}`)
  50    tag.push(`dim ${variant.width}x${variant.height}`)
  51    tag.push(`variant ${variant.variant}`)
  52  
  53    if (variant.size !== undefined) {
  54      tag.push(`size ${variant.size}`)
  55    }
  56  
  57    if (variant.blurhash) {
  58      tag.push(`blurhash ${variant.blurhash}`)
  59    }
  60  
  61    return tag
  62  }
  63  
  64  /**
  65   * Create a NIP-94 File Metadata draft event for a responsive image set
  66   *
  67   * @param variants - Array of uploaded variants (should include original + scaled versions)
  68   * @param options - Optional description and alt text
  69   * @returns DraftEvent ready for signing and publishing
  70   */
  71  export function createResponsiveImageEvent(
  72    variants: UploadedVariant[],
  73    options?: ResponsiveImageEventOptions
  74  ): DraftEvent {
  75    if (variants.length === 0) {
  76      throw new Error('At least one variant is required')
  77    }
  78  
  79    // Sort variants from smallest to largest
  80    const sortedVariants = [...variants].sort(
  81      (a, b) => VARIANT_ORDER.indexOf(a.variant) - VARIANT_ORDER.indexOf(b.variant)
  82    )
  83  
  84    const tags: string[][] = []
  85  
  86    // Add imeta tag for each variant
  87    for (const variant of sortedVariants) {
  88      tags.push(buildImetaTag(variant))
  89    }
  90  
  91    // Add separate x tags for each variant hash (enables NIP-01 tag queries)
  92    for (const variant of sortedVariants) {
  93      tags.push(['x', variant.sha256])
  94    }
  95  
  96    // Add alt tag if provided
  97    if (options?.alt) {
  98      tags.push(['alt', options.alt])
  99    }
 100  
 101    return {
 102      kind: FILE_METADATA_KIND,
 103      content: options?.description ?? '',
 104      tags,
 105      created_at: Math.floor(Date.now() / 1000)
 106    }
 107  }
 108  
 109  /**
 110   * Parse a kind 1063 event to extract variant information
 111   *
 112   * @param event - A kind 1063 event with imeta tags
 113   * @returns Array of parsed variants
 114   */
 115  export function parseResponsiveImageEvent(event: { kind: number; tags: string[][] }): UploadedVariant[] {
 116    if (event.kind !== FILE_METADATA_KIND) {
 117      throw new Error(`Expected kind ${FILE_METADATA_KIND}, got ${event.kind}`)
 118    }
 119  
 120    const variants: UploadedVariant[] = []
 121  
 122    for (const tag of event.tags) {
 123      if (tag[0] !== 'imeta') continue
 124  
 125      const fields = new Map<string, string>()
 126      for (let i = 1; i < tag.length; i++) {
 127        const part = tag[i]
 128        const spaceIndex = part.indexOf(' ')
 129        if (spaceIndex > 0) {
 130          const key = part.substring(0, spaceIndex)
 131          const value = part.substring(spaceIndex + 1)
 132          fields.set(key, value)
 133        }
 134      }
 135  
 136      const url = fields.get('url')
 137      const sha256 = fields.get('x')
 138      const mimeType = fields.get('m')
 139      const dim = fields.get('dim')
 140      const variant = fields.get('variant') as ImageVariant | undefined
 141  
 142      if (!url || !sha256 || !mimeType || !dim) continue
 143  
 144      const dimMatch = dim.match(/^(\d+)x(\d+)$/)
 145      if (!dimMatch) continue
 146  
 147      const width = parseInt(dimMatch[1], 10)
 148      const height = parseInt(dimMatch[2], 10)
 149  
 150      const parsed: UploadedVariant = {
 151        variant: variant ?? 'original',
 152        url,
 153        sha256,
 154        width,
 155        height,
 156        mimeType
 157      }
 158  
 159      const size = fields.get('size')
 160      if (size) parsed.size = parseInt(size, 10)
 161  
 162      const blurhash = fields.get('blurhash')
 163      if (blurhash) parsed.blurhash = blurhash
 164  
 165      variants.push(parsed)
 166    }
 167  
 168    return variants
 169  }
 170  
 171  /**
 172   * Get the thumbnail variant from a set of variants
 173   */
 174  export function getThumbnailVariant(variants: UploadedVariant[]): UploadedVariant | undefined {
 175    return variants.find((v) => v.variant === 'thumb')
 176  }
 177  
 178  /**
 179   * Get the original variant from a set of variants
 180   */
 181  export function getOriginalVariant(variants: UploadedVariant[]): UploadedVariant | undefined {
 182    return variants.find((v) => v.variant === 'original')
 183  }
 184