spark-server/internal/infrastructure/media/video.go
2026-09-29 10:22:28 +08:00

155 lines
4.4 KiB
Go

package media
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
pkgmedia "spark-server/internal/pkg/media"
)
// VideoResult is compressed video output (H.264 MP4).
type VideoResult struct {
Data []byte
Width int
Height int
MIME string
}
// CompressVideoBytes writes src to a temp file, runs ffmpeg scale+libx264, returns MP4 bytes.
// Requires ffmpeg on PATH (or Options.FFmpegPath). Returns error if ffmpeg is missing.
func CompressVideoBytes(ctx context.Context, src []byte, opt *Options) (*VideoResult, error) {
if opt == nil {
return nil, fmt.Errorf("media: nil options")
}
opt.normalize()
if len(src) == 0 {
return nil, fmt.Errorf("media: empty video")
}
if int64(len(src)) > opt.Limits.VideoMaxBytes*2 {
// refuse absurd remote downloads before temp write
return nil, fmt.Errorf("media: video source too large")
}
dir, err := os.MkdirTemp("", "spark-media-*")
if err != nil {
return nil, err
}
defer func() { _ = os.RemoveAll(dir) }()
inPath := filepath.Join(dir, "in.bin")
outPath := filepath.Join(dir, "out.mp4")
if err := os.WriteFile(inPath, src, 0o600); err != nil {
return nil, err
}
w, h, err := probeVideoSize(ctx, opt.FFmpegPath, inPath)
if err != nil {
// ffprobe may be absent; fall through to always re-encode
w, h = 0, 0
}
if w > 0 && h > 0 && !pkgmedia.NeedsResize(w, h, opt.Limits.VideoMaxEdge) && int64(len(src)) <= opt.Limits.VideoMaxBytes {
return &VideoResult{Data: src, Width: w, Height: h, MIME: "video/mp4"}, nil
}
if err := runFFmpegCompress(ctx, opt, inPath, outPath); err != nil {
return nil, err
}
out, err := os.ReadFile(outPath)
if err != nil {
return nil, err
}
if int64(len(out)) > opt.Limits.VideoMaxBytes {
return nil, fmt.Errorf("media: compressed video still too large (%d > %d)", len(out), opt.Limits.VideoMaxBytes)
}
ow, oh, _ := probeVideoSize(ctx, opt.FFmpegPath, outPath)
if ow <= 0 {
ow, oh = w, h
}
return &VideoResult{Data: out, Width: ow, Height: oh, MIME: "video/mp4"}, nil
}
func runFFmpegCompress(ctx context.Context, opt *Options, inPath, outPath string) error {
if ctx == nil {
ctx = context.Background()
}
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
}
maxEdge := opt.Limits.VideoMaxEdge
// Longest side ≤ maxEdge; force_original_aspect_ratio keeps aspect, even dims for H.264.
vf := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2", maxEdge, maxEdge)
args := []string{
"-y", "-hide_banner", "-loglevel", "error",
"-i", inPath,
"-vf", vf,
"-c:v", "libx264", "-crf", strconv.Itoa(opt.VideoCRF), "-preset", "veryfast",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
outPath,
}
cmd := exec.CommandContext(ctx, opt.FFmpegPath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
if isExecNotFound(err) {
return fmt.Errorf("media: ffmpeg not found (install ffmpeg for bot video compress): %w", err)
}
return fmt.Errorf("media: ffmpeg: %s", msg)
}
return nil
}
func probeVideoSize(ctx context.Context, ffmpegPath, path string) (w, h int, err error) {
// Prefer ffprobe beside ffmpeg; fall back to ffmpeg -i parse.
probe := strings.Replace(ffmpegPath, "ffmpeg", "ffprobe", 1)
if probe == ffmpegPath {
probe = "ffprobe"
}
cmd := exec.CommandContext(ctx, probe,
"-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path,
)
out, err := cmd.Output()
if err != nil {
return 0, 0, err
}
parts := strings.Split(strings.TrimSpace(string(out)), "x")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("media: bad ffprobe output")
}
w, _ = strconv.Atoi(parts[0])
h, _ = strconv.Atoi(parts[1])
if w <= 0 || h <= 0 {
return 0, 0, fmt.Errorf("media: invalid video size")
}
return w, h, nil
}
func isExecNotFound(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "executable file not found") ||
strings.Contains(err.Error(), "no such file") ||
os.IsNotExist(err)
}
// FFmpegAvailable reports whether the configured ffmpeg binary can be executed.
func FFmpegAvailable(opt *Options) bool {
if opt == nil {
return false
}
opt.normalize()
path, err := exec.LookPath(opt.FFmpegPath)
return err == nil && path != ""
}