detached 4d62f01

feat(media): add PNG and FFmpeg writers

commit
4d62f01fb53ea44e9fa88c6b8c68dcf02b690d6c
author
brice · · 2026-09-30 08:10 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
3cdbdaa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +1447 -0
A internal/adapters/ffmpeg/ffmpeg.go +494 -0
@@ -0,0 +1,494 @@
1+// Package ffmpeg encodes silent SDR video by streaming raw frames to an FFmpeg
2+// child process that muxes fragmented MP4 into a reserved output file.
3+package ffmpeg
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "io/fs"
12+ "os"
13+ "os/exec"
14+ "strconv"
15+ "sync"
16+ "sync/atomic"
17+ "syscall"
18+ "time"
19+
20+ "github.com/bnema/nefercap/internal/ports"
21+)
22+
23+const (
24+ defaultBinary = "ffmpeg"
25+ // closeTimeout bounds how long Close waits for the encoder to drain after
26+ // EOF before killing it; abortTimeout does the same for Abort.
27+ closeTimeout = 15 * time.Second
28+ abortTimeout = 3 * time.Second
29+ // killTimeout bounds the wait for the reaper after a kill.
30+ killTimeout = 5 * time.Second
31+ // exitProbe bounds how long a failed pipe write waits for the exit status.
32+ exitProbe = time.Second
33+ // waitDelay bounds Wait's I/O draining once the process has exited.
34+ waitDelay = 2 * time.Second
35+ // stderrLimit caps retained encoder diagnostics.
36+ stderrLimit = 4 << 10
37+)
38+
39+// ErrNoFrames is returned by Close when no frame was written, so no video exists.
40+var ErrNoFrames = errors.New("ffmpeg: no frames written")
41+
42+// past is a write deadline that is always expired.
43+var past = time.Unix(1, 0)
44+
45+const (
46+ stateIdle int32 = iota
47+ stateRunning
48+ stateClosing
49+ stateClosed
50+)
51+
52+type failure struct{ err error }
53+
54+// Writer implements ports.VideoWriter. Start, Write and Close have one owner;
55+// Abort may be called concurrently with Write. A Writer is single-use.
56+type Writer struct {
57+ binary string
58+ closeTimeout time.Duration
59+ abortTimeout time.Duration
60+
61+ state atomic.Int32
62+ mu sync.Mutex // serializes state transitions
63+
64+ // Set by Start before state becomes running; read-only afterwards.
65+ cmd *exec.Cmd
66+ stdin *os.File
67+ path string
68+ width int
69+ height int
70+ format ports.PixelFormat
71+ stderr *boundedBuffer
72+ waitDone chan struct{} // closed once cmd.Wait has returned
73+ waitErr error // valid after waitDone is closed
74+
75+ frames atomic.Int64
76+ cause atomic.Pointer[failure] // sticky first failure or interruption
77+
78+ watchMu sync.Mutex
79+ watchDead bool
80+ startStop func() bool
81+ writeStop func() bool
82+ writeDone <-chan struct{}
83+ writeGen uint64
84+ active atomic.Uint64 // generation of the Write using a per-call watcher
85+
86+ abortCh chan struct{}
87+ abortOnce sync.Once
88+ termDone chan struct{}
89+ termErr error
90+}
91+
92+var _ ports.VideoWriter = (*Writer)(nil)
93+
94+// New returns a Writer that runs the ffmpeg found on PATH.
95+func New() *Writer { return NewWithBinary(defaultBinary) }
96+
97+// NewWithBinary returns a Writer that runs the given encoder executable, which
98+// must accept FFmpeg's command line. It is used to test with helper processes.
99+func NewWithBinary(binary string) *Writer {
100+ return &Writer{
101+ binary: binary,
102+ closeTimeout: closeTimeout,
103+ abortTimeout: abortTimeout,
104+ abortCh: make(chan struct{}),
105+ termDone: make(chan struct{}),
106+ }
107+}
108+
109+// outputSize validates settings and returns the encoded dimensions. Zero
110+// dimensions retain the source size rounded down to even values (yuv420p).
111+func outputSize(f ports.Frame, s ports.VideoSettings) (int, int, error) {
112+ if s.FPS < 1 || s.FPS > ports.MaxFPS {
113+ return 0, 0, fmt.Errorf("ffmpeg: fps %d outside 1..%d", s.FPS, ports.MaxFPS)
114+ }
115+ if s.Width == 0 && s.Height == 0 {
116+ w, h := f.Width&^1, f.Height&^1
117+ if w < 2 || h < 2 {
118+ return 0, 0, fmt.Errorf("ffmpeg: source %dx%d too small to encode", f.Width, f.Height)
119+ }
120+ return w, h, nil
121+ }
122+ if s.Width < 2 || s.Height < 2 || s.Width > ports.MaxDimension || s.Height > ports.MaxDimension ||
123+ s.Width%2 != 0 || s.Height%2 != 0 {
124+ return 0, 0, fmt.Errorf("ffmpeg: output size %dx%d must be even, positive and at most %d", s.Width, s.Height, ports.MaxDimension)
125+ }
126+ return s.Width, s.Height, nil
127+}
128+
129+// buildArgs returns the FFmpeg argument vector (no shell). Input is raw bgr0
130+// on stdin; MP4 is fragmented to fd 3, an inherited pre-opened output file.
131+// When retain is set, an odd source is cropped by at most one row/column
132+// instead of being resampled.
133+func buildArgs(f ports.Frame, outW, outH, fps int, retain bool) []string {
134+ filter := ""
135+ if retain && (outW != f.Width || outH != f.Height) {
136+ filter = "crop=" + strconv.Itoa(outW) + ":" + strconv.Itoa(outH) + ":0:0,"
137+ }
138+ filter += "scale=" + strconv.Itoa(outW) + ":" + strconv.Itoa(outH) +
139+ ":flags=bicubic:out_color_matrix=bt709:out_range=tv,format=yuv420p"
140+ rate := strconv.Itoa(fps)
141+ return []string{
142+ "-hide_banner", "-nostats", "-loglevel", "error",
143+ "-f", "rawvideo", "-pix_fmt", "bgr0",
144+ "-video_size", strconv.Itoa(f.Width) + "x" + strconv.Itoa(f.Height),
145+ "-framerate", rate, "-i", "pipe:0",
146+ "-an", "-vf", filter,
147+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
148+ "-x264-params", "rc-lookahead=10:sync-lookahead=0",
149+ "-g", strconv.Itoa(fps * 2), "-r", rate,
150+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
151+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
152+ "-f", "mp4", "pipe:3",
153+ }
154+}
155+
156+// Start launches the encoder and reserves path with O_EXCL; an existing path
157+// yields ports.ErrPathExists and is never touched. Frame geometry and format
158+// are fixed from firstFrame; the frame itself is not written. Cancelling ctx
159+// interrupts a blocked Write but does not kill the encoder: call Close to
160+// finalize the video.
161+func (w *Writer) Start(ctx context.Context, first ports.Frame, path string, s ports.VideoSettings) error {
162+ w.mu.Lock()
163+ defer w.mu.Unlock()
164+ if w.state.Load() != stateIdle || w.cmd != nil {
165+ return errors.New("ffmpeg: writer already started")
166+ }
167+ if err := ctx.Err(); err != nil {
168+ return err
169+ }
170+ if err := first.Validate(); err != nil {
171+ return err
172+ }
173+ if path == "" {
174+ return errors.New("ffmpeg: empty path")
175+ }
176+ outW, outH, err := outputSize(first, s)
177+ if err != nil {
178+ return err
179+ }
180+ bin, err := exec.LookPath(w.binary)
181+ if err != nil {
182+ return fmt.Errorf("ffmpeg: %w", err)
183+ }
184+ out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
185+ if err != nil {
186+ if errors.Is(err, fs.ErrExist) {
187+ return fmt.Errorf("%w: %s", ports.ErrPathExists, path)
188+ }
189+ return fmt.Errorf("ffmpeg: create output: %w", err)
190+ }
191+ // From here we own path (created exclusively) and remove it on failure.
192+ fail := func(err error, closers ...*os.File) error {
193+ for _, c := range closers {
194+ _ = c.Close()
195+ }
196+ if rerr := os.Remove(path); rerr != nil && !errors.Is(rerr, fs.ErrNotExist) {
197+ err = errors.Join(err, fmt.Errorf("ffmpeg: remove output: %w", rerr))
198+ }
199+ return err
200+ }
201+ pr, pw, err := os.Pipe()
202+ if err != nil {
203+ return fail(fmt.Errorf("ffmpeg: stdin pipe: %w", err), out)
204+ }
205+ stderr := &boundedBuffer{limit: stderrLimit}
206+ cmd := exec.Command(bin, buildArgs(first, outW, outH, s.FPS, s.Width == 0)...)
207+ cmd.Stdin = pr
208+ cmd.Stderr = stderr
209+ cmd.ExtraFiles = []*os.File{out}
210+ cmd.WaitDelay = waitDelay
211+ // Own process group: a terminal SIGINT aimed at the recorder must not kill
212+ // the encoder before it receives EOF and finalizes the file.
213+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
214+ if err := cmd.Start(); err != nil {
215+ _ = pr.Close()
216+ _ = pw.Close()
217+ return fail(fmt.Errorf("ffmpeg: start: %w", err), out)
218+ }
219+ // The child holds its own copies of these descriptors.
220+ _ = pr.Close()
221+ _ = out.Close()
222+
223+ w.cmd, w.stdin, w.path, w.stderr = cmd, pw, path, stderr
224+ w.width, w.height, w.format = first.Width, first.Height, first.Format
225+ w.waitDone = make(chan struct{})
226+ go func() { // sole reaper: runs Wait exactly once, also after any Kill
227+ w.waitErr = cmd.Wait()
228+ close(w.waitDone)
229+ }()
230+ if ctx.Done() != nil {
231+ w.startStop = context.AfterFunc(ctx, func() { w.interrupt(context.Cause(ctx)) })
232+ }
233+ w.state.Store(stateRunning)
234+ return nil
235+}
236+
237+// interrupt records the first failure and unblocks a pending pipe write.
238+func (w *Writer) interrupt(err error) {
239+ w.cause.CompareAndSwap(nil, &failure{err})
240+ _ = w.stdin.SetWriteDeadline(past)
241+}
242+
243+// watch makes cancellation of a Write context interrupt the pipe. The watcher
244+// is registered once per distinct context, not per frame, and only fires while
245+// a Write using it is active.
246+func (w *Writer) watch(ctx context.Context) (gen uint64, ok bool) {
247+ done := ctx.Done()
248+ if done == nil {
249+ return 0, false
250+ }
251+ w.watchMu.Lock()
252+ defer w.watchMu.Unlock()
253+ if w.watchDead {
254+ return 0, false
255+ }
256+ if done != w.writeDone {
257+ if w.writeStop != nil {
258+ w.writeStop()
259+ }
260+ w.writeGen++
261+ g := w.writeGen
262+ w.writeDone = done
263+ w.writeStop = context.AfterFunc(ctx, func() {
264+ if w.active.Load() == g {
265+ w.interrupt(context.Cause(ctx))
266+ }
267+ })
268+ }
269+ return w.writeGen, true
270+}
271+
272+// Write sends one frame. Rows are written straight from borrowed storage, so
273+// pixels are not retained. Width, height and format must match Start.
274+func (w *Writer) Write(ctx context.Context, f ports.Frame) error {
275+ if w.state.Load() != stateRunning {
276+ return ports.ErrClosed
277+ }
278+ if c := w.cause.Load(); c != nil {
279+ return c.err
280+ }
281+ if err := ctx.Err(); err != nil {
282+ return err
283+ }
284+ if err := f.Validate(); err != nil {
285+ return err
286+ }
287+ if f.Width != w.width || f.Height != w.height || f.Format != w.format {
288+ return ports.ErrGeometryChanged
289+ }
290+ gen, watched := w.watch(ctx)
291+ if watched {
292+ w.active.Store(gen)
293+ defer w.active.Store(0)
294+ if err := ctx.Err(); err != nil {
295+ return err
296+ }
297+ }
298+ if err := writeRows(w.stdin, f); err != nil {
299+ return w.writeFailed(err)
300+ }
301+ w.frames.Add(1)
302+ return nil
303+}
304+
305+func (w *Writer) writeFailed(err error) error {
306+ if w.state.Load() != stateRunning {
307+ return ports.ErrClosed
308+ }
309+ if c := w.cause.Load(); c != nil {
310+ return c.err
311+ }
312+ // The encoder most likely exited; give it a moment to report why.
313+ t := time.NewTimer(exitProbe)
314+ defer t.Stop()
315+ select {
316+ case <-w.waitDone:
317+ case <-t.C:
318+ }
319+ msg := fmt.Errorf("ffmpeg: write frame: %w%s", err, w.diagnostics())
320+ w.cause.CompareAndSwap(nil, &failure{msg})
321+ return w.cause.Load().err
322+}
323+
324+// diagnostics describes the exit state and captured stderr once the process
325+// has been reaped; it is empty while the process is still running.
326+func (w *Writer) diagnostics() string {
327+ select {
328+ case <-w.waitDone:
329+ default:
330+ return ""
331+ }
332+ s := ""
333+ if w.waitErr != nil {
334+ s = ": " + w.waitErr.Error()
335+ }
336+ if d := w.stderr.String(); d != "" {
337+ s += ": " + d
338+ }
339+ return s
340+}
341+
342+// writeRows writes the frame in top-to-bottom row order without copying it.
343+// A tightly packed, upright frame is a single write.
344+func writeRows(dst io.Writer, f ports.Frame) error {
345+ rowLen := f.Width * ports.BytesPerPixel
346+ if !f.YInvert && f.Stride == rowLen {
347+ _, err := dst.Write(f.Pixels[:rowLen*f.Height])
348+ return err
349+ }
350+ for y := 0; y < f.Height; y++ {
351+ if _, err := dst.Write(f.Row(y)); err != nil {
352+ return err
353+ }
354+ }
355+ return nil
356+}
357+
358+// Close signals EOF, waits for the encoder to finalize the file (bounded, then
359+// kills it) and reports the outcome. The video is kept when the encoder exits
360+// cleanly after at least one frame, including after a cancelled context; it is
361+// removed otherwise. Repeated calls return the first result.
362+func (w *Writer) Close() error { return w.terminate(false) }
363+
364+// Abort interrupts a pending Write and stops the encoder with a shorter grace
365+// period than Close; a Close already draining is escalated to a kill. It is
366+// safe concurrently with Write and idempotent.
367+func (w *Writer) Abort() error { return w.terminate(true) }
368+
369+func (w *Writer) terminate(abort bool) error {
370+ w.mu.Lock()
371+ first := false
372+ switch w.state.Load() {
373+ case stateIdle:
374+ w.state.Store(stateClosed)
375+ close(w.termDone)
376+ w.mu.Unlock()
377+ return nil
378+ case stateRunning:
379+ w.state.Store(stateClosing)
380+ first = true
381+ }
382+ w.mu.Unlock()
383+
384+ if abort {
385+ w.abortOnce.Do(func() { close(w.abortCh) })
386+ w.interrupt(ports.ErrClosed)
387+ }
388+ if first {
389+ w.termErr = w.shutdown(abort)
390+ w.state.Store(stateClosed)
391+ close(w.termDone)
392+ } else {
393+ <-w.termDone
394+ }
395+ if abort && errors.Is(w.termErr, ErrNoFrames) {
396+ return nil // nothing was recorded, so there is nothing to lose
397+ }
398+ return w.termErr
399+}
400+
401+func (w *Writer) shutdown(abort bool) error {
402+ w.watchMu.Lock()
403+ w.watchDead = true
404+ for _, stop := range []func() bool{w.startStop, w.writeStop} {
405+ if stop != nil {
406+ stop()
407+ }
408+ }
409+ w.watchMu.Unlock()
410+
411+ _ = w.stdin.Close() // EOF: the encoder finalizes the file
412+
413+ limit := w.closeTimeout
414+ escalate := w.abortCh
415+ if abort {
416+ limit, escalate = w.abortTimeout, nil
417+ }
418+ timer := time.NewTimer(limit)
419+ defer timer.Stop()
420+ var err error
421+ select {
422+ case <-w.waitDone:
423+ case <-timer.C:
424+ err = fmt.Errorf("ffmpeg: encoder did not finish within %s; killed", limit)
425+ case <-escalate:
426+ err = errors.New("ffmpeg: shutdown aborted; encoder killed")
427+ }
428+ if err != nil {
429+ _ = w.cmd.Process.Kill() // ErrProcessDone is harmless
430+ kt := time.NewTimer(killTimeout)
431+ defer kt.Stop()
432+ select {
433+ case <-w.waitDone:
434+ case <-kt.C:
435+ err = errors.Join(err, errors.New("ffmpeg: encoder not reaped after kill"))
436+ }
437+ }
438+ if err == nil {
439+ select {
440+ case <-w.waitDone:
441+ if w.waitErr != nil {
442+ err = fmt.Errorf("ffmpeg: encoder failed%s", w.diagnostics())
443+ }
444+ default:
445+ }
446+ } else {
447+ err = fmt.Errorf("%w%s", err, w.diagnostics())
448+ }
449+ if err == nil && w.frames.Load() == 0 {
450+ err = ErrNoFrames
451+ }
452+ if err != nil {
453+ if rerr := os.Remove(w.path); rerr != nil && !errors.Is(rerr, fs.ErrNotExist) {
454+ err = errors.Join(err, fmt.Errorf("ffmpeg: remove output: %w", rerr))
455+ }
456+ }
457+ return err
458+}
459+
460+// boundedBuffer keeps the first limit bytes written and discards the rest, so
461+// a chatty encoder cannot grow memory.
462+type boundedBuffer struct {
463+ mu sync.Mutex
464+ buf []byte
465+ limit int
466+ truncated bool
467+}
468+
469+func (b *boundedBuffer) Write(p []byte) (int, error) {
470+ b.mu.Lock()
471+ defer b.mu.Unlock()
472+ if room := b.limit - len(b.buf); room > 0 {
473+ if room > len(p) {
474+ room = len(p)
475+ }
476+ b.buf = append(b.buf, p[:room]...)
477+ if room < len(p) {
478+ b.truncated = true
479+ }
480+ } else if len(p) > 0 {
481+ b.truncated = true
482+ }
483+ return len(p), nil
484+}
485+
486+func (b *boundedBuffer) String() string {
487+ b.mu.Lock()
488+ defer b.mu.Unlock()
489+ s := string(bytes.TrimSpace(b.buf))
490+ if b.truncated {
491+ s += " [truncated]"
492+ }
493+ return s
494+}
@@ -0,0 +1,494 @@
1+// Package ffmpeg encodes silent SDR video by streaming raw frames to an FFmpeg
2+// child process that muxes fragmented MP4 into a reserved output file.
3+package ffmpeg
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "io/fs"
12+ "os"
13+ "os/exec"
14+ "strconv"
15+ "sync"
16+ "sync/atomic"
17+ "syscall"
18+ "time"
19+
20+ "github.com/bnema/nefercap/internal/ports"
21+)
22+
23+const (
24+ defaultBinary = "ffmpeg"
25+ // closeTimeout bounds how long Close waits for the encoder to drain after
26+ // EOF before killing it; abortTimeout does the same for Abort.
27+ closeTimeout = 15 * time.Second
28+ abortTimeout = 3 * time.Second
29+ // killTimeout bounds the wait for the reaper after a kill.
30+ killTimeout = 5 * time.Second
31+ // exitProbe bounds how long a failed pipe write waits for the exit status.
32+ exitProbe = time.Second
33+ // waitDelay bounds Wait's I/O draining once the process has exited.
34+ waitDelay = 2 * time.Second
35+ // stderrLimit caps retained encoder diagnostics.
36+ stderrLimit = 4 << 10
37+)
38+
39+// ErrNoFrames is returned by Close when no frame was written, so no video exists.
40+var ErrNoFrames = errors.New("ffmpeg: no frames written")
41+
42+// past is a write deadline that is always expired.
43+var past = time.Unix(1, 0)
44+
45+const (
46+ stateIdle int32 = iota
47+ stateRunning
48+ stateClosing
49+ stateClosed
50+)
51+
52+type failure struct{ err error }
53+
54+// Writer implements ports.VideoWriter. Start, Write and Close have one owner;
55+// Abort may be called concurrently with Write. A Writer is single-use.
56+type Writer struct {
57+ binary string
58+ closeTimeout time.Duration
59+ abortTimeout time.Duration
60+
61+ state atomic.Int32
62+ mu sync.Mutex // serializes state transitions
63+
64+ // Set by Start before state becomes running; read-only afterwards.
65+ cmd *exec.Cmd
66+ stdin *os.File
67+ path string
68+ width int
69+ height int
70+ format ports.PixelFormat
71+ stderr *boundedBuffer
72+ waitDone chan struct{} // closed once cmd.Wait has returned
73+ waitErr error // valid after waitDone is closed
74+
75+ frames atomic.Int64
76+ cause atomic.Pointer[failure] // sticky first failure or interruption
77+
78+ watchMu sync.Mutex
79+ watchDead bool
80+ startStop func() bool
81+ writeStop func() bool
82+ writeDone <-chan struct{}
83+ writeGen uint64
84+ active atomic.Uint64 // generation of the Write using a per-call watcher
85+
86+ abortCh chan struct{}
87+ abortOnce sync.Once
88+ termDone chan struct{}
89+ termErr error
90+}
91+
92+var _ ports.VideoWriter = (*Writer)(nil)
93+
94+// New returns a Writer that runs the ffmpeg found on PATH.
95+func New() *Writer { return NewWithBinary(defaultBinary) }
96+
97+// NewWithBinary returns a Writer that runs the given encoder executable, which
98+// must accept FFmpeg's command line. It is used to test with helper processes.
99+func NewWithBinary(binary string) *Writer {
100+ return &Writer{
101+ binary: binary,
102+ closeTimeout: closeTimeout,
103+ abortTimeout: abortTimeout,
104+ abortCh: make(chan struct{}),
105+ termDone: make(chan struct{}),
106+ }
107+}
108+
109+// outputSize validates settings and returns the encoded dimensions. Zero
110+// dimensions retain the source size rounded down to even values (yuv420p).
111+func outputSize(f ports.Frame, s ports.VideoSettings) (int, int, error) {
112+ if s.FPS < 1 || s.FPS > ports.MaxFPS {
113+ return 0, 0, fmt.Errorf("ffmpeg: fps %d outside 1..%d", s.FPS, ports.MaxFPS)
114+ }
115+ if s.Width == 0 && s.Height == 0 {
116+ w, h := f.Width&^1, f.Height&^1
117+ if w < 2 || h < 2 {
118+ return 0, 0, fmt.Errorf("ffmpeg: source %dx%d too small to encode", f.Width, f.Height)
119+ }
120+ return w, h, nil
121+ }
122+ if s.Width < 2 || s.Height < 2 || s.Width > ports.MaxDimension || s.Height > ports.MaxDimension ||
123+ s.Width%2 != 0 || s.Height%2 != 0 {
124+ return 0, 0, fmt.Errorf("ffmpeg: output size %dx%d must be even, positive and at most %d", s.Width, s.Height, ports.MaxDimension)
125+ }
126+ return s.Width, s.Height, nil
127+}
128+
129+// buildArgs returns the FFmpeg argument vector (no shell). Input is raw bgr0
130+// on stdin; MP4 is fragmented to fd 3, an inherited pre-opened output file.
131+// When retain is set, an odd source is cropped by at most one row/column
132+// instead of being resampled.
133+func buildArgs(f ports.Frame, outW, outH, fps int, retain bool) []string {
134+ filter := ""
135+ if retain && (outW != f.Width || outH != f.Height) {
136+ filter = "crop=" + strconv.Itoa(outW) + ":" + strconv.Itoa(outH) + ":0:0,"
137+ }
138+ filter += "scale=" + strconv.Itoa(outW) + ":" + strconv.Itoa(outH) +
139+ ":flags=bicubic:out_color_matrix=bt709:out_range=tv,format=yuv420p"
140+ rate := strconv.Itoa(fps)
141+ return []string{
142+ "-hide_banner", "-nostats", "-loglevel", "error",
143+ "-f", "rawvideo", "-pix_fmt", "bgr0",
144+ "-video_size", strconv.Itoa(f.Width) + "x" + strconv.Itoa(f.Height),
145+ "-framerate", rate, "-i", "pipe:0",
146+ "-an", "-vf", filter,
147+ "-c:v", "libx264", "-preset", "fast", "-crf", "23",
148+ "-x264-params", "rc-lookahead=10:sync-lookahead=0",
149+ "-g", strconv.Itoa(fps * 2), "-r", rate,
150+ "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv",
151+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
152+ "-f", "mp4", "pipe:3",
153+ }
154+}
155+
156+// Start launches the encoder and reserves path with O_EXCL; an existing path
157+// yields ports.ErrPathExists and is never touched. Frame geometry and format
158+// are fixed from firstFrame; the frame itself is not written. Cancelling ctx
159+// interrupts a blocked Write but does not kill the encoder: call Close to
160+// finalize the video.
161+func (w *Writer) Start(ctx context.Context, first ports.Frame, path string, s ports.VideoSettings) error {
162+ w.mu.Lock()
163+ defer w.mu.Unlock()
164+ if w.state.Load() != stateIdle || w.cmd != nil {
165+ return errors.New("ffmpeg: writer already started")
166+ }
167+ if err := ctx.Err(); err != nil {
168+ return err
169+ }
170+ if err := first.Validate(); err != nil {
171+ return err
172+ }
173+ if path == "" {
174+ return errors.New("ffmpeg: empty path")
175+ }
176+ outW, outH, err := outputSize(first, s)
177+ if err != nil {
178+ return err
179+ }
180+ bin, err := exec.LookPath(w.binary)
181+ if err != nil {
182+ return fmt.Errorf("ffmpeg: %w", err)
183+ }
184+ out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
185+ if err != nil {
186+ if errors.Is(err, fs.ErrExist) {
187+ return fmt.Errorf("%w: %s", ports.ErrPathExists, path)
188+ }
189+ return fmt.Errorf("ffmpeg: create output: %w", err)
190+ }
191+ // From here we own path (created exclusively) and remove it on failure.
192+ fail := func(err error, closers ...*os.File) error {
193+ for _, c := range closers {
194+ _ = c.Close()
195+ }
196+ if rerr := os.Remove(path); rerr != nil && !errors.Is(rerr, fs.ErrNotExist) {
197+ err = errors.Join(err, fmt.Errorf("ffmpeg: remove output: %w", rerr))
198+ }
199+ return err
200+ }
201+ pr, pw, err := os.Pipe()
202+ if err != nil {
203+ return fail(fmt.Errorf("ffmpeg: stdin pipe: %w", err), out)
204+ }
205+ stderr := &boundedBuffer{limit: stderrLimit}
206+ cmd := exec.Command(bin, buildArgs(first, outW, outH, s.FPS, s.Width == 0)...)
207+ cmd.Stdin = pr
208+ cmd.Stderr = stderr
209+ cmd.ExtraFiles = []*os.File{out}
210+ cmd.WaitDelay = waitDelay
211+ // Own process group: a terminal SIGINT aimed at the recorder must not kill
212+ // the encoder before it receives EOF and finalizes the file.
213+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
214+ if err := cmd.Start(); err != nil {
215+ _ = pr.Close()
216+ _ = pw.Close()
217+ return fail(fmt.Errorf("ffmpeg: start: %w", err), out)
218+ }
219+ // The child holds its own copies of these descriptors.
220+ _ = pr.Close()
221+ _ = out.Close()
222+
223+ w.cmd, w.stdin, w.path, w.stderr = cmd, pw, path, stderr
224+ w.width, w.height, w.format = first.Width, first.Height, first.Format
225+ w.waitDone = make(chan struct{})
226+ go func() { // sole reaper: runs Wait exactly once, also after any Kill
227+ w.waitErr = cmd.Wait()
228+ close(w.waitDone)
229+ }()
230+ if ctx.Done() != nil {
231+ w.startStop = context.AfterFunc(ctx, func() { w.interrupt(context.Cause(ctx)) })
232+ }
233+ w.state.Store(stateRunning)
234+ return nil
235+}
236+
237+// interrupt records the first failure and unblocks a pending pipe write.
238+func (w *Writer) interrupt(err error) {
239+ w.cause.CompareAndSwap(nil, &failure{err})
240+ _ = w.stdin.SetWriteDeadline(past)
241+}
242+
243+// watch makes cancellation of a Write context interrupt the pipe. The watcher
244+// is registered once per distinct context, not per frame, and only fires while
245+// a Write using it is active.
246+func (w *Writer) watch(ctx context.Context) (gen uint64, ok bool) {
247+ done := ctx.Done()
248+ if done == nil {
249+ return 0, false
250+ }
251+ w.watchMu.Lock()
252+ defer w.watchMu.Unlock()
253+ if w.watchDead {
254+ return 0, false
255+ }
256+ if done != w.writeDone {
257+ if w.writeStop != nil {
258+ w.writeStop()
259+ }
260+ w.writeGen++
261+ g := w.writeGen
262+ w.writeDone = done
263+ w.writeStop = context.AfterFunc(ctx, func() {
264+ if w.active.Load() == g {
265+ w.interrupt(context.Cause(ctx))
266+ }
267+ })
268+ }
269+ return w.writeGen, true
270+}
271+
272+// Write sends one frame. Rows are written straight from borrowed storage, so
273+// pixels are not retained. Width, height and format must match Start.
274+func (w *Writer) Write(ctx context.Context, f ports.Frame) error {
275+ if w.state.Load() != stateRunning {
276+ return ports.ErrClosed
277+ }
278+ if c := w.cause.Load(); c != nil {
279+ return c.err
280+ }
281+ if err := ctx.Err(); err != nil {
282+ return err
283+ }
284+ if err := f.Validate(); err != nil {
285+ return err
286+ }
287+ if f.Width != w.width || f.Height != w.height || f.Format != w.format {
288+ return ports.ErrGeometryChanged
289+ }
290+ gen, watched := w.watch(ctx)
291+ if watched {
292+ w.active.Store(gen)
293+ defer w.active.Store(0)
294+ if err := ctx.Err(); err != nil {
295+ return err
296+ }
297+ }
298+ if err := writeRows(w.stdin, f); err != nil {
299+ return w.writeFailed(err)
300+ }
301+ w.frames.Add(1)
302+ return nil
303+}
304+
305+func (w *Writer) writeFailed(err error) error {
306+ if w.state.Load() != stateRunning {
307+ return ports.ErrClosed
308+ }
309+ if c := w.cause.Load(); c != nil {
310+ return c.err
311+ }
312+ // The encoder most likely exited; give it a moment to report why.
313+ t := time.NewTimer(exitProbe)
314+ defer t.Stop()
315+ select {
316+ case <-w.waitDone:
317+ case <-t.C:
318+ }
319+ msg := fmt.Errorf("ffmpeg: write frame: %w%s", err, w.diagnostics())
320+ w.cause.CompareAndSwap(nil, &failure{msg})
321+ return w.cause.Load().err
322+}
323+
324+// diagnostics describes the exit state and captured stderr once the process
325+// has been reaped; it is empty while the process is still running.
326+func (w *Writer) diagnostics() string {
327+ select {
328+ case <-w.waitDone:
329+ default:
330+ return ""
331+ }
332+ s := ""
333+ if w.waitErr != nil {
334+ s = ": " + w.waitErr.Error()
335+ }
336+ if d := w.stderr.String(); d != "" {
337+ s += ": " + d
338+ }
339+ return s
340+}
341+
342+// writeRows writes the frame in top-to-bottom row order without copying it.
343+// A tightly packed, upright frame is a single write.
344+func writeRows(dst io.Writer, f ports.Frame) error {
345+ rowLen := f.Width * ports.BytesPerPixel
346+ if !f.YInvert && f.Stride == rowLen {
347+ _, err := dst.Write(f.Pixels[:rowLen*f.Height])
348+ return err
349+ }
350+ for y := 0; y < f.Height; y++ {
351+ if _, err := dst.Write(f.Row(y)); err != nil {
352+ return err
353+ }
354+ }
355+ return nil
356+}
357+
358+// Close signals EOF, waits for the encoder to finalize the file (bounded, then
359+// kills it) and reports the outcome. The video is kept when the encoder exits
360+// cleanly after at least one frame, including after a cancelled context; it is
361+// removed otherwise. Repeated calls return the first result.
362+func (w *Writer) Close() error { return w.terminate(false) }
363+
364+// Abort interrupts a pending Write and stops the encoder with a shorter grace
365+// period than Close; a Close already draining is escalated to a kill. It is
366+// safe concurrently with Write and idempotent.
367+func (w *Writer) Abort() error { return w.terminate(true) }
368+
369+func (w *Writer) terminate(abort bool) error {
370+ w.mu.Lock()
371+ first := false
372+ switch w.state.Load() {
373+ case stateIdle:
374+ w.state.Store(stateClosed)
375+ close(w.termDone)
376+ w.mu.Unlock()
377+ return nil
378+ case stateRunning:
379+ w.state.Store(stateClosing)
380+ first = true
381+ }
382+ w.mu.Unlock()
383+
384+ if abort {
385+ w.abortOnce.Do(func() { close(w.abortCh) })
386+ w.interrupt(ports.ErrClosed)
387+ }
388+ if first {
389+ w.termErr = w.shutdown(abort)
390+ w.state.Store(stateClosed)
391+ close(w.termDone)
392+ } else {
393+ <-w.termDone
394+ }
395+ if abort && errors.Is(w.termErr, ErrNoFrames) {
396+ return nil // nothing was recorded, so there is nothing to lose
397+ }
398+ return w.termErr
399+}
400+
401+func (w *Writer) shutdown(abort bool) error {
402+ w.watchMu.Lock()
403+ w.watchDead = true
404+ for _, stop := range []func() bool{w.startStop, w.writeStop} {
405+ if stop != nil {
406+ stop()
407+ }
408+ }
409+ w.watchMu.Unlock()
410+
411+ _ = w.stdin.Close() // EOF: the encoder finalizes the file
412+
413+ limit := w.closeTimeout
414+ escalate := w.abortCh
415+ if abort {
416+ limit, escalate = w.abortTimeout, nil
417+ }
418+ timer := time.NewTimer(limit)
419+ defer timer.Stop()
420+ var err error
421+ select {
422+ case <-w.waitDone:
423+ case <-timer.C:
424+ err = fmt.Errorf("ffmpeg: encoder did not finish within %s; killed", limit)
425+ case <-escalate:
426+ err = errors.New("ffmpeg: shutdown aborted; encoder killed")
427+ }
428+ if err != nil {
429+ _ = w.cmd.Process.Kill() // ErrProcessDone is harmless
430+ kt := time.NewTimer(killTimeout)
431+ defer kt.Stop()
432+ select {
433+ case <-w.waitDone:
434+ case <-kt.C:
435+ err = errors.Join(err, errors.New("ffmpeg: encoder not reaped after kill"))
436+ }
437+ }
438+ if err == nil {
439+ select {
440+ case <-w.waitDone:
441+ if w.waitErr != nil {
442+ err = fmt.Errorf("ffmpeg: encoder failed%s", w.diagnostics())
443+ }
444+ default:
445+ }
446+ } else {
447+ err = fmt.Errorf("%w%s", err, w.diagnostics())
448+ }
449+ if err == nil && w.frames.Load() == 0 {
450+ err = ErrNoFrames
451+ }
452+ if err != nil {
453+ if rerr := os.Remove(w.path); rerr != nil && !errors.Is(rerr, fs.ErrNotExist) {
454+ err = errors.Join(err, fmt.Errorf("ffmpeg: remove output: %w", rerr))
455+ }
456+ }
457+ return err
458+}
459+
460+// boundedBuffer keeps the first limit bytes written and discards the rest, so
461+// a chatty encoder cannot grow memory.
462+type boundedBuffer struct {
463+ mu sync.Mutex
464+ buf []byte
465+ limit int
466+ truncated bool
467+}
468+
469+func (b *boundedBuffer) Write(p []byte) (int, error) {
470+ b.mu.Lock()
471+ defer b.mu.Unlock()
472+ if room := b.limit - len(b.buf); room > 0 {
473+ if room > len(p) {
474+ room = len(p)
475+ }
476+ b.buf = append(b.buf, p[:room]...)
477+ if room < len(p) {
478+ b.truncated = true
479+ }
480+ } else if len(p) > 0 {
481+ b.truncated = true
482+ }
483+ return len(p), nil
484+}
485+
486+func (b *boundedBuffer) String() string {
487+ b.mu.Lock()
488+ defer b.mu.Unlock()
489+ s := string(bytes.TrimSpace(b.buf))
490+ if b.truncated {
491+ s += " [truncated]"
492+ }
493+ return s
494+}
A internal/adapters/ffmpeg/ffmpeg_test.go +645 -0
@@ -0,0 +1,645 @@
1+package ffmpeg
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "syscall"
14+ "testing"
15+ "time"
16+
17+ "github.com/bnema/nefercap/internal/ports"
18+)
19+
20+const helperEnv = "NEFERCAP_FFMPEG_HELPER"
21+
22+// TestMain turns the test binary into a stand-in encoder when helperEnv is set.
23+// The Writer runs it with FFmpeg's arguments; they are ignored.
24+func TestMain(m *testing.M) {
25+ switch mode := os.Getenv(helperEnv); mode {
26+ case "":
27+ os.Exit(m.Run())
28+ case "exit1": // fails immediately with a large diagnostic
29+ fmt.Fprint(os.Stderr, strings.Repeat("boom ", 10000))
30+ os.Exit(1)
31+ case "hang": // never reads stdin and ignores EOF
32+ time.Sleep(time.Hour)
33+ case "drain": // reads to EOF, writes output to fd 3, exits cleanly
34+ n, _ := io.Copy(io.Discard, os.Stdin)
35+ out := os.NewFile(3, "out")
36+ fmt.Fprintf(out, "bytes=%d", n)
37+ out.Close()
38+ os.Exit(0)
39+ case "slowclose": // drains, then refuses to exit after EOF
40+ _, _ = io.Copy(io.Discard, os.Stdin)
41+ time.Sleep(time.Hour)
42+ }
43+ os.Exit(2)
44+}
45+
46+func newFrame(w, h, stride int, fill byte) ports.Frame {
47+ pix := make([]byte, stride*h)
48+ for i := range pix {
49+ pix[i] = fill
50+ }
51+ return ports.Frame{Pixels: pix, Width: w, Height: h, Stride: stride, Format: ports.XRGB8888}
52+}
53+
54+func helperWriter(t *testing.T, mode string) *Writer {
55+ t.Helper()
56+ t.Setenv(helperEnv, mode)
57+ w := NewWithBinary(os.Args[0])
58+ w.closeTimeout = 300 * time.Millisecond
59+ w.abortTimeout = 300 * time.Millisecond
60+ t.Cleanup(func() { _ = w.Abort() })
61+ return w
62+}
63+
64+func gone(pid int) bool { return syscall.Kill(pid, 0) == syscall.ESRCH }
65+
66+func settings() ports.VideoSettings { return ports.VideoSettings{FPS: 30} }
67+
68+func TestStartValidation(t *testing.T) {
69+ f := newFrame(64, 48, 256, 0)
70+ odd := newFrame(63, 47, 256, 0)
71+ cases := []struct {
72+ name string
73+ frame ports.Frame
74+ set ports.VideoSettings
75+ ok bool
76+ }{
77+ {"fps zero", f, ports.VideoSettings{}, false},
78+ {"fps too high", f, ports.VideoSettings{FPS: 121}, false},
79+ {"one dimension", f, ports.VideoSettings{FPS: 30, Width: 32}, false},
80+ {"odd output", f, ports.VideoSettings{FPS: 30, Width: 33, Height: 32}, false},
81+ {"huge output", f, ports.VideoSettings{FPS: 30, Width: 16386, Height: 32}, false},
82+ {"odd source retained", odd, ports.VideoSettings{FPS: 30}, true},
83+ {"even scale", f, ports.VideoSettings{FPS: 120, Width: 32, Height: 24}, true},
84+ {"1x1 source retained", newFrame(1, 1, 4, 0), ports.VideoSettings{FPS: 30}, false},
85+ }
86+ for _, tc := range cases {
87+ t.Run(tc.name, func(t *testing.T) {
88+ w := helperWriter(t, "drain")
89+ path := filepath.Join(t.TempDir(), "v.mp4")
90+ err := w.Start(context.Background(), tc.frame, path, tc.set)
91+ if (err == nil) != tc.ok {
92+ t.Fatalf("Start error = %v, want ok=%v", err, tc.ok)
93+ }
94+ if !tc.ok {
95+ if _, serr := os.Stat(path); !errors.Is(serr, os.ErrNotExist) {
96+ t.Fatalf("rejected Start left a file: %v", serr)
97+ }
98+ }
99+ })
100+ }
101+}
102+
103+func TestBuildArgs(t *testing.T) {
104+ f := newFrame(63, 47, 252, 0)
105+ args := strings.Join(buildArgs(f, 62, 46, 30, true), " ")
106+ for _, want := range []string{"-pix_fmt bgr0", "-video_size 63x47", "crop=62:46:0:0,scale=62:46", "-preset fast", "libx264", "pipe:3", "-an"} {
107+ if !strings.Contains(args, want) {
108+ t.Errorf("args missing %q: %s", want, args)
109+ }
110+ }
111+ if strings.Contains(strings.Join(buildArgs(f, 32, 32, 30, false), " "), "crop") {
112+ t.Error("explicit scaling must not crop")
113+ }
114+}
115+
116+func TestStartRefusesExistingPath(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "v.mp4")
118+ if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
119+ t.Fatal(err)
120+ }
121+ w := helperWriter(t, "drain")
122+ err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings())
123+ if !errors.Is(err, ports.ErrPathExists) {
124+ t.Fatalf("got %v, want ErrPathExists", err)
125+ }
126+ if got, _ := os.ReadFile(path); string(got) != "keep" {
127+ t.Fatalf("existing file changed: %q", got)
128+ }
129+ if err := w.Write(context.Background(), newFrame(4, 4, 16, 0)); !errors.Is(err, ports.ErrClosed) {
130+ t.Fatalf("write on unstarted writer: %v", err)
131+ }
132+ if err := w.Close(); err != nil {
133+ t.Fatal(err)
134+ }
135+ if got, _ := os.ReadFile(path); string(got) != "keep" {
136+ t.Fatalf("Close touched foreign file: %q", got)
137+ }
138+}
139+
140+func TestStartFailureRemovesReservedPath(t *testing.T) {
141+ dir := t.TempDir()
142+ notExec := filepath.Join(dir, "encoder")
143+ if err := os.WriteFile(notExec, []byte("x"), 0o755); err != nil { // not a valid executable
144+ t.Fatal(err)
145+ }
146+ path := filepath.Join(dir, "v.mp4")
147+ w := NewWithBinary(notExec)
148+ if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err == nil {
149+ t.Fatal("Start succeeded with a broken encoder")
150+ }
151+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
152+ t.Fatalf("reserved path remains: %v", err)
153+ }
154+ w = NewWithBinary(filepath.Join(dir, "missing"))
155+ if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err == nil {
156+ t.Fatal("Start succeeded with a missing encoder")
157+ }
158+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
159+ t.Fatalf("reserved path remains: %v", err)
160+ }
161+}
162+
163+func TestDrainLifecycle(t *testing.T) {
164+ w := helperWriter(t, "drain")
165+ w.closeTimeout = 10 * time.Second
166+ path := filepath.Join(t.TempDir(), "v.mp4")
167+ ctx := context.Background()
168+ first := newFrame(4, 4, 16, 1)
169+ if err := w.Start(ctx, first, path, settings()); err != nil {
170+ t.Fatal(err)
171+ }
172+ if err := w.Start(ctx, first, path, settings()); err == nil {
173+ t.Fatal("second Start accepted")
174+ }
175+ // Stride changes are allowed; geometry and format changes are not.
176+ if err := w.Write(ctx, first); err != nil {
177+ t.Fatal(err)
178+ }
179+ if err := w.Write(ctx, newFrame(4, 4, 32, 2)); err != nil {
180+ t.Fatalf("stride change rejected: %v", err)
181+ }
182+ inv := newFrame(4, 4, 16, 3)
183+ inv.YInvert = true
184+ if err := w.Write(ctx, inv); err != nil {
185+ t.Fatal(err)
186+ }
187+ for _, bad := range []ports.Frame{newFrame(8, 4, 32, 0), newFrame(4, 8, 16, 0)} {
188+ if err := w.Write(ctx, bad); !errors.Is(err, ports.ErrGeometryChanged) {
189+ t.Fatalf("got %v, want ErrGeometryChanged", err)
190+ }
191+ }
192+ argb := newFrame(4, 4, 16, 0)
193+ argb.Format = ports.ARGB8888
194+ if err := w.Write(ctx, argb); !errors.Is(err, ports.ErrGeometryChanged) {
195+ t.Fatalf("format change: got %v", err)
196+ }
197+ // A rejected frame does not poison the stream.
198+ if err := w.Write(ctx, first); err != nil {
199+ t.Fatal(err)
200+ }
201+ pid := w.cmd.Process.Pid
202+ if err := w.Close(); err != nil {
203+ t.Fatal(err)
204+ }
205+ if err := w.Close(); err != nil {
206+ t.Fatalf("second Close: %v", err)
207+ }
208+ if err := w.Abort(); err != nil {
209+ t.Fatalf("Abort after Close: %v", err)
210+ }
211+ if err := w.Write(ctx, first); !errors.Is(err, ports.ErrClosed) {
212+ t.Fatalf("Write after Close: %v", err)
213+ }
214+ if !gone(pid) {
215+ t.Fatal("encoder not reaped")
216+ }
217+ // 4 frames of 4x4x4 bytes reached the encoder exactly (no padding).
218+ if got, _ := os.ReadFile(path); string(got) != "bytes=256" {
219+ t.Fatalf("encoder output %q", got)
220+ }
221+}
222+
223+func TestEncoderExitReportsBoundedDiagnostics(t *testing.T) {
224+ w := helperWriter(t, "exit1")
225+ path := filepath.Join(t.TempDir(), "v.mp4")
226+ ctx := context.Background()
227+ f := newFrame(64, 64, 256, 0)
228+ if err := w.Start(ctx, f, path, settings()); err != nil {
229+ t.Fatal(err)
230+ }
231+ var err error
232+ deadline := time.Now().Add(10 * time.Second)
233+ for err == nil && time.Now().Before(deadline) {
234+ err = w.Write(ctx, f)
235+ }
236+ if err == nil {
237+ t.Fatal("Write never failed")
238+ }
239+ if !strings.Contains(err.Error(), "boom") || len(err.Error()) > stderrLimit+512 {
240+ t.Fatalf("unexpected diagnostic (%d bytes): %.200s", len(err.Error()), err)
241+ }
242+ if !strings.Contains(err.Error(), "truncated") {
243+ t.Fatal("diagnostics were not bounded")
244+ }
245+ if err2 := w.Write(ctx, f); err2 == nil || err2.Error() != err.Error() {
246+ t.Fatalf("failure not sticky: %v", err2)
247+ }
248+ if err := w.Close(); err == nil {
249+ t.Fatal("Close hid encoder failure")
250+ }
251+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
252+ t.Fatalf("failed recording left a file: %v", err)
253+ }
254+}
255+
256+func TestCancelInterruptsBlockedWrite(t *testing.T) {
257+ w := helperWriter(t, "hang")
258+ path := filepath.Join(t.TempDir(), "v.mp4")
259+ f := newFrame(1024, 1024, 4096, 0) // 4 MiB fills the pipe
260+ if err := w.Start(context.Background(), f, path, settings()); err != nil {
261+ t.Fatal(err)
262+ }
263+ pid := w.cmd.Process.Pid
264+ ctx, cancel := context.WithCancel(context.Background())
265+ res := make(chan error, 1)
266+ go func() { res <- w.Write(ctx, f) }()
267+ select {
268+ case err := <-res:
269+ t.Fatalf("Write returned early: %v", err)
270+ case <-time.After(200 * time.Millisecond):
271+ }
272+ cancel()
273+ select {
274+ case err := <-res:
275+ if !errors.Is(err, context.Canceled) {
276+ t.Fatalf("got %v, want Canceled", err)
277+ }
278+ case <-time.After(5 * time.Second):
279+ t.Fatal("cancel did not interrupt blocked Write")
280+ }
281+ if err := w.Write(context.Background(), f); !errors.Is(err, context.Canceled) {
282+ t.Fatalf("Write after interruption: %v", err)
283+ }
284+ start := time.Now()
285+ if err := w.Close(); err == nil {
286+ t.Fatal("hung encoder reported success")
287+ }
288+ if d := time.Since(start); d > 5*time.Second {
289+ t.Fatalf("Close took %s", d)
290+ }
291+ if !gone(pid) {
292+ t.Fatal("hung encoder not killed and reaped")
293+ }
294+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
295+ t.Fatalf("unfinished output remains: %v", err)
296+ }
297+}
298+
299+func TestStartContextCancelInterruptsWrite(t *testing.T) {
300+ w := helperWriter(t, "hang")
301+ f := newFrame(1024, 1024, 4096, 0)
302+ ctx, cancel := context.WithCancel(context.Background())
303+ if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
304+ t.Fatal(err)
305+ }
306+ res := make(chan error, 1)
307+ go func() { res <- w.Write(context.Background(), f) }()
308+ time.Sleep(200 * time.Millisecond)
309+ cancel()
310+ select {
311+ case err := <-res:
312+ if !errors.Is(err, context.Canceled) {
313+ t.Fatalf("got %v", err)
314+ }
315+ case <-time.After(5 * time.Second):
316+ t.Fatal("Start context cancel did not interrupt Write")
317+ }
318+}
319+
320+func TestAbortInterruptsBlockedWriteAndIsIdempotent(t *testing.T) {
321+ w := helperWriter(t, "hang")
322+ path := filepath.Join(t.TempDir(), "v.mp4")
323+ f := newFrame(1024, 1024, 4096, 0)
324+ if err := w.Start(context.Background(), f, path, settings()); err != nil {
325+ t.Fatal(err)
326+ }
327+ pid := w.cmd.Process.Pid
328+ res := make(chan error, 1)
329+ go func() { res <- w.Write(context.Background(), f) }()
330+ time.Sleep(200 * time.Millisecond)
331+ aborts := make(chan error, 3)
332+ for i := 0; i < 3; i++ {
333+ go func() { aborts <- w.Abort() }()
334+ }
335+ for i := 0; i < 3; i++ {
336+ select {
337+ case <-aborts:
338+ case <-time.After(10 * time.Second):
339+ t.Fatal("Abort hung")
340+ }
341+ }
342+ select {
343+ case err := <-res:
344+ if !errors.Is(err, ports.ErrClosed) {
345+ t.Fatalf("got %v, want ErrClosed", err)
346+ }
347+ case <-time.After(5 * time.Second):
348+ t.Fatal("Write still blocked after Abort")
349+ }
350+ if !gone(pid) {
351+ t.Fatal("encoder not reaped")
352+ }
353+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
354+ t.Fatalf("aborted output remains: %v", err)
355+ }
356+}
357+
358+func TestAbortEscalatesDrainingClose(t *testing.T) {
359+ w := helperWriter(t, "slowclose")
360+ w.closeTimeout = time.Minute
361+ path := filepath.Join(t.TempDir(), "v.mp4")
362+ f := newFrame(4, 4, 16, 0)
363+ if err := w.Start(context.Background(), f, path, settings()); err != nil {
364+ t.Fatal(err)
365+ }
366+ if err := w.Write(context.Background(), f); err != nil {
367+ t.Fatal(err)
368+ }
369+ pid := w.cmd.Process.Pid
370+ closed := make(chan error, 1)
371+ go func() { closed <- w.Close() }()
372+ time.Sleep(200 * time.Millisecond)
373+ _ = w.Abort()
374+ select {
375+ case err := <-closed:
376+ if err == nil {
377+ t.Fatal("killed encoder reported success")
378+ }
379+ case <-time.After(10 * time.Second):
380+ t.Fatal("Close still draining after Abort")
381+ }
382+ if !gone(pid) {
383+ t.Fatal("encoder not reaped")
384+ }
385+}
386+
387+func TestCloseWatchdogKillsStuckEncoder(t *testing.T) {
388+ w := helperWriter(t, "slowclose")
389+ f := newFrame(4, 4, 16, 0)
390+ if err := w.Start(context.Background(), f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
391+ t.Fatal(err)
392+ }
393+ if err := w.Write(context.Background(), f); err != nil {
394+ t.Fatal(err)
395+ }
396+ pid := w.cmd.Process.Pid
397+ start := time.Now()
398+ err := w.Close()
399+ if err == nil || !strings.Contains(err.Error(), "killed") {
400+ t.Fatalf("got %v, want kill error", err)
401+ }
402+ if time.Since(start) > 5*time.Second || !gone(pid) {
403+ t.Fatal("watchdog did not bound shutdown")
404+ }
405+}
406+
407+func TestCloseWithoutFrames(t *testing.T) {
408+ w := helperWriter(t, "drain")
409+ w.closeTimeout = 10 * time.Second // helper start-up is slow under -race
410+ path := filepath.Join(t.TempDir(), "v.mp4")
411+ if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err != nil {
412+ t.Fatal(err)
413+ }
414+ if err := w.Close(); !errors.Is(err, ErrNoFrames) {
415+ t.Fatalf("got %v, want ErrNoFrames", err)
416+ }
417+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
418+ t.Fatalf("empty recording remains: %v", err)
419+ }
420+}
421+
422+func TestNeverStartedTerminalCallsAreNoOps(t *testing.T) {
423+ w := New()
424+ if err := w.Abort(); err != nil {
425+ t.Fatal(err)
426+ }
427+ if err := w.Close(); err != nil {
428+ t.Fatal(err)
429+ }
430+}
431+
432+func TestWriteAfterCloseDoesNotLeakWatchers(t *testing.T) {
433+ w := helperWriter(t, "drain")
434+ w.closeTimeout = 10 * time.Second
435+ f := newFrame(4, 4, 16, 0)
436+ ctx, cancel := context.WithCancel(context.Background())
437+ defer cancel()
438+ if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
439+ t.Fatal(err)
440+ }
441+ for i := 0; i < 100; i++ { // one watcher registration for the whole loop
442+ if err := w.Write(ctx, f); err != nil {
443+ t.Fatal(err)
444+ }
445+ }
446+ if w.writeGen != 1 {
447+ t.Fatalf("watcher registered %d times for one context", w.writeGen)
448+ }
449+ if err := w.Close(); err != nil {
450+ t.Fatal(err)
451+ }
452+ cancel() // after Close: must be harmless
453+}
454+
455+func TestRealFFmpeg(t *testing.T) {
456+ bin, err := exec.LookPath("ffmpeg")
457+ if err != nil {
458+ t.Skip("ffmpeg not installed")
459+ }
460+ if out, _ := exec.Command(bin, "-hide_banner", "-encoders").Output(); !bytes.Contains(out, []byte("libx264")) {
461+ t.Skip("ffmpeg lacks libx264")
462+ }
463+ probe, perr := exec.LookPath("ffprobe")
464+
465+ for _, tc := range []struct {
466+ name string
467+ w, h, stride int
468+ set ports.VideoSettings
469+ wantW, wantH int
470+ invert bool
471+ cancelInstead bool
472+ }{
473+ {"retain padded inverted", 64, 48, 64*4 + 16, ports.VideoSettings{FPS: 30}, 64, 48, true, false},
474+ {"odd source cropped", 63, 47, 63 * 4, ports.VideoSettings{FPS: 30}, 62, 46, false, false},
475+ {"scaled", 64, 48, 64 * 4, ports.VideoSettings{FPS: 60, Width: 32, Height: 24}, 32, 24, false, false},
476+ {"graceful cancel keeps video", 64, 48, 64 * 4, ports.VideoSettings{FPS: 30}, 64, 48, false, true},
477+ } {
478+ t.Run(tc.name, func(t *testing.T) {
479+ w := New()
480+ path := filepath.Join(t.TempDir(), "out.mp4")
481+ ctx, cancel := context.WithCancel(context.Background())
482+ defer cancel()
483+ f := newFrame(tc.w, tc.h, tc.stride, 0)
484+ f.YInvert = tc.invert
485+ if err := w.Start(ctx, f, path, tc.set); err != nil {
486+ t.Fatal(err)
487+ }
488+ for i := 0; i < 30; i++ {
489+ for j := range f.Pixels {
490+ f.Pixels[j] = byte(i*8 + j)
491+ }
492+ if err := w.Write(ctx, f); err != nil {
493+ t.Fatalf("frame %d: %v", i, err)
494+ }
495+ }
496+ if tc.cancelInstead {
497+ cancel() // signal-style stop: the encoder must still finalize
498+ }
499+ if err := w.Close(); err != nil {
500+ t.Fatal(err)
501+ }
502+ info, err := os.Stat(path)
503+ if err != nil || info.Size() == 0 {
504+ t.Fatalf("no output: %v", err)
505+ }
506+ if perr != nil {
507+ return
508+ }
509+ out, err := exec.Command(probe, "-v", "error", "-select_streams", "v:0", "-count_frames",
510+ "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "csv=p=0", path).Output()
511+ if err != nil {
512+ t.Fatalf("ffprobe: %v", err)
513+ }
514+ want := fmt.Sprintf("h264,%d,%d,yuv420p,30", tc.wantW, tc.wantH)
515+ if got := strings.TrimSpace(string(out)); got != want {
516+ t.Fatalf("probe = %q, want %q", got, want)
517+ }
518+ if streams, _ := exec.Command(probe, "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", path).Output(); strings.Contains(string(streams), "audio") {
519+ t.Fatal("output has audio")
520+ }
521+ })
522+ }
523+}
524+
525+func TestRealFFmpegRefusesExistingPath(t *testing.T) {
526+ if _, err := exec.LookPath("ffmpeg"); err != nil {
527+ t.Skip("ffmpeg not installed")
528+ }
529+ path := filepath.Join(t.TempDir(), "out.mp4")
530+ if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
531+ t.Fatal(err)
532+ }
533+ w := New()
534+ if err := w.Start(context.Background(), newFrame(64, 48, 256, 0), path, settings()); !errors.Is(err, ports.ErrPathExists) {
535+ t.Fatalf("got %v", err)
536+ }
537+}
538+
539+func TestVideoRowsAllocations(t *testing.T) {
540+ cases := map[string]ports.Frame{
541+ "packed": newFrame(64, 32, 256, 1),
542+ "padded": newFrame(64, 32, 320, 1),
543+ "inverted": newFrame(64, 32, 320, 1),
544+ }
545+ inv := cases["inverted"]
546+ inv.YInvert = true
547+ cases["inverted"] = inv
548+ for name, f := range cases {
549+ t.Run(name, func(t *testing.T) {
550+ if err := f.Validate(); err != nil {
551+ t.Fatal(err)
552+ }
553+ if allocs := testing.AllocsPerRun(200, func() {
554+ if err := writeRows(io.Discard, f); err != nil {
555+ panic(err)
556+ }
557+ }); allocs != 0 {
558+ t.Fatalf("io.Discard: %g allocations", allocs)
559+ }
560+ var buf bytes.Buffer
561+ buf.Grow(f.Width * 4 * f.Height)
562+ if allocs := testing.AllocsPerRun(200, func() {
563+ buf.Reset()
564+ if err := writeRows(&buf, f); err != nil {
565+ panic(err)
566+ }
567+ }); allocs != 0 {
568+ t.Fatalf("bytes.Buffer: %g allocations", allocs)
569+ }
570+ })
571+ }
572+}
573+
574+func TestWriteRowsLayout(t *testing.T) {
575+ // 2x2, stride 12, stored bottom row first.
576+ pix := []byte{
577+ 5, 5, 5, 5, 6, 6, 6, 6, 9, 9, 9, 9,
578+ 1, 1, 1, 1, 2, 2, 2, 2, 9, 9, 9, 9,
579+ }
580+ f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: ports.XRGB8888, YInvert: true}
581+ var buf bytes.Buffer
582+ if err := writeRows(&buf, f); err != nil {
583+ t.Fatal(err)
584+ }
585+ want := []byte{1, 1, 1, 1, 2, 2, 2, 2, 5, 5, 5, 5, 6, 6, 6, 6}
586+ if !bytes.Equal(buf.Bytes(), want) {
587+ t.Fatalf("got %v, want %v", buf.Bytes(), want)
588+ }
589+}
590+
591+func TestStderrBounded(t *testing.T) {
592+ b := &boundedBuffer{limit: 8}
593+ for i := 0; i < 100; i++ {
594+ if n, err := b.Write([]byte("abcdef")); n != 6 || err != nil {
595+ t.Fatal(n, err)
596+ }
597+ }
598+ if got := b.String(); got != "abcdefab [truncated]" {
599+ t.Fatalf("got %q", got)
600+ }
601+}
602+
603+func BenchmarkVideoRows(b *testing.B) {
604+ for _, size := range []struct {
605+ name string
606+ w, h int
607+ invert bool
608+ }{{"1080p", 1920, 1080, false}, {"1080p-inverted", 1920, 1080, true}, {"4K", 3840, 2160, false}, {"4K-inverted", 3840, 2160, true}} {
609+ b.Run(size.name, func(b *testing.B) {
610+ f := newFrame(size.w, size.h, size.w*4, 1)
611+ f.YInvert = size.invert
612+ b.SetBytes(int64(size.w * size.h * 4))
613+ b.ReportAllocs()
614+ for b.Loop() {
615+ if err := writeRows(io.Discard, f); err != nil {
616+ b.Fatal(err)
617+ }
618+ }
619+ })
620+ }
621+}
622+
623+func TestWriteSteadyStateAllocations(t *testing.T) {
624+ w := helperWriter(t, "drain")
625+ w.closeTimeout = 10 * time.Second
626+ f := newFrame(64, 32, 320, 1)
627+ ctx, cancel := context.WithCancel(context.Background())
628+ defer cancel()
629+ if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
630+ t.Fatal(err)
631+ }
632+ if err := w.Write(ctx, f); err != nil { // registers the one watcher
633+ t.Fatal(err)
634+ }
635+ if allocs := testing.AllocsPerRun(200, func() {
636+ if err := w.Write(ctx, f); err != nil {
637+ panic(err)
638+ }
639+ }); allocs != 0 {
640+ t.Fatalf("Write to a real pipe: %g allocations", allocs)
641+ }
642+ if err := w.Close(); err != nil {
643+ t.Fatal(err)
644+ }
645+}
@@ -0,0 +1,645 @@
1+package ffmpeg
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "syscall"
14+ "testing"
15+ "time"
16+
17+ "github.com/bnema/nefercap/internal/ports"
18+)
19+
20+const helperEnv = "NEFERCAP_FFMPEG_HELPER"
21+
22+// TestMain turns the test binary into a stand-in encoder when helperEnv is set.
23+// The Writer runs it with FFmpeg's arguments; they are ignored.
24+func TestMain(m *testing.M) {
25+ switch mode := os.Getenv(helperEnv); mode {
26+ case "":
27+ os.Exit(m.Run())
28+ case "exit1": // fails immediately with a large diagnostic
29+ fmt.Fprint(os.Stderr, strings.Repeat("boom ", 10000))
30+ os.Exit(1)
31+ case "hang": // never reads stdin and ignores EOF
32+ time.Sleep(time.Hour)
33+ case "drain": // reads to EOF, writes output to fd 3, exits cleanly
34+ n, _ := io.Copy(io.Discard, os.Stdin)
35+ out := os.NewFile(3, "out")
36+ fmt.Fprintf(out, "bytes=%d", n)
37+ out.Close()
38+ os.Exit(0)
39+ case "slowclose": // drains, then refuses to exit after EOF
40+ _, _ = io.Copy(io.Discard, os.Stdin)
41+ time.Sleep(time.Hour)
42+ }
43+ os.Exit(2)
44+}
45+
46+func newFrame(w, h, stride int, fill byte) ports.Frame {
47+ pix := make([]byte, stride*h)
48+ for i := range pix {
49+ pix[i] = fill
50+ }
51+ return ports.Frame{Pixels: pix, Width: w, Height: h, Stride: stride, Format: ports.XRGB8888}
52+}
53+
54+func helperWriter(t *testing.T, mode string) *Writer {
55+ t.Helper()
56+ t.Setenv(helperEnv, mode)
57+ w := NewWithBinary(os.Args[0])
58+ w.closeTimeout = 300 * time.Millisecond
59+ w.abortTimeout = 300 * time.Millisecond
60+ t.Cleanup(func() { _ = w.Abort() })
61+ return w
62+}
63+
64+func gone(pid int) bool { return syscall.Kill(pid, 0) == syscall.ESRCH }
65+
66+func settings() ports.VideoSettings { return ports.VideoSettings{FPS: 30} }
67+
68+func TestStartValidation(t *testing.T) {
69+ f := newFrame(64, 48, 256, 0)
70+ odd := newFrame(63, 47, 256, 0)
71+ cases := []struct {
72+ name string
73+ frame ports.Frame
74+ set ports.VideoSettings
75+ ok bool
76+ }{
77+ {"fps zero", f, ports.VideoSettings{}, false},
78+ {"fps too high", f, ports.VideoSettings{FPS: 121}, false},
79+ {"one dimension", f, ports.VideoSettings{FPS: 30, Width: 32}, false},
80+ {"odd output", f, ports.VideoSettings{FPS: 30, Width: 33, Height: 32}, false},
81+ {"huge output", f, ports.VideoSettings{FPS: 30, Width: 16386, Height: 32}, false},
82+ {"odd source retained", odd, ports.VideoSettings{FPS: 30}, true},
83+ {"even scale", f, ports.VideoSettings{FPS: 120, Width: 32, Height: 24}, true},
84+ {"1x1 source retained", newFrame(1, 1, 4, 0), ports.VideoSettings{FPS: 30}, false},
85+ }
86+ for _, tc := range cases {
87+ t.Run(tc.name, func(t *testing.T) {
88+ w := helperWriter(t, "drain")
89+ path := filepath.Join(t.TempDir(), "v.mp4")
90+ err := w.Start(context.Background(), tc.frame, path, tc.set)
91+ if (err == nil) != tc.ok {
92+ t.Fatalf("Start error = %v, want ok=%v", err, tc.ok)
93+ }
94+ if !tc.ok {
95+ if _, serr := os.Stat(path); !errors.Is(serr, os.ErrNotExist) {
96+ t.Fatalf("rejected Start left a file: %v", serr)
97+ }
98+ }
99+ })
100+ }
101+}
102+
103+func TestBuildArgs(t *testing.T) {
104+ f := newFrame(63, 47, 252, 0)
105+ args := strings.Join(buildArgs(f, 62, 46, 30, true), " ")
106+ for _, want := range []string{"-pix_fmt bgr0", "-video_size 63x47", "crop=62:46:0:0,scale=62:46", "-preset fast", "libx264", "pipe:3", "-an"} {
107+ if !strings.Contains(args, want) {
108+ t.Errorf("args missing %q: %s", want, args)
109+ }
110+ }
111+ if strings.Contains(strings.Join(buildArgs(f, 32, 32, 30, false), " "), "crop") {
112+ t.Error("explicit scaling must not crop")
113+ }
114+}
115+
116+func TestStartRefusesExistingPath(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "v.mp4")
118+ if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
119+ t.Fatal(err)
120+ }
121+ w := helperWriter(t, "drain")
122+ err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings())
123+ if !errors.Is(err, ports.ErrPathExists) {
124+ t.Fatalf("got %v, want ErrPathExists", err)
125+ }
126+ if got, _ := os.ReadFile(path); string(got) != "keep" {
127+ t.Fatalf("existing file changed: %q", got)
128+ }
129+ if err := w.Write(context.Background(), newFrame(4, 4, 16, 0)); !errors.Is(err, ports.ErrClosed) {
130+ t.Fatalf("write on unstarted writer: %v", err)
131+ }
132+ if err := w.Close(); err != nil {
133+ t.Fatal(err)
134+ }
135+ if got, _ := os.ReadFile(path); string(got) != "keep" {
136+ t.Fatalf("Close touched foreign file: %q", got)
137+ }
138+}
139+
140+func TestStartFailureRemovesReservedPath(t *testing.T) {
141+ dir := t.TempDir()
142+ notExec := filepath.Join(dir, "encoder")
143+ if err := os.WriteFile(notExec, []byte("x"), 0o755); err != nil { // not a valid executable
144+ t.Fatal(err)
145+ }
146+ path := filepath.Join(dir, "v.mp4")
147+ w := NewWithBinary(notExec)
148+ if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err == nil {
149+ t.Fatal("Start succeeded with a broken encoder")
150+ }
151+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
152+ t.Fatalf("reserved path remains: %v", err)
153+ }
154+ w = NewWithBinary(filepath.Join(dir, "missing"))
155+ if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err == nil {
156+ t.Fatal("Start succeeded with a missing encoder")
157+ }
158+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
159+ t.Fatalf("reserved path remains: %v", err)
160+ }
161+}
162+
163+func TestDrainLifecycle(t *testing.T) {
164+ w := helperWriter(t, "drain")
165+ w.closeTimeout = 10 * time.Second
166+ path := filepath.Join(t.TempDir(), "v.mp4")
167+ ctx := context.Background()
168+ first := newFrame(4, 4, 16, 1)
169+ if err := w.Start(ctx, first, path, settings()); err != nil {
170+ t.Fatal(err)
171+ }
172+ if err := w.Start(ctx, first, path, settings()); err == nil {
173+ t.Fatal("second Start accepted")
174+ }
175+ // Stride changes are allowed; geometry and format changes are not.
176+ if err := w.Write(ctx, first); err != nil {
177+ t.Fatal(err)
178+ }
179+ if err := w.Write(ctx, newFrame(4, 4, 32, 2)); err != nil {
180+ t.Fatalf("stride change rejected: %v", err)
181+ }
182+ inv := newFrame(4, 4, 16, 3)
183+ inv.YInvert = true
184+ if err := w.Write(ctx, inv); err != nil {
185+ t.Fatal(err)
186+ }
187+ for _, bad := range []ports.Frame{newFrame(8, 4, 32, 0), newFrame(4, 8, 16, 0)} {
188+ if err := w.Write(ctx, bad); !errors.Is(err, ports.ErrGeometryChanged) {
189+ t.Fatalf("got %v, want ErrGeometryChanged", err)
190+ }
191+ }
192+ argb := newFrame(4, 4, 16, 0)
193+ argb.Format = ports.ARGB8888
194+ if err := w.Write(ctx, argb); !errors.Is(err, ports.ErrGeometryChanged) {
195+ t.Fatalf("format change: got %v", err)
196+ }
197+ // A rejected frame does not poison the stream.
198+ if err := w.Write(ctx, first); err != nil {
199+ t.Fatal(err)
200+ }
201+ pid := w.cmd.Process.Pid
202+ if err := w.Close(); err != nil {
203+ t.Fatal(err)
204+ }
205+ if err := w.Close(); err != nil {
206+ t.Fatalf("second Close: %v", err)
207+ }
208+ if err := w.Abort(); err != nil {
209+ t.Fatalf("Abort after Close: %v", err)
210+ }
211+ if err := w.Write(ctx, first); !errors.Is(err, ports.ErrClosed) {
212+ t.Fatalf("Write after Close: %v", err)
213+ }
214+ if !gone(pid) {
215+ t.Fatal("encoder not reaped")
216+ }
217+ // 4 frames of 4x4x4 bytes reached the encoder exactly (no padding).
218+ if got, _ := os.ReadFile(path); string(got) != "bytes=256" {
219+ t.Fatalf("encoder output %q", got)
220+ }
221+}
222+
223+func TestEncoderExitReportsBoundedDiagnostics(t *testing.T) {
224+ w := helperWriter(t, "exit1")
225+ path := filepath.Join(t.TempDir(), "v.mp4")
226+ ctx := context.Background()
227+ f := newFrame(64, 64, 256, 0)
228+ if err := w.Start(ctx, f, path, settings()); err != nil {
229+ t.Fatal(err)
230+ }
231+ var err error
232+ deadline := time.Now().Add(10 * time.Second)
233+ for err == nil && time.Now().Before(deadline) {
234+ err = w.Write(ctx, f)
235+ }
236+ if err == nil {
237+ t.Fatal("Write never failed")
238+ }
239+ if !strings.Contains(err.Error(), "boom") || len(err.Error()) > stderrLimit+512 {
240+ t.Fatalf("unexpected diagnostic (%d bytes): %.200s", len(err.Error()), err)
241+ }
242+ if !strings.Contains(err.Error(), "truncated") {
243+ t.Fatal("diagnostics were not bounded")
244+ }
245+ if err2 := w.Write(ctx, f); err2 == nil || err2.Error() != err.Error() {
246+ t.Fatalf("failure not sticky: %v", err2)
247+ }
248+ if err := w.Close(); err == nil {
249+ t.Fatal("Close hid encoder failure")
250+ }
251+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
252+ t.Fatalf("failed recording left a file: %v", err)
253+ }
254+}
255+
256+func TestCancelInterruptsBlockedWrite(t *testing.T) {
257+ w := helperWriter(t, "hang")
258+ path := filepath.Join(t.TempDir(), "v.mp4")
259+ f := newFrame(1024, 1024, 4096, 0) // 4 MiB fills the pipe
260+ if err := w.Start(context.Background(), f, path, settings()); err != nil {
261+ t.Fatal(err)
262+ }
263+ pid := w.cmd.Process.Pid
264+ ctx, cancel := context.WithCancel(context.Background())
265+ res := make(chan error, 1)
266+ go func() { res <- w.Write(ctx, f) }()
267+ select {
268+ case err := <-res:
269+ t.Fatalf("Write returned early: %v", err)
270+ case <-time.After(200 * time.Millisecond):
271+ }
272+ cancel()
273+ select {
274+ case err := <-res:
275+ if !errors.Is(err, context.Canceled) {
276+ t.Fatalf("got %v, want Canceled", err)
277+ }
278+ case <-time.After(5 * time.Second):
279+ t.Fatal("cancel did not interrupt blocked Write")
280+ }
281+ if err := w.Write(context.Background(), f); !errors.Is(err, context.Canceled) {
282+ t.Fatalf("Write after interruption: %v", err)
283+ }
284+ start := time.Now()
285+ if err := w.Close(); err == nil {
286+ t.Fatal("hung encoder reported success")
287+ }
288+ if d := time.Since(start); d > 5*time.Second {
289+ t.Fatalf("Close took %s", d)
290+ }
291+ if !gone(pid) {
292+ t.Fatal("hung encoder not killed and reaped")
293+ }
294+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
295+ t.Fatalf("unfinished output remains: %v", err)
296+ }
297+}
298+
299+func TestStartContextCancelInterruptsWrite(t *testing.T) {
300+ w := helperWriter(t, "hang")
301+ f := newFrame(1024, 1024, 4096, 0)
302+ ctx, cancel := context.WithCancel(context.Background())
303+ if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
304+ t.Fatal(err)
305+ }
306+ res := make(chan error, 1)
307+ go func() { res <- w.Write(context.Background(), f) }()
308+ time.Sleep(200 * time.Millisecond)
309+ cancel()
310+ select {
311+ case err := <-res:
312+ if !errors.Is(err, context.Canceled) {
313+ t.Fatalf("got %v", err)
314+ }
315+ case <-time.After(5 * time.Second):
316+ t.Fatal("Start context cancel did not interrupt Write")
317+ }
318+}
319+
320+func TestAbortInterruptsBlockedWriteAndIsIdempotent(t *testing.T) {
321+ w := helperWriter(t, "hang")
322+ path := filepath.Join(t.TempDir(), "v.mp4")
323+ f := newFrame(1024, 1024, 4096, 0)
324+ if err := w.Start(context.Background(), f, path, settings()); err != nil {
325+ t.Fatal(err)
326+ }
327+ pid := w.cmd.Process.Pid
328+ res := make(chan error, 1)
329+ go func() { res <- w.Write(context.Background(), f) }()
330+ time.Sleep(200 * time.Millisecond)
331+ aborts := make(chan error, 3)
332+ for i := 0; i < 3; i++ {
333+ go func() { aborts <- w.Abort() }()
334+ }
335+ for i := 0; i < 3; i++ {
336+ select {
337+ case <-aborts:
338+ case <-time.After(10 * time.Second):
339+ t.Fatal("Abort hung")
340+ }
341+ }
342+ select {
343+ case err := <-res:
344+ if !errors.Is(err, ports.ErrClosed) {
345+ t.Fatalf("got %v, want ErrClosed", err)
346+ }
347+ case <-time.After(5 * time.Second):
348+ t.Fatal("Write still blocked after Abort")
349+ }
350+ if !gone(pid) {
351+ t.Fatal("encoder not reaped")
352+ }
353+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
354+ t.Fatalf("aborted output remains: %v", err)
355+ }
356+}
357+
358+func TestAbortEscalatesDrainingClose(t *testing.T) {
359+ w := helperWriter(t, "slowclose")
360+ w.closeTimeout = time.Minute
361+ path := filepath.Join(t.TempDir(), "v.mp4")
362+ f := newFrame(4, 4, 16, 0)
363+ if err := w.Start(context.Background(), f, path, settings()); err != nil {
364+ t.Fatal(err)
365+ }
366+ if err := w.Write(context.Background(), f); err != nil {
367+ t.Fatal(err)
368+ }
369+ pid := w.cmd.Process.Pid
370+ closed := make(chan error, 1)
371+ go func() { closed <- w.Close() }()
372+ time.Sleep(200 * time.Millisecond)
373+ _ = w.Abort()
374+ select {
375+ case err := <-closed:
376+ if err == nil {
377+ t.Fatal("killed encoder reported success")
378+ }
379+ case <-time.After(10 * time.Second):
380+ t.Fatal("Close still draining after Abort")
381+ }
382+ if !gone(pid) {
383+ t.Fatal("encoder not reaped")
384+ }
385+}
386+
387+func TestCloseWatchdogKillsStuckEncoder(t *testing.T) {
388+ w := helperWriter(t, "slowclose")
389+ f := newFrame(4, 4, 16, 0)
390+ if err := w.Start(context.Background(), f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
391+ t.Fatal(err)
392+ }
393+ if err := w.Write(context.Background(), f); err != nil {
394+ t.Fatal(err)
395+ }
396+ pid := w.cmd.Process.Pid
397+ start := time.Now()
398+ err := w.Close()
399+ if err == nil || !strings.Contains(err.Error(), "killed") {
400+ t.Fatalf("got %v, want kill error", err)
401+ }
402+ if time.Since(start) > 5*time.Second || !gone(pid) {
403+ t.Fatal("watchdog did not bound shutdown")
404+ }
405+}
406+
407+func TestCloseWithoutFrames(t *testing.T) {
408+ w := helperWriter(t, "drain")
409+ w.closeTimeout = 10 * time.Second // helper start-up is slow under -race
410+ path := filepath.Join(t.TempDir(), "v.mp4")
411+ if err := w.Start(context.Background(), newFrame(4, 4, 16, 0), path, settings()); err != nil {
412+ t.Fatal(err)
413+ }
414+ if err := w.Close(); !errors.Is(err, ErrNoFrames) {
415+ t.Fatalf("got %v, want ErrNoFrames", err)
416+ }
417+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
418+ t.Fatalf("empty recording remains: %v", err)
419+ }
420+}
421+
422+func TestNeverStartedTerminalCallsAreNoOps(t *testing.T) {
423+ w := New()
424+ if err := w.Abort(); err != nil {
425+ t.Fatal(err)
426+ }
427+ if err := w.Close(); err != nil {
428+ t.Fatal(err)
429+ }
430+}
431+
432+func TestWriteAfterCloseDoesNotLeakWatchers(t *testing.T) {
433+ w := helperWriter(t, "drain")
434+ w.closeTimeout = 10 * time.Second
435+ f := newFrame(4, 4, 16, 0)
436+ ctx, cancel := context.WithCancel(context.Background())
437+ defer cancel()
438+ if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
439+ t.Fatal(err)
440+ }
441+ for i := 0; i < 100; i++ { // one watcher registration for the whole loop
442+ if err := w.Write(ctx, f); err != nil {
443+ t.Fatal(err)
444+ }
445+ }
446+ if w.writeGen != 1 {
447+ t.Fatalf("watcher registered %d times for one context", w.writeGen)
448+ }
449+ if err := w.Close(); err != nil {
450+ t.Fatal(err)
451+ }
452+ cancel() // after Close: must be harmless
453+}
454+
455+func TestRealFFmpeg(t *testing.T) {
456+ bin, err := exec.LookPath("ffmpeg")
457+ if err != nil {
458+ t.Skip("ffmpeg not installed")
459+ }
460+ if out, _ := exec.Command(bin, "-hide_banner", "-encoders").Output(); !bytes.Contains(out, []byte("libx264")) {
461+ t.Skip("ffmpeg lacks libx264")
462+ }
463+ probe, perr := exec.LookPath("ffprobe")
464+
465+ for _, tc := range []struct {
466+ name string
467+ w, h, stride int
468+ set ports.VideoSettings
469+ wantW, wantH int
470+ invert bool
471+ cancelInstead bool
472+ }{
473+ {"retain padded inverted", 64, 48, 64*4 + 16, ports.VideoSettings{FPS: 30}, 64, 48, true, false},
474+ {"odd source cropped", 63, 47, 63 * 4, ports.VideoSettings{FPS: 30}, 62, 46, false, false},
475+ {"scaled", 64, 48, 64 * 4, ports.VideoSettings{FPS: 60, Width: 32, Height: 24}, 32, 24, false, false},
476+ {"graceful cancel keeps video", 64, 48, 64 * 4, ports.VideoSettings{FPS: 30}, 64, 48, false, true},
477+ } {
478+ t.Run(tc.name, func(t *testing.T) {
479+ w := New()
480+ path := filepath.Join(t.TempDir(), "out.mp4")
481+ ctx, cancel := context.WithCancel(context.Background())
482+ defer cancel()
483+ f := newFrame(tc.w, tc.h, tc.stride, 0)
484+ f.YInvert = tc.invert
485+ if err := w.Start(ctx, f, path, tc.set); err != nil {
486+ t.Fatal(err)
487+ }
488+ for i := 0; i < 30; i++ {
489+ for j := range f.Pixels {
490+ f.Pixels[j] = byte(i*8 + j)
491+ }
492+ if err := w.Write(ctx, f); err != nil {
493+ t.Fatalf("frame %d: %v", i, err)
494+ }
495+ }
496+ if tc.cancelInstead {
497+ cancel() // signal-style stop: the encoder must still finalize
498+ }
499+ if err := w.Close(); err != nil {
500+ t.Fatal(err)
501+ }
502+ info, err := os.Stat(path)
503+ if err != nil || info.Size() == 0 {
504+ t.Fatalf("no output: %v", err)
505+ }
506+ if perr != nil {
507+ return
508+ }
509+ out, err := exec.Command(probe, "-v", "error", "-select_streams", "v:0", "-count_frames",
510+ "-show_entries", "stream=codec_name,width,height,nb_read_frames,pix_fmt", "-of", "csv=p=0", path).Output()
511+ if err != nil {
512+ t.Fatalf("ffprobe: %v", err)
513+ }
514+ want := fmt.Sprintf("h264,%d,%d,yuv420p,30", tc.wantW, tc.wantH)
515+ if got := strings.TrimSpace(string(out)); got != want {
516+ t.Fatalf("probe = %q, want %q", got, want)
517+ }
518+ if streams, _ := exec.Command(probe, "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", path).Output(); strings.Contains(string(streams), "audio") {
519+ t.Fatal("output has audio")
520+ }
521+ })
522+ }
523+}
524+
525+func TestRealFFmpegRefusesExistingPath(t *testing.T) {
526+ if _, err := exec.LookPath("ffmpeg"); err != nil {
527+ t.Skip("ffmpeg not installed")
528+ }
529+ path := filepath.Join(t.TempDir(), "out.mp4")
530+ if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
531+ t.Fatal(err)
532+ }
533+ w := New()
534+ if err := w.Start(context.Background(), newFrame(64, 48, 256, 0), path, settings()); !errors.Is(err, ports.ErrPathExists) {
535+ t.Fatalf("got %v", err)
536+ }
537+}
538+
539+func TestVideoRowsAllocations(t *testing.T) {
540+ cases := map[string]ports.Frame{
541+ "packed": newFrame(64, 32, 256, 1),
542+ "padded": newFrame(64, 32, 320, 1),
543+ "inverted": newFrame(64, 32, 320, 1),
544+ }
545+ inv := cases["inverted"]
546+ inv.YInvert = true
547+ cases["inverted"] = inv
548+ for name, f := range cases {
549+ t.Run(name, func(t *testing.T) {
550+ if err := f.Validate(); err != nil {
551+ t.Fatal(err)
552+ }
553+ if allocs := testing.AllocsPerRun(200, func() {
554+ if err := writeRows(io.Discard, f); err != nil {
555+ panic(err)
556+ }
557+ }); allocs != 0 {
558+ t.Fatalf("io.Discard: %g allocations", allocs)
559+ }
560+ var buf bytes.Buffer
561+ buf.Grow(f.Width * 4 * f.Height)
562+ if allocs := testing.AllocsPerRun(200, func() {
563+ buf.Reset()
564+ if err := writeRows(&buf, f); err != nil {
565+ panic(err)
566+ }
567+ }); allocs != 0 {
568+ t.Fatalf("bytes.Buffer: %g allocations", allocs)
569+ }
570+ })
571+ }
572+}
573+
574+func TestWriteRowsLayout(t *testing.T) {
575+ // 2x2, stride 12, stored bottom row first.
576+ pix := []byte{
577+ 5, 5, 5, 5, 6, 6, 6, 6, 9, 9, 9, 9,
578+ 1, 1, 1, 1, 2, 2, 2, 2, 9, 9, 9, 9,
579+ }
580+ f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: ports.XRGB8888, YInvert: true}
581+ var buf bytes.Buffer
582+ if err := writeRows(&buf, f); err != nil {
583+ t.Fatal(err)
584+ }
585+ want := []byte{1, 1, 1, 1, 2, 2, 2, 2, 5, 5, 5, 5, 6, 6, 6, 6}
586+ if !bytes.Equal(buf.Bytes(), want) {
587+ t.Fatalf("got %v, want %v", buf.Bytes(), want)
588+ }
589+}
590+
591+func TestStderrBounded(t *testing.T) {
592+ b := &boundedBuffer{limit: 8}
593+ for i := 0; i < 100; i++ {
594+ if n, err := b.Write([]byte("abcdef")); n != 6 || err != nil {
595+ t.Fatal(n, err)
596+ }
597+ }
598+ if got := b.String(); got != "abcdefab [truncated]" {
599+ t.Fatalf("got %q", got)
600+ }
601+}
602+
603+func BenchmarkVideoRows(b *testing.B) {
604+ for _, size := range []struct {
605+ name string
606+ w, h int
607+ invert bool
608+ }{{"1080p", 1920, 1080, false}, {"1080p-inverted", 1920, 1080, true}, {"4K", 3840, 2160, false}, {"4K-inverted", 3840, 2160, true}} {
609+ b.Run(size.name, func(b *testing.B) {
610+ f := newFrame(size.w, size.h, size.w*4, 1)
611+ f.YInvert = size.invert
612+ b.SetBytes(int64(size.w * size.h * 4))
613+ b.ReportAllocs()
614+ for b.Loop() {
615+ if err := writeRows(io.Discard, f); err != nil {
616+ b.Fatal(err)
617+ }
618+ }
619+ })
620+ }
621+}
622+
623+func TestWriteSteadyStateAllocations(t *testing.T) {
624+ w := helperWriter(t, "drain")
625+ w.closeTimeout = 10 * time.Second
626+ f := newFrame(64, 32, 320, 1)
627+ ctx, cancel := context.WithCancel(context.Background())
628+ defer cancel()
629+ if err := w.Start(ctx, f, filepath.Join(t.TempDir(), "v.mp4"), settings()); err != nil {
630+ t.Fatal(err)
631+ }
632+ if err := w.Write(ctx, f); err != nil { // registers the one watcher
633+ t.Fatal(err)
634+ }
635+ if allocs := testing.AllocsPerRun(200, func() {
636+ if err := w.Write(ctx, f); err != nil {
637+ panic(err)
638+ }
639+ }); allocs != 0 {
640+ t.Fatalf("Write to a real pipe: %g allocations", allocs)
641+ }
642+ if err := w.Close(); err != nil {
643+ t.Fatal(err)
644+ }
645+}
A internal/adapters/png/png.go +126 -0
@@ -0,0 +1,126 @@
1+// Package png writes opaque SDR captures as PNG files.
2+package png
3+
4+import (
5+ "bufio"
6+ "context"
7+ "errors"
8+ "fmt"
9+ "image"
10+ stdpng "image/png"
11+ "io"
12+ "io/fs"
13+ "os"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+// ctxCheckRows bounds how many rows are converted between cancellation checks.
19+const ctxCheckRows = 64
20+
21+// Writer implements ports.ScreenshotWriter. It is stateless and safe for
22+// concurrent use; each Save owns its own conversion buffer.
23+type Writer struct{}
24+
25+var _ ports.ScreenshotWriter = (*Writer)(nil)
26+
27+// New returns a PNG screenshot writer.
28+func New() *Writer { return &Writer{} }
29+
30+// Save encodes f as an opaque RGB PNG at path. The path is created with
31+// O_EXCL, so an existing file (or symlink) is never overwritten and yields
32+// ports.ErrPathExists. Any file created by a failed Save is removed. All reads
33+// of f.Pixels complete before Save returns.
34+func (*Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) {
35+ if err := ctx.Err(); err != nil {
36+ return err
37+ }
38+ if err := f.Validate(); err != nil {
39+ return err
40+ }
41+ if path == "" {
42+ return errors.New("png: empty path")
43+ }
44+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
45+ if err != nil {
46+ if errors.Is(err, fs.ErrExist) {
47+ return fmt.Errorf("%w: %s", ports.ErrPathExists, path)
48+ }
49+ return fmt.Errorf("png: create output: %w", err)
50+ }
51+ // We created the file exclusively, so we own it and may remove it.
52+ defer func() {
53+ if err == nil {
54+ if cerr := file.Close(); cerr != nil {
55+ err = fmt.Errorf("png: close output: %w", cerr)
56+ }
57+ } else {
58+ _ = file.Close()
59+ }
60+ if err != nil {
61+ if rerr := os.Remove(path); rerr != nil && !errors.Is(rerr, fs.ErrNotExist) {
62+ err = errors.Join(err, fmt.Errorf("png: remove partial output: %w", rerr))
63+ }
64+ }
65+ }()
66+ return encode(ctx, file, f)
67+}
68+
69+// encode writes f to w. The frame is converted once to opaque RGBA because the
70+// encoder only has fast paths for the standard image types and stored pixels
71+// are BGRA; the copy is exactly one frame and is released on return.
72+func encode(ctx context.Context, w io.Writer, f ports.Frame) error {
73+ img, err := toRGBA(ctx, f)
74+ if err != nil {
75+ return err
76+ }
77+ bw := bufio.NewWriterSize(&ctxWriter{ctx: ctx, w: w}, 64<<10)
78+ if err := stdpng.Encode(bw, img); err != nil {
79+ return fmt.Errorf("png: encode: %w", err)
80+ }
81+ if err := bw.Flush(); err != nil {
82+ return fmt.Errorf("png: write: %w", err)
83+ }
84+ return nil
85+}
86+
87+// toRGBA converts top-to-bottom, honoring stride and YInvert, and forces alpha
88+// to 255 because output is opaque (XRGB padding and ARGB alpha are ignored).
89+func toRGBA(ctx context.Context, f ports.Frame) (*image.RGBA, error) {
90+ rowLen := f.Width * ports.BytesPerPixel
91+ img := &image.RGBA{
92+ Pix: make([]byte, rowLen*f.Height),
93+ Stride: rowLen,
94+ Rect: image.Rect(0, 0, f.Width, f.Height),
95+ }
96+ for y := 0; y < f.Height; y++ {
97+ if y%ctxCheckRows == 0 {
98+ if err := ctx.Err(); err != nil {
99+ return nil, err
100+ }
101+ }
102+ src := f.Row(y)
103+ dst := img.Pix[y*rowLen : (y+1)*rowLen]
104+ dst = dst[:len(src)]
105+ for x := 0; x+3 < len(src); x += 4 {
106+ dst[x] = src[x+2]
107+ dst[x+1] = src[x+1]
108+ dst[x+2] = src[x]
109+ dst[x+3] = 0xff
110+ }
111+ }
112+ return img, nil
113+}
114+
115+// ctxWriter makes a long encode stop at the next write after cancellation.
116+type ctxWriter struct {
117+ ctx context.Context
118+ w io.Writer
119+}
120+
121+func (c *ctxWriter) Write(p []byte) (int, error) {
122+ if err := c.ctx.Err(); err != nil {
123+ return 0, err
124+ }
125+ return c.w.Write(p)
126+}
@@ -0,0 +1,126 @@
1+// Package png writes opaque SDR captures as PNG files.
2+package png
3+
4+import (
5+ "bufio"
6+ "context"
7+ "errors"
8+ "fmt"
9+ "image"
10+ stdpng "image/png"
11+ "io"
12+ "io/fs"
13+ "os"
14+
15+ "github.com/bnema/nefercap/internal/ports"
16+)
17+
18+// ctxCheckRows bounds how many rows are converted between cancellation checks.
19+const ctxCheckRows = 64
20+
21+// Writer implements ports.ScreenshotWriter. It is stateless and safe for
22+// concurrent use; each Save owns its own conversion buffer.
23+type Writer struct{}
24+
25+var _ ports.ScreenshotWriter = (*Writer)(nil)
26+
27+// New returns a PNG screenshot writer.
28+func New() *Writer { return &Writer{} }
29+
30+// Save encodes f as an opaque RGB PNG at path. The path is created with
31+// O_EXCL, so an existing file (or symlink) is never overwritten and yields
32+// ports.ErrPathExists. Any file created by a failed Save is removed. All reads
33+// of f.Pixels complete before Save returns.
34+func (*Writer) Save(ctx context.Context, f ports.Frame, path string) (err error) {
35+ if err := ctx.Err(); err != nil {
36+ return err
37+ }
38+ if err := f.Validate(); err != nil {
39+ return err
40+ }
41+ if path == "" {
42+ return errors.New("png: empty path")
43+ }
44+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
45+ if err != nil {
46+ if errors.Is(err, fs.ErrExist) {
47+ return fmt.Errorf("%w: %s", ports.ErrPathExists, path)
48+ }
49+ return fmt.Errorf("png: create output: %w", err)
50+ }
51+ // We created the file exclusively, so we own it and may remove it.
52+ defer func() {
53+ if err == nil {
54+ if cerr := file.Close(); cerr != nil {
55+ err = fmt.Errorf("png: close output: %w", cerr)
56+ }
57+ } else {
58+ _ = file.Close()
59+ }
60+ if err != nil {
61+ if rerr := os.Remove(path); rerr != nil && !errors.Is(rerr, fs.ErrNotExist) {
62+ err = errors.Join(err, fmt.Errorf("png: remove partial output: %w", rerr))
63+ }
64+ }
65+ }()
66+ return encode(ctx, file, f)
67+}
68+
69+// encode writes f to w. The frame is converted once to opaque RGBA because the
70+// encoder only has fast paths for the standard image types and stored pixels
71+// are BGRA; the copy is exactly one frame and is released on return.
72+func encode(ctx context.Context, w io.Writer, f ports.Frame) error {
73+ img, err := toRGBA(ctx, f)
74+ if err != nil {
75+ return err
76+ }
77+ bw := bufio.NewWriterSize(&ctxWriter{ctx: ctx, w: w}, 64<<10)
78+ if err := stdpng.Encode(bw, img); err != nil {
79+ return fmt.Errorf("png: encode: %w", err)
80+ }
81+ if err := bw.Flush(); err != nil {
82+ return fmt.Errorf("png: write: %w", err)
83+ }
84+ return nil
85+}
86+
87+// toRGBA converts top-to-bottom, honoring stride and YInvert, and forces alpha
88+// to 255 because output is opaque (XRGB padding and ARGB alpha are ignored).
89+func toRGBA(ctx context.Context, f ports.Frame) (*image.RGBA, error) {
90+ rowLen := f.Width * ports.BytesPerPixel
91+ img := &image.RGBA{
92+ Pix: make([]byte, rowLen*f.Height),
93+ Stride: rowLen,
94+ Rect: image.Rect(0, 0, f.Width, f.Height),
95+ }
96+ for y := 0; y < f.Height; y++ {
97+ if y%ctxCheckRows == 0 {
98+ if err := ctx.Err(); err != nil {
99+ return nil, err
100+ }
101+ }
102+ src := f.Row(y)
103+ dst := img.Pix[y*rowLen : (y+1)*rowLen]
104+ dst = dst[:len(src)]
105+ for x := 0; x+3 < len(src); x += 4 {
106+ dst[x] = src[x+2]
107+ dst[x+1] = src[x+1]
108+ dst[x+2] = src[x]
109+ dst[x+3] = 0xff
110+ }
111+ }
112+ return img, nil
113+}
114+
115+// ctxWriter makes a long encode stop at the next write after cancellation.
116+type ctxWriter struct {
117+ ctx context.Context
118+ w io.Writer
119+}
120+
121+func (c *ctxWriter) Write(p []byte) (int, error) {
122+ if err := c.ctx.Err(); err != nil {
123+ return 0, err
124+ }
125+ return c.w.Write(p)
126+}
A internal/adapters/png/png_test.go +182 -0
@@ -0,0 +1,182 @@
1+package png
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "image"
8+ "image/color"
9+ stdpng "image/png"
10+ "math/rand/v2"
11+ "os"
12+ "path/filepath"
13+ "testing"
14+ "time"
15+
16+ "github.com/bnema/nefercap/internal/ports"
17+)
18+
19+func decode(t *testing.T, path string) image.Image {
20+ t.Helper()
21+ data, err := os.ReadFile(path)
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ img, err := stdpng.Decode(bytes.NewReader(data))
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ return img
30+}
31+
32+func TestSaveConvertsBGRAWithStrideAndInvert(t *testing.T) {
33+ // 2x2 pixels, stride 12 (4 padding bytes per row), stored bottom-first.
34+ // Pixel bytes are B,G,R,A; alpha values must be ignored.
35+ pix := []byte{
36+ // stored row 0 == bottom row
37+ 30, 20, 10, 0, 60, 50, 40, 7, 0xEE, 0xEE, 0xEE, 0xEE,
38+ // stored row 1 == top row
39+ 3, 2, 1, 255, 6, 5, 4, 0, 0xEE, 0xEE, 0xEE, 0xEE,
40+ }
41+ for _, format := range []ports.PixelFormat{ports.ARGB8888, ports.XRGB8888} {
42+ f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: format, YInvert: true}
43+ path := filepath.Join(t.TempDir(), "shot.png")
44+ if err := New().Save(context.Background(), f, path); err != nil {
45+ t.Fatal(err)
46+ }
47+ img := decode(t, path)
48+ want := [2][2]color.RGBA{
49+ {{1, 2, 3, 255}, {4, 5, 6, 255}},
50+ {{10, 20, 30, 255}, {40, 50, 60, 255}},
51+ }
52+ for y := 0; y < 2; y++ {
53+ for x := 0; x < 2; x++ {
54+ if got := color.RGBAModel.Convert(img.At(x, y)); got != want[y][x] {
55+ t.Fatalf("format %d pixel (%d,%d): got %v want %v", format, x, y, got, want[y][x])
56+ }
57+ }
58+ }
59+ raw, err := os.ReadFile(path)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ if raw[25] != 2 { // IHDR colour type 2 is truecolour without alpha
64+ t.Fatalf("colour type %d, want 2 (opaque RGB)", raw[25])
65+ }
66+ }
67+}
68+
69+func TestSaveDoesNotModifyFrame(t *testing.T) {
70+ pix := []byte{1, 2, 3, 4, 5, 6, 7, 8}
71+ orig := bytes.Clone(pix)
72+ f := ports.Frame{Pixels: pix, Width: 2, Height: 1, Stride: 8, Format: ARGB}
73+ if err := New().Save(context.Background(), f, filepath.Join(t.TempDir(), "a.png")); err != nil {
74+ t.Fatal(err)
75+ }
76+ if !bytes.Equal(pix, orig) {
77+ t.Fatal("frame storage was modified")
78+ }
79+}
80+
81+const ARGB = ports.ARGB8888
82+
83+func TestSaveNeverOverwrites(t *testing.T) {
84+ path := filepath.Join(t.TempDir(), "exists.png")
85+ if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
86+ t.Fatal(err)
87+ }
88+ f := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
89+ err := New().Save(context.Background(), f, path)
90+ if !errors.Is(err, ports.ErrPathExists) {
91+ t.Fatalf("got %v, want ErrPathExists", err)
92+ }
93+ if got, _ := os.ReadFile(path); string(got) != "keep" {
94+ t.Fatalf("existing file changed: %q", got)
95+ }
96+}
97+
98+func TestSaveDoesNotFollowSymlink(t *testing.T) {
99+ dir := t.TempDir()
100+ target := filepath.Join(dir, "target")
101+ link := filepath.Join(dir, "link.png")
102+ if err := os.Symlink(target, link); err != nil {
103+ t.Skip(err)
104+ }
105+ f := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
106+ if err := New().Save(context.Background(), f, link); !errors.Is(err, ports.ErrPathExists) {
107+ t.Fatalf("got %v, want ErrPathExists", err)
108+ }
109+ if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
110+ t.Fatalf("symlink target was created: %v", err)
111+ }
112+}
113+
114+func TestSaveInvalidInputCreatesNothing(t *testing.T) {
115+ dir := t.TempDir()
116+ path := filepath.Join(dir, "bad.png")
117+ bad := ports.Frame{Pixels: make([]byte, 3), Width: 1, Height: 1, Stride: 4, Format: ARGB}
118+ if err := New().Save(context.Background(), bad, path); err == nil {
119+ t.Fatal("accepted short frame")
120+ }
121+ ctx, cancel := context.WithCancel(context.Background())
122+ cancel()
123+ ok := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
124+ if err := New().Save(ctx, ok, path); !errors.Is(err, context.Canceled) {
125+ t.Fatalf("got %v, want context.Canceled", err)
126+ }
127+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
128+ t.Fatalf("unexpected files: %v", entries)
129+ }
130+}
131+
132+func TestSaveCancelDuringEncodeRemovesPartialOutput(t *testing.T) {
133+ const side = 2048
134+ pix := make([]byte, side*side*4)
135+ rng := rand.New(rand.NewPCG(1, 2))
136+ for i := range pix {
137+ pix[i] = byte(rng.Uint32())
138+ }
139+ f := ports.Frame{Pixels: pix, Width: side, Height: side, Stride: side * 4, Format: ports.XRGB8888}
140+ path := filepath.Join(t.TempDir(), "partial.png")
141+ ctx, cancel := context.WithCancel(context.Background())
142+ defer cancel()
143+ go func() {
144+ for ctx.Err() == nil {
145+ if _, err := os.Stat(path); err == nil {
146+ cancel()
147+ return
148+ }
149+ time.Sleep(50 * time.Microsecond)
150+ }
151+ }()
152+ if err := New().Save(ctx, f, path); !errors.Is(err, context.Canceled) {
153+ t.Fatalf("got %v, want context.Canceled", err)
154+ }
155+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
156+ t.Fatalf("partial output remains: %v", err)
157+ }
158+}
159+
160+func BenchmarkSave(b *testing.B) {
161+ for _, size := range []struct {
162+ name string
163+ w, h int
164+ }{{"1080p", 1920, 1080}, {"4K", 3840, 2160}} {
165+ b.Run(size.name, func(b *testing.B) {
166+ f := ports.Frame{Pixels: make([]byte, size.w*size.h*4), Width: size.w, Height: size.h, Stride: size.w * 4, Format: ports.XRGB8888}
167+ dir := b.TempDir()
168+ w := New()
169+ b.ReportAllocs()
170+ b.ResetTimer()
171+ for i := 0; b.Loop(); i++ {
172+ b.StopTimer()
173+ path := filepath.Join(dir, "b.png")
174+ _ = os.Remove(path)
175+ b.StartTimer()
176+ if err := w.Save(context.Background(), f, path); err != nil {
177+ b.Fatal(err)
178+ }
179+ }
180+ })
181+ }
182+}
@@ -0,0 +1,182 @@
1+package png
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "image"
8+ "image/color"
9+ stdpng "image/png"
10+ "math/rand/v2"
11+ "os"
12+ "path/filepath"
13+ "testing"
14+ "time"
15+
16+ "github.com/bnema/nefercap/internal/ports"
17+)
18+
19+func decode(t *testing.T, path string) image.Image {
20+ t.Helper()
21+ data, err := os.ReadFile(path)
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ img, err := stdpng.Decode(bytes.NewReader(data))
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ return img
30+}
31+
32+func TestSaveConvertsBGRAWithStrideAndInvert(t *testing.T) {
33+ // 2x2 pixels, stride 12 (4 padding bytes per row), stored bottom-first.
34+ // Pixel bytes are B,G,R,A; alpha values must be ignored.
35+ pix := []byte{
36+ // stored row 0 == bottom row
37+ 30, 20, 10, 0, 60, 50, 40, 7, 0xEE, 0xEE, 0xEE, 0xEE,
38+ // stored row 1 == top row
39+ 3, 2, 1, 255, 6, 5, 4, 0, 0xEE, 0xEE, 0xEE, 0xEE,
40+ }
41+ for _, format := range []ports.PixelFormat{ports.ARGB8888, ports.XRGB8888} {
42+ f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: format, YInvert: true}
43+ path := filepath.Join(t.TempDir(), "shot.png")
44+ if err := New().Save(context.Background(), f, path); err != nil {
45+ t.Fatal(err)
46+ }
47+ img := decode(t, path)
48+ want := [2][2]color.RGBA{
49+ {{1, 2, 3, 255}, {4, 5, 6, 255}},
50+ {{10, 20, 30, 255}, {40, 50, 60, 255}},
51+ }
52+ for y := 0; y < 2; y++ {
53+ for x := 0; x < 2; x++ {
54+ if got := color.RGBAModel.Convert(img.At(x, y)); got != want[y][x] {
55+ t.Fatalf("format %d pixel (%d,%d): got %v want %v", format, x, y, got, want[y][x])
56+ }
57+ }
58+ }
59+ raw, err := os.ReadFile(path)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ if raw[25] != 2 { // IHDR colour type 2 is truecolour without alpha
64+ t.Fatalf("colour type %d, want 2 (opaque RGB)", raw[25])
65+ }
66+ }
67+}
68+
69+func TestSaveDoesNotModifyFrame(t *testing.T) {
70+ pix := []byte{1, 2, 3, 4, 5, 6, 7, 8}
71+ orig := bytes.Clone(pix)
72+ f := ports.Frame{Pixels: pix, Width: 2, Height: 1, Stride: 8, Format: ARGB}
73+ if err := New().Save(context.Background(), f, filepath.Join(t.TempDir(), "a.png")); err != nil {
74+ t.Fatal(err)
75+ }
76+ if !bytes.Equal(pix, orig) {
77+ t.Fatal("frame storage was modified")
78+ }
79+}
80+
81+const ARGB = ports.ARGB8888
82+
83+func TestSaveNeverOverwrites(t *testing.T) {
84+ path := filepath.Join(t.TempDir(), "exists.png")
85+ if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
86+ t.Fatal(err)
87+ }
88+ f := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
89+ err := New().Save(context.Background(), f, path)
90+ if !errors.Is(err, ports.ErrPathExists) {
91+ t.Fatalf("got %v, want ErrPathExists", err)
92+ }
93+ if got, _ := os.ReadFile(path); string(got) != "keep" {
94+ t.Fatalf("existing file changed: %q", got)
95+ }
96+}
97+
98+func TestSaveDoesNotFollowSymlink(t *testing.T) {
99+ dir := t.TempDir()
100+ target := filepath.Join(dir, "target")
101+ link := filepath.Join(dir, "link.png")
102+ if err := os.Symlink(target, link); err != nil {
103+ t.Skip(err)
104+ }
105+ f := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
106+ if err := New().Save(context.Background(), f, link); !errors.Is(err, ports.ErrPathExists) {
107+ t.Fatalf("got %v, want ErrPathExists", err)
108+ }
109+ if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
110+ t.Fatalf("symlink target was created: %v", err)
111+ }
112+}
113+
114+func TestSaveInvalidInputCreatesNothing(t *testing.T) {
115+ dir := t.TempDir()
116+ path := filepath.Join(dir, "bad.png")
117+ bad := ports.Frame{Pixels: make([]byte, 3), Width: 1, Height: 1, Stride: 4, Format: ARGB}
118+ if err := New().Save(context.Background(), bad, path); err == nil {
119+ t.Fatal("accepted short frame")
120+ }
121+ ctx, cancel := context.WithCancel(context.Background())
122+ cancel()
123+ ok := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
124+ if err := New().Save(ctx, ok, path); !errors.Is(err, context.Canceled) {
125+ t.Fatalf("got %v, want context.Canceled", err)
126+ }
127+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
128+ t.Fatalf("unexpected files: %v", entries)
129+ }
130+}
131+
132+func TestSaveCancelDuringEncodeRemovesPartialOutput(t *testing.T) {
133+ const side = 2048
134+ pix := make([]byte, side*side*4)
135+ rng := rand.New(rand.NewPCG(1, 2))
136+ for i := range pix {
137+ pix[i] = byte(rng.Uint32())
138+ }
139+ f := ports.Frame{Pixels: pix, Width: side, Height: side, Stride: side * 4, Format: ports.XRGB8888}
140+ path := filepath.Join(t.TempDir(), "partial.png")
141+ ctx, cancel := context.WithCancel(context.Background())
142+ defer cancel()
143+ go func() {
144+ for ctx.Err() == nil {
145+ if _, err := os.Stat(path); err == nil {
146+ cancel()
147+ return
148+ }
149+ time.Sleep(50 * time.Microsecond)
150+ }
151+ }()
152+ if err := New().Save(ctx, f, path); !errors.Is(err, context.Canceled) {
153+ t.Fatalf("got %v, want context.Canceled", err)
154+ }
155+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
156+ t.Fatalf("partial output remains: %v", err)
157+ }
158+}
159+
160+func BenchmarkSave(b *testing.B) {
161+ for _, size := range []struct {
162+ name string
163+ w, h int
164+ }{{"1080p", 1920, 1080}, {"4K", 3840, 2160}} {
165+ b.Run(size.name, func(b *testing.B) {
166+ f := ports.Frame{Pixels: make([]byte, size.w*size.h*4), Width: size.w, Height: size.h, Stride: size.w * 4, Format: ports.XRGB8888}
167+ dir := b.TempDir()
168+ w := New()
169+ b.ReportAllocs()
170+ b.ResetTimer()
171+ for i := 0; b.Loop(); i++ {
172+ b.StopTimer()
173+ path := filepath.Join(dir, "b.png")
174+ _ = os.Remove(path)
175+ b.StartTimer()
176+ if err := w.Save(context.Background(), f, path); err != nil {
177+ b.Fatal(err)
178+ }
179+ }
180+ })
181+ }
182+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close