You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

354 lines
9.8 KiB

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',
'.3gp': 'video/3gpp',
'.3g2': 'video/3gpp2',
'.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
})
}
/** Frame grab + blurhash for one video file (used for NIP-94 before/after main upload). */
export type VideoNip94PreviewResult = {
blurhash: string | null
posterJpeg: File | null
width: number
height: number
}
const VIDEO_PREVIEW_LOAD_MS = 18_000
const VIDEO_PREVIEW_SEEK_MS = 12_000
/**
* Decode one frame from a local video file: JPEG poster (for separate upload) + blurhash + dimensions.
* Never throws; returns null fields on failure (unsupported codec, timeout, etc.).
*/
export async function extractVideoNip94Preview(file: File): Promise<VideoNip94PreviewResult | null> {
if (!file.type.startsWith('video/')) return null
const objectUrl = URL.createObjectURL(file)
const video = document.createElement('video')
video.muted = true
video.playsInline = true
video.preload = 'auto'
const cleanup = () => {
try {
URL.revokeObjectURL(objectUrl)
} catch {
/* ignore */
}
video.removeAttribute('src')
video.load()
}
try {
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('load timeout')), VIDEO_PREVIEW_LOAD_MS)
const done = (err?: Error) => {
clearTimeout(timer)
if (err) reject(err)
else resolve()
}
video.onloadeddata = () => done()
video.onerror = () => done(new Error('video load error'))
video.src = objectUrl
})
const w = video.videoWidth
const h = video.videoHeight
if (!w || !h) return null
const dur = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0
const tseek = dur > 0 ? Math.min(Math.max(0.05, dur * 0.1), dur - 0.05) : 0.1
video.currentTime = tseek
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('seek timeout')), VIDEO_PREVIEW_SEEK_MS)
video.onseeked = () => {
clearTimeout(timer)
resolve()
}
video.onerror = () => {
clearTimeout(timer)
reject(new Error('seek error'))
}
})
const maxPosterSide = 1280
const ps = Math.min(1, maxPosterSide / Math.max(w, h))
const pw = Math.max(1, Math.round(w * ps))
const ph = Math.max(1, Math.round(h * ps))
const canvas = document.createElement('canvas')
canvas.width = pw
canvas.height = ph
const ctx = canvas.getContext('2d')
if (!ctx) return { blurhash: null, posterJpeg: null, width: w, height: h }
ctx.drawImage(video, 0, 0, pw, ph)
const maxBh = 64
const bs = Math.min(1, maxBh / Math.max(pw, ph))
const bw = Math.max(1, Math.round(pw * bs))
const bhH = Math.max(1, Math.round(ph * bs))
const bhCanvas = document.createElement('canvas')
bhCanvas.width = bw
bhCanvas.height = bhH
const bhCtx = bhCanvas.getContext('2d')
let blurhash: string | null = null
if (bhCtx) {
bhCtx.drawImage(canvas, 0, 0, bw, bhH)
try {
const { data } = bhCtx.getImageData(0, 0, bw, bhH)
blurhash = encodeBlurhash(data, bw, bhH, 4, 3)
} catch {
blurhash = null
}
}
const jpegBlob: Blob | null = await new Promise((res) =>
canvas.toBlob((b) => res(b), 'image/jpeg', 0.82)
)
const base = file.name.replace(/\.[^.]+$/, '') || 'video'
const posterJpeg = jpegBlob
? new File([jpegBlob], `${base}-poster.jpg`, { type: 'image/jpeg' })
: null
return { blurhash, posterJpeg, width: w, height: h }
} catch {
return null
} finally {
cleanup()
}
}
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,
videoPreview?: VideoNip94PreviewResult | null
): 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/')) {
let vp = videoPreview
if (!vp) {
vp = await extractVideoNip94Preview(file)
}
const dim =
vp && vp.width > 0 && vp.height > 0
? { width: vp.width, height: vp.height }
: await videoDimensionsFromFile(file)
if (dim) {
pairs.push(['dim', `${dim.width}x${dim.height}`])
}
if (vp?.blurhash) {
pairs.push(['blurhash', vp.blurhash])
}
}
return pairs
}