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.