Compare commits

...

5 Commits
v1.0.9 ... main

9 changed files with 320 additions and 61 deletions

View File

@ -1,5 +1,10 @@
# CHANGELOG - apigo.cc/go/vision # 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) ## v1.0.9 (2026-05-17)
- **新特性**: 内置全能命令行工具 `vision` (`cmd/vision`)。 - **新特性**: 内置全能命令行工具 `vision` (`cmd/vision`)。
- **功能增强**: `vision.Load` 增加多级环境探测sips, heif-convert, magick, ffmpeg完美支持 HEIC 及其网格重构解码。 - **功能增强**: `vision.Load` 增加多级环境探测sips, heif-convert, magick, ffmpeg完美支持 HEIC 及其网格重构解码。

View File

@ -87,9 +87,15 @@ vision.Save(frame, "preview.jpg")
// 生成图片缩略图 (WebP, 自动填充/裁剪) // 生成图片缩略图 (WebP, 自动填充/裁剪)
vision.GenerateImagePreview("photo.jpg", "thumb.webp", 200, 200) vision.GenerateImagePreview("photo.jpg", "thumb.webp", 200, 200)
// 生成 4 帧动画 WebP (自动填充/裁剪, 效果动态) // 生成动画预览 (WebP/GIF, 默认 30s 采样一帧,自动填充/裁剪)
vision.GenerateVideoPreview("movie.mp4", "preview.webp", 320, 180) vision.GenerateVideoPreview("movie.mp4", "preview.webp", 320, 180)
// 生成单张预览图 (JPG/PNG, 取视频中间帧)
vision.GenerateVideoPreview("movie.mp4", "preview.jpg", 320, 180)
// 提取多张预览帧到文件夹 (输出 1.webp, 2.webp...)
vision.GenerateVideoPreview("movie.mp4", "frames_dir", 320, 180)
// 提取音频预览片段 (16kHz Ogg Opus, 最长 3 分钟) // 提取音频预览片段 (16kHz Ogg Opus, 最长 3 分钟)
vision.GenerateAudioPreview("input.mp4", "preview.ogg") vision.GenerateAudioPreview("input.mp4", "preview.ogg")
``` ```
@ -119,7 +125,7 @@ vision --data "https://apigo.cc" -o qr.png --size 512
vision in.png -o out.png --resize 800x600 --blur 1.5 --grayscale vision in.png -o out.png --resize 800x600 --blur 1.5 --grayscale
# 5. 生成视频动态预览 (WebP) # 5. 生成视频动态预览 (WebP)
vision video.mp4 --type video -o preview.webp --width 320 --height 180 vision video.mp4 --type video -o preview.webp --width 320 --height 180 --step 30
# 6. 生成验证码 # 6. 生成验证码
vision --captcha -o captcha.png --len 6 vision --captcha -o captcha.png --len 6

View File

