Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1645918690 | ||
|
|
a0e2a2b47b | ||
|
|
f269f92527 | ||
|
|
46546825c3 |
@ -1,5 +1,10 @@
|
||||
# CHANGELOG - apigo.cc/go/vision
|
||||
|
||||
## v1.5.1 (2026-06-08)
|
||||
- **JS 对齐 & 智能文档**:
|
||||
- 将所有注册到 `jsmod` 的方法名统一为 PascalCase。
|
||||
- **可选参数优化**: 将 `New` 的 `bg`、`Save` 的 `quality` 等改用指针类型包装。配合最新的 `go/js` 引擎,生成的 `.d.ts` 定义将正确标识为可选参数 `?`,大幅提升 AI 开发效率。
|
||||
|
||||
## v1.0.9 (2026-05-17)
|
||||
- **新特性**: 内置全能命令行工具 `vision` (`cmd/vision`)。
|
||||
- **功能增强**: `vision.Load` 增加多级环境探测(sips, heif-convert, magick, ffmpeg),完美支持 HEIC 及其网格重构解码。
|
||||
|
||||
15
go.mod
15
go.mod
@ -3,9 +3,10 @@ module apigo.cc/go/vision
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
apigo.cc/go/cast v1.3.3
|
||||
apigo.cc/go/file v1.3.2
|
||||
apigo.cc/go/rand v1.3.1
|
||||
apigo.cc/go/cast v1.5.0
|
||||
apigo.cc/go/file v1.5.0
|
||||
apigo.cc/go/jsmod v1.5.0
|
||||
apigo.cc/go/rand v1.5.0
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/flopp/go-findfont v0.1.0
|
||||
@ -16,12 +17,12 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
apigo.cc/go/encoding v1.3.1 // indirect
|
||||
apigo.cc/go/safe v1.3.1 // indirect
|
||||
apigo.cc/go/encoding v1.5.0 // indirect
|
||||
apigo.cc/go/safe v1.5.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
28
go.sum
28
go.sum
@ -1,13 +1,15 @@
|
||||
apigo.cc/go/cast v1.3.3 h1:aln5eDR5DZVWVzZ/y5SJh1gQNgWv2sT82I25NaO9g34=
|
||||
apigo.cc/go/cast v1.3.3/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
|
||||
apigo.cc/go/encoding v1.3.1 h1:y8O58KYAyulkThg1O2ji2BqjnFoSvk42sit9I3z+K7Y=
|
||||
apigo.cc/go/encoding v1.3.1/go.mod h1:xAJk5b83VZ31mXMTnyp0dfMoBKfT/AHDn0u+cQfojgY=
|
||||
apigo.cc/go/file v1.3.2 h1:pu4oiDyiqgj3/eykfnJf+/6+A9v/Z0b3ClP5XK+lwG4=
|
||||
apigo.cc/go/file v1.3.2/go.mod h1:vci4h0Pz94mV6dkniQkuyBYERVYeq7/LX4jJVuCg9hs=
|
||||
apigo.cc/go/rand v1.3.1 h1:7FvsI6PtQ5XrWER0dTiLVo0p7GIxRidT/TBKhVy93j8=
|
||||
apigo.cc/go/rand v1.3.1/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
|
||||
apigo.cc/go/safe v1.3.1 h1:irTCqPAC97gGsX/Lw5AzLelDt1xXLEZIAaVhLELWe9Q=
|
||||
apigo.cc/go/safe v1.3.1/go.mod h1:XdOpBhN2vkImalaykYXXmEpczqWa1y3ah6/Q72cdRqE=
|
||||
apigo.cc/go/cast v1.5.0 h1:UBGJtFQ8eJPMQXs37cUgqd7YQo1zI9opuSDBDmn2/pE=
|
||||
apigo.cc/go/cast v1.5.0/go.mod h1:z2GW5p5WCZGEqVVIJUdhl232vRbLf2Qu4EDlEakX/D8=
|
||||
apigo.cc/go/encoding v1.5.0 h1:EJNdRVDOMoI2DAvZwQNQTbYuqB/6zsEzvg7lS5pQI+I=
|
||||
apigo.cc/go/encoding v1.5.0/go.mod h1:8++NfZj3hWig0qh2g7GQRw/4LpSvCYMWUZ+8J+x58cA=
|
||||
apigo.cc/go/file v1.5.0 h1:Fh1NSDBqaxjuXYJ71yPHPXVJ8BFEv/AGS3l+jkLi5uw=
|
||||
apigo.cc/go/file v1.5.0/go.mod h1:4YhOGgBINTpmmmgws3H8LAyXQQBGzBp44hYUoCS+kr0=
|
||||
apigo.cc/go/jsmod v1.5.0 h1:JgQtJNiJWy1NOP9AzE8NX5VXJkpO/x3GqLsCCSny5Ec=
|
||||
apigo.cc/go/jsmod v1.5.0/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
|
||||
apigo.cc/go/rand v1.5.0 h1:1o8hh8fhdBuk1/h02IvugvamuT3dkWbVJrqEJVQKB2E=
|
||||
apigo.cc/go/rand v1.5.0/go.mod h1:Lh98S2dm9UY0X+M+kNQQEKyXHG5pcCKSFPyXN0QCGdk=
|
||||
apigo.cc/go/safe v1.5.0 h1:W1NblmcU8cex1f9Y5z8mNLUJOzZTE1s6fszb3FbhGnk=
|
||||
apigo.cc/go/safe v1.5.0/go.mod h1:OfQ5d6COePSGEuPvMeOk6KagX2sezw7nvKh7exj9SeM=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
@ -29,13 +31,11 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
|
||||
167
js_export.go
Normal file
167
js_export.go
Normal file
@ -0,0 +1,167 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apigo.cc/go/file"
|
||||
"apigo.cc/go/jsmod"
|
||||
)
|
||||
|
||||
func init() {
|
||||
jsmod.Register("vision", map[string]any{
|
||||
// 基础操作
|
||||
"Load": func(ctx context.Context, path string) (*jsCanvas, error) {
|
||||
p, err := file.VerifyPathForSafeMode(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &jsCanvas{ctx: ctx, c: c}, nil
|
||||
},
|
||||
"New": func(ctx context.Context, w, h int, bg *string) *jsCanvas {
|
||||
if bg != nil {
|
||||
return &jsCanvas{ctx: ctx, c: New(w, h, *bg)}
|
||||
}
|
||||
return &jsCanvas{ctx: ctx, c: New(w, h)}
|
||||
},
|
||||
"Save": func(ctx context.Context, j *jsCanvas, path string, quality *int) error {
|
||||
p, err := file.VerifyPathForSafeMode(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if quality != nil {
|
||||
return Save(j.c, p, *quality)
|
||||
}
|
||||
return Save(j.c, p)
|
||||
},
|
||||
|
||||
// 生成类
|
||||
"GenerateQRCode": func(ctx context.Context, content string, size int) (*jsCanvas, error) {
|
||||
c, err := GenerateQRCode(content, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &jsCanvas{ctx: ctx, c: c}, nil
|
||||
},
|
||||
"GenerateBarcode": func(ctx context.Context, content string, w, h int) (*jsCanvas, error) {
|
||||
c, err := GenerateBarcode(content, w, h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &jsCanvas{ctx: ctx, c: c}, nil
|
||||
},
|
||||
"GenerateCaptcha": func(ctx context.Context, opt *CaptchaOption) *jsCanvas {
|
||||
return &jsCanvas{ctx: ctx, c: GenerateCaptcha(opt)}
|
||||
},
|
||||
|
||||
// 预览类 (直接写文件)
|
||||
"GenerateImagePreview": func(ctx context.Context, src, out string, w, h int) error {
|
||||
pSrc, err := file.VerifyPathForSafeMode(ctx, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pOut, err := file.VerifyPathForSafeMode(ctx, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return GenerateImagePreview(pSrc, pOut, w, h)
|
||||
},
|
||||
"GenerateVideoPreview": func(ctx context.Context, src, out string, w, h int, interval *int) error {
|
||||
pSrc, err := file.VerifyPathForSafeMode(ctx, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pOut, err := file.VerifyPathForSafeMode(ctx, out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if interval != nil {
|
||||
return GenerateVideoPreview(pSrc, pOut, w, h, *interval)
|
||||
}
|
||||
return GenerateVideoPreview(pSrc, pOut, w, h)
|
||||
},
|
||||
|
||||
// 转换类
|
||||
"Convert": func(ctx context.Context, src, dst string, quality *int) error {
|
||||
pSrc, err := file.VerifyPathForSafeMode(ctx, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pDst, err := file.VerifyPathForSafeMode(ctx, dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if quality != nil {
|
||||
return Convert(pSrc, pDst, *quality)
|
||||
}
|
||||
return Convert(pSrc, pDst)
|
||||
},
|
||||
"Optimize": func(ctx context.Context, path string, maxWidth int, quality int) error {
|
||||
p, err := file.VerifyPathForSafeMode(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Optimize(p, maxWidth, quality)
|
||||
},
|
||||
|
||||
// 辅助工具
|
||||
"LoadFonts": LoadFonts,
|
||||
"Recognize": func(ctx context.Context, path string) (string, error) {
|
||||
p, err := file.VerifyPathForSafeMode(ctx, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return Recognize(p)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// jsCanvas 包装器,支持链式调用
|
||||
type jsCanvas struct {
|
||||
ctx context.Context
|
||||
c *Canvas
|
||||
}
|
||||
|
||||
func (j *jsCanvas) Width() int { return j.c.Width() }
|
||||
func (j *jsCanvas) Height() int { return j.c.Height() }
|
||||
|
||||
func (j *jsCanvas) Save(path string, quality *int) error {
|
||||
p, err := file.VerifyPathForSafeMode(j.ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if quality != nil {
|
||||
return Save(j.c, p, *quality)
|
||||
}
|
||||
return Save(j.c, p)
|
||||
}
|
||||
|
||||
// 效果与绘图 (返回自身以支持链式)
|
||||
func (j *jsCanvas) Resize(w, h int) *jsCanvas { j.c.Resize(w, h); return j }
|
||||
func (j *jsCanvas) Blur(sigma float64) *jsCanvas { j.c.Blur(sigma); return j }
|
||||
func (j *jsCanvas) Grayscale() *jsCanvas { j.c.Grayscale(); return j }
|
||||
func (j *jsCanvas) Invert() *jsCanvas { j.c.Invert(); return j }
|
||||
func (j *jsCanvas) Rotate(angle float64) *jsCanvas { j.c.Rotate(angle); return j }
|
||||
func (j *jsCanvas) FlipH() *jsCanvas { j.c.FlipH(); return j }
|
||||
func (j *jsCanvas) FlipV() *jsCanvas { j.c.FlipV(); return j }
|
||||
func (j *jsCanvas) Sharpen(sigma float64) *jsCanvas { j.c.Sharpen(sigma); return j }
|
||||
func (j *jsCanvas) AdjustBrightness(p float64) *jsCanvas { j.c.AdjustBrightness(p); return j }
|
||||
|
||||
func (j *jsCanvas) Rect(x, y, w, h float64, opt *DrawStyle) *jsCanvas { j.c.Rect(x, y, w, h, opt); return j }
|
||||
func (j *jsCanvas) Circle(x, y, r float64, opt *DrawStyle) *jsCanvas { j.c.Circle(x, y, r, opt); return j }
|
||||
func (j *jsCanvas) Line(x1, y1, x2, y2 float64, opt *DrawStyle) *jsCanvas {
|
||||
j.c.Line(x1, y1, x2, y2, opt)
|
||||
return j
|
||||
}
|
||||
func (j *jsCanvas) DrawText(x, y float64, text string, opt *TextOption) *jsCanvas {
|
||||
j.c.DrawText(x, y, text, opt)
|
||||
return j
|
||||
}
|
||||
|
||||
// 识别类
|
||||
func (j *jsCanvas) DecodeAll() (string, error) { return j.c.DecodeAll() }
|
||||
func (j *jsCanvas) Recognize() (string, error) { return j.c.Recognize() }
|
||||
func (j *jsCanvas) DecodeQRCode() (string, error) { return j.c.DecodeQRCode() }
|
||||
41
preview.go
41
preview.go
@ -10,6 +10,7 @@ import (
|
||||
|
||||
// GenerateImagePreview 生成图片预览
|
||||
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
|
||||
// 根据 outPath 后缀自动转换格式 (.webp, .jpg, .png 等)
|
||||
func GenerateImagePreview(srcPath, outPath string, width, height int) error {
|
||||
// 使用统一的 Load() 加载,内部已处理好 HEIC/sips/FFmpeg 的复杂格式兼容
|
||||
c, err := Load(srcPath)
|
||||
@ -18,14 +19,15 @@ func GenerateImagePreview(srcPath, outPath string, width, height int) error {
|
||||
}
|
||||
c.Fill(width, height)
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(outPath), ".webp") {
|
||||
// 借用 FFmpeg 将生成的画布转为高质量 WebP
|
||||
ext := strings.ToLower(filepath.Ext(outPath))
|
||||
if ext == ".webp" {
|
||||
// 借用 FFmpeg 将生成的画布转为高质量 WebP (比标准库或第三方库压缩更好)
|
||||
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("preview_%d.png", os.Getpid()))
|
||||
defer os.Remove(tmpFile)
|
||||
if err := Save(c, tmpFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
v, err := NewVideo()
|
||||
if err == nil {
|
||||
cmd := exec.Command(v.FFmpegPath, "-i", tmpFile, "-c:v", "libwebp", "-quality", "80", "-y", outPath)
|
||||
@ -112,8 +114,8 @@ func GenerateVideoPreview(videoPath, outPath string, width, height int, frameInt
|
||||
return err
|
||||
}
|
||||
for i, t := range times {
|
||||
framePath := filepath.Join(outPath, fmt.Sprintf("%d.webp", i+1))
|
||||
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, "-c:v", "libwebp", "-quality", "80", "-y", framePath)
|
||||
framePath := filepath.Join(outPath, fmt.Sprintf("%d.jpg", i+1))
|
||||
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, "-q:v", "2", "-y", framePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -122,20 +124,30 @@ func GenerateVideoPreview(videoPath, outPath string, width, height int, frameInt
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateAudioPreview 提取 3 分钟内的音频用于预览或语音转写
|
||||
// 格式: Ogg Opus, 16kHz, 单声道, 12kbps (极致压缩,保留人声特征)
|
||||
// GenerateAudioPreview 提取音频用于预览或语音转写
|
||||
// 支持根据 outPath 后缀输出格式:
|
||||
// - .ogg: 使用 libopus (16kHz, 单声道, 12kbps), 极致压缩且保留人声特征,适合转写
|
||||
// - .wav: 标准 PCM (16kHz, 单声道), 无损但体积较大,部分转写引擎强制要求
|
||||
// - 其他: 默认使用 libopus 转为 ogg
|
||||
func GenerateAudioPreview(mediaPath, outPath string) error {
|
||||
v, err := NewVideo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// -vn: 禁用视频
|
||||
// -c:a libopus: 高效音频压缩
|
||||
// -ar 16000: 采样率 16k (转写标准)
|
||||
// -ac 1: 单声道
|
||||
// -b:a 12k: 极致压缩
|
||||
// -t 180: 最长 180 秒 (足以获得内容概要)
|
||||
cmd := exec.Command(v.FFmpegPath, "-i", mediaPath, "-vn", "-c:a", "libopus", "-ar", "16000", "-ac", "1", "-b:a", "12k", "-t", "180", "-y", outPath)
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(outPath))
|
||||
// 通用参数: 禁用视频, 16kHz 采样率 (STT 标准), 单声道
|
||||
args := []string{"-i", mediaPath, "-vn", "-ar", "16000", "-ac", "1"}
|
||||
|
||||
if ext == ".wav" {
|
||||
// WAV 格式,保留 PCM,最长 180 秒避免 LLM OOM
|
||||
args = append(args, "-t", "180", "-y", outPath)
|
||||
} else {
|
||||
// 默认或 .ogg 使用 libopus 极致压缩,最长 180 秒
|
||||
args = append(args, "-c:a", "libopus", "-b:a", "12k", "-t", "180", "-y", outPath)
|
||||
}
|
||||
|
||||
cmd := exec.Command(v.FFmpegPath, args...)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
@ -148,3 +160,4 @@ func getVideoDuration(videoPath string) (float64, error) {
|
||||
_, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%f", &duration)
|
||||
return duration, err
|
||||
}
|
||||
|
||||
|
||||
@ -105,10 +105,19 @@ func TestPreviewer(t *testing.T) {
|
||||
t.Run("GenerateAudioPreview", func(t *testing.T) {
|
||||
err := GenerateAudioPreview(videoPath, oggPath)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateAudioPreview failed: %v", err)
|
||||
t.Errorf("GenerateAudioPreview (ogg) failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(oggPath); os.IsNotExist(err) {
|
||||
t.Error("Ogg output not created")
|
||||
}
|
||||
|
||||
wavPath := filepath.Join(tmpDir, "preview.wav")
|
||||
err = GenerateAudioPreview(videoPath, wavPath)
|
||||
if err != nil {
|
||||
t.Errorf("GenerateAudioPreview (wav) failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(wavPath); os.IsNotExist(err) {
|
||||
t.Error("Wav output not created")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user