mirror of
https://github.com/langhuihui/monibuca.git
synced 2025-12-24 13:48:04 +08:00
refactor: snap plugin
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
snap:
|
||||
watermark:
|
||||
text: "" # 水印文字内容
|
||||
fontpath: "" # 水印字体文件路径
|
||||
fontcolor: "rgba(255,165,0,1)" # 水印字体颜色,支持rgba格式
|
||||
fontsize: 36 # 水印字体大小
|
||||
offsetx: 0 # 水印位置X偏移
|
||||
offsety: 0 # 水印位置Y偏移
|
||||
timeinterval: 1s # 截图时间间隔,默认1分钟
|
||||
savepath: "snaps" # 截图保存路径
|
||||
filter: ".*" # 截图流过滤器,支持正则表达式
|
||||
iframeinterval: 3 # 间隔多少帧截图
|
||||
mode: 0 # 截图模式:0-时间间隔,1-关键帧间隔 2-HTTP请求模式(手动触发)
|
||||
querytimedelta: 3 # 查询截图时允许的最大时间差(秒)
|
||||
onpub:
|
||||
transform:
|
||||
.+:
|
||||
output:
|
||||
- watermark:
|
||||
text: "abcd" # 水印文字内容
|
||||
fontpath: /Users/dexter/Library/Fonts/MapleMono-NF-CN-Medium.ttf # 水印字体文件路径
|
||||
fontcolor: "rgba(255,165,0,1)" # 水印字体颜色,支持rgba格式
|
||||
fontsize: 36 # 水印字体大小
|
||||
offsetx: 0 # 水印位置X偏移
|
||||
offsety: 0 # 水印位置Y偏移
|
||||
timeinterval: 1s # 截图时间间隔
|
||||
savepath: "snaps" # 截图保存路径
|
||||
iframeinterval: 3 # 间隔多少帧截图
|
||||
querytimedelta: 3 # 查询截图时允许的最大时间差(秒)
|
||||
|
||||
@@ -164,3 +164,36 @@ func (v HTTPValues) DeepClone() (ret HTTPValues) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *TransfromOutput) UnmarshalYAML(node *yaml.Node) error {
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
// If it's a string, assign it to Target
|
||||
return node.Decode(&r.Target)
|
||||
}
|
||||
|
||||
if node.Kind == yaml.MappingNode {
|
||||
var conf map[string]any
|
||||
if err := node.Decode(&conf); err != nil {
|
||||
return err
|
||||
}
|
||||
var normal bool
|
||||
if conf["target"] != nil {
|
||||
r.Target = conf["target"].(string)
|
||||
normal = true
|
||||
}
|
||||
if conf["streampath"] != nil {
|
||||
r.StreamPath = conf["streampath"].(string)
|
||||
normal = true
|
||||
}
|
||||
if conf["conf"] != nil {
|
||||
r.Conf = conf["conf"]
|
||||
normal = true
|
||||
}
|
||||
if !normal {
|
||||
r.Conf = conf
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported node kind: %v", node.Kind)
|
||||
}
|
||||
|
||||
@@ -634,7 +634,7 @@ func (p *Plugin) Record(pub *Publisher, conf config.Record, subConf *config.Subs
|
||||
|
||||
func (p *Plugin) Transform(pub *Publisher, conf config.Transform) {
|
||||
transformer := p.Meta.Transformer()
|
||||
job := transformer.GetTransformJob().Init(transformer, p, pub.StreamPath, conf)
|
||||
job := transformer.GetTransformJob().Init(transformer, p, pub, conf)
|
||||
job.Depend(pub)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
|
||||
globalPB "m7s.live/v5/pb"
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/config"
|
||||
pb "m7s.live/v5/plugin/sei/pb"
|
||||
sei "m7s.live/v5/plugin/sei/pkg"
|
||||
@@ -17,9 +16,9 @@ func (conf *SEIPlugin) Insert(ctx context.Context, req *pb.InsertRequest) (*glob
|
||||
if targetStreamPath == "" {
|
||||
targetStreamPath = streamPath + "/sei"
|
||||
}
|
||||
ok := conf.Server.Streams.Has(streamPath)
|
||||
if !ok {
|
||||
return nil, pkg.ErrNotFound
|
||||
publisher, err := conf.Server.GetPublisher(streamPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var transformer *sei.Transformer
|
||||
if tm, ok := conf.Server.Transforms.Get(targetStreamPath); ok {
|
||||
@@ -29,7 +28,7 @@ func (conf *SEIPlugin) Insert(ctx context.Context, req *pb.InsertRequest) (*glob
|
||||
}
|
||||
} else {
|
||||
transformer = sei.NewTransform().(*sei.Transformer)
|
||||
transformer.TransformJob.Init(transformer, &conf.Plugin, streamPath, config.Transform{
|
||||
transformer.TransformJob.Init(transformer, &conf.Plugin, publisher, config.Transform{
|
||||
Output: []config.TransfromOutput{
|
||||
{
|
||||
Target: targetStreamPath,
|
||||
@@ -41,7 +40,7 @@ func (conf *SEIPlugin) Insert(ctx context.Context, req *pb.InsertRequest) (*glob
|
||||
t := req.Type
|
||||
|
||||
transformer.AddSEI(byte(t), req.Data)
|
||||
err := transformer.WaitStarted()
|
||||
err = transformer.WaitStarted()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -6,19 +6,22 @@ Snap 插件提供了对流媒体的截图功能,支持定时截图、按关键
|
||||
|
||||
```yaml
|
||||
snap:
|
||||
watermark:
|
||||
text: "" # 水印文字内容
|
||||
fontpath: "" # 水印字体文件路径
|
||||
fontcolor: "rgba(255,165,0,1)" # 水印字体颜色,支持rgba格式
|
||||
fontsize: 36 # 水印字体大小
|
||||
offsetx: 0 # 水印位置X偏移
|
||||
offsety: 0 # 水印位置Y偏移
|
||||
timeinterval: 1s # 截图时间间隔,默认1分钟
|
||||
savepath: "snaps" # 截图保存路径
|
||||
filter: ".*" # 截图流过滤器,支持正则表达式
|
||||
iframeinterval: 3 # 间隔多少帧截图
|
||||
mode: 0 # 截图模式:0-时间间隔,1-关键帧间隔 2-HTTP请求模式(手动触发)
|
||||
querytimedelta: 3 # 查询截图时允许的最大时间差(秒)
|
||||
onpub:
|
||||
transform:
|
||||
.+: # 正则表达式过滤流
|
||||
output:
|
||||
- watermark:
|
||||
text: "abcd" # 水印文字内容
|
||||
fontpath: /Users/dexter/Library/Fonts/MapleMono-NF-CN-Medium.ttf # 水印字体文件路径
|
||||
fontcolor: "rgba(255,165,0,1)" # 水印字体颜色,支持rgba格式
|
||||
fontsize: 36 # 水印字体大小
|
||||
offsetx: 0 # 水印位置X偏移
|
||||
offsety: 0 # 水印位置Y偏移
|
||||
timeinterval: 1m # 截图时间间隔
|
||||
savepath: "snaps" # 截图保存路径
|
||||
iframeinterval: 3 # 间隔多少帧截图(在timeinterval为 0 时生效,都为 0 则为手动截图模式)
|
||||
querytimedelta: 3 # 查询截图时允许的最大时间差(秒)
|
||||
|
||||
```
|
||||
|
||||
## HTTP API
|
||||
@@ -90,15 +93,19 @@ GET /query?streamPath={streamPath}&snapTime={timestamp}
|
||||
配置示例:
|
||||
```yaml
|
||||
snap:
|
||||
watermark:
|
||||
text: "测试水印 $T{2006-01-02 15:04:05}"
|
||||
fontpath: "/path/to/font.ttf"
|
||||
fontcolor: "rgba(255,0,0,0.5)"
|
||||
fontsize: 48
|
||||
offsetx: 20
|
||||
offsety: 20
|
||||
mode: 0
|
||||
timeinterval: 1m
|
||||
onpub:
|
||||
transform:
|
||||
.+: # 正则表达式过滤流
|
||||
output:
|
||||
- watermark:
|
||||
text: "测试水印 $T{2006-01-02 15:04:05}"
|
||||
fontpath: "/path/to/font.ttf"
|
||||
fontcolor: "rgba(255,0,0,0.5)"
|
||||
fontsize: 48
|
||||
offsetx: 20
|
||||
offsety: 20
|
||||
timeinterval: 1m # 截图时间间隔
|
||||
savepath: "snaps" # 截图保存路径
|
||||
```
|
||||
|
||||
## 数据库记录
|
||||
@@ -110,32 +117,9 @@ snap:
|
||||
- 截图路径(SnapPath)
|
||||
- 创建时间(CreatedAt)
|
||||
|
||||
## 使用示例
|
||||
|
||||
1. 基础配置示例:
|
||||
```yaml
|
||||
snap:
|
||||
timeinterval: 30s
|
||||
savepath: "./snapshots"
|
||||
mode: 1
|
||||
iframeinterval: 5
|
||||
```
|
||||
|
||||
2. 带水印的配置示例:
|
||||
```yaml
|
||||
snap:
|
||||
watermark:
|
||||
text: "测试水印"
|
||||
fontpath: "/path/to/font.ttf"
|
||||
fontcolor: "rgba(255,0,0,0.5)"
|
||||
fontsize: 48
|
||||
offsetx: 20
|
||||
offsety: 20
|
||||
mode: 0
|
||||
timeinterval: 1m
|
||||
```
|
||||
|
||||
3. API调用示例:
|
||||
## API调用示例:
|
||||
```bash
|
||||
# 手动触发截图
|
||||
curl http://localhost:8080/snap/live/stream1
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
m7s "m7s.live/v5"
|
||||
"m7s.live/v5/pkg"
|
||||
snap_pkg "m7s.live/v5/plugin/snap/pkg"
|
||||
"m7s.live/v5/plugin/snap/pkg/watermark"
|
||||
@@ -34,9 +35,10 @@ func parseRGBA(rgba string) (color.RGBA, error) {
|
||||
}
|
||||
|
||||
// snap 方法负责实际的截图操作
|
||||
func (p *SnapPlugin) snap(streamPath string) (*bytes.Buffer, error) {
|
||||
func (p *SnapPlugin) snap(publisher *m7s.Publisher, watermarkConfig *snap_pkg.WatermarkConfig) (*bytes.Buffer, error) {
|
||||
|
||||
// 获取视频帧
|
||||
annexb, _, err := snap_pkg.GetVideoFrame(streamPath, p.Server)
|
||||
annexb, _, err := snap_pkg.GetVideoFrame(publisher, p.Server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -48,7 +50,12 @@ func (p *SnapPlugin) snap(streamPath string) (*bytes.Buffer, error) {
|
||||
}
|
||||
|
||||
// 如果设置了水印文字,添加水印
|
||||
if p.Watermark.Text != "" && snap_pkg.GlobalWatermarkConfig.Font != nil {
|
||||
if watermarkConfig != nil && watermarkConfig.Text != "" {
|
||||
// 加载字体
|
||||
if err := watermarkConfig.LoadFont(); err != nil {
|
||||
return nil, fmt.Errorf("load watermark font failed: %w", err)
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
img, _, err := image.Decode(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
@@ -57,20 +64,20 @@ func (p *SnapPlugin) snap(streamPath string) (*bytes.Buffer, error) {
|
||||
|
||||
// 添加水印
|
||||
result, err := watermark.DrawWatermarkSingle(img, watermark.TextConfig{
|
||||
Text: snap_pkg.GlobalWatermarkConfig.Text,
|
||||
Font: snap_pkg.GlobalWatermarkConfig.Font,
|
||||
FontSize: snap_pkg.GlobalWatermarkConfig.FontSize,
|
||||
Spacing: snap_pkg.GlobalWatermarkConfig.FontSpacing,
|
||||
Text: watermarkConfig.Text,
|
||||
Font: watermarkConfig.Font,
|
||||
FontSize: watermarkConfig.FontSize,
|
||||
Spacing: watermarkConfig.FontSpacing,
|
||||
RowSpacing: 10,
|
||||
ColSpacing: 20,
|
||||
Rows: 1,
|
||||
Cols: 1,
|
||||
DPI: 72,
|
||||
Color: snap_pkg.GlobalWatermarkConfig.FontColor,
|
||||
Color: watermarkConfig.FontColor,
|
||||
IsGrid: false,
|
||||
Angle: 0,
|
||||
OffsetX: snap_pkg.GlobalWatermarkConfig.OffsetX,
|
||||
OffsetY: snap_pkg.GlobalWatermarkConfig.OffsetY,
|
||||
OffsetX: watermarkConfig.OffsetX,
|
||||
OffsetY: watermarkConfig.OffsetY,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add watermark failed: %w", err)
|
||||
@@ -88,14 +95,39 @@ func (p *SnapPlugin) snap(streamPath string) (*bytes.Buffer, error) {
|
||||
|
||||
func (p *SnapPlugin) doSnap(rw http.ResponseWriter, r *http.Request) {
|
||||
streamPath := r.PathValue("streamPath")
|
||||
|
||||
if !p.Server.Streams.Has(streamPath) {
|
||||
// 获取发布者
|
||||
publisher, err := p.Server.GetPublisher(streamPath)
|
||||
if err != nil {
|
||||
http.Error(rw, pkg.ErrNotFound.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取查询参数
|
||||
query := r.URL.Query()
|
||||
|
||||
// 从查询参数中获取水印配置
|
||||
var watermarkConfig *snap_pkg.WatermarkConfig
|
||||
watermarkText := query.Get("watermark")
|
||||
if watermarkText != "" {
|
||||
watermarkConfig = &snap_pkg.WatermarkConfig{
|
||||
Text: watermarkText,
|
||||
FontPath: query.Get("fontPath"),
|
||||
FontSize: parseFloat64(query.Get("fontSize"), 36),
|
||||
FontSpacing: parseFloat64(query.Get("fontSpacing"), 2),
|
||||
OffsetX: parseInt(query.Get("offsetX"), 0),
|
||||
OffsetY: parseInt(query.Get("offsetY"), 0),
|
||||
}
|
||||
|
||||
// 解析颜色
|
||||
if fontColor := query.Get("fontColor"); fontColor != "" {
|
||||
if color, err := parseRGBA(fontColor); err == nil {
|
||||
watermarkConfig.FontColor = color
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 snap 进行截图
|
||||
buf, err := p.snap(streamPath)
|
||||
buf, err := p.snap(publisher, watermarkConfig)
|
||||
if err != nil {
|
||||
p.Error("snap failed", "error", err.Error())
|
||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||
@@ -103,12 +135,12 @@ func (p *SnapPlugin) doSnap(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 处理保存逻辑
|
||||
var savePath string
|
||||
if p.SavePath != "" && p.IsManualModeSave {
|
||||
savePath := query.Get("savePath")
|
||||
if savePath != "" {
|
||||
now := time.Now()
|
||||
filename := fmt.Sprintf("%s_%s.jpg", streamPath, now.Format("20060102150405.000"))
|
||||
filename = strings.ReplaceAll(filename, "/", "_")
|
||||
savePath = filepath.Join(p.SavePath, filename)
|
||||
savePath = filepath.Join(savePath, filename)
|
||||
|
||||
// 保存到本地
|
||||
if err := os.WriteFile(savePath, buf.Bytes(), 0644); err != nil {
|
||||
@@ -138,13 +170,37 @@ func (p *SnapPlugin) doSnap(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:解析浮点数
|
||||
func parseFloat64(s string, defaultValue float64) float64 {
|
||||
if s == "" {
|
||||
return defaultValue
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// 辅助函数:解析整数
|
||||
func parseInt(s string, defaultValue int) int {
|
||||
if s == "" {
|
||||
return defaultValue
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (p *SnapPlugin) querySnap(rw http.ResponseWriter, r *http.Request) {
|
||||
if p.DB == nil {
|
||||
http.Error(rw, "database not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
streamPath := r.URL.Query().Get("streamPath")
|
||||
streamPath := r.PathValue("streamPath")
|
||||
if streamPath == "" {
|
||||
http.Error(rw, "streamPath is required", http.StatusBadRequest)
|
||||
return
|
||||
@@ -194,7 +250,7 @@ func (p *SnapPlugin) querySnap(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (p *SnapPlugin) RegisterHandler() map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/{streamPath...}": p.doSnap,
|
||||
"/query": p.querySnap,
|
||||
"/{streamPath...}": p.doSnap,
|
||||
"/query/{streamPath...}": p.querySnap,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
package plugin_snap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"image/color"
|
||||
|
||||
snap "m7s.live/v5/plugin/snap/pkg"
|
||||
|
||||
m7s "m7s.live/v5"
|
||||
@@ -18,138 +10,14 @@ var _ = m7s.InstallPlugin[SnapPlugin](snap.NewTransform)
|
||||
|
||||
type SnapPlugin struct {
|
||||
m7s.Plugin
|
||||
Watermark struct {
|
||||
Text string `default:"" desc:"水印文字内容"`
|
||||
FontPath string `default:"" desc:"水印字体文件路径"`
|
||||
FontColor string `default:"rgba(255,165,0,1)" desc:"水印字体颜色,支持rgba格式"`
|
||||
FontSize float64 `default:"36" desc:"水印字体大小"`
|
||||
FontSpacing float64 `default:"2" desc:"水印字体间距"`
|
||||
OffsetX int `default:"0" desc:"水印位置X"`
|
||||
OffsetY int `default:"0" desc:"水印位置Y"`
|
||||
} `desc:"水印配置"`
|
||||
// 定时任务相关配置
|
||||
TimeInterval time.Duration `default:"1m" desc:"截图间隔"`
|
||||
SavePath string `default:"snaps" desc:"截图保存路径"`
|
||||
Filter string `default:".*" desc:"截图流过滤器,支持正则表达式"`
|
||||
IFrameInterval int `default:"3" desc:"间隔多少帧截图"`
|
||||
Mode int `default:"1" desc:"截图模式 0:间隔时间 1:间隔关键帧"`
|
||||
QueryTimeDelta int `default:"3" desc:"查询截图时允许的最大时间差(秒)"`
|
||||
IsManualModeSave bool `default:"false" desc:"手动截图是否保存文件"`
|
||||
filterRegex *regexp.Regexp
|
||||
QueryTimeDelta int `default:"3" desc:"查询截图时允许的最大时间差(秒)"`
|
||||
}
|
||||
|
||||
// OnInit 在插件初始化时添加定时任务
|
||||
func (p *SnapPlugin) OnInit() (err error) {
|
||||
// 检查 Mode 的值范围
|
||||
if p.Mode < snap.SnapModeTimeInterval || p.Mode > snap.SnapModeManual {
|
||||
p.Error("invalid snap mode",
|
||||
"mode", p.Mode,
|
||||
"valid_range", "0-1",
|
||||
)
|
||||
return fmt.Errorf("invalid snap mode: %d, valid range is 0-1", p.Mode)
|
||||
}
|
||||
// 检查 interval 是否大于0
|
||||
if p.TimeInterval < 0 {
|
||||
p.Error("invalid snap time interval",
|
||||
"interval", p.TimeInterval,
|
||||
"valid_range", ">=0",
|
||||
)
|
||||
return fmt.Errorf("invalid snap time interval: %d, valid range is >=0", p.TimeInterval)
|
||||
}
|
||||
if p.IFrameInterval < 0 {
|
||||
p.Error("invalid snap i-frame interval",
|
||||
"interval", p.IFrameInterval,
|
||||
"valid_range", ">=0",
|
||||
)
|
||||
return fmt.Errorf("invalid snap i-frame interval: %d, valid range is >=0", p.IFrameInterval)
|
||||
}
|
||||
|
||||
// 初始化数据库
|
||||
if p.DB != nil {
|
||||
err = p.DB.AutoMigrate(&snap.SnapRecord{})
|
||||
if err != nil {
|
||||
p.Error("failed to migrate database", "error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 创建保存目录
|
||||
if err = os.MkdirAll(p.SavePath, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 编译正则表达式
|
||||
if p.filterRegex, err = regexp.Compile(p.Filter); err != nil {
|
||||
p.Error("invalid filter regex", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化全局水印配置
|
||||
snap.GlobalWatermarkConfig = snap.WatermarkConfig{
|
||||
Text: p.Watermark.Text,
|
||||
FontPath: p.Watermark.FontPath,
|
||||
FontSize: p.Watermark.FontSize,
|
||||
FontSpacing: p.Watermark.FontSpacing,
|
||||
FontColor: color.RGBA{}, // 将在下面解析
|
||||
OffsetX: p.Watermark.OffsetX,
|
||||
OffsetY: p.Watermark.OffsetY,
|
||||
}
|
||||
|
||||
if p.Watermark.Text != "" {
|
||||
// 判断字体是否存在
|
||||
if _, err := os.Stat(p.Watermark.FontPath); os.IsNotExist(err) {
|
||||
p.Error("watermark font file not found", "path", p.Watermark.FontPath)
|
||||
return fmt.Errorf("watermark font file not found: %w", err)
|
||||
}
|
||||
// 解析颜色
|
||||
if p.Watermark.FontColor != "" {
|
||||
rgba := p.Watermark.FontColor
|
||||
rgba = strings.TrimPrefix(rgba, "rgba(")
|
||||
rgba = strings.TrimSuffix(rgba, ")")
|
||||
parts := strings.Split(rgba, ",")
|
||||
if len(parts) == 4 {
|
||||
fontColor, err := parseRGBA(p.Watermark.FontColor)
|
||||
if err == nil {
|
||||
snap.GlobalWatermarkConfig.FontColor = fontColor
|
||||
} else {
|
||||
p.Error("parse color failed", "error", err.Error())
|
||||
snap.GlobalWatermarkConfig.FontColor = color.RGBA{uint8(255), uint8(255), uint8(255), uint8(255)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载字体
|
||||
if snap.GlobalWatermarkConfig.Text != "" && snap.GlobalWatermarkConfig.FontPath != "" {
|
||||
if err := snap.GlobalWatermarkConfig.LoadFont(); err != nil {
|
||||
p.Error("load watermark font failed",
|
||||
"error", err.Error(),
|
||||
"path", snap.GlobalWatermarkConfig.FontPath,
|
||||
)
|
||||
return fmt.Errorf("load watermark font failed: %w", err)
|
||||
}
|
||||
p.Info("watermark config loaded",
|
||||
"text", snap.GlobalWatermarkConfig.Text,
|
||||
"font", snap.GlobalWatermarkConfig.FontPath,
|
||||
"size", snap.GlobalWatermarkConfig.FontSize,
|
||||
)
|
||||
}
|
||||
|
||||
//如果截图模式不是时间模式,则不加定时任务
|
||||
if p.Mode != snap.SnapModeTimeInterval {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果间隔时间小于0,则不添加定时任务;等于0则走onpub的transform
|
||||
if p.TimeInterval <= 0 {
|
||||
return
|
||||
}
|
||||
// 添加定时任务
|
||||
p.AddTask(&SnapTimerTask{
|
||||
Interval: p.TimeInterval,
|
||||
SavePath: p.SavePath,
|
||||
Plugin: p,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ package snap
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/config"
|
||||
|
||||
m7s "m7s.live/v5"
|
||||
"m7s.live/v5/pkg/task"
|
||||
@@ -18,16 +23,53 @@ const (
|
||||
SnapModeManual
|
||||
)
|
||||
|
||||
// parseRGBA 解析rgba格式的颜色字符串
|
||||
func parseRGBA(rgbaStr string) (color.RGBA, error) {
|
||||
rgba := strings.TrimPrefix(rgbaStr, "rgba(")
|
||||
rgba = strings.TrimSuffix(rgba, ")")
|
||||
parts := strings.Split(rgba, ",")
|
||||
if len(parts) != 4 {
|
||||
return color.RGBA{}, fmt.Errorf("invalid rgba format")
|
||||
}
|
||||
|
||||
r, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return color.RGBA{}, err
|
||||
}
|
||||
|
||||
g, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return color.RGBA{}, err
|
||||
}
|
||||
|
||||
b, err := strconv.Atoi(strings.TrimSpace(parts[2]))
|
||||
if err != nil {
|
||||
return color.RGBA{}, err
|
||||
}
|
||||
|
||||
a, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
|
||||
if err != nil {
|
||||
return color.RGBA{}, err
|
||||
}
|
||||
|
||||
return color.RGBA{
|
||||
R: uint8(r),
|
||||
G: uint8(g),
|
||||
B: uint8(b),
|
||||
A: uint8(a * 255),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 保存截图到文件
|
||||
func saveSnapshot(annexb []*pkg.AnnexB, savePath string, plugin *m7s.Plugin, streamPath string, snapMode int) error {
|
||||
func saveSnapshot(annexb []*pkg.AnnexB, savePath string, plugin *m7s.Plugin, streamPath string, snapMode int, watermarkConfig *WatermarkConfig) error {
|
||||
var buf bytes.Buffer
|
||||
if err := ProcessWithFFmpeg(annexb, &buf); err != nil {
|
||||
return fmt.Errorf("process with ffmpeg error: %w", err)
|
||||
}
|
||||
|
||||
// 如果配置了水印,添加水印
|
||||
if GlobalWatermarkConfig.Text != "" {
|
||||
imgData, err := AddWatermark(buf.Bytes(), GlobalWatermarkConfig)
|
||||
if watermarkConfig != nil && watermarkConfig.Text != "" {
|
||||
imgData, err := AddWatermark(buf.Bytes(), *watermarkConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add watermark error: %w", err)
|
||||
}
|
||||
@@ -58,11 +100,130 @@ func saveSnapshot(annexb []*pkg.AnnexB, savePath string, plugin *m7s.Plugin, str
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ task.TaskGo = (*Transformer)(nil)
|
||||
// SnapConfig 截图配置
|
||||
type SnapConfig struct {
|
||||
TimeInterval time.Duration `json:"timeInterval" desc:"截图时间间隔,大于0时使用时间间隔模式"`
|
||||
IFrameInterval int `json:"iFrameInterval" desc:"间隔多少帧截图,大于0时使用关键帧间隔模式"`
|
||||
SavePath string `json:"savePath" desc:"截图保存路径"`
|
||||
Watermark struct {
|
||||
Text string `json:"text" default:"" desc:"水印文字内容"`
|
||||
FontPath string `json:"fontPath" default:"" desc:"水印字体文件路径"`
|
||||
FontColor string `json:"fontColor" default:"rgba(255,165,0,1)" desc:"水印字体颜色,支持rgba格式"`
|
||||
FontSize float64 `json:"fontSize" default:"36" desc:"水印字体大小"`
|
||||
FontSpacing float64 `json:"fontSpacing" default:"2" desc:"水印字体间距"`
|
||||
OffsetX int `json:"offsetX" default:"0" desc:"水印位置X"`
|
||||
OffsetY int `json:"offsetY" default:"0" desc:"水印位置Y"`
|
||||
} `json:"watermark" desc:"水印配置"`
|
||||
}
|
||||
|
||||
func NewTransform() m7s.ITransformer {
|
||||
ret := &Transformer{}
|
||||
return ret
|
||||
// SnapTask 基础截图任务结构
|
||||
type SnapTask struct {
|
||||
config SnapConfig
|
||||
job *m7s.TransformJob
|
||||
watermarkConfig *WatermarkConfig
|
||||
}
|
||||
|
||||
// saveSnap 保存截图
|
||||
func (t *SnapTask) saveSnap(annexb []*pkg.AnnexB, snapMode int) error {
|
||||
// 生成文件名
|
||||
now := time.Now()
|
||||
filename := fmt.Sprintf("%s_%s.jpg", t.job.StreamPath, now.Format("20060102150405.000"))
|
||||
filename = strings.ReplaceAll(filename, "/", "_")
|
||||
savePath := filepath.Join(t.config.SavePath, filename)
|
||||
|
||||
// 处理视频帧
|
||||
var buf bytes.Buffer
|
||||
if err := ProcessWithFFmpeg(annexb, &buf); err != nil {
|
||||
return fmt.Errorf("process with ffmpeg error: %w", err)
|
||||
}
|
||||
|
||||
// 如果配置了水印,添加水印
|
||||
if t.watermarkConfig != nil && t.watermarkConfig.Text != "" {
|
||||
imgData, err := AddWatermark(buf.Bytes(), *t.watermarkConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add watermark error: %w", err)
|
||||
}
|
||||
err = os.WriteFile(savePath, imgData, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err := os.WriteFile(savePath, buf.Bytes(), 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 保存记录到数据库
|
||||
if t.job.Plugin != nil && t.job.Plugin.DB != nil {
|
||||
record := SnapRecord{
|
||||
StreamName: t.job.StreamPath,
|
||||
SnapMode: snapMode,
|
||||
SnapTime: time.Now(),
|
||||
SnapPath: savePath,
|
||||
}
|
||||
if err := t.job.Plugin.DB.Create(&record).Error; err != nil {
|
||||
return fmt.Errorf("save snapshot record failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimeSnapTask 定时截图任务
|
||||
type TimeSnapTask struct {
|
||||
task.TickTask
|
||||
SnapTask
|
||||
}
|
||||
|
||||
func (t *TimeSnapTask) GetTickInterval() time.Duration {
|
||||
return t.config.TimeInterval
|
||||
}
|
||||
|
||||
// Tick 执行定时截图操作
|
||||
func (t *TimeSnapTask) Tick(any) {
|
||||
// 获取视频帧
|
||||
annexb, _, err := GetVideoFrame(t.job.OriginPublisher, t.job.Plugin.Server)
|
||||
if err != nil {
|
||||
t.Error("get video frame failed", "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := t.saveSnap(annexb, SnapModeTimeInterval); err != nil {
|
||||
t.Error("save snapshot failed", "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// IFrameSnapTask 关键帧截图任务
|
||||
type IFrameSnapTask struct {
|
||||
task.Task
|
||||
SnapTask
|
||||
subscriber *m7s.Subscriber
|
||||
}
|
||||
|
||||
func (t *IFrameSnapTask) Start() (err error) {
|
||||
subConfig := t.job.Plugin.GetCommonConf().Subscribe
|
||||
subConfig.SubType = m7s.SubscribeTypeTransform
|
||||
subConfig.IFrameOnly = true
|
||||
t.subscriber, err = t.job.Plugin.SubscribeWithConfig(t, t.job.StreamPath, subConfig)
|
||||
return
|
||||
}
|
||||
|
||||
func (t *IFrameSnapTask) Go() (err error) {
|
||||
iframeCount := 0
|
||||
err = m7s.PlayBlock(t.subscriber, (func(audio *pkg.RawAudio) error)(nil), func(video *pkg.AnnexB) error {
|
||||
iframeCount++
|
||||
if iframeCount%t.config.IFrameInterval == 0 {
|
||||
if err := t.saveSnap([]*pkg.AnnexB{video}, SnapModeIFrameInterval); err != nil {
|
||||
t.Error("save snapshot failed", "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Error("iframe interval snap error", "error", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type Transformer struct {
|
||||
@@ -74,13 +235,91 @@ func (r *Transformer) GetTransformJob() *m7s.TransformJob {
|
||||
return &r.TransformJob
|
||||
}
|
||||
|
||||
func (t *Transformer) Start() (err error) {
|
||||
|
||||
func NewTransform() m7s.ITransformer {
|
||||
return &Transformer{}
|
||||
}
|
||||
|
||||
func (t *Transformer) Go() error {
|
||||
func (t *Transformer) Start() (err error) {
|
||||
// 为每个输出配置创建一个截图任务
|
||||
for _, output := range t.TransformJob.Config.Output {
|
||||
var task task.ITask
|
||||
var snapConfig SnapConfig
|
||||
if output.Conf != nil {
|
||||
switch v := output.Conf.(type) {
|
||||
case SnapConfig:
|
||||
snapConfig = v
|
||||
case map[string]any:
|
||||
var conf config.Config
|
||||
conf.Parse(&snapConfig)
|
||||
conf.ParseModifyFile(v)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化水印配置
|
||||
var watermarkConfig *WatermarkConfig
|
||||
if snapConfig.Watermark.Text != "" {
|
||||
watermarkConfig = &WatermarkConfig{
|
||||
Text: snapConfig.Watermark.Text,
|
||||
FontPath: snapConfig.Watermark.FontPath,
|
||||
FontSize: snapConfig.Watermark.FontSize,
|
||||
FontSpacing: snapConfig.Watermark.FontSpacing,
|
||||
OffsetX: snapConfig.Watermark.OffsetX,
|
||||
OffsetY: snapConfig.Watermark.OffsetY,
|
||||
}
|
||||
|
||||
// 判断字体是否存在
|
||||
if _, err := os.Stat(watermarkConfig.FontPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("watermark font file not found: %w", err)
|
||||
}
|
||||
// 解析颜色
|
||||
if snapConfig.Watermark.FontColor != "" {
|
||||
fontColor, err := parseRGBA(snapConfig.Watermark.FontColor)
|
||||
if err == nil {
|
||||
watermarkConfig.FontColor = fontColor
|
||||
} else {
|
||||
t.Error("parse color failed", "error", err.Error())
|
||||
watermarkConfig.FontColor = color.RGBA{uint8(255), uint8(255), uint8(255), uint8(255)}
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载字体
|
||||
if err := watermarkConfig.LoadFont(); err != nil {
|
||||
return fmt.Errorf("load watermark font failed: %w", err)
|
||||
}
|
||||
t.Info("watermark config loaded",
|
||||
"text", watermarkConfig.Text,
|
||||
"font", watermarkConfig.FontPath,
|
||||
"size", watermarkConfig.FontSize,
|
||||
)
|
||||
}
|
||||
// 创建保存目录
|
||||
if err := os.MkdirAll(snapConfig.SavePath, 0755); err != nil {
|
||||
return fmt.Errorf("create save directory failed: %w", err)
|
||||
}
|
||||
// 根据配置创建对应的任务
|
||||
if snapConfig.TimeInterval > 0 {
|
||||
timeTask := &TimeSnapTask{
|
||||
SnapTask: SnapTask{
|
||||
config: snapConfig,
|
||||
job: &t.TransformJob,
|
||||
watermarkConfig: watermarkConfig,
|
||||
},
|
||||
}
|
||||
task = timeTask
|
||||
} else if snapConfig.IFrameInterval > 0 {
|
||||
iframeTask := &IFrameSnapTask{
|
||||
SnapTask: SnapTask{
|
||||
config: snapConfig,
|
||||
job: &t.TransformJob,
|
||||
watermarkConfig: watermarkConfig,
|
||||
},
|
||||
}
|
||||
task = iframeTask
|
||||
}
|
||||
|
||||
if task != nil {
|
||||
t.AddTask(task)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Transformer) Dispose() {
|
||||
}
|
||||
|
||||
@@ -10,30 +10,25 @@ import (
|
||||
)
|
||||
|
||||
// GetVideoFrame 获取视频帧数据
|
||||
func GetVideoFrame(streamPath string, server *m7s.Server) ([]*pkg.AnnexB, *pkg.AVTrack, error) {
|
||||
// 获取发布者
|
||||
publisher, err := server.GetPublisher(streamPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
func GetVideoFrame(publisher *m7s.Publisher, server *m7s.Server) ([]*pkg.AnnexB, *pkg.AVTrack, error) {
|
||||
if publisher.VideoTrack.AVTrack == nil {
|
||||
return nil, nil, pkg.ErrNotFound
|
||||
}
|
||||
|
||||
// 等待视频就绪
|
||||
if err = publisher.VideoTrack.WaitReady(); err != nil {
|
||||
if err := publisher.VideoTrack.WaitReady(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 创建读取器并等待 I 帧
|
||||
reader := pkg.NewAVRingReader(publisher.VideoTrack.AVTrack, "snapshot")
|
||||
if err = reader.StartRead(publisher.VideoTrack.GetIDR()); err != nil {
|
||||
if err := reader.StartRead(publisher.VideoTrack.GetIDR()); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer reader.StopRead()
|
||||
var track pkg.AVTrack
|
||||
var annexb pkg.AnnexB
|
||||
var err error
|
||||
track.ICodecCtx, track.SequenceFrame, err = annexb.ConvertCtx(publisher.VideoTrack.ICodecCtx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -15,9 +15,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
fontCache = make(map[string]*truetype.Font)
|
||||
fontCacheLock sync.RWMutex
|
||||
GlobalWatermarkConfig WatermarkConfig
|
||||
fontCache = make(map[string]*truetype.Font)
|
||||
fontCacheLock sync.RWMutex
|
||||
)
|
||||
|
||||
// WatermarkConfig 水印配置
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package plugin_snap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
snap_pkg "m7s.live/v5/plugin/snap/pkg"
|
||||
|
||||
"m7s.live/v5/pkg/task"
|
||||
)
|
||||
|
||||
// SnapTimerTask 定时截图任务结构体
|
||||
type SnapTimerTask struct {
|
||||
task.TickTask
|
||||
Interval time.Duration // 截图时间间隔
|
||||
SavePath string // 截图保存路径
|
||||
Plugin *SnapPlugin // 插件实例引用
|
||||
}
|
||||
|
||||
// GetTickInterval 设置定时间隔
|
||||
func (t *SnapTimerTask) GetTickInterval() time.Duration {
|
||||
return t.Interval // 使用配置的间隔时间
|
||||
}
|
||||
|
||||
// Tick 执行定时截图
|
||||
func (t *SnapTimerTask) Tick(any) {
|
||||
for publisher := range t.Plugin.Server.Streams.Range {
|
||||
// 检查流是否匹配过滤器
|
||||
if !t.Plugin.filterRegex.MatchString(publisher.StreamPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
if publisher.HasVideoTrack() {
|
||||
streamPath := publisher.StreamPath
|
||||
go func() {
|
||||
buf, err := t.Plugin.snap(streamPath)
|
||||
if err != nil {
|
||||
t.Error("take snapshot failed", "error", err.Error())
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
filename := fmt.Sprintf("%s_%s.jpg", streamPath, now.Format("20060102150405.000"))
|
||||
filename = strings.ReplaceAll(filename, "/", "_")
|
||||
savePath := filepath.Join(t.SavePath, filename)
|
||||
// 保存到本地
|
||||
err = os.WriteFile(savePath, buf.Bytes(), 0644)
|
||||
if err != nil {
|
||||
t.Error("take snapshot failed", "error", err.Error())
|
||||
return
|
||||
}
|
||||
t.Info("take snapshot success", "path", savePath)
|
||||
|
||||
// 保存记录到数据库
|
||||
if t.Plugin.DB != nil {
|
||||
record := snap_pkg.SnapRecord{
|
||||
StreamName: streamPath,
|
||||
SnapMode: t.Plugin.Mode,
|
||||
SnapTime: now,
|
||||
SnapPath: savePath,
|
||||
}
|
||||
if err := t.Plugin.DB.Create(&record).Error; err != nil {
|
||||
t.Error("save snapshot record failed",
|
||||
"error", err.Error(),
|
||||
"record", record,
|
||||
)
|
||||
} else {
|
||||
t.Info("save snapshot record success",
|
||||
"stream", streamPath,
|
||||
"path", savePath,
|
||||
"time", now,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
t.Warn("database not initialized, skip saving record")
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,9 +409,10 @@ func (s *Server) Start() (err error) {
|
||||
}
|
||||
}
|
||||
if plugin.Meta.Transformer != nil {
|
||||
for streamPath, conf := range plugin.config.Transform {
|
||||
transformer := plugin.Meta.Transformer()
|
||||
transformer.GetTransformJob().Init(transformer, plugin, streamPath, conf)
|
||||
for streamPath, _ := range plugin.config.Transform {
|
||||
plugin.OnSubscribe(streamPath, url.Values{}) //按需转换
|
||||
// transformer := plugin.Meta.Transformer()
|
||||
// transformer.GetTransformJob().Init(transformer, plugin, streamPath, conf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ type (
|
||||
Transformer = func() ITransformer
|
||||
TransformJob struct {
|
||||
task.Job
|
||||
StreamPath string // 对应本地流
|
||||
Config config.Transform // 对应目标流
|
||||
Plugin *Plugin
|
||||
Publisher *Publisher
|
||||
Subscriber *Subscriber
|
||||
Transformer ITransformer
|
||||
StreamPath string // 对应本地流
|
||||
Config config.Transform // 对应目标流
|
||||
Plugin *Plugin
|
||||
OriginPublisher *Publisher
|
||||
Publisher *Publisher
|
||||
Subscriber *Subscriber
|
||||
Transformer ITransformer
|
||||
}
|
||||
DefaultTransformer struct {
|
||||
task.Task
|
||||
@@ -93,17 +94,18 @@ func (p *TransformJob) Publish(streamPath string) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (p *TransformJob) Init(transformer ITransformer, plugin *Plugin, streamPath string, conf config.Transform) *TransformJob {
|
||||
func (p *TransformJob) Init(transformer ITransformer, plugin *Plugin, pub *Publisher, conf config.Transform) *TransformJob {
|
||||
p.Plugin = plugin
|
||||
p.Config = conf
|
||||
p.StreamPath = streamPath
|
||||
p.StreamPath = pub.StreamPath
|
||||
p.OriginPublisher = pub
|
||||
p.Transformer = transformer
|
||||
p.SetDescriptions(task.Description{
|
||||
"streamPath": streamPath,
|
||||
"streamPath": pub.StreamPath,
|
||||
"conf": conf,
|
||||
})
|
||||
transformer.SetRetry(-1, time.Second*2)
|
||||
plugin.Server.Transforms.AddTask(p, plugin.Logger.With("streamPath", streamPath))
|
||||
plugin.Server.Transforms.AddTask(p, plugin.Logger.With("streamPath", pub.StreamPath))
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -128,10 +130,6 @@ func (p *TransformJob) Start() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
//func (p *TransformJob) TransformPublished(pub *Publisher) {
|
||||
//
|
||||
//}
|
||||
|
||||
func (p *TransformJob) Dispose() {
|
||||
transList := &p.Plugin.Server.Transforms
|
||||
p.Info("transform -1", "count", transList.Length)
|
||||
|
||||
Reference in New Issue
Block a user