@ -39,6 +39,7 @@ var (
// 视频 // 视频
vtime = flag.Float64("time", 0, "提取视频帧的时间点 (秒)") vtime = flag.Float64("time", 0, "提取视频帧的时间点 (秒)")
vstep = flag.Int("step", 30, "视频预览采样间隔 (秒,默认 30)")
) )
const visionVersion = "1.0.0" const visionVersion = "1.0.0"
@ -160,7 +161,7 @@ func runPreview(src string) {
case "image": case "image":
err = vision.GenerateImagePreview(src, *outFile, w, h) err = vision.GenerateImagePreview(src, *outFile, w, h)
case "video": case "video":
err = vision.GenerateVideoPreview(src, *outFile, w, h) err = vision.GenerateVideoPreview(src, *outFile, w, h, *vstep)
case "audio": case "audio":
err = vision.GenerateAudioPreview(src, *outFile) err = vision.GenerateAudioPreview(src, *outFile)
default: default:

15
go.mod
View File

@ -3,9 +3,10 @@ module apigo.cc/go/vision
go 1.25.0 go 1.25.0
require ( require (
apigo.cc/go/cast v1.3.3 apigo.cc/go/cast v1.5.0
apigo.cc/go/file v1.3.2 apigo.cc/go/file v1.5.0
apigo.cc/go/rand v1.3.1 apigo.cc/go/jsmod v1.5.0
apigo.cc/go/rand v1.5.0
github.com/boombuler/barcode v1.1.0 github.com/boombuler/barcode v1.1.0
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/flopp/go-findfont v0.1.0 github.com/flopp/go-findfont v0.1.0
@ -16,12 +17,12 @@ require (
) )
require ( require (
apigo.cc/go/encoding v1.3.1 // indirect apigo.cc/go/encoding v1.5.0 // indirect
apigo.cc/go/safe v1.3.1 // indirect apigo.cc/go/safe v1.5.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
golang.org/x/crypto v0.51.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

28
go.sum
View File

@ -1,13 +1,15 @@
apigo.cc/go/cast v1.3.3 h1:aln5eDR5DZVWVzZ/y5SJh1gQNgWv2sT82I25NaO9g34= apigo.cc/go/cast v1.5.0 h1:UBGJtFQ8eJPMQXs37cUgqd7YQo1zI9opuSDBDmn2/pE=
apigo.cc/go/cast v1.3.3/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk= apigo.cc/go/cast v1.5.0/go.mod h1:z2GW5p5WCZGEqVVIJUdhl232vRbLf2Qu4EDlEakX/D8=
apigo.cc/go/encoding v1.3.1 h1:y8O58KYAyulkThg1O2ji2BqjnFoSvk42sit9I3z+K7Y= apigo.cc/go/encoding v1.5.0 h1:EJNdRVDOMoI2DAvZwQNQTbYuqB/6zsEzvg7lS5pQI+I=
apigo.cc/go/encoding v1.3.1/go.mod h1:xAJk5b83VZ31mXMTnyp0dfMoBKfT/AHDn0u+cQfojgY= apigo.cc/go/encoding v1.5.0/go.mod h1:8++NfZj3hWig0qh2g7GQRw/4LpSvCYMWUZ+8J+x58cA=
apigo.cc/go/file v1.3.2 h1:pu4oiDyiqgj3/eykfnJf+/6+A9v/Z0b3ClP5XK+lwG4= apigo.cc/go/file v1.5.0 h1:Fh1NSDBqaxjuXYJ71yPHPXVJ8BFEv/AGS3l+jkLi5uw=
apigo.cc/go/file v1.3.2/go.mod h1:vci4h0Pz94mV6dkniQkuyBYERVYeq7/LX4jJVuCg9hs= apigo.cc/go/file v1.5.0/go.mod h1:4YhOGgBINTpmmmgws3H8LAyXQQBGzBp44hYUoCS+kr0=
apigo.cc/go/rand v1.3.1 h1:7FvsI6PtQ5XrWER0dTiLVo0p7GIxRidT/TBKhVy93j8= apigo.cc/go/jsmod v1.5.0 h1:JgQtJNiJWy1NOP9AzE8NX5VXJkpO/x3GqLsCCSny5Ec=
apigo.cc/go/rand v1.3.1/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk= apigo.cc/go/jsmod v1.5.0/go.mod h1:bmyeZtOAP/j5am+YRnaiM89smysK24K7ebk0koFtsSw=
apigo.cc/go/safe v1.3.1 h1:irTCqPAC97gGsX/Lw5AzLelDt1xXLEZIAaVhLELWe9Q= apigo.cc/go/rand v1.5.0 h1:1o8hh8fhdBuk1/h02IvugvamuT3dkWbVJrqEJVQKB2E=
apigo.cc/go/safe v1.3.1/go.mod h1:XdOpBhN2vkImalaykYXXmEpczqWa1y3ah6/Q72cdRqE= 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 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 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 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=

167
js_export.go Normal file
View 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() }

BIN
main

Binary file not shown.

View File

@ -10,6 +10,7 @@ import (
// GenerateImagePreview 生成图片预览 // GenerateImagePreview 生成图片预览
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式) // 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
// 根据 outPath 后缀自动转换格式 (.webp, .jpg, .png 等)
func GenerateImagePreview(srcPath, outPath string, width, height int) error { func GenerateImagePreview(srcPath, outPath string, width, height int) error {
// 使用统一的 Load() 加载,内部已处理好 HEIC/sips/FFmpeg 的复杂格式兼容 // 使用统一的 Load() 加载,内部已处理好 HEIC/sips/FFmpeg 的复杂格式兼容
c, err := Load(srcPath) c, err := Load(srcPath)
@ -18,8 +19,9 @@ func GenerateImagePreview(srcPath, outPath string, width, height int) error {
} }
c.Fill(width, height) c.Fill(width, height)
if strings.HasSuffix(strings.ToLower(outPath), ".webp") { ext := strings.ToLower(filepath.Ext(outPath))
// 借用 FFmpeg 将生成的画布转为高质量 WebP if ext == ".webp" {
// 借用 FFmpeg 将生成的画布转为高质量 WebP (比标准库或第三方库压缩更好)
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("preview_%d.png", os.Getpid())) tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("preview_%d.png", os.Getpid()))
defer os.Remove(tmpFile) defer os.Remove(tmpFile)
if err := Save(c, tmpFile); err != nil { if err := Save(c, tmpFile); err != nil {
@ -38,9 +40,13 @@ func GenerateImagePreview(srcPath, outPath string, width, height int) error {
return Save(c, outPath) return Save(c, outPath)
} }
// GenerateVideoPreview 生成视频预览 (动态 WebP) // GenerateVideoPreview 生成视频预览
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式) // 根据 outPath 后缀判断输出格式:
func GenerateVideoPreview(videoPath, outPath string, width, height int) error { // - .webp | .gif: 生成动态动画 (默认每 30 秒采样一帧,可通过 frameInterval 调整)
// - .jpg | .jpeg | .png: 生成单张预览图 (取视频中间帧)
// - 其他: 将 outPath 视为文件夹,在其中生成多张静态 .webp 图像
// frameInterval: 每隔多少秒采样一帧,默认 30。
func GenerateVideoPreview(videoPath, outPath string, width, height int, frameInterval ...int) error {
v, err := NewVideo() v, err := NewVideo()
if err != nil { if err != nil {
return err return err
@ -51,60 +57,97 @@ func GenerateVideoPreview(videoPath, outPath string, width, height int) error {
return err return err
} }
// 动态计算帧数: 适合交给大模型 (VLM) 处理 ext := strings.ToLower(filepath.Ext(outPath))
// 避免过多的帧浪费 Token每 30 秒 1 帧,最少 3 帧,最多 8 帧 vf := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", width, height, width, height)
frameCount := int(duration / 30.0)
// 1. 单张图片模式
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" {
t := duration * 0.5
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, "-y", outPath)
return cmd.Run()
}
// 2. 动画或多图模式需要计算多帧
interval := 30
if len(frameInterval) > 0 && frameInterval[0] > 0 {
interval = frameInterval[0]
}
// 动态计算帧数: 避免过多的帧浪费 Token每 interval 秒 1 帧,最少 3 帧,最多 8 帧
frameCount := int(duration / float64(interval))
if frameCount < 3 { if frameCount < 3 {
frameCount = 3 frameCount = 3
} else if frameCount > 8 { } else if frameCount > 8 {
frameCount = 8 frameCount = 8
} }
// 在 10% 到 90% 之间均匀采样,跳过片头片尾的可能黑屏
times := make([]float64, frameCount) times := make([]float64, frameCount)
if frameCount == 1 { for i := 0; i < frameCount; i++ {
times[0] = duration * 0.5 times[i] = duration * (0.10 + 0.80*(float64(i)/float64(frameCount-1)))
} else {
for i := 0; i < frameCount; i++ {
times[i] = duration * (0.10 + 0.80*(float64(i)/float64(frameCount-1)))
}
} }
tmpDir, _ := os.MkdirTemp("", "frames") // 2a. 动画模式 (.webp, .gif)
defer os.RemoveAll(tmpDir) if ext == ".webp" || ext == ".gif" {
tmpDir, _ := os.MkdirTemp("", "frames")
defer os.RemoveAll(tmpDir)
// 使用 ffmpeg 的 scale 和 crop 滤镜实现 Fill 效果 for i, t := range times {
vf := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", width, height, width, height) framePath := filepath.Join(tmpDir, fmt.Sprintf("frame_%02d.png", i))
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, "-y", framePath)
if err := cmd.Run(); err != nil {
return err
}
}
var cmd *exec.Cmd
if ext == ".webp" {
cmd = exec.Command(v.FFmpegPath, "-framerate", "1", "-i", filepath.Join(tmpDir, "frame_%02d.png"),
"-c:v", "libwebp", "-lossless", "0", "-quality", "70", "-loop", "0", "-y", outPath)
} else {
cmd = exec.Command(v.FFmpegPath, "-framerate", "1", "-i", filepath.Join(tmpDir, "frame_%02d.png"), "-y", outPath)
}
return cmd.Run()
}
// 2b. 文件夹多图模式
if err := os.MkdirAll(outPath, 0755); err != nil {
return err
}
for i, t := range times { for i, t := range times {
framePath := filepath.Join(tmpDir, fmt.Sprintf("frame_%02d.png", i)) framePath := filepath.Join(outPath, fmt.Sprintf("%d.jpg", i+1))
// 使用 -ss 快速 seek性能极高 cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, "-q:v", "2", "-y", framePath)
cmd := exec.Command(v.FFmpegPath, "-ss", fmt.Sprintf("%f", t), "-i", videoPath, "-frames:v", "1", "-vf", vf, "-y", framePath)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return err return err
} }
} }
// 生成 WebP 动画,每秒 1 帧 (1000ms 间隔,让大模型能看清每一帧) return nil
cmd := exec.Command(v.FFmpegPath, "-framerate", "1", "-i", filepath.Join(tmpDir, "frame_%02d.png"),
"-c:v", "libwebp", "-lossless", "0", "-quality", "70", "-loop", "0", "-y", outPath)
return cmd.Run()
} }
// GenerateAudioPreview 提取 3 分钟内的音频用于预览或语音转写 // GenerateAudioPreview 提取音频用于预览或语音转写
// 格式: Ogg Opus, 16kHz, 单声道, 12kbps (极致压缩,保留人声特征) // 支持根据 outPath 后缀输出格式:
// - .ogg: 使用 libopus (16kHz, 单声道, 12kbps), 极致压缩且保留人声特征,适合转写
// - .wav: 标准 PCM (16kHz, 单声道), 无损但体积较大,部分转写引擎强制要求
// - 其他: 默认使用 libopus 转为 ogg
func GenerateAudioPreview(mediaPath, outPath string) error { func GenerateAudioPreview(mediaPath, outPath string) error {
v, err := NewVideo() v, err := NewVideo()
if err != nil { if err != nil {
return err return err
} }
// -vn: 禁用视频
// -c:a libopus: 高效音频压缩 ext := strings.ToLower(filepath.Ext(outPath))
// -ar 16000: 采样率 16k (转写标准) // 通用参数: 禁用视频, 16kHz 采样率 (STT 标准), 单声道
// -ac 1: 单声道 args := []string{"-i", mediaPath, "-vn", "-ar", "16000", "-ac", "1"}
// -b:a 12k: 极致压缩
// -t 180: 最长 180 秒 (足以获得内容概要) if ext == ".wav" {
cmd := exec.Command(v.FFmpegPath, "-i", mediaPath, "-vn", "-c:a", "libopus", "-ar", "16000", "-ac", "1", "-b:a", "12k", "-t", "180", "-y", outPath) // 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() return cmd.Run()
} }
@ -117,3 +160,4 @@ func getVideoDuration(videoPath string) (float64, error) {
_, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%f", &duration) _, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%f", &duration)
return duration, err return duration, err
} }

