From c05f728ad012d7851d4c44f3140dc0437174d9c0 Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 13 May 2024 23:32:47 -0400 Subject: [PATCH 01/38] Add KEYSTROKES token --- token/token.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/token/token.go b/token/token.go index a9cdd3d3..c2d9579a 100644 --- a/token/token.go +++ b/token/token.go @@ -82,6 +82,7 @@ const ( LETTER_SPACING = "LETTER_SPACING" //nolint:revive LINE_HEIGHT = "LINE_HEIGHT" //nolint:revive TYPING_SPEED = "TYPING_SPEED" //nolint:revive + KEYSTROKES = "KEYSTROKES" PADDING = "PADDING" THEME = "THEME" LOOP_OFFSET = "LOOP_OFFSET" //nolint:revive @@ -138,6 +139,7 @@ var Keywords = map[string]Type{ "LineHeight": LINE_HEIGHT, "PlaybackSpeed": PLAYBACK_SPEED, "TypingSpeed": TYPING_SPEED, + "KeyStrokes": KEYSTROKES, "Padding": PADDING, "Theme": THEME, "Width": WIDTH, @@ -156,7 +158,7 @@ var Keywords = map[string]Type{ func IsSetting(t Type) bool { switch t { case SHELL, FONT_FAMILY, FONT_SIZE, LETTER_SPACING, LINE_HEIGHT, - FRAMERATE, TYPING_SPEED, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH, + FRAMERATE, TYPING_SPEED, KEYSTROKES, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH, PADDING, LOOP_OFFSET, MARGIN_FILL, MARGIN, WINDOW_BAR, WINDOW_BAR_SIZE, BORDER_RADIUS, CURSOR_BLINK: return true From 2e3ed013a2a49c98a66673c8b78bf06d747d1450 Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 13 May 2024 23:42:29 -0400 Subject: [PATCH 02/38] Add coverage for KeyStrokes to lexer_test.go --- examples/fixtures/all.tape | 2 ++ lexer/lexer_test.go | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/examples/fixtures/all.tape b/examples/fixtures/all.tape index cfd9e75f..66adf8b3 100644 --- a/examples/fixtures/all.tape +++ b/examples/fixtures/all.tape @@ -19,6 +19,8 @@ Set Padding 50 Set Framerate 60 Set PlaybackSpeed 2 Set TypingSpeed .1 +Set KeyStrokes Hide +Set KeyStrokes Show Set LoopOffset 60.4 Set LoopOffset 20.99% Set CursorBlink false diff --git a/lexer/lexer_test.go b/lexer/lexer_test.go index 1383dfe7..2579d49b 100644 --- a/lexer/lexer_test.go +++ b/lexer/lexer_test.go @@ -147,6 +147,12 @@ func TestLexTapeFile(t *testing.T) { {token.TYPING_SPEED, "TypingSpeed"}, {token.NUMBER, ".1"}, {token.SET, "Set"}, + {token.KEYSTROKES, "KeyStrokes"}, + {token.HIDE, "Hide"}, + {token.SET, "Set"}, + {token.KEYSTROKES, "KeyStrokes"}, + {token.SHOW, "Show"}, + {token.SET, "Set"}, {token.LOOP_OFFSET, "LoopOffset"}, {token.NUMBER, "60.4"}, {token.SET, "Set"}, From c48ae74f52ccd3190405bc6cf04c198ef70dde65 Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 13 May 2024 23:57:02 -0400 Subject: [PATCH 03/38] Handle KeyStroke token in parser.go --- parser/parser.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/parser/parser.go b/parser/parser.go index 3ee8f2c9..646c4ddd 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -414,6 +414,14 @@ func (p *Parser) parseSet() Command { } else if cmd.Options == "TypingSpeed" { cmd.Args += "s" } + case token.KEYSTROKES: + switch p.peek.Type { + case token.HIDE, token.SHOW: + cmd.Args = p.peek.Literal + p.nextToken() + default: + p.errors = append(p.errors, NewError(p.peek, "Expected Hide or Show after KeyStrokes")) + } case token.WINDOW_BAR: cmd.Args = p.peek.Literal p.nextToken() From 17d0a0a30aa0ba8fcdd045c293ed6960aedd39fb Mon Sep 17 00:00:00 2001 From: may h Date: Tue, 14 May 2024 00:18:09 -0400 Subject: [PATCH 04/38] Improve the error message on TestParserError failures --- parser/parser_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/parser/parser_test.go b/parser/parser_test.go index c82c7f65..f5b30bf4 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -90,15 +90,18 @@ Sleep Bar` " 4:1 │ Invalid command: Foo", " 5:1 │ Expected time after Sleep", " 5:7 │ Invalid command: Bar", + actualErrorMessages := make([]string, len(p.errors)) + for i, err := range p.errors { + actualErrorMessages[i] = err.String() } if len(p.errors) != len(expectedErrors) { - t.Fatalf("Expected %d errors, got %d", len(expectedErrors), len(p.errors)) + t.Fatalf("Expected %d errors, got %d (expected:\n%s\ngot:\n%s)", len(expectedErrors), len(p.errors), strings.Join(expectedErrors, "\n"), strings.Join(actualErrorMessages, "\n")) } - for i, err := range p.errors { - if err.String() != expectedErrors[i] { - t.Errorf("Expected error %d to be [%s], got (%s)", i, expectedErrors[i], err) + for i, errMsg := range actualErrorMessages { + if errMsg != expectedErrors[i] { + t.Errorf("Expected error %d to be [%s], got (%s)", i, expectedErrors[i], errMsg) } } } From 412c514aa8196015473f9470b9a82b7762e46c63 Mon Sep 17 00:00:00 2001 From: may h Date: Tue, 14 May 2024 00:18:52 -0400 Subject: [PATCH 05/38] Add coverage for KeyStroke parsing ADd # modified: parser/parser_test.go --- parser/parser_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/parser/parser_test.go b/parser/parser_test.go index f5b30bf4..e62f5f7b 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -78,7 +78,8 @@ func TestParserErrors(t *testing.T) { Type Enter Type "echo 'Hello, World!'" Enter Foo -Sleep Bar` +Sleep Bar +Set KeyStrokes imsowrong` l := lexer.New(input) p := New(l) @@ -90,6 +91,10 @@ Sleep Bar` " 4:1 │ Invalid command: Foo", " 5:1 │ Expected time after Sleep", " 5:7 │ Invalid command: Bar", + " 6:16 │ Expected Hide or Show after KeyStrokes", + " 6:16 │ Invalid command: imsowrong", + } + actualErrorMessages := make([]string, len(p.errors)) for i, err := range p.errors { actualErrorMessages[i] = err.String() @@ -129,6 +134,8 @@ func TestParseTapeFile(t *testing.T) { {Type: token.SET, Options: "Framerate", Args: "60"}, {Type: token.SET, Options: "PlaybackSpeed", Args: "2"}, {Type: token.SET, Options: "TypingSpeed", Args: ".1s"}, + {Type: token.SET, Options: "KeyStrokes", Args: "Hide"}, + {Type: token.SET, Options: "KeyStrokes", Args: "Show"}, {Type: token.SET, Options: "LoopOffset", Args: "60.4%"}, {Type: token.SET, Options: "LoopOffset", Args: "20.99%"}, {Type: token.SET, Options: "CursorBlink", Args: "false"}, From 2f7eb73173e37a341ac6a570214751443aa53588 Mon Sep 17 00:00:00 2001 From: may h Date: Tue, 14 May 2024 00:22:16 -0400 Subject: [PATCH 06/38] Add KeyStrokes to Options and implement ExecuteSetKeyStrokes for setting it --- command.go | 6 ++++++ vhs.go | 1 + 2 files changed, 7 insertions(+) diff --git a/command.go b/command.go index 79832cc9..d834109c 100644 --- a/command.go +++ b/command.go @@ -283,6 +283,7 @@ var Settings = map[string]CommandFunc{ "Padding": ExecuteSetPadding, "Theme": ExecuteSetTheme, "TypingSpeed": ExecuteSetTypingSpeed, + "KeyStrokes": ExecuteSetKeyStrokes, "Width": ExecuteSetWidth, "Shell": ExecuteSetShell, "LoopOffset": ExecuteLoopOffset, @@ -380,6 +381,11 @@ func ExecuteSetTypingSpeed(c parser.Command, v *VHS) { v.Options.TypingSpeed = typingSpeed } +// ExecuteSetKeyStrokes enables or disables keystroke overlay recording. +func ExecuteSetKeyStrokes(c parser.Command, v *VHS) { + v.Options.ShouldRenderKeyStrokes = c.Args == "Show" +} + // ExecuteSetPadding applies the padding on the vhs. func ExecuteSetPadding(c parser.Command, v *VHS) { v.Options.Video.Style.Padding, _ = strconv.Atoi(c.Args) diff --git a/vhs.go b/vhs.go index 6660896f..f153dac6 100644 --- a/vhs.go +++ b/vhs.go @@ -42,6 +42,7 @@ type Options struct { LetterSpacing float64 LineHeight float64 TypingSpeed time.Duration + KeyStrokes bool Theme Theme Test TestOptions Video VideoOptions From 24fd73fe5fa2a5e727c3170a34442e1be5b82b34 Mon Sep 17 00:00:00 2001 From: may h Date: Tue, 14 May 2024 00:22:39 -0400 Subject: [PATCH 07/38] Add some coverage for Settings This, as you might expect, includes KeyStrokes coverage! --- command_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/command_test.go b/command_test.go index c8629cde..bb52938e 100644 --- a/command_test.go +++ b/command_test.go @@ -17,6 +17,11 @@ func TestCommand(t *testing.T) { if len(CommandFuncs) != numberOfCommandFuncs { t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs)) } + + const numberOfSettings = 20 + if len(Settings) != numberOfSettings { + t.Errorf("Expected %d settings, got %d", numberOfSettings, len(Settings)) + } } func TestExecuteSetTheme(t *testing.T) { From cfb56d0a44a9ed6e9f1d326abc10895b0988c9f9 Mon Sep 17 00:00:00 2001 From: may h Date: Wed, 15 May 2024 00:25:27 -0400 Subject: [PATCH 08/38] Add inverseKeymap in preparation for keystroke types We're going to need this, at least temporarily, for supporting the incoming key stroke recording types. --- keys.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/keys.go b/keys.go index 38484630..0ecaaf6b 100644 --- a/keys.go +++ b/keys.go @@ -129,3 +129,13 @@ var keymap = map[rune]input.Key{ '→': input.ArrowRight, '↓': input.ArrowDown, } + +// inverseKeymap is the inverse map of keymap, mapping input.Keys to runes. +var inverseKeymap = make(map[input.Key]rune) + +// init initializes the invKeymap map to ensure it stays in-sync with keymap. +func init() { + for r, k := range keymap { + inverseKeymap[k] = r + } +} From 425eea5062dc47eafcdd0e0e1aebfa5f71ee1f5b Mon Sep 17 00:00:00 2001 From: may h Date: Wed, 15 May 2024 00:22:30 -0400 Subject: [PATCH 09/38] Add initial implementation of keystroke.go This currently includes a few different things: - KeyStrokeEvent: Mostly taken from steno almost verbatim. - KeyStrokeEvents: A wrapper around a slice of KeyStrokeEvents that helps us package a little state with the events such that we can record exactly when they should be rendered. - Page/Keyboard: Another wrapper type around their equivalently named rod types. These help us ensure that we continue to log the keypresses without substantial changes to the call sites, and also minimizes the chance of bugs when command execution is modified since all inputs go through these types. --- keystroke.go | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 keystroke.go diff --git a/keystroke.go b/keystroke.go new file mode 100644 index 00000000..0c08baed --- /dev/null +++ b/keystroke.go @@ -0,0 +1,100 @@ +package main + +import ( + "strconv" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/input" +) + +// KeyStrokeEvent represents a key press event for the purposes of keystroke +// overlay. +type KeyStrokeEvent struct { + // Display generally includes the current key stroke sequence. + Display string + // WhenMS is the time in milliseconds when the key was pressed starting + // from the beginning of the recording. + WhenMS int64 +} + +// KeyStrokeEvents is a collection of key press events that you can push to. +type KeyStrokeEvents struct { + events []KeyStrokeEvent + startTime time.Time +} + +// NewKeyStrokeEvents creates a new KeyStrokeEvents struct. +func NewKeyStrokeEvents() KeyStrokeEvents { + return KeyStrokeEvents{ + events: make([]KeyStrokeEvent, 0), + startTime: time.Now(), + } +} + +// Push adds a new key press event to the collection. +func (k KeyStrokeEvents) Push(display string) { + event := KeyStrokeEvent{Display: strconv.Quote(display), WhenMS: time.Now().Sub(k.startTime).Milliseconds()} + k.events = append(k.events, event) +} + +// Slice returns the underlying slice of key press events. +// NOTE: This is a reference. +func (k KeyStrokeEvents) Slice() []KeyStrokeEvent { + return k.events +} + +// Page is a wrapper around the rod.Page object. +// It's primary purpose is to decorate the rod.Page struct such that we can +// record keypress events during the recording for keypress overlays. We prefer +// decorating so that we that minimize the possibility of future bugs around +// forgetting to log key presses, since all input is done through rod.Page (and +// technically rod.Page.MustElement() + rod.Page.Keyboard). +type Page struct { + *rod.Page + Keyboard Keyboard + KeyStrokeEvents KeyStrokeEvents +} + +// NewPage creates a new wrapper Page object. +func NewPage(page *rod.Page) *Page { + keyStrokeEvents := NewKeyStrokeEvents() + return &Page{Page: page, KeyStrokeEvents: keyStrokeEvents, Keyboard: Keyboard{page.Keyboard, page.MustElement("textarea"), keyStrokeEvents}} +} + +// MustSetViewport is a wrapper around the rod.Page#MustSetViewport method. +func (p *Page) MustSetViewport(width, height int, deviceScaleFactor float64, mobile bool) *Page { + p.Page.MustSetViewport(width, height, deviceScaleFactor, mobile) + return p +} + +// MustWait is a wrapper around the rod.Page#MustWait method. +func (p *Page) MustWait(js string) *Page { + p.Page.MustWait(js) + return p +} + +// Keyboard is a wrapper around the rod.Keyboard object. +type Keyboard struct { + *rod.Keyboard + textAreaElem *rod.Element + KeyStrokeEvents KeyStrokeEvents +} + +// Press is a wrapper around the rod.Keyboard#Press method. +func (k *Keyboard) Press(key input.Key) { + k.KeyStrokeEvents.Push(string(inverseKeymap[key])) + k.Keyboard.Press(key) +} + +// Type is a wrapper around the rod.Keyboard#Type method. +func (k *Keyboard) Type(key input.Key) { + k.KeyStrokeEvents.Push(string(inverseKeymap[key])) + k.Keyboard.Type(key) +} + +// Input is a wrapper around the rod.Keyboard#Input method. +func (k *Keyboard) Input(text string) { + k.KeyStrokeEvents.Push(text) + k.textAreaElem.Input(text) +} From 216b9ca989dcba3bd3043213829478da6d9cbbb6 Mon Sep 17 00:00:00 2001 From: may h Date: Wed, 15 May 2024 00:26:16 -0400 Subject: [PATCH 10/38] Start recording keystrokes --- command.go | 30 +++++++++++++++--------------- keystroke.go | 19 +++++++++++-------- vhs.go | 4 ++-- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/command.go b/command.go index d834109c..689a350c 100644 --- a/command.go +++ b/command.go @@ -89,7 +89,7 @@ func ExecuteKey(k input.Key) CommandFunc { repeat = 1 } for i := 0; i < repeat; i++ { - _ = v.Page.Keyboard.Type(k) + v.Page.Keyboard.Type(k) time.Sleep(typingSpeed) } } @@ -140,45 +140,45 @@ func ExecuteCtrl(c parser.Command, v *VHS) { // 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) { - _ = v.Page.Keyboard.Press(input.AltLeft) + v.Page.Keyboard.Press(input.AltLeft) if k, ok := token.Keywords[c.Args]; ok { switch k { case token.ENTER: - _ = v.Page.Keyboard.Type(input.Enter) + v.Page.Keyboard.Type(input.Enter) case token.TAB: - _ = v.Page.Keyboard.Type(input.Tab) + v.Page.Keyboard.Type(input.Tab) } } else { for _, r := range c.Args { if k, ok := keymap[r]; ok { - _ = v.Page.Keyboard.Type(k) + v.Page.Keyboard.Type(k) } } } - _ = v.Page.Keyboard.Release(input.AltLeft) + v.Page.Keyboard.Release(input.AltLeft) } // 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) { - _ = v.Page.Keyboard.Press(input.ShiftLeft) + v.Page.Keyboard.Press(input.ShiftLeft) if k, ok := token.Keywords[c.Args]; ok { switch k { case token.ENTER: - _ = v.Page.Keyboard.Type(input.Enter) + v.Page.Keyboard.Type(input.Enter) case token.TAB: - _ = v.Page.Keyboard.Type(input.Tab) + v.Page.Keyboard.Type(input.Tab) } } else { for _, r := range c.Args { if k, ok := keymap[r]; ok { - _ = v.Page.Keyboard.Type(k) + v.Page.Keyboard.Type(k) } } } - _ = v.Page.Keyboard.Release(input.ShiftLeft) + v.Page.Keyboard.Release(input.ShiftLeft) } // ExecuteHide is a CommandFunc that starts or stops the recording of the vhs. @@ -219,9 +219,9 @@ func ExecuteType(c parser.Command, v *VHS) { for _, r := range c.Args { k, ok := keymap[r] if ok { - _ = v.Page.Keyboard.Type(k) + v.Page.Keyboard.Type(k) } else { - _ = v.Page.MustElement("textarea").Input(string(r)) + v.Page.Keyboard.Input(string(r)) v.Page.MustWaitIdle() } time.Sleep(typingSpeed) @@ -263,9 +263,9 @@ func ExecutePaste(_ parser.Command, v *VHS) { for _, r := range clip { k, ok := keymap[r] if ok { - _ = v.Page.Keyboard.Type(k) + v.Page.Keyboard.Type(k) } else { - _ = v.Page.MustElement("textarea").Input(string(r)) + v.Page.Keyboard.Input(string(r)) v.Page.MustWaitIdle() } } diff --git a/keystroke.go b/keystroke.go index 0c08baed..4a5c2a0c 100644 --- a/keystroke.go +++ b/keystroke.go @@ -20,27 +20,30 @@ type KeyStrokeEvent struct { // KeyStrokeEvents is a collection of key press events that you can push to. type KeyStrokeEvents struct { + display string events []KeyStrokeEvent startTime time.Time } // NewKeyStrokeEvents creates a new KeyStrokeEvents struct. -func NewKeyStrokeEvents() KeyStrokeEvents { - return KeyStrokeEvents{ +func NewKeyStrokeEvents() *KeyStrokeEvents { + return &KeyStrokeEvents{ + display: "", events: make([]KeyStrokeEvent, 0), startTime: time.Now(), } } // Push adds a new key press event to the collection. -func (k KeyStrokeEvents) Push(display string) { - event := KeyStrokeEvent{Display: strconv.Quote(display), WhenMS: time.Now().Sub(k.startTime).Milliseconds()} +func (k *KeyStrokeEvents) Push(display string) { + k.display += strconv.Quote(display) + event := KeyStrokeEvent{Display: k.display, WhenMS: time.Now().Sub(k.startTime).Milliseconds()} k.events = append(k.events, event) } // Slice returns the underlying slice of key press events. // NOTE: This is a reference. -func (k KeyStrokeEvents) Slice() []KeyStrokeEvent { +func (k *KeyStrokeEvents) Slice() []KeyStrokeEvent { return k.events } @@ -53,7 +56,7 @@ func (k KeyStrokeEvents) Slice() []KeyStrokeEvent { type Page struct { *rod.Page Keyboard Keyboard - KeyStrokeEvents KeyStrokeEvents + KeyStrokeEvents *KeyStrokeEvents } // NewPage creates a new wrapper Page object. @@ -78,7 +81,7 @@ func (p *Page) MustWait(js string) *Page { type Keyboard struct { *rod.Keyboard textAreaElem *rod.Element - KeyStrokeEvents KeyStrokeEvents + KeyStrokeEvents *KeyStrokeEvents } // Press is a wrapper around the rod.Keyboard#Press method. @@ -93,7 +96,7 @@ func (k *Keyboard) Type(key input.Key) { k.Keyboard.Type(key) } -// Input is a wrapper around the rod.Keyboard#Input method. +// Input is a wrapper around the rod.Page#MustElement("textarea")#Input method. func (k *Keyboard) Input(text string) { k.KeyStrokeEvents.Push(text) k.textAreaElem.Input(text) diff --git a/vhs.go b/vhs.go index f153dac6..9f02c6e7 100644 --- a/vhs.go +++ b/vhs.go @@ -22,7 +22,7 @@ import ( type VHS struct { Options *Options Errors []error - Page *rod.Page + Page *Page browser *rod.Browser TextCanvas *rod.Element CursorCanvas *rod.Element @@ -142,7 +142,7 @@ func (vhs *VHS) Start() error { } vhs.browser = browser - vhs.Page = page + vhs.Page = NewPage(page) vhs.close = vhs.browser.Close vhs.started = true return nil From dde35ae4317d543febb312644242c19e5f72b76c Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 19 May 2024 15:09:24 -0400 Subject: [PATCH 11/38] Implement FilterComplexBuilder#WithKeyStrokes() --- ffmpeg.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/ffmpeg.go b/ffmpeg.go index 4d4ae802..844f3a39 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -172,6 +172,43 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB return fb } +// WithKeyStrokes adds key stroke drawtext options to the ffmpeg filter_complex. +func (fb *FilterComplexBuilder) WithKeyStrokes(events []KeyStrokeEvent) *FilterComplexBuilder { + prevStageName := fb.prevStageName + for i := range events { + event := events[i] + fb.filterComplex.WriteString(";") + stageName := fmt.Sprintf("keypressOverlay%d", i) + enableCondition := fmt.Sprintf("gte(t,%f)", float64(event.WhenMS)/1000) + if i < len(events)-1 { + enableCondition = fmt.Sprintf("between(t,%f,%f)", float64(events[i].WhenMS)/1000, float64(events[i+1].WhenMS)/1000) + } + fb.filterComplex.WriteString( + fmt.Sprintf(` + [%s]drawtext=fontfile=%s:text='%s':fontcolor=%s:fontsize=%d:x=%d:y=%d:enable='%s'[%s] + `, + prevStageName, + "/path/to/font.ttf", + events[i].Display, + "white", + 30, + 00, + 00, + enableCondition, + stageName, + ), + ) + prevStageName = stageName + } + + // fmt.Println("drawtext filter added: ", fb.filterComplex.String()) + + // At the end of the loop, the previous stage name is now transfered to the filter complex builder's + // state for use in subsequent filters. + fb.prevStageName = prevStageName + return fb +} + // WithGIF adds gif options to ffmepg filter_complex. func (fb *FilterComplexBuilder) WithGIF() *FilterComplexBuilder { fb.filterComplex.WriteString(";") From c5b714eb0f7773f31e83efc62b4948293b81ad75 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 19 May 2024 15:10:06 -0400 Subject: [PATCH 12/38] Integrate drawText calls into rendering codepaths via VideoOptions I was hesitant about this approach at first, and technically still am to a degree. However, ApplyLoopOffset() seems to be setting a precedent where VideoOpts may be modified at a later point in the program's lifespan, so I think this might be a fine approach. --- vhs.go | 34 ++++++++++++++++++++-------------- video.go | 19 ++++++++++++------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/vhs.go b/vhs.go index 9f02c6e7..df2cf242 100644 --- a/vhs.go +++ b/vhs.go @@ -36,20 +36,20 @@ type VHS struct { // Options is the set of options for the setup. type Options struct { - Shell Shell - FontFamily string - FontSize int - LetterSpacing float64 - LineHeight float64 - TypingSpeed time.Duration - KeyStrokes bool - Theme Theme - Test TestOptions - Video VideoOptions - LoopOffset float64 - CursorBlink bool - Screenshot ScreenshotOptions - Style StyleOptions + Shell Shell + FontFamily string + FontSize int + LetterSpacing float64 + LineHeight float64 + TypingSpeed time.Duration + ShouldRenderKeyStrokes bool + Theme Theme + Test TestOptions + Video VideoOptions + LoopOffset float64 + CursorBlink bool + Screenshot ScreenshotOptions + Style StyleOptions } const ( @@ -218,6 +218,11 @@ func (vhs *VHS) Render() error { return err } + if vhs.Options.ShouldRenderKeyStrokes { + vhs.Options.Video.KeyStrokeEvents = vhs.Page.KeyStrokeEvents.Slice() + } + fmt.Println("should render keystrokes?: ", vhs.Options.ShouldRenderKeyStrokes, " num events: ", len(vhs.Options.Video.KeyStrokeEvents)) + // Generate the video(s) with the frames. var cmds []*exec.Cmd cmds = append(cmds, MakeGIF(vhs.Options.Video)) @@ -229,6 +234,7 @@ func (vhs *VHS) Render() error { if cmd == nil { continue } + fmt.Println("executing cmd; ", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { log.Println(string(out)) diff --git a/video.go b/video.go index 1cbf77f5..323430e5 100644 --- a/video.go +++ b/video.go @@ -49,13 +49,14 @@ type VideoOutputs struct { // VideoOptions is the set of options for converting frames to a GIF. type VideoOptions struct { - Framerate int - PlaybackSpeed float64 - Input string - MaxColors int - Output VideoOutputs - StartingFrame int - Style *StyleOptions + Framerate int + PlaybackSpeed float64 + Input string + MaxColors int + Output VideoOutputs + StartingFrame int + Style *StyleOptions + KeyStrokeEvents []KeyStrokeEvent } const ( @@ -119,6 +120,10 @@ func buildFFopts(opts VideoOptions, targetFile string) []string { WithBorderRadius(streamBuilder.cornerStream). WithMarginFill(streamBuilder.marginStream) + if opts.KeyStrokeEvents != nil { // TODO: What if number of events is 0? + filterBuilder = filterBuilder.WithKeyStrokes(opts.KeyStrokeEvents) + } + // Format-specific options switch filepath.Ext(targetFile) { case gif: From 607a44bb176b34e2a4dd305be587e8e99c45e26e Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 19 May 2024 16:02:04 -0400 Subject: [PATCH 13/38] Use a hardcoded font family instead of fontfile This also makes it easier to interop with potential future changes that make the overlay follow the set font family for the recording. --- ffmpeg.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ffmpeg.go b/ffmpeg.go index 844f3a39..3f903efc 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -185,10 +185,10 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(events []KeyStrokeEvent) *FilterC } fb.filterComplex.WriteString( fmt.Sprintf(` - [%s]drawtext=fontfile=%s:text='%s':fontcolor=%s:fontsize=%d:x=%d:y=%d:enable='%s'[%s] + [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x=%d:y=%d:enable='%s'[%s] `, prevStageName, - "/path/to/font.ttf", + "Noto Mono", events[i].Display, "white", 30, From e1788399ca1b525f88943fe07dc1f80ac8acb80d Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 19 May 2024 16:39:01 -0400 Subject: [PATCH 14/38] Render symbols for certain keypresses This allows us to not have to do things like draw an actual newline and instead draw some readable symbol for it. --- keystroke.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/keystroke.go b/keystroke.go index 4a5c2a0c..e9fc8827 100644 --- a/keystroke.go +++ b/keystroke.go @@ -1,7 +1,7 @@ package main import ( - "strconv" + "fmt" "time" "github.com/go-rod/rod" @@ -34,9 +34,93 @@ func NewKeyStrokeEvents() *KeyStrokeEvents { } } +// keypressSymbolOverrides maps certain input keys to their corresponding +// keypress string or symbol. These override the default rune for the +// corresponding input key to improve the visuals or readability of the keypress +// overlay. A good example of this improvement can be seen in things like Enter +// (newline). The description string and symbol are embedded into an inner map, +// which can be indexed into based on whether special symbols are requested or +// not. +var keypressSymbolOverrides = map[input.Key]map[bool]string{ + input.Backspace: { + true: "\\\\\\\\b", + false: "⌫", + }, + input.Delete: { + true: "\\\\\\\\d", + false: "␡", + }, + input.ControlLeft: { + true: "+", + false: "C-", + }, + input.ControlRight: { + true: "+", + false: "C-", + }, + input.AltLeft: { + true: "+", + false: "⎇-", + }, + input.AltRight: { + true: "+", + false: "⎇-", + }, + input.ArrowDown: { + true: "", + false: "↓", + }, + input.PageDown: { + true: "", + false: "⤓", + }, + input.ArrowUp: { + true: "", + false: "↑", + }, + input.PageUp: { + true: "", + false: "⤒", + }, + input.ArrowLeft: { + true: "", + false: "←", + }, + input.ArrowRight: { + true: "", + false: "→", + }, + input.Space: { + true: "", + false: "\\\\\\\\s", + }, + input.Enter: { + true: "", + false: "⏎", + }, + input.Escape: { + true: "", + false: "⎋", + }, + input.Tab: { + true: "", + false: "⇥", + }, +} + +func keyToDisplay(key input.Key) string { + if override, ok := keypressSymbolOverrides[key]; ok { + if symbol, ok := override[false]; ok { + fmt.Println("returning symbol: ", symbol) + return symbol + } + } + return string(inverseKeymap[key]) +} + // Push adds a new key press event to the collection. func (k *KeyStrokeEvents) Push(display string) { - k.display += strconv.Quote(display) + k.display += display event := KeyStrokeEvent{Display: k.display, WhenMS: time.Now().Sub(k.startTime).Milliseconds()} k.events = append(k.events, event) } @@ -86,13 +170,13 @@ type Keyboard struct { // Press is a wrapper around the rod.Keyboard#Press method. func (k *Keyboard) Press(key input.Key) { - k.KeyStrokeEvents.Push(string(inverseKeymap[key])) + k.KeyStrokeEvents.Push(keyToDisplay(key)) k.Keyboard.Press(key) } // Type is a wrapper around the rod.Keyboard#Type method. func (k *Keyboard) Type(key input.Key) { - k.KeyStrokeEvents.Push(string(inverseKeymap[key])) + k.KeyStrokeEvents.Push(keyToDisplay(key)) k.Keyboard.Type(key) } From c8b0351adbad4aae85d776318cf8cfd4c16a731e Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 19 May 2024 16:40:16 -0400 Subject: [PATCH 15/38] Draw overlay at bottom center with some padding This is actually still too simple. We'll have to do testing with the margin or whatever settings in vhs. --- ffmpeg.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ffmpeg.go b/ffmpeg.go index 3f903efc..3f8cdec2 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -185,15 +185,15 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(events []KeyStrokeEvent) *FilterC } fb.filterComplex.WriteString( fmt.Sprintf(` - [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x=%d:y=%d:enable='%s'[%s] + [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x=%s:y=%s:enable='%s'[%s] `, prevStageName, "Noto Mono", events[i].Display, "white", 30, - 00, - 00, + "(w-text_w)/2", + "h-text_h-40", enableCondition, stageName, ), From 55996de6687cd0e7c3a275d3884a2725331febcc Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 19 May 2024 22:15:19 -0400 Subject: [PATCH 16/38] Fix slight (~30ms) desync in keystroke overlay --- keystroke.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/keystroke.go b/keystroke.go index e9fc8827..8c188c73 100644 --- a/keystroke.go +++ b/keystroke.go @@ -1,7 +1,7 @@ package main import ( - "fmt" + "sync" "time" "github.com/go-rod/rod" @@ -22,14 +22,21 @@ type KeyStrokeEvent struct { type KeyStrokeEvents struct { display string events []KeyStrokeEvent + once sync.Once startTime time.Time } // NewKeyStrokeEvents creates a new KeyStrokeEvents struct. func NewKeyStrokeEvents() *KeyStrokeEvents { return &KeyStrokeEvents{ - display: "", - events: make([]KeyStrokeEvent, 0), + display: "", + events: make([]KeyStrokeEvent, 0), + // NOTE: This is actually setting the startTime too early. It + // takes a while (in computer time) to get to the point where + // we start recording. Therefore, we actually set this another + // time on the first push. Without this, the final overlay + // would be slightly desynced by a 20-40 ms, which is + // noticeable to the human eye. startTime: time.Now(), } } @@ -111,7 +118,6 @@ var keypressSymbolOverrides = map[input.Key]map[bool]string{ func keyToDisplay(key input.Key) string { if override, ok := keypressSymbolOverrides[key]; ok { if symbol, ok := override[false]; ok { - fmt.Println("returning symbol: ", symbol) return symbol } } @@ -120,6 +126,9 @@ func keyToDisplay(key input.Key) string { // Push adds a new key press event to the collection. func (k *KeyStrokeEvents) Push(display string) { + k.once.Do(func() { + k.startTime = time.Now() + }) k.display += display event := KeyStrokeEvent{Display: k.display, WhenMS: time.Now().Sub(k.startTime).Milliseconds()} k.events = append(k.events, event) From 30e2dafabf967ed837d9a6af6bc7030abfa48c11 Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 27 May 2024 13:21:02 -0400 Subject: [PATCH 17/38] Use a monospace as our hardcoded default family --- ffmpeg.go | 2 +- keystroke.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ffmpeg.go b/ffmpeg.go index 3f8cdec2..5399949a 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -188,7 +188,7 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(events []KeyStrokeEvent) *FilterC [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x=%s:y=%s:enable='%s'[%s] `, prevStageName, - "Noto Mono", + "monospace", events[i].Display, "white", 30, diff --git a/keystroke.go b/keystroke.go index 8c188c73..d5fa4d7d 100644 --- a/keystroke.go +++ b/keystroke.go @@ -99,7 +99,7 @@ var keypressSymbolOverrides = map[input.Key]map[bool]string{ }, input.Space: { true: "", - false: "\\\\\\\\s", + false: "␣", }, input.Enter: { true: "", From 597394bf16e890f71a7071126b23484767244d1a Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 27 May 2024 14:29:37 -0400 Subject: [PATCH 18/38] Handle keypress overlay color This also gets rid of the minor TODO comment about an empty events slice, which could technically happen. --- command.go | 7 +++++++ ffmpeg.go | 5 +++-- keystroke.go | 2 ++ vhs.go | 3 +-- video.go | 30 ++++++++++++++++++++---------- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/command.go b/command.go index 689a350c..381c5d99 100644 --- a/command.go +++ b/command.go @@ -370,6 +370,13 @@ func ExecuteSetTheme(c parser.Command, v *VHS) { _, _ = v.Page.Eval(fmt.Sprintf("() => term.options.theme = %s", string(bts))) v.Options.Video.Style.BackgroundColor = v.Options.Theme.Background v.Options.Video.Style.WindowBarColor = v.Options.Theme.Background + // The intuitive behavior is to have keystroke overlay inherit from the + // foreground color. One key benefit of this behavior is that you won't have + // issues where e.g. a light theme makes a default white-value keypress + // overlay be hard to read. If it does, then the theme is likely + // fundamentally 'broken' since the text you type at the shell will + // similarly be very hard to read. + v.Options.Video.KeyStrokeOverlay.Color = v.Options.Theme.Foreground } // ExecuteSetTypingSpeed applies the default typing speed on the vhs. diff --git a/ffmpeg.go b/ffmpeg.go index 5399949a..eabc6cb5 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -173,7 +173,8 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB } // WithKeyStrokes adds key stroke drawtext options to the ffmpeg filter_complex. -func (fb *FilterComplexBuilder) WithKeyStrokes(events []KeyStrokeEvent) *FilterComplexBuilder { +func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplexBuilder { + events := opts.KeyStrokeOverlay.Events prevStageName := fb.prevStageName for i := range events { event := events[i] @@ -190,7 +191,7 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(events []KeyStrokeEvent) *FilterC prevStageName, "monospace", events[i].Display, - "white", + opts.KeyStrokeOverlay.Color, 30, "(w-text_w)/2", "h-text_h-40", diff --git a/keystroke.go b/keystroke.go index d5fa4d7d..bd47d212 100644 --- a/keystroke.go +++ b/keystroke.go @@ -48,6 +48,8 @@ func NewKeyStrokeEvents() *KeyStrokeEvents { // (newline). The description string and symbol are embedded into an inner map, // which can be indexed into based on whether special symbols are requested or // not. +// TODO: I think we can ignore the non-special symbol case and just use the +// symbols since we can rely on font fallback behavior. var keypressSymbolOverrides = map[input.Key]map[bool]string{ input.Backspace: { true: "\\\\\\\\b", diff --git a/vhs.go b/vhs.go index df2cf242..06982cf4 100644 --- a/vhs.go +++ b/vhs.go @@ -219,9 +219,8 @@ func (vhs *VHS) Render() error { } if vhs.Options.ShouldRenderKeyStrokes { - vhs.Options.Video.KeyStrokeEvents = vhs.Page.KeyStrokeEvents.Slice() + vhs.Options.Video.KeyStrokeOverlay.Events = vhs.Page.KeyStrokeEvents.Slice() } - fmt.Println("should render keystrokes?: ", vhs.Options.ShouldRenderKeyStrokes, " num events: ", len(vhs.Options.Video.KeyStrokeEvents)) // Generate the video(s) with the frames. var cmds []*exec.Cmd diff --git a/video.go b/video.go index 323430e5..efe22ed8 100644 --- a/video.go +++ b/video.go @@ -47,16 +47,22 @@ type VideoOutputs struct { Frames string } +// KeyStrokeOptions is the set of options for rendering keystroke overlay. +type KeyStrokeOptions struct { + Events []KeyStrokeEvent + Color string +} + // VideoOptions is the set of options for converting frames to a GIF. type VideoOptions struct { - Framerate int - PlaybackSpeed float64 - Input string - MaxColors int - Output VideoOutputs - StartingFrame int - Style *StyleOptions - KeyStrokeEvents []KeyStrokeEvent + Framerate int + PlaybackSpeed float64 + Input string + MaxColors int + Output VideoOutputs + StartingFrame int + Style *StyleOptions + KeyStrokeOverlay KeyStrokeOptions } const ( @@ -74,6 +80,10 @@ func DefaultVideoOptions() VideoOptions { Output: VideoOutputs{GIF: "", WebM: "", MP4: "", Frames: ""}, PlaybackSpeed: defaultPlaybackSpeed, StartingFrame: defaultStartingFrame, + KeyStrokeOverlay: KeyStrokeOptions{ + Events: []KeyStrokeEvent{}, + Color: DefaultTheme.Foreground, + }, } } @@ -120,8 +130,8 @@ func buildFFopts(opts VideoOptions, targetFile string) []string { WithBorderRadius(streamBuilder.cornerStream). WithMarginFill(streamBuilder.marginStream) - if opts.KeyStrokeEvents != nil { // TODO: What if number of events is 0? - filterBuilder = filterBuilder.WithKeyStrokes(opts.KeyStrokeEvents) + if len(opts.KeyStrokeOverlay.Events) > 0 { + filterBuilder = filterBuilder.WithKeyStrokes(opts) } // Format-specific options From 70eb243d6062a350ee447b9a5d7bc1723b52301c Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 27 May 2024 23:38:01 -0400 Subject: [PATCH 19/38] Explain the position equations for overlay --- ffmpeg.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ffmpeg.go b/ffmpeg.go index eabc6cb5..13751096 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -193,8 +193,8 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplex events[i].Display, opts.KeyStrokeOverlay.Color, 30, - "(w-text_w)/2", - "h-text_h-40", + "(w-text_w)/2", // Horizontal center. + "h-text_h-40", // Vertical center. enableCondition, stageName, ), From 87371709eaf50b12d36556d60cb235822ee5b4d7 Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 27 May 2024 23:43:12 -0400 Subject: [PATCH 20/38] Use default font size for overlay --- ffmpeg.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ffmpeg.go b/ffmpeg.go index 13751096..24657a94 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -192,7 +192,7 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplex "monospace", events[i].Display, opts.KeyStrokeOverlay.Color, - 30, + defaultFontSize, "(w-text_w)/2", // Horizontal center. "h-text_h-40", // Vertical center. enableCondition, From 43e0f934f2e4152607ee2ab417d69db717b51bfa Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 27 May 2024 23:47:56 -0400 Subject: [PATCH 21/38] Some minor clean-up to ffmpeg.go changes --- ffmpeg.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ffmpeg.go b/ffmpeg.go index 24657a94..7c4f5d9f 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -174,12 +174,22 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB // WithKeyStrokes adds key stroke drawtext options to the ffmpeg filter_complex. func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplexBuilder { + const ( + defaultFontFamily = "monospace" + horizontalCenter = "(w-text_w)/2" + verticalCenter = "h-text_h-40" + ) + events := opts.KeyStrokeOverlay.Events prevStageName := fb.prevStageName for i := range events { event := events[i] fb.filterComplex.WriteString(";") stageName := fmt.Sprintf("keypressOverlay%d", i) + + // When setting the enable conditions, we have to handle the very last + // event specially. It technically has no 'end' so we set it to render + // until the end of the video. enableCondition := fmt.Sprintf("gte(t,%f)", float64(event.WhenMS)/1000) if i < len(events)-1 { enableCondition = fmt.Sprintf("between(t,%f,%f)", float64(events[i].WhenMS)/1000, float64(events[i+1].WhenMS)/1000) @@ -189,12 +199,12 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplex [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x=%s:y=%s:enable='%s'[%s] `, prevStageName, - "monospace", + defaultFontFamily, events[i].Display, opts.KeyStrokeOverlay.Color, defaultFontSize, - "(w-text_w)/2", // Horizontal center. - "h-text_h-40", // Vertical center. + horizontalCenter, + verticalCenter, enableCondition, stageName, ), @@ -202,8 +212,6 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplex prevStageName = stageName } - // fmt.Println("drawtext filter added: ", fb.filterComplex.String()) - // At the end of the loop, the previous stage name is now transfered to the filter complex builder's // state for use in subsequent filters. fb.prevStageName = prevStageName From 82a46fa5b4ac4e249ad0b63f0d38e5afa549a60c Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 10 Jun 2024 11:04:15 -0400 Subject: [PATCH 22/38] Handle margin & padding in the keystroke overlay Makes it so that the keystroke overlay's drawing agrees with the specified margin, otherwise, we'd be drawing on the margin which is probably OK, but doesn't make as much sense as honoring the margin. For padding, I opted to just reuse opts.Style.Padding for consistency. --- ffmpeg.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ffmpeg.go b/ffmpeg.go index 7c4f5d9f..6d9958d5 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -174,10 +174,10 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB // WithKeyStrokes adds key stroke drawtext options to the ffmpeg filter_complex. func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplexBuilder { - const ( + var ( defaultFontFamily = "monospace" horizontalCenter = "(w-text_w)/2" - verticalCenter = "h-text_h-40" + verticalCenter = fmt.Sprintf("h-text_h-%d", opts.Style.Margin+opts.Style.Padding) ) events := opts.KeyStrokeOverlay.Events @@ -196,7 +196,7 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplex } fb.filterComplex.WriteString( fmt.Sprintf(` - [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x=%s:y=%s:enable='%s'[%s] + [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x='%s':y='%s':enable='%s'[%s] `, prevStageName, defaultFontFamily, From e8f7dcffbfda21a6f5c620aca44bdfceb291b03b Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 10 Jun 2024 11:16:52 -0400 Subject: [PATCH 23/38] Implement ring-buffer style tracking of the display text --- keystroke.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/keystroke.go b/keystroke.go index bd47d212..8cfd9384 100644 --- a/keystroke.go +++ b/keystroke.go @@ -20,10 +20,11 @@ type KeyStrokeEvent struct { // KeyStrokeEvents is a collection of key press events that you can push to. type KeyStrokeEvents struct { - display string - events []KeyStrokeEvent - once sync.Once - startTime time.Time + display string + events []KeyStrokeEvent + once sync.Once + startTime time.Time + maxDisplaySize int } // NewKeyStrokeEvents creates a new KeyStrokeEvents struct. @@ -37,7 +38,8 @@ func NewKeyStrokeEvents() *KeyStrokeEvents { // time on the first push. Without this, the final overlay // would be slightly desynced by a 20-40 ms, which is // noticeable to the human eye. - startTime: time.Now(), + startTime: time.Now(), + maxDisplaySize: 20, } } @@ -132,6 +134,16 @@ func (k *KeyStrokeEvents) Push(display string) { k.startTime = time.Now() }) k.display += display + // Keep k.display @ 20 max. + // Anymore than that is probably overkill, and we don't want to run into + // issues where the overlay text is longer than the video width itself. + if len(k.display) > k.maxDisplaySize { + // We need to be cognizant of unicode -- we can't just slice off a byte, + // we have to slice off a _rune_. The conversion back-and-forth may be a + // bit inefficient, but k.display will always be tiny thanks to + // k.maxDisplaySize. + k.display = string([]rune(k.display)[1:]) + } event := KeyStrokeEvent{Display: k.display, WhenMS: time.Now().Sub(k.startTime).Milliseconds()} k.events = append(k.events, event) } From c288a328cad7a077d9e357337a2f948ee7c364ac Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 10 Jun 2024 11:42:18 -0400 Subject: [PATCH 24/38] Implement keystroke hide/show Before, we were either showing all the overlay the moment things were turned on, or never at all. Now we properly support turning it on/off in the middle of the recording. --- command.go | 9 ++++++++- evaluator.go | 18 +++++++++++++++++- keystroke.go | 21 +++++++++++++++++++++ vhs.go | 31 ++++++++++++++----------------- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/command.go b/command.go index 381c5d99..c77c73b4 100644 --- a/command.go +++ b/command.go @@ -390,7 +390,14 @@ func ExecuteSetTypingSpeed(c parser.Command, v *VHS) { // ExecuteSetKeyStrokes enables or disables keystroke overlay recording. func ExecuteSetKeyStrokes(c parser.Command, v *VHS) { - v.Options.ShouldRenderKeyStrokes = c.Args == "Show" + switch c.Args { + case "Hide": + v.Page.KeyStrokeEvents.Disable() + case "Show": + v.Page.KeyStrokeEvents.Enable() + default: + return + } } // ExecuteSetPadding applies the padding on the vhs. diff --git a/evaluator.go b/evaluator.go index 3e10b529..2e4532d8 100644 --- a/evaluator.go +++ b/evaluator.go @@ -15,6 +15,22 @@ import ( // EvaluatorOption is a function that can be used to modify the VHS instance. type EvaluatorOption func(*VHS) +// isIntermediateAllowedSet returns true if the set command is allowed to be +// evaluated in the middle of the tape. +// This function assumes that the given option is from a Set command. +func isIntermediateAllowedSet(opt string) bool { + intermediateAllowedSets := []string{ + "TypingSpeed", + "KeyStrokes", + } + for _, allowed := range intermediateAllowedSets { + if opt == allowed { + return true + } + } + return false +} + // Evaluate takes as input a tape string, an output writer, and an output file // and evaluates all the commands within the tape string and produces a GIF. func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...EvaluatorOption) []error { @@ -134,7 +150,7 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator // GIF as the frame sequence will change dimensions. This is fixable. // // We should remove if isSetting statement. - isSetting := cmd.Type == token.SET && cmd.Options != "TypingSpeed" + isSetting := cmd.Type == token.SET && !isIntermediateAllowedSet(cmd.Options) if isSetting || cmd.Type == token.REQUIRE { fmt.Fprintln(out, Highlight(cmd, true)) continue diff --git a/keystroke.go b/keystroke.go index 8cfd9384..0e2ddb35 100644 --- a/keystroke.go +++ b/keystroke.go @@ -20,6 +20,7 @@ type KeyStrokeEvent struct { // KeyStrokeEvents is a collection of key press events that you can push to. type KeyStrokeEvents struct { + enabled bool display string events []KeyStrokeEvent once sync.Once @@ -128,11 +129,31 @@ func keyToDisplay(key input.Key) string { return string(inverseKeymap[key]) } +// Enable enables key press event recording. +func (k *KeyStrokeEvents) Enable() { + k.enabled = true +} + +// Disable disables key press event recording. +func (k *KeyStrokeEvents) Disable() { + k.enabled = false +} + // Push adds a new key press event to the collection. func (k *KeyStrokeEvents) Push(display string) { k.once.Do(func() { k.startTime = time.Now() }) + + // If we're not enabled, we don't want to do anything. + // But note that we still want to update the start time -- this is because + // we need to know the global start time if we want to render any subsequent + // events correctly, and the keystroke overlay may be re-enabled later in + // the recording. + if !k.enabled { + return + } + k.display += display // Keep k.display @ 20 max. // Anymore than that is probably overkill, and we don't want to run into diff --git a/vhs.go b/vhs.go index 06982cf4..6e02e7ac 100644 --- a/vhs.go +++ b/vhs.go @@ -36,20 +36,19 @@ type VHS struct { // Options is the set of options for the setup. type Options struct { - Shell Shell - FontFamily string - FontSize int - LetterSpacing float64 - LineHeight float64 - TypingSpeed time.Duration - ShouldRenderKeyStrokes bool - Theme Theme - Test TestOptions - Video VideoOptions - LoopOffset float64 - CursorBlink bool - Screenshot ScreenshotOptions - Style StyleOptions + Shell Shell + FontFamily string + FontSize int + LetterSpacing float64 + LineHeight float64 + TypingSpeed time.Duration + Theme Theme + Test TestOptions + Video VideoOptions + LoopOffset float64 + CursorBlink bool + Screenshot ScreenshotOptions + Style StyleOptions } const ( @@ -218,9 +217,7 @@ func (vhs *VHS) Render() error { return err } - if vhs.Options.ShouldRenderKeyStrokes { - vhs.Options.Video.KeyStrokeOverlay.Events = vhs.Page.KeyStrokeEvents.Slice() - } + vhs.Options.Video.KeyStrokeOverlay.Events = vhs.Page.KeyStrokeEvents.Slice() // Generate the video(s) with the frames. var cmds []*exec.Cmd From f682b7ce42f2c2ea6d12cffd0a3d99fea58c298f Mon Sep 17 00:00:00 2001 From: may h Date: Mon, 10 Jun 2024 20:04:46 -0400 Subject: [PATCH 25/38] Remove symbol overrides We weren't actually using them, and in general from our talks with Maas, we want to err on implementing less in the first implementation, and add more as requests come in. --- keystroke.go | 90 +++++++++++----------------------------------------- 1 file changed, 19 insertions(+), 71 deletions(-) diff --git a/keystroke.go b/keystroke.go index 0e2ddb35..2ea6ec03 100644 --- a/keystroke.go +++ b/keystroke.go @@ -51,80 +51,28 @@ func NewKeyStrokeEvents() *KeyStrokeEvents { // (newline). The description string and symbol are embedded into an inner map, // which can be indexed into based on whether special symbols are requested or // not. -// TODO: I think we can ignore the non-special symbol case and just use the -// symbols since we can rely on font fallback behavior. -var keypressSymbolOverrides = map[input.Key]map[bool]string{ - input.Backspace: { - true: "\\\\\\\\b", - false: "⌫", - }, - input.Delete: { - true: "\\\\\\\\d", - false: "␡", - }, - input.ControlLeft: { - true: "+", - false: "C-", - }, - input.ControlRight: { - true: "+", - false: "C-", - }, - input.AltLeft: { - true: "+", - false: "⎇-", - }, - input.AltRight: { - true: "+", - false: "⎇-", - }, - input.ArrowDown: { - true: "", - false: "↓", - }, - input.PageDown: { - true: "", - false: "⤓", - }, - input.ArrowUp: { - true: "", - false: "↑", - }, - input.PageUp: { - true: "", - false: "⤒", - }, - input.ArrowLeft: { - true: "", - false: "←", - }, - input.ArrowRight: { - true: "", - false: "→", - }, - input.Space: { - true: "", - false: "␣", - }, - input.Enter: { - true: "", - false: "⏎", - }, - input.Escape: { - true: "", - false: "⎋", - }, - input.Tab: { - true: "", - false: "⇥", - }, +var keypressSymbolOverrides = map[input.Key]string{ + input.Backspace: "⌫", + input.Delete: "␡", + input.ControlLeft: "C-", + input.ControlRight: "C-", + input.AltLeft: "⎇-", + input.AltRight: "⎇-", + input.ArrowDown: "↓", + input.PageDown: "⤓", + input.ArrowUp: "↑", + input.PageUp: "⤒", + input.ArrowLeft: "←", + input.ArrowRight: "→", + input.Space: "␣", + input.Enter: "⏎", + input.Escape: "⎋", + input.Tab: "⇥", } func keyToDisplay(key input.Key) string { - if override, ok := keypressSymbolOverrides[key]; ok { - if symbol, ok := override[false]; ok { - return symbol - } + if symbol, ok := keypressSymbolOverrides[key]; ok { + return symbol } return string(inverseKeymap[key]) } From 156eccc759dbcf36d8f17a67f9436abe3924fbac Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 16 Jun 2024 13:41:30 -0400 Subject: [PATCH 26/38] Replace mentions of 'keypress' with 'keystroke' This is mostly to avoid confusion with so-called 'keypress commands'. --- command.go | 2 +- ffmpeg.go | 2 +- keystroke.go | 26 +++++++++++++------------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/command.go b/command.go index c77c73b4..205fe366 100644 --- a/command.go +++ b/command.go @@ -372,7 +372,7 @@ func ExecuteSetTheme(c parser.Command, v *VHS) { v.Options.Video.Style.WindowBarColor = v.Options.Theme.Background // The intuitive behavior is to have keystroke overlay inherit from the // foreground color. One key benefit of this behavior is that you won't have - // issues where e.g. a light theme makes a default white-value keypress + // issues where e.g. a light theme makes a default white-value keystroke // overlay be hard to read. If it does, then the theme is likely // fundamentally 'broken' since the text you type at the shell will // similarly be very hard to read. diff --git a/ffmpeg.go b/ffmpeg.go index 6d9958d5..88dfc15d 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -185,7 +185,7 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplex for i := range events { event := events[i] fb.filterComplex.WriteString(";") - stageName := fmt.Sprintf("keypressOverlay%d", i) + stageName := fmt.Sprintf("keystrokeOverlay%d", i) // When setting the enable conditions, we have to handle the very last // event specially. It technically has no 'end' so we set it to render diff --git a/keystroke.go b/keystroke.go index 2ea6ec03..916231f2 100644 --- a/keystroke.go +++ b/keystroke.go @@ -44,14 +44,14 @@ func NewKeyStrokeEvents() *KeyStrokeEvents { } } -// keypressSymbolOverrides maps certain input keys to their corresponding -// keypress string or symbol. These override the default rune for the -// corresponding input key to improve the visuals or readability of the keypress -// overlay. A good example of this improvement can be seen in things like Enter -// (newline). The description string and symbol are embedded into an inner map, -// which can be indexed into based on whether special symbols are requested or -// not. -var keypressSymbolOverrides = map[input.Key]string{ +// keystrokeSymbolOverrides maps certain input keys to their corresponding +// keystroke string or symbol. These override the default rune for the +// corresponding input key to improve the visuals or readability of the +// keystroke overlay. A good example of this improvement can be seen in things +// like Enter (newline). The description string and symbol are embedded into an +// inner map, which can be indexed into based on whether special symbols are +// requested or not. +var keystrokeSymbolOverrides = map[input.Key]string{ input.Backspace: "⌫", input.Delete: "␡", input.ControlLeft: "C-", @@ -71,7 +71,7 @@ var keypressSymbolOverrides = map[input.Key]string{ } func keyToDisplay(key input.Key) string { - if symbol, ok := keypressSymbolOverrides[key]; ok { + if symbol, ok := keystrokeSymbolOverrides[key]; ok { return symbol } return string(inverseKeymap[key]) @@ -125,10 +125,10 @@ func (k *KeyStrokeEvents) Slice() []KeyStrokeEvent { // Page is a wrapper around the rod.Page object. // It's primary purpose is to decorate the rod.Page struct such that we can -// record keypress events during the recording for keypress overlays. We prefer -// decorating so that we that minimize the possibility of future bugs around -// forgetting to log key presses, since all input is done through rod.Page (and -// technically rod.Page.MustElement() + rod.Page.Keyboard). +// record keystroke events during the recording for keystroke overlays. We +// prefer decorating so that we that minimize the possibility of future bugs +// around forgetting to log key presses, since all input is done through +// rod.Page (and technically rod.Page.MustElement() + rod.Page.Keyboard). type Page struct { *rod.Page Keyboard Keyboard From 065737cea96e26f9f243506db0be8f62d631ff74 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 16 Jun 2024 14:04:15 -0400 Subject: [PATCH 27/38] Make maxDisplaySize a parameter to NewKeyStrokeEvents() for testability --- keystroke.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/keystroke.go b/keystroke.go index 916231f2..9e4a9686 100644 --- a/keystroke.go +++ b/keystroke.go @@ -28,8 +28,14 @@ type KeyStrokeEvents struct { maxDisplaySize int } +const ( + // DefaultMaxDisplaySize is the default maximum display size for the + // keystroke overlay. + DefaultMaxDisplaySize = 20 +) + // NewKeyStrokeEvents creates a new KeyStrokeEvents struct. -func NewKeyStrokeEvents() *KeyStrokeEvents { +func NewKeyStrokeEvents(maxDisplaySize int) *KeyStrokeEvents { return &KeyStrokeEvents{ display: "", events: make([]KeyStrokeEvent, 0), @@ -40,7 +46,7 @@ func NewKeyStrokeEvents() *KeyStrokeEvents { // would be slightly desynced by a 20-40 ms, which is // noticeable to the human eye. startTime: time.Now(), - maxDisplaySize: 20, + maxDisplaySize: maxDisplaySize, } } @@ -137,7 +143,7 @@ type Page struct { // NewPage creates a new wrapper Page object. func NewPage(page *rod.Page) *Page { - keyStrokeEvents := NewKeyStrokeEvents() + keyStrokeEvents := NewKeyStrokeEvents(DefaultMaxDisplaySize) return &Page{Page: page, KeyStrokeEvents: keyStrokeEvents, Keyboard: Keyboard{page.Keyboard, page.MustElement("textarea"), keyStrokeEvents}} } From 2a5612cdef05c0d0e836994aa743559b417d6665 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 16 Jun 2024 14:04:36 -0400 Subject: [PATCH 28/38] Add initial keystroke_test.go & scaffolding --- keystroke_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 keystroke_test.go diff --git a/keystroke_test.go b/keystroke_test.go new file mode 100644 index 00000000..6f149bff --- /dev/null +++ b/keystroke_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" +) + +// checkKeyStrokeEvents checks if the key stroke events are as expected. +func checkKeyStrokeEvents(t *testing.T, events *KeyStrokeEvents, expected ...string) { + for i, event := range events.Slice() { + if actual := event.Display; expected[i] != actual { + t.Fatalf("expected event display %q, got %q", expected, actual) + } + } +} + +func defaultKeyStrokeEvents() *KeyStrokeEvents { + events := NewKeyStrokeEvents(DefaultMaxDisplaySize) + events.enabled = true + + return events +} From 5b5cafb18e5996dec280b9c7b640bd4926b6b662 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 16 Jun 2024 22:37:22 -0400 Subject: [PATCH 29/38] Add various tests for keystroke.go --- keystroke_test.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/keystroke_test.go b/keystroke_test.go index 6f149bff..feadc62b 100644 --- a/keystroke_test.go +++ b/keystroke_test.go @@ -2,6 +2,8 @@ package main import ( "testing" + + "github.com/go-rod/rod/lib/input" ) // checkKeyStrokeEvents checks if the key stroke events are as expected. @@ -15,7 +17,83 @@ func checkKeyStrokeEvents(t *testing.T, events *KeyStrokeEvents, expected ...str func defaultKeyStrokeEvents() *KeyStrokeEvents { events := NewKeyStrokeEvents(DefaultMaxDisplaySize) - events.enabled = true + events.Enable() return events } + +func TestKeyStrokeEventsRemembersKeyStrokes(t *testing.T) { + events := defaultKeyStrokeEvents() + events.Push("a") + events.Push("b") + events.Push("c") + checkKeyStrokeEvents(t, events, "a", "ab", "abc") +} + +func TestKeyStrokeEventsHonorsMaxDisplaySize(t *testing.T) { + events := defaultKeyStrokeEvents() + events.maxDisplaySize = 2 + + events.Push("a") + events.Push("b") + events.Push("c") + + // NOTE: It should not be "ab", but "bc" at the end -- we should be acting + // like a ring buffer. + checkKeyStrokeEvents(t, events, "a", "ab", "bc") +} + +func TestKeyStrokeEventsShowsNothingIfDisabled(t *testing.T) { + events := defaultKeyStrokeEvents() + events.Disable() + + events.Push("a") + events.Push("b") + events.Push("c") + + checkKeyStrokeEvents(t, events) +} + +func TestKeyStrokeEventsKeyToDisplay(t *testing.T) { + cases := []struct { + name string + key input.Key + expected string + }{ + { + name: "letter", + key: input.KeyA, + expected: "a", + }, + { + name: "number", + key: input.Digit1, + expected: "1", + }, + { + name: "symbol no override", + key: input.Minus, + expected: "-", + }, + { + name: "shifted key", + key: shift(input.Minus), + expected: "_", + }, + { + name: "symbol override", + key: input.Backspace, + expected: "⌫", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if actual := keyToDisplay(tc.key); tc.expected != actual { + t.Fatalf("expected display %q, got %q", tc.expected, actual) + } + }) + } +} From 0bcd635179733cba7ee83831b80968f5c228fe79 Mon Sep 17 00:00:00 2001 From: may h Date: Sat, 22 Jun 2024 16:31:29 -0400 Subject: [PATCH 30/38] Use time.Ticker for record loop instead of calculating it ourselves This also fixes the time-drift that became more and more pronounced as the recording proceeded. --- vhs.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/vhs.go b/vhs.go index 6e02e7ac..74e96b96 100644 --- a/vhs.go +++ b/vhs.go @@ -320,7 +320,8 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { go func() { counter := 0 - start := time.Now() + ticker := time.NewTicker(interval) + defer ticker.Stop() for { select { case <-ctx.Done(): @@ -333,10 +334,7 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { close(ch) return - case <-time.After(interval - time.Since(start)): - // record last attempt - start = time.Now() - + case <-ticker.C: if !vhs.recording { continue } From 92fadbe87e4f104fb759689f1771dfc064061437 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 14:14:18 -0400 Subject: [PATCH 31/38] Fix incorrect calculation of display size for unicode We need to count the runes in the conditional check, not just the truncating of the display string. Simple oversight. --- keystroke.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/keystroke.go b/keystroke.go index 9e4a9686..2b4624fd 100644 --- a/keystroke.go +++ b/keystroke.go @@ -112,12 +112,12 @@ func (k *KeyStrokeEvents) Push(display string) { // Keep k.display @ 20 max. // Anymore than that is probably overkill, and we don't want to run into // issues where the overlay text is longer than the video width itself. - if len(k.display) > k.maxDisplaySize { + if displayRunes := []rune(k.display); len(displayRunes) > k.maxDisplaySize { // We need to be cognizant of unicode -- we can't just slice off a byte, // we have to slice off a _rune_. The conversion back-and-forth may be a // bit inefficient, but k.display will always be tiny thanks to // k.maxDisplaySize. - k.display = string([]rune(k.display)[1:]) + k.display = string(displayRunes[1:]) } event := KeyStrokeEvent{Display: k.display, WhenMS: time.Now().Sub(k.startTime).Milliseconds()} k.events = append(k.events, event) From e137bbf5d2ab6d5aafcf9657b05919951e0684a3 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 14:34:56 -0400 Subject: [PATCH 32/38] Handle edge case of keystroke event close to end of recording --- command.go | 1 + ffmpeg.go | 26 +++++++++++++++++++++++++- keystroke.go | 14 ++++++++------ keystroke_test.go | 2 +- vhs.go | 5 ++++- video.go | 12 ++++++++---- 6 files changed, 47 insertions(+), 13 deletions(-) diff --git a/command.go b/command.go index 205fe366..aa110188 100644 --- a/command.go +++ b/command.go @@ -386,6 +386,7 @@ func ExecuteSetTypingSpeed(c parser.Command, v *VHS) { return } v.Options.TypingSpeed = typingSpeed + v.Options.Video.KeyStrokeOverlay.TypingSpeed = typingSpeed } // ExecuteSetKeyStrokes enables or disables keystroke overlay recording. diff --git a/ffmpeg.go b/ffmpeg.go index 88dfc15d..045a9e05 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -179,8 +179,32 @@ func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplex horizontalCenter = "(w-text_w)/2" verticalCenter = fmt.Sprintf("h-text_h-%d", opts.Style.Margin+opts.Style.Padding) ) - events := opts.KeyStrokeOverlay.Events + + // When we are dealing with the last event, things can actually get very + // subtly tricky. + // If the last keystroke event is _very_ close to the end of the recording + // (e.g. the last line of the .tape is Type), then there is a chance that + // this keystroke draw event gets effectively dropped. That is because the + // gte() clause here may have a timestamp that is exactly equal to or + // slightly greater than the actual length of the recording itself. In order + // to fix this, we actually want to "pad" the recording a little extra in + // this case. While we could simply unconditionally record a few more + // seconds at the end of every vhs recording, this is actually not a + // generalizable solution for a dynamic typing speed, where we would + // proportionally require _more_ extra frames to make sure to not drop any + // keystrokes. + // Now, the condition in which we need to do this corrective action is if + // the last event is recorded after the duration of the recording with a + // tolerance of 100 ms: + if overflow := events[len(events)-1].WhenMS - opts.KeyStrokeOverlay.Duration.Milliseconds(); overflow > -100 && overflow < 100 { + // If so, extend the recording by a window twice the length of the + // typing speed. This should give ample time for the last keystroke + // event to be properly rendered. + fb.filterComplex.WriteString(fmt.Sprintf(";\n[%s]tpad=stop_mode=clone:stop_duration=%f[endpad]\n", fb.prevStageName, opts.KeyStrokeOverlay.TypingSpeed.Seconds()*2)) + fb.prevStageName = "endpad" + } + prevStageName := fb.prevStageName for i := range events { event := events[i] diff --git a/keystroke.go b/keystroke.go index 2b4624fd..596a1c9a 100644 --- a/keystroke.go +++ b/keystroke.go @@ -25,6 +25,7 @@ type KeyStrokeEvents struct { events []KeyStrokeEvent once sync.Once startTime time.Time + duration time.Duration maxDisplaySize int } @@ -93,6 +94,13 @@ func (k *KeyStrokeEvents) Disable() { k.enabled = false } +// End signals to the KeyStrokeEvents that the recording has finished. +// This _seems_ small, but it is crucial to ensure that a final key stroke event +// is not lost due to the recording finishing 1 frame too early. +func (k *KeyStrokeEvents) End() { + k.duration = time.Now().Sub(k.startTime) +} + // Push adds a new key press event to the collection. func (k *KeyStrokeEvents) Push(display string) { k.once.Do(func() { @@ -123,12 +131,6 @@ func (k *KeyStrokeEvents) Push(display string) { k.events = append(k.events, event) } -// Slice returns the underlying slice of key press events. -// NOTE: This is a reference. -func (k *KeyStrokeEvents) Slice() []KeyStrokeEvent { - return k.events -} - // Page is a wrapper around the rod.Page object. // It's primary purpose is to decorate the rod.Page struct such that we can // record keystroke events during the recording for keystroke overlays. We diff --git a/keystroke_test.go b/keystroke_test.go index feadc62b..1ef3a3b1 100644 --- a/keystroke_test.go +++ b/keystroke_test.go @@ -8,7 +8,7 @@ import ( // checkKeyStrokeEvents checks if the key stroke events are as expected. func checkKeyStrokeEvents(t *testing.T, events *KeyStrokeEvents, expected ...string) { - for i, event := range events.Slice() { + for i, event := range events.events { if actual := event.Display; expected[i] != actual { t.Fatalf("expected event display %q, got %q", expected, actual) } diff --git a/vhs.go b/vhs.go index 74e96b96..42867532 100644 --- a/vhs.go +++ b/vhs.go @@ -190,6 +190,8 @@ const cleanupWaitTime = 100 * time.Millisecond // Terminate cleans up a VHS instance and terminates the go-rod browser and ttyd // processes. func (vhs *VHS) terminate() error { + // Signal the end of all keystroke events. + vhs.Page.KeyStrokeEvents.End() // Give some time for any commands executed (such as `rm`) to finish. // // If a user runs a long running command, they must sleep for the required time @@ -217,7 +219,8 @@ func (vhs *VHS) Render() error { return err } - vhs.Options.Video.KeyStrokeOverlay.Events = vhs.Page.KeyStrokeEvents.Slice() + vhs.Options.Video.KeyStrokeOverlay.Events = vhs.Page.KeyStrokeEvents.events + vhs.Options.Video.KeyStrokeOverlay.Duration = vhs.Page.KeyStrokeEvents.duration // Generate the video(s) with the frames. var cmds []*exec.Cmd diff --git a/video.go b/video.go index efe22ed8..b509db79 100644 --- a/video.go +++ b/video.go @@ -15,6 +15,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" ) const ( @@ -49,8 +50,10 @@ type VideoOutputs struct { // KeyStrokeOptions is the set of options for rendering keystroke overlay. type KeyStrokeOptions struct { - Events []KeyStrokeEvent - Color string + Events []KeyStrokeEvent + Color string + TypingSpeed time.Duration + Duration time.Duration } // VideoOptions is the set of options for converting frames to a GIF. @@ -81,8 +84,9 @@ func DefaultVideoOptions() VideoOptions { PlaybackSpeed: defaultPlaybackSpeed, StartingFrame: defaultStartingFrame, KeyStrokeOverlay: KeyStrokeOptions{ - Events: []KeyStrokeEvent{}, - Color: DefaultTheme.Foreground, + Events: []KeyStrokeEvent{}, + Color: DefaultTheme.Foreground, + TypingSpeed: defaultTypingSpeed, }, } } From c9700173a9f65de54d59bdbce7bd228543d26135 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 15:06:21 -0400 Subject: [PATCH 33/38] Tune up the keystroke symbols Does some tune-up of our special symbols: - Delete now uses its correct symbol - PageUp & PageDown is replaced with their correct symbols - Escape replaced with left diagonal arrow (way more readable too) - Alt replaced with the option key symbol, not sure how I feel about this. - Control replaced with ^. - Shift added. It should be noted that "correct" here is a bit misleading. I don't know if there are any 'standard' symbols for some of these keystrokes, but I'm just choosing ones that seem to map to the key when you search them up online. --- keystroke.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/keystroke.go b/keystroke.go index 596a1c9a..21c61fcc 100644 --- a/keystroke.go +++ b/keystroke.go @@ -60,20 +60,22 @@ func NewKeyStrokeEvents(maxDisplaySize int) *KeyStrokeEvents { // requested or not. var keystrokeSymbolOverrides = map[input.Key]string{ input.Backspace: "⌫", - input.Delete: "␡", - input.ControlLeft: "C-", - input.ControlRight: "C-", - input.AltLeft: "⎇-", - input.AltRight: "⎇-", + input.Delete: "⌦", + input.ControlLeft: "^", + input.ControlRight: "^", + input.AltLeft: "⌥", + input.AltRight: "⌥", + input.ShiftLeft: "⇧", + input.ShiftRight: "⇧", input.ArrowDown: "↓", - input.PageDown: "⤓", + input.PageDown: "⇟", input.ArrowUp: "↑", - input.PageUp: "⤒", + input.PageUp: "⇞", input.ArrowLeft: "←", input.ArrowRight: "→", input.Space: "␣", input.Enter: "⏎", - input.Escape: "⎋", + input.Escape: "↖", input.Tab: "⇥", } From 02791e78bc5dba2c90e8d530627788cc4fbf28aa Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 16:12:45 -0400 Subject: [PATCH 34/38] Wrap *rod.KeyActions --- keystroke.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/keystroke.go b/keystroke.go index 21c61fcc..07a6b456 100644 --- a/keystroke.go +++ b/keystroke.go @@ -163,6 +163,50 @@ func (p *Page) MustWait(js string) *Page { return p } +// KeyActions is a wrapper around the rod.Page#KeyActions method. +func (p *Page) KeyActions() *KeyActions { + return &KeyActions{ + KeyActions: p.Page.KeyActions(), + displays: []string{}, + KeyStrokeEvents: p.KeyStrokeEvents, + } +} + +// Keyboard is a wrapper around the rod.KeyActions method. +type KeyActions struct { + *rod.KeyActions + displays []string + KeyStrokeEvents *KeyStrokeEvents +} + +// Press is a wrapper around the rod.KeyActions#Press method. +func (k *KeyActions) Press(key input.Key) *KeyActions { + k.displays = append(k.displays, keyToDisplay(key)) + return &KeyActions{ + KeyActions: k.KeyActions.Press(key), + displays: k.displays, + KeyStrokeEvents: k.KeyStrokeEvents, + } +} + +// Type is a wrapper around the rod.KeyActions#Type method. +func (k *KeyActions) Type(key input.Key) *KeyActions { + k.displays = append(k.displays, keyToDisplay(key)) + return &KeyActions{ + KeyActions: k.KeyActions.Type(key), + displays: k.displays, + KeyStrokeEvents: k.KeyStrokeEvents, + } +} + +// MustDo is a wrapper around the rod.KeyActions#MustDo method. +func (k *KeyActions) MustDo() { + for _, display := range k.displays { + k.KeyStrokeEvents.Push(display) + } + k.KeyActions.MustDo() +} + // Keyboard is a wrapper around the rod.Keyboard object. type Keyboard struct { *rod.Keyboard From cedabfd9ffdcd582a04ddecb8dae35f0117a958c Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 16:47:02 -0400 Subject: [PATCH 35/38] Add README section for KeyStrokes (without GIFs) --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 87404849..49a42673 100644 --- a/README.md +++ b/README.md @@ -515,6 +515,22 @@ Set CursorBlink false Example of setting the cursor blink. +#### Set KeyStroke Overlay + +Set whether keystrokes in the recording should be rendered in subtitle fashion on the recording. You can toggle keystrokes rendering on or off for sections of the tape. + +**NOTE**: Keystroke overlay will use symbols for certain non-printable keystrokes (e.g. Backspace). If these symbols do not render correctly, investigate your `fontconfig` settings for the `monospace` family. + +```elixir +Set KeyStrokes Show +``` + + + + + Example of setting the keystroke overlay. + + ### Type Use `Type` to emulate key presses. That is, you can use `Type` to script typing From 3421169bb2ceb239e4088c29c6fa58b8cb098254 Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 17:23:22 -0400 Subject: [PATCH 36/38] Remove debug print --- vhs.go | 1 - 1 file changed, 1 deletion(-) diff --git a/vhs.go b/vhs.go index 42867532..862f7bee 100644 --- a/vhs.go +++ b/vhs.go @@ -233,7 +233,6 @@ func (vhs *VHS) Render() error { if cmd == nil { continue } - fmt.Println("executing cmd; ", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { log.Println(string(out)) From 73af99328f3fe877bc2fecca1e89bd94b28c84eb Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 20:18:53 -0400 Subject: [PATCH 37/38] Add uploaded gifs for keystroke overlay --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 49a42673..c651860e 100644 --- a/README.md +++ b/README.md @@ -526,9 +526,9 @@ Set KeyStrokes Show ``` - - - Example of setting the keystroke overlay. + + + Example of setting the keystroke overlay. ### Type From e999a7da72bff5e1019b480f39412f05230001bf Mon Sep 17 00:00:00 2001 From: may h Date: Sun, 30 Jun 2024 20:21:20 -0400 Subject: [PATCH 38/38] Fix uploaded demo gif --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c651860e..4b3784b8 100644 --- a/README.md +++ b/README.md @@ -526,9 +526,9 @@ Set KeyStrokes Show ``` - - - Example of setting the keystroke overlay. + + + Example of setting the keystroke overlay. ### Type