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