probe.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. package transcoder
  2. /*
  3. Probe.go
  4. Reports what codecs a media file actually contains.
  5. A container extension says nothing about decodability: an .mp4 may hold
  6. HEVC, AV1 or 10-bit H.264, none of which most browsers can decode. Choosing
  7. direct playback from the extension alone is what makes such a file fail with
  8. a bare decode error instead of being transcoded. This lets the caller ask
  9. first.
  10. */
  11. import (
  12. "context"
  13. "encoding/json"
  14. "errors"
  15. "fmt"
  16. "os/exec"
  17. "strings"
  18. "time"
  19. )
  20. const codecProbeTimeout = 30 * time.Second
  21. // MediaCodecInfo describes the primary video and audio streams of a file.
  22. type MediaCodecInfo struct {
  23. VideoCodec string `json:"videoCodec"` // h264, hevc, vp9, av1, …
  24. VideoProfile string `json:"videoProfile"` // "High", "Main 10", …
  25. PixelFormat string `json:"pixelFormat"` // yuv420p, yuv420p10le, …
  26. AudioCodec string `json:"audioCodec"` // aac, opus, ac3, …
  27. Width int `json:"width"`
  28. Height int `json:"height"`
  29. // DirectPlay is the server's verdict on whether a mainstream browser can
  30. // decode this without transcoding. The client still has the final say via
  31. // canPlayType, but this catches the common cases up front.
  32. DirectPlay bool `json:"directPlay"`
  33. // Reason explains a false verdict, for logs and diagnostics.
  34. Reason string `json:"reason,omitempty"`
  35. }
  36. // browserVideoCodecs are the video codecs a current mainstream browser can be
  37. // expected to decode. HEVC is deliberately absent: Safari plays it, but Firefox
  38. // has no support at all and Chrome's is platform-dependent, so treating it as
  39. // playable is what produced decode failures.
  40. var browserVideoCodecs = map[string]bool{
  41. "h264": true,
  42. "vp8": true,
  43. "vp9": true,
  44. "av1": true,
  45. }
  46. // browserAudioCodecs are the audio codecs safe to hand a browser directly.
  47. var browserAudioCodecs = map[string]bool{
  48. "aac": true, "mp3": true, "opus": true, "vorbis": true, "flac": true,
  49. "": true, // a file with no audio track is fine
  50. }
  51. // tenBitPixelFormats are the high-depth formats browsers generally refuse for
  52. // H.264. Even where the codec is supported, 10-bit H.264 (High 10) is not.
  53. func isHighBitDepth(pixFmt string) bool {
  54. f := strings.ToLower(pixFmt)
  55. return strings.Contains(f, "10le") || strings.Contains(f, "10be") ||
  56. strings.Contains(f, "12le") || strings.Contains(f, "12be") ||
  57. strings.Contains(f, "p010") || strings.Contains(f, "16le")
  58. }
  59. // ProbeMediaCodecs inspects a file and reports its primary streams.
  60. func ProbeMediaCodecs(inputFile string) (*MediaCodecInfo, error) {
  61. ctx, cancel := context.WithTimeout(context.Background(), codecProbeTimeout)
  62. defer cancel()
  63. cmd := exec.CommandContext(ctx, "ffprobe",
  64. "-v", "quiet",
  65. "-print_format", "json",
  66. "-show_streams",
  67. inputFile,
  68. )
  69. output, err := cmd.Output()
  70. if err != nil {
  71. if ctx.Err() == context.DeadlineExceeded {
  72. return nil, errors.New("codec probe timed out")
  73. }
  74. return nil, fmt.Errorf("ffprobe failed: %w", err)
  75. }
  76. return parseMediaCodecs(output)
  77. }
  78. // parseMediaCodecs maps ffprobe output onto a playability verdict. Separated
  79. // from the exec call so the rules can be unit-tested without ffmpeg present.
  80. func parseMediaCodecs(probeJSON []byte) (*MediaCodecInfo, error) {
  81. var parsed struct {
  82. Streams []struct {
  83. CodecName string `json:"codec_name"`
  84. CodecType string `json:"codec_type"`
  85. Profile string `json:"profile"`
  86. PixFmt string `json:"pix_fmt"`
  87. Width int `json:"width"`
  88. Height int `json:"height"`
  89. // An attached cover image is a video stream by codec_type; its
  90. // disposition is what tells it apart from the real picture.
  91. Disposition map[string]int `json:"disposition"`
  92. } `json:"streams"`
  93. }
  94. if err := json.Unmarshal(probeJSON, &parsed); err != nil {
  95. return nil, fmt.Errorf("could not parse ffprobe output: %w", err)
  96. }
  97. info := &MediaCodecInfo{}
  98. haveVideo := false
  99. haveAudio := false
  100. for i := range parsed.Streams {
  101. s := &parsed.Streams[i]
  102. switch strings.ToLower(s.CodecType) {
  103. case "video":
  104. // Skip embedded cover art, which would otherwise be mistaken for
  105. // the video track and reported as an mjpeg still.
  106. if s.Disposition["attached_pic"] == 1 {
  107. continue
  108. }
  109. if haveVideo {
  110. continue
  111. }
  112. haveVideo = true
  113. info.VideoCodec = strings.ToLower(s.CodecName)
  114. info.VideoProfile = s.Profile
  115. info.PixelFormat = s.PixFmt
  116. info.Width = s.Width
  117. info.Height = s.Height
  118. case "audio":
  119. if haveAudio {
  120. continue
  121. }
  122. haveAudio = true
  123. info.AudioCodec = strings.ToLower(s.CodecName)
  124. }
  125. }
  126. if !haveVideo {
  127. info.DirectPlay = false
  128. info.Reason = "no video stream"
  129. return info, nil
  130. }
  131. switch {
  132. case !browserVideoCodecs[info.VideoCodec]:
  133. info.Reason = "video codec " + info.VideoCodec + " is not broadly supported by browsers"
  134. case info.VideoCodec == "h264" && isHighBitDepth(info.PixelFormat):
  135. info.Reason = "10-bit H.264 is not decodable in most browsers"
  136. case !browserAudioCodecs[info.AudioCodec]:
  137. info.Reason = "audio codec " + info.AudioCodec + " is not broadly supported by browsers"
  138. default:
  139. info.DirectPlay = true
  140. }
  141. return info, nil
  142. }