Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
S
saas-lab
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
EzijingWeb
saas-lab
Commits
464c32ed
提交
464c32ed
authored
7月 13, 2026
作者:
王鹏飞
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
feat: 增强直播功能,支持水印创建与上传会话管理
上级
3b1e4ca6
隐藏空白字符变更
内嵌
并排
正在显示
2 个修改的文件
包含
176 行增加
和
38 行删除
+176
-38
useLive.ts
src/composables/useLive.ts
+105
-9
useLiveMonitor.ts
src/composables/useLiveMonitor.ts
+71
-29
没有找到文件。
src/composables/useLive.ts
浏览文件 @
464c32ed
...
@@ -21,12 +21,83 @@ export const readBlobAsBase64 = (blob: Blob): Promise<string> => {
...
@@ -21,12 +21,83 @@ export const readBlobAsBase64 = (blob: Blob): Promise<string> => {
interface
UseLiveProps
{
interface
UseLiveProps
{
enabledUserMedia
?:
boolean
enabledUserMedia
?:
boolean
keepChunks
?:
boolean
keepChunks
?:
boolean
watermark
?:
boolean
onStart
?:
()
=>
void
onStart
?:
()
=>
void
onRecord
?:
(
data
:
Blob
)
=>
void
onRecord
?:
(
data
:
Blob
)
=>
void
onStop
?:
(
blob
:
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
startTime
=
ref
(
0
)
const
endTime
=
ref
(
0
)
const
endTime
=
ref
(
0
)
const
duration
=
computed
(()
=>
(
endTime
.
value
-
startTime
.
value
)
/
1000
)
const
duration
=
computed
(()
=>
(
endTime
.
value
-
startTime
.
value
)
/
1000
)
...
@@ -53,7 +124,7 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
...
@@ -53,7 +124,7 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
})
})
// 创建媒体流
// 创建媒体流
const
{
stream
,
restart
}
=
useUserMedia
({
const
{
stream
,
restart
:
restartStream
}
=
useUserMedia
({
enabled
:
enabledUserMedia
,
enabled
:
enabledUserMedia
,
constraints
:
constraints
as
any
,
constraints
:
constraints
as
any
,
})
})
...
@@ -63,11 +134,22 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
...
@@ -63,11 +134,22 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
let
mediaRecorder
:
MediaRecorder
|
null
=
null
let
mediaRecorder
:
MediaRecorder
|
null
=
null
let
timeUpdateInterval
:
number
|
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
// 初始化MediaRecorder
const
initializeMediaRecorder
=
()
=>
{
const
initializeMediaRecorder
=
async
()
=>
{
if
(
!
stream
.
value
)
return
// 水印模式:等待 canvas 流就绪
if
(
watermark
&&
watermarkCtx
)
await
watermarkCtx
.
ready
()
const
recordStream
=
getRecordStream
()
if
(
!
recordStream
)
return
const
mimeType
=
MediaRecorder
.
isTypeSupported
(
'video/mp4'
)
?
'video/mp4'
:
'video/webm'
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
.
ondataavailable
=
handleDataAvailable
mediaRecorder
.
onstart
=
handleStart
mediaRecorder
.
onstart
=
handleStart
mediaRecorder
.
onstop
=
handleStop
mediaRecorder
.
onstop
=
handleStop
...
@@ -107,8 +189,8 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
...
@@ -107,8 +189,8 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
}
}
// 开始录制
// 开始录制
const
start
=
()
=>
{
const
start
=
async
()
=>
{
if
(
!
mediaRecorder
)
initializeMediaRecorder
()
if
(
!
mediaRecorder
)
await
initializeMediaRecorder
()
recordedChunks
.
value
=
[]
recordedChunks
.
value
=
[]
mediaRecorder
?.
start
(
3000
)
mediaRecorder
?.
start
(
3000
)
}
}
...
@@ -122,8 +204,8 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
...
@@ -122,8 +204,8 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
// 重新开始
// 重新开始
const
handleRestart
=
async
()
=>
{
const
handleRestart
=
async
()
=>
{
stop
()
stop
()
await
restart
()
await
restart
Stream
()
start
()
await
start
()
}
}
// 切换摄像头(移动端前置/后置)
// 切换摄像头(移动端前置/后置)
...
@@ -134,6 +216,20 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
...
@@ -134,6 +216,20 @@ export function useLive({ enabledUserMedia = true, keepChunks = true, onStart, o
nextTick
(()
=>
initializeMediaRecorder
())
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
{
return
{
stream
,
stream
,
start
,
start
,
...
...
src/composables/useLiveMonitor.ts
浏览文件 @
464c32ed
...
@@ -20,7 +20,15 @@ export function useLiveMonitor({
...
@@ -20,7 +20,15 @@ export function useLiveMonitor({
const
fileName
=
computed
(()
=>
md5
(
`
${
ssoId
}${
startTime
.
value
}
`
))
const
fileName
=
computed
(()
=>
md5
(
`
${
ssoId
}${
startTime
.
value
}
`
))
const
dateStr
=
new
Date
().
toISOString
().
slice
(
0
,
10
).
replace
(
/-/g
,
''
)
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
=
()
=>
{}
let
send
:
(
data
:
string
)
=>
void
=
()
=>
{}
if
(
!
isOSSUpload
)
{
if
(
!
isOSSUpload
)
{
...
@@ -42,26 +50,38 @@ export function useLiveMonitor({
...
@@ -42,26 +50,38 @@ export function useLiveMonitor({
const
{
startTime
,
start
,
stop
,
restart
,
stream
}
=
useLive
({
const
{
startTime
,
start
,
stop
,
restart
,
stream
}
=
useLive
({
enabledUserMedia
:
autoStart
,
enabledUserMedia
:
autoStart
,
keepChunks
:
false
,
keepChunks
:
false
,
onRecord
:
async
(
blob
)
=>
{
watermark
:
true
,
onStart
:
()
=>
{
uploadSession
=
{
result
:
null
,
queue
:
Promise
.
resolve
(),
}
},
onRecord
:
(
blob
)
=>
{
if
(
isOSSUpload
)
{
if
(
isOSSUpload
)
{
try
{
const
session
=
uploadSession
ossResult
=
await
appendUpload
(
blob
,
{
const
name
=
`date_
${
dateStr
}
/user_
${
ssoId
}
/
${
fileName
.
value
}
.mp4`
name
:
`date_
${
dateStr
}
/user_
${
ssoId
}
/
${
fileName
.
value
}
.mp4`
,
session
.
queue
=
session
.
queue
position
:
ossResult
?.
nextAppendPosition
,
.
then
(
async
()
=>
{
session
.
result
=
await
appendUpload
(
blob
,
{
name
,
position
:
session
.
result
?.
nextAppendPosition
,
})
if
(
uploadSession
===
session
)
fileUrl
.
value
=
session
.
result
.
url
})
.
catch
((
error
)
=>
{
console
.
error
(
'OSS append upload failed:'
,
error
)
})
})
fileUrl
.
value
=
ossResult
.
url
}
catch
(
error
)
{
console
.
error
(
'OSS append upload failed:'
,
error
)
}
}
else
{
}
else
{
const
base64Data
=
await
readBlobAsBase64
(
blob
)
readBlobAsBase64
(
blob
).
then
((
base64Data
)
=>
{
send
(
send
(
JSON
.
stringify
({
JSON
.
stringify
({
type
:
'send'
,
type
:
'send'
,
sso_id
:
ssoId
,
sso_id
:
ssoId
,
data
:
{
type
:
'video_rtc'
,
channel
:
'rtc'
,
data
:
{
video
:
base64Data
,
file_name
:
fileName
.
value
}
},
data
:
{
type
:
'video_rtc'
,
channel
:
'rtc'
,
data
:
{
video
:
base64Data
,
file_name
:
fileName
.
value
}
},
}),
}),
)
)
})
}
}
},
},
})
})
...
@@ -87,21 +107,43 @@ export function useLiveMonitor({
...
@@ -87,21 +107,43 @@ export function useLiveMonitor({
// iframe 直播协作:直播开始时暂停监控释放摄像头,直播结束后恢复
// iframe 直播协作:直播开始时暂停监控释放摄像头,直播结束后恢复
const
monitored
=
ref
(
false
)
const
monitored
=
ref
(
false
)
const
monitorPause
=
()
=>
{
let
shouldMonitor
=
autoStart
if
(
!
monitored
.
value
)
return
let
monitorTransition
=
Promise
.
resolve
()
stop
()
stream
.
value
?.
getTracks
().
forEach
((
t
)
=>
t
.
stop
())
const
monitorPause
=
(
event
:
MessageEvent
)
=>
{
monitored
.
value
=
false
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
=
()
=>
{
await
restart
()
shouldMonitor
=
true
start
()
monitorTransition
=
monitorTransition
.
then
(
async
()
=>
{
monitored
.
value
=
true
if
(
monitored
.
value
||
!
shouldMonitor
)
return
try
{
await
restart
()
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
)
=>
{
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
()
if
(
event
.
data
?.
action
===
'monitor-resume'
)
monitorResume
()
}
}
...
...
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论