feat(headless): write frames.ffconcat index for video recording
- commit
- 241d9bef16487de8ea5f6e92f3979160b6f49917
- author
- brice · · 2026-10-21 04:37 UTC
- parent
- ba1d5fd
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +87 -9
M docs/headless.md +11 -1
| @@ -7,13 +7,23 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots | |||
| 7 | 7 | WAYLAND_DISPLAY=<name from the log> foot | |
| 8 | 8 | ``` | |
| 9 | 9 | ||
| 10 | - | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | |
| 10 | + | - `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | |
| 11 | 11 | - `--size 640x360,640x360` creates two outputs, arranged left to right, | |
| 12 | 12 | for testing `focus-monitor-left/right` and moving columns between outputs. | |
| 13 | 13 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | |
| 14 | 14 | - `--timeout 5s` stops NeferWL after 5 seconds. | |
| 15 | 15 | - `grim` and recorders work too; see [Screen capture](capture.md). | |
| 16 | 16 | ||
| 17 | + | ## Recording a video | |
| 18 | + | ||
| 19 | + | Convert the screenshots and their real-time timing index into a video: | |
| 20 | + | ||
| 21 | + | ```sh | |
| 22 | + | ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420p" -c:v libx264 -crf 18 demo.mp4 | |
| 23 | + | ``` | |
| 24 | + | ||
| 25 | + | For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters. | |
| 26 | + | ||
| 17 | 27 | ## Input scripts | |
| 18 | 28 | ||
| 19 | 29 | `--input <file>`, or `--input -` for stdin, plays one command per line: | |
| @@ -7,13 +7,23 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots | |||||
| 7 | WAYLAND_DISPLAY=<name from the log> foot | 7 | WAYLAND_DISPLAY=<name from the log> foot | ||
| 8 | ``` | 8 | ``` | ||
| 9 | 9 | ||||
| 10 | - | - `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. | 10 | + | - `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. |
| 11 | - `--size 640x360,640x360` creates two outputs, arranged left to right, | 11 | - `--size 640x360,640x360` creates two outputs, arranged left to right, | ||
| 12 | for testing `focus-monitor-left/right` and moving columns between outputs. | 12 | for testing `focus-monitor-left/right` and moving columns between outputs. | ||
| 13 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | 13 | - With several outputs, each writes to `<dir>/HEADLESS-<n>/`. | ||
| 14 | - `--timeout 5s` stops NeferWL after 5 seconds. | 14 | - `--timeout 5s` stops NeferWL after 5 seconds. | ||
| 15 | - `grim` and recorders work too; see [Screen capture](capture.md). | 15 | - `grim` and recorders work too; see [Screen capture](capture.md). | ||
| 16 | 16 | ||||
| 17 | + | ## Recording a video | |||
| 18 | + | ||||
| 19 | + | Convert the screenshots and their real-time timing index into a video: | |||
| 20 | + | ||||
| 21 | + | ```sh | |||
| 22 | + | ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420p" -c:v libx264 -crf 18 demo.mp4 | |||
| 23 | + | ``` | |||
| 24 | + | ||||
| 25 | + | For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters. | |||
| 26 | + | ||||
| 17 | ## Input scripts | 27 | ## Input scripts | ||
| 18 | 28 | ||||
| 19 | `--input <file>`, or `--input -` for stdin, plays one command per line: | 29 | `--input <file>`, or `--input -` for stdin, plays one command per line: | ||
M internal/adapters/headless/output.go +54 -8
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package headless | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bufio" | |
| 4 | 5 | "context" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "image" | |
| @@ -96,6 +97,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 96 | 97 | var scene ports.Scene | |
| 97 | 98 | haveScene, dirty := false, false | |
| 98 | 99 | frame := 0 | |
| 100 | + | var index *os.File | |
| 101 | + | var indexWriter *bufio.Writer | |
| 102 | + | var lastShot time.Time | |
| 103 | + | defer func() { | |
| 104 | + | if index != nil { | |
| 105 | + | _ = index.Close() | |
| 106 | + | } | |
| 107 | + | }() | |
| 99 | 108 | var requests capture.Requests | |
| 100 | 109 | ctx, cancelCaptures := context.WithCancel(ctx) | |
| 101 | 110 | requests.Init(ctx, opts.Captured) | |
| @@ -556,12 +565,40 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 556 | 565 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | |
| 557 | 566 | continue | |
| 558 | 567 | } | |
| 559 | - | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil { | |
| 568 | + | name := fmt.Sprintf("frame-%06d.png", frame) | |
| 569 | + | written, err := writePNGSecureResult(filepath.Join(opts.ScreenshotDir, name), shot, opts.Security, scene.Security) | |
| 570 | + | if err != nil { | |
| 560 | 571 | return fmt.Errorf("screenshot: %w", err) | |
| 561 | 572 | } | |
| 562 | - | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil { | |
| 573 | + | if !written { | |
| 574 | + | continue | |
| 575 | + | } | |
| 576 | + | written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security) | |
| 577 | + | if err != nil { | |
| 563 | 578 | return fmt.Errorf("latest screenshot: %w", err) | |
| 564 | 579 | } | |
| 580 | + | if !written { | |
| 581 | + | continue | |
| 582 | + | } | |
| 583 | + | now := time.Now() | |
| 584 | + | if index == nil { | |
| 585 | + | index, err = os.OpenFile(filepath.Join(opts.ScreenshotDir, "frames.ffconcat"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_APPEND, 0o600) | |
| 586 | + | if err != nil { | |
| 587 | + | return fmt.Errorf("frame index: %w", err) | |
| 588 | + | } | |
| 589 | + | indexWriter = bufio.NewWriter(index) | |
| 590 | + | _, _ = fmt.Fprintln(indexWriter, "ffconcat version 1.0") | |
| 591 | + | } | |
| 592 | + | // Complete the previous frame's duration before starting this one. | |
| 593 | + | // An idle scene leaves that frame displayed and adds no entries. | |
| 594 | + | if !lastShot.IsZero() { | |
| 595 | + | _, _ = fmt.Fprintf(indexWriter, "duration %.6f\n", now.Sub(lastShot).Seconds()) | |
| 596 | + | } | |
| 597 | + | _, _ = fmt.Fprintf(indexWriter, "file '%s'\n", name) | |
| 598 | + | if err := indexWriter.Flush(); err != nil { | |
| 599 | + | return fmt.Errorf("frame index: %w", err) | |
| 600 | + | } | |
| 601 | + | lastShot = now | |
| 565 | 602 | if opts.RawHDR && opts.HDR { | |
| 566 | 603 | // No cursor on the raw image. Only a confirmed HDR output | |
| 567 | 604 | // has a readable PQ target; else HDRPixels is nil. | |
| @@ -589,25 +626,34 @@ func (opts Options) protected() bool { | |||
| 589 | 626 | } | |
| 590 | 627 | ||
| 591 | 628 | func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error { | |
| 629 | + | _, err := writePNGSecureResult(path, img, security, state) | |
| 630 | + | return err | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // writePNGSecureResult distinguishes a security skip from a published PNG. | |
| 634 | + | func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { | |
| 592 | 635 | if security != nil && (state.Protected || security.Snapshot() != state) { | |
| 593 | - | return nil | |
| 636 | + | return false, nil | |
| 594 | 637 | } | |
| 595 | 638 | f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png") | |
| 596 | 639 | if err != nil { | |
| 597 | - | return err | |
| 640 | + | return false, err | |
| 598 | 641 | } | |
| 599 | 642 | defer os.Remove(f.Name()) | |
| 600 | 643 | if err := png.Encode(f, img); err != nil { | |
| 601 | 644 | _ = f.Close() | |
| 602 | - | return err | |
| 645 | + | return false, err | |
| 603 | 646 | } | |
| 604 | 647 | if err := f.Close(); err != nil { | |
| 605 | - | return err | |
| 648 | + | return false, err | |
| 606 | 649 | } | |
| 607 | 650 | if security != nil && security.Snapshot() != state { | |
| 608 | - | return nil | |
| 651 | + | return false, nil | |
| 652 | + | } | |
| 653 | + | if err := os.Rename(f.Name(), path); err != nil { | |
| 654 | + | return false, err | |
| 609 | 655 | } | |
| 610 | - | return os.Rename(f.Name(), path) | |
| 656 | + | return true, nil | |
| 611 | 657 | } | |
| 612 | 658 | ||
| 613 | 659 | // report queues what the output read, with flip for a drawn frame, and | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package headless | 1 | package headless | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bufio" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "image" | 7 | "image" | ||
| @@ -96,6 +97,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 96 | var scene ports.Scene | 97 | var scene ports.Scene | ||
| 97 | haveScene, dirty := false, false | 98 | haveScene, dirty := false, false | ||
| 98 | frame := 0 | 99 | frame := 0 | ||
| 100 | + | var index *os.File | |||
| 101 | + | var indexWriter *bufio.Writer | |||
| 102 | + | var lastShot time.Time | |||
| 103 | + | defer func() { | |||
| 104 | + | if index != nil { | |||
| 105 | + | _ = index.Close() | |||
| 106 | + | } | |||
| 107 | + | }() | |||
| 99 | var requests capture.Requests | 108 | var requests capture.Requests | ||
| 100 | ctx, cancelCaptures := context.WithCancel(ctx) | 109 | ctx, cancelCaptures := context.WithCancel(ctx) | ||
| 101 | requests.Init(ctx, opts.Captured) | 110 | requests.Init(ctx, opts.Captured) | ||
| @@ -556,12 +565,40 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 556 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | 565 | if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security { | ||
| 557 | continue | 566 | continue | ||
| 558 | } | 567 | } | ||
| 559 | - | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil { | 568 | + | name := fmt.Sprintf("frame-%06d.png", frame) |
| 569 | + | written, err := writePNGSecureResult(filepath.Join(opts.ScreenshotDir, name), shot, opts.Security, scene.Security) | |||
| 570 | + | if err != nil { | |||
| 560 | return fmt.Errorf("screenshot: %w", err) | 571 | return fmt.Errorf("screenshot: %w", err) | ||
| 561 | } | 572 | } | ||
| 562 | - | if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil { | 573 | + | if !written { |
| 574 | + | continue | |||
| 575 | + | } | |||
| 576 | + | written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security) | |||
| 577 | + | if err != nil { | |||
| 563 | return fmt.Errorf("latest screenshot: %w", err) | 578 | return fmt.Errorf("latest screenshot: %w", err) | ||
| 564 | } | 579 | } | ||
| 580 | + | if !written { | |||
| 581 | + | continue | |||
| 582 | + | } | |||
| 583 | + | now := time.Now() | |||
| 584 | + | if index == nil { | |||
| 585 | + | index, err = os.OpenFile(filepath.Join(opts.ScreenshotDir, "frames.ffconcat"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_APPEND, 0o600) | |||
| 586 | + | if err != nil { | |||
| 587 | + | return fmt.Errorf("frame index: %w", err) | |||
| 588 | + | } | |||
| 589 | + | indexWriter = bufio.NewWriter(index) | |||
| 590 | + | _, _ = fmt.Fprintln(indexWriter, "ffconcat version 1.0") | |||
| 591 | + | } | |||
| 592 | + | // Complete the previous frame's duration before starting this one. | |||
| 593 | + | // An idle scene leaves that frame displayed and adds no entries. | |||
| 594 | + | if !lastShot.IsZero() { | |||
| 595 | + | _, _ = fmt.Fprintf(indexWriter, "duration %.6f\n", now.Sub(lastShot).Seconds()) | |||
| 596 | + | } | |||
| 597 | + | _, _ = fmt.Fprintf(indexWriter, "file '%s'\n", name) | |||
| 598 | + | if err := indexWriter.Flush(); err != nil { | |||
| 599 | + | return fmt.Errorf("frame index: %w", err) | |||
| 600 | + | } | |||
| 601 | + | lastShot = now | |||
| 565 | if opts.RawHDR && opts.HDR { | 602 | if opts.RawHDR && opts.HDR { | ||
| 566 | // No cursor on the raw image. Only a confirmed HDR output | 603 | // No cursor on the raw image. Only a confirmed HDR output | ||
| 567 | // has a readable PQ target; else HDRPixels is nil. | 604 | // has a readable PQ target; else HDRPixels is nil. | ||
| @@ -589,25 +626,34 @@ func (opts Options) protected() bool { | |||||
| 589 | } | 626 | } | ||
| 590 | 627 | ||||
| 591 | func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error { | 628 | func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error { | ||
| 629 | + | _, err := writePNGSecureResult(path, img, security, state) | |||
| 630 | + | return err | |||
| 631 | + | } | |||
| 632 | + | ||||
| 633 | + | // writePNGSecureResult distinguishes a security skip from a published PNG. | |||
| 634 | + | func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) { | |||
| 592 | if security != nil && (state.Protected || security.Snapshot() != state) { | 635 | if security != nil && (state.Protected || security.Snapshot() != state) { | ||
| 593 | - | return nil | 636 | + | return false, nil |
| 594 | } | 637 | } | ||
| 595 | f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png") | 638 | f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png") | ||
| 596 | if err != nil { | 639 | if err != nil { | ||
| 597 | - | return err | 640 | + | return false, err |
| 598 | } | 641 | } | ||
| 599 | defer os.Remove(f.Name()) | 642 | defer os.Remove(f.Name()) | ||
| 600 | if err := png.Encode(f, img); err != nil { | 643 | if err := png.Encode(f, img); err != nil { | ||
| 601 | _ = f.Close() | 644 | _ = f.Close() | ||
| 602 | - | return err | 645 | + | return false, err |
| 603 | } | 646 | } | ||
| 604 | if err := f.Close(); err != nil { | 647 | if err := f.Close(); err != nil { | ||
| 605 | - | return err | 648 | + | return false, err |
| 606 | } | 649 | } | ||
| 607 | if security != nil && security.Snapshot() != state { | 650 | if security != nil && security.Snapshot() != state { | ||
| 608 | - | return nil | 651 | + | return false, nil |
| 652 | + | } | |||
| 653 | + | if err := os.Rename(f.Name(), path); err != nil { | |||
| 654 | + | return false, err | |||
| 609 | } | 655 | } | ||
| 610 | - | return os.Rename(f.Name(), path) | 656 | + | return true, nil |
| 611 | } | 657 | } | ||
| 612 | 658 | ||||
| 613 | // report queues what the output read, with flip for a drawn frame, and | 659 | // report queues what the output read, with flip for a drawn frame, and | ||
M internal/adapters/headless/output_test.go +22 -0
| @@ -8,8 +8,10 @@ import ( | |||
| 8 | 8 | "image/color" | |
| 9 | 9 | "image/png" | |
| 10 | 10 | "maps" | |
| 11 | + | "math" | |
| 11 | 12 | "os" | |
| 12 | 13 | "path/filepath" | |
| 14 | + | "strconv" | |
| 13 | 15 | "strings" | |
| 14 | 16 | "sync" | |
| 15 | 17 | "testing" | |
| @@ -118,6 +120,26 @@ func TestRun(t *testing.T) { | |||
| 118 | 120 | t.Fatal(err) | |
| 119 | 121 | } | |
| 120 | 122 | } | |
| 123 | + | index, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat")) | |
| 124 | + | if err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | lines := strings.Split(strings.TrimSpace(string(index)), "\n") | |
| 128 | + | if len(lines) != 6 || lines[0] != "ffconcat version 1.0" { | |
| 129 | + | t.Fatalf("unexpected index: %s", index) | |
| 130 | + | } | |
| 131 | + | for i, want := range []string{"file 'frame-000001.png'", "file 'frame-000002.png'", "file 'frame-000003.png'"} { | |
| 132 | + | if lines[1+2*i] != want { | |
| 133 | + | t.Fatalf("frame %d: %s", i+1, lines[1+2*i]) | |
| 134 | + | } | |
| 135 | + | if i < 2 { | |
| 136 | + | line := lines[2+2*i] | |
| 137 | + | value, err := strconv.ParseFloat(strings.TrimPrefix(line, "duration "), 64) | |
| 138 | + | if !strings.HasPrefix(line, "duration ") || err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { | |
| 139 | + | t.Fatalf("invalid duration: %q", line) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 121 | 143 | } | |
| 122 | 144 | ||
| 123 | 145 | // A scene whose Seq the output already holds is not drawn again. | |
| @@ -8,8 +8,10 @@ import ( | |||||
| 8 | "image/color" | 8 | "image/color" | ||
| 9 | "image/png" | 9 | "image/png" | ||
| 10 | "maps" | 10 | "maps" | ||
| 11 | + | "math" | |||
| 11 | "os" | 12 | "os" | ||
| 12 | "path/filepath" | 13 | "path/filepath" | ||
| 14 | + | "strconv" | |||
| 13 | "strings" | 15 | "strings" | ||
| 14 | "sync" | 16 | "sync" | ||
| 15 | "testing" | 17 | "testing" | ||
| @@ -118,6 +120,26 @@ func TestRun(t *testing.T) { | |||||
| 118 | t.Fatal(err) | 120 | t.Fatal(err) | ||
| 119 | } | 121 | } | ||
| 120 | } | 122 | } | ||
| 123 | + | index, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat")) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | lines := strings.Split(strings.TrimSpace(string(index)), "\n") | |||
| 128 | + | if len(lines) != 6 || lines[0] != "ffconcat version 1.0" { | |||
| 129 | + | t.Fatalf("unexpected index: %s", index) | |||
| 130 | + | } | |||
| 131 | + | for i, want := range []string{"file 'frame-000001.png'", "file 'frame-000002.png'", "file 'frame-000003.png'"} { | |||
| 132 | + | if lines[1+2*i] != want { | |||
| 133 | + | t.Fatalf("frame %d: %s", i+1, lines[1+2*i]) | |||
| 134 | + | } | |||
| 135 | + | if i < 2 { | |||
| 136 | + | line := lines[2+2*i] | |||
| 137 | + | value, err := strconv.ParseFloat(strings.TrimPrefix(line, "duration "), 64) | |||
| 138 | + | if !strings.HasPrefix(line, "duration ") || err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { | |||
| 139 | + | t.Fatalf("invalid duration: %q", line) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 121 | } | 143 | } | ||
| 122 | 144 | ||||
| 123 | // A scene whose Seq the output already holds is not drawn again. | 145 | // A scene whose Seq the output already holds is not drawn again. | ||