NIP-92: 媒體附件
在 Nostr 事件中嵌入圖片、影片和檔案的標準方法
概述
NIP-92 定義了在 Nostr 事件中包含媒體附件(圖片、影片和其他檔案)的標準方法。
透過在事件內容中包含 URL,並搭配對應的 imeta 標籤提供元資料, 客戶端可以顯示豐富的媒體預覽和資訊。
基本結構
媒體附件由兩部分組成:
- 事件內容中的媒體 URL
- 對應的
imeta標籤包含元資料
{
"kind": 1,
"content": "看看這張美麗的風景照!\n\nhttps://example.com/photo.jpg",
"tags": [
["imeta",
"url https://example.com/photo.jpg",
"m image/jpeg",
"dim 3024x4032",
"blurhash LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
"alt 一張美麗的山景照片,有藍天白雲"
]
]
} imeta 標籤格式
imeta 標籤是可變長度的,使用空格分隔的鍵值對:
["imeta", "key1 value1", "key2 value2", ...] 必要欄位
| 欄位 | 說明 | 必要性 |
|---|---|---|
url | 媒體檔案的 URL | 必要 |
| 其他欄位 | 至少需要一個額外欄位 | 必要 |
常用欄位(來自 NIP-94)
| 欄位 | 說明 | 範例 |
|---|---|---|
m | MIME 類型 | m image/jpeg |
dim | 尺寸(寬x高) | dim 1920x1080 |
blurhash | 模糊預覽雜湊 | blurhash LKO2?U%2Tw=w... |
alt | 無障礙描述 | alt 照片描述文字 |
x | SHA256 雜湊 | x abc123... |
fallback | 備用 URL | fallback https://backup.com/file.jpg |
size | 檔案大小(位元組) | size 1048576 |
範例
單張圖片
{
"kind": 1,
"content": "今天的日落真美!\n\nhttps://cdn.example.com/sunset.jpg",
"tags": [
["imeta",
"url https://cdn.example.com/sunset.jpg",
"m image/jpeg",
"dim 4032x3024",
"blurhash L6PZfSi_.AyE_3t7t7R**0o#DgR4",
"alt 海邊日落,天空呈現橙紅色漸層"
]
]
} 多張圖片
{
"kind": 1,
"content": "週末旅行的照片集:\n\nhttps://cdn.example.com/trip1.jpg\nhttps://cdn.example.com/trip2.jpg\nhttps://cdn.example.com/trip3.jpg",
"tags": [
["imeta",
"url https://cdn.example.com/trip1.jpg",
"m image/jpeg",
"dim 3024x4032",
"alt 山頂風景"
],
["imeta",
"url https://cdn.example.com/trip2.jpg",
"m image/jpeg",
"dim 4032x3024",
"alt 湖邊露營"
],
["imeta",
"url https://cdn.example.com/trip3.jpg",
"m image/jpeg",
"dim 3024x4032",
"alt 夜晚星空"
]
]
} 影片附件
{
"kind": 1,
"content": "剛錄的教學影片:\n\nhttps://cdn.example.com/tutorial.mp4",
"tags": [
["imeta",
"url https://cdn.example.com/tutorial.mp4",
"m video/mp4",
"dim 1920x1080",
"size 52428800",
"alt Nostr 入門教學影片"
]
]
} 帶備用連結的圖片
{
"kind": 1,
"content": "重要的圖片:\n\nhttps://cdn.example.com/important.png",
"tags": [
["imeta",
"url https://cdn.example.com/important.png",
"m image/png",
"dim 800x600",
"x e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"fallback https://backup.example.com/important.png",
"fallback https://ipfs.io/ipfs/QmHash..."
]
]
} TypeScript 實作
建立帶媒體附件的事件
import { finalizeEvent, generateSecretKey } from 'nostr-tools';
interface MediaAttachment {
url: string;
mimeType?: string;
dimensions?: { width: number; height: number };
blurhash?: string;
alt?: string;
sha256?: string;
size?: number;
fallbackUrls?: string[];
}
function createMediaEvent(
content: string,
attachments: MediaAttachment[],
secretKey: Uint8Array
) {
// 建立 imeta 標籤
const imetaTags = attachments.map((attachment) => {
const parts: string[] = [`url ${attachment.url}`];
if (attachment.mimeType) {
parts.push(`m ${attachment.mimeType}`);
}
if (attachment.dimensions) {
parts.push(`dim ${attachment.dimensions.width}x${attachment.dimensions.height}`);
}
if (attachment.blurhash) {
parts.push(`blurhash ${attachment.blurhash}`);
}
if (attachment.alt) {
parts.push(`alt ${attachment.alt}`);
}
if (attachment.sha256) {
parts.push(`x ${attachment.sha256}`);
}
if (attachment.size) {
parts.push(`size ${attachment.size}`);
}
if (attachment.fallbackUrls) {
attachment.fallbackUrls.forEach((url) => {
parts.push(`fallback ${url}`);
});
}
return ['imeta', ...parts];
});
// 將 URL 附加到內容中
const urlList = attachments.map((a) => a.url).join('\n');
const fullContent = content + (urlList ? `\n\n${urlList}` : '');
const event = {
kind: 1,
content: fullContent,
tags: imetaTags,
created_at: Math.floor(Date.now() / 1000),
};
return finalizeEvent(event, secretKey);
}
// 使用範例
const sk = generateSecretKey();
const event = createMediaEvent(
'今天拍的風景照!',
[
{
url: 'https://cdn.example.com/landscape.jpg',
mimeType: 'image/jpeg',
dimensions: { width: 4032, height: 3024 },
blurhash: 'LKO2?U%2Tw=w]~RBVZRi};RPxuwH',
alt: '山間的美麗風景',
sha256: 'abc123...',
},
],
sk
);
console.log(event); 解析媒體附件
interface ParsedMedia {
url: string;
mimeType?: string;
dimensions?: { width: number; height: number };
blurhash?: string;
alt?: string;
sha256?: string;
size?: number;
fallbackUrls: string[];
}
function parseMediaAttachments(event: any): ParsedMedia[] {
const imetaTags = event.tags.filter((t: string[]) => t[0] === 'imeta');
return imetaTags.map((tag: string[]) => {
const media: ParsedMedia = {
url: '',
fallbackUrls: [],
};
for (let i = 1; i < tag.length; i++) {
const part = tag[i];
const spaceIndex = part.indexOf(' ');
if (spaceIndex === -1) continue;
const key = part.slice(0, spaceIndex);
const value = part.slice(spaceIndex + 1);
switch (key) {
case 'url':
media.url = value;
break;
case 'm':
media.mimeType = value;
break;
case 'dim':
const [w, h] = value.split('x').map(Number);
media.dimensions = { width: w, height: h };
break;
case 'blurhash':
media.blurhash = value;
break;
case 'alt':
media.alt = value;
break;
case 'x':
media.sha256 = value;
break;
case 'size':
media.size = parseInt(value);
break;
case 'fallback':
media.fallbackUrls.push(value);
break;
}
}
return media;
});
}
// 使用範例
const attachments = parseMediaAttachments(event);
attachments.forEach((media, i) => {
console.log(`附件 ${i + 1}:`);
console.log(` URL: ${media.url}`);
console.log(` 類型: ${media.mimeType}`);
console.log(` 尺寸: ${media.dimensions?.width}x${media.dimensions?.height}`);
console.log(` 描述: ${media.alt}`);
}); 上傳並附加媒體
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';
import { encode } from 'blurhash';
async function uploadAndAttach(
file: File,
uploadEndpoint: string
): Promise<MediaAttachment> {
// 讀取檔案
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
// 計算 SHA256
const hash = bytesToHex(sha256(bytes));
// 上傳檔案(假設返回 URL)
const formData = new FormData();
formData.append('file', file);
const response = await fetch(uploadEndpoint, {
method: 'POST',
body: formData,
});
const { url } = await response.json();
// 如果是圖片,取得尺寸和 blurhash
let dimensions: { width: number; height: number } | undefined;
let blurhash: string | undefined;
if (file.type.startsWith('image/')) {
const img = await loadImage(file);
dimensions = { width: img.width, height: img.height };
blurhash = await generateBlurhash(img);
}
return {
url,
mimeType: file.type,
dimensions,
blurhash,
sha256: hash,
size: file.size,
};
}
async function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
async function generateBlurhash(img: HTMLImageElement): Promise<string> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// 縮小尺寸以加快計算
const scale = 32 / Math.max(img.width, img.height);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
return encode(imageData.data, imageData.width, imageData.height, 4, 3);
} 客戶端行為
規範允許客戶端有以下行為:
- 豐富預覽:客戶端可以將 imeta URL 替換為豐富的預覽顯示
- 忽略不匹配:客戶端可以忽略與內容中 URL 不匹配的 imeta 標籤
- 上傳流程:上傳完成後附加元資料
- 貼上流程:在發布前下載並分析檔案
最佳實踐
- 總是包含 alt 文字:提升無障礙性
- 使用 blurhash:提供載入中的預覽
- 包含尺寸資訊:幫助客戶端預留空間
- 提供備用連結:確保媒體可用性
- 驗證雜湊值:確保內容完整性
- 每個 URL 一個 imeta:不要重複標籤
相關 NIPs
參考資源
已複製連結