mirror of
https://github.com/langhuihui/monibuca.git
synced 2025-09-27 14:22:08 +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/rtmp"
|
||||||
_ "m7s.live/v5/plugin/rtsp"
|
_ "m7s.live/v5/plugin/rtsp"
|
||||||
_ "m7s.live/v5/plugin/sei"
|
_ "m7s.live/v5/plugin/sei"
|
||||||
|
_ "m7s.live/v5/plugin/snap"
|
||||||
_ "m7s.live/v5/plugin/srt"
|
_ "m7s.live/v5/plugin/srt"
|
||||||
_ "m7s.live/v5/plugin/stress"
|
_ "m7s.live/v5/plugin/stress"
|
||||||
_ "m7s.live/v5/plugin/transcode"
|
_ "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/cilium/ebpf v0.15.0
|
||||||
github.com/cloudwego/goref v0.0.0-20240724113447-685d2a9523c8
|
github.com/cloudwego/goref v0.0.0-20240724113447-685d2a9523c8
|
||||||
github.com/deepch/vdk v0.0.27
|
github.com/deepch/vdk v0.0.27
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/emiago/sipgo v0.22.0
|
github.com/emiago/sipgo v0.22.0
|
||||||
github.com/go-delve/delve v1.23.0
|
github.com/go-delve/delve v1.23.0
|
||||||
github.com/gobwas/ws v1.3.2
|
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/google/gopacket v1.1.19
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1
|
||||||
github.com/husanpao/ip v0.0.0-20220711082147-73160bb611a8
|
github.com/husanpao/ip v0.0.0-20220711082147-73160bb611a8
|
||||||
@@ -34,7 +36,8 @@ require (
|
|||||||
github.com/shirou/gopsutil/v4 v4.24.8
|
github.com/shirou/gopsutil/v4 v4.24.8
|
||||||
github.com/vishvananda/netlink v1.1.0
|
github.com/vishvananda/netlink v1.1.0
|
||||||
github.com/yapingcat/gomedia v0.0.0-20240601043430-920523f8e5c7
|
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/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d
|
||||||
google.golang.org/grpc v1.65.0
|
google.golang.org/grpc v1.65.0
|
||||||
google.golang.org/protobuf v1.34.2
|
google.golang.org/protobuf v1.34.2
|
||||||
@@ -114,7 +117,7 @@ require (
|
|||||||
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect
|
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/arch v0.6.0 // 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
|
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
|
github.com/quangngotan95/go-m3u8 v0.1.0
|
||||||
go.uber.org/mock v0.4.0 // indirect
|
go.uber.org/mock v0.4.0 // indirect
|
||||||
golang.org/x/crypto v0.26.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/mod v0.19.0 // indirect
|
||||||
golang.org/x/net v0.27.0
|
golang.org/x/net v0.27.0
|
||||||
golang.org/x/sys v0.25.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/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 h1:j/SHaTiZhA47wRpaue8NRp7P9xwOOO/lunxrDJBwcao=
|
||||||
github.com/deepch/vdk v0.0.27/go.mod h1:JlgGyR2ld6+xOIHa7XAxJh+stSDBAkdNvIPkUIdIywk=
|
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 h1:GaQ51m26M9QnVBVY2aDJ/mXqq/BDfZ1A+nW7XgU/4Ts=
|
||||||
github.com/emiago/sipgo v0.22.0/go.mod h1:a77FgPEEjJvfYWYfP3p53u+dNhWEMb/VGVS6guvBzx0=
|
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=
|
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 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
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/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.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/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
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/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 h1:wDLEX9a7YQoKdKNQt88rtydkqDxeGaBUTnIYc3iG/mA=
|
||||||
golang.org/x/exp v0.0.0-20240716175740-e3f259677ff7/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
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/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.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
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-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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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.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.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.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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
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 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
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=
|
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