Last active
May 2, 2025 23:00
-
-
Save jim60105/0b3cbc1ab1fbd3e257fd1d5a2b9a03c6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
已確認可在以下網站執行