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
相關 NIPs
- NIP-94:檔案中繼資料 - imeta 標籤的基礎
- NIP-96:檔案儲存 - 影片上傳服務
- NIP-36:敏感內容 - 內容警告標記
- NIP-53:直播活動 - 即時影片串流
- NIP-57:Zaps - 影片打賞
參考資源
已複製連結