跳至主要內容

NIP-71: 影片事件

Nostr 影片內容發布標準,支援一般影片與短影音格式

概述

NIP-71 定義了在 Nostr 上發布影片內容的標準格式。它區分了兩種影片類型:一般影片(kind 21)適合橫向內容, 短影音(kind 22)適合直式內容如限時動態或 Reels。這個規範讓 Nostr 能夠支援豐富的影片分享體驗。

事件類型

Kind 21:一般影片

用於標準影片內容,通常是橫向(16:9)格式的影片,如教學、Vlog、音樂錄影帶等。

Kind 22:短影音

用於短影片內容,通常是直式(9:16)格式的影片,類似於 TikTok、Instagram Reels 或 YouTube Shorts。

注意:雖然區分了兩種類型,但沒有技術限制阻止短影音實際上是長的,或一般影片是直式的。 這種區分主要是風格上的建議,幫助客戶端決定如何呈現內容。

事件結構

一般影片事件

{
  "kind": 21,
  "content": "這是一部關於 Nostr 協議的入門教學影片...",
  "tags": [
    ["title", "Nostr 新手入門教學"],
    ["published_at", "1700000000"],
    ["imeta",
      "url https://example.com/video.mp4",
      "m video/mp4",
      "dim 1920x1080",
      "duration 600",
      "bitrate 5000000",
      "x abc123...",
      "image https://example.com/thumb.jpg",
      "fallback https://backup.com/video.mp4"
    ],
    ["alt", "Nostr 協議教學影片,說明如何建立帳號和發送訊息"],
    ["t", "nostr"],
    ["t", "教學"],
    ["p", "<講者公鑰>", "wss://relay.example.com", "host"]
  ]
}

短影音事件

{
  "kind": 22,
  "content": "快速展示如何使用 Nostr 客戶端 #nostr #web3",
  "tags": [
    ["title", "60 秒學會 Nostr"],
    ["published_at", "1700000000"],
    ["imeta",
      "url https://example.com/short.mp4",
      "m video/mp4",
      "dim 1080x1920",
      "duration 60",
      "bitrate 3000000",
      "x def456...",
      "image https://example.com/short-thumb.jpg"
    ],
    ["t", "nostr"],
    ["t", "web3"]
  ]
}

必要標籤

標籤 說明 範例
title 影片標題 ["title", "我的第一支 Nostr 影片"]
content 事件內容欄位,用於影片描述 放在 content 欄位中
標籤 說明 範例
published_at 首次發布的 Unix 時間戳 ["published_at", "1700000000"]
imeta 影片元資料,定義影片變體 見下方詳細說明

imeta 標籤詳解

imeta 標籤是影片元資料的主要來源,包含多個屬性來描述影片檔案:

屬性 說明 範例
url 主要影片來源 URL url https://example.com/video.mp4
m MIME 類型 m video/mp4
dim 解析度(寬x高) dim 1920x1080
duration 影片長度(秒) duration 600
bitrate 平均位元率(bits/sec) bitrate 5000000
x 檔案雜湊值 x abc123...
image 預覽縮圖 URL image https://example.com/thumb.jpg
fallback 備用影片來源 URL fallback https://backup.com/video.mp4
service nip96 參考作者的 NIP-96 伺服器 service nip96

可選標籤

標籤 說明 範例
alt 無障礙描述 ["alt", "影片內容的文字描述"]
text-track WebVTT 字幕/章節檔案 ["text-track", "url", "captions", "zh"]
content-warning NSFW 內容警告 ["content-warning", "包含成人內容"]
segment 章節標記 ["segment", "0", "60", "介紹", "thumb1.jpg"]
t 主題標籤 ["t", "nostr"]
p 參與者公鑰 ["p", "<pubkey>", "relay", "role"]
r 外部參考連結 ["r", "https://example.com/article"]

字幕與章節 (text-track)

text-track 標籤用於添加 WebVTT 格式的字幕、旁白或章節資訊:

{
  "tags": [
    ["text-track", "https://example.com/captions-zh.vtt", "captions", "zh"],
    ["text-track", "https://example.com/captions-en.vtt", "captions", "en"],
    ["text-track", "https://example.com/chapters.vtt", "chapters"]
  ]
}

格式:["text-track", "URL", "類型", "語言代碼(可選)"]

支援的類型:

  • captions - 字幕(包含音效描述)
  • subtitles - 純對話字幕
  • chapters - 章節標記
  • descriptions - 音訊描述

影片章節 (segment)

segment 標籤用於定義影片章節,方便觀眾跳轉到特定部分:

{
  "tags": [
    ["segment", "0", "120", "開場介紹", "https://example.com/thumb1.jpg"],
    ["segment", "120", "300", "核心概念", "https://example.com/thumb2.jpg"],
    ["segment", "300", "480", "實作示範", "https://example.com/thumb3.jpg"],
    ["segment", "480", "600", "總結與 Q&A", "https://example.com/thumb4.jpg"]
  ]
}

格式:["segment", "開始秒數", "結束秒數", "標題", "縮圖URL"]

TypeScript 實作

建立影片事件

import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';

interface VideoMetadata {
  url: string;
  mimeType: string;
  dimensions: { width: number; height: number };
  duration: number;
  bitrate?: number;
  hash?: string;
  thumbnail?: string;
  fallbackUrl?: string;
}

function createVideoEvent(
  title: string,
  description: string,
  metadata: VideoMetadata,
  isShort: boolean = false,
  options?: {
    hashtags?: string[];
    participants?: Array<{ pubkey: string; relay?: string; role?: string }>;
    contentWarning?: string;
    altText?: string;
    segments?: Array<{
      start: number;
      end: number;
      title: string;
      thumbnail?: string;
    }>;
  }
) {
  const kind = isShort ? 22 : 21;

  // 構建 imeta 標籤
  const imetaParts = [
    `url ${metadata.url}`,
    `m ${metadata.mimeType}`,
    `dim ${metadata.dimensions.width}x${metadata.dimensions.height}`,
    `duration ${metadata.duration}`,
  ];

  if (metadata.bitrate) {
    imetaParts.push(`bitrate ${metadata.bitrate}`);
  }
  if (metadata.hash) {
    imetaParts.push(`x ${metadata.hash}`);
  }
  if (metadata.thumbnail) {
    imetaParts.push(`image ${metadata.thumbnail}`);
  }
  if (metadata.fallbackUrl) {
    imetaParts.push(`fallback ${metadata.fallbackUrl}`);
  }

  const tags: string[][] = [
    ['title', title],
    ['published_at', Math.floor(Date.now() / 1000).toString()],
    ['imeta', ...imetaParts],
  ];

  // 添加可選標籤
  if (options?.altText) {
    tags.push(['alt', options.altText]);
  }

  if (options?.contentWarning) {
    tags.push(['content-warning', options.contentWarning]);
  }

  if (options?.hashtags) {
    options.hashtags.forEach(tag => {
      tags.push(['t', tag]);
    });
  }

  if (options?.participants) {
    options.participants.forEach(p => {
      const pTag = ['p', p.pubkey];
      if (p.relay) pTag.push(p.relay);
      if (p.role) pTag.push(p.role);
      tags.push(pTag);
    });
  }

  if (options?.segments) {
    options.segments.forEach(seg => {
      const segTag = [
        'segment',
        seg.start.toString(),
        seg.end.toString(),
        seg.title,
      ];
      if (seg.thumbnail) segTag.push(seg.thumbnail);
      tags.push(segTag);
    });
  }

  return {
    kind,
    content: description,
    tags,
    created_at: Math.floor(Date.now() / 1000),
  };
}

// 使用範例:建立一般影片
const sk = generateSecretKey();
const videoEvent = createVideoEvent(
  'Nostr 完整教學',
  '這支影片會帶你從零開始學習 Nostr 協議...',
  {
    url: 'https://cdn.example.com/video.mp4',
    mimeType: 'video/mp4',
    dimensions: { width: 1920, height: 1080 },
    duration: 1800,
    bitrate: 5000000,
    hash: 'abc123def456',
    thumbnail: 'https://cdn.example.com/thumb.jpg',
    fallbackUrl: 'https://backup.example.com/video.mp4',
  },
  false,
  {
    hashtags: ['nostr', '教學', 'web3'],
    altText: 'Nostr 協議完整教學影片',
    segments: [
      { start: 0, end: 120, title: '什麼是 Nostr' },
      { start: 120, end: 600, title: '建立你的第一個帳號' },
      { start: 600, end: 1200, title: '發送訊息與互動' },
      { start: 1200, end: 1800, title: '進階功能與設定' },
    ],
  }
);

const signedEvent = finalizeEvent(videoEvent, sk);
console.log(signedEvent);

建立短影音

const shortVideoEvent = createVideoEvent(
  '30 秒學會 Zaps',
  '快速了解如何在 Nostr 上發送比特幣打賞! #nostr #bitcoin #zaps',
  {
    url: 'https://cdn.example.com/short.mp4',
    mimeType: 'video/mp4',
    dimensions: { width: 1080, height: 1920 }, // 直式
    duration: 30,
    bitrate: 3000000,
    thumbnail: 'https://cdn.example.com/short-thumb.jpg',
  },
  true, // isShort = true → kind 22
  {
    hashtags: ['nostr', 'bitcoin', 'zaps'],
  }
);

const signedShort = finalizeEvent(shortVideoEvent, sk);

解析影片事件

interface ParsedVideoEvent {
  kind: 21 | 22;
  title: string;
  description: string;
  publishedAt: number;
  metadata: {
    url: string;
    mimeType?: string;
    dimensions?: { width: number; height: number };
    duration?: number;
    bitrate?: number;
    hash?: string;
    thumbnail?: string;
    fallbackUrls: string[];
  };
  hashtags: string[];
  participants: Array<{ pubkey: string; relay?: string; role?: string }>;
  segments: Array<{
    start: number;
    end: number;
    title: string;
    thumbnail?: string;
  }>;
  textTracks: Array<{
    url: string;
    type: string;
    language?: string;
  }>;
  contentWarning?: string;
  altText?: string;
}

