Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, " ")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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}
}
Expand Down
86 changes: 86 additions & 0 deletions keylogger.go
Original file line number Diff line number Diff line change
@@ -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)
}
168 changes: 168 additions & 0 deletions keylogger_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

})
}
}
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var (
ttydMinVersion = version.Must(version.NewVersion("1.7.2"))

publishFlag bool
keyLogFile string
outputs *[]string

quietFlag bool
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions vhs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"regexp"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/go-rod/rod"
Expand All @@ -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.
Expand Down Expand Up @@ -118,6 +122,7 @@ func New() VHS {
Options: &opts,
recording: true,
mutex: mu,
KeyLogger: NewKeyLogger(),
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading