Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add keystroke overlay #496

Open
wants to merge 38 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c05f728
Add KEYSTROKES token
Utagai May 14, 2024
2e3ed01
Add coverage for KeyStrokes to lexer_test.go
Utagai May 14, 2024
c48ae74
Handle KeyStroke token in parser.go
Utagai May 14, 2024
17d0a0a
Improve the error message on TestParserError failures
Utagai May 14, 2024
412c514
Add coverage for KeyStroke parsing
Utagai May 14, 2024
2f7eb73
Add KeyStrokes to Options and implement ExecuteSetKeyStrokes for sett…
Utagai May 14, 2024
24fd73f
Add some coverage for Settings
Utagai May 14, 2024
cfb56d0
Add inverseKeymap in preparation for keystroke types
Utagai May 15, 2024
425eea5
Add initial implementation of keystroke.go
Utagai May 15, 2024
216b9ca
Start recording keystrokes
Utagai May 15, 2024
dde35ae
Implement FilterComplexBuilder#WithKeyStrokes()
Utagai May 19, 2024
c5b714e
Integrate drawText calls into rendering codepaths via VideoOptions
Utagai May 19, 2024
607a44b
Use a hardcoded font family instead of fontfile
Utagai May 19, 2024
e178839
Render symbols for certain keypresses
Utagai May 19, 2024
c8b0351
Draw overlay at bottom center with some padding
Utagai May 19, 2024
55996de
Fix slight (~30ms) desync in keystroke overlay
Utagai May 20, 2024
30e2daf
Use a monospace as our hardcoded default family
Utagai May 27, 2024
597394b
Handle keypress overlay color
Utagai May 27, 2024
70eb243
Explain the position equations for overlay
Utagai May 28, 2024
8737170
Use default font size for overlay
Utagai May 28, 2024
43e0f93
Some minor clean-up to ffmpeg.go changes
Utagai May 28, 2024
82a46fa
Handle margin & padding in the keystroke overlay
Utagai Jun 10, 2024
e8f7dcf
Implement ring-buffer style tracking of the display text
Utagai Jun 10, 2024
c288a32
Implement keystroke hide/show
Utagai Jun 10, 2024
f682b7c
Remove symbol overrides
Utagai Jun 11, 2024
156eccc
Replace mentions of 'keypress' with 'keystroke'
Utagai Jun 16, 2024
065737c
Make maxDisplaySize a parameter to NewKeyStrokeEvents() for testability
Utagai Jun 16, 2024
2a5612c
Add initial keystroke_test.go & scaffolding
Utagai Jun 16, 2024
5b5cafb
Add various tests for keystroke.go
Utagai Jun 17, 2024
0bcd635
Use time.Ticker for record loop instead of calculating it ourselves
Utagai Jun 22, 2024
92fadbe
Fix incorrect calculation of display size for unicode
Utagai Jun 30, 2024
e137bbf
Handle edge case of keystroke event close to end of recording
Utagai Jun 30, 2024
c970017
Tune up the keystroke symbols
Utagai Jun 30, 2024
02791e7
Wrap *rod.KeyActions
Utagai Jun 30, 2024
cedabfd
Add README section for KeyStrokes (without GIFs)
Utagai Jun 30, 2024
3421169
Remove debug print
Utagai Jun 30, 2024
73af993
Add uploaded gifs for keystroke overlay
Utagai Jul 1, 2024
e999a7d
Fix uploaded demo gif
Utagai Jul 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,22 @@ Set CursorBlink false
<img width="600" alt="Example of setting the cursor blink." src="https://vhs.charm.sh/vhs-3rMCb80VEkaDdTOJMCrxKy.gif">
</picture>

#### 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
```

<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://vhs.charm.sh/vhs-7GzZg7oEHSbXtghZi97gwf.gif">
<source media="(prefers-color-scheme: light)" srcset="https://vhs.charm.sh/vhs-7GzZg7oEHSbXtghZi97gwf.gif">
<img width="600" alt="Example of setting the keystroke overlay." src="https://vhs.charm.sh/vhs-7GzZg7oEHSbXtghZi97gwf.gif">
</picture>

### Type

Use `Type` to emulate key presses. That is, you can use `Type` to script typing
Expand Down
51 changes: 36 additions & 15 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
}
Expand All @@ -283,6 +283,7 @@ var Settings = map[string]CommandFunc{
"Padding": ExecuteSetPadding,
"Theme": ExecuteSetTheme,
"TypingSpeed": ExecuteSetTypingSpeed,
"KeyStrokes": ExecuteSetKeyStrokes,
"Width": ExecuteSetWidth,
"Shell": ExecuteSetShell,
"LoopOffset": ExecuteLoopOffset,
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 17 additions & 1 deletion evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions examples/fixtures/all.tape
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions ffmpeg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(";")
Expand Down
10 changes: 10 additions & 0 deletions keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading