跳至主要內容

NIP-92: 媒體附件

在 Nostr 事件中嵌入圖片、影片和檔案的標準方法

概述

NIP-92 定義了在 Nostr 事件中包含媒體附件(圖片、影片和其他檔案)的標準方法。 透過在事件內容中包含 URL,並搭配對應的 imeta 標籤提供元資料, 客戶端可以顯示豐富的媒體預覽和資訊。

基本結構

媒體附件由兩部分組成:

  1. 事件內容中的媒體 URL
  2. 對應的 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:不要重複標籤
  • NIP-94:檔案中繼資料 - imeta 欄位來源
  • NIP-96:檔案儲存 - 媒體上傳服務
  • NIP-71:影片事件 - 專門的影片格式
  • NIP-36:敏感內容 - 內容警告

參考資源

已複製連結
已複製到剪貼簿