feat: add snap image

This commit is contained in:
banshan
2024-12-09 09:08:20 +08:00
parent d9f29c16f9
commit 7e3db70daa
8 changed files with 807 additions and 7 deletions

View File

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

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

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

View 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()
}
}

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

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