mirror of
https://github.com/langhuihui/monibuca.git
synced 2025-09-26 23:05:55 +08:00
feat: add snap image
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
_ "m7s.live/v5/plugin/rtmp"
|
||||
_ "m7s.live/v5/plugin/rtsp"
|
||||
_ "m7s.live/v5/plugin/sei"
|
||||
_ "m7s.live/v5/plugin/snap"
|
||||
_ "m7s.live/v5/plugin/srt"
|
||||
_ "m7s.live/v5/plugin/stress"
|
||||
_ "m7s.live/v5/plugin/transcode"
|
||||
|
9
go.mod
9
go.mod
@@ -11,9 +11,11 @@ require (
|
||||
github.com/cilium/ebpf v0.15.0
|
||||
github.com/cloudwego/goref v0.0.0-20240724113447-685d2a9523c8
|
||||
github.com/deepch/vdk v0.0.27
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emiago/sipgo v0.22.0
|
||||
github.com/go-delve/delve v1.23.0
|
||||
github.com/gobwas/ws v1.3.2
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1
|
||||
github.com/husanpao/ip v0.0.0-20220711082147-73160bb611a8
|
||||
@@ -34,7 +36,8 @@ require (
|
||||
github.com/shirou/gopsutil/v4 v4.24.8
|
||||
github.com/vishvananda/netlink v1.1.0
|
||||
github.com/yapingcat/gomedia v0.0.0-20240601043430-920523f8e5c7
|
||||
golang.org/x/text v0.17.0
|
||||
golang.org/x/image v0.22.0
|
||||
golang.org/x/text v0.20.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d
|
||||
google.golang.org/grpc v1.65.0
|
||||
google.golang.org/protobuf v1.34.2
|
||||
@@ -114,7 +117,7 @@ require (
|
||||
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/arch v0.6.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sync v0.9.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect
|
||||
)
|
||||
|
||||
@@ -132,7 +135,7 @@ require (
|
||||
github.com/quangngotan95/go-m3u8 v0.1.0
|
||||
go.uber.org/mock v0.4.0 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 // indirect
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7
|
||||
golang.org/x/mod v0.19.0 // indirect
|
||||
golang.org/x/net v0.27.0
|
||||
golang.org/x/sys v0.25.0
|
||||
|
15
go.sum
15
go.sum
@@ -59,6 +59,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deepch/vdk v0.0.27 h1:j/SHaTiZhA47wRpaue8NRp7P9xwOOO/lunxrDJBwcao=
|
||||
github.com/deepch/vdk v0.0.27/go.mod h1:JlgGyR2ld6+xOIHa7XAxJh+stSDBAkdNvIPkUIdIywk=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/emiago/sipgo v0.22.0 h1:GaQ51m26M9QnVBVY2aDJ/mXqq/BDfZ1A+nW7XgU/4Ts=
|
||||
github.com/emiago/sipgo v0.22.0/go.mod h1:a77FgPEEjJvfYWYfP3p53u+dNhWEMb/VGVS6guvBzx0=
|
||||
github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
|
||||
@@ -86,6 +88,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
|
||||
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
@@ -365,6 +369,9 @@ golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7 h1:wDLEX9a7YQoKdKNQt88rtydkqDxeGaBUTnIYc3iG/mA=
|
||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
|
||||
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -400,8 +407,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -468,8 +475,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
84
plugin/snap/api.go
Executable file
84
plugin/snap/api.go
Executable file
@@ -0,0 +1,84 @@
|
||||
package plugin_snap
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/config"
|
||||
snap "m7s.live/v5/plugin/snap/pkg"
|
||||
)
|
||||
|
||||
func (t *SnapPlugin) doSnap(rw http.ResponseWriter, r *http.Request) {
|
||||
streamPath := r.PathValue("streamPath")
|
||||
targetStreamPath := streamPath
|
||||
|
||||
ok := t.Server.Streams.Has(streamPath)
|
||||
if !ok {
|
||||
http.Error(rw, pkg.ErrNotFound.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
flusher, ok := rw.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(rw, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "image/jpeg")
|
||||
rw.Header().Set("Transfer-Encoding", "chunked")
|
||||
rw.Header().Del("Content-Length")
|
||||
|
||||
done := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
defer pr.Close()
|
||||
defer close(done)
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
_, err := io.CopyBuffer(rw, pr, buf)
|
||||
if err != nil {
|
||||
t.Error("write response error", err)
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
var transformer *snap.Transformer
|
||||
if tm, ok := t.Server.Transforms.Get(targetStreamPath); ok {
|
||||
transformer, ok = tm.TransformJob.Transformer.(*snap.Transformer)
|
||||
if !ok {
|
||||
http.Error(rw, "not a snap transformer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
transformer = snap.NewTransform().(*snap.Transformer)
|
||||
transformer.TransformJob.Init(transformer, &t.Plugin, streamPath, config.Transform{
|
||||
Output: []config.TransfromOutput{
|
||||
{
|
||||
Target: targetStreamPath,
|
||||
StreamPath: targetStreamPath,
|
||||
Conf: pw,
|
||||
},
|
||||
},
|
||||
}).WaitStarted()
|
||||
}
|
||||
|
||||
transformer.TriggerSnap()
|
||||
|
||||
if err := <-done; err != nil {
|
||||
t.Error("snapshot failed", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (config *SnapPlugin) RegisterHandler() map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"/{streamPath...}": config.doSnap,
|
||||
}
|
||||
}
|
13
plugin/snap/index.go
Executable file
13
plugin/snap/index.go
Executable file
@@ -0,0 +1,13 @@
|
||||
package plugin_snap
|
||||
|
||||
import (
|
||||
m7s "m7s.live/v5"
|
||||
)
|
||||
|
||||
var _ = m7s.InstallPlugin[SnapPlugin]()
|
||||
|
||||
type SnapPlugin struct {
|
||||
//pb.UnimplementedApiServer
|
||||
m7s.Plugin
|
||||
LogToFile string
|
||||
}
|
153
plugin/snap/pkg/transform.go
Normal file
153
plugin/snap/pkg/transform.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package snap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"m7s.live/v5/pkg"
|
||||
"m7s.live/v5/pkg/filerotate"
|
||||
|
||||
m7s "m7s.live/v5"
|
||||
"m7s.live/v5/pkg/task"
|
||||
)
|
||||
|
||||
// 定义传输模式的常量
|
||||
const (
|
||||
SNAP_MODE_PIPE SnapMode = "pipe"
|
||||
SNAP_MODE_REMOTE SnapMode = "remote"
|
||||
)
|
||||
|
||||
type (
|
||||
SnapMode string
|
||||
SnapConfig struct {
|
||||
Mode SnapMode `default:"pipe" json:"mode" desc:"截图模式"` //截图模式
|
||||
Remote string `json:"remote" desc:"远程地址"`
|
||||
}
|
||||
SnapRule struct {
|
||||
From SnapConfig `json:"from"`
|
||||
LogToFile string `json:"logtofile" desc:"截图是否写入日志"` //截图日志写入文件
|
||||
}
|
||||
|
||||
Config struct {
|
||||
Input interface{} `json:"input"`
|
||||
Output []Output `json:"output"`
|
||||
}
|
||||
|
||||
Output struct {
|
||||
Target string `json:"target"`
|
||||
Conf interface{} `json:"conf"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewTransform() m7s.ITransformer {
|
||||
ret := &Transformer{
|
||||
snapChan: make(chan struct{}, 1),
|
||||
}
|
||||
ret.SetDescription(task.OwnerTypeKey, "Snap")
|
||||
return ret
|
||||
}
|
||||
|
||||
type Transformer struct {
|
||||
m7s.DefaultTransformer
|
||||
SnapRule
|
||||
logFile *filerotate.File
|
||||
ffmpeg *exec.Cmd
|
||||
snapChan chan struct{}
|
||||
}
|
||||
|
||||
func (t *Transformer) TriggerSnap() {
|
||||
select {
|
||||
case t.snapChan <- struct{}{}:
|
||||
default:
|
||||
// 如果通道已满,移除旧的请求
|
||||
<-t.snapChan
|
||||
t.snapChan <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transformer) Run() (err error) {
|
||||
|
||||
// 等待截图触发信号
|
||||
<-t.snapChan
|
||||
s := t.GetTransformJob().Plugin.Server
|
||||
publisher, ok := s.Streams.Get(t.TransformJob.StreamPath)
|
||||
if !ok || publisher.VideoTrack.AVTrack == nil {
|
||||
return pkg.ErrNotFound
|
||||
}
|
||||
|
||||
err = publisher.VideoTrack.WaitReady()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader := pkg.NewAVRingReader(publisher.VideoTrack.AVTrack, "Origin")
|
||||
err = reader.StartRead(publisher.VideoTrack.GetIDR())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.StopRead()
|
||||
|
||||
if reader.Value.Raw == nil {
|
||||
if err = reader.Value.Demux(publisher.VideoTrack.ICodecCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var annexb pkg.AnnexB
|
||||
var track pkg.AVTrack
|
||||
|
||||
track.ICodecCtx, track.SequenceFrame, err = annexb.ConvertCtx(publisher.VideoTrack.ICodecCtx)
|
||||
if track.ICodecCtx == nil {
|
||||
return fmt.Errorf("unsupported codec")
|
||||
}
|
||||
annexb.Mux(track.ICodecCtx, &reader.Value)
|
||||
|
||||
// 创建ffmpeg命令
|
||||
cmd := exec.Command("ffmpeg", "-hide_banner", "-i", "pipe:0", "-vframes", "1", "-f", "mjpeg", "pipe:1")
|
||||
|
||||
// 获取输入和输出pipe
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 启动ffmpeg进程
|
||||
if err = cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 将annexb数据写入到ffmpeg的stdin
|
||||
_, err = annexb.WriteTo(stdin)
|
||||
stdin.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 从ffmpeg的stdout读取图片数据并写入到输出配置中
|
||||
_, err = io.Copy(t.TransformJob.Config.Output[0].Conf.(io.Writer), stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 等待ffmpeg进程结束
|
||||
if err = cmd.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Transformer) Dispose() {
|
||||
close(t.snapChan)
|
||||
if t.ffmpeg != nil {
|
||||
err := t.ffmpeg.Process.Kill()
|
||||
t.Error("kill ffmpeg", "err", err)
|
||||
}
|
||||
if t.logFile != nil {
|
||||
_ = t.logFile.Close()
|
||||
}
|
||||
}
|
334
plugin/snap/pkg/watermark/watermark.go
Executable file
334
plugin/snap/pkg/watermark/watermark.go
Executable file
@@ -0,0 +1,334 @@
|
||||
package watermark
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"math"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
// TextConfig 文字配置结构体
|
||||
type TextConfig struct {
|
||||
Text string // 水印文字
|
||||
Font *truetype.Font // 字体
|
||||
FontSize float64 // 字体大小
|
||||
Spacing float64 // 字符间距
|
||||
RowSpacing float64 // 行间距
|
||||
ColSpacing float64 // 列间距
|
||||
Rows int // 行数
|
||||
Cols int // 列数
|
||||
DPI float64 // 分辨率
|
||||
Color color.RGBA // 文字颜色
|
||||
Angle float64 // 旋转角度
|
||||
IsGrid bool
|
||||
OffsetX int
|
||||
OffsetY int
|
||||
}
|
||||
|
||||
// CalculateTextDimensions 计算文字尺寸的公共函数
|
||||
func CalculateTextDimensions(config TextConfig, face font.Face) (width, height float64) {
|
||||
// 计算文字宽度
|
||||
var textWidth float64
|
||||
prevC := rune(-1)
|
||||
for i, c := range config.Text {
|
||||
if prevC >= 0 {
|
||||
advance := face.Kern(prevC, c)
|
||||
textWidth += float64(advance) / 64
|
||||
}
|
||||
advance, _ := face.GlyphAdvance(c)
|
||||
textWidth += float64(advance) / 64
|
||||
if i < len(config.Text)-1 {
|
||||
textWidth += config.Spacing
|
||||
}
|
||||
prevC = c
|
||||
}
|
||||
|
||||
// 计算文字高度
|
||||
metrics := face.Metrics()
|
||||
textHeight := float64(metrics.Height) / 64
|
||||
|
||||
return textWidth, textHeight
|
||||
}
|
||||
|
||||
func DrawWatermark(baseImg image.Image, config TextConfig, isDebug bool) (image.Image, error) {
|
||||
switch {
|
||||
case config.IsGrid:
|
||||
return DrawWatermarkGrid(baseImg, config, isDebug)
|
||||
default:
|
||||
return DrawWatermarkSingle(baseImg, config, isDebug)
|
||||
}
|
||||
}
|
||||
|
||||
func DrawWatermarkSingle(baseImg image.Image, config TextConfig, isDebug bool) (image.Image, error) {
|
||||
bounds := baseImg.Bounds()
|
||||
// width, height := bounds.Dx(), bounds.Dy()
|
||||
|
||||
// 设置字体选项
|
||||
opts := truetype.Options{
|
||||
Size: config.FontSize,
|
||||
DPI: config.DPI,
|
||||
Hinting: font.HintingFull,
|
||||
}
|
||||
face := truetype.NewFace(config.Font, &opts)
|
||||
defer face.Close()
|
||||
|
||||
// 计算文字尺寸
|
||||
textWidth, textHeight := CalculateTextDimensions(config, face)
|
||||
|
||||
// 创建一个与文字大小相同的透明图层
|
||||
textImg := image.NewRGBA(image.Rect(0, 0, int(math.Ceil(textWidth)), int(math.Ceil(textHeight))))
|
||||
|
||||
// 设置字体上下文
|
||||
c := freetype.NewContext()
|
||||
c.SetDPI(config.DPI)
|
||||
c.SetFont(config.Font)
|
||||
c.SetFontSize(config.FontSize)
|
||||
c.SetClip(textImg.Bounds())
|
||||
c.SetDst(textImg)
|
||||
c.SetSrc(image.NewUniform(config.Color))
|
||||
c.SetHinting(font.HintingFull)
|
||||
|
||||
// 绘制文字,从左上角开始
|
||||
pt := freetype.Pt(0, int(textHeight))
|
||||
|
||||
prevC := rune(-1)
|
||||
for i, char := range config.Text {
|
||||
if prevC >= 0 {
|
||||
kern := face.Kern(prevC, char)
|
||||
pt.X += kern
|
||||
}
|
||||
|
||||
_, err := c.DrawString(string(char), pt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
advance, _ := face.GlyphAdvance(char)
|
||||
pt.X += advance
|
||||
if i < len(config.Text)-1 {
|
||||
pt.X += fixed.Int26_6(config.Spacing * 64)
|
||||
}
|
||||
|
||||
prevC = char
|
||||
}
|
||||
|
||||
if isDebug {
|
||||
imaging.Save(textImg, "watermark.png")
|
||||
}
|
||||
|
||||
// 创建最终图像
|
||||
finalImg := image.NewRGBA(bounds)
|
||||
|
||||
// 复制原图
|
||||
draw.Draw(finalImg, bounds, baseImg, image.Point{}, draw.Src)
|
||||
|
||||
// 确保偏移量在图像范围内
|
||||
x := config.OffsetX
|
||||
y := config.OffsetY
|
||||
|
||||
// 如果需要旋转
|
||||
if config.Angle != 0 {
|
||||
// 旋转文字,以文字左上角为圆心
|
||||
rotated := imaging.Rotate(textImg, config.Angle, color.Transparent)
|
||||
if isDebug {
|
||||
imaging.Save(rotated, "rotated_watermark.png")
|
||||
}
|
||||
|
||||
rotatedBounds := rotated.Bounds()
|
||||
|
||||
// 归化角度到-180到180之间
|
||||
normalizedAngle := normalizeAngle(config.Angle)
|
||||
|
||||
// 根据角度范围决定对齐方式
|
||||
var rect image.Rectangle
|
||||
switch {
|
||||
case normalizedAngle > 0 && normalizedAngle <= 90:
|
||||
// 左下角对齐
|
||||
rect = image.Rect(x, y-rotatedBounds.Dy(), x+rotatedBounds.Dx(), y)
|
||||
case normalizedAngle > 90 && normalizedAngle <= 180:
|
||||
// 右下角对齐
|
||||
rect = image.Rect(x-rotatedBounds.Dx(), y-rotatedBounds.Dy(), x, y)
|
||||
case normalizedAngle > -90 && normalizedAngle <= 0:
|
||||
// 左上角对齐
|
||||
rect = image.Rect(x, y, x+rotatedBounds.Dx(), y+rotatedBounds.Dy())
|
||||
default: // -180 到 -90
|
||||
// 右上角对齐
|
||||
rect = image.Rect(x-rotatedBounds.Dx(), y, x, y+rotatedBounds.Dy())
|
||||
}
|
||||
|
||||
// 混合旋转后的文字图层
|
||||
draw.Draw(finalImg,
|
||||
rect,
|
||||
rotated,
|
||||
rotatedBounds.Min,
|
||||
draw.Over)
|
||||
} else {
|
||||
// 不旋转时直接绘制
|
||||
draw.Draw(finalImg,
|
||||
image.Rect(x, y, x+textImg.Bounds().Dx(), y+textImg.Bounds().Dy()),
|
||||
textImg,
|
||||
textImg.Bounds().Min,
|
||||
draw.Over)
|
||||
}
|
||||
|
||||
return finalImg, nil
|
||||
}
|
||||
|
||||
// normalizeAngle 将角度归化到-180到180之间
|
||||
func normalizeAngle(angle float64) float64 {
|
||||
// 先归化到0-360
|
||||
angle = math.Mod(angle, 360)
|
||||
if angle > 180 {
|
||||
angle -= 360
|
||||
} else if angle <= -180 {
|
||||
angle += 360
|
||||
}
|
||||
return angle
|
||||
}
|
||||
|
||||
// DrawWatermark 绘制水印网格并旋转
|
||||
func DrawWatermarkGrid(baseImg image.Image, config TextConfig, isDebug bool) (image.Image, error) {
|
||||
bounds := baseImg.Bounds()
|
||||
width, height := bounds.Dx(), bounds.Dy()
|
||||
|
||||
opts := truetype.Options{
|
||||
Size: config.FontSize,
|
||||
DPI: config.DPI,
|
||||
Hinting: font.HintingFull,
|
||||
}
|
||||
face := truetype.NewFace(config.Font, &opts)
|
||||
defer face.Close()
|
||||
|
||||
// 计算矩形的外接圆半径
|
||||
radius := math.Sqrt(float64(width*width+height*height)) / 2
|
||||
|
||||
// 计算外接圆的外切正方形边长
|
||||
squareSize := int(math.Ceil(radius * 2))
|
||||
|
||||
// 如果行数或列数为0,自动计算
|
||||
if config.Rows == 0 || config.Cols == 0 {
|
||||
textWidth, textHeight := CalculateTextDimensions(config, face)
|
||||
|
||||
// 计算单个水印占用的空间
|
||||
cellWidth := textWidth + config.ColSpacing
|
||||
cellHeight := textHeight + config.RowSpacing
|
||||
|
||||
// 自动计算行数和列数
|
||||
if config.Cols == 0 {
|
||||
config.Cols = int(math.Floor(float64(squareSize) / cellWidth))
|
||||
}
|
||||
if config.Rows == 0 {
|
||||
config.Rows = int(math.Floor(float64(squareSize) / cellHeight))
|
||||
}
|
||||
|
||||
// 确保至少有一行一列
|
||||
if config.Cols < 1 {
|
||||
config.Cols = 1
|
||||
}
|
||||
if config.Rows < 1 {
|
||||
config.Rows = 1
|
||||
}
|
||||
}
|
||||
|
||||
// 创建一个正方形的透明图层
|
||||
textImg := image.NewRGBA(image.Rect(0, 0, squareSize, squareSize))
|
||||
|
||||
// 设置字体上下文
|
||||
c := freetype.NewContext()
|
||||
c.SetDPI(config.DPI)
|
||||
c.SetFont(config.Font)
|
||||
c.SetFontSize(config.FontSize)
|
||||
c.SetClip(textImg.Bounds())
|
||||
c.SetDst(textImg)
|
||||
c.SetSrc(image.NewUniform(config.Color))
|
||||
c.SetHinting(font.HintingFull)
|
||||
|
||||
textWidth, textHeight := CalculateTextDimensions(config, face)
|
||||
|
||||
// 计算网格的总宽度和高度
|
||||
gridWidth := textWidth + config.ColSpacing
|
||||
gridHeight := textHeight + config.RowSpacing
|
||||
|
||||
// 计算起始位置,使整个网格居中
|
||||
startX := (float64(squareSize) - (float64(config.Cols)*gridWidth - config.ColSpacing)) / 2
|
||||
startY := (float64(squareSize) - (float64(config.Rows)*gridHeight - config.RowSpacing)) / 2
|
||||
|
||||
// 绘制文字网格
|
||||
for row := 0; row < config.Rows; row++ {
|
||||
for col := 0; col < config.Cols; col++ {
|
||||
x := startX + float64(col)*gridWidth
|
||||
y := startY + float64(row)*gridHeight
|
||||
|
||||
pt := freetype.Pt(
|
||||
int(x),
|
||||
int(y+textHeight),
|
||||
)
|
||||
|
||||
prevC := rune(-1)
|
||||
for i, char := range config.Text {
|
||||
if prevC >= 0 {
|
||||
kern := face.Kern(prevC, char)
|
||||
pt.X += kern
|
||||
}
|
||||
|
||||
_, err := c.DrawString(string(char), pt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
advance, _ := face.GlyphAdvance(char)
|
||||
pt.X += advance
|
||||
if i < len(config.Text)-1 {
|
||||
pt.X += fixed.Int26_6(config.Spacing * 64)
|
||||
}
|
||||
|
||||
prevC = char
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isDebug {
|
||||
// 保存文字模板
|
||||
imaging.Save(textImg, "watermark.png")
|
||||
}
|
||||
// 旋转整个文字网格
|
||||
rotated := imaging.Rotate(textImg, config.Angle, color.Transparent)
|
||||
if isDebug {
|
||||
// 保存旋转后的文字模板
|
||||
imaging.Save(rotated, "rotated_watermark.png")
|
||||
}
|
||||
|
||||
// 创建最终图像
|
||||
finalImg := image.NewRGBA(bounds)
|
||||
|
||||
// 复制原图
|
||||
draw.Draw(finalImg, bounds, baseImg, image.Point{}, draw.Src)
|
||||
|
||||
// 计算旋转后图片的位置(确保居中)
|
||||
rotatedBounds := rotated.Bounds()
|
||||
x := (width - rotatedBounds.Dx()) / 2
|
||||
y := (height - rotatedBounds.Dy()) / 2
|
||||
if isDebug {
|
||||
fmt.Printf("width: %d, height: %d, x: %d, y: %d\n", width, height, x, y)
|
||||
}
|
||||
// 混合旋转后的文字图层
|
||||
draw.Draw(finalImg,
|
||||
image.Rect(x, y, x+rotatedBounds.Dx(), y+rotatedBounds.Dy()),
|
||||
rotated,
|
||||
rotatedBounds.Min,
|
||||
draw.Over)
|
||||
|
||||
return finalImg, nil
|
||||
}
|
||||
|
||||
// CreateTextColor 创建文字颜色
|
||||
func CreateTextColor(r, g, b uint8, a uint8) *image.Uniform {
|
||||
return image.NewUniform(color.RGBA{r, g, b, a})
|
||||
}
|
205
plugin/snap/pkg/watermark/watermark_test.go
Normal file
205
plugin/snap/pkg/watermark/watermark_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package watermark
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
xfont "golang.org/x/image/font"
|
||||
)
|
||||
|
||||
func loadTestFont(t *testing.T) *truetype.Font {
|
||||
var fontPath string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
fontPath = "C:\\Windows\\Fonts\\simsun.ttc"
|
||||
case "darwin":
|
||||
fontPath = "/System/Library/Fonts/STHeiti Light.ttc"
|
||||
case "linux":
|
||||
fontPath = "/usr/share/fonts/truetype/winfonts/simsun.ttc"
|
||||
default:
|
||||
t.Fatal("不支持的操作系统")
|
||||
}
|
||||
|
||||
fontBytes, err := os.ReadFile(fontPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取字体文件失败: %v", err)
|
||||
}
|
||||
|
||||
font, err := truetype.Parse(fontBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("解析字体失败: %v", err)
|
||||
}
|
||||
|
||||
return font
|
||||
}
|
||||
|
||||
func TestDrawWatermarkSingle(t *testing.T) {
|
||||
// 准备测试图片
|
||||
tmpDir := t.TempDir()
|
||||
testImagePath := filepath.Join(tmpDir, "test_input.png")
|
||||
|
||||
// 创建一个测试图片
|
||||
img := imaging.New(800, 600, color.White)
|
||||
err := imaging.Save(img, testImagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 加载测试图片
|
||||
baseImg, err := imaging.Open(testImagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 加载字体
|
||||
font := loadTestFont(t)
|
||||
|
||||
// 创建水印配置
|
||||
config := TextConfig{
|
||||
Text: "测试水印",
|
||||
Font: font,
|
||||
FontSize: 36,
|
||||
Spacing: 10,
|
||||
RowSpacing: 10,
|
||||
ColSpacing: 20,
|
||||
DPI: 72,
|
||||
Color: color.RGBA{R: 255, G: 0, B: 0, A: 255}, // 红色
|
||||
IsGrid: false,
|
||||
Angle: 45,
|
||||
OffsetX: 100,
|
||||
OffsetY: 100,
|
||||
}
|
||||
|
||||
// 测试单个水印
|
||||
result, err := DrawWatermarkSingle(baseImg, config, true)
|
||||
if err != nil {
|
||||
t.Fatalf("绘制单个水印失败: %v", err)
|
||||
}
|
||||
|
||||
// 保存结果用于目视检查
|
||||
outputPath := filepath.Join(tmpDir, "test_output_single.png")
|
||||
err = imaging.Save(result, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证结果图片存在且大小正确
|
||||
stat, err := os.Stat(outputPath)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, stat.Size() > 0)
|
||||
}
|
||||
|
||||
func TestDrawWatermarkGrid(t *testing.T) {
|
||||
// 准备测试图片
|
||||
tmpDir := t.TempDir()
|
||||
testImagePath := filepath.Join(tmpDir, "test_input.png")
|
||||
|
||||
// 创建一个测试图片
|
||||
img := imaging.New(800, 600, color.White)
|
||||
err := imaging.Save(img, testImagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 加载测试图片
|
||||
baseImg, err := imaging.Open(testImagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("加载测试图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 加载字体
|
||||
font := loadTestFont(t)
|
||||
|
||||
// 创建水印配置
|
||||
config := TextConfig{
|
||||
Text: "测试水印",
|
||||
Font: font,
|
||||
FontSize: 36,
|
||||
Spacing: 10,
|
||||
RowSpacing: 10,
|
||||
ColSpacing: 20,
|
||||
Rows: 3,
|
||||
Cols: 4,
|
||||
DPI: 72,
|
||||
Color: color.RGBA{R: 0, G: 0, B: 255, A: 255}, // 蓝色
|
||||
IsGrid: true,
|
||||
Angle: 30,
|
||||
}
|
||||
|
||||
// 测试网格水印
|
||||
result, err := DrawWatermarkGrid(baseImg, config, true)
|
||||
if err != nil {
|
||||
t.Fatalf("绘制网格水印失败: %v", err)
|
||||
}
|
||||
|
||||
// 保存结果用于目视检查
|
||||
outputPath := filepath.Join(tmpDir, "test_output_grid.png")
|
||||
err = imaging.Save(result, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果图片失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证结果图片存在且大小正确
|
||||
stat, err := os.Stat(outputPath)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, stat.Size() > 0)
|
||||
}
|
||||
|
||||
func TestCalculateTextDimensions(t *testing.T) {
|
||||
// 加载字体
|
||||
font := loadTestFont(t)
|
||||
|
||||
// 创建配置
|
||||
config := TextConfig{
|
||||
Text: "测试文本",
|
||||
Font: font,
|
||||
FontSize: 36,
|
||||
Spacing: 10,
|
||||
DPI: 72,
|
||||
}
|
||||
|
||||
// 设置字体选项
|
||||
opts := truetype.Options{
|
||||
Size: config.FontSize,
|
||||
DPI: config.DPI,
|
||||
Hinting: xfont.HintingFull,
|
||||
}
|
||||
face := truetype.NewFace(font, &opts)
|
||||
defer face.Close()
|
||||
|
||||
// 计算文字尺寸
|
||||
width, height := CalculateTextDimensions(config, face)
|
||||
|
||||
// 验证结果
|
||||
assert.True(t, width > 0, "文字宽度应该大于0")
|
||||
assert.True(t, height > 0, "文字高度应该大于0")
|
||||
}
|
||||
|
||||
func TestNormalizeAngle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input float64
|
||||
expected float64
|
||||
}{
|
||||
{0, 0},
|
||||
{180, 180},
|
||||
{-180, -180},
|
||||
{360, 0},
|
||||
{-360, 0},
|
||||
{540, 180},
|
||||
{-540, -180},
|
||||
{270, -90},
|
||||
{-270, 90},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := normalizeAngle(test.input)
|
||||
assert.Equal(t, test.expected, result, "输入角度 %f 应该归一化为 %f,但得到 %f",
|
||||
test.input, test.expected, result)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user