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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ There are a few basic types of VHS commands:
- [`Backspace`](#backspace) [`Enter`](#enter) [`Tab`](#tab) [`Space`](#space): special keys
- [`Ctrl[+Alt][+Shift]+<char>`](#ctrl): press control + key and/or modifier
- [`Sleep <time>`](#sleep): wait for a certain amount of time
- [`Playback@<speed>`](#playback): change playback speed for subsequent commands
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use the existing SET PlaybackSpeed inline like SET TypingSpeed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cwarden Great question! I wanted to leave the existing implementation as is, specially because "PlaybackSpeed" is a global setting.

This was a compromise on my part to ensure "PlaybackSpeed" remains global and the new "Playback@*" commands become anchors/markers for this section-base speed configuration.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't check for an existing pull request before adding the same feature, but reusing SET PlaybackSpeek. I don't feel that strongly about it, but making playback speed congruent with typing speed makes sense to me. There's an alternative implementation in 5b1c930 using this approach.

Copy link
Copy Markdown
Author

@0xjuanma 0xjuanma Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, as you pointed out I also went with the "Playback@" approach to try to follow what "TypingSpeed" and "Type@*" are currently doing.

I also don't have strong opinions about this, so I'll let the maintainers decide whats the better choice for this 👍🏽

- [`Wait[+Screen][+Line] /regex/`](#wait): wait for specific conditions
- [`Hide`](#hide): hide commands from output
- [`Show`](#show): stop hiding commands from output
Expand Down Expand Up @@ -495,14 +496,34 @@ Set Framerate 60

#### Set Playback Speed

Set the playback speed of the final render.
Set the global playback speed of the final render.

```elixir
Set PlaybackSpeed 0.5 # Make output 2 times slower
Set PlaybackSpeed 1.0 # Keep output at normal speed (default)
Set PlaybackSpeed 2.0 # Make output 2 times faster
```

### Playback

Set the playback speed for subsequent sections or commands. Unlike `Set PlaybackSpeed` which applies globally to the entire output, `Playback@` lets you vary the speed at different points in your recording.

```elixir
Type "Normal speed here"

Playback@2
# This renders at 2x speed
Type "SlowCommand"
Enter
Sleep 1s

Playback@0.5
Type "This plays in slow motion"

Playback@1
Type "Back to normal speed"
```

#### Set Loop Offset

Set the offset for when the GIF loop should begin. This allows you to make the
Expand Down
11 changes: 11 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ var CommandFuncs = map[parser.CommandType]CommandFunc{
token.PASTE: ExecutePaste,
token.ENV: ExecuteEnv,
token.WAIT: ExecuteWait,
token.PLAYBACK: ExecutePlayback,
}

// ExecuteNoop is a no-op command that does nothing.
Expand Down Expand Up @@ -324,6 +325,16 @@ func ExecuteSleep(c parser.Command, _ *VHS) error {
return nil
}

// ExecutePlayback records a playback speed section boundary at the current frame.
func ExecutePlayback(c parser.Command, v *VHS) error {
speed, err := strconv.ParseFloat(c.Args, 64)
if err != nil {
return fmt.Errorf("failed to parse playback speed: %w", err)
}
v.AddPlaybackSection(speed)
return nil
}

// ExecuteType types the argument string on the running instance of vhs.
func ExecuteType(c parser.Command, v *VHS) error {
typingSpeed := v.Options.TypingSpeed
Expand Down
4 changes: 2 additions & 2 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import (
)

func TestCommand(t *testing.T) {
const numberOfCommands = 29
const numberOfCommands = 30
if len(parser.CommandTypes) != numberOfCommands {
t.Errorf("Expected %d commands, got %d", numberOfCommands, len(parser.CommandTypes))
}

const numberOfCommandFuncs = 29
const numberOfCommandFuncs = 30
if len(CommandFuncs) != numberOfCommandFuncs {
t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs))
}
Expand Down
74 changes: 67 additions & 7 deletions ffmpeg.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,35 @@ func NewVideoFilterBuilder(videoOpts *VideoOptions) *FilterComplexBuilder {
filterCode := strings.Builder{}
termWidth, termHeight := calcTermDimensions(*videoOpts.Style)

// Base overlay and scale
filterCode.WriteString(
fmt.Sprintf(`
[0][1]overlay[merged];
[merged]scale=%d:%d:force_original_aspect_ratio=1[scaled];
[scaled]fps=%d,setpts=PTS/%f[speed];
[speed]pad=%d:%d:(ow-iw)/2:(oh-ih)/2:%s[padded];
[padded]fillborders=left=%d:right=%d:top=%d:bottom=%d:mode=fixed:color=%s[padded]
`,
[merged]scale=%d:%d:force_original_aspect_ratio=1[scaled]`,
termWidth-double(videoOpts.Style.Padding),
termHeight-double(videoOpts.Style.Padding),
),
)

videoOpts.Framerate,
videoOpts.PlaybackSpeed,
// Apply speed: either uniform or per-section
if len(videoOpts.PlaybackSections) > 0 {
buildPlaybackSections(&filterCode, videoOpts)
} else {
filterCode.WriteString(
fmt.Sprintf(`;
[scaled]fps=%d,setpts=PTS/%f[speed]`,
videoOpts.Framerate,
videoOpts.PlaybackSpeed,
),
)
}

// Padding and borders
filterCode.WriteString(
fmt.Sprintf(`;
[speed]pad=%d:%d:(ow-iw)/2:(oh-ih)/2:%s[padded];
[padded]fillborders=left=%d:right=%d:top=%d:bottom=%d:mode=fixed:color=%s[padded]
`,
termWidth,
termHeight,
videoOpts.Style.BackgroundColor,
Expand All @@ -56,6 +71,51 @@ func NewVideoFilterBuilder(videoOpts *VideoOptions) *FilterComplexBuilder {
}
}

// buildPlaybackSections generates FFmpeg filter for variable playback speed.
func buildPlaybackSections(filterCode *strings.Builder, opts *VideoOptions) {
sections := opts.PlaybackSections
n := len(sections)
framerate := opts.Framerate

// Apply fps first
filterCode.WriteString(fmt.Sprintf(`;
[scaled]fps=%d[fpsout]`, framerate))

// Split into n streams
filterCode.WriteString(fmt.Sprintf(`;
[fpsout]split=%d`, n))
for i := 0; i < n; i++ {
filterCode.WriteString(fmt.Sprintf("[s%d]", i))
}

// Process each section with trim and speed adjustment
startingFrame := opts.StartingFrame
for i, section := range sections {
// Convert frame numbers to time
// FFmpeg uses -start_number StartingFrame, so frame StartingFrame is at time 0
startTime := float64(section.StartFrame-startingFrame) / float64(framerate)
var trimFilter string
if i < n-1 {
endTime := float64(sections[i+1].StartFrame-startingFrame) / float64(framerate)
trimFilter = fmt.Sprintf("trim=start=%f:end=%f", startTime, endTime)
} else {
trimFilter = fmt.Sprintf("trim=start=%f", startTime)
}

filterCode.WriteString(fmt.Sprintf(`;
[s%d]%s,setpts=PTS-STARTPTS,setpts=PTS/%f[v%d]`,
i, trimFilter, section.Speed, i))
}

// Concat all sections
filterCode.WriteString(`;
`)
for i := 0; i < n; i++ {
filterCode.WriteString(fmt.Sprintf("[v%d]", i))
}
filterCode.WriteString(fmt.Sprintf("concat=n=%d:v=1:a=0[speed]", n))
}

// NewScreenshotFilterComplexBuilder returns instance of FilterComplexBuilder with screenshot config.
func NewScreenshotFilterComplexBuilder(style *StyleOptions) *FilterComplexBuilder {
filterCode := strings.Builder{}
Expand Down
14 changes: 13 additions & 1 deletion lexer/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ Sleep 2
Wait+Screen@1m /foobar/
Wait+Screen@1m /foo\/bar/
Wait+Screen@1m /foo\\/
Wait+Screen@1m /foo\\\/bar/`
Wait+Screen@1m /foo\\\/bar/
Playback@2.0
Playback@0.5
Playback@1`

tests := []struct {
expectedType token.Type
Expand Down Expand Up @@ -119,6 +122,15 @@ Wait+Screen@1m /foo\\\/bar/`
{token.NUMBER, "1"},
{token.MINUTES, "m"},
{token.REGEX, "foo\\\\\\/bar"},
{token.PLAYBACK, "Playback"},
{token.AT, "@"},
{token.NUMBER, "2.0"},
{token.PLAYBACK, "Playback"},
{token.AT, "@"},
{token.NUMBER, "0.5"},
{token.PLAYBACK, "Playback"},
{token.AT, "@"},
{token.NUMBER, "1"},
}

l := New(input)
Expand Down
39 changes: 39 additions & 0 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var CommandTypes = []CommandType{ //nolint: deadcode
token.COPY,
token.PASTE,
token.ENV,
token.PLAYBACK,
}

// String returns the string representation of the command.
Expand Down Expand Up @@ -181,6 +182,8 @@ func (p *Parser) parseCommand() []Command {
return []Command{p.parsePaste()}
case token.ENV:
return []Command{p.parseEnv()}
case token.PLAYBACK:
return []Command{p.parsePlayback()}
default:
p.errors = append(p.errors, NewError(p.cur, "Invalid command: "+p.cur.Literal))
return []Command{{Type: token.ILLEGAL}}
Expand Down Expand Up @@ -534,6 +537,42 @@ func (p *Parser) parseSleep() Command {
return cmd
}

// parsePlayback parses a Playback command.
// A playback command uses @<number> syntax to set playback speed multiplier.
//
// Playback@<number>
func (p *Parser) parsePlayback() Command {
cmd := Command{Type: token.PLAYBACK}

if p.peek.Type != token.AT {
p.errors = append(p.errors, NewError(p.cur, "Expected @ after Playback"))
return cmd
}
p.nextToken()

if p.peek.Type != token.NUMBER {
p.errors = append(p.errors, NewError(p.cur, "Expected number after Playback@"))
return cmd
}

speed, err := strconv.ParseFloat(p.peek.Literal, 64)
if err != nil {
p.errors = append(p.errors, NewError(p.peek, "Invalid playback speed: "+p.peek.Literal))
p.nextToken()
return cmd
}

if speed <= 0 {
p.errors = append(p.errors, NewError(p.peek, "Playback speed must be positive"))
p.nextToken()
return cmd
}

cmd.Args = p.peek.Literal
p.nextToken()
return cmd
}

// parseHide parses a Hide command.
//
// Hide
Expand Down
40 changes: 39 additions & 1 deletion parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ Sleep 100ms
Sleep 3
Wait
Wait+Screen
Wait@100ms /foobar/`
Wait@100ms /foobar/
Playback@2.0
Playback@0.5
Playback@1`

expected := []Command{
{Type: token.SET, Options: "TypingSpeed", Args: "100ms"},
Expand All @@ -59,6 +62,9 @@ Wait@100ms /foobar/`
{Type: token.WAIT, Args: "Line"},
{Type: token.WAIT, Args: "Screen"},
{Type: token.WAIT, Options: "100ms", Args: "Line foobar"},
{Type: token.PLAYBACK, Args: "2.0"},
{Type: token.PLAYBACK, Args: "0.5"},
{Type: token.PLAYBACK, Args: "1"},
}

l := lexer.New(input)
Expand Down Expand Up @@ -427,3 +433,35 @@ func TestParseScreeenshot(t *testing.T) {
test.run(t)
})
}

func TestParsePlayback(t *testing.T) {
t.Run("valid playback commands", func(t *testing.T) {
l := lexer.New("Playback@2.0")
p := New(l)
cmds := p.Parse()
if len(p.errors) > 0 {
t.Errorf("expected no errors, got %v", p.errors)
}
if len(cmds) != 1 || cmds[0].Type != token.PLAYBACK || cmds[0].Args != "2.0" {
t.Errorf("expected Playback command with args 2.0, got %v", cmds)
}
})

t.Run("missing @ symbol", func(t *testing.T) {
l := lexer.New("Playback 2.0")
p := New(l)
_ = p.Parse()
if len(p.errors) == 0 {
t.Error("expected error for missing @")
}
})

t.Run("zero speed", func(t *testing.T) {
l := lexer.New("Playback@0")
p := New(l)
_ = p.Parse()
if len(p.errors) == 0 {
t.Error("expected error for zero speed")
}
})
}
2 changes: 2 additions & 0 deletions syntax.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ func Highlight(c parser.Command, faint bool) string {
case token.TYPE:
optionsStyle = TimeStyle
argsStyle = StringStyle
case token.PLAYBACK:
argsStyle = NumberStyle
case token.HIDE, token.SHOW:
return FaintStyle.Render(c.Type.String())
}
Expand Down
4 changes: 3 additions & 1 deletion token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
PAGE_UP = "PAGE_UP" //nolint:revive
SLEEP = "SLEEP"
SPACE = "SPACE"
PLAYBACK = "PLAYBACK"
TAB = "TAB"
SHIFT = "SHIFT"

Expand Down Expand Up @@ -158,6 +159,7 @@ var Keywords = map[string]Type{
"WaitPattern": WAIT_PATTERN,
"Wait": WAIT,
"Source": SOURCE,
"Playback": PLAYBACK,
"CursorBlink": CURSOR_BLINK,
"true": BOOLEAN,
"false": BOOLEAN,
Expand All @@ -183,7 +185,7 @@ func IsSetting(t Type) bool {
// IsCommand returns whether the string is a command.
func IsCommand(t Type) bool {
switch t {
case TYPE, SLEEP,
case TYPE, SLEEP, PLAYBACK,
UP, DOWN, RIGHT, LEFT, PAGE_UP, PAGE_DOWN,
ENTER, BACKSPACE, DELETE, TAB,
ESCAPE, HOME, INSERT, END, CTRL, SOURCE, SCREENSHOT, COPY, PASTE, WAIT:
Expand Down
Loading