subtitles.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. package transcoder
  2. /*
  3. Subtitles.go
  4. Discovery and extraction of subtitle tracks and font attachments that are
  5. muxed inside a container (typically MKV).
  6. Text tracks are converted to SubRip on the way out so the player has a single
  7. format to parse. Note that converting ASS/SSA discards styling, positioning
  8. and karaoke timing — only the dialogue text and its timings survive. Font
  9. attachments are exposed separately so the player can still render that text in
  10. the typeface the release intended.
  11. */
  12. import (
  13. "bytes"
  14. "context"
  15. "encoding/json"
  16. "errors"
  17. "fmt"
  18. "os"
  19. "os/exec"
  20. "path/filepath"
  21. "strings"
  22. "time"
  23. )
  24. const (
  25. // Probing and extraction are bounded so a damaged container cannot hang a
  26. // request forever.
  27. subtitleProbeTimeout = 30 * time.Second
  28. subtitleExtractTimeout = 5 * time.Minute
  29. // Guards against a malicious or broken file advertising an enormous track.
  30. maxSubtitleBytes = 32 << 20 // 32 MiB
  31. maxFontBytes = 32 << 20 // 32 MiB
  32. )
  33. // EmbeddedSubtitle describes one subtitle track found inside a container.
  34. type EmbeddedSubtitle struct {
  35. Index int `json:"index"` // absolute stream index, used for -map
  36. Codec string `json:"codec"` // subrip, ass, hdmv_pgs_subtitle, …
  37. Language string `json:"language"` // ISO code from the container, may be empty
  38. Title string `json:"title"` // human label from the container, may be empty
  39. Default bool `json:"default"`
  40. Forced bool `json:"forced"`
  41. Textual bool `json:"textual"` // false for bitmap tracks, which cannot be converted
  42. }
  43. // EmbeddedFont describes one font attachment found inside a container.
  44. type EmbeddedFont struct {
  45. Index int `json:"index"` // ordinal among attachment streams, used for -dump_attachment
  46. Filename string `json:"filename"` // original name, e.g. "FOT-Pearl Std L.ttf"
  47. Mimetype string `json:"mimetype"` // font/ttf, application/x-truetype-font, …
  48. Family string `json:"family"` // internal family name from the font's name table
  49. }
  50. // MediaSubtitleInfo is the full picture of what a container carries.
  51. type MediaSubtitleInfo struct {
  52. Subtitles []EmbeddedSubtitle `json:"subtitles"`
  53. Fonts []EmbeddedFont `json:"fonts"`
  54. }
  55. // textualSubtitleCodecs are the codecs ffmpeg can turn into SubRip. Everything
  56. // else (PGS, VobSub, …) is a bitmap format that would need OCR.
  57. var textualSubtitleCodecs = map[string]bool{
  58. "subrip": true,
  59. "srt": true,
  60. "ass": true,
  61. "ssa": true,
  62. "webvtt": true,
  63. "mov_text": true,
  64. "text": true,
  65. "microdvd": true,
  66. }
  67. // fontMimeHints are the attachment mimetypes that identify a font.
  68. var fontMimeHints = []string{"font", "truetype", "opentype", "sfnt"}
  69. // fontFileExtensions are the fallback signal when a container omits a mimetype.
  70. var fontFileExtensions = map[string]bool{
  71. ".ttf": true, ".otf": true, ".ttc": true, ".woff": true, ".woff2": true,
  72. }
  73. // IsTextualSubtitleCodec reports whether a subtitle codec carries text that can
  74. // be converted to SubRip, as opposed to a bitmap format needing OCR.
  75. func IsTextualSubtitleCodec(codec string) bool {
  76. return textualSubtitleCodecs[strings.ToLower(strings.TrimSpace(codec))]
  77. }
  78. // IsFontAttachment reports whether an attachment stream looks like a font,
  79. // judged by mimetype first and filename extension second.
  80. func IsFontAttachment(mimetype string, filename string) bool {
  81. lowerMime := strings.ToLower(mimetype)
  82. for _, hint := range fontMimeHints {
  83. if strings.Contains(lowerMime, hint) {
  84. return true
  85. }
  86. }
  87. return fontFileExtensions[strings.ToLower(filepath.Ext(filename))]
  88. }
  89. // ffprobeStream is the subset of ffprobe's stream output we care about.
  90. type ffprobeStream struct {
  91. Index int `json:"index"`
  92. CodecName string `json:"codec_name"`
  93. CodecType string `json:"codec_type"`
  94. Tags map[string]string `json:"tags"`
  95. Disposition map[string]int `json:"disposition"`
  96. }
  97. // tag reads a container tag case-insensitively, since muxers disagree on case.
  98. func (s *ffprobeStream) tag(name string) string {
  99. for k, v := range s.Tags {
  100. if strings.EqualFold(k, name) {
  101. return v
  102. }
  103. }
  104. return ""
  105. }
  106. // ProbeEmbeddedTracksWithFontNames lists embedded tracks and additionally reads
  107. // each font attachment's internal family name.
  108. //
  109. // ASS styles reference fonts by that internal name, so the player cannot match
  110. // a style to an attachment without it. Every attachment is dumped in a single
  111. // ffmpeg call, which costs about the same as dumping one (measured at ~65ms for
  112. // 14 fonts) because attachments are written while the input is opened.
  113. //
  114. // Name resolution is best effort: a font that cannot be parsed simply keeps an
  115. // empty Family and the caller falls back to the filename.
  116. func ProbeEmbeddedTracksWithFontNames(inputFile string, workDir string) (*MediaSubtitleInfo, error) {
  117. info, err := ProbeEmbeddedTracks(inputFile)
  118. if err != nil {
  119. return nil, err
  120. }
  121. if len(info.Fonts) == 0 {
  122. return info, nil
  123. }
  124. fonts, err := extractAllFontAttachments(inputFile, info.Fonts, workDir)
  125. if err != nil {
  126. return info, nil // listing is still useful without family names
  127. }
  128. for i := range info.Fonts {
  129. data, ok := fonts[info.Fonts[i].Index]
  130. if !ok {
  131. continue
  132. }
  133. if family, err := FontFamilyName(data); err == nil {
  134. info.Fonts[i].Family = family
  135. }
  136. }
  137. return info, nil
  138. }
  139. // extractAllFontAttachments dumps every listed attachment in one ffmpeg pass and
  140. // returns the bytes keyed by attachment ordinal.
  141. func extractAllFontAttachments(inputFile string, fonts []EmbeddedFont, workDir string) (map[int][]byte, error) {
  142. if strings.TrimSpace(workDir) == "" {
  143. workDir = os.TempDir()
  144. }
  145. scratchDir, err := os.MkdirTemp(workDir, "subfonts-")
  146. if err != nil {
  147. return nil, fmt.Errorf("scratch directory unavailable: %w", err)
  148. }
  149. defer os.RemoveAll(scratchDir)
  150. args := []string{"-y", "-v", "error"}
  151. paths := map[int]string{}
  152. for _, font := range fonts {
  153. path := filepath.Join(scratchDir, fmt.Sprintf("%d.bin", font.Index))
  154. paths[font.Index] = path
  155. args = append(args, fmt.Sprintf("-dump_attachment:t:%d", font.Index), path)
  156. }
  157. // As in ExtractFontAttachment, no output is given on purpose: the dump
  158. // completes while the input is opened, and adding one would make ffmpeg
  159. // process the entire video first.
  160. args = append(args, "-i", inputFile)
  161. ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
  162. defer cancel()
  163. exec.CommandContext(ctx, "ffmpeg", args...).Run() // exit status is not the signal
  164. out := map[int][]byte{}
  165. for index, path := range paths {
  166. data, err := os.ReadFile(path)
  167. if err == nil && len(data) > 0 && len(data) <= maxFontBytes {
  168. out[index] = data
  169. }
  170. }
  171. if len(out) == 0 {
  172. return nil, errors.New("no attachments could be read")
  173. }
  174. return out, nil
  175. }
  176. // ExtractRawSubtitleTrack copies a subtitle track out in its native format,
  177. // preserving ASS styling, positioning and layering.
  178. //
  179. // This is a stream copy rather than a re-encode, so it is dramatically cheaper
  180. // than converting to SubRip — measured at 0.24s versus 5.3s on a 614MB file.
  181. func ExtractRawSubtitleTrack(inputFile string, streamIndex int, format string) ([]byte, error) {
  182. if streamIndex < 0 {
  183. return nil, errors.New("invalid subtitle stream index")
  184. }
  185. if format != "ass" && format != "srt" && format != "webvtt" {
  186. return nil, errors.New("unsupported raw subtitle format")
  187. }
  188. ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
  189. defer cancel()
  190. cmd := exec.CommandContext(ctx, "ffmpeg",
  191. "-v", "error",
  192. "-i", inputFile,
  193. "-map", fmt.Sprintf("0:%d", streamIndex),
  194. "-vn", "-an",
  195. "-c:s", "copy",
  196. "-f", format,
  197. "pipe:1",
  198. )
  199. var stdout, stderr bytes.Buffer
  200. cmd.Stdout = &stdout
  201. cmd.Stderr = &stderr
  202. if err := cmd.Run(); err != nil {
  203. if ctx.Err() == context.DeadlineExceeded {
  204. return nil, errors.New("subtitle extraction timed out")
  205. }
  206. return nil, fmt.Errorf("ffmpeg failed: %w (%s)", err, lastLines(stderr.String(), 2))
  207. }
  208. if stdout.Len() == 0 {
  209. return nil, errors.New("subtitle track is empty")
  210. }
  211. if stdout.Len() > maxSubtitleBytes {
  212. return nil, errors.New("subtitle track is unexpectedly large")
  213. }
  214. return stdout.Bytes(), nil
  215. }
  216. // ProbeEmbeddedTracks lists the subtitle tracks and font attachments inside a
  217. // container. A file with neither returns empty slices rather than an error.
  218. func ProbeEmbeddedTracks(inputFile string) (*MediaSubtitleInfo, error) {
  219. ctx, cancel := context.WithTimeout(context.Background(), subtitleProbeTimeout)
  220. defer cancel()
  221. cmd := exec.CommandContext(ctx, "ffprobe",
  222. "-v", "quiet",
  223. "-print_format", "json",
  224. "-show_streams",
  225. inputFile,
  226. )
  227. output, err := cmd.Output()
  228. if err != nil {
  229. if ctx.Err() == context.DeadlineExceeded {
  230. return nil, errors.New("subtitle probe timed out")
  231. }
  232. return nil, fmt.Errorf("ffprobe failed: %w", err)
  233. }
  234. return parseEmbeddedTracks(output)
  235. }
  236. // parseEmbeddedTracks turns ffprobe JSON into the track listing. Split out from
  237. // the exec call so the mapping rules can be unit-tested without ffmpeg.
  238. func parseEmbeddedTracks(probeJSON []byte) (*MediaSubtitleInfo, error) {
  239. var parsed struct {
  240. Streams []ffprobeStream `json:"streams"`
  241. }
  242. if err := json.Unmarshal(probeJSON, &parsed); err != nil {
  243. return nil, fmt.Errorf("could not parse ffprobe output: %w", err)
  244. }
  245. info := &MediaSubtitleInfo{
  246. Subtitles: []EmbeddedSubtitle{},
  247. Fonts: []EmbeddedFont{},
  248. }
  249. attachmentOrdinal := 0
  250. for i := range parsed.Streams {
  251. stream := &parsed.Streams[i]
  252. switch strings.ToLower(stream.CodecType) {
  253. case "subtitle":
  254. info.Subtitles = append(info.Subtitles, EmbeddedSubtitle{
  255. Index: stream.Index,
  256. Codec: stream.CodecName,
  257. Language: stream.tag("language"),
  258. Title: stream.tag("title"),
  259. Default: stream.Disposition["default"] == 1,
  260. Forced: stream.Disposition["forced"] == 1,
  261. Textual: IsTextualSubtitleCodec(stream.CodecName),
  262. })
  263. case "attachment":
  264. filename := stream.tag("filename")
  265. mimetype := stream.tag("mimetype")
  266. // The ordinal counts every attachment, font or not, because that is
  267. // what ffmpeg's -dump_attachment:t:<n> specifier indexes on.
  268. current := attachmentOrdinal
  269. attachmentOrdinal++
  270. if !IsFontAttachment(mimetype, filename) {
  271. continue
  272. }
  273. info.Fonts = append(info.Fonts, EmbeddedFont{
  274. Index: current,
  275. Filename: filename,
  276. Mimetype: mimetype,
  277. })
  278. }
  279. }
  280. return info, nil
  281. }
  282. // ExtractSubtitleTrack pulls one subtitle track out of a container and returns
  283. // it as SubRip text.
  284. //
  285. // streamIndex is the absolute stream index reported by ProbeEmbeddedTracks. The
  286. // conversion flattens ASS/SSA styling to plain text; callers wanting full
  287. // styling would need to extract the native format and render it with libass.
  288. func ExtractSubtitleTrack(inputFile string, streamIndex int) ([]byte, error) {
  289. if streamIndex < 0 {
  290. return nil, errors.New("invalid subtitle stream index")
  291. }
  292. ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
  293. defer cancel()
  294. cmd := exec.CommandContext(ctx, "ffmpeg",
  295. "-v", "error",
  296. "-i", inputFile,
  297. "-map", fmt.Sprintf("0:%d", streamIndex),
  298. "-vn", "-an", // subtitle stream only
  299. "-c:s", "srt",
  300. "-f", "srt",
  301. "pipe:1",
  302. )
  303. var stdout, stderr bytes.Buffer
  304. cmd.Stdout = &stdout
  305. cmd.Stderr = &stderr
  306. if err := cmd.Run(); err != nil {
  307. if ctx.Err() == context.DeadlineExceeded {
  308. return nil, errors.New("subtitle extraction timed out")
  309. }
  310. return nil, fmt.Errorf("ffmpeg failed: %w (%s)", err, lastLines(stderr.String(), 2))
  311. }
  312. if stdout.Len() == 0 {
  313. return nil, errors.New("subtitle track is empty")
  314. }
  315. if stdout.Len() > maxSubtitleBytes {
  316. return nil, errors.New("subtitle track is unexpectedly large")
  317. }
  318. return stdout.Bytes(), nil
  319. }
  320. // ExtractFontAttachment pulls one font attachment out of a container.
  321. //
  322. // fontIndex is the attachment ordinal reported by ProbeEmbeddedTracks. ffmpeg
  323. // can only dump attachments to a real path, so this renders into scratch space
  324. // under workDir and returns the bytes for the caller to serve or store.
  325. func ExtractFontAttachment(inputFile string, fontIndex int, workDir string) ([]byte, error) {
  326. if fontIndex < 0 {
  327. return nil, errors.New("invalid font attachment index")
  328. }
  329. if strings.TrimSpace(workDir) == "" {
  330. workDir = os.TempDir()
  331. }
  332. if err := os.MkdirAll(workDir, 0775); err != nil {
  333. return nil, fmt.Errorf("scratch directory unavailable: %w", err)
  334. }
  335. scratch, err := os.CreateTemp(workDir, "subfont-*.bin")
  336. if err != nil {
  337. return nil, fmt.Errorf("could not create scratch file: %w", err)
  338. }
  339. scratchPath := scratch.Name()
  340. scratch.Close()
  341. defer os.Remove(scratchPath)
  342. ctx, cancel := context.WithTimeout(context.Background(), subtitleExtractTimeout)
  343. defer cancel()
  344. // -dump_attachment is an input option, so it has to precede -i.
  345. //
  346. // No output is specified on purpose. Attachments are written while ffmpeg
  347. // opens the input, so the dump is already complete by the time it complains
  348. // that no output file was given and exits non-zero. Adding "-f null -" to
  349. // silence that error would make ffmpeg process every video and audio packet
  350. // in the container first: measured at 74s versus 0.06s on a 614MB HEVC file.
  351. cmd := exec.CommandContext(ctx, "ffmpeg",
  352. "-y",
  353. "-v", "error",
  354. fmt.Sprintf("-dump_attachment:t:%d", fontIndex), scratchPath,
  355. "-i", inputFile,
  356. )
  357. // Hence the written file, not the exit status, is the success signal.
  358. out, runErr := cmd.CombinedOutput()
  359. data, err := os.ReadFile(scratchPath)
  360. if err != nil || len(data) == 0 {
  361. if ctx.Err() == context.DeadlineExceeded {
  362. return nil, errors.New("font extraction timed out")
  363. }
  364. if runErr != nil {
  365. return nil, fmt.Errorf("ffmpeg failed: %w (%s)", runErr, lastLines(string(out), 2))
  366. }
  367. return nil, errors.New("font attachment is empty")
  368. }
  369. if len(data) > maxFontBytes {
  370. return nil, errors.New("font attachment is unexpectedly large")
  371. }
  372. return data, nil
  373. }