[*] 服务端录制

This commit is contained in:
acgist
2023-05-31 07:34:18 +08:00
parent b1aa1e4a7a
commit 441e99483b
37 changed files with 640 additions and 545 deletions

View File

@@ -16,3 +16,30 @@
## STUN/TURN
视频房间不用`STUN/TURN`服务,视频会话需要自己搭建`coturn`服务。
## SDP格式
```
v= (protocol version) #协议版本
o= (owner/creator and session identifier) #所有者创建者和会话标识符
s= (session name) #会话名称
i=* (session information) #会话信息
u=* (URI of description) #URI描述
e=* (email address) #Email地址
p=* (phone number) #电话号码
c=* (connection information - not required if included in all media) #连接信息(如果包含在所有媒体中则不需要该字段)
b=* (zero or more bandwidth information lines) #带宽信息
z=* (time zone adjustments) #时区调整
k=* (encryption key) #加密密钥
a=* (zero or more session attribute lines) #0或多次会话属性
Time description #时间描述
t= (time the session is active) #会话活动时间
r=* (zero or more repeat times) #0或多次重复次数
Media description #媒体描述
m= (media name and transport address) #媒体名称和传输地址
i=* (media title) #媒体标题
c=* (connection information - optional if included at session-level) #连接信息(如果包含在会话层则该字段可选)
b=* (zero or more bandwidth information lines) #带宽信息
k=* (encryption key) #加密密钥
a=* (zero or more media attribute lines) #0或多个会话属性
````

View File

@@ -0,0 +1,39 @@
package com.acgist.taoyao.boot.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/**
* FFmpeg配置
*
* @author acgist
*/
@Getter
@Setter
@Schema(title = "FFmpeg配置", description = "FFmpeg配置")
@ConfigurationProperties(prefix = "taoyao.ffmpeg")
public class FfmpegProperties {
@Schema(title = "SDP模板", description = "SDP模板")
private String sdp;
@Schema(title = "媒体录像", description = "媒体录像")
private String record;
@Schema(title = "预览截图", description = "预览截图")
private String preview;
@Schema(title = "视频时长", description = "视频时长")
private String duration;
@Schema(title = "存储目录", description = "存储目录")
private String storagePath;
@Schema(title = "图片存储目录", description = "图片存储目录")
private String storageImagePath;
@Schema(title = "视频存储目录", description = "视频存储目录")
private String storageVideoPath;
@Schema(title = "录像最小端口", description = "录像最小端口")
private Integer minPort;
@Schema(title = "录像最大端口", description = "录像最大端口")
private Integer maxPort;
}

View File

@@ -43,6 +43,7 @@ import org.springframework.web.context.request.async.AsyncRequestTimeoutExceptio
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.NoHandlerFoundException;
import com.acgist.taoyao.boot.config.FfmpegProperties;
import com.acgist.taoyao.boot.config.IdProperties;
import com.acgist.taoyao.boot.config.IpRewriteProperties;
import com.acgist.taoyao.boot.config.MediaProperties;
@@ -86,6 +87,7 @@ import lombok.extern.slf4j.Slf4j;
@EnableConfigurationProperties({
IdProperties.class,
MediaProperties.class,
FfmpegProperties.class,
ScriptProperties.class,
SocketProperties.class,
TaoyaoProperties.class,
@@ -94,11 +96,13 @@ import lombok.extern.slf4j.Slf4j;
IpRewriteProperties.class
})
public class BootAutoConfiguration {
private final FfmpegProperties ffmpegProperties;
private final TaoyaoProperties taoyaoProperties;
private final ApplicationContext applicationContext;
public BootAutoConfiguration(TaoyaoProperties taoyaoProperties, ApplicationContext applicationContext) {
public BootAutoConfiguration(FfmpegProperties ffmpegProperties, TaoyaoProperties taoyaoProperties, ApplicationContext applicationContext) {
this.ffmpegProperties = ffmpegProperties;
this.taoyaoProperties = taoyaoProperties;
this.applicationContext = applicationContext;
}
@@ -188,6 +192,8 @@ public class BootAutoConfiguration {
this.applicationContext.getBeansOfType(TaskScheduler.class).forEach((k, v) -> {
log.info("系统定时任务线程池:{} - {}", k, v);
});
FileUtils.mkdirs(this.ffmpegProperties.getStorageImagePath());
FileUtils.mkdirs(this.ffmpegProperties.getStorageVideoPath());
}
/**

View File

@@ -1,5 +1,6 @@
package com.acgist.taoyao.boot.utils;
import java.io.File;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -55,4 +56,24 @@ public final class FileUtils {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_EVEN) + UNITS[index];
}
/**
* @return 是否Linux平台
*/
public static final boolean linux() {
return File.separatorChar == '/';
}
/**
* 创建目录
*
* @param path
*/
public static final void mkdirs(String path) {
final File file = new File(path);
if(file.exists()) {
return;
}
file.mkdirs();
}
}

View File

@@ -4,11 +4,11 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.acgist.taoyao.boot.config.FfmpegProperties;
import com.acgist.taoyao.boot.config.MediaProperties;
import com.acgist.taoyao.boot.config.SocketProperties;
import com.acgist.taoyao.boot.config.WebrtcProperties;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.config.camera.CameraProperties;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@@ -29,7 +29,7 @@ import lombok.RequiredArgsConstructor;
public class ConfigController {
private final MediaProperties mediaProperties;
private final CameraProperties cameraProperties;
private final FfmpegProperties ffmpegProperties;
private final SocketProperties socketProperties;
private final WebrtcProperties webrtcProperties;
@@ -40,11 +40,11 @@ public class ConfigController {
return Message.success(this.mediaProperties);
}
@Operation(summary = "摄像头配置", description = "摄像头配置")
@GetMapping("/camera")
@ApiResponse(content = @Content(schema = @Schema(implementation = CameraProperties.class)))
private Message camera() {
return Message.success(this.cameraProperties);
@Operation(summary = "FFmpeg配置", description = "FFmpeg配置")
@GetMapping("/ffmpeg")
@ApiResponse(content = @Content(schema = @Schema(implementation = FfmpegProperties.class)))
public Message ffmpeg() {
return Message.success(this.ffmpegProperties);
}
@Operation(summary = "Socket配置", description = "Socket配置")

View File

@@ -9,19 +9,11 @@ import org.springframework.web.bind.annotation.RestController;
import com.acgist.taoyao.boot.config.MediaAudioProperties;
import com.acgist.taoyao.boot.config.MediaVideoProperties;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.config.camera.AiProperties;
import com.acgist.taoyao.signal.config.camera.BeautyProperties;
import com.acgist.taoyao.signal.config.camera.WatermarkProperties;
import com.acgist.taoyao.signal.model.control.PtzModel;
import com.acgist.taoyao.signal.protocol.control.ControlAiProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlBeautyProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlBellProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlConfigAudioProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlConfigVideoProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlPhotographProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlPtzProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlRecordProtocol;
import com.acgist.taoyao.signal.protocol.control.ControlWatermarkProtocol;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -41,68 +33,40 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class ControlController {
private final ControlAiProtocol controlAiProtocol;
private final ControlPtzProtocol controlPtzProtocol;
private final ControlBellProtocol controlBellProtocol;
private final ControlBeautyProtocol controlBeautyProtocol;
private final ControlRecordProtocol controlRecordProtocol;
private final ControlWatermarkProtocol controlWatermarkProtocol;
private final ControlPhotographProtocol controlPhotographProtocol;
private final ControlConfigAudioProtocol controlConfigAudioProtocol;
private final ControlConfigVideoProtocol controlConfigVideoProtocol;
@Operation(summary = "AI识别", description = "AI识别控制")
@GetMapping("/ai/{clientId}")
public Message ai(@PathVariable String clientId, @Valid AiProperties aiProperties) {
return Message.success(this.controlAiProtocol.execute(clientId, aiProperties));
}
@Operation(summary = "PTZ", description = "PTZ控制")
@GetMapping("/ptz/{clientId}")
public Message ptz(@PathVariable String clientId, @Valid PtzModel ptzModel) {
return Message.success(this.controlPtzProtocol.execute(clientId, ptzModel));
}
@Operation(summary = "响铃", description = "响铃控制")
@GetMapping("/bell/{clientId}")
public Message bell(@PathVariable String clientId, @NotNull(message = "没有指定操作状态") Boolean enabled) {
return Message.success(this.controlBellProtocol.execute(clientId, enabled));
}
@Operation(summary = "美颜", description = "美颜控制")
@GetMapping("/beauty/{clientId}")
public Message beauty(@PathVariable String clientId, @Valid BeautyProperties beautyProperties) {
return Message.success(this.controlBeautyProtocol.execute(clientId, beautyProperties));
return this.controlBellProtocol.execute(clientId, enabled);
}
@Operation(summary = "录像", description = "录像控制")
@GetMapping("/record/{clientId}")
public Message record(@PathVariable String clientId, @NotNull(message = "没有指定操作状态") Boolean enabled) {
return Message.success(this.controlRecordProtocol.execute(clientId, enabled));
}
@Operation(summary = "水印", description = "水印控制")
@GetMapping("/watermark/{clientId}")
public Message watermark(@PathVariable String clientId, @Valid WatermarkProperties watermarkProperties) {
return Message.success(this.controlWatermarkProtocol.execute(clientId, watermarkProperties));
return this.controlRecordProtocol.execute(clientId, enabled);
}
@Operation(summary = "拍照", description = "拍照控制")
@GetMapping("/photograph/{clientId}")
public Message photograph(@PathVariable String clientId) {
return Message.success(this.controlPhotographProtocol.execute(clientId));
return this.controlPhotographProtocol.execute(clientId);
}
@Operation(summary = "配置音频", description = "配置音频")
@GetMapping("/config/audio/{clientId}")
public Message configAudio(@PathVariable String clientId, @Valid MediaAudioProperties mediaAudioProperties) {
return Message.success(this.controlConfigAudioProtocol.execute(clientId, mediaAudioProperties));
return this.controlConfigAudioProtocol.execute(clientId, mediaAudioProperties);
}
@Operation(summary = "配置视频", description = "配置视频")
@GetMapping("/config/video/{clientId}")
public Message configVideo(@PathVariable String clientId, @Valid MediaVideoProperties mediaVideoProperties) {
return Message.success(this.controlConfigVideoProtocol.execute(clientId, mediaVideoProperties));
return this.controlConfigVideoProtocol.execute(clientId, mediaVideoProperties);
}
}

View File

@@ -0,0 +1,37 @@
package com.acgist.taoyao.controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.protocol.media.MediaRecordProtocol;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
/**
* 媒体
*
* @author acgist
*/
@Tag(name = "媒体", description = "媒体管理")
@Validated
@RestController
@RequestMapping("/media")
@RequiredArgsConstructor
public class MediaController {
private final MediaRecordProtocol mediaRecordProtocol;
@Operation(summary = "录像", description = "媒体录像")
@GetMapping("/record/{roomId}/{clientId}")
public Message record(@PathVariable String roomId, @PathVariable String clientId, @NotNull(message = "没有指定操作状态") Boolean enabled) {
return this.mediaRecordProtocol.execute(roomId, clientId, enabled);
}
}

View File

@@ -67,7 +67,7 @@ taoyao:
max-client-index: 99999
# 媒体配置
media:
# =============== 视频配置 ===============
# 视频配置
# 宽度
min-width: 720
max-width: 4096
@@ -80,7 +80,7 @@ taoyao:
# 视频码率
min-video-bitrate: 800
max-video-bitrate: 1600
# =============== 音频配置 ===============
# 音频配置
# 采样位数
min-sample-size: 8
max-sample-size: 32
@@ -196,35 +196,6 @@ taoyao:
# port: 3478
# username: taoyao
# password: taoyao
# 摄像头配置
camera:
# 混音
audio-mixer: false
# 变声
audio-changer: false
# 降噪
audio-denoise: true
# 存储目录
storage-path: /data/taoyao/storage
# 图片存储目录
storage-image-path: /data/taoyao/storage/image
# 视频存储目录
storage-video-path: /data/taoyao/storage/video
# AI识别
ai:
enabled: false
type: PERSON
# 美颜
beauty:
enabled: false
level: 10
# 水印
watermark:
enabled: false
text: taoyao
posx: 10
posy: 10
opacity: 0.8
# 安全配置
security:
enabled: true
@@ -253,6 +224,36 @@ taoyao:
- network: 192.168.8.0
inner-host:
outer-host:
ffmpeg:
# SDPVP8 | H264
sdp: |
v=0
o=- 0 0 IN IP4 127.0.0.1
s=TaoyaoRecord
t=0 0
m=audio %d RTP/AVP 97
c=IN IP4 127.0.0.1
a=rtpmap:97 opus/48000/2
a=fmtp:97 sprop-stereo=1
m=video %d RTP/AVP 96
c=IN IP4 127.0.0.1
a=rtpmap:96 H264/90000
a=fmtp:96 packetization-mode=1
# 录像命令
record: ffmpeg -y -protocol_whitelist "file,rtp,udp" -i %s %s
# 截图命令
preview: ffmpeg -y -i %s -t %d -f image2 %s
# 时长命令
duration: ffprobe -i %s -show_entries format=duration
# 存储目录
storage-path: /data/taoyao/storage
# 图片存储目录
storage-image-path: /data/taoyao/storage/image
# 视频存储目录
storage-video-path: /data/taoyao/storage/video
# 端口范围
min-port: 50000
max-port: 59999
# 脚本配置
script:
enabled: true

View File

@@ -0,0 +1,36 @@
package com.acgist.taoyao;
import org.junit.jupiter.api.Test;
import com.acgist.taoyao.boot.config.FfmpegProperties;
import com.acgist.taoyao.signal.party.media.Recorder;
public class RecorderTest {
@Test
public void testStart() throws InterruptedException {
final FfmpegProperties ffmpegProperties = new FfmpegProperties();
ffmpegProperties.setStorageVideoPath("D:\\tmp\\video");
ffmpegProperties.setSdp("""
v=0
o=- 0 0 IN IP4 127.0.0.1
s=TaoyaoRecord
t=0 0
m=audio %d RTP/AVP 97
c=IN IP4 127.0.0.1
a=rtpmap:97 opus/48000/2
a=fmtp:97 sprop-stereo=1
m=video %d RTP/AVP 96
c=IN IP4 127.0.0.1
a=rtpmap:96 H264/90000
a=fmtp:96 packetization-mode=1
""");
ffmpegProperties.setRecord("ffmpeg -y -protocol_whitelist \"file,rtp,udp\" -i %s %s");
final Recorder recorder = new Recorder(ffmpegProperties);
recorder.start();
Thread.sleep(20 * 1000);
recorder.stop();
Thread.sleep(20 * 1000);
}
}

View File

@@ -82,20 +82,13 @@ public class RtpTest {
// {"header":{"v":"1.0.0","id":1215310510002009,"signal":"room::enter"},"body":{"roomId":"8260e615-3081-4bfc-96a8-574f4dd780d9"}}
// {"header":{"v":"1.0.0","id":1215310510002010,"signal":"media::transport::plain"},"body":{"roomId":"8260e615-3081-4bfc-96a8-574f4dd780d9","rtcpMux":false,"comedia":true}}
// {"header":{"v":"1.0.0","id":1215375110006012,"signal":"media::produce"},"body":{"kind":"video","roomId":"8260e615-3081-4bfc-96a8-574f4dd780d9","transportId":"14dc9307-bf9c-4442-a9ad-ce6a97623ef4","appData":{},"rtpParameters":{"codecs":[{"mimeType":"video/vp8","clockRate":90000,"payloadType":102,"rtcpFeedback":[]}],"encodings":[{"ssrc":123123}]}}}
// ffmpeg直接播放
// ffmpeg -re -i video.mp4 -c:a copy -vn -map 0:1 -f rtp rtp://localhost:6666 > taoyao.sdp
// ffmpeg -re -i video.mp4 -c:v copy -an -map 0:0 -f rtp rtp://localhost:6666 > taoyao.sdp
// ffplay -protocol_whitelist "file,udp,rtp" taoyao.sdp
// ffmpeg不支持rtcpMux
// ffmpeg -re -i video.mp4 -c:v libvpx -map 0:0 -f tee "[select=v:f=rtp:ssrc=123123:payload_type=102]rtp://192.168.1.110:40793?rtcpport=47218"
// ffmpeg -re -i video.mp4 -c:v vp8 -map 0:0 -f tee "[select=v:f=rtp:ssrc=123123:payload_type=102]rtp://192.168.1.110:40793?rtcpport=47218"
// ffmpeg -re -i video.mp4 -c:v libvpx -map 0:0 -f tee "[select=v:f=rtp:ssrc=123123:payload_type=102]rtp://192.168.1.110:40793?rtcpport=47218"
// 音频视频同时传输
// ffmpeg -re -i video.mp4 -c:v copy -an -f rtp rtp://192.168.8.122:6666 -c:a copy -vn -f rtp rtp://192.168.8.122:7777 -sdp_file taoyao.sdp
// ffmpeg -re -i video.mp4 -c:a libopus -vn -f rtp rtp://192.168.1.110:8888 -c:v libx264 -an -f rtp rtp://192.168.1.110:9999 -sdp_file taoyao.sdp
// ffplay -protocol_whitelist "file,rtp,udp" -i taoyao.sdp
// ffmpeg -protocol_whitelist "file,rtp,udp" -i taoyao.sdp taoyao.mp4
// 混音
// ffmpeg -re -i video.mp4 -i music.mp3 -map 0 -vn -c:a copy -f rtp rtp://192.168.8.122:6666 -map 0 -an -c:v copy -f rtp rtp://192.168.8.122:7777 -map 1 -c:a aac -f rtp rtp://192.168.8.122:8888 -sdp_file taoyao.sdp
// ffmpeg -protocol_whitelist "file,rtp,udp" -i taoyao.sdp -filter_complex "[0:0]aresample=8000[a0];[0:2]aresample=8000[a2];[a0][a2]amix=inputs=2" taoyao.mp4
final Scanner scanner = new Scanner(System.in);
do {
if(StringUtils.isEmpty(line)) {

View File

@@ -1,36 +0,0 @@
package com.acgist.taoyao.signal.config.camera;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
/**
* AI识别配置
*
* @author acgist
*/
@Getter
@Setter
@Schema(title = "AI识别配置", description = "AI识别配置")
public class AiProperties {
/**
* 识别类型
*
* @author acgist
*/
public enum Type {
// 人
PERSON;
}
@Schema(title = "是否开启", description = "是否开启")
@NotNull(message = "没有指定操作状态")
private Boolean enabled;
@Schema(title = "识别类型", description = "识别类型")
private Type type;
}

View File

@@ -1,24 +0,0 @@
package com.acgist.taoyao.signal.config.camera;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
/**
* 美颜配置
*
* @author acgist
*/
@Getter
@Setter
@Schema(title = "美颜配置", description = "美颜配置")
public class BeautyProperties {
@Schema(title = "是否开启", description = "是否开启")
@NotNull(message = "没有指定操作状态")
private Boolean enabled;
@Schema(title = "美颜级别", description = "美颜级别")
private Integer level;
}

View File

@@ -1,50 +0,0 @@
package com.acgist.taoyao.signal.config.camera;
import org.springframework.boot.context.properties.ConfigurationProperties;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/**
* 摄像头配置
*
* 音频:
* 混音:不用混音
* 变声:
* 降噪:
*
* 视频:
* 录制:
* 水印:
* 美颜:
* AI识别
*
* @author acgist
*/
@Getter
@Setter
@Schema(title = "摄像头配置", description = "摄像头配置")
@ConfigurationProperties(prefix = "taoyao.camera")
public class CameraProperties {
@Schema(title = "混音", description = "混音")
private Boolean audioMixer = Boolean.FALSE;
@Schema(title = "变声", description = "变声")
private Boolean audioChanger;
@Schema(title = "降噪", description = "降噪")
private Boolean audioDenoise;
@Schema(title = "存储目录", description = "存储目录")
private String storagePath;
@Schema(title = "图片存储目录", description = "图片存储目录")
private String storageImagePath;
@Schema(title = "视频存储目录", description = "视频存储目录")
private String storageVideoPath;
@Schema(title = "AI识别配置", description = "AI识别配置")
private AiProperties ai;
@Schema(title = "美颜配置", description = "美颜配置")
private BeautyProperties beauty;
@Schema(title = "水印配置", description = "水印配置")
private WatermarkProperties watermark;
}

View File

@@ -1,30 +0,0 @@
package com.acgist.taoyao.signal.config.camera;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
/**
* 水印配置
*
* @author acgist
*/
@Getter
@Setter
@Schema(title = "水印配置", description = "水印配置")
public class WatermarkProperties {
@Schema(title = "是否开启", description = "是否开启")
@NotNull(message = "没有指定操作状态")
private Boolean enabled;
@Schema(title = "水印内容", description = "水印内容")
private String text;
@Schema(title = "X坐标", description = "X坐标")
private Integer posx;
@Schema(title = "Y坐标", description = "Y坐标")
private Integer posy;
@Schema(title = "透明度", description = "透明度")
private Double opacity;
}

View File

@@ -2,12 +2,10 @@ package com.acgist.taoyao.signal.configuration;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import com.acgist.taoyao.boot.runner.OrderedCommandLineRunner;
import com.acgist.taoyao.signal.config.camera.CameraProperties;
import com.acgist.taoyao.signal.event.EventPublisher;
import com.acgist.taoyao.signal.protocol.ProtocolManager;
@@ -21,9 +19,6 @@ import lombok.RequiredArgsConstructor;
*/
@AutoConfiguration
@RequiredArgsConstructor
@EnableConfigurationProperties({
CameraProperties.class
})
public class SignalAutoConfiguration {
private final ApplicationContext applicationContext;

View File

@@ -1,37 +0,0 @@
package com.acgist.taoyao.signal.model.control;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
/**
* PTZ控制参数
*
* @author acgist
*/
@Schema(title = "PTZ控制参数", description = "PTZ控制参数")
public class PtzModel {
/**
* PTZ类型
*
* @author acgist
*/
public enum Type {
// 水平
PAN,
// 垂直
TILT,
// 变焦
ZOOM;
}
@Schema(title = "PTZ类型", description = "PTZ类型")
@NotNull(message = "PTZ类型不能为空")
private Type type;
@Schema(title = "PTZ参数", description = "PTZ参数")
@NotNull(message = "PTZ参数不能为空")
private Double value;
}

View File

@@ -50,6 +50,10 @@ public class ClientWrapper implements AutoCloseable {
* SCTP协商
*/
private Object sctpCapabilities;
/**
* 媒体录像
*/
private Recorder recorder;
/**
* 发送通道
*/

View File

@@ -0,0 +1,224 @@
package com.acgist.taoyao.signal.party.media;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.UUID;
import com.acgist.taoyao.boot.config.FfmpegProperties;
import com.acgist.taoyao.boot.utils.FileUtils;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
/**
* 媒体录像
*
* @author acgist
*/
@Slf4j
@Getter
@Setter
public class Recorder {
/**
* 是否关闭
*/
private boolean close;
/**
* 是否正在运行
*/
private boolean running;
/**
* 音频端口
*/
private Integer audioPort;
/**
* 视频端口
*/
private Integer videoPort;
/**
* 传输通道
*/
private Transport transport;
/**
* 音频消费者
*/
private Consumer audioConsumer;
/**
* 视频消费者
*/
private Consumer videoConsumer;
/**
* 录像进程
*/
private Process process;
/**
* 进程Builder
*/
private ProcessBuilder processBuilder;
/**
* 录制线程
*/
private Thread thread;
/**
* 日志线程
*/
private Thread inputThread;
/**
* 异常线程
*/
private Thread errorThread;
/**
* 命令
*/
private String command;
/**
* 文件路径
*/
private final String folder;
/**
* SDP路径
*/
private final String sdpfile;
/**
* 文件路径
*/
private final String filepath;
/**
* FFmpeg配置
*/
private final FfmpegProperties ffmpegProperties;
public Recorder(FfmpegProperties ffmpegProperties) {
this.close = false;
this.running = false;
this.ffmpegProperties = ffmpegProperties;
final String id = UUID.randomUUID().toString();
this.folder = Paths.get(ffmpegProperties.getStorageVideoPath(), id).toAbsolutePath().toString();
this.sdpfile = Paths.get(this.folder, "taoyao.sdp").toAbsolutePath().toString();
this.filepath = Paths.get(this.folder, "taoyao.mp4").toAbsolutePath().toString();
this.command = String.format(this.ffmpegProperties.getRecord(), this.sdpfile, this.filepath);
FileUtils.mkdirs(this.folder);
}
/**
* 开始录像
*/
public void start() {
synchronized (this) {
if(this.running) {
return;
}
this.running = true;
this.thread = new Thread(this::record);
this.thread.setDaemon(true);
this.thread.setName("TaoyaoRecord");
this.thread.start();
}
}
/**
* 录制视频
*/
private void record() {
this.buildSdpfile();
int status = 0;
final StringBuilder input = new StringBuilder();
final StringBuilder error = new StringBuilder();
try {
final boolean linux = FileUtils.linux();
if(linux) {
this.processBuilder = new ProcessBuilder("/bin/bash", "-c", this.command);
this.process = processBuilder.start();
} else {
this.processBuilder = new ProcessBuilder("cmd", "/c", this.command);
this.process = processBuilder.start();
}
log.debug("""
开始录像:{}
录像命令:{}
""", this.filepath, this.command);
this.inputThread = new Thread(() -> {
try (final InputStream inputStream = this.process.getInputStream()) {
int length;
final byte[] bytes = new byte[1024];
while(this.running && !this.close && (length = inputStream.read(bytes)) >= 0) {
input.append(linux ? new String(bytes, 0, length) : new String(bytes, 0, length, "GBK"));
}
} catch (Exception e) {
log.error("读取录像日志异常", e);
}
});
this.inputThread.setDaemon(true);
this.inputThread.setName("TaoyaoRecordInput");
this.inputThread.start();
this.errorThread = new Thread(() -> {
try (final InputStream inputStream = this.process.getErrorStream();) {
int length;
final byte[] bytes = new byte[1024];
while(this.running && !this.close && (length = inputStream.read(bytes)) >= 0) {
error.append(linux ? new String(bytes, 0, length) : new String(bytes, 0, length, "GBK"));
}
} catch (Exception e) {
log.error("读取录像错误异常", e);
}
});
this.errorThread.setDaemon(true);
this.errorThread.setName("TaoyaoRecordError");
this.errorThread.start();
status = this.process.waitFor();
} catch (Exception e) {
log.error("录像异常:{}", this.command, e);
} finally {
this.stop();
}
log.debug("""
结束录像:{}
结束状态:{}
录像日志:{}
异常日志:{}
""", this.filepath, status, input, error);
}
/**
* 创建SDP文件
*/
private void buildSdpfile() {
try {
Files.write(
Paths.get(this.sdpfile),
String.format(this.ffmpegProperties.getSdp(), 8888, 9999).getBytes(),
StandardOpenOption.WRITE, StandardOpenOption.CREATE
);
} catch (IOException e) {
log.error("创建SDP文件异常{}", this.sdpfile, e);
}
}
/**
* 结束录像
*/
public void stop() {
synchronized (this) {
if(this.close) {
return;
}
this.close = true;
}
if(this.process == null) {
return;
}
log.debug("结束媒体录像:{}", this.filepath);
// 所有子进程
this.process.children().forEach(process -> {
process.destroy();
});
// 当前父进程
this.process.destroy();
}
}

View File

@@ -1,55 +0,0 @@
package com.acgist.taoyao.signal.protocol.control;
import java.util.Map;
import com.acgist.taoyao.boot.annotation.Description;
import com.acgist.taoyao.boot.annotation.Protocol;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.client.Client;
import com.acgist.taoyao.signal.client.ClientType;
import com.acgist.taoyao.signal.config.camera.AiProperties;
import com.acgist.taoyao.signal.protocol.ProtocolControlAdapter;
/**
* 打开AI识别信令
*
* @author acgist
*/
@Protocol
@Description(
body = """
{
"to": "目标终端ID",
...AiProperties
}
""",
flow = {
"信令服务->终端",
"终端=>信令服务->终端"
}
)
public class ControlAiProtocol extends ProtocolControlAdapter {
public static final String SIGNAL = "control::ai";
public ControlAiProtocol() {
super("打开AI识别信令", SIGNAL);
}
@Override
public void execute(String clientId, ClientType clientType, Client client, Client targetClient, Message message, Map<String, Object> body) {
client.push(targetClient.request(message));
}
/**
* @param clientId 终端标识
* @param aiProperties AI识别配置
*
* @return 执行结果
*/
public Message execute(String clientId, AiProperties aiProperties) {
return this.request(clientId, this.build(aiProperties));
}
}

View File

@@ -1,54 +0,0 @@
package com.acgist.taoyao.signal.protocol.control;
import java.util.Map;
import com.acgist.taoyao.boot.annotation.Description;
import com.acgist.taoyao.boot.annotation.Protocol;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.client.Client;
import com.acgist.taoyao.signal.client.ClientType;
import com.acgist.taoyao.signal.config.camera.BeautyProperties;
import com.acgist.taoyao.signal.protocol.ProtocolControlAdapter;
/**
* 打开美颜信令
*
* @author acgist
*/
@Protocol
@Description(
body = """
{
"to": "目标终端ID",
...BeautyProperties
}
""",
flow = {
"信令服务->终端",
"终端=>信令服务->终端"
}
)
public class ControlBeautyProtocol extends ProtocolControlAdapter {
public static final String SIGNAL = "control::beauty";
public ControlBeautyProtocol() {
super("打开美颜信令", SIGNAL);
}
@Override
public void execute(String clientId, ClientType clientType, Client client, Client targetClient, Message message, Map<String, Object> body) {
client.push(targetClient.request(message));
}
/**
* @param clientId 终端ID
* @param beautyProperties 美颜配置
*
* @return 执行结果
*/
public Message execute(String clientId, BeautyProperties beautyProperties) {
return this.request(clientId, this.build(beautyProperties));
}
}

View File

@@ -1,54 +0,0 @@
package com.acgist.taoyao.signal.protocol.control;
import java.util.Map;
import com.acgist.taoyao.boot.annotation.Description;
import com.acgist.taoyao.boot.annotation.Protocol;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.client.Client;
import com.acgist.taoyao.signal.client.ClientType;
import com.acgist.taoyao.signal.model.control.PtzModel;
import com.acgist.taoyao.signal.protocol.ProtocolControlAdapter;
/**
* PTZ信令
*
* @author acgist
*/
@Protocol
@Description(
body = """
{
"to": "目标终端ID",
...PtzControl
}
""",
flow = {
"信令服务->终端",
"终端->信令服务->终端"
}
)
public class ControlPtzProtocol extends ProtocolControlAdapter {
public static final String SIGNAL = "control::ptz";
public ControlPtzProtocol() {
super("PTZ信令", SIGNAL);
}
@Override
public void execute(String clientId, ClientType clientType, Client client, Client targetClient, Message message, Map<String, Object> body) {
client.push(targetClient.request(message));
}
/**
* @param clientId 终端标识
* @param ptzModel PTZ控制参数
*
* @return 执行结果
*/
public Message execute(String clientId, PtzModel ptzModel) {
return this.request(clientId, this.build(ptzModel));
}
}

View File

@@ -1,55 +0,0 @@
package com.acgist.taoyao.signal.protocol.control;
import java.util.Map;
import com.acgist.taoyao.boot.annotation.Description;
import com.acgist.taoyao.boot.annotation.Protocol;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.client.Client;
import com.acgist.taoyao.signal.client.ClientType;
import com.acgist.taoyao.signal.config.camera.WatermarkProperties;
import com.acgist.taoyao.signal.protocol.ProtocolControlAdapter;
/**
* 配置水印信令
*
* @author acgist
*/
@Protocol
@Description(
memo = "如果没有指定参数使用默认参数配置",
body = """
{
"to": "目标终端ID",
...WatermarkProperties
}
""",
flow = {
"信令服务->终端",
"终端=>信令服务->终端"
}
)
public class ControlWatermarkProtocol extends ProtocolControlAdapter {
public static final String SIGNAL = "control::watermark";
public ControlWatermarkProtocol() {
super("配置水印信令", SIGNAL);
}
@Override
public void execute(String clientId, ClientType clientType, Client client, Client targetClient, Message message, Map<String, Object> body) {
client.push(targetClient.request(message));
}
/**
* @param clientId 终端ID
* @param watermarkProperties 水印配置
*
* @return 执行结果
*/
public Message execute(String clientId, WatermarkProperties watermarkProperties) {
return this.request(clientId, this.build(watermarkProperties));
}
}

View File

@@ -1,11 +1,84 @@
package com.acgist.taoyao.signal.protocol.media;
import java.util.Map;
import com.acgist.taoyao.boot.annotation.Description;
import com.acgist.taoyao.boot.annotation.Protocol;
import com.acgist.taoyao.boot.config.FfmpegProperties;
import com.acgist.taoyao.boot.model.Message;
import com.acgist.taoyao.signal.client.Client;
import com.acgist.taoyao.signal.client.ClientType;
import com.acgist.taoyao.signal.party.media.ClientWrapper;
import com.acgist.taoyao.signal.party.media.Recorder;
import com.acgist.taoyao.signal.party.media.Room;
import com.acgist.taoyao.signal.protocol.ProtocolRoomAdapter;
/**
* 媒体录
* 使用ffmpeg进行录制代码侵入较低
* 媒体录
*
* @author acgist
*/
public class MediaRecordProtocol {
@Protocol
@Description(
body = {
"""
{
"clientId": "目标终端ID",
"enabled": 是否录像true|false
}
""",
"""
{
"enabled": 是否录像true|false,
"filepath": "视频文件路径"
}
"""
},
flow = "终端=>信令服务->终端"
)
public class MediaRecordProtocol extends ProtocolRoomAdapter {
private final FfmpegProperties ffmpegProperties;
public MediaRecordProtocol(FfmpegProperties ffmpegProperties) {
super("媒体录像", "media::record");
this.ffmpegProperties = ffmpegProperties;
}
@Override
public void execute(String clientId, ClientType clientType, Room room, Client client, Client mediaClient, Message message, Map<String, Object> body) {
}
/**
* @param roomId 房间ID
* @param clientId 终端ID
* @param enabled 状态
*
* @return 执行结果
*/
public Message execute(String roomId, String clientId, Boolean enabled) {
final Room room = this.roomManager.room(roomId);
final Client client = this.clientManager.clients(clientId);
if(enabled) {
this.record(room, client);
}
return null;
}
/**
* 开始录制
*/
private void record(Room room, Client client) {
final ClientWrapper clientWrapper = room.clientWrapper(client);
synchronized (clientWrapper) {
if(clientWrapper.getRecorder() != null) {
return;
}
final Recorder recorder = new Recorder(this.ffmpegProperties);
recorder.start();
clientWrapper.setRecorder(recorder);
}
}
}