diff --git a/README.md b/README.md index 874048493..4b3784b88 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 diff --git a/command.go b/command.go index 79832cc9f..aa110188a 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() } } @@ -283,6 +283,7 @@ var Settings = map[string]CommandFunc{ "Padding": ExecuteSetPadding, "Theme": ExecuteSetTheme, "TypingSpeed": ExecuteSetTypingSpeed, + "KeyStrokes": ExecuteSetKeyStrokes, "Width": ExecuteSetWidth, "Shell": ExecuteSetShell, "LoopOffset": ExecuteLoopOffset, @@ -369,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 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. + v.Options.Video.KeyStrokeOverlay.Color = v.Options.Theme.Foreground } // ExecuteSetTypingSpeed applies the default typing speed on the vhs. @@ -378,6 +386,19 @@ 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. +func ExecuteSetKeyStrokes(c parser.Command, v *VHS) { + 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/command_test.go b/command_test.go index c8629cdee..bb52938ef 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) { diff --git a/evaluator.go b/evaluator.go index 3e10b5298..2e4532d85 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/examples/fixtures/all.tape b/examples/fixtures/all.tape index cfd9e75fc..66adf8b34 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/ffmpeg.go b/ffmpeg.go index 4d4ae8026..045a9e05b 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -172,6 +172,76 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB return fb } +// WithKeyStrokes adds key stroke drawtext options to the ffmpeg filter_complex. +func (fb *FilterComplexBuilder) WithKeyStrokes(opts VideoOptions) *FilterComplexBuilder { + var ( + defaultFontFamily = "monospace" + 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] + fb.filterComplex.WriteString(";") + 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 + // 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) + } + fb.filterComplex.WriteString( + fmt.Sprintf(` + [%s]drawtext=font=%s:text='%s':fontcolor=%s:fontsize=%d:x='%s':y='%s':enable='%s'[%s] + `, + prevStageName, + defaultFontFamily, + events[i].Display, + opts.KeyStrokeOverlay.Color, + defaultFontSize, + horizontalCenter, + verticalCenter, + enableCondition, + stageName, + ), + ) + prevStageName = stageName + } + + // 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(";") diff --git a/keys.go b/keys.go index 384846306..0ecaaf6b6 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 + } +} diff --git a/keystroke.go b/keystroke.go new file mode 100644 index 000000000..07a6b4567 --- /dev/null +++ b/keystroke.go @@ -0,0 +1,233 @@ +package main + +import ( + "sync" + "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 { + enabled bool + display string + events []KeyStrokeEvent + once sync.Once + startTime time.Time + duration time.Duration + maxDisplaySize int +} + +const ( + // DefaultMaxDisplaySize is the default maximum display size for the + // keystroke overlay. + DefaultMaxDisplaySize = 20 +) + +// NewKeyStrokeEvents creates a new KeyStrokeEvents struct. +func NewKeyStrokeEvents(maxDisplaySize int) *KeyStrokeEvents { + return &KeyStrokeEvents{ + 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(), + maxDisplaySize: maxDisplaySize, + } +} + +// 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: "^", + input.ControlRight: "^", + input.AltLeft: "⌥", + input.AltRight: "⌥", + input.ShiftLeft: "⇧", + input.ShiftRight: "⇧", + 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 symbol, ok := keystrokeSymbolOverrides[key]; ok { + return symbol + } + 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 +} + +// 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() { + 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 + // issues where the overlay text is longer than the video width itself. + 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(displayRunes[1:]) + } + event := KeyStrokeEvent{Display: k.display, WhenMS: time.Now().Sub(k.startTime).Milliseconds()} + k.events = append(k.events, event) +} + +// 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 +// 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(DefaultMaxDisplaySize) + 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 +} + +// 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 + 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(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(keyToDisplay(key)) + k.Keyboard.Type(key) +} + +// 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/keystroke_test.go b/keystroke_test.go new file mode 100644 index 000000000..1ef3a3b11 --- /dev/null +++ b/keystroke_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "testing" + + "github.com/go-rod/rod/lib/input" +) + +// checkKeyStrokeEvents checks if the key stroke events are as expected. +func checkKeyStrokeEvents(t *testing.T, events *KeyStrokeEvents, expected ...string) { + for i, event := range events.events { + if actual := event.Display; expected[i] != actual { + t.Fatalf("expected event display %q, got %q", expected, actual) + } + } +} + +func defaultKeyStrokeEvents() *KeyStrokeEvents { + events := NewKeyStrokeEvents(DefaultMaxDisplaySize) + 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) + } + }) + } +} diff --git a/lexer/lexer_test.go b/lexer/lexer_test.go index 1383dfe77..2579d49bb 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"}, diff --git a/parser/parser.go b/parser/parser.go index 3ee8f2c9a..646c4dddc 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() diff --git a/parser/parser_test.go b/parser/parser_test.go index c82c7f652..e62f5f7b4 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,15 +91,22 @@ 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() } 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) } } } @@ -126,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"}, diff --git a/token/token.go b/token/token.go index a9cdd3d30..c2d9579a9 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 diff --git a/vhs.go b/vhs.go index 6660896fd..862f7bee2 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 @@ -141,7 +141,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 @@ -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,6 +219,9 @@ func (vhs *VHS) Render() error { return err } + 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 cmds = append(cmds, MakeGIF(vhs.Options.Video)) @@ -317,7 +322,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(): @@ -330,10 +336,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 } diff --git a/video.go b/video.go index 1cbf77f55..b509db79b 100644 --- a/video.go +++ b/video.go @@ -15,6 +15,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" ) const ( @@ -47,15 +48,24 @@ type VideoOutputs struct { Frames string } +// KeyStrokeOptions is the set of options for rendering keystroke overlay. +type KeyStrokeOptions struct { + Events []KeyStrokeEvent + Color string + TypingSpeed time.Duration + Duration time.Duration +} + // 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 + KeyStrokeOverlay KeyStrokeOptions } const ( @@ -73,6 +83,11 @@ func DefaultVideoOptions() VideoOptions { Output: VideoOutputs{GIF: "", WebM: "", MP4: "", Frames: ""}, PlaybackSpeed: defaultPlaybackSpeed, StartingFrame: defaultStartingFrame, + KeyStrokeOverlay: KeyStrokeOptions{ + Events: []KeyStrokeEvent{}, + Color: DefaultTheme.Foreground, + TypingSpeed: defaultTypingSpeed, + }, } } @@ -119,6 +134,10 @@ func buildFFopts(opts VideoOptions, targetFile string) []string { WithBorderRadius(streamBuilder.cornerStream). WithMarginFill(streamBuilder.marginStream) + if len(opts.KeyStrokeOverlay.Events) > 0 { + filterBuilder = filterBuilder.WithKeyStrokes(opts) + } + // Format-specific options switch filepath.Ext(targetFile) { case gif: