refactor: snap plugin

This commit is contained in:
langhuihui
2025-04-03 17:21:36 +08:00
committed by dexter
parent 4e46ecc8cd
commit 5f77f2f5f9
13 changed files with 434 additions and 342 deletions

View File

@@ -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 # 查询截图时允许的最大时间差(秒)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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() {
}

View File

@@ -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

View File

@@ -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 水印配置

View File

@@ -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")
}
}()
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)