vision/preview.go

151 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vision
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GenerateImagePreview 生成图片预览
// 支持缩放并裁剪以填充指定尺寸 (Fill 模式)
func GenerateImagePreview(srcPath, outPath string, width, height int) error {
// 使用统一的 Load() 加载,内部已处理好 HEIC/sips/FFmpeg 的复杂格式兼容
c, err := Load(srcPath)
if err != nil {
return err
}
c.Fill(width, height)
if strings.HasSuffix(strings.ToLower(outPath), ".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)
if err := cmd.Run(); err == nil {
return nil
}
}
}
return Save(c, outPath)
}
// GenerateVideoPreview 生成视频预览
// 根据 outPath 后缀判断输出格式:
// - .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()
if err != nil {
return err
}
duration, err := getVideoDuration(videoPath)
if err != nil {
return err
}
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))
if frameCount < 3 {
frameCount = 3
} else if frameCount > 8 {
frameCount = 8
}
times := make([]float64, frameCount)
for i := 0; i < frameCount; i++ {
times[i] = duration * (0.10 + 0.80*(float64(i)/float64(frameCount-1)))
}
// 2a. 动画模式 (.webp, .gif)
if ext == ".webp" || ext == ".gif" {
tmpDir, _ := os.MkdirTemp("", "frames")
defer os.RemoveAll(tmpDir)
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()
}
// 2b. 文件夹多图模式
if err := os.MkdirAll(outPath, 0755); err != nil {
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)
if err := cmd.Run(); err != nil {
return err
}
}
return nil
}
// GenerateAudioPreview 提取 3 分钟内的音频用于预览或语音转写
// 格式: Ogg Opus, 16kHz, 单声道, 12kbps (极致压缩,保留人声特征)
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)
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(strings.TrimSpace(string(out)), "%f", &duration)
return duration, err
}