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) { opt = 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 { opt = opt.normalize() path, err := exec.LookPath(opt.FFmpegPath) return err == nil && path != "" }