155 lines
4.4 KiB
Go
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 != ""
|
|
}
|