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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||