Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ dist
vhs
.idea/
.vscode/
vhs-dev
78 changes: 75 additions & 3 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ var CommandFuncs = map[parser.CommandType]CommandFunc{
token.PASTE: ExecutePaste,
token.ENV: ExecuteEnv,
token.WAIT: ExecuteWait,
token.SUBTITLE: ExecuteSubtitle,
}

// ExecuteNoop is a no-op command that does nothing.
Expand Down Expand Up @@ -476,9 +477,16 @@ var Settings = map[string]CommandFunc{
"WindowBar": ExecuteSetWindowBar,
"WindowBarSize": ExecuteSetWindowBarSize,
"BorderRadius": ExecuteSetBorderRadius,
"WaitPattern": ExecuteSetWaitPattern,
"WaitTimeout": ExecuteSetWaitTimeout,
"CursorBlink": ExecuteSetCursorBlink,
"WaitPattern": ExecuteSetWaitPattern,
"WaitTimeout": ExecuteSetWaitTimeout,
"CursorBlink": ExecuteSetCursorBlink,
"SubtitleFontSize": ExecuteSetSubtitleFontSize,
"SubtitleFontFamily": ExecuteSetSubtitleFontFamily,
"SubtitleColor": ExecuteSetSubtitleColor,
"SubtitleBackground": ExecuteSetSubtitleBackground,
"SubtitlePosition": ExecuteSetSubtitlePosition,
"SubtitlePadding": ExecuteSetSubtitlePadding,
"SubtitleBorderRadius": ExecuteSetSubtitleBorderRadius,
}

// ExecuteSet applies the settings on the running vhs specified by the
Expand Down Expand Up @@ -773,3 +781,67 @@ func getJSONTheme(s string) (Theme, error) {
}
return t, nil
}

// ExecuteSubtitle stores subtitle state. The actual rendering happens in the
// Record loop where we draw onto the overlay canvas before each frame capture.
func ExecuteSubtitle(c parser.Command, v *VHS) error {
v.mu.Lock()
defer v.mu.Unlock()
v.subtitleText = c.Args
v.hasOverlay = true
return nil
}

// Subtitle setting executors

func ExecuteSetSubtitleFontSize(c parser.Command, v *VHS) error {
fontSize, err := strconv.Atoi(c.Args)
if err != nil {
return fmt.Errorf("invalid SubtitleFontSize: %s", c.Args)
}
v.Options.Subtitle.FontSize = fontSize
return nil
}

func ExecuteSetSubtitleFontFamily(c parser.Command, v *VHS) error {
v.Options.Subtitle.FontFamily = c.Args
return nil
}

func ExecuteSetSubtitleColor(c parser.Command, v *VHS) error {
v.Options.Subtitle.Color = c.Args
return nil
}

func ExecuteSetSubtitleBackground(c parser.Command, v *VHS) error {
v.Options.Subtitle.Background = c.Args
return nil
}

func ExecuteSetSubtitlePosition(c parser.Command, v *VHS) error {
pos := strings.ToLower(c.Args)
if pos != "top" && pos != "center" && pos != "bottom" {
return fmt.Errorf("invalid SubtitlePosition: %s (expected top, center, or bottom)", c.Args)
}
v.Options.Subtitle.Position = pos
return nil
}

func ExecuteSetSubtitlePadding(c parser.Command, v *VHS) error {
padding, err := strconv.Atoi(c.Args)
if err != nil {
return fmt.Errorf("invalid SubtitlePadding: %s", c.Args)
}
v.Options.Subtitle.Padding = padding
return nil
}

func ExecuteSetSubtitleBorderRadius(c parser.Command, v *VHS) error {
radius, err := strconv.Atoi(c.Args)
if err != nil {
return fmt.Errorf("invalid SubtitleBorderRadius: %s", c.Args)
}
v.Options.Subtitle.BorderRadius = radius
return nil
}

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 = 31
const numberOfCommands = 32
if len(parser.CommandTypes) != numberOfCommands {
t.Errorf("Expected %d commands, got %d", numberOfCommands, len(parser.CommandTypes))
}

