NextGB, web demo powerd by vue

This commit is contained in:
chenhaibo
2025-02-03 16:27:46 +08:00
parent 0b7126b12b
commit c80247286e
113 changed files with 16731 additions and 9944 deletions
+95
View File
@@ -0,0 +1,95 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import type * as Types from './types'
import type { MediaServer } from '@/api/mediaserver/types'
const api = axios.create({
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
timeout: 5000,
headers: {
'Content-Type': 'application/json',
},
})
// 媒体服务器相关 API
export const mediaServerApi = {
// 获取媒体服务器列表
getMediaServers: () =>
api.get<Types.ApiResponse<MediaServer[]>>('/srs-sip/v1/media-servers'),
// 添加媒体服务器
addMediaServer: (data: Omit<MediaServer, 'id' | 'status' | 'created_at'>) =>
api.post<Types.ApiResponse<{ msg: string }>>('/srs-sip/v1/media-servers', data),
// 删除媒体服务器
deleteMediaServer: (id: number) =>
api.delete<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/${id}`),
// 设置默认媒体服务器
setDefaultMediaServer: (id: number) =>
api.post<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/default/${id}`),
}
// 设备相关 API
export const deviceApi = {
// 获取设备列表
getDevices: () => api.get<Types.ApiResponse<Types.Device[]>>('/srs-sip/v1/devices'),
// 获取设备通道
getDeviceChannels: (deviceId: string) =>
api.get<Types.ApiResponse<Types.ChannelInfo[]>>(`/srs-sip/v1/devices/${deviceId}/channels`),
// 添加 invite API
invite: (params: Types.InviteRequest) =>
api.post<Types.ApiResponse<Types.InviteResponse>>('/srs-sip/v1/invite', params),
// 停止播放
bye: (params: Types.ByeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/bye', params),
// 暂停播放
pause: (params: Types.PauseRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/pause', params),
// 恢复播放
resume: (params: Types.ResumeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/resume', params),
// 设置播放速度
speed: (params: Types.SpeedRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/speed', params),
// 云台控制
controlPTZ: (params: Types.PTZControlRequest) =>
api.post<Types.ApiResponse<any>>('/srs-sip/v1/ptz', params),
// 查询录像
queryRecord: (params: Types.RecordInfoRequest) =>
api.post<Types.ApiResponse<Types.RecordInfoResponse[]>>('/srs-sip/v1/query-record', params),
}
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 配置处理逻辑
return config
},
(error) => {
return Promise.reject(error)
},
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
const res = response.data as Types.ApiResponse<any>
if (res.code !== 0) {
ElMessage.error('请求失败')
return Promise.reject(new Error('请求失败'))
}
response.data = res.data
return response
},
(error) => {
ElMessage.error(error.response?.data?.message || '网络错误')
return Promise.reject(error)
},
)
export default api
+30
View File
@@ -0,0 +1,30 @@
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from './types'
import { MediaServerType } from './types'
/**
* 媒体服务器接口
*/
export interface IMediaServer {
type: MediaServerType
getVersion(): Promise<VersionInfo>
getStreamInfo(): Promise<StreamInfo[]>
getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
createRtcPlayer(): RtcPlayer
}
/**
* 媒体服务器基础实现类
*/
export abstract class BaseMediaServer implements IMediaServer {
type: MediaServerType
constructor(type: MediaServerType) {
this.type = type
}
abstract getVersion(): Promise<VersionInfo>
abstract getStreamInfo(): Promise<StreamInfo[]>
abstract getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
abstract createRtcPlayer(): RtcPlayer
}
@@ -0,0 +1,22 @@
import type { MediaServer } from './types'
import { MediaServerType } from './types'
import type { BaseMediaServer } from './base'
import { SRSServer } from './srs/srs'
import { ZLMServer } from './zlm/zlm'
/**
* 创建媒体服务器实例的工厂函数
*/
export const createMediaServer = (config: MediaServer): BaseMediaServer => {
// 统一转换为小写进行比较
const serverType = config.type.toLowerCase()
switch (serverType) {
case MediaServerType.SRS:
return new SRSServer(config.ip, config.port)
case MediaServerType.ZLM:
return new ZLMServer(config.ip, config.port, config.secret)
default:
throw new Error(`Unsupported media server type: ${config.type}`)
}
}
+364
View File
@@ -0,0 +1,364 @@
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from '@/api/mediaserver/types'
import { MediaServerType } from '@/api/mediaserver/types'
import { BaseMediaServer } from '@/api/mediaserver/base'
import axios from 'axios'
interface SRSVersionResponse {
code: number
server: string
service: string
pid: string
data: {
major: number
minor: number
revision: number
version: string
}
}
interface SRSClientsResponse {
code: number
server: string
service: string
pid: string
clients: {
id: string
vhost: string
stream: string
ip: string
pageUrl: string
swfUrl: string
tcUrl: string
url: string
name: string
type: string
publish: boolean
alive: number
send_bytes: number
recv_bytes: number
kbps: {
recv_30s: number
send_30s: number
}
}[]
}
interface SRSStreamResponse {
code: number
server: string
service: string
pid: string
streams: {
id: string
name: string
vhost: string
app: string
tcUrl: string
url: string
live_ms: number
clients: number
frames: number
send_bytes: number
recv_bytes: number
kbps: {
recv_30s: number
send_30s: number
}
publish: {
active: boolean
cid: string
}
video?: {
codec: string
profile: string
level: string
width: number
height: number
}
audio?: {
codec: string
sample_rate: number
channel: number
profile: string
}
}[]
}
interface UserQuery {
[key: string]: string | undefined
schema?: string
play?: string
}
interface ParsedUrl {
url: string
schema: string
server: string
port: number
vhost: string
app: string
stream: string
user_query: UserQuery
}
export class SRSServer extends BaseMediaServer {
private baseUrl: string
constructor(host: string, port: number) {
super(MediaServerType.SRS)
this.baseUrl = `http://${host}:${port}`
}
async getVersion(): Promise<VersionInfo> {
try {
const response = await axios.get<SRSVersionResponse>(`${this.baseUrl}/api/v1/versions`)
return {
version: response.data.data.version,
buildDate: undefined, // SRS API 没有提供构建日期
}
} catch (error) {
throw new Error(`Failed to get SRS version: ${error}`)
}
}
async getStreamInfo(): Promise<StreamInfo[]> {
try {
const response = await axios.get<SRSStreamResponse>(`${this.baseUrl}/api/v1/streams/`)
return response.data.streams.map((stream) => ({
id: stream.id,
name: stream.name,
vhost: stream.vhost,
url: stream.tcUrl,
clients: stream.clients - 1,
active: stream.publish.active,
send_bytes: stream.send_bytes,
recv_bytes: stream.recv_bytes,
video: stream.video
? {
codec: stream.video.codec,
width: stream.video.width,
height: stream.video.height,
fps: 0, // SRS API 没有直接提供 fps 信息
}
: undefined,
audio: stream.audio
? {
codec: stream.audio.codec,
sampleRate: stream.audio.sample_rate,
channels: stream.audio.channel,
}
: undefined,
}))
} catch (error) {
throw new Error(`Failed to get SRS streams info: ${error}`)
}
}
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
try {
const response = await axios.get<SRSClientsResponse>(`${this.baseUrl}/api/v1/clients/`)
let clients = response.data.clients.filter((client) => !client.publish)
// 如果指定了 stream_id则过滤出对应的流
if (params?.stream_id) {
clients = clients.filter(client => client.stream === params.stream_id)
}
return clients.map((client) => {
console.log('Client alive value:', client.alive, typeof client.alive)
return {
id: client.id,
vhost: client.vhost,
stream: client.stream,
ip: client.ip,
url: client.url,
alive: Math.round(client.alive * 1000), // 转换为毫秒并四舍五入
type: client.type,
}
})
} catch (error) {
throw new Error(`Failed to get SRS clients info: ${error}`)
}
}
async kickClient(clientId: string) {
const response = await axios.post(`${this.baseUrl}/api/v1/clients/${clientId}/kick`)
return response.data
}
createRtcPlayer(): RtcPlayer {
const self = {
pc: new RTCPeerConnection({
iceServers: [],
}),
async play(url: string) {
const conf = this.__internal.prepareUrl(url)
this.pc.addTransceiver('audio', { direction: 'recvonly' })
this.pc.addTransceiver('video', { direction: 'recvonly' })
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
const session = await fetch(conf.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api: conf.apiUrl,
streamurl: conf.streamUrl,
clientip: null,
sdp: offer.sdp,
}),
}).then((res) => res.json())
if (session.code) {
throw session
}
await this.pc.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: session.sdp }),
)
return session
},
async close() {
this.pc.close()
},
ontrack: null as ((event: RTCTrackEvent) => void) | null,
__internal: {
defaultPath: '/rtc/v1/play/',
prepareUrl(webrtcUrl: string) {
const urlObject = this.parse(webrtcUrl) as ParsedUrl
const schema = urlObject.user_query.schema
? urlObject.user_query.schema + ':'
: window.location.protocol
let port = urlObject.port || 1985
if (schema === 'https:') {
port = urlObject.port || 443
}
let api = urlObject.user_query.play || this.defaultPath
if (api.lastIndexOf('/') !== api.length - 1) {
api += '/'
}
let apiUrl = schema + '//' + urlObject.server + ':' + port + api
for (const key in urlObject.user_query) {
if (key !== 'api' && key !== 'play') {
apiUrl += '&' + key + '=' + urlObject.user_query[key]
}
}
apiUrl = apiUrl.replace(api + '&', api + '?')
return {
apiUrl,
streamUrl: urlObject.url,
schema,
urlObject,
port,
}
},
parse(url: string): ParsedUrl {
const a = document.createElement('a')
a.href = url
.replace('rtmp://', 'http://')
.replace('webrtc://', 'http://')
.replace('rtc://', 'http://')
let vhost = a.hostname
let app = a.pathname.substring(1, a.pathname.lastIndexOf('/'))
const stream = a.pathname.slice(a.pathname.lastIndexOf('/') + 1)
app = app.replace('...vhost...', '?vhost=')
if (app.indexOf('?') >= 0) {
const params = app.slice(app.indexOf('?'))
app = app.slice(0, app.indexOf('?'))
if (params.indexOf('vhost=') > 0) {
vhost = params.slice(params.indexOf('vhost=') + 'vhost='.length)
if (vhost.indexOf('&') > 0) {
vhost = vhost.slice(0, vhost.indexOf('&'))
}
}
}
if (a.hostname === vhost) {
const re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
if (re.test(a.hostname)) {
vhost = '__defaultVhost__'
}
}
let schema = 'rtmp'
if (url.indexOf('://') > 0) {
schema = url.slice(0, url.indexOf('://'))
}
let port = parseInt(a.port)
if (!port) {
if (schema === 'http') {
port = 80
} else if (schema === 'https') {
port = 443
} else if (schema === 'rtmp') {
port = 1935
}
}
const ret: ParsedUrl = {
url,
schema,
server: a.hostname,
port,
vhost,
app,
stream,
user_query: {},
}
this.fill_query(a.search, ret)
return ret
},
fill_query(query_string: string, obj: ParsedUrl) {
if (query_string.length === 0) {
return
}
if (query_string.indexOf('?') >= 0) {
query_string = query_string.split('?')[1]
}
const queries = query_string.split('&')
for (const elem of queries) {
const query = elem.split('=')
obj.user_query[query[0]] = query[1]
}
if (obj.user_query.domain) {
obj.vhost = obj.user_query.domain
}
},
},
}
self.pc.ontrack = (event: RTCTrackEvent) => {
if (self.ontrack) {
self.ontrack(event)
}
}
return self
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 流媒体服务器类型枚举
*/
export enum MediaServerType {
ZLM = 'zlm', // ZLMediaKit
SRS = 'srs', // SRS
CUSTOM = 'custom', // 自定义服务器
}
export interface RtcPlayer {
pc: RTCPeerConnection
play(url: string): Promise<void>
close(): Promise<void>
ontrack: ((event: RTCTrackEvent) => void) | null
}
export interface MediaServer {
id: number
name: string
ip: string
port: number
type: string
username?: string
password?: string
secret?: string
status: number
created_at: string
isDefault?: number
}
/**
* 版本信息接口
*/
export interface VersionInfo {
version: string
buildDate?: string
}
/**
* 视频编码信息
*/
export interface VideoCodecInfo {
codec: string // 视频编码格式,如 H264, H265
width: number // 视频宽度
height: number // 视频高度
fps: number // 帧率
bitrate?: number // 比特率 (kbps)
}
/**
* 音频编码信息
*/
export interface AudioCodecInfo {
codec: string // 音频编码格式,如 AAC, G711
sampleRate: number // 采样率
channels: number // 声道数
bitrate?: number // 比特率 (kbps)
}
export interface StreamInfo {
id: string
name: string // 流名称
vhost: string // 虚拟主机
url: string // 流地址
clients: number // 客户端连接数
active: boolean // 是否活跃
video?: VideoCodecInfo // 视频编码信息
audio?: AudioCodecInfo // 音频编码信息
send_bytes?: number // 已传输字节数
recv_bytes?: number // 已接收字节数
}
export interface ClientInfo {
id: string
vhost: string
stream: string
ip: string
url: string
alive: number
type: string
}
+285
View File
@@ -0,0 +1,285 @@
import type { ClientInfo, StreamInfo, VersionInfo, MediaServer, RtcPlayer } from '@/api/mediaserver/types'
import { MediaServerType } from '@/api/mediaserver/types'
import { BaseMediaServer } from '@/api/mediaserver/base'
import axios from 'axios'
// {
// "code": 0,
// "data": {
// "branchName": "master",
// "buildTime": "2023-04-19T10:34:34",
// "commitHash": "f143898"
// }
// }
interface ZLMVersionResponse {
code: number
data: {
branchName: string
buildTime: string
commitHash: string
}
}
// {
// "code" : 0,
// "data" : [
// {
// "app" : "live", # 应用名
// "readerCount" : 0, # 本协议观看人数
// "totalReaderCount" : 0, # 观看总人数包括hls/rtsp/rtmp/http-flv/ws-flv
// "schema" : "rtsp", # 协议
// "stream" : "obs", # 流id
// "originSock": { # 客户端和服务器网络信息可能为null类型
// "identifier": "140241931428384",
// "local_ip": "127.0.0.1",
// "local_port": 1935,
// "peer_ip": "127.0.0.1",
// "peer_port": 50097
// },
// "originType": 1, # 产生源类型,包括 unknown = 0,rtmp_push=1,rtsp_push=2,rtp_push=3,pull=4,ffmpeg_pull=5,mp4_vod=6,device_chn=7
// "originTypeStr": "MediaOriginType::rtmp_push",
// "originUrl": "rtmp://127.0.0.1:1935/live/hks2", #产生源的url
// "createStamp": 1602205811, #GMT unix系统时间戳单位秒
// "aliveSecond": 100, #存活时间,单位秒
// "bytesSpeed": 12345, #数据产生速度单位byte/s
// "tracks" : [ # 音视频轨道
// {
// "channels" : 1, # 音频通道数
// "codec_id" : 2, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
// "codec_id_name" : "CodecAAC", # 编码类型名称
// "codec_type" : 1, # Video = 0, Audio = 1
// "ready" : true, # 轨道是否准备就绪
// "frames" : 1119, #累计接收帧数
// "sample_bit" : 16, # 音频采样位数
// "sample_rate" : 8000 # 音频采样率
// },
// {
// "codec_id" : 0, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
// "codec_id_name" : "CodecH264", # 编码类型名称
// "codec_type" : 0, # Video = 0, Audio = 1
// "fps" : 59, # 视频fps
// "frames" : 1119, #累计接收帧数不包含sei/aud/sps/pps等不能解码的帧
// "gop_interval_ms" : 1993, #gop间隔时间单位毫秒
// "gop_size" : 60, #gop大小单位帧数
// "key_frames" : 21, #累计接收关键帧数
// "height" : 720, # 视频高
// "ready" : true, # 轨道是否准备就绪
// "width" : 1280 # 视频宽
// }
// ],
// "vhost" : "__defaultVhost__" # 虚拟主机名
// }
// ]
// }
interface ZLMTrackInfo {
channels?: number // 音频通道数
codec_id: number // 编码器ID
codec_id_name: string // 编码器名称
codec_type: number // 编码类型 (0: Video, 1: Audio)
ready: boolean // 轨道是否就绪
frames: number // 累计接收帧数
sample_bit?: number // 音频采样位数
sample_rate?: number // 音频采样率
// 视频特有属性
fps?: number // 视频帧率
width?: number // 视频宽度
height?: number // 视频高度
gop_interval_ms?: number // GOP间隔时间
gop_size?: number // GOP大小
key_frames?: number // 关键帧数
bytesSpeed?: number // 数据速率
}
interface ZLMStreamInfo {
app: string
readerCount: number
totalReaderCount: number
schema: string
stream: string
originSock: {
identifier: string
local_ip: string
local_port: number
peer_ip: string
peer_port: number
}
originType: number
originTypeStr: string
originUrl: string
createStamp: number
aliveSecond: number
bytesSpeed: number
tracks: ZLMTrackInfo[]
vhost: string
}
interface ZLMStreamResponse {
code: number
data: ZLMStreamInfo[]
}
// {
// "code": 0,
// "data": [
// {
// "identifier": "3-309",
// "local_ip": "::",
// "local_port": 8000,
// "peer_ip": "172.18.190.159",
// "peer_port": 52996,
// "typeid": "mediakit::WebRtcSession"
// }
// ]
// }
interface ZLMClientInfo {
identifier: string
local_ip: string
local_port: number
peer_ip: string
peer_port: number
typeid: string
}
interface ZLMClientInfoResponse {
code: number
data: ZLMClientInfo[]
}
interface ZLMRtcResponse {
code: number
id: string
sdp: string
type: string
}
export class ZLMServer extends BaseMediaServer {
private baseUrl: string
private secret: string
constructor(host: string, port: number, secret: string = '') {
super(MediaServerType.ZLM)
this.baseUrl = `http://${host}:${port}`
this.secret = secret
}
async getVersion(): Promise<VersionInfo> {
try {
const response = await axios.get<ZLMVersionResponse>(`${this.baseUrl}/index/api/version${this.secret ? '?secret=' + this.secret : ''}`)
return {
version: response.data.data.buildTime,
buildDate: response.data.data.buildTime,
}
} catch (error) {
throw new Error(`Failed to get ZLM version: ${error}`)
}
}
// /index/api/getMediaList?schema=rtsp&secret=
async getStreamInfo(): Promise<StreamInfo[]> {
try {
const response = await axios.get<ZLMStreamResponse>(`${this.baseUrl}/index/api/getMediaList?schema=rtsp&secret=${this.secret}`)
return response.data.data.map((stream) => {
const videoTrack = stream.tracks.find((track) => track.codec_type === 0)
const audioTrack = stream.tracks.find((track) => track.codec_type === 1)
return {
id: stream.stream,
name: stream.stream,
vhost: stream.vhost,
url: stream.originUrl,
clients: stream.readerCount,
active: stream.aliveSecond > 0,
send_bytes: 0,
recv_bytes: stream.bytesSpeed,
video: videoTrack?.codec_type === 0 ? {
codec: videoTrack.codec_id_name,
width: videoTrack.width ?? 0,
height: videoTrack.height ?? 0,
fps: videoTrack.fps ?? 0,
bitrate: videoTrack.bytesSpeed ?? 0,
} : undefined,
audio: audioTrack?.codec_type === 1 ? {
codec: audioTrack.codec_id_name,
sampleRate: audioTrack.sample_rate ?? 0,
channels: audioTrack.channels ?? 0,
bitrate: audioTrack.bytesSpeed ?? 0,
} : undefined,
}
})
} catch (error) {
throw new Error(`Failed to get ZLM streams info: ${error}`)
}
}
// /index/api/getMediaPlayerList?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&schema=rtsp&vhost=defaultVhost&app=live&stream=test
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
try {
const streamParam = params?.stream_id ? `&stream=${params.stream_id}` : ''
const response = await axios.get<ZLMClientInfoResponse>(
`${this.baseUrl}/index/api/getMediaPlayerList?secret=${this.secret}&schema=rtsp&vhost=defaultVhost&app=rtp${streamParam}`
)
return response.data.data.map((client) => ({
id: client.identifier,
vhost: '__defaultVhost__', // ZLM默认虚拟主机
stream: 'null',
ip: client.peer_ip,
url: `${client.local_ip}:${client.local_port}`,
alive: 1, // 在线状态
type: client.typeid.replace('mediakit::', '') // 移除 mediakit:: 前缀
}))
} catch (error) {
throw new Error(`Failed to get ZLM clients info: ${error}`)
}
}
createRtcPlayer(): RtcPlayer {
const self = {
pc: new RTCPeerConnection({
iceServers: [],
}),
async play(url: string): Promise<void> {
this.pc.addTransceiver('audio', { direction: 'recvonly' })
this.pc.addTransceiver('video', { direction: 'recvonly' })
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
const response = await axios.post<ZLMRtcResponse>(
url,
offer.sdp,
{
headers: {
'Content-Type': 'text/plain;charset=utf-8'
}
}
)
if (response.data.code !== 0) {
throw new Error('创建WebRTC播放器失败')
}
await this.pc.setRemoteDescription(
new RTCSessionDescription({ type: 'answer', sdp: response.data.sdp })
)
},
async close(): Promise<void> {
this.pc.close()
},
ontrack: null as ((event: RTCTrackEvent) => void) | null,
}
self.pc.ontrack = (event: RTCTrackEvent) => {
if (self.ontrack) {
self.ontrack(event)
}
}
return self
}
}
+110
View File
@@ -0,0 +1,110 @@
// API 响应类型
export interface ApiResponse<T = any> {
code: number
data: T
}
export interface InviteRequest {
media_server_id: number
device_id: string
channel_id: string
sub_stream: number
play_type: number
start_time: number
end_time: number
}
export interface InviteResponse {
url: string
}
export interface ByeRequest {
device_id: string
channel_id: string
url: string
}
export interface PauseRequest {
device_id: string
channel_id: string
url: string
}
export interface ResumeRequest {
device_id: string
channel_id: string
url: string
}
export interface SpeedRequest {
device_id: string
channel_id: string
url: string
speed: number
}
// 通道状态类型
export type ChannelStatus = 'ON' | 'OFF'
// 通道信息类型
export interface ChannelInfo {
device_id: string
parent_id: string
name: string
manufacturer: string
model: string
owner: string
civil_code: string
address: string
port: number
parental: number
safety_way: number
register_way: number
secrecy: number
ip_address: string
status: ChannelStatus
longitude: number
latitude: number
info: {
ptz_type: number
resolution: string
download_speed: string
}
ssrc: string
}
export interface RecordInfoRequest {
device_id: string
channel_id: string
start_time: number
end_time: number
}
export interface RecordInfoResponse {
device_id: string
name: string
file_path: string
address: string
start_time: string
end_time: string
secrecy: number
type: string
}
export interface Device {
device_id: string
source_addr: string
network_type: string
status: number
name: string
}
export interface PTZControlRequest {
device_id: string
channel_id: string
ptz: string
speed: string
}
// 媒体服务器类型