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