function parseVideoEvent(event: any): ParsedVideoEvent | null {
  if (event.kind !== 21 && event.kind !== 22) {
    return null;
  }

  const getTag = (name: string) =>
    event.tags.find((t: string[]) => t[0] === name)?.[1];

  const getAllTags = (name: string) =>
    event.tags.filter((t: string[]) => t[0] === name);

  // 解析 imeta 標籤
  const imetaTag = event.tags.find((t: string[]) => t[0] === 'imeta');
  const metadata: ParsedVideoEvent['metadata'] = {
    url: '',
    fallbackUrls: [],
  };

  if (imetaTag) {
    for (let i = 1; i < imetaTag.length; i++) {
      const part = imetaTag[i];
      if (part.startsWith('url ')) {
        metadata.url = part.slice(4);
      } else if (part.startsWith('m ')) {
        metadata.mimeType = part.slice(2);
      } else if (part.startsWith('dim ')) {
        const [w, h] = part.slice(4).split('x').map(Number);
        metadata.dimensions = { width: w, height: h };
      } else if (part.startsWith('duration ')) {
        metadata.duration = parseInt(part.slice(9));
      } else if (part.startsWith('bitrate ')) {
        metadata.bitrate = parseInt(part.slice(8));
      } else if (part.startsWith('x ')) {
        metadata.hash = part.slice(2);
      } else if (part.startsWith('image ')) {
        metadata.thumbnail = part.slice(6);
      } else if (part.startsWith('fallback ')) {
        metadata.fallbackUrls.push(part.slice(9));
      }
    }
  }

  // 解析參與者
  const participants = getAllTags('p').map((t: string[]) => ({
    pubkey: t[1],
    relay: t[2],
    role: t[3],
  }));

  // 解析章節
  const segments = getAllTags('segment').map((t: string[]) => ({
    start: parseInt(t[1]),
    end: parseInt(t[2]),
    title: t[3],
    thumbnail: t[4],
  }));

  // 解析字幕軌道
  const textTracks = getAllTags('text-track').map((t: string[]) => ({
    url: t[1],
    type: t[2],
    language: t[3],
  }));

  return {
    kind: event.kind,
    title: getTag('title') || '',
    description: event.content,
    publishedAt: parseInt(getTag('published_at') || event.created_at),
    metadata,
    hashtags: getAllTags('t').map((t: string[]) => t[1]),
    participants,
    segments,
    textTracks,
    contentWarning: getTag('content-warning'),
    altText: getTag('alt'),
  };
}

查詢範例

查詢所有影片

import { SimplePool } from 'nostr-tools';

const pool = new SimplePool();
const relays = ['wss://relay.damus.io', 'wss://nos.lol'];

// 查詢所有類型的影片
const allVideos = await pool.querySync(relays, {
  kinds: [21, 22],
  limit: 50,
});

// 只查詢一般影片
const normalVideos = await pool.querySync(relays, {
  kinds: [21],
  limit: 50,
});

// 只查詢短影音
const shortVideos = await pool.querySync(relays, {
  kinds: [22],
  limit: 50,
});

查詢特定用戶的影片

const userVideos = await pool.querySync(relays, {
  kinds: [21, 22],
  authors: ['<user-pubkey>'],
});

查詢特定主題的影片

const nostrVideos = await pool.querySync(relays, {
  kinds: [21, 22],
  '#t': ['nostr'],
});

影片播放器實作建議

客戶端實作影片播放時應考慮以下功能:

基本功能

  • 支援 fallback URL,當主要來源失敗時自動切換
  • 顯示預覽縮圖直到用戶點擊播放
  • 根據 kind 決定預設播放模式(橫式或直式)
  • 顯示影片時長和進度條

進階功能

  • 支援多語言字幕切換
  • 實作章節導航,讓用戶快速跳轉
  • 驗證檔案雜湊確保內容完整性
  • 遵守內容警告標記,預設模糊處理

使用場景

教學平台

  • 發布課程影片與章節標記
  • 提供多語言字幕
  • 結合 NIP-57 實現付費觀看

社群媒體

  • 短影音分享(kind 22)
  • 直式內容的原生支援
  • 標籤分類與發現

新聞與媒體

  • 新聞影片發布
  • 完整字幕支援
  • 參與者標記(記者、受訪者)

最佳實踐

  • 提供多個來源:使用 fallback URL 確保影片可用性
  • 加入縮圖:良好的預覽圖能提高點擊率
  • 添加字幕:提升無障礙性和觸及率
  • 使用章節:幫助觀眾快速找到感興趣的部分
  • 正確標記內容:敏感內容務必加上 content-warning
  • 驗證雜湊:提供檔案雜湊讓客戶端驗證完整性
  • 選擇正確的 kind:橫式長影片用 21,直式短影音用 22
  • NIP-94:檔案中繼資料 - imeta 標籤的基礎
  • NIP-96:檔案儲存 - 影片上傳服務
  • NIP-36:敏感內容 - 內容警告標記
  • NIP-53:直播活動 - 即時影片串流
  • NIP-57:Zaps - 影片打賞

參考資源

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