mediago-dev--mediago
217 行
5.5 KiB
Go
217 行
5.5 KiB
Go
package service
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"caorushizi.cn/mediago/internal/logger"
|
|
)
|
|
|
|
// ProgressCallback is called with the conversion progress (0-100).
|
|
type ProgressCallback func(progress int)
|
|
|
|
// Converter manages ffmpeg conversion processes.
|
|
type Converter struct {
|
|
ffmpegBin string
|
|
mu sync.Mutex
|
|
cancels map[int64]context.CancelFunc
|
|
}
|
|
|
|
// NewConverter creates a Converter with the given ffmpeg binary path.
|
|
func NewConverter(ffmpegBin string) *Converter {
|
|
return &Converter{
|
|
ffmpegBin: ffmpegBin,
|
|
cancels: make(map[int64]context.CancelFunc),
|
|
}
|
|
}
|
|
|
|
// Start begins an ffmpeg conversion. It blocks until the conversion completes or is cancelled.
|
|
// The onProgress callback is called periodically with the current progress percentage.
|
|
func (c *Converter) Start(id int64, inputPath, outputFormat, quality string, onProgress ProgressCallback) (outputPath string, err error) {
|
|
if c.ffmpegBin == "" {
|
|
return "", fmt.Errorf("ffmpeg binary path not configured")
|
|
}
|
|
|
|
// Generate output path: same dir, new extension
|
|
dir := filepath.Dir(inputPath)
|
|
base := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
|
|
outputPath = filepath.Join(dir, base+"."+outputFormat)
|
|
|
|
// Get input duration for progress calculation
|
|
duration := c.probeDuration(inputPath)
|
|
|
|
// Build ffmpeg args
|
|
args := buildFFmpegArgs(inputPath, outputPath, outputFormat, quality)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.mu.Lock()
|
|
c.cancels[id] = cancel
|
|
c.mu.Unlock()
|
|
|
|
defer func() {
|
|
c.mu.Lock()
|
|
delete(c.cancels, id)
|
|
c.mu.Unlock()
|
|
cancel()
|
|
}()
|
|
|
|
cmd := exec.CommandContext(ctx, c.ffmpegBin, args...)
|
|
|
|
// ffmpeg writes progress to stderr
|
|
stderr, pipeErr := cmd.StderrPipe()
|
|
if pipeErr != nil {
|
|
return "", fmt.Errorf("failed to get stderr pipe: %w", pipeErr)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return "", fmt.Errorf("failed to start ffmpeg: %w", err)
|
|
}
|
|
|
|
// Parse progress from stderr
|
|
if duration > 0 && onProgress != nil {
|
|
scanner := bufio.NewScanner(stderr)
|
|
scanner.Split(scanFFmpegOutput)
|
|
timeRegex := regexp.MustCompile(`time=(\d+):(\d+):(\d+)\.(\d+)`)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if matches := timeRegex.FindStringSubmatch(line); matches != nil {
|
|
h, _ := strconv.Atoi(matches[1])
|
|
m, _ := strconv.Atoi(matches[2])
|
|
s, _ := strconv.Atoi(matches[3])
|
|
elapsed := time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second
|
|
pct := int(float64(elapsed) / float64(duration) * 100)
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
onProgress(pct)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := cmd.Wait(); err != nil {
|
|
if ctx.Err() == context.Canceled {
|
|
return "", fmt.Errorf("conversion cancelled")
|
|
}
|
|
return "", fmt.Errorf("ffmpeg failed: %w", err)
|
|
}
|
|
|
|
return outputPath, nil
|
|
}
|
|
|
|
// Stop cancels a running conversion.
|
|
func (c *Converter) Stop(id int64) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if cancel, ok := c.cancels[id]; ok {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// probeDuration uses ffprobe to get the input file duration.
|
|
func (c *Converter) probeDuration(inputPath string) time.Duration {
|
|
ffprobe := strings.TrimSuffix(c.ffmpegBin, filepath.Ext(c.ffmpegBin))
|
|
if !strings.HasSuffix(ffprobe, "ffprobe") {
|
|
// Derive ffprobe path from ffmpeg path (same directory)
|
|
ffprobe = filepath.Join(filepath.Dir(c.ffmpegBin), "ffprobe")
|
|
}
|
|
|
|
cmd := exec.Command(ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", inputPath)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
logger.Warnf("ffprobe failed for %s: %v", inputPath, err)
|
|
return 0
|
|
}
|
|
s := strings.TrimSpace(string(out))
|
|
seconds, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return time.Duration(seconds * float64(time.Second))
|
|
}
|
|
|
|
// scanFFmpegOutput is a split function for bufio.Scanner that splits on \r or \n.
|
|
// ffmpeg uses \r for progress line overwrites.
|
|
func scanFFmpegOutput(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
|
if atEOF && len(data) == 0 {
|
|
return 0, nil, nil
|
|
}
|
|
for i := 0; i < len(data); i++ {
|
|
if data[i] == '\r' || data[i] == '\n' {
|
|
return i + 1, data[:i], nil
|
|
}
|
|
}
|
|
if atEOF {
|
|
return len(data), data, nil
|
|
}
|
|
return 0, nil, nil
|
|
}
|
|
|
|
// buildFFmpegArgs builds the ffmpeg command-line arguments for a given format and quality.
|
|
func buildFFmpegArgs(input, output, format, quality string) []string {
|
|
args := []string{"-y", "-i", input}
|
|
|
|
switch format {
|
|
case "mp4", "mkv":
|
|
crf := qualityCRF(quality)
|
|
args = append(args, "-c:v", "libx264", "-crf", strconv.Itoa(crf), "-c:a", "aac", "-b:a", "192k")
|
|
case "webm":
|
|
crf := qualityCRF(quality)
|
|
args = append(args, "-c:v", "libvpx-vp9", "-crf", strconv.Itoa(crf), "-b:v", "0", "-c:a", "libopus")
|
|
case "mp3":
|
|
args = append(args, "-vn", "-acodec", "libmp3lame", "-b:a", audioQualityMP3(quality))
|
|
case "aac":
|
|
args = append(args, "-vn", "-acodec", "aac", "-b:a", audioQualityAAC(quality))
|
|
case "flac":
|
|
args = append(args, "-vn", "-acodec", "flac")
|
|
case "wav":
|
|
args = append(args, "-vn", "-acodec", "pcm_s16le")
|
|
default:
|
|
// Fallback: copy streams
|
|
args = append(args, "-c", "copy")
|
|
}
|
|
|
|
args = append(args, output)
|
|
return args
|
|
}
|
|
|
|
func qualityCRF(quality string) int {
|
|
switch quality {
|
|
case "high":
|
|
return 18
|
|
case "low":
|
|
return 28
|
|
default:
|
|
return 23
|
|
}
|
|
}
|
|
|
|
func audioQualityMP3(quality string) string {
|
|
switch quality {
|
|
case "high":
|
|
return "320k"
|
|
case "low":
|
|
return "128k"
|
|
default:
|
|
return "192k"
|
|
}
|
|
}
|
|
|
|
func audioQualityAAC(quality string) string {
|
|
switch quality {
|
|
case "high":
|
|
return "256k"
|
|
case "low":
|
|
return "96k"
|
|
default:
|
|
return "128k"
|
|
}
|
|
}
|