View File

@ -76,13 +76,48 @@ func TestPreviewer(t *testing.T) {
} }
}) })
t.Run("GenerateVideoPreview_SingleImage", func(t *testing.T) {
jpgPath := filepath.Join(tmpDir, "preview.jpg")
err := GenerateVideoPreview(videoPath, jpgPath, 160, 120)
if err != nil {
t.Errorf("GenerateVideoPreview (jpg) failed: %v", err)
}
if _, err := os.Stat(jpgPath); os.IsNotExist(err) {
t.Error("JPG output not created")
}
})
t.Run("GenerateVideoPreview_Directory", func(t *testing.T) {
dirPath := filepath.Join(tmpDir, "frames_dir")
err := GenerateVideoPreview(videoPath, dirPath, 160, 120)
if err != nil {
t.Errorf("GenerateVideoPreview (dir) failed: %v", err)
}
files, err := os.ReadDir(dirPath)
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(files) == 0 {
t.Error("No frames generated in directory")
}
})
t.Run("GenerateAudioPreview", func(t *testing.T) { t.Run("GenerateAudioPreview", func(t *testing.T) {
err := GenerateAudioPreview(videoPath, oggPath) err := GenerateAudioPreview(videoPath, oggPath)
if err != nil { if err != nil {
t.Errorf("GenerateAudioPreview failed: %v", err) t.Errorf("GenerateAudioPreview (ogg) failed: %v", err)
} }
if _, err := os.Stat(oggPath); os.IsNotExist(err) { if _, err := os.Stat(oggPath); os.IsNotExist(err) {
t.Error("Ogg output not created") 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")
}
}) })
} }