進階
NIP-94 檔案中繼資料
深入了解 Nostr 的檔案中繼資料標準,使用 kind 1063 事件描述檔案的 URL、類型、大小等資訊。
8 分鐘
什麼是 NIP-94?
NIP-94 定義了描述檔案中繼資料的標準格式。它使用 kind 1063 事件來儲存檔案的 URL、MIME 類型、檔案大小、雜湊值、圖片尺寸等資訊。這個標準讓客戶端可以 在下載前了解檔案詳情,並驗證檔案完整性。
與 NIP-96 的關係: NIP-96 檔案上傳伺服器會在回應中返回 NIP-94 格式的中繼資料, 讓客戶端可以直接使用這些資訊。
事件結構
{
"kind": 1063,
"content": "這是我的照片描述",
"tags": [
["url", "https://media.nostr.build/abc123.jpg"],
["m", "image/jpeg"],
["x", "a1b2c3d4e5f6..."],
["ox", "original-hash..."],
["size", "1234567"],
["dim", "1920x1080"],
["blurhash", "LEHV6nWB2yk8pyo0adR*.7kCMdnj"],
["thumb", "https://media.nostr.build/abc123_thumb.jpg"],
["image", "https://media.nostr.build/abc123_preview.jpg"],
["summary", "日落時分的山景照片"],
["alt", "橙紅色天空下的山脈剪影"]
],
"pubkey": "<上傳者公鑰>",
"created_at": 1704067200,
"id": "...",
"sig": "..."
} 標籤說明
| 標籤 | 必須 | 說明 |
|---|---|---|
| url | 是 | 檔案的下載 URL |
| m | 建議 | MIME 類型(如 image/jpeg、video/mp4) |
| x | 建議 | 檔案的 SHA-256 雜湊(十六進位) |
| ox | 可選 | 原始檔案的 SHA-256(處理前) |
| size | 可選 | 檔案大小(bytes) |
| dim | 可選 | 圖片/影片尺寸(寬x高) |
| blurhash | 可選 | 模糊預覽雜湊(用於載入中預覽) |
| thumb | 可選 | 縮圖 URL |
| image | 可選 | 預覽圖 URL(用於影片) |
| summary | 可選 | 檔案摘要描述 |
| alt | 可選 | 替代文字(無障礙) |
| fallback | 可選 | 備用下載 URL |
常見 MIME 類型
圖片
- image/jpeg
- image/png
- image/gif
- image/webp
- image/svg+xml
影片
- video/mp4
- video/webm
- video/ogg
- video/quicktime
音訊
- audio/mpeg
- audio/ogg
- audio/wav
- audio/webm
程式碼範例
建立檔案中繼資料事件
async function createFileMetadataEvent(fileInfo) {
const {
url,
mimeType,
hash,
originalHash,
size,
width,
height,
blurhash,
thumbnailUrl,
description,
altText
} = fileInfo
const event = {
kind: 1063,
content: description || '',
created_at: Math.floor(Date.now() / 1000),
tags: [
['url', url],
['m', mimeType]
]
}
// 添加可選標籤
if (hash) event.tags.push(['x', hash])
if (originalHash) event.tags.push(['ox', originalHash])
if (size) event.tags.push(['size', size.toString()])
if (width && height) event.tags.push(['dim', `${width}x${height}`])
if (blurhash) event.tags.push(['blurhash', blurhash])
if (thumbnailUrl) event.tags.push(['thumb', thumbnailUrl])
if (altText) event.tags.push(['alt', altText])
// 簽名並發布
const signedEvent = await window.nostr.signEvent(event)
await relay.publish(signedEvent)
return signedEvent
}
// 使用範例
const fileEvent = await createFileMetadataEvent({
url: 'https://media.nostr.build/abc123.jpg',
mimeType: 'image/jpeg',
hash: 'a1b2c3d4...',
size: 1234567,
width: 1920,
height: 1080,
blurhash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
description: '美麗的日落',
altText: '橙色天空下的山脈'
}) 解析檔案中繼資料
function parseFileMetadata(event) {
if (event.kind !== 1063) {
throw new Error('Not a file metadata event')
}
const getTag = (name) => event.tags.find(t => t[0] === name)?.[1]
const dim = getTag('dim')
let width, height
if (dim) {
[width, height] = dim.split('x').map(Number)
}
return {
url: getTag('url'),
mimeType: getTag('m'),
hash: getTag('x'),
originalHash: getTag('ox'),
size: getTag('size') ? parseInt(getTag('size')) : null,
width,
height,
blurhash: getTag('blurhash'),
thumbnailUrl: getTag('thumb'),
previewUrl: getTag('image'),
summary: getTag('summary'),
altText: getTag('alt'),
fallbackUrl: getTag('fallback'),
description: event.content,
pubkey: event.pubkey,
createdAt: event.created_at
}
}
// 判斷檔案類型
function getFileType(mimeType) {
if (!mimeType) return 'unknown'
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
if (mimeType.startsWith('audio/')) return 'audio'
if (mimeType.startsWith('application/pdf')) return 'pdf'
return 'file'
} 驗證檔案完整性
async function verifyFileIntegrity(metadata) {
const { url, hash, size } = metadata
// 下載檔案
const response = await fetch(url)
const blob = await response.blob()
// 驗證大小
if (size && blob.size !== size) {
return {
valid: false,
error: `大小不匹配: 預期 ${size}, 實際 ${blob.size}`
}
}
// 驗證雜湊
if (hash) {
const buffer = await blob.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const actualHash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
if (actualHash !== hash) {
return {
valid: false,
error: '雜湊不匹配,檔案可能已被竄改'
}
}
}
return { valid: true }
}
// 使用
const result = await verifyFileIntegrity(fileMetadata)
if (!result.valid) {
console.error('檔案驗證失敗:', result.error)
} BlurHash 預覽
BlurHash 是一種將圖片編碼為短字串的演算法,用於在圖片載入前顯示模糊預覽:
import { decode } from 'blurhash'
function renderBlurhash(blurhash, width, height, canvas) {
// 解碼 blurhash
const pixels = decode(blurhash, width, height)
// 建立 ImageData
const imageData = new ImageData(
new Uint8ClampedArray(pixels),
width,
height
)
// 繪製到 canvas
const ctx = canvas.getContext('2d')
ctx.putImageData(imageData, 0, 0)
}
// 在圖片載入前顯示 blurhash
function ImageWithBlurhash({ url, blurhash, width, height, alt }) {
const [loaded, setLoaded] = useState(false)
const canvasRef = useRef(null)
useEffect(() => {
if (blurhash && canvasRef.current) {
renderBlurhash(blurhash, 32, 32, canvasRef.current)
}
}, [blurhash])
return (
<div style={{ position: 'relative', width, height }}>
{!loaded && blurhash && (
<canvas
ref={canvasRef}
width={32}
height={32}
style={{
position: 'absolute',
width: '100%',
height: '100%',
filter: 'blur(20px)',
transform: 'scale(1.2)'
}}
/>
)}
<img
src={url}
alt={alt}
onLoad={() => setLoaded(true)}
style={{
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s'
}}
/>
</div>
)
} 在貼文中引用媒體
使用 imeta 標籤在貼文中內嵌 NIP-94 風格的中繼資料:
// 在 kind 1 貼文中嵌入媒體中繼資料
{
"kind": 1,
"content": "今天拍的照片! https://media.nostr.build/abc.jpg",
"tags": [
["imeta",
"url https://media.nostr.build/abc.jpg",
"m image/jpeg",
"dim 1920x1080",
"x a1b2c3d4...",
"blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj",
"alt 美麗的風景照片"
]
]
}
// imeta 標籤格式
// 每個屬性以 "key value" 格式
// 多個屬性作為 imeta 標籤的元素
// 多個媒體時使用多個 imeta 標籤
{
"kind": 1,
"content": "兩張照片 https://a.jpg https://b.jpg",
"tags": [
["imeta", "url https://a.jpg", "m image/jpeg", "dim 800x600"],
["imeta", "url https://b.jpg", "m image/png", "dim 1200x900"]
]
} 查詢檔案事件
// 查詢特定用戶上傳的所有檔案
function getUserFiles(relay, pubkey, limit = 50) {
return new Promise((resolve) => {
const files = []
const sub = relay.subscribe([{
kinds: [1063],
authors: [pubkey],
limit
}])
sub.on('event', (event) => {
files.push(parseFileMetadata(event))
})
sub.on('eose', () => {
sub.close()
resolve(files)
})
})
}
// 查詢特定類型的檔案
function getFilesByType(relay, mimeTypePrefix, limit = 50) {
return new Promise((resolve) => {
const files = []
const sub = relay.subscribe([{
kinds: [1063],
limit: limit * 3 // 過量請求以過濾
}])
sub.on('event', (event) => {
const metadata = parseFileMetadata(event)
if (metadata.mimeType?.startsWith(mimeTypePrefix)) {
files.push(metadata)
}
})
sub.on('eose', () => {
sub.close()
resolve(files.slice(0, limit))
})
})
}
// 使用
const images = await getFilesByType(relay, 'image/', 20)
const videos = await getFilesByType(relay, 'video/', 10) 最佳實踐
建議做法
- • 總是提供 MIME 類型和雜湊
- • 為圖片添加 blurhash 改善體驗
- • 提供 alt 文字確保無障礙
- • 使用 fallback 提供備用來源
- • 下載前驗證檔案雜湊
注意事項
- • URL 可能會失效
- • 大檔案可能載入緩慢
- • 不是所有客戶端都支援所有格式
- • 注意檔案大小限制
提示: NIP-94 中繼資料通常由 NIP-96 伺服器自動生成。 當你使用支援的客戶端上傳檔案時,這些標籤會自動填入。
已複製連結