const numberOfCommandFuncs = 31
const numberOfCommandFuncs = 32
if len(CommandFuncs) != numberOfCommandFuncs {
t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs))
}
Expand Down
8 changes: 8 additions & 0 deletions evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator
}
}

// Check if any Subtitle commands exist — if so, enable overlay stream
for _, cmd := range cmds {
if cmd.Type == token.SUBTITLE {
v.hasOverlay = true
break
}
}

// Begin recording frames as we are now in a recording state.
ctx, cancel := context.WithCancel(ctx) //nolint:gosec
ch := v.Record(ctx)
Expand Down
23 changes: 20 additions & 3 deletions ffmpeg.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ func (fb *FilterComplexBuilder) WithMarginFill(marginStream int) *FilterComplexB
return fb
}

// WithOverlay adds the overlay stream on top of the styled terminal frame.
// The overlay canvas is pre-sized to match the full output dimensions.
func (fb *FilterComplexBuilder) WithOverlay(overlayStream int) *FilterComplexBuilder {
fb.filterComplex.WriteString(";")
_, _ = fmt.Fprintf(
fb.filterComplex,
`
[%s][%d]overlay=0:0[withoverlay]
`,
fb.prevStageName,
overlayStream,
)
fb.prevStageName = "withoverlay"
return fb
}

// WithGIF adds gif options to ffmepg filter_complex.
func (fb *FilterComplexBuilder) WithGIF() *FilterComplexBuilder {
fb.filterComplex.WriteString(";")
Expand Down Expand Up @@ -200,9 +216,10 @@ type StreamBuilder struct {
termWidth int
termHeight int
input string
barStream int
cornerStream int
marginStream int
barStream int
cornerStream int
marginStream int
overlayStream int
}

// NewStreamBuilder returns instance of StreamBuilder.
Expand Down
28 changes: 28 additions & 0 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ var CommandTypes = []CommandType{
token.COPY,
token.PASTE,
token.ENV,
token.SUBTITLE,
}

// String returns the string representation of the command.
Expand Down Expand Up @@ -185,6 +186,8 @@ func (p *Parser) parseCommand() []Command {
return []Command{p.parsePaste()}
case token.ENV:
return []Command{p.parseEnv()}
case token.SUBTITLE:
return []Command{p.parseSubtitle()}
default:
p.errors = append(p.errors, NewError(p.cur, "Invalid command: "+p.cur.Literal))
return []Command{{Type: token.ILLEGAL}}
Expand Down Expand Up @@ -665,6 +668,31 @@ func (p *Parser) parseEnv() Command {
return cmd
}

// parseSubtitle parses a subtitle command.
// Subtitle takes a string argument for the text to display.
// An empty string hides the subtitle.
//
// Subtitle "text to display"
// Subtitle ""
func (p *Parser) parseSubtitle() Command {
cmd := Command{Type: token.SUBTITLE}

if p.peek.Type != token.STRING {
p.errors = append(p.errors, NewError(p.peek, "Subtitle expects string"))
return cmd
}

for p.peek.Type == token.STRING {
p.nextToken()
if cmd.Args != "" {
cmd.Args += " "
}
cmd.Args += p.cur.Literal
}

return cmd
}

// parseSource parses source command.
// Source command takes a tape path to include in current tape.
//
Expand Down
50 changes: 50 additions & 0 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,53 @@ func TestParseScreeenshot(t *testing.T) {
test.run(t)
})
}

