detached 241d9be

feat(headless): write frames.ffconcat index for video recording

commit
241d9bef16487de8ea5f6e92f3979160b6f49917
author
brice · · 2026-10-21 04:37 UTC
parent
ba1d5fd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
77 WAYLAND_DISPLAY=<name from the log> foot
88 ```
99
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.
1111 - `--size 640x360,640x360` creates two outputs, arranged left to right,
1212 for testing `focus-monitor-left/right` and moving columns between outputs.
1313 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
1414 - `--timeout 5s` stops NeferWL after 5 seconds.
1515 - `grim` and recorders work too; see [Screen capture](capture.md).
1616
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+
1727 ## Input scripts
1828
1929 `--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> foot7 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 scripts27 ## 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 @@
11 package headless
22
33 import (
4+ "bufio"
45 "context"
56 "fmt"
67 "image"
@@ -96,6 +97,14 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
9697 var scene ports.Scene
9798 haveScene, dirty := false, false
9899 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+ }()
99108 var requests capture.Requests
100109 ctx, cancelCaptures := context.WithCancel(ctx)
101110 requests.Init(ctx, opts.Captured)
@@ -556,12 +565,40 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
556565 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
557566 continue
558567 }
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 {
560571 return fmt.Errorf("screenshot: %w", err)
561572 }
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 {
563578 return fmt.Errorf("latest screenshot: %w", err)
564579 }
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
565602 if opts.RawHDR && opts.HDR {
566603 // No cursor on the raw image. Only a confirmed HDR output
567604 // has a readable PQ target; else HDRPixels is nil.
@@ -589,25 +626,34 @@ func (opts Options) protected() bool {
589626 }
590627
591628 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) {
592635 if security != nil && (state.Protected || security.Snapshot() != state) {
593- return nil
636+ return false, nil
594637 }
595638 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
596639 if err != nil {
597- return err
640+ return false, err
598641 }
599642 defer os.Remove(f.Name())
600643 if err := png.Encode(f, img); err != nil {
601644 _ = f.Close()
602- return err
645+ return false, err
603646 }
604647 if err := f.Close(); err != nil {
605- return err
648+ return false, err
606649 }
607650 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
609655 }
610- return os.Rename(f.Name(), path)
656+ return true, nil
611657 }
612658
613659 // report queues what the output read, with flip for a drawn frame, and
@@ -1,6 +1,7 @@
1 package headless1 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.Scene97 var scene ports.Scene
97 haveScene, dirty := false, false98 haveScene, dirty := false, false
98 frame := 099 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.Requests108 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 continue566 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 output603 // 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 nil636+ 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 err640+ 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 err645+ return false, err
603 }646 }
604 if err := f.Close(); err != nil {647 if err := f.Close(); err != nil {
605- return err648+ return false, err
606 }649 }
607 if security != nil && security.Snapshot() != state {650 if security != nil && security.Snapshot() != state {
608- return nil651+ 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, and659 // 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 (
88 "image/color"
99 "image/png"
1010 "maps"
11+ "math"
1112 "os"
1213 "path/filepath"
14+ "strconv"
1315 "strings"
1416 "sync"
1517 "testing"
@@ -118,6 +120,26 @@ func TestRun(t *testing.T) {
118120 t.Fatal(err)
119121 }
120122 }
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+ }
121143 }
122144
123145 // 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.

Keyboard shortcuts

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

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

Branches 26

Tags 10

↑↓ moveenter openesc close