Appearance
实现 屏幕录制 工具
分类:general | 标签:录屏、录制、WebM 浏览器屏幕录制,导出 WebM
2.1 功能说明
浏览器屏幕录制,导出 WebM
核心能力
- 采集麦克风
- 摄像头画中画
- 小窗位置
- rb
- lb
- rt
- lt
- 帧率
- 15 fps
- 24 fps
- 30 fps
- 60 fps
2.1.1 使用指南
屏幕录制使用说明
本工具使用浏览器原生的 getDisplayMedia 与 MediaRecorder 录屏, 画面与声音全程留在本机内存,不会上传到任何服务器,录制完成后直接在浏览器里生成文件供下载。
操作步骤
1. 按需打开“采集麦克风”和“摄像头画中画”,选择帧率。
2. 点击“开始录制”,浏览器会弹出共享选择框,可选择整个屏幕、某个应用窗口或某个浏览器标签页。
3. 想录制标签页里的声音,请在共享选择框中勾选“分享标签页音频”或“分享系统音频”,这个开关由浏览器提供,工具无法代为勾选。
4. 录制过程中可暂停与继续,点击“停止并生成文件”后即可预览和下载。
运行环境要求
屏幕采集属于敏感权限,只在 https 站点或 http://localhost 下可用,通过 IP 明文访问会被浏览器直接拒绝。 桌面版 Chrome、Edge、Firefox、Opera 支持良好,Safari 从 13 起部分支持; 绝大多数移动端浏览器(含 iOS Safari、微信内置浏览器)不提供屏幕采集能力,这类环境下工具会给出提示而不是崩溃。
关于声音
系统声音能否录到取决于操作系统与共享方式:Windows 下共享整个屏幕或标签页通常可以带声音; macOS 出于系统限制,共享整屏时一般拿不到系统声音,只能录到麦克风。 同时开启麦克风与系统声音时,工具用 Web Audio 把两路声音混合成一路后写入文件。 预览窗口刻意静音,避免麦克风采到扬声器声音造成啸叫。
关于输出格式
浏览器录制的原生格式是 WebM(VP9 或 VP8 视频加 Opus 音频),工具会自动挑选当前浏览器支持的编码组合。 WebM 可以直接用 Chrome、Edge、VLC、PotPlayer 播放,也能被剪映、Premiere 等主流剪辑软件导入。 需要 MP4 时请用任意转换工具转封装,纯前端转码代价过高,此处不提供。
常见问题
点开始后没有弹窗:多为权限被永久拒绝,点击地址栏的权限图标重新允许,或检查系统层面的屏幕录制权限(macOS 需在“系统设置 - 隐私与安全性 - 屏幕录制”中授权浏览器)。
录制中途自动停止:说明你在浏览器底部的共享提示条上点了“停止共享”,工具会自动收尾并生成已录制的部分,内容不会丢失。
文件时长显示异常:部分播放器读取 WebM 流式文件的时长信息不完整,属于容器特性,画面内容是完整的,用支持的播放器或转封装后即可正常显示。
录制很久后文件很大:可以降低帧率,或分段录制。长时间录制请留意设备内存占用。
使用摄像头画中画的注意事项
开启画中画后,工具需要把屏幕画面与摄像头画面实时合成到一张画布上再编码,这个合成动作由本页面的定时器驱动。 浏览器会限制后台标签页里定时器的执行频率,因此建议共享“整个屏幕”或“某个应用窗口”, 并让本工具所在的标签页保持可见(例如放在另一台显示器或分屏的一侧)。 如果共享的是另一个浏览器标签页而本页被切到后台,合成帧率可能下降。 不开启画中画时,工具直接录制原始屏幕轨道,不受该限制影响。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
formatTime()p()pickMimeType()humanSize()stopStream()cleanupAll()buildAudioTrack()drawPip()buildVideoTrack()start()togglePause()stop()finalize()download()discard()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
const supported = ref(true)
const unsupportedReason = ref('')
const withMic = ref(false)
const withCamera = ref(false)
const pipPosition = ref('rb')
const pipRatio = ref(22)
const fps = ref(24)
const recording = ref(false)
const paused = ref(false)
const elapsed = ref(0)
const error = ref('')
const resultUrl = ref('')
const resultDuration = ref(0)
const resultSize = ref('')
const livePreview = ref(null)
const cameraVideo = ref(null)
const mixCanvas = ref(null)
let screenStream = null
let micStream = null
let camStream = null
let mixedStream = null
let audioCtx = null
let recorder = null
let chunks = []
let timer = null
let drawTimer = null
let screenVideoEl = null
const statusText = computed(() => {
if (!supported.value) return '不可用'
if (paused.value) return '已暂停'
if (recording.value) return '录制中'
return '待机'
})
const statusType = computed(() => {
if (!supported.value) return 'danger'
if (paused.value) return 'warning'
if (recording.value) return 'success'
return 'info'
})
function formatTime(sec) {
const s = Math.max(0, Math.floor(sec))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const ss = s % 60
const p = (n) => String(n).padStart(2, '0')
return h > 0 ? p(h) + ':' + p(m) + ':' + p(ss) : p(m) + ':' + p(ss)
}
const elapsedText = computed(() => formatTime(elapsed.value))
function pickMimeType() {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm'
]
for (let i = 0; i < candidates.length; i += 1) {
try {
if (window.MediaRecorder && window.MediaRecorder.isTypeSupported(candidates[i])) return candidates[i]
} catch (e) {
// 忽略检测异常,继续尝试下一个
}
}
return ''
}
function humanSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
function stopStream(stream) {
if (!stream) return
try {
stream.getTracks().forEach((t) => t.stop())
} catch (e) {
// 忽略停止轨道时的异常
}
}
function cleanupAll() {
if (timer) {
window.clearInterval(timer)
timer = null
}
if (drawTimer) {
window.clearInterval(drawTimer)
drawTimer = null
}
stopStream(screenStream)
stopStream(micStream)
stopStream(camStream)
screenStream = null
micStream = null
camStream = null
mixedStream = null
if (audioCtx) {
try {
audioCtx.close()
} catch (e) {
// 忽略关闭音频上下文的异常
}
audioCtx = null
}
if (screenVideoEl) {
try {
screenVideoEl.srcObject = null
} catch (e) {
// 忽略清理异常
}
screenVideoEl = null
}
if (livePreview.value) livePreview.value.srcObject = null
if (cameraVideo.value) cameraVideo.value.srcObject = null
}
function buildAudioTrack() {
const screenAudio = screenStream ? screenStream.getAudioTracks() : []
const micAudio = micStream ? micStream.getAudioTracks() : []
if (screenAudio.length === 0 && micAudio.length === 0) return null
if (screenAudio.length > 0 && micAudio.length === 0) return screenAudio[0]
if (screenAudio.length === 0 && micAudio.length > 0) return micAudio[0]
// 两路都有,用 Web Audio 混合
try {
const Ctx = window.AudioContext || window.webkitAudioContext
audioCtx = new Ctx()
const dest = audioCtx.createMediaStreamDestination()
audioCtx.createMediaStreamSource(new MediaStream([screenAudio[0]])).connect(dest)
audioCtx.createMediaStreamSource(new MediaStream([micAudio[0]])).connect(dest)
return dest.stream.getAudioTracks()[0]
} catch (e) {
// 混音失败时退化为只录麦克风
return micAudio[0]
}
}
function drawPip() {
const canvas = mixCanvas.value
const sv = screenVideoEl
if (!canvas || !sv) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
if (sv.videoWidth > 0) {
ctx.drawImage(sv, 0, 0, w, h)
} else {
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, w, h)
}
const cv = cameraVideo.value
if (cv && cv.videoWidth > 0) {
const pw = Math.round((w * pipRatio.value) / 100)
const ph = Math.round((pw * cv.videoHeight) / cv.videoWidth)
const margin = Math.round(w * 0.015)
let x = w - pw - margin
let y = h - ph - margin
if (pipPosition.value === 'lb') x = margin
if (pipPosition.value === 'rt') y = margin
if (pipPosition.value === 'lt') {
x = margin
y = margin
}
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.45)'
ctx.shadowBlur = 12
ctx.drawImage(cv, x, y, pw, ph)
ctx.restore()
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 2
ctx.strokeRect(x, y, pw, ph)
}
}
async function buildVideoTrack() {
const screenTrack = screenStream.getVideoTracks()[0]
if (!withCamera.value || !camStream) return screenTrack
// 用 canvas 把屏幕画面与摄像头合成为一路视频
const settings = screenTrack.getSettings ? screenTrack.getSettings() : {}
const w = settings.width || 1280
const h = settings.height || 720
const canvas = mixCanvas.value
canvas.width = w
canvas.height = h
screenVideoEl = document.createElement('video')
screenVideoEl.muted = true
screenVideoEl.playsInline = true
screenVideoEl.srcObject = new MediaStream([screenTrack])
await screenVideoEl.play().catch(() => {})
if (cameraVideo.value) {
cameraVideo.value.srcObject = camStream
await cameraVideo.value.play().catch(() => {})
}
// 使用定时器而非 requestAnimationFrame:录屏时本页面常处于后台,
// rAF 会被浏览器暂停,导致合成画面卡住
drawTimer = window.setInterval(drawPip, Math.round(1000 / fps.value))
const canvasStream = canvas.captureStream(fps.value)
return canvasStream.getVideoTracks()[0]
}
async function start() {
error.value = ''
discard()
if (!supported.value) return
try {
screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: fps.value } },
audio: true
})
} catch (e) {
const name = e && e.name ? e.name : ''
if (name === 'NotAllowedError') {
error.value = '你取消了共享,或浏览器拒绝了屏幕采集权限。请重新点击开始录制并在弹窗中选择要共享的内容。'
} else if (name === 'NotFoundError') {
error.value = '没有找到可共享的屏幕源。'
} else if (name === 'NotSupportedError') {
error.value = '当前浏览器或运行环境不支持屏幕采集,请在 https 或 localhost 下使用桌面版浏览器。'
} else {
error.value = '开启屏幕采集失败:' + (e && e.message ? e.message : String(e))
}
cleanupAll()
return
}
if (withMic.value) {
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
})
} catch (e) {
error.value = '麦克风开启失败,将只录制屏幕声音。原因:' + (e && e.message ? e.message : String(e))
}
}
if (withCamera.value) {
try {
camStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 } }
})
} catch (e) {
error.value = '摄像头开启失败,将只录制屏幕画面。原因:' + (e && e.message ? e.message : String(e))
camStream = null
}
}
try {
const videoTrack = await buildVideoTrack()
const audioTrack = buildAudioTrack()
const tracks = audioTrack ? [videoTrack, audioTrack] : [videoTrack]
mixedStream = new MediaStream(tracks)
if (livePreview.value) {
livePreview.value.srcObject = new MediaStream([videoTrack])
livePreview.value.play().catch(() => {})
}
const mimeType = pickMimeType()
chunks = []
recorder = mimeType ? new MediaRecorder(mixedStream, { mimeType }) : new MediaRecorder(mixedStream)
recorder.ondataavailable = (ev) => {
if (ev.data && ev.data.size > 0) chunks.push(ev.data)
}
recorder.onstop = finalize
recorder.onerror = (ev) => {
error.value = '录制过程出错:' + (ev && ev.error ? ev.error.name : '未知错误')
stop()
}
recorder.start(1000)
// 用户通过浏览器共享条停止共享时自动收尾
screenStream.getVideoTracks().forEach((t) => {
t.onended = () => {
if (recording.value) stop()
}
})
recording.value = true
paused.value = false
elapsed.value = 0
timer = window.setInterval(() => {
if (!paused.value) elapsed.value += 1
}, 1000)
} catch (e) {
error.value = '初始化录制器失败:' + (e && e.message ? e.message : String(e))
cleanupAll()
recording.value = false
}
}
function togglePause() {
if (!recorder) return
try {
if (paused.value) {
recorder.resume()
paused.value = false
} else {
recorder.pause()
paused.value = true
}
} catch (e) {
error.value = '当前浏览器不支持暂停录制,请直接停止后重新录制。'
}
}
function stop() {
if (!recorder) return
try {
if (recorder.state !== 'inactive') recorder.stop()
else finalize()
} catch (e) {
error.value = '停止录制失败:' + (e && e.message ? e.message : String(e))
finalize()
}
}
function finalize() {
const type = recorder && recorder.mimeType ? recorder.mimeType : 'video/webm'
recording.value = false
paused.value = false
if (timer) {
window.clearInterval(timer)
timer = null
}
if (chunks.length > 0) {
const blob = new Blob(chunks, { type })
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
resultUrl.value = URL.createObjectURL(blob)
resultDuration.value = elapsed.value
resultSize.value = humanSize(blob.size)
} else {
error.value = '没有录到任何数据,请重试。'
}
chunks = []
recorder = null
cleanupAll()
}
function download() {
if (!resultUrl.value) return
const stamp = new Date()
const p = (n) => String(n).padStart(2, '0')
const name =
'screen-record-' +
stamp.getFullYear() +
p(stamp.getMonth() + 1) +
p(stamp.getDate()) +
'-' +
p(stamp.getHours()) +
p(stamp.getMinutes()) +
p(stamp.getSeconds()) +
'.webm'
const a = document.createElement('a')
a.href = resultUrl.value
a.download = name
a.click()
}
function discard() {
if (resultUrl.value) {
URL.revokeObjectURL(resultUrl.value)
resultUrl.value = ''
}
resultDuration.value = 0
resultSize.value = ''
elapsed.value = 0
}
onMounted(() => {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
supported.value = false
const insecure = typeof window !== 'undefined' && window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1'
unsupportedReason.value = insecure
? '屏幕采集只在 https 或 localhost 下可用,当前页面是明文访问。请改用 https 地址后重试。'
: '未检测到 navigator.mediaDevices.getDisplayMedia。移动端浏览器与部分内置浏览器不提供屏幕采集能力,请改用桌面版 Chrome、Edge 或 Firefox。'
return
}
if (typeof window.MediaRecorder === 'undefined') {
supported.value = false
unsupportedReason.value = '当前浏览器不支持 MediaRecorder,无法把画面编码成文件。请改用桌面版 Chrome、Edge 或 Firefox。'
}
})
onBeforeUnmount(() => {
try {
if (recorder && recorder.state !== 'inactive') recorder.stop()
} catch (e) {
// 忽略卸载时的异常
}
recorder = null
cleanupAll()
if (resultUrl.value) URL.revokeObjectURL(resultUrl.value)
})2.3 效果截图
