提交 464c32ed authored 作者: 王鹏飞's avatar 王鹏飞

feat: 增强直播功能,支持水印创建与上传会话管理

上级 3b1e4ca6
......@@ -21,12 +21,83 @@ export const readBlobAsBase64 = (blob: Blob): Promise<string> => {
interface UseLiveProps {
enabledUserMedia?: boolean
keepChunks?: boolean
watermark?: boolean
onStart?: () => void
onRecord?: (data: Blob) => void
onStop?: (blob: Blob) => void
}
export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, onRecord, onStop }: UseLiveProps) {
function formatTimestamp() {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
/** 创建时间水印覆盖层:video → canvas → canvasStream */
function createWatermark(cameraStream: MediaStream) {
const video = document.createElement('video')
video.muted = true
video.playsInline = true
video.srcObject = cameraStream
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
let animId: number | null = null
let _captureStream: MediaStream | null = null
let _ready: Promise<void>
const start = () => {
_ready = video.play().then(() => {
canvas.width = video.videoWidth || 640
canvas.height = video.videoHeight || 480
_captureStream = canvas.captureStream(30)
cameraStream.getAudioTracks().forEach((track) => _captureStream?.addTrack(track))
const draw = () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const fontSize = Math.max(14, Math.floor(canvas.height / 36))
ctx.font = `bold ${fontSize}px monospace`
ctx.textBaseline = 'bottom'
const text = formatTimestamp()
const textWidth = ctx.measureText(text).width
const x = canvas.width - textWidth - 12
const y = canvas.height - 12
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'
ctx.fillRect(x - 6, y - fontSize - 4, textWidth + 12, fontSize + 8)
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'
ctx.fillText(text, x, y)
animId = requestAnimationFrame(draw)
}
if (video.readyState >= 1) draw()
else video.addEventListener('loadedmetadata', draw, { once: true })
})
}
const ready = () => _ready
const getStream = () => _captureStream
const stop = () => {
if (animId !== null) cancelAnimationFrame(animId)
if (_captureStream) {
_captureStream.getTracks().forEach((t) => t.stop())
_captureStream = null
}
video.pause()
video.srcObject = null
}
return { start, stop, getStream, ready }
}
export function useLive({
enabledUserMedia = true,
keepChunks = true,
watermark = false,
onStart,
onRecord,
onStop,
}: UseLiveProps) {
const startTime = ref(0)
const endTime = ref(0)
const duration = computed(() => (endTime.value - startTime.value) / 1000)
......@@ -53,7 +124,7 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
})
// 创建媒体流
const { stream, restart } = useUserMedia({
const { stream, restart: restartStream } = useUserMedia({
enabled: enabledUserMedia,
constraints: constraints as any,
})
......@@ -63,11 +134,22 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
let mediaRecorder: MediaRecorder | null = null
let timeUpdateInterval: number | null = null
// 水印
let watermarkCtx: ReturnType<typeof createWatermark> | null = null
function getRecordStream(): MediaStream | null {
if (watermark && watermarkCtx) return watermarkCtx.getStream()
return stream.value ?? null
}
// 初始化MediaRecorder
const initializeMediaRecorder = () => {
if (!stream.value) return
const initializeMediaRecorder = async () => {
// 水印模式:等待 canvas 流就绪
if (watermark && watermarkCtx) await watermarkCtx.ready()
const recordStream = getRecordStream()
if (!recordStream) return
const mimeType = MediaRecorder.isTypeSupported('video/mp4') ? 'video/mp4' : 'video/webm'
mediaRecorder = new MediaRecorder(stream.value, { mimeType })
mediaRecorder = new MediaRecorder(recordStream, { mimeType })
mediaRecorder.ondataavailable = handleDataAvailable
mediaRecorder.onstart = handleStart
mediaRecorder.onstop = handleStop
......@@ -107,8 +189,8 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
}
// 开始录制
const start = () => {
if (!mediaRecorder) initializeMediaRecorder()
const start = async () => {
if (!mediaRecorder) await initializeMediaRecorder()
recordedChunks.value = []
mediaRecorder?.start(3000)
}
......@@ -122,8 +204,8 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
// 重新开始
const handleRestart = async () => {
stop()
await restart()
start()
await restartStream()
await start()
}
// 切换摄像头(移动端前置/后置)
......@@ -134,6 +216,20 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
nextTick(() => initializeMediaRecorder())
}
// 水印生命周期(sync 确保 restart 后 start 前水印已就绪)
watch(stream, (newStream) => {
if (!watermark) return
if (watermarkCtx) watermarkCtx.stop()
if (newStream) {
watermarkCtx = createWatermark(newStream)
watermarkCtx.start()
}
}, { flush: 'sync' })
onUnmounted(() => {
if (watermarkCtx) watermarkCtx.stop()
})
return {
stream,
start,
......
......@@ -20,7 +20,15 @@ export function useLiveMonitor({
const fileName = computed(() => md5(`${ssoId}${startTime.value}`))
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '')
let ossResult: any = null
interface UploadSession {
result: any
queue: Promise<void>
}
let uploadSession: UploadSession = {
result: null,
queue: Promise.resolve(),
}
let send: (data: string) => void = () => {}
if (!isOSSUpload) {
......@@ -42,19 +50,30 @@ export function useLiveMonitor({
const { startTime, start, stop, restart, stream } = useLive({
enabledUserMedia: autoStart,
keepChunks: false,
onRecord: async (blob) => {
watermark: true,
onStart: () => {
uploadSession = {
result: null,
queue: Promise.resolve(),
}
},
onRecord: (blob) => {
if (isOSSUpload) {
try {
ossResult = await appendUpload(blob, {
name: `date_${dateStr}/user_${ssoId}/${fileName.value}.mp4`,
position: ossResult?.nextAppendPosition,
const session = uploadSession
const name = `date_${dateStr}/user_${ssoId}/${fileName.value}.mp4`
session.queue = session.queue
.then(async () => {
session.result = await appendUpload(blob, {
name,
position: session.result?.nextAppendPosition,
})
fileUrl.value = ossResult.url
} catch (error) {
if (uploadSession === session) fileUrl.value = session.result.url
})
.catch((error) => {
console.error('OSS append upload failed:', error)
}
})
} else {
const base64Data = await readBlobAsBase64(blob)
readBlobAsBase64(blob).then((base64Data) => {
send(
JSON.stringify({
type: 'send',
......@@ -62,6 +81,7 @@ export function useLiveMonitor({
data: { type: 'video_rtc', channel: 'rtc', data: { video: base64Data, file_name: fileName.value } },
}),
)
})
}
},
})
......@@ -87,21 +107,43 @@ export function useLiveMonitor({
// iframe 直播协作:直播开始时暂停监控释放摄像头,直播结束后恢复
const monitored = ref(false)
const monitorPause = () => {
if (!monitored.value) return
stop()
stream.value?.getTracks().forEach((t) => t.stop())
let shouldMonitor = autoStart
let monitorTransition = Promise.resolve()
const monitorPause = (event: MessageEvent) => {
shouldMonitor = false
monitorTransition = monitorTransition.then(() => {
if (monitored.value) {
monitored.value = false
stop()
stream.value?.getTracks().forEach((track) => track.stop())
}
const targetOrigin = event.origin === 'null' ? '*' : event.origin
event.source?.postMessage({ action: 'monitor-paused' }, { targetOrigin })
})
}
const monitorResume = async () => {
if (monitored.value) return
const monitorResume = () => {
shouldMonitor = true
monitorTransition = monitorTransition.then(async () => {
if (monitored.value || !shouldMonitor) return
try {
await restart()
start()
if (shouldMonitor) {
monitored.value = true
} else {
stop()
stream.value?.getTracks().forEach((track) => track.stop())
}
} catch (error) {
console.error('Failed to resume live monitor:', error)
}
})
}
const handleIframeMessage = (event: MessageEvent) => {
if (event.data?.action === 'monitor-pause') monitorPause()
if (event.data?.action === 'monitor-pause') monitorPause(event)
if (event.data?.action === 'monitor-resume') monitorResume()
}
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论