35 changed files with 714 additions and 80 deletions
@ -0,0 +1,83 @@ |
|||||||
|
import type { Slide } from 'yet-another-react-lightbox' |
||||||
|
import type { TImetaInfo } from '@/types' |
||||||
|
import { isAudio, isImage, isMedia, isVideo, preferBlossomPrimalDisplayUrl } from '@/lib/url' |
||||||
|
|
||||||
|
function sourceTypeFromPath(url: string, kind: 'video' | 'audio'): string { |
||||||
|
const path = url.split(/[?#]/)[0].toLowerCase() |
||||||
|
if (kind === 'audio') { |
||||||
|
if (path.endsWith('.mka')) return 'audio/x-matroska' |
||||||
|
if (path.endsWith('.opus')) return 'audio/opus' |
||||||
|
if (path.endsWith('.webm')) return 'audio/webm' |
||||||
|
if (path.endsWith('.ogg')) return 'audio/ogg' |
||||||
|
if (path.endsWith('.m4a') || path.endsWith('.aac')) return 'audio/mp4' |
||||||
|
if (path.endsWith('.flac')) return 'audio/flac' |
||||||
|
if (path.endsWith('.wav')) return 'audio/wav' |
||||||
|
return 'audio/mpeg' |
||||||
|
} |
||||||
|
if (path.endsWith('.webm')) return 'video/webm' |
||||||
|
if (path.endsWith('.mkv')) return 'video/x-matroska' |
||||||
|
if (path.endsWith('.ogv')) return 'video/ogg' |
||||||
|
return 'video/mp4' |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a Yet Another React Lightbox slide from imeta (or URL + optional MIME). |
||||||
|
* Uses the Video plugin for video/audio URLs so the lightbox can play what we upload. |
||||||
|
*/ |
||||||
|
export function lightboxSlideFromImeta(info: Pick<TImetaInfo, 'url' | 'alt' | 'm' | 'image'>): Slide { |
||||||
|
const url = preferBlossomPrimalDisplayUrl(info.url) |
||||||
|
const title = info.alt || info.url |
||||||
|
const m = info.m?.toLowerCase() |
||||||
|
|
||||||
|
if (m?.startsWith('image/')) { |
||||||
|
return { src: url, alt: title, title } |
||||||
|
} |
||||||
|
if (m?.startsWith('video/')) { |
||||||
|
return { |
||||||
|
type: 'video', |
||||||
|
title, |
||||||
|
poster: info.image, |
||||||
|
sources: [{ src: url, type: info.m as string }] |
||||||
|
} |
||||||
|
} |
||||||
|
if (m?.startsWith('audio/')) { |
||||||
|
return { |
||||||
|
type: 'video', |
||||||
|
width: 400, |
||||||
|
height: 64, |
||||||
|
title, |
||||||
|
sources: [{ src: url, type: info.m as string }] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (isVideo(url)) { |
||||||
|
return { |
||||||
|
type: 'video', |
||||||
|
title, |
||||||
|
poster: info.image, |
||||||
|
sources: [{ src: url, type: sourceTypeFromPath(url, 'video') }] |
||||||
|
} |
||||||
|
} |
||||||
|
if (isAudio(url)) { |
||||||
|
return { |
||||||
|
type: 'video', |
||||||
|
width: 400, |
||||||
|
height: 64, |
||||||
|
title, |
||||||
|
sources: [{ src: url, type: sourceTypeFromPath(url, 'audio') }] |
||||||
|
} |
||||||
|
} |
||||||
|
if (isImage(url)) { |
||||||
|
return { src: url, alt: title, title } |
||||||
|
} |
||||||
|
if (isMedia(url)) { |
||||||
|
return { |
||||||
|
type: 'video', |
||||||
|
title, |
||||||
|
poster: info.image, |
||||||
|
sources: [{ src: url, type: 'video/mp4' }] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { src: url, alt: title, title } |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { mergeNip94Pairs, nip94PairsToImetaTag } from './upload-nip94-imeta' |
||||||
|
|
||||||
|
describe('mergeNip94Pairs', () => { |
||||||
|
it('keeps client fields when server is empty', () => { |
||||||
|
const client = [ |
||||||
|
['url', 'https://cdn.example/a.webp'], |
||||||
|
['m', 'image/webp'], |
||||||
|
['x', 'abc'], |
||||||
|
['size', '1234'] |
||||||
|
] |
||||||
|
const merged = mergeNip94Pairs(client, []) |
||||||
|
expect(merged).toEqual(client) |
||||||
|
}) |
||||||
|
|
||||||
|
it('lets non-empty server values overwrite client', () => { |
||||||
|
const client = [ |
||||||
|
['url', 'https://cdn.example/a.webp'], |
||||||
|
['m', 'image/webp'], |
||||||
|
['x', 'clienthash'], |
||||||
|
['dim', '100x100'], |
||||||
|
['blurhash', 'clientbh'] |
||||||
|
] |
||||||
|
const server = [ |
||||||
|
['url', 'https://cdn.example/a.webp'], |
||||||
|
['blurhash', 'serverbh'], |
||||||
|
['dim', '200x200'] |
||||||
|
] |
||||||
|
const merged = mergeNip94Pairs(client, server) |
||||||
|
const map = Object.fromEntries(merged.filter(([k]) => k !== 'fallback')) |
||||||
|
expect(map.url).toBe('https://cdn.example/a.webp') |
||||||
|
expect(map.blurhash).toBe('serverbh') |
||||||
|
expect(map.dim).toBe('200x200') |
||||||
|
expect(map.x).toBe('clienthash') |
||||||
|
expect(map.m).toBe('image/webp') |
||||||
|
}) |
||||||
|
|
||||||
|
it('ignores empty server values so client wins', () => { |
||||||
|
const client = [['url', 'https://x.test/u'], ['m', 'image/png'], ['x', 'deadbeef']] |
||||||
|
const server = [['blurhash', ''], ['m', '']] |
||||||
|
const merged = mergeNip94Pairs(client, server) |
||||||
|
const map = Object.fromEntries(merged.filter(([k]) => k !== 'fallback')) |
||||||
|
expect(map.m).toBe('image/png') |
||||||
|
expect(map.blurhash).toBeUndefined() |
||||||
|
}) |
||||||
|
|
||||||
|
it('merges fallback with server first then unique client', () => { |
||||||
|
const client = [ |
||||||
|
['url', 'https://a'], |
||||||
|
['fallback', 'https://mirror1'], |
||||||
|
['fallback', 'https://mirror2'] |
||||||
|
] |
||||||
|
const server = [['fallback', 'https://mirror1'], ['fallback', 'https://srv']] |
||||||
|
const merged = mergeNip94Pairs(client, server) |
||||||
|
const fbs = merged.filter(([k]) => k === 'fallback').map(([, v]) => v) |
||||||
|
expect(fbs).toEqual(['https://mirror1', 'https://srv', 'https://mirror2']) |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
describe('nip94PairsToImetaTag', () => { |
||||||
|
it('builds a single imeta row', () => { |
||||||
|
const row = nip94PairsToImetaTag([ |
||||||
|
['url', 'https://u'], |
||||||
|
['m', 'image/jpeg'] |
||||||
|
]) |
||||||
|
expect(row[0]).toBe('imeta') |
||||||
|
expect(row).toContain('url https://u') |
||||||
|
expect(row).toContain('m image/jpeg') |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,225 @@ |
|||||||
|
import { encode as encodeBlurhash } from 'blurhash' |
||||||
|
|
||||||
|
/** NIP-94-style `[name, value]` rows before folding into a single `imeta` tag (NIP-92). */ |
||||||
|
|
||||||
|
const EXT_TO_MIME: Record<string, string> = { |
||||||
|
'.jpg': 'image/jpeg', |
||||||
|
'.jpeg': 'image/jpeg', |
||||||
|
'.png': 'image/png', |
||||||
|
'.gif': 'image/gif', |
||||||
|
'.webp': 'image/webp', |
||||||
|
'.avif': 'image/avif', |
||||||
|
'.apng': 'image/apng', |
||||||
|
'.svg': 'image/svg+xml', |
||||||
|
'.heic': 'image/heic', |
||||||
|
'.mp4': 'video/mp4', |
||||||
|
'.m4v': 'video/x-m4v', |
||||||
|
'.webm': 'video/webm', |
||||||
|
'.mkv': 'video/x-matroska', |
||||||
|
'.mov': 'video/quicktime', |
||||||
|
'.avi': 'video/x-msvideo', |
||||||
|
'.mp3': 'audio/mpeg', |
||||||
|
'.m4a': 'audio/mp4', |
||||||
|
'.mka': 'audio/x-matroska', |
||||||
|
'.ogg': 'audio/ogg', |
||||||
|
'.opus': 'audio/opus', |
||||||
|
'.wav': 'audio/wav', |
||||||
|
'.flac': 'audio/flac', |
||||||
|
'.aac': 'audio/aac' |
||||||
|
} |
||||||
|
|
||||||
|
function extFromName(name: string): string { |
||||||
|
const i = name.lastIndexOf('.') |
||||||
|
return i >= 0 ? name.slice(i).toLowerCase() : '' |
||||||
|
} |
||||||
|
|
||||||
|
export function guessMimeFromFile(file: File): string | undefined { |
||||||
|
if (file.type && file.type.trim() !== '') { |
||||||
|
return file.type |
||||||
|
} |
||||||
|
const ext = extFromName(file.name) |
||||||
|
return EXT_TO_MIME[ext] |
||||||
|
} |
||||||
|
|
||||||
|
export async function sha256HexOfFile(file: File): Promise<string> { |
||||||
|
const buf = await file.arrayBuffer() |
||||||
|
const digest = await crypto.subtle.digest('SHA-256', buf) |
||||||
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') |
||||||
|
} |
||||||
|
|
||||||
|
async function imageDimensionsFromFile(file: File): Promise<{ width: number; height: number } | null> { |
||||||
|
try { |
||||||
|
const bmp = await createImageBitmap(file) |
||||||
|
const width = bmp.width |
||||||
|
const height = bmp.height |
||||||
|
bmp.close() |
||||||
|
if (width > 0 && height > 0) { |
||||||
|
return { width, height } |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// unsupported decode / HEIC / etc.
|
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
function videoDimensionsFromFile(file: File): Promise<{ width: number; height: number } | null> { |
||||||
|
return new Promise((resolve) => { |
||||||
|
const objectUrl = URL.createObjectURL(file) |
||||||
|
const video = document.createElement('video') |
||||||
|
video.preload = 'metadata' |
||||||
|
video.muted = true |
||||||
|
const done = (dims: { width: number; height: number } | null) => { |
||||||
|
URL.revokeObjectURL(objectUrl) |
||||||
|
resolve(dims) |
||||||
|
} |
||||||
|
video.onloadedmetadata = () => { |
||||||
|
const w = video.videoWidth |
||||||
|
const h = video.videoHeight |
||||||
|
done(w > 0 && h > 0 ? { width: w, height: h } : null) |
||||||
|
} |
||||||
|
video.onerror = () => done(null) |
||||||
|
video.src = objectUrl |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function shouldTryBlurhash(mime: string | undefined): boolean { |
||||||
|
if (!mime || !mime.startsWith('image/')) return false |
||||||
|
if (mime === 'image/svg+xml') return false |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
async function blurhashFromRasterFile(file: File): Promise<string | null> { |
||||||
|
try { |
||||||
|
const bmp = await createImageBitmap(file) |
||||||
|
const maxSide = 64 |
||||||
|
let w = bmp.width |
||||||
|
let h = bmp.height |
||||||
|
const scale = Math.min(1, maxSide / Math.max(w, h, 1)) |
||||||
|
w = Math.max(1, Math.round(w * scale)) |
||||||
|
h = Math.max(1, Math.round(h * scale)) |
||||||
|
const canvas = document.createElement('canvas') |
||||||
|
canvas.width = w |
||||||
|
canvas.height = h |
||||||
|
const ctx = canvas.getContext('2d') |
||||||
|
if (!ctx) { |
||||||
|
bmp.close() |
||||||
|
return null |
||||||
|
} |
||||||
|
ctx.drawImage(bmp, 0, 0, w, h) |
||||||
|
bmp.close() |
||||||
|
const { data } = ctx.getImageData(0, 0, w, h) |
||||||
|
return encodeBlurhash(data, w, h, 4, 3) |
||||||
|
} catch { |
||||||
|
return null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function isNonEmpty(v: string | undefined): boolean { |
||||||
|
return v != null && v.trim() !== '' |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Merge NIP-94 tag pairs: start from client, then let the server overwrite any key |
||||||
|
* it supplies with a non-empty value. `fallback` rows are merged with server first. |
||||||
|
*/ |
||||||
|
export function mergeNip94Pairs(client: string[][], server: string[][]): string[][] { |
||||||
|
const mergedSingle: Record<string, string> = {} |
||||||
|
const clientFallback: string[] = [] |
||||||
|
|
||||||
|
for (const row of client) { |
||||||
|
if (!row?.length || !row[0]) continue |
||||||
|
const k = row[0] |
||||||
|
const v = row[1] ?? '' |
||||||
|
if (k === 'fallback') { |
||||||
|
if (isNonEmpty(v)) clientFallback.push(v) |
||||||
|
} else { |
||||||
|
mergedSingle[k] = v |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const serverFallback: string[] = [] |
||||||
|
for (const row of server) { |
||||||
|
if (!row?.length || !row[0]) continue |
||||||
|
const k = row[0] |
||||||
|
const v = row[1] ?? '' |
||||||
|
if (k === 'fallback') { |
||||||
|
if (isNonEmpty(v)) serverFallback.push(v) |
||||||
|
} else if (isNonEmpty(v)) { |
||||||
|
mergedSingle[k] = v |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const preferredOrder = ['url', 'm', 'x', 'ox', 'size', 'dim', 'blurhash', 'thumb', 'image', 'alt'] |
||||||
|
const used = new Set<string>() |
||||||
|
const out: string[][] = [] |
||||||
|
|
||||||
|
for (const key of preferredOrder) { |
||||||
|
const val = mergedSingle[key] |
||||||
|
if (isNonEmpty(val)) { |
||||||
|
out.push([key, val!]) |
||||||
|
used.add(key) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (const key of Object.keys(mergedSingle)) { |
||||||
|
if (used.has(key)) continue |
||||||
|
const val = mergedSingle[key] |
||||||
|
if (isNonEmpty(val)) { |
||||||
|
out.push([key, val!]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const seenFb = new Set<string>() |
||||||
|
for (const f of serverFallback) { |
||||||
|
out.push(['fallback', f]) |
||||||
|
seenFb.add(f) |
||||||
|
} |
||||||
|
for (const f of clientFallback) { |
||||||
|
if (!seenFb.has(f)) { |
||||||
|
out.push(['fallback', f]) |
||||||
|
seenFb.add(f) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return out |
||||||
|
} |
||||||
|
|
||||||
|
export function nip94PairsToImetaTag(pairs: string[][]): string[] { |
||||||
|
const body = pairs.map(([k, v]) => `${k} ${v}`) |
||||||
|
return ['imeta', ...body] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Client-side NIP-94 fields for the uploaded bytes (post-compression) and final URL. |
||||||
|
*/ |
||||||
|
export async function buildClientNip94Pairs(file: File, url: string): Promise<string[][]> { |
||||||
|
const pairs: string[][] = [['url', url]] |
||||||
|
|
||||||
|
const mime = guessMimeFromFile(file) |
||||||
|
if (mime) { |
||||||
|
pairs.push(['m', mime]) |
||||||
|
} |
||||||
|
|
||||||
|
pairs.push(['x', await sha256HexOfFile(file)]) |
||||||
|
pairs.push(['size', String(file.size)]) |
||||||
|
|
||||||
|
if (mime?.startsWith('image/')) { |
||||||
|
const dim = await imageDimensionsFromFile(file) |
||||||
|
if (dim) { |
||||||
|
pairs.push(['dim', `${dim.width}x${dim.height}`]) |
||||||
|
} |
||||||
|
if (shouldTryBlurhash(mime)) { |
||||||
|
const bh = await blurhashFromRasterFile(file) |
||||||
|
if (bh) { |
||||||
|
pairs.push(['blurhash', bh]) |
||||||
|
} |
||||||
|
} |
||||||
|
} else if (mime?.startsWith('video/')) { |
||||||
|
const dim = await videoDimensionsFromFile(file) |
||||||
|
if (dim) { |
||||||
|
pairs.push(['dim', `${dim.width}x${dim.height}`]) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return pairs |
||||||
|
} |
||||||
Loading…
Reference in new issue