func TestParseSubtitle(t *testing.T) {
t.Run("subtitle with text", func(t *testing.T) {
l := lexer.New(`Subtitle "Hello World"`)
p := New(l)
cmds := p.Parse()

if len(p.errors) > 0 {
t.Fatalf("unexpected errors: %v", p.errors)
}
if len(cmds) != 1 {
t.Fatalf("expected 1 command, got %d", len(cmds))
}
if cmds[0].Type != token.SUBTITLE {
t.Errorf("expected SUBTITLE, got %s", cmds[0].Type)
}
if cmds[0].Args != "Hello World" {
t.Errorf("expected 'Hello World', got '%s'", cmds[0].Args)
}
})

t.Run("subtitle with empty string hides", func(t *testing.T) {
l := lexer.New(`Subtitle ""`)
p := New(l)
cmds := p.Parse()

if len(p.errors) > 0 {
t.Fatalf("unexpected errors: %v", p.errors)
}
if len(cmds) != 1 {
t.Fatalf("expected 1 command, got %d", len(cmds))
}
if cmds[0].Args != "" {
t.Errorf("expected empty string, got '%s'", cmds[0].Args)
}
})

t.Run("subtitle with multiple words", func(t *testing.T) {
l := lexer.New(`Subtitle "This is a longer subtitle message"`)
p := New(l)
cmds := p.Parse()

if len(p.errors) > 0 {
t.Fatalf("unexpected errors: %v", p.errors)
}
if cmds[0].Args != "This is a longer subtitle message" {
t.Errorf("expected full message, got '%s'", cmds[0].Args)
}
})
}
27 changes: 24 additions & 3 deletions token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ const (
WAIT_TIMEOUT = "WAIT_TIMEOUT"
WAIT_PATTERN = "WAIT_PATTERN"
CURSOR_BLINK = "CURSOR_BLINK"

SUBTITLE = "SUBTITLE"
SUBTITLE_FONT_SIZE = "SUBTITLE_FONT_SIZE"
SUBTITLE_COLOR = "SUBTITLE_COLOR"
SUBTITLE_BACKGROUND = "SUBTITLE_BACKGROUND"
SUBTITLE_POSITION = "SUBTITLE_POSITION"
SUBTITLE_PADDING = "SUBTITLE_PADDING"
SUBTITLE_BORDER_RADIUS = "SUBTITLE_BORDER_RADIUS"
SUBTITLE_FONT_FAMILY = "SUBTITLE_FONT_FAMILY"
)

// Keywords maps keyword strings to tokens.
Expand Down Expand Up @@ -168,7 +177,15 @@ var Keywords = map[string]Type{
"Screenshot": SCREENSHOT,
"Copy": COPY,
"Paste": PASTE,
"Env": ENV,
"Env": ENV,
"Subtitle": SUBTITLE,
"SubtitleFontSize": SUBTITLE_FONT_SIZE,
"SubtitleColor": SUBTITLE_COLOR,
"SubtitleBackground": SUBTITLE_BACKGROUND,
"SubtitlePosition": SUBTITLE_POSITION,
"SubtitlePadding": SUBTITLE_PADDING,
"SubtitleBorderRadius": SUBTITLE_BORDER_RADIUS,
"SubtitleFontFamily": SUBTITLE_FONT_FAMILY,
}

// IsSetting returns whether a token is a setting.
Expand All @@ -177,7 +194,10 @@ func IsSetting(t Type) bool {
case SHELL, FONT_FAMILY, FONT_SIZE, LETTER_SPACING, LINE_HEIGHT,
FRAMERATE, TYPING_SPEED, THEME, PLAYBACK_SPEED, HEIGHT, WIDTH,
PADDING, LOOP_OFFSET, MARGIN_FILL, MARGIN, WINDOW_BAR,
WINDOW_BAR_SIZE, BORDER_RADIUS, CURSOR_BLINK, WAIT_TIMEOUT, WAIT_PATTERN:
WINDOW_BAR_SIZE, BORDER_RADIUS, CURSOR_BLINK, WAIT_TIMEOUT, WAIT_PATTERN,
SUBTITLE_FONT_SIZE, SUBTITLE_COLOR, SUBTITLE_BACKGROUND,
SUBTITLE_POSITION, SUBTITLE_PADDING, SUBTITLE_BORDER_RADIUS,
SUBTITLE_FONT_FAMILY:
return true
default:
return false
Expand All @@ -190,7 +210,8 @@ func IsCommand(t Type) bool {
case TYPE, SLEEP,
UP, DOWN, RIGHT, LEFT, PAGE_UP, PAGE_DOWN, SCROLL_UP, SCROLL_DOWN,
ENTER, BACKSPACE, DELETE, TAB,
ESCAPE, HOME, INSERT, END, CTRL, SOURCE, SCREENSHOT, COPY, PASTE, WAIT:
ESCAPE, HOME, INSERT, END, CTRL, SOURCE, SCREENSHOT, COPY, PASTE, WAIT,
SUBTITLE:
return true
default:
return false
Expand Down
Loading