直播:三模式采集、Canvas 单轨与可拖动小窗;观众端单路视频

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 14:27:07 +08:00
parent 26e90c30f9
commit f28b80354f
4 changed files with 417 additions and 146 deletions

View File

@@ -1,7 +1,9 @@
/**
* 管理后台 WebRTC 开播(/api/web/live/ws?role=publish&token=
* 画质:官网 /live 写入 localStorageyh_live_capture_quality
* 支持:仅摄像头 / 屏幕共享 + 摄像头小窗;信令断线自动重连(复用 MediaStream
* 管理后台 WebRTC 开播(观众端始终单路视频
* - camera仅摄像头
* - screen_only仅共享屏幕 + 麦克风
* - screen_pip屏幕与摄像头 Canvas 合成;小窗位置由 getPipRect() 归一化矩形 (nx,ny,nw,nh) 决定,与预览拖动一致
* - 直播中可 switchMode 切换,无需结束直播
*/
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
@@ -59,6 +61,9 @@ const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
const MAX_SIGNAL_RECONNECT = 15
const CANVAS_W = 1280
const CANVAS_H = 720
function healthCheckUrl() {
if (apiBase) return `${apiBase}/api/health`
if (typeof window !== 'undefined') return `${window.location.origin}/api/health`
@@ -80,6 +85,22 @@ function buildCameraConstraints(publishKey, videoDeviceId) {
}
}
function clamp(v, lo, hi) {
return Math.min(hi, Math.max(lo, v))
}
/** @param {() => { nx?: number, ny?: number, nw?: number, nh?: number } | null | undefined} getPipRect */
function readPipRect(getPipRect) {
const d = typeof getPipRect === 'function' ? getPipRect() : null
const nw = clamp(Number(d?.nw) || 0.24, 0.08, 0.55)
const nh = clamp(Number(d?.nh) || 0.24, 0.08, 0.55)
const defNx = 1 - nw - 10 / CANVAS_W
const defNy = 1 - nh - 10 / CANVAS_H
const nx = clamp(Number(d?.nx) || defNx, 0, 1 - nw)
const ny = clamp(Number(d?.ny) || defNy, 0, 1 - nh)
return { nx, ny, nw, nh }
}
function humanizeGetUserMediaError(err) {
const name = err && err.name
const raw = ((err && err.message) || '').toLowerCase()
@@ -106,31 +127,28 @@ function humanizeGetUserMediaError(err) {
return (err && err.message) || '无法打开摄像头'
}
/**
* @param {object} opts
* @param {string} opts.token
* @param {'camera'|'screen_pip'} [opts.captureMode]
* @param {string} [opts.videoDeviceId] videoinput deviceId空则默认设备
* @param {(s: string) => void} [opts.onStatus]
* @param {(p: { main: MediaStream, pip: MediaStream|null }) => void} [opts.onLocalStream]
*/
export function startPublishing(opts = {}) {
const {
token = '',
captureMode = 'camera',
videoDeviceId = '',
captureMode: initialMode = 'camera',
videoDeviceId: initialDeviceId = '',
onStatus = () => {},
onLocalStream = () => {}
onLocalStream = () => {},
onActiveModeChange = () => {},
getPipRect = null
} = opts
if (!token) {
onStatus('未登录,无法开播')
return { stop: () => {} }
return { stop: () => {}, switchMode: async () => {} }
}
const publishKey = effectivePublishQualityKey()
const wsUrl = liveWsURLPublish(token)
let activeMode = initialMode
let deviceIdState = initialDeviceId
let closedByLocal = false
let stream = null
let ws = null
@@ -139,6 +157,42 @@ export function startPublishing(opts = {}) {
let reconnectAttempt = 0
let wsGen = 0
let reconnectStopped = false
let switchBusy = false
let rafId = null
let vScreen = null
let vCam = null
let canvasEl = null
let screenShareTrack = null
function teardownComposite() {
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
if (vScreen) {
try {
const so = vScreen.srcObject
if (so) so.getTracks().forEach((t) => t.stop())
} catch (_) {}
try {
vScreen.srcObject = null
} catch (_) {}
vScreen = null
}
if (vCam) {
try {
const so = vCam.srcObject
if (so) so.getTracks().forEach((t) => t.stop())
} catch (_) {}
try {
vCam.srcObject = null
} catch (_) {}
vCam = null
}
canvasEl = null
screenShareTrack = null
}
function clearReconnectTimer() {
if (reconnectTimer) {
@@ -151,44 +205,121 @@ export function startPublishing(opts = {}) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
}
async function acquireMedia() {
if (captureMode === 'screen_pip') {
let display
try {
display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
} catch (e) {
const name = e && e.name
throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败')
}
const screenVideo = display.getVideoTracks()[0]
if (!screenVideo) {
display.getTracks().forEach((t) => t.stop())
throw new Error('未获得屏幕画面')
}
screenVideo.addEventListener('ended', () => {
if (!closedByLocal) {
onStatus('屏幕共享已结束')
stop()
}
async function acquireDisplayStream() {
let display
try {
display = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
})
let cam
} catch (e) {
const name = e && e.name
throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败')
}
const sTr = display.getVideoTracks()[0]
if (!sTr) {
display.getTracks().forEach((t) => t.stop())
throw new Error('未获得屏幕画面')
}
screenShareTrack = sTr
sTr.addEventListener('ended', () => {
if (!closedByLocal) {
onStatus('屏幕共享已结束')
stop()
}
})
return display
}
async function buildPublishStream() {
teardownComposite()
if (activeMode === 'screen_only') {
const display = await acquireDisplayStream()
const sTr = display.getVideoTracks()[0]
let micStream
try {
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
} catch (e) {
screenVideo.stop()
sTr.stop()
display.getTracks().forEach((t) => t.stop())
throw e
}
const camVideo = cam.getVideoTracks()[0]
const previewVid = new MediaStream([sTr])
onLocalStream({ layout: 'screen_only', main: previewVid })
return new MediaStream([sTr, ...micStream.getAudioTracks()])
}
if (activeMode === 'screen_pip') {
const display = await acquireDisplayStream()
const sTr = display.getVideoTracks()[0]
let cam
try {
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
} catch (e) {
sTr.stop()
display.getTracks().forEach((t) => t.stop())
throw e
}
vScreen = document.createElement('video')
vCam = document.createElement('video')
vScreen.muted = true
vCam.muted = true
vScreen.playsInline = true
vCam.playsInline = true
vScreen.srcObject = display
vCam.srcObject = cam
await vScreen.play().catch(() => {})
await vCam.play().catch(() => {})
canvasEl = document.createElement('canvas')
canvasEl.width = CANVAS_W
canvasEl.height = CANVAS_H
const ctx = canvasEl.getContext('2d')
function tick() {
if (closedByLocal || !canvasEl) return
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
if (vScreen && vScreen.readyState >= 2) {
try {
ctx.drawImage(vScreen, 0, 0, CANVAS_W, CANVAS_H)
} catch (_) {}
}
if (vCam && vCam.readyState >= 2) {
const { nx, ny, nw, nh } = readPipRect(getPipRect)
const pw = Math.round(CANVAS_W * nw)
const ph = Math.round(CANVAS_H * nh)
const px = Math.round(CANVAS_W * nx)
const py = Math.round(CANVAS_H * ny)
ctx.strokeStyle = 'rgba(64,158,255,0.9)'
ctx.lineWidth = 3
ctx.strokeRect(px, py, pw, ph)
try {
ctx.drawImage(vCam, px, py, pw, ph)
} catch (_) {}
}
rafId = requestAnimationFrame(tick)
}
tick()
const cap = canvasEl.captureStream(30)
const outV = cap.getVideoTracks()[0]
if (!outV) {
teardownComposite()
sTr.stop()
cam.getTracks().forEach((t) => t.stop())
throw new Error('画布采集失败')
}
const mic = cam.getAudioTracks()
const publish = new MediaStream([screenVideo, camVideo, ...mic])
const mainPrev = new MediaStream([screenVideo])
const pipPrev = new MediaStream([camVideo])
onLocalStream({ main: mainPrev, pip: pipPrev })
const publish = new MediaStream([outV, ...mic])
onLocalStream({
layout: 'screen_pip',
screen: display,
cam: new MediaStream([cam.getVideoTracks()[0]])
})
return publish
}
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, videoDeviceId))
onLocalStream({ main: s, pip: null })
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
onLocalStream({ layout: 'camera', main: s })
return s
}
@@ -203,8 +334,9 @@ export function startPublishing(opts = {}) {
t.stop()
} catch (_) {}
})
teardownComposite()
try {
stream = await acquireMedia()
stream = await buildPublishStream()
} catch (e) {
const msg =
e && typeof e.message === 'string' && e.message
@@ -233,6 +365,62 @@ export function startPublishing(opts = {}) {
onStatus('协商中…')
}
async function switchMode(mode, camDeviceId) {
if (closedByLocal || switchBusy || !pc || pc.signalingState === 'closed') return
switchBusy = true
if (typeof camDeviceId === 'string') deviceIdState = camDeviceId
const switchingTo = mode
activeMode = mode
onStatus('切换画面中…')
try {
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
stream = null
teardownComposite()
try {
stream = await buildPublishStream()
} catch (e1) {
if (switchingTo === 'screen_pip' || switchingTo === 'screen_only') {
activeMode = 'camera'
try {
onActiveModeChange('camera')
} catch (_) {}
onStatus(
e1?.message ? `${e1.message},已切回仅摄像头` : '屏幕共享未就绪,已切回仅摄像头'
)
stream = await buildPublishStream()
} else {
throw e1
}
}
const vT = stream.getVideoTracks()[0]
const aT = stream.getAudioTracks()[0]
const vSender = pc.getSenders().find((s) => s.track?.kind === 'video')
const aSender = pc.getSenders().find((s) => s.track?.kind === 'audio')
if (vSender && vT) {
await vSender.replaceTrack(vT)
} else if (vT) {
pc.addTrack(vT, stream)
}
if (aSender && aT) {
await aSender.replaceTrack(aT)
} else if (aT) {
pc.addTrack(aT, stream)
}
const offer = await pc.createOffer({ iceRestart: false })
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
onStatus('已切换,协商中…')
} catch (e) {
onStatus(e?.message || humanizeGetUserMediaError(e))
} finally {
switchBusy = false
}
}
function onSocketMessage(ev) {
let msg
try {
@@ -269,7 +457,7 @@ export function startPublishing(opts = {}) {
if (reconnectAttempt > MAX_SIGNAL_RECONNECT) {
reconnectStopped = true
onStatus(
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 返回 502进程未启动/崩溃)或 Nginx 未正确反代到 Go。请检查服务与 \`/api/web/live/ws\` 的 WebSocket 配置,修复后刷新本页再点「开始直播」`
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 502 或未启动。修复后刷新本页再开播`
)
return
}
@@ -281,10 +469,10 @@ export function startPublishing(opts = {}) {
try {
const r = await fetch(healthCheckUrl(), { method: 'GET', cache: 'no-store' })
if (!r.ok) {
onStatus(`API 不可用HTTP ${r.status},多为网关 502推迟重试…`)
onStatus(`API 不可用HTTP ${r.status}`)
}
} catch (_) {
onStatus('无法访问健康检查接口,请确认网络与域名。')
onStatus('无法访问健康检查接口')
}
if (closedByLocal || reconnectStopped) return
openSignalingSocket()
@@ -349,6 +537,7 @@ export function startPublishing(opts = {}) {
reconnectStopped = true
wsGen += 1
clearReconnectTimer()
teardownComposite()
if (ws) {
try {
ws.close()
@@ -371,5 +560,8 @@ export function startPublishing(opts = {}) {
openSignalingSocket()
return { stop }
return {
stop,
switchMode: (mode, camDeviceId) => switchMode(mode, camDeviceId)
}
}