44package ffmpeg
55
66import (
7+ "bytes"
78 "context"
89 "errors"
910 "fmt"
@@ -12,6 +13,7 @@ import (
1213 "path/filepath"
1314 "strconv"
1415 "strings"
16+ "sync"
1517 "time"
1618)
1719
4749const (
4850 bits64 = 64
4951 base10 = 10
52+ // Keep the last bytes of ffmpeg stderr to improve diagnostics.
53+ defaultStderrTail = 8192
5054)
5155
5256// Config defines how ffmpeg shall transcode a stream.
@@ -73,7 +77,12 @@ type Encoder struct {
7377
7478// Get an encoder interface.
7579func Get (config * Config ) * Encoder {
76- encode := & Encoder {config : config }
80+ cfg := & Config {}
81+ if config != nil {
82+ * cfg = * config
83+ }
84+
85+ encode := & Encoder {config : cfg }
7786 if encode .config .FFMPEG == "" {
7887 encode .config .FFMPEG = DefaultFFmpegPath
7988 }
@@ -180,50 +189,88 @@ func (e *Encoder) SetSize(size string) int64 {
180189
181190// GetVideo retreives video from an input and returns an io.ReadCloser to consume the output.
182191// Input must be an RTSP URL. Title is encoded into the video as the "movie title."
183- // Returns command used, io.ReadCloser and error or nil.
192+ // Returns command used for diagnostics , io.ReadCloser and error or nil.
184193// This will automatically create a context with a timeout equal to the time duration requested plus 1 second.
185194// If no time duration is requested the context has no timeout.
186195// If you want to control the context, use GetVideoContext().
187196func (e * Encoder ) GetVideo (input , title string ) (string , io.ReadCloser , error ) {
188197 ctx := context .Background ()
189198
190- if e .config .Time > 0 {
191- var cancel func ()
199+ var cancel context.CancelFunc
192200
201+ if e .config .Time > 0 {
193202 ctx , cancel = context .WithTimeout (ctx , time .Second * time .Duration (e .config .Time + 1 ))
194- defer cancel ()
195203 }
196204
197- return e .GetVideoContext (ctx , input , title )
205+ cmdStr , stream , err := e .GetVideoContext (ctx , input , title )
206+ if err != nil {
207+ if cancel != nil {
208+ cancel ()
209+ }
210+
211+ return cmdStr , nil , err
212+ }
213+
214+ if cancel == nil {
215+ return cmdStr , stream , nil
216+ }
217+
218+ return cmdStr , & cancelReadCloser {ReadCloser : stream , cancel : cancel }, nil
198219}
199220
200221// GetVideoContext retreives video from an input and returns an io.ReadCloser to consume the output.
201222// Input must be an RTSP URL. Title is encoded into the video as the "movie title."
202- // Returns command used, io.ReadCloser and error or nil.
223+ // Returns command used for diagnostics , io.ReadCloser and error or nil.
203224// Use the context to add a timeout value (max run duration) to the ffmpeg command.
225+ //
226+ //nolint:contextcheck // caller-provided context is accepted and used for command execution.
204227func (e * Encoder ) GetVideoContext (ctx context.Context , input , title string ) (string , io.ReadCloser , error ) {
205228 if input == "" {
206229 return "" , nil , ErrInvalidInput
207230 }
208231
209- cmdStr , cmd := e .getVideoHandle (ctx , input , "-" , title )
232+ if ctx == nil {
233+ ctx = context .Background ()
234+ }
235+
236+ cmdCtx , cmdCancel := context .WithCancel (ctx )
237+ cmdStr , cmd := e .getVideoHandle (cmdCtx , input , "-" , title )
238+ stderr := newTailBuffer (defaultStderrTail )
239+ cmd .Stderr = stderr
210240
211241 stdoutpipe , err := cmd .StdoutPipe ()
212242 if err != nil {
243+ cmdCancel ()
244+
213245 return cmdStr , nil , fmt .Errorf ("subcommand failed: %w" , err )
214246 }
215247
216- err = cmd .Run ()
248+ err = cmd .Start ()
217249 if err != nil {
218- return cmdStr , stdoutpipe , fmt .Errorf ("run failed: %w" , err )
250+ _ = stdoutpipe .Close ()
251+
252+ cmdCancel ()
253+
254+ return cmdStr , nil , withStderr ("run failed" , err , stderr .String ())
219255 }
220256
221- return cmdStr , stdoutpipe , nil
257+ done := make (chan error , 1 )
258+
259+ go func () {
260+ done <- cmd .Wait ()
261+ }()
262+
263+ return cmdStr , & streamResult {
264+ out : stdoutpipe ,
265+ done : done ,
266+ cmdCancel : cmdCancel ,
267+ stderr : stderr ,
268+ }, nil
222269}
223270
224271// SaveVideo saves a video snippet to a file.
225272// Input must be an RTSP URL and output must be a file path. It will be overwritten.
226- // Returns command used, command output and error or nil.
273+ // Returns command used for diagnostics , command output and error or nil.
227274// This will automatically create a context with a timeout equal to the time duration requested plus 1 second.
228275// If no time duration is requested the context has no timeout.
229276// If you want to control the context, use SaveVideoContext().
@@ -244,7 +291,7 @@ func (e *Encoder) SaveVideo(input, output, title string) (cmdStr, outputStr stri
244291
245292// SaveVideoContext saves a video snippet to a file using a provided context.
246293// Input must be an RTSP URL and output must be a file path. It will be overwritten.
247- // Returns command used, command output and error or nil.
294+ // Returns command used for diagnostics , command output and error or nil.
248295// Use the context to add a timeout value (max run duration) to the ffmpeg command.
249296//
250297//nolint:nonamedreturns // the names help readability.
@@ -253,19 +300,26 @@ func (e *Encoder) SaveVideoContext(
253300) (cmdStr , outputStr string , err error ) {
254301 if input == "" {
255302 return "" , "" , ErrInvalidInput
256- } else if output == "" || output == "-" {
303+ }
304+
305+ if output == "" || output == "-" {
257306 return "" , "" , ErrInvalidOutput
258307 }
259308
260309 cmdStr , cmd := e .getVideoHandle (ctx , input , output , title )
261- // log.Println(cmdStr) // DEBUG
310+ stderr := newTailBuffer (defaultStderrTail )
311+
312+ var stdout bytes.Buffer
262313
263- out , err := cmd .CombinedOutput ()
314+ cmd .Stdout = & stdout
315+ cmd .Stderr = stderr
316+
317+ err = cmd .Run ()
264318 if err != nil {
265- return cmdStr , string ( out ), fmt . Errorf ("subcommand failed: %w " , err )
319+ return cmdStr , stdout . String ( ), withStderr ("subcommand failed" , err , stderr . String () )
266320 }
267321
268- return cmdStr , string ( out ), nil
322+ return cmdStr , stdout . String ( ), nil
269323}
270324
271325// fixValues makes sure video request values are sane.
@@ -333,11 +387,16 @@ func (e *Encoder) getVideoHandle(ctx context.Context, input, output, title strin
333387 "-v" , "16" , // log level
334388 "-rtsp_transport" , "tcp" ,
335389 "-i" , input ,
336- "-f" , "mov" ,
337- "-metadata" , `title="` + title + `"` ,
390+ "-metadata" , "title=" + title ,
338391 "-y" , "-map" , "0" ,
339392 }
340393
394+ if output == "-" {
395+ arg = append (arg , "-f" , "mp4" , "-movflags" , "frag_keyframe+empty_moov" )
396+ } else {
397+ arg = append (arg , "-f" , "mov" )
398+ }
399+
341400 if e .config .Size > 0 {
342401 arg = append (arg , "-fs" , strconv .FormatInt (e .config .Size , base10 ))
343402 }
@@ -351,12 +410,15 @@ func (e *Encoder) getVideoHandle(ctx context.Context, input, output, title strin
351410 "-profile:v" , e .config .Prof ,
352411 "-level" , e .config .Level ,
353412 "-pix_fmt" , "yuv420p" ,
354- "-movflags" , "faststart" ,
355413 "-s" , strconv .Itoa (e .config .Width )+ "x" + strconv .Itoa (e .config .Height ),
356414 "-preset" , "superfast" ,
357415 "-crf" , strconv .Itoa (e .config .CRF ),
358416 "-r" , strconv .Itoa (e .config .Rate ),
359417 )
418+
419+ if output != "-" {
420+ arg = append (arg , "-movflags" , "faststart" )
421+ }
360422 } else {
361423 arg = append (arg , "-c" , "copy" )
362424 }
@@ -369,6 +431,113 @@ func (e *Encoder) getVideoHandle(ctx context.Context, input, output, title strin
369431
370432 arg = append (arg , output ) // save file path goes last.
371433
434+ // This command string is for diagnostics only; it is not shell-escaped.
372435 //nolint:gosec // it's ok, but maybe it's not.
373436 return strings .Join (arg , " " ), exec .CommandContext (ctx , arg [0 ], arg [1 :]... )
374437}
438+
439+ // streamResult is our custom io.ReadCloser that also cleans up the command and context.
440+ type streamResult struct {
441+ out io.ReadCloser
442+ done <- chan error
443+ cmdCancel context.CancelFunc
444+ stderr * tailBuffer
445+ closeOnce sync.Once
446+ closeErr error
447+ }
448+
449+ func (s * streamResult ) Read (data []byte ) (int , error ) {
450+ bytesRead , err := s .out .Read (data )
451+ if err == nil {
452+ return bytesRead , nil
453+ }
454+
455+ if errors .Is (err , io .EOF ) {
456+ return bytesRead , io .EOF
457+ }
458+
459+ if err != nil {
460+ return bytesRead , fmt .Errorf ("read stream: %w" , err )
461+ }
462+
463+ return bytesRead , nil
464+ }
465+
466+ func (s * streamResult ) Close () error {
467+ s .closeOnce .Do (func () {
468+ s .cmdCancel ()
469+
470+ _ = s .out .Close ()
471+
472+ waitErr := <- s .done
473+ if waitErr != nil && ! errors .Is (waitErr , context .Canceled ) {
474+ s .closeErr = withStderr ("run failed" , waitErr , s .stderr .String ())
475+ }
476+ })
477+
478+ return s .closeErr
479+ }
480+
481+ type tailBuffer struct {
482+ buf []byte
483+ max int
484+ }
485+
486+ func newTailBuffer (limit int ) * tailBuffer {
487+ return & tailBuffer {max : limit }
488+ }
489+
490+ func (t * tailBuffer ) Write (data []byte ) (int , error ) {
491+ if t .max <= 0 {
492+ return len (data ), nil
493+ }
494+
495+ if len (data ) >= t .max {
496+ t .buf = append (t .buf [:0 ], data [len (data )- t .max :]... )
497+
498+ return len (data ), nil
499+ }
500+
501+ need := len (t .buf ) + len (data ) - t .max
502+ if need > 0 {
503+ t .buf = append (t .buf [:0 ], t .buf [need :]... )
504+ }
505+
506+ t .buf = append (t .buf , data ... )
507+
508+ return len (data ), nil
509+ }
510+
511+ func (t * tailBuffer ) String () string {
512+ return strings .TrimSpace (string (t .buf ))
513+ }
514+
515+ func withStderr (prefix string , err error , stderr string ) error {
516+ if stderr == "" {
517+ return fmt .Errorf ("%s: %w" , prefix , err )
518+ }
519+
520+ return fmt .Errorf ("%s: %w: %s" , prefix , err , stderr )
521+ }
522+
523+ type cancelReadCloser struct {
524+ io.ReadCloser
525+
526+ cancel context.CancelFunc
527+ closeOnce sync.Once
528+ }
529+
530+ func (c * cancelReadCloser ) Close () error {
531+ var err error
532+
533+ c .closeOnce .Do (func () {
534+ err = c .ReadCloser .Close ()
535+ c .cancel ()
536+ })
537+
538+ if err != nil {
539+ return fmt .Errorf ("close stream: %w" , err )
540+ }
541+
542+ return nil
543+ }
0 commit comments