diff --git a/example/8080/snap.yaml b/example/8080/snap.yaml index 2dc2c87..1ce14a9 100644 --- a/example/8080/snap.yaml +++ b/example/8080/snap.yaml @@ -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 # 查询截图时允许的最大时间差(秒) \ No newline at end of file + 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 # 查询截图时允许的最大时间差(秒) diff --git a/pkg/config/types.go b/pkg/config/types.go index 59264ff..fbc8e68 100755 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -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) +} diff --git a/plugin.go b/plugin.go index 72d5f19..38d3533 100644 --- a/plugin.go +++ b/plugin.go @@ -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) } diff --git a/plugin/sei/api.go b/plugin/sei/api.go index 6128239..04a5f09 100644 --- a/plugin/sei/api.go +++ b/plugin/sei/api.go @@ -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 } diff --git a/plugin/snap/README.md b/plugin/snap/README.md index 1120d16..38eeb82 100644 --- a/plugin/snap/README.md +++ b/plugin/snap/README.md @@ -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 diff --git a/plugin/snap/api.go b/plugin/snap/api.go index 323b0d0..fc4240c 100755 --- a/plugin/snap/api.go +++ b/plugin/snap/api.go @@ -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, } } diff --git a/plugin/snap/index.go b/plugin/snap/index.go index b4646c4..b307c9f 100755 --- a/plugin/snap/index.go +++ b/plugin/snap/index.go @@ -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 } diff --git a/plugin/snap/pkg/transform.go b/plugin/snap/pkg/transform.go index 674c878..d3bbc67 100644 --- a/plugin/snap/pkg/transform.go +++ b/plugin/snap/pkg/transform.go @@ -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() { -} diff --git a/plugin/snap/pkg/util.go b/plugin/snap/pkg/util.go index e0ab6a3..d912bef 100644 --- a/plugin/snap/pkg/util.go +++ b/plugin/snap/pkg/util.go @@ -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 diff --git a/plugin/snap/pkg/watermark.go b/plugin/snap/pkg/watermark.go index be0f254..232b264 100644 --- a/plugin/snap/pkg/watermark.go +++ b/plugin/snap/pkg/watermark.go @@ -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 水印配置 diff --git a/plugin/snap/tick.go b/plugin/snap/tick.go deleted file mode 100644 index 4e6d528..0000000 --- a/plugin/snap/tick.go +++ /dev/null @@ -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") - } - }() - } - } -} diff --git a/server.go b/server.go index 3e95edc..22d2256 100644 --- a/server.go +++ b/server.go @@ -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) } } } diff --git a/transformer.go b/transformer.go index 5c47204..c0a2703 100644 --- a/transformer.go +++ b/transformer.go @@ -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)