Skip to content

Commit 25bfe8e

Browse files
authored
Merge pull request #28 from golift/dn2_stderr
Capture all ffmpeg errors, output flag fixes.
2 parents 15d3ed8 + 4fc77c9 commit 25bfe8e

3 files changed

Lines changed: 273 additions & 25 deletions

File tree

.github/workflows/codetests.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
name: test-and-lint
22
on:
3+
push:
4+
branches:
5+
- main
36
pull_request:
47
branches:
58
- main
@@ -18,7 +21,7 @@ jobs:
1821
with:
1922
go-version: stable
2023
- name: go-test
21-
run: go test -race -covermode=atomic ./...
24+
run: go test -race -covermode=atomic -coverprofile=coverage.out ./... && go tool cover -func=coverage.out
2225

2326
# Runs golangci-lint on macos against freebsd and macos.
2427
golangci-darwin:

encode.go

Lines changed: 190 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package ffmpeg
55

66
import (
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

@@ -47,6 +49,8 @@ var (
4749
const (
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.
7579
func 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().
187196
func (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.
204227
func (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

Comments
 (0)