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
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
|
|
}
|
|
|