2026-05-13 23:29:43 +08:00
|
|
|
|
package vision
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"os/exec"
|
|
|
|
|
|
"path/filepath"
|
2026-05-17 11:53:26 +08:00
|
|
|
|
"strings"
|
2026-05-13 23:29:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-17 11:53:26 +08:00
|
|
|
|
// GenerateImagePreview 生成图片预览
|
2026-05-13 23:29:43 +08:00
|
|
|
|
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
|
2026-05-17 12:51:25 +08:00
|
|
|
|
// 根据 outPath 后缀自动转换格式 (.webp, .jpg, .png 等)
|
2026-05-13 23:29:43 +08:00
|
|
|
|
func GenerateImagePreview(srcPath, outPath string, width, height int) error {
|
2026-05-17 11:53:26 +08:00
|
|
|
|
// 使用统一的 Load() 加载,内部已处理好 HEIC/sips/FFmpeg 的复杂格式兼容
|
2026-05-13 23:29:43 +08:00
|
|
|
|
c, err := Load(srcPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
c.Fill(width, height)
|
2026-05-17 11:53:26 +08:00
|
|
|
|
|
2026-05-17 12:51:25 +08:00
|
|
|
|
ext := strings.ToLower(filepath.Ext(outPath))
|
|
|
|
|
|
if ext == ".webp" {
|
|
|
|
|
|
// 借用 FFmpeg 将生成的画布转为高质量 WebP (比标准库或第三方库压缩更好)
|
2026-05-17 11:53:26 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-17 12:51:25 +08:00
|
|
|
|
|
2026-05-17 11:53:26 +08:00
|
|
|
|
v, err := NewVideo()
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
cmd := exec.Command(v.FFmpegPath, "-i", tmpFile, "-c:v", "libwebp", "-quality", "80", "-y", outPath)
|
|
|
|
|
|
if err := cmd.Run(); err == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 23:29:43 +08:00
|
|
|
|
return Save(c, outPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:46:45 +08:00
|
|
|
|
// GenerateVideoPreview 生成视频预览
|
|
|
|
|
|
// 根据 outPath 后缀判断输出格式:
|
|
|
|
|
|
// - .webp | .gif: 生成动态动画 (默认每 30 秒采样一帧,可通过 frameInterval 调整)
|
|
|
|
|
|
// - .jpg | .jpeg | .png: 生成单张预览图 (取视频中间帧)
|
|
|
|
|
|
// - 其他: 将 outPath 视为文件夹,在其中生成多张静态 .webp 图像
|
|
|
|
|
|
// frameInterval: 每隔多少秒采样一帧,默认 30。
|
|
|
|
|
|
func GenerateVideoPreview(videoPath, outPath string, width, height int, frameInterval ...int) error {
|
2026-05-13 23:29:43 +08:00
|
|
|
|
v, err := NewVideo()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
duration, err := getVideoDuration(videoPath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:46:45 +08:00
|
|
|
|
ext := strings.ToLower(filepath.Ext(outPath))
|
|
|
|
|
|
vf := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", width, height, width, height)
|
|
|
|
|
|
|
|
|
|
|
|
// 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))
|
2026-05-17 11:53:26 +08:00
|
|
|
|
if frameCount < 3 {
|
|
|
|
|
|
frameCount = 3
|
|
|
|
|
|
} else if frameCount > 8 {
|
|
|
|
|
|
frameCount = 8
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
times := make([]float64, frameCount)
|
2026-05-17 12:46:45 +08:00
|
|
|
|
for i := 0; i < frameCount; i++ {
|
|
|
|
|
|
times[i] = duration * (0.10 + 0.80*(float64(i)/float64(frameCount-1)))
|
2026-05-17 11:53:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:46:45 +08:00
|
|
|
|
// 2a. 动画模式 (.webp, .gif)
|
|
|
|
|
|
if ext == ".webp" || ext == ".gif" {
|
|
|
|
|
|
tmpDir, _ := os.MkdirTemp("", "frames")
|
|
|
|
|
|
defer os.RemoveAll(tmpDir)
|
2026-05-13 23:29:43 +08:00
|
|
|
|
|
2026-05-17 12:46:45 +08:00
|
|
|
|
for i, t := range times {
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
2026-05-13 23:29:43 +08:00
|
|
|
|
|
2026-05-17 12:46:45 +08:00
|
|
|
|
// 2b. 文件夹多图模式
|
|
|
|
|
|
if err := os.MkdirAll(outPath, 0755); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-05-13 23:29:43 +08:00
|
|
|
|
for i, t := range times {
|
2026-05-18 19:51:37 +08:00
|
|
|
|
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)
|
2026-05-13 23:29:43 +08:00
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:46:45 +08:00
|
|
|
|
return nil
|
2026-05-13 23:29:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 12:51:25 +08:00
|
|
|
|
// GenerateAudioPreview 提取音频用于预览或语音转写
|
|
|
|
|
|
// 支持根据 outPath 后缀输出格式:
|
|
|
|
|
|
// - .ogg: 使用 libopus (16kHz, 单声道, 12kbps), 极致压缩且保留人声特征,适合转写
|
|
|
|
|
|
// - .wav: 标准 PCM (16kHz, 单声道), 无损但体积较大,部分转写引擎强制要求
|
|
|
|
|
|
// - 其他: 默认使用 libopus 转为 ogg
|
2026-05-13 23:29:43 +08:00
|
|
|
|
func GenerateAudioPreview(mediaPath, outPath string) error {
|
|
|
|
|
|
v, err := NewVideo()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-05-17 12:51:25 +08:00
|
|
|
|
|
|
|
|
|
|
ext := strings.ToLower(filepath.Ext(outPath))
|
|
|
|
|
|
// 通用参数: 禁用视频, 16kHz 采样率 (STT 标准), 单声道
|
|
|
|
|
|
args := []string{"-i", mediaPath, "-vn", "-ar", "16000", "-ac", "1"}
|
|
|
|
|
|
|
|
|
|
|
|
if ext == ".wav" {
|
2026-05-18 19:51:37 +08:00
|
|
|
|
// WAV 格式,保留 PCM,最长 180 秒避免 LLM OOM
|
|
|
|
|
|
args = append(args, "-t", "180", "-y", outPath)
|
2026-05-17 12:51:25 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 默认或 .ogg 使用 libopus 极致压缩,最长 180 秒
|
|
|
|
|
|
args = append(args, "-c:a", "libopus", "-b:a", "12k", "-t", "180", "-y", outPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cmd := exec.Command(v.FFmpegPath, args...)
|
2026-05-13 23:29:43 +08:00
|
|
|
|
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
|
2026-05-17 11:53:26 +08:00
|
|
|
|
_, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%f", &duration)
|
2026-05-13 23:29:43 +08:00
|
|
|
|
return duration, err
|
|
|
|
|
|
}
|
2026-05-18 19:51:37 +08:00
|
|
|
|
|