You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

116 lines
2.9 KiB

package video
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"os/exec"
"strings"
"syscall"
"time"
)
func Stream(w http.ResponseWriter, r *http.Request, absPath, ffmpegPath string) error {
if ffmpegPath == "" {
ffmpegPath = defaultFFmpegPath
}
cmd := exec.Command(
ffmpegPath,
// Use all CPU cores for decoding the H.265 input.
// This is a global ffmpeg option and must come before -i.
// "0" means auto-detect (= number of cores available to the process).
"-threads", "0",
"-loglevel", "error",
"-i", absPath,
// Transcode H.265 → H.264 for universal browser support.
// Firefox and Chrome on Linux/Windows cannot decode H.265 at all;
// stream-copying the HEVC bitstream into MP4 produces a file the
// <video> element silently refuses to play.
//
// ultrafast minimises CPU time on slow hardware (Intel Atom).
// crf 28 trades a little quality for faster encode and smaller output.
// yuv420p converts from the camera's yuvj420p (full-range JPEG colour
// space) to the standard limited-range H.264 expected by browsers;
// without this the picture looks washed out.
// -an drops the audio track; security camera clips rarely carry useful
// audio and stripping it halves the work for the encoder.
// libx264 picks its own thread count automatically (matching -threads 0
// above), so no separate -x264-params threads= is needed.
"-c:v", "libx264",
"-preset", "ultrafast",
"-crf", "28",
"-pix_fmt", "yuv420p",
"-an",
"-movflags", "frag_keyframe+empty_moov",
"-f", "mp4",
"pipe:1",
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("open ffmpeg stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start ffmpeg: %w%s", err, stderrSuffix(stderr.String()))
}
processDone := make(chan struct{})
go func() {
select {
case <-r.Context().Done():
if cmd.Process == nil {
return
}
_ = cmd.Process.Signal(syscall.SIGTERM)
timer := time.NewTimer(250 * time.Millisecond)
defer timer.Stop()
select {
case <-processDone:
case <-timer.C:
_ = cmd.Process.Kill()
}
case <-processDone:
}
}()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
_, copyErr := io.Copy(w, stdout)
waitErr := cmd.Wait()
close(processDone)
if r.Context().Err() != nil {
return nil
}
if copyErr != nil {
log.Printf("ffmpeg remux copy failed for %s: %v", absPath, copyErr)
return nil
}
if waitErr != nil {
log.Printf("ffmpeg remux failed for %s: %v%s", absPath, waitErr, stderrSuffix(stderr.String()))
}
return nil
}
func stderrSuffix(stderr string) string {
trimmed := strings.TrimSpace(stderr)
if trimmed == "" {
return ""
}
return ": " + trimmed
}