194 lines
5.5 KiB
JavaScript
194 lines
5.5 KiB
JavaScript
|
|
import { spawn } from 'child_process'
|
||
|
|
import { createCameraGrpcClient } from './grpcClient.js'
|
||
|
|
|
||
|
|
const FIRST_FRAME_TIMEOUT_MS = 10000
|
||
|
|
const FRAME_TYPES = ['U8C1', 'U16C1', 'U8C3', 'U16C3', 'F16C1', 'F32C1']
|
||
|
|
|
||
|
|
function normalizeCodec(codec = '') {
|
||
|
|
return codec.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||
|
|
}
|
||
|
|
|
||
|
|
function inputArguments(frame) {
|
||
|
|
const codec = normalizeCodec(frame.codec)
|
||
|
|
|
||
|
|
if (['jpeg', 'jpg', 'mjpeg', 'imagejpeg'].includes(codec)) return ['-f', 'mjpeg']
|
||
|
|
if (['png', 'imagepng'].includes(codec)) return ['-f', 'image2pipe', '-vcodec', 'png']
|
||
|
|
if (['h264', 'avc', 'avc1'].includes(codec)) return ['-f', 'h264']
|
||
|
|
if (['h265', 'hevc', 'hev1', 'hvc1'].includes(codec)) return ['-f', 'hevc']
|
||
|
|
|
||
|
|
const width = Number(frame.width)
|
||
|
|
const height = Number(frame.height)
|
||
|
|
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
|
||
|
|
throw new Error(`未压缩视频帧的尺寸无效: ${frame.width}x${frame.height}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
const declaredPixelFormats = {
|
||
|
|
gray: 'gray',
|
||
|
|
gray8: 'gray',
|
||
|
|
gray16: 'gray16le',
|
||
|
|
gray16le: 'gray16le',
|
||
|
|
rgb24: 'rgb24',
|
||
|
|
bgr24: 'bgr24',
|
||
|
|
rgb48: 'rgb48le',
|
||
|
|
rgb48le: 'rgb48le',
|
||
|
|
bgr48: 'bgr48le',
|
||
|
|
bgr48le: 'bgr48le',
|
||
|
|
grayf32: 'grayf32le',
|
||
|
|
grayf32le: 'grayf32le'
|
||
|
|
}
|
||
|
|
const rawPixelFormats = {
|
||
|
|
U8C1: 'gray',
|
||
|
|
U16C1: 'gray16le',
|
||
|
|
U8C3: 'rgb24',
|
||
|
|
U16C3: 'rgb48le',
|
||
|
|
F32C1: 'grayf32le'
|
||
|
|
}
|
||
|
|
const frameType = typeof frame.type === 'number' ? FRAME_TYPES[frame.type] : frame.type
|
||
|
|
const pixelFormat = declaredPixelFormats[codec] || rawPixelFormats[frameType]
|
||
|
|
if (!pixelFormat) {
|
||
|
|
throw new Error(`暂不支持未压缩帧类型 ${frameType || frame.type},请让相机返回 JPEG/H.264/H.265`)
|
||
|
|
}
|
||
|
|
|
||
|
|
return [
|
||
|
|
'-f', 'rawvideo',
|
||
|
|
'-pixel_format', pixelFormat,
|
||
|
|
'-video_size', `${width}x${height}`,
|
||
|
|
'-framerate', String(Number(frame.source_fps) || 25)
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
function createTranscoder(frame) {
|
||
|
|
const fps = Math.max(1, Number(frame.source_fps) || 25)
|
||
|
|
const ffmpeg = process.env.FFMPEG_PATH || 'ffmpeg'
|
||
|
|
const args = [
|
||
|
|
'-hide_banner', '-loglevel', 'warning',
|
||
|
|
...inputArguments(frame),
|
||
|
|
'-i', 'pipe:0',
|
||
|
|
'-an',
|
||
|
|
'-c:v', 'libx264',
|
||
|
|
'-preset', 'ultrafast',
|
||
|
|
'-tune', 'zerolatency',
|
||
|
|
'-pix_fmt', 'yuv420p',
|
||
|
|
'-g', String(fps),
|
||
|
|
'-keyint_min', String(fps),
|
||
|
|
'-sc_threshold', '0',
|
||
|
|
'-f', 'mp4',
|
||
|
|
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
|
||
|
|
'pipe:1'
|
||
|
|
]
|
||
|
|
return spawn(ffmpeg, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||
|
|
}
|
||
|
|
|
||
|
|
function message(error) {
|
||
|
|
return error?.details || error?.message || String(error)
|
||
|
|
}
|
||
|
|
|
||
|
|
export function streamCameraVideo(req, res) {
|
||
|
|
const ip = typeof req.query.ip === 'string' ? req.query.ip.trim() : ''
|
||
|
|
const deviceId = typeof req.query.deviceId === 'string' ? req.query.deviceId.trim() : ''
|
||
|
|
if (!ip || !deviceId) {
|
||
|
|
res.status(400).json({ code: 400, message: 'ip 和 deviceId 不能为空' })
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const client = createCameraGrpcClient(ip)
|
||
|
|
const grpcStream = client.getRgbImageStream()
|
||
|
|
let transcoder
|
||
|
|
let closed = false
|
||
|
|
let responseStarted = false
|
||
|
|
let stderr = ''
|
||
|
|
|
||
|
|
const close = () => {
|
||
|
|
if (closed) return
|
||
|
|
closed = true
|
||
|
|
clearTimeout(firstFrameTimer)
|
||
|
|
grpcStream.cancel()
|
||
|
|
client.close()
|
||
|
|
if (transcoder && !transcoder.killed) {
|
||
|
|
transcoder.stdin.destroy()
|
||
|
|
transcoder.kill()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const fail = (error) => {
|
||
|
|
if (closed) return
|
||
|
|
console.error('[camera] video stream failed:', message(error))
|
||
|
|
if (!responseStarted) {
|
||
|
|
res.status(502).json({ code: 502, message: `相机视频流不可用: ${message(error)}` })
|
||
|
|
} else {
|
||
|
|
res.destroy(error instanceof Error ? error : undefined)
|
||
|
|
}
|
||
|
|
close()
|
||
|
|
}
|
||
|
|
|
||
|
|
const firstFrameTimer = setTimeout(
|
||
|
|
() => fail(new Error('等待相机首帧超时')),
|
||
|
|
FIRST_FRAME_TIMEOUT_MS
|
||
|
|
)
|
||
|
|
|
||
|
|
grpcStream.on('data', (feedback) => {
|
||
|
|
if (closed) return
|
||
|
|
if (feedback?.header?.success === false) {
|
||
|
|
fail(new Error(feedback.header.error_message || '相机拒绝视频流请求'))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const frame = feedback?.color_frame
|
||
|
|
if (!frame?.data?.length) return
|
||
|
|
|
||
|
|
if (!transcoder) {
|
||
|
|
clearTimeout(firstFrameTimer)
|
||
|
|
try {
|
||
|
|
transcoder = createTranscoder(frame)
|
||
|
|
} catch (error) {
|
||
|
|
fail(error)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
res.status(200)
|
||
|
|
res.set({
|
||
|
|
'Content-Type': 'video/mp4',
|
||
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||
|
|
Connection: 'keep-alive',
|
||
|
|
'X-Accel-Buffering': 'no'
|
||
|
|
})
|
||
|
|
responseStarted = true
|
||
|
|
transcoder.stdout.pipe(res)
|
||
|
|
transcoder.stderr.on('data', (chunk) => {
|
||
|
|
stderr = (stderr + chunk.toString()).slice(-4000)
|
||
|
|
})
|
||
|
|
transcoder.stdin.on('error', (error) => {
|
||
|
|
if (!closed && error.code !== 'EPIPE') fail(error)
|
||
|
|
})
|
||
|
|
transcoder.on('error', fail)
|
||
|
|
transcoder.on('exit', (code, signal) => {
|
||
|
|
if (!closed && code !== 0) {
|
||
|
|
fail(new Error(`FFmpeg 异常退出 (${code ?? signal}): ${stderr.trim()}`))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!transcoder.stdin.write(frame.data)) {
|
||
|
|
grpcStream.pause()
|
||
|
|
transcoder.stdin.once('drain', () => grpcStream.resume())
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
grpcStream.on('error', (error) => {
|
||
|
|
// CANCELLED 是浏览器断开后主动清理产生的正常状态。
|
||
|
|
if (!closed && error.code !== 1) fail(error)
|
||
|
|
})
|
||
|
|
grpcStream.on('end', () => {
|
||
|
|
if (!closed) {
|
||
|
|
transcoder?.stdin.end()
|
||
|
|
if (!responseStarted) fail(new Error('相机未返回任何视频帧'))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
res.on('close', close)
|
||
|
|
grpcStream.write({
|
||
|
|
header: { device_id: deviceId },
|
||
|
|
eof: false
|
||
|
|
})
|
||
|
|
}
|