Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jim60105/0b3cbc1ab1fbd3e257fd1d5a2b9a03c6 to your computer and use it in GitHub Desktop.
Save jim60105/0b3cbc1ab1fbd3e257fd1d5a2b9a03c6 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Video HTML Capture Stream Recorder
// @name:zh HTML 串流影片錄製器
// @version 0.7
// @description 串流影片錄製器,支援錄製 html5 video element 並下載為 WebM 格式。理論上只要是 html5 <video> 都能錄製。此腳本匹配任何網站,建議僅在需要它時再從 UI 啟用。
// @updateURL https://gist.github.com/jim60105/0b3cbc1ab1fbd3e257fd1d5a2b9a03c6/raw/Video_HTML_Capture_Stream_Recorder.user.js
// @downloadURL https://gist.github.com/jim60105/0b3cbc1ab1fbd3e257fd1d5a2b9a03c6/raw/Video_HTML_Capture_Stream_Recorder.user.js
// @icon https://琳.tw/favicon.svg
// @author 琳(jim60105)
// @match *://*/*
// @grant GM_addStyle
// @grant GM_addElement
// @grant unsafeWindow
// @license GPL3
// ==/UserScript==
/**
* HTML 串流影片錄製器
* 支援錄製任何 HTML5 video 元素並下載為 WebM 格式
* 使用檔案系統存取 API 和 MediaRecorder API 實現高效能錄製
*
* @author 琳(jim60105)
* @license GPL3
*/
(function () {
'use strict';
/**
* 按鈕狀態列舉,用於管理錄製按鈕的不同顯示狀態
* @enum {string}
*/
const ButtonState = {
READY: 'ready', // 準備開始錄製
RECORDING: 'recording', // 正在錄製中
DOWNLOAD_COMPLETE: 'download-complete', // 下載完成
};
// 全域變數聲明
let mediaRecorder; // 媒體錄製器實例
let fsFileHandle; // 檔案系統的檔案控制代碼
let fsWritableStream; // 檔案寫入串流
let btn; // 錄製按鈕元素
let recordingStartTime; // 錄製開始時間
let recordingTimer; // 錄製計時器
let recordingDuration = 0; // 錄製時長(秒)
let fileSize = 0; // 檔案大小(位元組)
let chunks = []; // 儲存影片資料塊的陣列
let isProcessingChunks = false; // 是否正在處理資料塊
let isStreamClosing = false; // 串流是否正在關閉
// 建立 TrustedTypes 政策,用於安全地操作 HTML
const sanitizer = trustedTypes.createPolicy('default', {
createHTML: (input) => input,
});
/**
* 檢查檔案系統 API 是否可用
* 檢測瀏覽器是否支援檔案系統存取 API 或 storage API
*
* @returns {boolean} 是否支援檔案系統存取功能
*/
function isFileSystemAccessSupported() {
// 檢查是否支援 File System Access API 或 storage.getDirectory API
return (
'showSaveFilePicker' in unsafeWindow ||
(unsafeWindow.navigator &&
unsafeWindow.navigator.storage &&
typeof unsafeWindow.navigator.storage.getDirectory === 'function')
);
}
/**
* 取得檔案儲存位置,在預設目錄建立檔案
* 使用 navigator.storage API 在瀏覽器預設目錄中建立檔案
*
* @param {string} suggestedName - 建議的檔案名稱,包含副檔名
* @returns {Promise<FileSystemFileHandle>} 檔案控制代碼物件
* @throws {Error} 當無法取得目錄存取權或建立檔案時會拋出錯誤
*/
async function getFileHandle(suggestedName) {
// 取得預設目錄存取權
const directoryHandle = await unsafeWindow.navigator.storage.getDirectory();
// 在預設目錄建立檔案
const fileHandle = await directoryHandle.getFileHandle(suggestedName, {
create: true,
});
console.log(`已在預設下載目錄建立檔案: ${suggestedName}`);
return fileHandle;
}
/**
* 由檔案控制代碼下載檔案,使用串流方式避免耗盡記憶體
* 先嘗試使用 Streams API 建立串流下載,失敗時回退到傳統下載方式
*
* @param {FileSystemFileHandle} fileHandle - 要下載的檔案控制代碼
* @returns {Promise<void>}
* @throws {Error} 當串流下載和傳統下載都失敗時會拋出錯誤
*/
async function saveFile(fileHandle) {
try {
// 建立檔案名稱
const filename = fileHandle.name;
console.log(`開始準備檔案串流下載: ${filename}`);
// 取得檔案資訊而不需要一次性讀取整個檔案內容
const file = await fileHandle.getFile();
const fileSize = file.size;
console.log(`檔案大小: ${(fileSize / (1024 * 1024)).toFixed(2)} MB`);
// 嘗試使用串流方式下載,保持低記憶體使用率
const readableStream = await file.stream();
// 提示使用者選擇下載位置
const newHandle = await unsafeWindow.showSaveFilePicker({
suggestedName: filename,
types: [
{
description: 'WebM 影片檔',
accept: { 'video/webm': ['.webm'] },
},
],
});
// 取得可寫入的串流
const writableStream = await newHandle.createWritable();
// 利用 stream.pipeTo 將資料從讀取串流導入寫入串流
// 這種方式不會一次性載入整個檔案到記憶體
await readableStream.pipeTo(writableStream);
console.log(`檔案 "${filename}" 下載已完成`);
// 通知使用者下載已完成(UI 提示)
updateButtonState(ButtonState.DOWNLOAD_COMPLETE);
// 刪除暫存檔案
setTimeout(() => {
fileHandle
.remove()
.then(() => {
console.log('暫存檔案已移除');
})
.catch((err) => {
console.warn('移除暫存檔案時發生錯誤:', err);
});
}, 1000);
} catch (error) {
console.error('串流下載檔案時發生錯誤:', error);
// 發生錯誤時,嘗試使用傳統方法下載
try {
console.log('嘗試使用傳統方法下載...');
const file = await fileHandle.getFile();
const filename = fileHandle.name;
const blobURL = URL.createObjectURL(file);
const downloadLink = document.createElement('a');
downloadLink.href = blobURL;
downloadLink.download = filename;
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
// 清理
// 計算基於檔案大小的延遲時間
const fileSizeInMB = file.size / (1024 * 1024);
// 基本延遲時間為 3 秒,每 10MB 增加 1 秒,最大為 30 秒
const baseDelay = 3000; // 基本延遲 3 秒
const sizeBasedDelay = Math.min(
30000,
baseDelay + Math.floor(fileSizeInMB / 10) * 1000
);
console.log(
`檔案大小: ${fileSizeInMB.toFixed(2)} MB,等待時間: ${sizeBasedDelay / 1000} 秒`
);
setTimeout(() => {
if (document.body.contains(downloadLink)) {
document.body.removeChild(downloadLink);
}
URL.revokeObjectURL(blobURL);
updateButtonState(ButtonState.DOWNLOAD_COMPLETE);
// 延遲刪除檔案
setTimeout(() => {
fileHandle
.remove()
.then(() => {
console.log('暫存檔案已移除');
})
.catch((err) => {
console.warn('移除暫存檔案時發生錯誤:', err);
});
}, 1000);
}, sizeBasedDelay);
} catch (fallbackError) {
console.error('使用傳統方法下載失敗:', fallbackError);
alert('下載失敗。錯誤: ' + fallbackError.message);
}
}
}
/**
* 確保有檔案寫入權限
* 檢查並請求寫入檔案的權限,先嘗試查詢權限狀態,若未授權則請求權限
*
* @param {FileSystemFileHandle} fileHandle - 檔案控制代碼
* @returns {Promise<boolean>} 是否已取得寫入權限
*/
async function getWritePermission(fileHandle) {
const writeMode = { mode: 'readwrite' };
if ((await fileHandle.queryPermission(writeMode)) === 'granted') return true;
if ((await fileHandle.requestPermission(writeMode)) === 'granted') return true;
return false;
}
/**
* 取得頁面上的第一個 Video 播放器元素
*
* @returns {HTMLVideoElement|null} Video 播放器元素,若無則返回 null
*/
function getPlayer() {
const videoPlayers = document.querySelectorAll('video');
return videoPlayers[0];
}
/**
* 初始化媒體錄製系統
* 設定 MediaRecorder 並綁定事件處理程序,處理資料擷取和錄製結束
* 支援 VP9 編碼格式,若不支援則使用瀏覽器預設編碼
*
* @param {HTMLVideoElement} player - Video 播放器元素
*/
function initCaptureSystem(player) {
const mediaStream = player.captureStream();
const options = {
mimeType: 'video/webm;codecs=vp9',
videoBitsPerSecond: 5000000,
};
try {
mediaRecorder = new MediaRecorder(mediaStream, options);
} catch (e) {
console.warn('不支援 VP9 編碼,嘗試使用預設編碼:', e);
mediaRecorder = new MediaRecorder(mediaStream);
}
mediaRecorder.ondataavailable = async (e) => {
if (e.data.size > 0) {
chunks.push(e.data);
fileSize += e.data.size;
updateRecordingStats();
if (!isProcessingChunks && fsWritableStream) {
processChunks();
}
console.debug('串流區塊大小:', e.data.size, '位元組');
}
};
mediaRecorder.onstop = async () => {
isStreamClosing = true;
if (fsWritableStream) {
try {
await processAllRemainingChunks();
await fsWritableStream.close();
await saveFile(fsFileHandle);
} catch (error) {
console.error('關閉串流時發生錯誤:', error);
} finally {
fsWritableStream = null;
isStreamClosing = false;
}
}
updateButtonState(ButtonState.DOWNLOAD_COMPLETE);
stopRecordingTimer();
chunks = [];
cleanupMediaRecorder();
const player = getPlayer();
if (player) {
initCaptureSystem(player);
}
};
}
/**
* 清除 MediaRecorder 實例
* 解除所有事件綁定並釋放資源
*/
function cleanupMediaRecorder() {
if (mediaRecorder) {
// 清除事件監聽器
mediaRecorder.ondataavailable = null;
mediaRecorder.onstop = null;
mediaRecorder = null;
}
}
/**
* 處理影片資料塊
* 將資料塊取出並寫入檔案串流,採用非同步處理避免阻塞主線程
* 每次執行只處理一定數量的塊,剩餘的留待下次處理
*
* @async
* @returns {Promise<void>}
*/
async function processChunks() {
if (chunks.length === 0 || !fsWritableStream || isProcessingChunks || isStreamClosing)
return;
isProcessingChunks = true;
try {
while (chunks.length > 0 && !isStreamClosing) {
const chunk = chunks.shift();
const arrayBuffer = await chunk.arrayBuffer();
if (fsWritableStream && !isStreamClosing) {
await fsWritableStream.write(arrayBuffer);
} else {
chunks.unshift(chunk);
break;
}
}
} catch (error) {
console.error('處理資料塊時發生錯誤:', error);
} finally {
isProcessingChunks = false;
}
}
/**
* 處理所有剩餘的資料塊
* 將所有剩餘資料塊合併並一次性寫入,減少 I/O 操作
* 在錄製結束時調用,確保所有資料都被寫入
*
* @async
* @returns {Promise<void>}
*/
async function processAllRemainingChunks() {
if (chunks.length === 0 || !fsWritableStream) return;
try {
// 將所有資料區塊合併成一個 Blob
const blob = new Blob(chunks);
// 轉換為 ArrayBuffer 後一次性寫入,減少 IO 操作
const arrayBuffer = await blob.arrayBuffer();
await fsWritableStream.write(arrayBuffer);
chunks = [];
} catch (error) {
console.error('處理剩餘資料塊時發生錯誤:', error);
}
}
/**
* 更新錄製按鈕的狀態與顯示文字
* 根據不同狀態更新按鈕的樣式與文字
*
* @param {string} state - 按鈕的狀態,使用 ButtonState 列舉值
*/
function updateButtonState(state) {
if (!btn) return;
switch (state) {
case ButtonState.READY:
btn.innerHTML = sanitizer.createHTML('⏺ 開始錄製');
btn.classList.remove('recording', 'completed');
break;
case ButtonState.RECORDING:
btn.innerHTML = sanitizer.createHTML('⏹ 停止錄製');
btn.classList.add('recording');
btn.classList.remove('completed');
break;
case ButtonState.DOWNLOAD_COMPLETE:
btn.innerHTML = sanitizer.createHTML('✅ 下載完成');
btn.classList.add('completed');
btn.classList.remove('recording');
setTimeout(() => {
updateButtonState(ButtonState.READY);
}, 3000);
break;
}
}
/**
* 開始錄製計時器
* 初始化計時器並開始記錄錄製時間和檔案大小
*/
function startRecordingTimer() {
recordingStartTime = Date.now();
recordingDuration = 0;
fileSize = 0;
if (!document.getElementById('recording-stats')) {
const statsDiv = document.createElement('div');
statsDiv.id = 'recording-stats';
document.getElementById('recorder-container').appendChild(statsDiv);
}
recordingTimer = setInterval(() => {
recordingDuration = Math.floor((Date.now() - recordingStartTime) / 1000);
updateRecordingStats();
}, 1000);
updateRecordingStats();
}
/**
* 停止錄製計時器
* 清除計時器並釋放相關資源
*/
function stopRecordingTimer() {
if (recordingTimer) {
clearInterval(recordingTimer);
recordingTimer = null;
}
}
/**
* 更新錄製統計資訊
* 計算並顯示錄製時間和檔案大小
*/
function updateRecordingStats() {
const statsDiv = document.getElementById('recording-stats');
if (!statsDiv) return;
const minutes = Math.floor(recordingDuration / 60);
const seconds = recordingDuration % 60;
const formattedTime = `${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
const formattedSize = (fileSize / (1024 * 1024)).toFixed(2);
statsDiv.innerHTML = sanitizer.createHTML(`
<div>⏱️ 錄製時間: ${formattedTime}</div>
<div>💾 檔案大小: ${formattedSize} MB</div>
`);
}
/**
* 建立使用者介面,包含錄製按鈕和統計資訊區域
* 設定固定位置的浮動控制面板和錄製按鈕樣式
* 註冊按鈕點擊事件處理器來開始或停止錄製
*/
function createUI() {
GM_addStyle(`
#recorder-container {
position: fixed;
left: 20px;
top: 20px;
z-index: 9999;
background: rgba(0,0,0,0.7);
padding: 10px;
border-radius: 8px;
color: white;
font-family: Arial, sans-serif;
transition: all 0.3s ease;
}
#recorder-button {
padding: 10px 15px;
background: #ff4757;
color: white;
border: none;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
#recorder-button:hover {
background: #ff6b81;
}
#recorder-button.recording {
background: #ff6348;
animation: pulse 1.5s infinite;
}
#recorder-button.completed {
background: #2ed573;
}
#recording-stats {
margin-top: 10px;
font-size: 12px;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
`);
const container = GM_addElement(document.body, 'div', {
id: 'recorder-container',
});
btn = GM_addElement(container, 'button', {
id: 'recorder-button',
textContent: '⏺ 開始錄製',
});
updateButtonState(ButtonState.READY);
btn.onclick = async () => {
if (!mediaRecorder) return;
if (mediaRecorder.state === 'recording') {
mediaRecorder.stop();
} else {
try {
const pageTitle = document.title || '影片';
const sanitizedTitle = pageTitle
.replace(/[\\/:*?"<>|.,;=+~`!@#$%^&()\[\]{}]/g, '_')
.trim();
const filename = `${sanitizedTitle}_${new Date()
.toISOString()
.slice(0, 19)}.webm`;
if (isFileSystemAccessSupported()) {
try {
fsFileHandle = await getFileHandle(filename);
if (!(await getWritePermission(fsFileHandle))) {
throw new Error('未授予檔案寫入權限');
}
fsWritableStream = await fsFileHandle.createWritable();
fileSize = 0;
chunks = [];
isStreamClosing = false;
mediaRecorder.start(1000);
startRecordingTimer();
updateButtonState(ButtonState.RECORDING);
} catch (error) {
console.error('存取檔案系統時發生錯誤:', error);
throw error;
}
} else {
throw new Error('您的瀏覽器不支援檔案系統存取 API');
}
} catch (error) {
console.error('建立檔案時發生錯誤:', error);
alert('無法建立檔案,請檢查瀏覽器權限或重試。錯誤: ' + error.message);
}
}
};
}
/**
* 腳本初始化函式
* 檢查環境相容性並啟動影片錄製系統
* 確認瀏覽器支援 MediaRecorder API 和檔案系統存取
*
* @async
*/
const init = async () => {
if (!window.MediaRecorder) {
console.error('您的瀏覽器不支援 MediaRecorder API,無法使用錄製功能。');
return;
}
if (!isFileSystemAccessSupported()) {
console.warn(
'您的瀏覽器不支援檔案系統存取 API,請使用 Chrome、Edge 或 Opera 最新版本。'
);
}
const checkPlayer = () => {
try {
const player = getPlayer();
if (player) {
// 找到播放器,初始化錄製系統
initCaptureSystem(player);
createUI();
console.log('影片錄製系統已初始化');
} else {
console.warn('沒有找到影片播放器');
}
} catch (error) {
console.error('初始化錄製系統時發生錯誤:', error);
}
};
// 延遲執行檢查,確保頁面已完全載入
setTimeout(checkPlayer, 1000);
};
/**
* 監聽頁面關閉事件,防止使用者在錄製中意外關閉頁面
* 顯示確認訊息提醒使用者有錄製進行中
*/
window.addEventListener('beforeunload', (event) => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
event.preventDefault();
event.returnValue = '錄製尚未完成,確定要離開嗎?';
return event.returnValue;
}
});
/**
* 頁面即將卸載時,嘗試停止正在進行的錄製
* 確保資源被適當釋放
*/
window.addEventListener('unload', async () => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
try {
mediaRecorder.stop();
} catch (e) {
console.error('停止錄製時發生錯誤:', e);
}
}
});
/**
* 頁面載入完成後初始化腳本
* 確保 DOM 已完全載入再開始初始化
*/
window.addEventListener('load', init);
})();
@jim60105
Copy link
Author

jim60105 commented May 2, 2025

已確認可在以下網站執行

  • Youtube
  • Twitch
  • Twitcasting
  • Zaiko

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment