Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6573dd6f24 | ||
|
|
762370e839 |
@ -1,5 +1,12 @@
|
||||
# CHANGELOG - apigo.cc/go/vision
|
||||
|
||||
## v1.0.5 (2026-05-13)
|
||||
- **高级水印系统**:
|
||||
- 为 `Watermark` 和 `TextWatermark` 增加旋转角度 (`angle`) 支持。
|
||||
- 新增 `TileWatermark` 和 `TileTextWatermark` 实现全图平铺水印,支持自定义间距与角度。
|
||||
- **GIF 水印支持**: 为 `Animation` 结构增加全套水印方法,支持对动图所有帧批量添加水印。
|
||||
- **状态确认**: 确认并完善了二维码 (`QR Code`) 与条形码 (`Barcode`) 的生成与识别能力。
|
||||
|
||||
## v1.0.4 (2026-05-13)
|
||||
- **水印系统**: 新增 `Watermark` (图片) 和 `TextWatermark` (文字) 支持九宫格位置定义与透明度。
|
||||
- **视频水印**: 扩展 `Video` 结构,支持通过 FFmpeg 一键给视频添加水印。
|
||||
|
||||
17
README.md
17
README.md
@ -81,14 +81,17 @@ frame.Blur(2.0)
|
||||
vision.Save(frame, "preview.jpg")
|
||||
```
|
||||
|
||||
### 5. 验证码生成
|
||||
### 5. 多媒体预览与转写转码
|
||||
针对 Web 端、列表缩略图或语音转写场景的一站式优化预览。支持自动缩放并裁剪以适应指定尺寸。
|
||||
```go
|
||||
captcha := vision.GenerateCaptcha(&vision.CaptchaOption{
|
||||
Length: 6,
|
||||
Width: 200,
|
||||
Height: 60,
|
||||
})
|
||||
vision.Save(captcha, "captcha.png")
|
||||
// 生成图片缩略图 (WebP, 自动填充/裁剪)
|
||||
vision.GenerateImagePreview("photo.jpg", "thumb.webp", 200, 200)
|
||||
|
||||
// 生成 4 帧动画 WebP (自动填充/裁剪, 效果动态)
|
||||
vision.GenerateVideoPreview("movie.mp4", "preview.webp", 320, 180)
|
||||
|
||||
// 提取音频预览片段 (16kHz Ogg Opus, 最长 3 分钟)
|
||||
vision.GenerateAudioPreview("input.mp4", "preview.ogg")
|
||||
```
|
||||
|
||||
## 🛠 API 概览
|
||||
|
||||
28
animation.go
28
animation.go
@ -83,3 +83,31 @@ func LoadGIF(path string) (*Animation, error) {
|
||||
}
|
||||
return anim, nil
|
||||
}
|
||||
|
||||
// Watermark 给动画所有帧添加图片水印
|
||||
func (a *Animation) Watermark(mark *Canvas, pos Position, opacity float64, padding int, angle ...float64) {
|
||||
for _, f := range a.Frames {
|
||||
f.Watermark(mark, pos, opacity, padding, angle...)
|
||||
}
|
||||
}
|
||||
|
||||
// TileWatermark 给动画所有帧平铺图片水印
|
||||
func (a *Animation) TileWatermark(mark *Canvas, opacity float64, spacing int, angle float64) {
|
||||
for _, f := range a.Frames {
|
||||
f.TileWatermark(mark, opacity, spacing, angle)
|
||||
}
|
||||
}
|
||||
|
||||
// TextWatermark 给动画所有帧添加文字水印
|
||||
func (a *Animation) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int, angle ...float64) {
|
||||
for _, f := range a.Frames {
|
||||
f.TextWatermark(text, pos, style, opacity, padding, angle...)
|
||||
}
|
||||
}
|
||||
|
||||
// TileTextWatermark 给动画所有帧平铺文字水印
|
||||
func (a *Animation) TileTextWatermark(text string, style *DrawStyle, opacity float64, spacing int, angle float64) {
|
||||
for _, f := range a.Frames {
|
||||
f.TileTextWatermark(text, style, opacity, spacing, angle)
|
||||
}
|
||||
}
|
||||
|
||||
79
preview.go
Normal file
79
preview.go
Normal file
@ -0,0 +1,79 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GenerateImagePreview 生成图片预览 (WebP)
|
||||
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
|
||||
func GenerateImagePreview(srcPath, outPath string, width, height int) error {
|
||||
c, err := Load(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Fill(width, height)
|
||||
return Save(c, outPath)
|
||||
}
|
||||
|
||||
// GenerateVideoPreview 生成视频预览 (4帧动画 WebP)
|
||||
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
|
||||
func GenerateVideoPreview(videoPath, outPath string, width, height int) error {
|
||||
v, err := NewVideo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
duration, err := getVideoDuration(videoPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
times := []float64{0, duration * 0.33, duration * 0.67, duration * 0.90}
|
||||
tmpDir, _ := os.MkdirTemp("", "frames")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 使用 ffmpeg 的 scale 和 crop 滤镜实现 Fill 效果
|
||||
// force_original_aspect_ratio=increase 确保图片至少覆盖目标尺寸
|
||||
// crop=w:h 裁剪中心区域
|
||||
vf := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", width, height, width, height)
|
||||
|
||||
for i, t := range times {
|
||||
framePath := filepath.Join(tmpDir, fmt.Sprintf("frame_%d.png", i))
|
||||
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, framePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(v.FFmpegPath, "-framerate", "1", "-i", filepath.Join(tmpDir, "frame_%d.png"),
|
||||
"-c:v", "libwebp", "-lossless", "0", "-quality", "70", "-loop", "0", outPath)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// GenerateAudioPreview 提取 3 分钟内的音频用于转写/预览
|
||||
// 格式: Ogg Opus, 16kHz, 单声道
|
||||
func GenerateAudioPreview(mediaPath, outPath string) error {
|
||||
v, err := NewVideo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// -vn: 禁用视频
|
||||
// -c:a libopus: 高效音频压缩
|
||||
// -ar 16000: 采样率 16k
|
||||
// -t 180: 最长 180 秒
|
||||
cmd := exec.Command(v.FFmpegPath, "-i", mediaPath, "-vn", "-c:a", "libopus", "-ar", "16000", "-ac", "1", "-t", "180", outPath)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func getVideoDuration(videoPath string) (float64, error) {
|
||||
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath).Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var duration float64
|
||||
_, err = fmt.Sscanf(string(out), "%f", &duration)
|
||||
return duration, err
|
||||
}
|
||||
88
preview_test.go
Normal file
88
preview_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
)
|
||||
|
||||
func TestPreviewer(t *testing.T) {
|
||||
// 1. 创建测试环境
|
||||
tmpDir, err := os.MkdirTemp("", "vision_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
videoPath := filepath.Join(tmpDir, "test.mp4")
|
||||
webPPath := filepath.Join(tmpDir, "preview.webp")
|
||||
oggPath := filepath.Join(tmpDir, "preview.ogg")
|
||||
|
||||
// 2. 模拟生成素材 (生成 5 张纯色帧图片用于合成视频)
|
||||
imgPattern := filepath.Join(tmpDir, "frame_%d.png")
|
||||
for i := 0; i < 5; i++ {
|
||||
dc := gg.NewContext(320, 240)
|
||||
dc.SetColor(color.RGBA{uint8(i * 50), 100, 200, 255})
|
||||
dc.Clear()
|
||||
dc.SavePNG(fmt.Sprintf(imgPattern, i))
|
||||
}
|
||||
|
||||
// 使用现有的 vision.Video 逻辑生成视频
|
||||
v, err := NewVideo()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 这里通过 ffmpeg 拼接图片并添加一个静音音频产生视频
|
||||
err = v.CreateVideoFromImages(filepath.Join(tmpDir, "frame_%d.png"), 1, videoPath)
|
||||
if err != nil {
|
||||
t.Skip("FFmpeg video generation failed, skipping integration test")
|
||||
}
|
||||
|
||||
// 为测试视频添加静音音频,否则提取音频会失败
|
||||
audioPath := filepath.Join(tmpDir, "silent.aac")
|
||||
err = exec.Command("ffmpeg", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "5", "-c:a", "aac", audioPath).Run()
|
||||
if err == nil {
|
||||
finalVideo := filepath.Join(tmpDir, "final.mp4")
|
||||
exec.Command("ffmpeg", "-i", videoPath, "-i", audioPath, "-c", "copy", finalVideo).Run()
|
||||
videoPath = finalVideo
|
||||
}
|
||||
|
||||
// 3. 测试 Preview 功能
|
||||
t.Run("GenerateImagePreview", func(t *testing.T) {
|
||||
imgPath := filepath.Join(tmpDir, "frame_0.png")
|
||||
previewImgPath := filepath.Join(tmpDir, "img_preview.webp")
|
||||
err := GenerateImagePreview(imgPath, previewImgPath, 100, 100)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateImagePreview failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(previewImgPath); os.IsNotExist(err) {
|
||||
t.Error("Image preview output not created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GenerateVideoPreview", func(t *testing.T) {
|
||||
err := GenerateVideoPreview(videoPath, webPPath, 160, 120)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateVideoPreview failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(webPPath); os.IsNotExist(err) {
|
||||
t.Error("WebP output not created")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GenerateAudioPreview", func(t *testing.T) {
|
||||
err := GenerateAudioPreview(videoPath, oggPath)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateAudioPreview failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(oggPath); os.IsNotExist(err) {
|
||||
t.Error("Ogg output not created")
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -65,6 +65,36 @@ func TestPHash(t *testing.T) {
|
||||
t.Logf("pHash distance: %d", dist)
|
||||
}
|
||||
|
||||
func TestTileWatermark(t *testing.T) {
|
||||
c := New(400, 300, "#FFFFFF")
|
||||
c.Circle(200, 150, 50, &DrawStyle{FillColor: "#0000FF"})
|
||||
|
||||
c.TileTextWatermark("CONFIDENTIAL", &DrawStyle{FillColor: "#FF0000"}, 0.2, 50, -0.785) // 45度
|
||||
|
||||
err := Save(c, "test_tile_watermark.png")
|
||||
if err != nil {
|
||||
t.Fatalf("save tile watermark test failed: %v", err)
|
||||
}
|
||||
defer os.Remove("test_tile_watermark.png")
|
||||
}
|
||||
|
||||
func TestAnimationWatermark(t *testing.T) {
|
||||
anim := NewAnimation()
|
||||
for i := 0; i < 5; i++ {
|
||||
c := New(100, 100, "#FFFFFF")
|
||||
c.Circle(float64(i*20), 50, 10, &DrawStyle{FillColor: "#00FF00"})
|
||||
anim.AddFrame(c, 10)
|
||||
}
|
||||
|
||||
anim.TextWatermark("GO", Center, &DrawStyle{FillColor: "#000000"}, 0.5, 0)
|
||||
|
||||
err := anim.SaveGIF("test_anim_watermark.gif", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("save anim watermark failed: %v", err)
|
||||
}
|
||||
defer os.Remove("test_anim_watermark.gif")
|
||||
}
|
||||
|
||||
func TestQRCode(t *testing.T) {
|
||||
content := "https://apigo.cc"
|
||||
c, err := GenerateQRCode(content, 200)
|
||||
|
||||
76
watermark.go
76
watermark.go
@ -26,7 +26,8 @@ const (
|
||||
// pos: 位置
|
||||
// opacity: 透明度 (0.0 - 1.0)
|
||||
// padding: 边距
|
||||
func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64, padding int) {
|
||||
// angle: 旋转角度 (弧度)
|
||||
func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64, padding int, angle ...float64) {
|
||||
if mark == nil || opacity < 0.01 {
|
||||
return
|
||||
}
|
||||
@ -56,6 +57,11 @@ func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64, padding
|
||||
x, y = w-mw-padding, h-mh-padding
|
||||
}
|
||||
|
||||
c.dc.Push()
|
||||
if len(angle) > 0 && angle[0] != 0 {
|
||||
c.dc.RotateAbout(angle[0], float64(x+mw/2), float64(y+mh/2))
|
||||
}
|
||||
|
||||
// 处理透明度
|
||||
if opacity >= 0.99 {
|
||||
c.dc.DrawImage(mark.dc.Image(), x, y)
|
||||
@ -63,23 +69,71 @@ func (c *Canvas) Watermark(mark *Canvas, pos Position, opacity float64, padding
|
||||
mask := image.NewUniform(color.Alpha{uint8(255 * opacity)})
|
||||
draw.DrawMask(c.dc.Image().(draw.Image), mark.dc.Image().Bounds().Add(image.Pt(x, y)), mark.dc.Image(), image.Point{}, mask, image.Point{}, draw.Over)
|
||||
}
|
||||
c.dc.Pop()
|
||||
}
|
||||
|
||||
// TileWatermark 平铺图片水印
|
||||
// spacing: 间距
|
||||
// angle: 旋转角度 (弧度)
|
||||
func (c *Canvas) TileWatermark(mark *Canvas, opacity float64, spacing int, angle float64) {
|
||||
if mark == nil || opacity < 0.01 {
|
||||
return
|
||||
}
|
||||
|
||||
mw, mh := mark.Width(), mark.Height()
|
||||
w, h := c.Width(), c.Height()
|
||||
|
||||
stepX := mw + spacing
|
||||
stepY := mh + spacing
|
||||
|
||||
for y := -mh; y < h+mh; y += stepY {
|
||||
for x := -mw; x < w+mw; x += stepX {
|
||||
c.dc.Push()
|
||||
c.dc.RotateAbout(angle, float64(x+mw/2), float64(y+mh/2))
|
||||
if opacity >= 0.99 {
|
||||
c.dc.DrawImage(mark.dc.Image(), x, y)
|
||||
} else {
|
||||
mask := image.NewUniform(color.Alpha{uint8(255 * opacity)})
|
||||
draw.DrawMask(c.dc.Image().(draw.Image), mark.dc.Image().Bounds().Add(image.Pt(x, y)), mark.dc.Image(), image.Point{}, mask, image.Point{}, draw.Over)
|
||||
}
|
||||
c.dc.Pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TextWatermark 给画布添加文字水印
|
||||
func (c *Canvas) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int) {
|
||||
func (c *Canvas) TextWatermark(text string, pos Position, style *DrawStyle, opacity float64, padding int, angle ...float64) {
|
||||
// 创建一个临时画布来渲染文字,然后调用 Watermark
|
||||
// 这样可以利用 Watermark 的位置计算逻辑
|
||||
c.dc.Push()
|
||||
if style != nil && style.FillColor != "" {
|
||||
c.SetColor(style.FillColor)
|
||||
}
|
||||
|
||||
// 这里简化实现,实际可以先测量文字宽度
|
||||
tw, th := c.dc.MeasureString(text)
|
||||
temp := New(int(tw)+2, int(th)+2)
|
||||
temp := New(int(tw)+4, int(th)+4)
|
||||
if style != nil && style.FillColor != "" {
|
||||
temp.dc.SetColor(ParseColor(style.FillColor))
|
||||
temp.dc.DrawString(text, 0, th)
|
||||
} else {
|
||||
temp.dc.SetColor(color.Black)
|
||||
}
|
||||
temp.dc.DrawString(text, 2, th+2)
|
||||
|
||||
c.Watermark(temp, pos, opacity, padding)
|
||||
ang := 0.0
|
||||
if len(angle) > 0 {
|
||||
ang = angle[0]
|
||||
}
|
||||
c.Watermark(temp, pos, opacity, padding, ang)
|
||||
c.dc.Pop()
|
||||
}
|
||||
|
||||
// TileTextWatermark 平铺文字水印
|
||||
func (c *Canvas) TileTextWatermark(text string, style *DrawStyle, opacity float64, spacing int, angle float64) {
|
||||
c.dc.Push()
|
||||
tw, th := c.dc.MeasureString(text)
|
||||
temp := New(int(tw)+4, int(th)+4)
|
||||
if style != nil && style.FillColor != "" {
|
||||
temp.dc.SetColor(ParseColor(style.FillColor))
|
||||
} else {
|
||||
temp.dc.SetColor(color.Black)
|
||||
}
|
||||
temp.dc.DrawString(text, 2, th+2)
|
||||
|
||||
c.TileWatermark(temp, opacity, spacing, angle)
|
||||
c.dc.Pop()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user