Skip to content

实现 屏幕录制 工具

分类:general | 标签:录屏、录制、WebM 浏览器屏幕录制,导出 WebM

2.1 功能说明

浏览器屏幕录制,导出 WebM

核心能力

  • 采集麦克风
  • 摄像头画中画
  • 小窗位置
  • rb
  • lb
  • rt
  • lt
  • 帧率
  • 15 fps
  • 24 fps
  • 30 fps
  • 60 fps

2.1.1 使用指南

屏幕录制使用说明

本工具使用浏览器原生的 getDisplayMediaMediaRecorder 录屏, 画面与声音全程留在本机内存,不会上传到任何服务器,录制完成后直接在浏览器里生成文件供下载。

操作步骤

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-inputel-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 效果截图

屏幕录制 效果截图

工具访问地址:https://www.i91tools.com/tools/screen-recorder