From ea2ed7368670ff08d08a61364a1ebeae32a82a1a Mon Sep 17 00:00:00 2001 From: Pete Kazmier Date: Tue, 10 Feb 2026 10:36:06 -0500 Subject: [PATCH] feat: add --keylog flag to record keypresses with timestamps Record key events during tape execution and output them as JSON for use by external subtitle generation or other accessibility tools. Timestamps are derived from frame numbers to ensure accurate alignment with video output. My first version used wall clock, but that resulted in significant drift even in a 30s video (1.35s of drift). This version results in +/- of a frame or two. Key events are not recorded during Hide sections. --- command.go | 9 +++ evaluator.go | 6 ++ keylogger.go | 86 ++++++++++++++++++++++++ keylogger_test.go | 168 ++++++++++++++++++++++++++++++++++++++++++++++ main.go | 5 ++ vhs.go | 8 +++ 6 files changed, 282 insertions(+) create mode 100644 keylogger.go create mode 100644 keylogger_test.go diff --git a/command.go b/command.go index 987df2f7..7ecee6c6 100644 --- a/command.go +++ b/command.go @@ -92,6 +92,7 @@ func ExecuteKey(k input.Key) CommandFunc { repeat = 1 } for i := 0; i < repeat; i++ { + v.KeyLogger.LogKey(c.Type.String()) err = v.Page.Keyboard.Type(k) if err != nil { return fmt.Errorf("failed to type key %c: %w", k, err) @@ -171,6 +172,7 @@ func ExecuteWait(c parser.Command, v *VHS) error { // ExecuteCtrl is a CommandFunc that presses the argument keys and/or modifiers // with the ctrl key held down on the running instance of vhs. func ExecuteCtrl(c parser.Command, v *VHS) error { + v.KeyLogger.LogKey("Ctrl+" + c.Args) // Create key combination by holding ControlLeft action := v.Page.KeyActions().Press(input.ControlLeft) keys := strings.Split(c.Args, " ") @@ -218,6 +220,7 @@ func ExecuteCtrl(c parser.Command, v *VHS) error { // ExecuteAlt is a CommandFunc that presses the argument key with the alt key // held down on the running instance of vhs. func ExecuteAlt(c parser.Command, v *VHS) error { + v.KeyLogger.LogKey("Alt+" + c.Args) err := v.Page.Keyboard.Press(input.AltLeft) if err != nil { return fmt.Errorf("failed to press Alt key: %w", err) @@ -257,6 +260,7 @@ func ExecuteAlt(c parser.Command, v *VHS) error { // ExecuteShift is a CommandFunc that presses the argument key with the shift // key held down on the running instance of vhs. func ExecuteShift(c parser.Command, v *VHS) error { + v.KeyLogger.LogKey("Shift+" + c.Args) err := v.Page.Keyboard.Press(input.ShiftLeft) if err != nil { return fmt.Errorf("failed to press Shift key: %w", err) @@ -335,6 +339,11 @@ func ExecuteType(c parser.Command, v *VHS) error { } } for _, r := range c.Args { + if r == ' ' { // make in consistent in the log file + v.KeyLogger.LogKey("Space") + } else { + v.KeyLogger.LogKey(string(r)) + } k, ok := keymap[r] if ok { err := v.Page.Keyboard.Type(k) diff --git a/evaluator.go b/evaluator.go index 077f2281..5bdf1439 100644 --- a/evaluator.go +++ b/evaluator.go @@ -114,6 +114,9 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator ctx, cancel := context.WithCancel(ctx) ch := v.Record(ctx) + // Start key logging + v.KeyLogger.Start(&v.currentFrame, v.Options.Video.Framerate) + // Clean up temporary files at the end. defer func() { if v.Options.Video.Output.Frames != "" { @@ -182,6 +185,9 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } teardown() + if err := v.KeyLogger.Save(v.KeyLogFile); err != nil { + return []error{err} + } if err := v.Render(); err != nil { return []error{err} } diff --git a/keylogger.go b/keylogger.go new file mode 100644 index 00000000..7f5874b5 --- /dev/null +++ b/keylogger.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "sync/atomic" +) + +// KeyEvent represents a single key press and timing information. +type KeyEvent struct { + // Ms is the number of milliseconds relative to the recording start. + Ms int64 `json:"ms"` + + // Key is the string representation of the key pressed. + Key string `json:"key"` +} + +// KeyLogger tracks key events during tape execution. +type KeyLogger struct { + events []KeyEvent + paused bool + frame *int64 // pointer to shared frame counter + framerate int +} + +// NewKeyLogger creates a new KeyLogger. +func NewKeyLogger() *KeyLogger { + return &KeyLogger{ + events: make([]KeyEvent, 0), + } +} + +// Start begins key recording. +// +// The first parameter is a pointer to the shared frame counter that is being +// incremented asynchronously by VHS as captures are being made. This is used +// with the framerate parameter to compute when a key event was made. +func (l *KeyLogger) Start(frame *int64, framerate int) { + l.frame = frame + l.framerate = framerate +} + +// Pause suspends key logging until Resume is called. +func (l *KeyLogger) Pause() { + l.paused = true +} + +// Resume enables key logging after Pause is called. +func (l *KeyLogger) Resume() { + l.paused = false +} + +// LogKey records the current key and time at which it occurred with respect +// to the current frame being captured. If key logging has not been started or +// if it has been paused, this does nothing. +func (l *KeyLogger) LogKey(key string) { + if l.frame == nil || l.paused { + return + } + + frameNum := atomic.LoadInt64(l.frame) + timeMs := frameNum * 1000 / int64(l.framerate) + + event := KeyEvent{ + Ms: timeMs, + Key: key, + } + l.events = append(l.events, event) +} + +// Save writes the recorded key events to logFile as JSON. If logFile is an +// empty string or if there are no events, this does nothing. +func (l *KeyLogger) Save(logFile string) error { + if logFile == "" || len(l.events) == 0 { + return nil + } + + log.Println(GrayStyle.Render("Saving keylog to " + logFile + "...")) + data, err := json.MarshalIndent(l.events, "", " ") + if err != nil { + return err + } + + return os.WriteFile(logFile, data, 0o644) +} diff --git a/keylogger_test.go b/keylogger_test.go new file mode 100644 index 00000000..1210303f --- /dev/null +++ b/keylogger_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestKeyLogger_Basic(t *testing.T) { + l := NewKeyLogger() + + l.LogKey("a") + if len(l.events) != 0 { + t.Error("Expected no events when recorder has not been started") + } + + var frame int64 + l.Start(&frame, 50) // 50 fps + + l.LogKey("a") + frame = 5 // 100ms at 50fps + l.LogKey("b") + + // Verify correct number of events + if len(l.events) != 2 { + t.Errorf("Expected 2 events, got %d", len(l.events)) + } + + // Verify ordering of events + if l.events[0].Ms >= l.events[1].Ms { + t.Error("Expected first event to have earlier timestamp") + } + + // Verify correct timestamps + if l.events[0].Ms != 0 { + t.Errorf("Expected first event at 0ms, got %d", l.events[0].Ms) + } + if l.events[1].Ms != 100 { + t.Errorf("Expected second event at 100ms, got %d", l.events[1].Ms) + } + + // Verify correct keys + if l.events[0].Key != "a" { + t.Errorf("Expected first event as 'a', got '%s'", l.events[0].Key) + } + if l.events[1].Key != "b" { + t.Errorf("Expected second event as 'b', got '%s'", l.events[1].Key) + } + +} + +func TestKeyLogger_PauseResume(t *testing.T) { + l := NewKeyLogger() + + var frame int64 + l.Start(&frame, 50) // 50 fps + + l.LogKey("a") + l.Pause() + l.LogKey("b") // this should not be logged + l.Resume() + l.LogKey("c") + + // Verify correct number of events + if len(l.events) != 2 { + t.Errorf("Expected 2 events, got %d", len(l.events)) + } + + // Verify correct keys + if l.events[0].Key != "a" { + t.Errorf("Expected first event as 'a', got '%s'", l.events[0].Key) + } + if l.events[1].Key != "c" { + t.Errorf("Expected second event as 'c', got '%s'", l.events[1].Key) + } +} + +func TestKeyLogger_Save(t *testing.T) { + l := NewKeyLogger() + + var frame int64 + l.Start(&frame, 50) // 50 fps + + l.LogKey("a") + l.LogKey("b") + l.LogKey("c") + + // Write events to JSON file + tempFile := filepath.Join(t.TempDir(), "keylog.json") + if err := l.Save(tempFile); err != nil { + t.Fatalf("Failed to save: %v", err) + } + + // Validate JSON file contains what we expected + data, err := os.ReadFile(tempFile) + if err != nil { + t.Fatalf("Failed to read saved file: %v", err) + } + + var events []KeyEvent + if err := json.Unmarshal(data, &events); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if len(events) != 3 { + t.Errorf("Expected 3 events in file, got %d", len(events)) + } + + expectedKeys := []string{"a", "b", "c"} + for i, expected := range expectedKeys { + if events[i].Key != expected { + t.Errorf("Event %d: expected key '%s', got '%s'", i, expected, events[i].Key) + } + } +} + +func TestKeyLogger_FrameAlignment(t *testing.T) { + tests := []struct { + name string + framerate int + frames []int64 + wantMs []int64 + }{ + { + name: "50fps", + framerate: 50, + frames: []int64{0, 25, 50, 100}, + wantMs: []int64{0, 500, 1000, 2000}, + }, + { + name: "30fps", + framerate: 30, + frames: []int64{0, 15, 30, 60}, + wantMs: []int64{0, 500, 1000, 2000}, + }, + { + name: "60fps", + framerate: 60, + frames: []int64{0, 30, 60, 120}, + wantMs: []int64{0, 500, 1000, 2000}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := NewKeyLogger() + var frame int64 + l.Start(&frame, tt.framerate) + + for _, f := range tt.frames { + frame = f + l.LogKey("a") + } + + if len(l.events) != len(tt.wantMs) { + t.Errorf("Expected %d events, got %d", len(tt.wantMs), len(l.events)) + } + + for i, want := range tt.wantMs { + if l.events[i].Ms != want { + t.Errorf("Event %d: expected %dms, got %dms", i, want, l.events[i].Ms) + } + } + + }) + } +} diff --git a/main.go b/main.go index 7df07d73..dc376b12 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ var ( ttydMinVersion = version.Must(version.NewVersion("1.7.2")) publishFlag bool + keyLogFile string outputs *[]string quietFlag bool @@ -91,6 +92,9 @@ var ( out = io.Discard } errs := Evaluate(cmd.Context(), string(input), out, func(v *VHS) { + // Save keylog file specified on command line + v.KeyLogFile = keyLogFile + // Output is being overridden, prevent all outputs if len(*outputs) <= 0 { publishFile = v.Options.Video.Output.GIF @@ -255,6 +259,7 @@ func main() { func init() { rootCmd.Flags().BoolVarP(&publishFlag, "publish", "p", false, "publish your GIF to vhs.charm.sh and get a shareable URL") + rootCmd.Flags().StringVarP(&keyLogFile, "keylog", "k", "", "file name to log key events as JSON") rootCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "quiet do not log messages. If publish flag is provided, it will log shareable URL") outputs = rootCmd.Flags().StringSliceP("output", "o", []string{}, "file name(s) of video output") diff --git a/vhs.go b/vhs.go index 383b0cfc..d9904256 100644 --- a/vhs.go +++ b/vhs.go @@ -12,6 +12,7 @@ import ( "regexp" "strings" "sync" + "sync/atomic" "time" "github.com/go-rod/rod" @@ -32,7 +33,10 @@ type VHS struct { recording bool tty *exec.Cmd totalFrames int + currentFrame int64 close func() error + KeyLogger *KeyLogger + KeyLogFile string } // Options is the set of options for the setup. @@ -118,6 +122,7 @@ func New() VHS { Options: &opts, recording: true, mutex: mu, + KeyLogger: NewKeyLogger(), } } @@ -359,6 +364,7 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { } counter++ + atomic.StoreInt64(&vhs.currentFrame, int64(counter)) if err := os.WriteFile( filepath.Join(vhs.Options.Video.Input, fmt.Sprintf(cursorFrameFormat, counter)), cursor, @@ -393,6 +399,7 @@ func (vhs *VHS) ResumeRecording() { defer vhs.mutex.Unlock() vhs.recording = true + vhs.KeyLogger.Resume() } // PauseRecording indicates to VHS that the recording should be paused. @@ -401,6 +408,7 @@ func (vhs *VHS) PauseRecording() { defer vhs.mutex.Unlock() vhs.recording = false + vhs.KeyLogger.Pause() } // ScreenshotNextFrame indicates to VHS that screenshot of next frame must be taken.