diff --git a/caption.go b/caption.go new file mode 100644 index 00000000..53d75f3a --- /dev/null +++ b/caption.go @@ -0,0 +1,453 @@ +package main + +import ( + "fmt" + "math" + "os" + "path/filepath" + "runtime" + "strings" + "text/template" +) + +// OverlayEvent represents a single text overlay at a specific point in the recording. +type OverlayEvent struct { + StartMs int64 + DurationMs int64 + Text string +} + +// OverlayOptions holds configuration for text overlays. +type OverlayOptions struct { + Font string + FontSize int + Alignment CaptionAlignment + FontColor string // #RRGGBB hex + BoxColor string // #RRGGBB hex + BoxOpacity float64 + BoxPadding int + MarginLeft int + MarginRight int + MarginVertical int +} + +const defaultOverlayDurationMs = 3000 + +// DefaultOverlayOptions returns overlay options with sensible defaults. +func DefaultOverlayOptions() OverlayOptions { + font := defaultOSFont() + return OverlayOptions{ + Font: font, + FontSize: 22, + Alignment: AlignTopCenter, + FontColor: "#FFFFFF", + BoxColor: "#000000", + BoxOpacity: 0.5, + BoxPadding: 10, + MarginLeft: 20, + MarginRight: 20, + MarginVertical: 20, + } +} + +// CaptionOptions holds configuration for keystroke captioning. +type CaptionOptions struct { + Font string + FontSize int + KeyStyle KeyStyle + MaxKeysOnscreen int + InactivityTimerMs int + Alignment CaptionAlignment + FontColor string // #RRGGBB hex + HighlightColor string // #RRGGBB hex + BoxColor string // #RRGGBB hex + BoxOpacity float64 + BoxPadding int + MarginLeft int + MarginRight int + MarginVertical int +} + +// DefaultCaptionOptions returns caption options with sensible defaults. +func DefaultCaptionOptions() CaptionOptions { + font := defaultOSFont() + return CaptionOptions{ + Font: font, + FontSize: 22, + KeyStyle: KeyStyleIcon, + MaxKeysOnscreen: 10, + InactivityTimerMs: 1000, + Alignment: AlignBottomCenter, + FontColor: "#FFFFFF", + HighlightColor: "#FFCC66", + BoxColor: "#000000", + BoxOpacity: 0.5, + BoxPadding: 10, + MarginLeft: 20, + MarginRight: 20, + MarginVertical: 20, + } +} + +func defaultOSFont() string { + font := "monospace" + switch runtime.GOOS { + case "windows": + font = "Consolas" + case "darwin": + font = "Menlo" + } + return font +} + +// KeyStyle represents the rendering style for key captions. +type KeyStyle string + +const ( + KeyStyleVim KeyStyle = "vim" + KeyStyleIcon KeyStyle = "icon" +) + +// CaptionAlignment controls where captions appear on screen. +type CaptionAlignment string + +const ( + AlignBottomLeft CaptionAlignment = "bottom-left" + AlignBottomCenter CaptionAlignment = "bottom-center" + AlignBottomRight CaptionAlignment = "bottom-right" + AlignMiddleLeft CaptionAlignment = "middle-left" + AlignMiddleCenter CaptionAlignment = "middle-center" + AlignMiddleRight CaptionAlignment = "middle-right" + AlignTopLeft CaptionAlignment = "top-left" + AlignTopCenter CaptionAlignment = "top-center" + AlignTopRight CaptionAlignment = "top-right" +) + +// CaptionAlignments maps alignment names to ASS numpad values (1–9). +var CaptionAlignments = map[CaptionAlignment]int{ + AlignBottomLeft: 1, + AlignBottomCenter: 2, + AlignBottomRight: 3, + AlignMiddleLeft: 4, + AlignMiddleCenter: 5, + AlignMiddleRight: 6, + AlignTopLeft: 7, + AlignTopCenter: 8, + AlignTopRight: 9, +} + +// ASSValue returns the ASS numpad alignment integer (1–9). +func (a CaptionAlignment) ASSValue() int { + if v, ok := CaptionAlignments[a]; ok { + return v + } + return CaptionAlignments[AlignBottomRight] +} + +// Normalize transforms a key name (e.g. "Ctrl+Shift+a") into styled display text. +func (s KeyStyle) Normalize(key string) string { + parts := strings.Split(key, "+") + overrides := vimOverrides + if s == KeyStyleIcon { + overrides = iconOverrides + } + for i, p := range parts { + if o, ok := overrides[p]; ok { + parts[i] = o + } + } + if s == KeyStyleIcon { + return strings.Join(parts, "") + } + if len(parts) == 1 && len([]rune(parts[0])) == 1 { + return parts[0] + } + return "<" + strings.Join(parts, "-") + ">" +} + +var vimOverrides = map[string]string{ + "Backspace": "BS", + "Delete": "Del", + "Enter": "CR", + "Escape": "Esc", + "Ctrl": "C", + "Alt": "A", + "Shift": "S", +} + +var iconOverrides = map[string]string{ + "Backspace": "⌫", + "Delete": "⌦", + "Ctrl": "^", + "Alt": "⌥", + "Shift": "⇧", + "Down": "↓", + "PageDown": "PgDn", + "Up": "↑", + "PageUp": "PgUp", + "Left": "←", + "Right": "→", + "Space": "␣", + "Enter": "↵", + "Escape": "Esc", + "Tab": "⇥", +} + +// CaptionWindow describes the sliding key display at the moment each key is pressed. +type CaptionWindow struct { + First, Last int + ShowUntil int + IsTruncated bool +} + +// captionWindows returns one CaptionWindow per event. Windows are computed +// such that keys remain on screen until inactivityTimerMs has been exceeded. +// Further, there will be no more than maxKeys shown on the screen at any +// point in time. +func captionWindows(events []KeyEvent, inactivityTimerMs, maxKeys int) []CaptionWindow { + n := len(events) + wins := make([]CaptionWindow, 0, n) + sessionStart := 0 + + for i := range n { + current := events[i] + windowStart := max(sessionStart, i-maxKeys+1) + isTruncated := windowStart > sessionStart + + var showUntil int + if i+1 < n && int(events[i+1].StartMs)-int(current.StartMs) < inactivityTimerMs { + showUntil = int(events[i+1].StartMs) + } else { + showUntil = int(current.StartMs) + inactivityTimerMs + sessionStart = i + 1 + } + + wins = append(wins, CaptionWindow{ + First: windowStart, + Last: i, + ShowUntil: showUntil, + IsTruncated: isTruncated, + }) + } + return wins +} + +// ASS helpers + +func msToASS(ms int) string { + h := ms / 3600000 + ms %= 3600000 + m := ms / 60000 + ms %= 60000 + s := ms / 1000 + ms %= 1000 + cs := ms / 10 + return fmt.Sprintf("%d:%02d:%02d.%02d", h, m, s, cs) +} + +func assEscape(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + // Restore ASS newline sequences that were double-escaped + s = strings.ReplaceAll(s, `\\N`, `\N`) + s = strings.ReplaceAll(s, `\\n`, `\n`) + s = strings.ReplaceAll(s, "{", `\{`) + s = strings.ReplaceAll(s, "}", `\}`) + return s +} + +func opacityToASSAlpha(opacity float64) string { + alpha := int(math.Round((1 - opacity) * 255)) + return fmt.Sprintf("%02X", alpha) +} + +func hexToASSBGR(hex string) string { + hex = strings.TrimPrefix(hex, "#") + r, g, b := hex[0:2], hex[2:4], hex[4:6] + return fmt.Sprintf("&H%s%s%s&", b, g, r) +} + +// captionWindowPlainText builds plain text with no inline overrides. +// Used for the background box layer so the opaque box renders without seams. +func captionWindowPlainText(events []KeyEvent, w CaptionWindow) string { + var parts []string + if w.IsTruncated { + parts = append(parts, "…") + } + for i := w.First; i <= w.Last; i++ { + parts = append(parts, assEscape(events[i].Key)) + } + return strings.Join(parts, " ") +} + +// captionWindowColoredText builds ASS text with the last key highlighted. +// Used for the visible text layer (no opaque box, so color overrides cause no seams). +func captionWindowColoredText(events []KeyEvent, w CaptionWindow, highlightColor string) string { + var parts []string + if w.IsTruncated { + parts = append(parts, "…") + } + for i := w.First; i < w.Last; i++ { + parts = append(parts, assEscape(events[i].Key)) + } + lastKey := assEscape(events[w.Last].Key) + parts = append(parts, fmt.Sprintf(`{\c%s}%s`, highlightColor, lastKey)) + return strings.Join(parts, " ") +} + +const ASSHeaderTemplate = `[Script Info] +ScriptType: v4.00+ +PlayResX: {{.ResX}} +PlayResY: {{.ResY}} +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +{{- if .HasCaption}} +Style: KeysBG,{{.Font}},{{.FontSize}},&H00FFFFFF,&H000000FF,{{.BoxColor}},&H00000000,1,0,0,0,100,100,0,0,3,{{.BoxPadding}},0,{{.Alignment}},{{.MarginLeft}},{{.MarginRight}},{{.MarginVertical}},1 +Style: KeysFG,{{.Font}},{{.FontSize}},{{.FontColor}},&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,0,0,{{.Alignment}},{{.MarginLeft}},{{.MarginRight}},{{.MarginVertical}},1 +{{- end}} +{{- if .HasOverlay}} +Style: OverlayBG,{{.OverlayFont}},{{.OverlayFontSize}},&H00FFFFFF,&H000000FF,{{.OverlayBoxColor}},&H00000000,1,0,0,0,100,100,0,0,3,{{.OverlayBoxPadding}},0,{{.OverlayAlignment}},{{.OverlayMarginLeft}},{{.OverlayMarginRight}},{{.OverlayMarginVertical}},1 +Style: OverlayFG,{{.OverlayFont}},{{.OverlayFontSize}},{{.OverlayFontColor}},&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,0,0,{{.OverlayAlignment}},{{.OverlayMarginLeft}},{{.OverlayMarginRight}},{{.OverlayMarginVertical}},1 +{{- end}} + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text` + +type ASSHeaderData struct { + ResX, ResY int + Font string + FontColor string + FontSize int + Alignment int + MarginLeft int + MarginRight int + MarginVertical int + BoxColor string + BoxPadding int + HasCaption bool + HasOverlay bool + OverlayFont string + OverlayFontColor string + OverlayFontSize int + OverlayAlignment int + OverlayMarginLeft int + OverlayMarginRight int + OverlayMarginVertical int + OverlayBoxColor string + OverlayBoxPadding int +} + +var ASSHeader = template.Must(template.New("captionASS").Parse(ASSHeaderTemplate)) + +// GenerateCaptionFile creates an ASS subtitle file from key events and/or overlay events and returns its path. +func GenerateCaptionFile(events []KeyEvent, overlays []OverlayEvent, videoOpts VideoOptions, opts CaptionOptions, overlayOpts OverlayOptions) (string, error) { + if len(events) == 0 && len(overlays) == 0 { + return "", nil + } + + width, height := calcTermDimensions(*videoOpts.Style) + playbackSpeed := videoOpts.PlaybackSpeed + tempDir := videoOpts.Input + + // Normalize caption events: apply key style and adjust for playback speed. + normalized := make([]KeyEvent, len(events)) + for i, e := range events { + normalized[i] = KeyEvent{ + StartMs: e.StartMs, + Key: opts.KeyStyle.Normalize(e.Key), + } + } + + // Normalize overlay events: copy so we can adjust timestamps in place. + normalizedOverlays := make([]OverlayEvent, len(overlays)) + copy(normalizedOverlays, overlays) + + // Adjust all timestamps for playback speed. + if playbackSpeed != 0 && playbackSpeed != 1.0 { + for i := range normalized { + normalized[i].StartMs = int64(float64(normalized[i].StartMs) / playbackSpeed) + } + for i := range normalizedOverlays { + normalizedOverlays[i].StartMs = int64(float64(normalizedOverlays[i].StartMs) / playbackSpeed) + normalizedOverlays[i].DurationMs = int64(float64(normalizedOverlays[i].DurationMs) / playbackSpeed) + } + } + + assPath := filepath.Join(tempDir, "captions.ass") + f, err := os.Create(assPath) + if err != nil { + return "", fmt.Errorf("failed to create caption file: %w", err) + } + defer f.Close() //nolint:errcheck + + data := ASSHeaderData{ + ResX: width, + ResY: height, + HasCaption: len(events) > 0, + HasOverlay: len(overlays) > 0, + Font: opts.Font, + FontSize: opts.FontSize, + Alignment: opts.Alignment.ASSValue(), + MarginLeft: opts.MarginLeft, + MarginRight: opts.MarginRight, + MarginVertical: opts.MarginVertical, + BoxPadding: opts.BoxPadding, + FontColor: hexToASSBGR(opts.FontColor), + BoxColor: hexToASSBGR(opts.BoxColor), + OverlayFont: overlayOpts.Font, + OverlayFontSize: overlayOpts.FontSize, + OverlayAlignment: overlayOpts.Alignment.ASSValue(), + OverlayMarginLeft: overlayOpts.MarginLeft, + OverlayMarginRight: overlayOpts.MarginRight, + OverlayMarginVertical: overlayOpts.MarginVertical, + OverlayBoxPadding: overlayOpts.BoxPadding, + OverlayFontColor: hexToASSBGR(overlayOpts.FontColor), + OverlayBoxColor: hexToASSBGR(overlayOpts.BoxColor), + } + + if err := ASSHeader.Execute(f, data); err != nil { + return "", fmt.Errorf("failed to write caption header: %w", err) + } + _, _ = fmt.Fprintln(f) + + alpha := opacityToASSAlpha(opts.BoxOpacity) + highlightColor := hexToASSBGR(opts.HighlightColor) + + for _, w := range captionWindows(normalized, opts.InactivityTimerMs, max(opts.MaxKeysOnscreen, 1)) { + start := msToASS(int(normalized[w.Last].StartMs)) + end := msToASS(w.ShowUntil) + + // Layer 0: invisible text with seamless opaque box (no inline overrides = no seams) + plainText := captionWindowPlainText(normalized, w) + bgLine := fmt.Sprintf(`Dialogue: 0,%s,%s,KeysBG,,0,0,0,,{\3a&H%s&\1a&HFF&}%s`, + start, end, alpha, plainText) + _, _ = fmt.Fprintln(f, bgLine) + + // Layer 1: visible colored text with no box + coloredText := captionWindowColoredText(normalized, w, highlightColor) + fgLine := fmt.Sprintf(`Dialogue: 1,%s,%s,KeysFG,,0,0,0,,%s`, + start, end, coloredText) + _, _ = fmt.Fprintln(f, fgLine) + } + + overlayAlpha := opacityToASSAlpha(overlayOpts.BoxOpacity) + + for _, o := range normalizedOverlays { + start := msToASS(int(o.StartMs)) + end := msToASS(int(o.StartMs + o.DurationMs)) + text := assEscape(o.Text) + + // Layer 2: invisible text with opaque box + bgLine := fmt.Sprintf(`Dialogue: 2,%s,%s,OverlayBG,,0,0,0,,{\3a&H%s&\1a&HFF&}%s`, + start, end, overlayAlpha, text) + _, _ = fmt.Fprintln(f, bgLine) + + // Layer 3: visible text + fgLine := fmt.Sprintf(`Dialogue: 3,%s,%s,OverlayFG,,0,0,0,,%s`, + start, end, text) + _, _ = fmt.Fprintln(f, fgLine) + } + + return assPath, nil +} diff --git a/caption_test.go b/caption_test.go new file mode 100644 index 00000000..4b0912d8 --- /dev/null +++ b/caption_test.go @@ -0,0 +1,444 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func keyEvents(ms ...int64) []KeyEvent { + out := make([]KeyEvent, len(ms)) + for i, t := range ms { + out[i] = KeyEvent{StartMs: t, Key: "a"} + } + return out +} + +func TestVimNormalize(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"a", "a"}, + {"Enter", ""}, + {"Backspace", ""}, + {"Escape", ""}, + {"Ctrl+c", ""}, + {"Alt+f", ""}, + {"Shift+Tab", ""}, + {"Space", ""}, + {"Delete", ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := KeyStyleVim.Normalize(tt.input) + if got != tt.want { + t.Errorf("KeyStyleVim.Normalize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestIconNormalize(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"a", "a"}, + {"Enter", "↵"}, + {"Escape", "Esc"}, + {"PageUp", "PgUp"}, + {"PageDown", "PgDn"}, + {"Backspace", "⌫"}, + {"Space", "␣"}, + {"Ctrl+c", "^c"}, + {"Alt+d", "⌥d"}, + {"Shift+Tab", "⇧⇥"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := KeyStyleIcon.Normalize(tt.input) + if got != tt.want { + t.Errorf("KeyStyleIcon.Normalize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestCaptionWindows(t *testing.T) { + tests := []struct { + name string + events []KeyEvent + inactivityTimerMs int + maxKeys int + want []CaptionWindow + }{ + { + name: "empty events", + events: []KeyEvent{}, + inactivityTimerMs: 1000, + maxKeys: 3, + want: []CaptionWindow{}, + }, + { + name: "single event", + events: keyEvents(0), + inactivityTimerMs: 1000, + maxKeys: 3, + want: []CaptionWindow{ + {First: 0, Last: 0, ShowUntil: 1000, IsTruncated: false}, + }, + }, + { + name: "two events in same session", + events: keyEvents(0, 500), + inactivityTimerMs: 1000, + maxKeys: 3, + want: []CaptionWindow{ + {First: 0, Last: 0, ShowUntil: 500, IsTruncated: false}, + {First: 0, Last: 1, ShowUntil: 1500, IsTruncated: false}, + }, + }, + { + name: "gap exactly equals inactivity timer starts new session", + events: keyEvents(0, 1000), + inactivityTimerMs: 1000, + maxKeys: 3, + want: []CaptionWindow{ + {First: 0, Last: 0, ShowUntil: 1000, IsTruncated: false}, + {First: 1, Last: 1, ShowUntil: 2000, IsTruncated: false}, + }, + }, + { + name: "gap one over inactivity timer starts new session", + events: keyEvents(0, 1001), + inactivityTimerMs: 1000, + maxKeys: 3, + want: []CaptionWindow{ + {First: 0, Last: 0, ShowUntil: 1000, IsTruncated: false}, + {First: 1, Last: 1, ShowUntil: 2001, IsTruncated: false}, + }, + }, + { + name: "sliding window truncates when maxKeys exceeded", + events: keyEvents(0, 100, 200, 300), + inactivityTimerMs: 1000, + maxKeys: 3, + want: []CaptionWindow{ + {First: 0, Last: 0, ShowUntil: 100, IsTruncated: false}, + {First: 0, Last: 1, ShowUntil: 200, IsTruncated: false}, + {First: 0, Last: 2, ShowUntil: 300, IsTruncated: false}, + {First: 1, Last: 3, ShowUntil: 1300, IsTruncated: true}, + }, + }, + { + name: "session reset clears truncation", + events: keyEvents(0, 100, 200, 2000, 2100), + inactivityTimerMs: 1000, + maxKeys: 2, + want: []CaptionWindow{ + {First: 0, Last: 0, ShowUntil: 100, IsTruncated: false}, + {First: 0, Last: 1, ShowUntil: 200, IsTruncated: false}, + {First: 1, Last: 2, ShowUntil: 1200, IsTruncated: true}, + {First: 3, Last: 3, ShowUntil: 2100, IsTruncated: false}, + {First: 3, Last: 4, ShowUntil: 3100, IsTruncated: false}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captionWindows(tt.events, tt.inactivityTimerMs, tt.maxKeys) + if len(got) == 0 && len(tt.want) == 0 { + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v\nwant %v", got, tt.want) + } + }) + } +} + +func TestMsToASS(t *testing.T) { + tests := []struct { + ms int + want string + }{ + {0, "0:00:00.00"}, + {1000, "0:00:01.00"}, + {61000, "0:01:01.00"}, + {3661050, "1:01:01.05"}, + {500, "0:00:00.50"}, + {10, "0:00:00.01"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := msToASS(tt.ms) + if got != tt.want { + t.Errorf("msToASS(%d) = %q, want %q", tt.ms, got, tt.want) + } + }) + } +} + +func TestAssEscape(t *testing.T) { + tests := []struct { + input string + want string + }{ + {`hello`, `hello`}, + {`a\b`, `a\\b`}, + {`{tag}`, `\{tag\}`}, + {`a\{b}`, `a\\\{b\}`}, + {`a\Nb`, `a\Nb`}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := assEscape(tt.input) + if got != tt.want { + t.Errorf("assEscape(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestOpacityToASSAlpha(t *testing.T) { + tests := []struct { + opacity float64 + want string + }{ + {1.0, "00"}, + {0.0, "FF"}, + {0.5, "80"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := opacityToASSAlpha(tt.opacity) + if got != tt.want { + t.Errorf("opacityToASSAlpha(%f) = %q, want %q", tt.opacity, got, tt.want) + } + }) + } +} + +func TestHexToASSBGR(t *testing.T) { + tests := []struct { + hex string + want string + }{ + {"#FFCC66", "&H66CCFF&"}, + {"#FF0000", "&H0000FF&"}, + {"#00FF00", "&H00FF00&"}, + } + for _, tt := range tests { + t.Run(tt.hex, func(t *testing.T) { + got := hexToASSBGR(tt.hex) + if got != tt.want { + t.Errorf("hexToASSBGR(%q) = %q, want %q", tt.hex, got, tt.want) + } + }) + } +} + +func TestCaptionAlignment(t *testing.T) { + tests := []struct { + alignment CaptionAlignment + want int + }{ + {AlignBottomLeft, 1}, + {AlignBottomCenter, 2}, + {AlignBottomRight, 3}, + {AlignMiddleLeft, 4}, + {AlignMiddleCenter, 5}, + {AlignMiddleRight, 6}, + {AlignTopLeft, 7}, + {AlignTopCenter, 8}, + {AlignTopRight, 9}, + {"unknown", 3}, // default + } + for _, tt := range tests { + t.Run(string(tt.alignment), func(t *testing.T) { + got := tt.alignment.ASSValue() + if got != tt.want { + t.Errorf("CaptionAlignment(%q).ASSValue() = %d, want %d", tt.alignment, got, tt.want) + } + }) + } +} + +func TestGenerateCaptionFile(t *testing.T) { + tmpDir := t.TempDir() + + events := []KeyEvent{ + {StartMs: 0, Key: "h"}, + {StartMs: 100, Key: "i"}, + {StartMs: 200, Key: "Enter"}, + } + + videoOpts := VideoOptions{PlaybackSpeed: 1.0, Input: tmpDir, Style: &StyleOptions{Width: 800, Height: 600}} + path, err := GenerateCaptionFile(events, nil, videoOpts, DefaultCaptionOptions(), DefaultOverlayOptions()) + if err != nil { + t.Fatalf("GenerateCaptionFile failed: %v", err) + } + + if filepath.Dir(path) != tmpDir { + t.Errorf("expected file in %s, got %s", tmpDir, path) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read caption file: %v", err) + } + content := string(data) + + if !strings.Contains(content, "[Script Info]") { + t.Error("expected ASS header in output") + } + if !strings.Contains(content, "PlayResX: 800") { + t.Error("expected PlayResX: 800") + } + if !strings.Contains(content, "Dialogue:") { + t.Error("expected Dialogue lines in output") + } + expectedFont := DefaultCaptionOptions().Font + if !strings.Contains(content, expectedFont) { + t.Errorf("expected default font %q in output", expectedFont) + } + // Default alignment is bottom-center (ASS numpad 2) + if !strings.Contains(content, ",2,20,20,20,") { + t.Error("expected default alignment value 2 in style lines") + } +} + +func TestGenerateCaptionFileDefaultFont(t *testing.T) { + tmpDir := t.TempDir() + + events := []KeyEvent{{StartMs: 0, Key: "a"}} + + // When CaptionFont is empty, should default to the OS-appropriate font + videoOpts := VideoOptions{PlaybackSpeed: 1.0, Input: tmpDir, Style: &StyleOptions{Width: 800, Height: 600}} + path, err := GenerateCaptionFile(events, nil, videoOpts, DefaultCaptionOptions(), DefaultOverlayOptions()) + if err != nil { + t.Fatalf("GenerateCaptionFile failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read caption file: %v", err) + } + content := string(data) + + expectedFont := DefaultCaptionOptions().Font + if !strings.Contains(content, expectedFont+",") { + t.Errorf("expected default font %q in ASS output", expectedFont) + } +} + +func TestGenerateCaptionFileExplicitFontOverride(t *testing.T) { + tmpDir := t.TempDir() + + events := []KeyEvent{{StartMs: 0, Key: "a"}} + opts := DefaultCaptionOptions() + opts.Font = "JetBrainsMonoNL NFM" + + videoOpts := VideoOptions{PlaybackSpeed: 1.0, Input: tmpDir, Style: &StyleOptions{Width: 800, Height: 600}} + path, err := GenerateCaptionFile(events, nil, videoOpts, opts, DefaultOverlayOptions()) + if err != nil { + t.Fatalf("GenerateCaptionFile failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read caption file: %v", err) + } + content := string(data) + + if !strings.Contains(content, "JetBrainsMonoNL NFM") { + t.Error("expected explicit CaptionFont in ASS output") + } + defaultFont := DefaultCaptionOptions().Font + if strings.Contains(content, defaultFont) { + t.Errorf("default font %q should not appear when CaptionFont is set explicitly", defaultFont) + } +} + +func TestGenerateCaptionFilePlaybackSpeed(t *testing.T) { + tmpDir := t.TempDir() + + events := []KeyEvent{ + {StartMs: 0, Key: "a"}, + {StartMs: 2000, Key: "b"}, + } + + videoOpts := VideoOptions{PlaybackSpeed: 2.0, Input: tmpDir, Style: &StyleOptions{Width: 800, Height: 600}} + path, err := GenerateCaptionFile(events, nil, videoOpts, DefaultCaptionOptions(), DefaultOverlayOptions()) + if err != nil { + t.Fatalf("GenerateCaptionFile failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read caption file: %v", err) + } + content := string(data) + + // At 2x speed, 2000ms becomes 1000ms = 0:00:01.00 + if !strings.Contains(content, "0:00:01.00") { + t.Error("expected timestamp adjusted for 2x playback speed") + } +} + +func TestCaptionFontSizeConsistentAcrossHeights(t *testing.T) { + fontSize := 22 + var firstContent string + for _, height := range []int{384, 600, 768, 1080} { + t.Run(fmt.Sprintf("height_%d", height), func(t *testing.T) { + tmpDir := t.TempDir() + events := []KeyEvent{{StartMs: 0, Key: "a"}} + opts := DefaultCaptionOptions() + opts.FontSize = fontSize + + videoOpts := VideoOptions{ + PlaybackSpeed: 1.0, + Input: tmpDir, + Style: &StyleOptions{Width: 800, Height: height}, + } + path, err := GenerateCaptionFile(events, nil, videoOpts, opts, DefaultOverlayOptions()) + if err != nil { + t.Fatalf("GenerateCaptionFile failed: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read caption file: %v", err) + } + content := string(data) + // Extract the font size from the style line + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "Style: KeysFG,") { + if firstContent == "" { + firstContent = line + } else if line != firstContent { + t.Errorf("font size differs across heights:\n first: %s\n current: %s", firstContent, line) + } + break + } + } + }) + } +} + +func TestGenerateCaptionFileEmpty(t *testing.T) { + tmpDir := t.TempDir() + + videoOpts := VideoOptions{PlaybackSpeed: 1.0, Input: tmpDir, Style: &StyleOptions{Width: 800, Height: 600}} + path, err := GenerateCaptionFile([]KeyEvent{}, nil, videoOpts, DefaultCaptionOptions(), DefaultOverlayOptions()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if path != "" { + t.Errorf("expected empty path for no events, got %q", path) + } +} diff --git a/command.go b/command.go index 4e5b0d06..ae059311 100644 --- a/command.go +++ b/command.go @@ -8,6 +8,7 @@ import ( "regexp" "strconv" "strings" + "sync/atomic" "time" "github.com/atotto/clipboard" @@ -70,6 +71,9 @@ var CommandFuncs = map[parser.CommandType]CommandFunc{ token.PASTE: ExecutePaste, token.ENV: ExecuteEnv, token.WAIT: ExecuteWait, + token.OVERLAY: ExecuteOverlay, + token.CAPTION_ON: ExecuteCaptionOn, + token.CAPTION_OFF: ExecuteCaptionOff, } // ExecuteNoop is a no-op command that does nothing. @@ -94,6 +98,7 @@ func ExecuteKey(k input.Key) CommandFunc { repeat = 1 } for i := 0; i < repeat; i++ { + v.KeyLogger.LogKey(c.Type.String()) err = v.Page.Keyboard.Type(k) if err != nil { return fmt.Errorf("failed to type key %c: %w", k, err) @@ -211,6 +216,7 @@ func ExecuteWait(c parser.Command, v *VHS) error { // ExecuteCtrl is a CommandFunc that presses the argument keys and/or modifiers // with the ctrl key held down on the running instance of vhs. func ExecuteCtrl(c parser.Command, v *VHS) error { + v.KeyLogger.LogKey("Ctrl+" + c.Args) // Create key combination by holding ControlLeft action := v.Page.KeyActions().Press(input.ControlLeft) keys := strings.Split(c.Args, " ") @@ -266,6 +272,7 @@ func ExecuteCtrl(c parser.Command, v *VHS) error { // 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) error { + v.KeyLogger.LogKey("Alt+" + c.Args) err := v.Page.Keyboard.Press(input.AltLeft) if err != nil { return fmt.Errorf("failed to press Alt key: %w", err) @@ -305,6 +312,7 @@ func ExecuteAlt(c parser.Command, v *VHS) error { // 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) error { + v.KeyLogger.LogKey("Shift+" + c.Args) err := v.Page.Keyboard.Press(input.ShiftLeft) if err != nil { return fmt.Errorf("failed to press Shift key: %w", err) @@ -383,6 +391,11 @@ func ExecuteType(c parser.Command, v *VHS) error { } } for _, r := range c.Args { + if r == ' ' { // make in consistent in the log file + v.KeyLogger.LogKey("Space") + } else { + v.KeyLogger.LogKey(string(r)) + } k, ok := keymap[r] if ok { err := v.Page.Keyboard.Type(k) @@ -458,27 +471,51 @@ func ExecutePaste(_ parser.Command, v *VHS) error { // Settings maps the Set commands to their respective functions. var Settings = map[string]CommandFunc{ - "FontFamily": ExecuteSetFontFamily, - "FontSize": ExecuteSetFontSize, - "Framerate": ExecuteSetFramerate, - "Height": ExecuteSetHeight, - "LetterSpacing": ExecuteSetLetterSpacing, - "LineHeight": ExecuteSetLineHeight, - "PlaybackSpeed": ExecuteSetPlaybackSpeed, - "Padding": ExecuteSetPadding, - "Theme": ExecuteSetTheme, - "TypingSpeed": ExecuteSetTypingSpeed, - "Width": ExecuteSetWidth, - "Shell": ExecuteSetShell, - "LoopOffset": ExecuteLoopOffset, - "MarginFill": ExecuteSetMarginFill, - "Margin": ExecuteSetMargin, - "WindowBar": ExecuteSetWindowBar, - "WindowBarSize": ExecuteSetWindowBarSize, - "BorderRadius": ExecuteSetBorderRadius, - "WaitPattern": ExecuteSetWaitPattern, - "WaitTimeout": ExecuteSetWaitTimeout, - "CursorBlink": ExecuteSetCursorBlink, + "FontFamily": ExecuteSetFontFamily, + "FontSize": ExecuteSetFontSize, + "Framerate": ExecuteSetFramerate, + "Height": ExecuteSetHeight, + "LetterSpacing": ExecuteSetLetterSpacing, + "LineHeight": ExecuteSetLineHeight, + "PlaybackSpeed": ExecuteSetPlaybackSpeed, + "Padding": ExecuteSetPadding, + "Theme": ExecuteSetTheme, + "TypingSpeed": ExecuteSetTypingSpeed, + "Width": ExecuteSetWidth, + "Shell": ExecuteSetShell, + "LoopOffset": ExecuteLoopOffset, + "MarginFill": ExecuteSetMarginFill, + "Margin": ExecuteSetMargin, + "WindowBar": ExecuteSetWindowBar, + "WindowBarSize": ExecuteSetWindowBarSize, + "BorderRadius": ExecuteSetBorderRadius, + "WaitPattern": ExecuteSetWaitPattern, + "WaitTimeout": ExecuteSetWaitTimeout, + "CursorBlink": ExecuteSetCursorBlink, + "CaptionFont": ExecuteSetCaptionFont, + "CaptionFontSize": ExecuteSetCaptionFontSize, + "CaptionKeyStyle": ExecuteSetCaptionKeyStyle, + "CaptionMaxKeys": ExecuteSetCaptionMaxKeys, + "CaptionInactivityTimer": ExecuteSetCaptionInactivityTimer, + "CaptionAlignment": ExecuteSetCaptionAlignment, + "CaptionFontColor": ExecuteSetCaptionFontColor, + "CaptionHighlightColor": ExecuteSetCaptionHighlightColor, + "CaptionBoxColor": ExecuteSetCaptionBoxColor, + "CaptionBoxOpacity": ExecuteSetCaptionBoxOpacity, + "CaptionBoxPadding": ExecuteSetCaptionBoxPadding, + "CaptionMarginLeft": ExecuteSetCaptionMarginLeft, + "CaptionMarginRight": ExecuteSetCaptionMarginRight, + "CaptionMarginVertical": ExecuteSetCaptionMarginVertical, + "OverlayFont": ExecuteSetOverlayFont, + "OverlayFontSize": ExecuteSetOverlayFontSize, + "OverlayFontColor": ExecuteSetOverlayFontColor, + "OverlayBoxColor": ExecuteSetOverlayBoxColor, + "OverlayBoxOpacity": ExecuteSetOverlayBoxOpacity, + "OverlayBoxPadding": ExecuteSetOverlayBoxPadding, + "OverlayAlignment": ExecuteSetOverlayAlignment, + "OverlayMarginLeft": ExecuteSetOverlayMarginLeft, + "OverlayMarginRight": ExecuteSetOverlayMarginRight, + "OverlayMarginVertical": ExecuteSetOverlayMarginVertical, } // ExecuteSet applies the settings on the running vhs specified by the @@ -754,6 +791,264 @@ func ExecuteScreenshot(c parser.Command, v *VHS) error { return nil } +// ensureLibass checks that ffmpeg was compiled with libass support, which is +// required for CaptionOn. We run `ffmpeg -filters` and look for the +// "ass" filter rather than letting the render fail late with a cryptic error. +func ensureLibass() error { + // ffmpeg -filters writes to stderr, so capture combined output + cmd := exec.Command("ffmpeg", "-filters") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("could not query ffmpeg filters: %w", err) + } + // Each filter line looks like: " T. ass V->V Render ASS subtitles" + // Match " ass " with surrounding spaces to avoid false positives (bass, allpass, etc.) + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == "ass" { + return nil + } + } + return fmt.Errorf("ffmpeg was not compiled with libass support, which is required for CaptionOn.\nReinstall ffmpeg with libass (e.g. `brew install ffmpeg-full` on macOS)") +} + +// ExecuteCaptionOn enables caption key logging. +func ExecuteCaptionOn(_ parser.Command, v *VHS) error { + if err := ensureLibass(); err != nil { + return err + } + v.KeyLogger.Enable() + return nil +} + +// ExecuteCaptionOff disables caption key logging. +func ExecuteCaptionOff(_ parser.Command, v *VHS) error { + v.KeyLogger.Disable() + return nil +} + +// ExecuteSetCaptionFont sets the caption font family. +func ExecuteSetCaptionFont(c parser.Command, v *VHS) error { + v.Options.Caption.Font = c.Args + return nil +} + +// ExecuteSetCaptionFontSize sets the caption font size. +func ExecuteSetCaptionFontSize(c parser.Command, v *VHS) error { + size, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse caption font size: %w", err) + } + v.Options.Caption.FontSize = size + return nil +} + +// ExecuteSetCaptionMaxKeys sets the caption sliding window size. +func ExecuteSetCaptionMaxKeys(c parser.Command, v *VHS) error { + n, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse caption max keys: %w", err) + } + v.Options.Caption.MaxKeysOnscreen = n + return nil +} + +// ExecuteSetCaptionInactivityTimer sets the caption inactivity timer. +func ExecuteSetCaptionInactivityTimer(c parser.Command, v *VHS) error { + dur, err := time.ParseDuration(c.Args) + if err != nil { + return fmt.Errorf("failed to parse caption inactivity timer: %w", err) + } + v.Options.Caption.InactivityTimerMs = int(dur.Milliseconds()) + return nil +} + +// ExecuteSetCaptionHighlightColor sets the caption highlight color. +func ExecuteSetCaptionHighlightColor(c parser.Command, v *VHS) error { + v.Options.Caption.HighlightColor = c.Args + return nil +} + +// ExecuteSetCaptionFontColor sets the caption text color for non-highlighted keys. +func ExecuteSetCaptionFontColor(c parser.Command, v *VHS) error { + v.Options.Caption.FontColor = c.Args + return nil +} + +// ExecuteSetCaptionBoxColor sets the caption background box fill color. +func ExecuteSetCaptionBoxColor(c parser.Command, v *VHS) error { + v.Options.Caption.BoxColor = c.Args + return nil +} + +// ExecuteSetCaptionBoxOpacity sets the caption box opacity. +func ExecuteSetCaptionBoxOpacity(c parser.Command, v *VHS) error { + opacity, err := strconv.ParseFloat(c.Args, 64) + if err != nil { + return fmt.Errorf("failed to parse caption box opacity: %w", err) + } + v.Options.Caption.BoxOpacity = opacity + return nil +} + +// ExecuteSetCaptionKeyStyle sets the caption key rendering style. +func ExecuteSetCaptionKeyStyle(c parser.Command, v *VHS) error { + v.Options.Caption.KeyStyle = KeyStyle(c.Args) + return nil +} + +// ExecuteSetCaptionMarginLeft sets the caption left margin. +func ExecuteSetCaptionMarginLeft(c parser.Command, v *VHS) error { + m, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse caption margin left: %w", err) + } + v.Options.Caption.MarginLeft = m + return nil +} + +// ExecuteSetCaptionMarginRight sets the caption right margin. +func ExecuteSetCaptionMarginRight(c parser.Command, v *VHS) error { + m, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse caption margin right: %w", err) + } + v.Options.Caption.MarginRight = m + return nil +} + +// ExecuteSetCaptionMarginVertical sets the caption vertical margin. +func ExecuteSetCaptionMarginVertical(c parser.Command, v *VHS) error { + m, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse caption margin vertical: %w", err) + } + v.Options.Caption.MarginVertical = m + return nil +} + +// ExecuteSetCaptionAlignment sets the caption alignment. +func ExecuteSetCaptionAlignment(c parser.Command, v *VHS) error { + v.Options.Caption.Alignment = CaptionAlignment(c.Args) + return nil +} + +// ExecuteSetCaptionBoxPadding sets the caption box padding. +func ExecuteSetCaptionBoxPadding(c parser.Command, v *VHS) error { + p, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse caption box padding: %w", err) + } + v.Options.Caption.BoxPadding = p + return nil +} + +// ExecuteOverlay records a text overlay event at the current frame timestamp. +func ExecuteOverlay(c parser.Command, v *VHS) error { + durationMs := int64(defaultOverlayDurationMs) + if c.Options != "" { + dur, err := time.ParseDuration(c.Options) + if err != nil { + return fmt.Errorf("failed to parse overlay duration: %w", err) + } + durationMs = dur.Milliseconds() + } + + frameNum := atomic.LoadInt64(&v.currentFrame) + timeMs := frameNum * 1000 / int64(v.Options.Video.Framerate) + + v.OverlayEvents = append(v.OverlayEvents, OverlayEvent{ + StartMs: timeMs, + DurationMs: durationMs, + Text: c.Args, + }) + return nil +} + +// ExecuteSetOverlayFont sets the overlay font family. +func ExecuteSetOverlayFont(c parser.Command, v *VHS) error { + v.Options.Overlay.Font = c.Args + return nil +} + +// ExecuteSetOverlayFontSize sets the overlay font size. +func ExecuteSetOverlayFontSize(c parser.Command, v *VHS) error { + size, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse overlay font size: %w", err) + } + v.Options.Overlay.FontSize = size + return nil +} + +// ExecuteSetOverlayFontColor sets the overlay text color. +func ExecuteSetOverlayFontColor(c parser.Command, v *VHS) error { + v.Options.Overlay.FontColor = c.Args + return nil +} + +// ExecuteSetOverlayBoxColor sets the overlay background box fill color. +func ExecuteSetOverlayBoxColor(c parser.Command, v *VHS) error { + v.Options.Overlay.BoxColor = c.Args + return nil +} + +// ExecuteSetOverlayBoxOpacity sets the overlay box opacity. +func ExecuteSetOverlayBoxOpacity(c parser.Command, v *VHS) error { + opacity, err := strconv.ParseFloat(c.Args, 64) + if err != nil { + return fmt.Errorf("failed to parse overlay box opacity: %w", err) + } + v.Options.Overlay.BoxOpacity = opacity + return nil +} + +// ExecuteSetOverlayBoxPadding sets the overlay box padding. +func ExecuteSetOverlayBoxPadding(c parser.Command, v *VHS) error { + p, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse overlay box padding: %w", err) + } + v.Options.Overlay.BoxPadding = p + return nil +} + +// ExecuteSetOverlayAlignment sets the overlay alignment. +func ExecuteSetOverlayAlignment(c parser.Command, v *VHS) error { + v.Options.Overlay.Alignment = CaptionAlignment(c.Args) + return nil +} + +// ExecuteSetOverlayMarginLeft sets the overlay left margin. +func ExecuteSetOverlayMarginLeft(c parser.Command, v *VHS) error { + m, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse overlay margin left: %w", err) + } + v.Options.Overlay.MarginLeft = m + return nil +} + +// ExecuteSetOverlayMarginRight sets the overlay right margin. +func ExecuteSetOverlayMarginRight(c parser.Command, v *VHS) error { + m, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse overlay margin right: %w", err) + } + v.Options.Overlay.MarginRight = m + return nil +} + +// ExecuteSetOverlayMarginVertical sets the overlay vertical margin. +func ExecuteSetOverlayMarginVertical(c parser.Command, v *VHS) error { + m, err := strconv.Atoi(c.Args) + if err != nil { + return fmt.Errorf("failed to parse overlay margin vertical: %w", err) + } + v.Options.Overlay.MarginVertical = m + return nil +} + func getTheme(s string) (Theme, error) { if strings.TrimSpace(s) == "" { return DefaultTheme, nil diff --git a/command_test.go b/command_test.go index 19ddc98a..df62f941 100644 --- a/command_test.go +++ b/command_test.go @@ -8,12 +8,12 @@ import ( ) func TestCommand(t *testing.T) { - const numberOfCommands = 31 + const numberOfCommands = 34 if len(parser.CommandTypes) != numberOfCommands { t.Errorf("Expected %d commands, got %d", numberOfCommands, len(parser.CommandTypes)) } - const numberOfCommandFuncs = 31 + const numberOfCommandFuncs = 34 if len(CommandFuncs) != numberOfCommandFuncs { t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs)) } diff --git a/evaluator.go b/evaluator.go index 5768b854..001428c9 100644 --- a/evaluator.go +++ b/evaluator.go @@ -114,6 +114,9 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator ctx, cancel := context.WithCancel(ctx) //nolint:gosec ch := v.Record(ctx) + // Start key logging + v.KeyLogger.Start(&v.currentFrame, v.Options.Video.Framerate) + // Clean up temporary files at the end. defer func() { if v.Options.Video.Output.Frames != "" { @@ -182,6 +185,22 @@ func Evaluate(ctx context.Context, tape string, out io.Writer, opts ...Evaluator } teardown() + + // Generate caption/overlay ASS file + hasCaptions := len(v.KeyLogger.Events()) > 0 + hasOverlays := len(v.OverlayEvents) > 0 + + if hasCaptions || hasOverlays { + if err := ensureLibass(); err != nil { + return []error{err} + } + assPath, err := GenerateCaptionFile(v.KeyLogger.Events(), v.OverlayEvents, v.Options.Video, v.Options.Caption, v.Options.Overlay) + if err != nil { + return []error{err} + } + v.Options.Video.CaptionFile = assPath + } + if err := v.Render(); err != nil { return []error{err} } diff --git a/examples/settings/set-caption-alignment-bottom-center.gif b/examples/settings/set-caption-alignment-bottom-center.gif new file mode 100644 index 00000000..6c3c041c Binary files /dev/null and b/examples/settings/set-caption-alignment-bottom-center.gif differ diff --git a/examples/settings/set-caption-alignment-bottom-center.tape b/examples/settings/set-caption-alignment-bottom-center.tape new file mode 100644 index 00000000..249f71e5 --- /dev/null +++ b/examples/settings/set-caption-alignment-bottom-center.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-bottom-center.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "bottom-center" + +CaptionOn + +Type "Where should I go? bottom-center?" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-bottom-left.gif b/examples/settings/set-caption-alignment-bottom-left.gif new file mode 100644 index 00000000..68eb5191 Binary files /dev/null and b/examples/settings/set-caption-alignment-bottom-left.gif differ diff --git a/examples/settings/set-caption-alignment-bottom-left.tape b/examples/settings/set-caption-alignment-bottom-left.tape new file mode 100644 index 00000000..8c22502b --- /dev/null +++ b/examples/settings/set-caption-alignment-bottom-left.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-bottom-left.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "bottom-left" + +CaptionOn + +Type "Where should I go? bottom-left?" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-bottom-right.gif b/examples/settings/set-caption-alignment-bottom-right.gif new file mode 100644 index 00000000..67ef5f7a Binary files /dev/null and b/examples/settings/set-caption-alignment-bottom-right.gif differ diff --git a/examples/settings/set-caption-alignment-bottom-right.tape b/examples/settings/set-caption-alignment-bottom-right.tape new file mode 100644 index 00000000..e52fcbd7 --- /dev/null +++ b/examples/settings/set-caption-alignment-bottom-right.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-bottom-right.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "bottom-right" + +CaptionOn + +Type "Where should I go? bottom-right?" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-middle-center.gif b/examples/settings/set-caption-alignment-middle-center.gif new file mode 100644 index 00000000..ed41c777 Binary files /dev/null and b/examples/settings/set-caption-alignment-middle-center.gif differ diff --git a/examples/settings/set-caption-alignment-middle-center.tape b/examples/settings/set-caption-alignment-middle-center.tape new file mode 100644 index 00000000..cd414b65 --- /dev/null +++ b/examples/settings/set-caption-alignment-middle-center.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-middle-center.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "middle-center" + +CaptionOn + +Type "Where should I go? middle-center" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-middle-left.gif b/examples/settings/set-caption-alignment-middle-left.gif new file mode 100644 index 00000000..931f1b22 Binary files /dev/null and b/examples/settings/set-caption-alignment-middle-left.gif differ diff --git a/examples/settings/set-caption-alignment-middle-left.tape b/examples/settings/set-caption-alignment-middle-left.tape new file mode 100644 index 00000000..f157fb12 --- /dev/null +++ b/examples/settings/set-caption-alignment-middle-left.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-middle-left.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "middle-left" + +CaptionOn + +Type "Where should I go? middle-left" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-middle-right.gif b/examples/settings/set-caption-alignment-middle-right.gif new file mode 100644 index 00000000..f576dbcb Binary files /dev/null and b/examples/settings/set-caption-alignment-middle-right.gif differ diff --git a/examples/settings/set-caption-alignment-middle-right.tape b/examples/settings/set-caption-alignment-middle-right.tape new file mode 100644 index 00000000..527252cb --- /dev/null +++ b/examples/settings/set-caption-alignment-middle-right.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-middle-right.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "middle-right" + +CaptionOn + +Type "Where should I go? middle-right" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-top-center.gif b/examples/settings/set-caption-alignment-top-center.gif new file mode 100644 index 00000000..7926bedf Binary files /dev/null and b/examples/settings/set-caption-alignment-top-center.gif differ diff --git a/examples/settings/set-caption-alignment-top-center.tape b/examples/settings/set-caption-alignment-top-center.tape new file mode 100644 index 00000000..30ea61bb --- /dev/null +++ b/examples/settings/set-caption-alignment-top-center.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-top-center.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "top-center" + +CaptionOn + +Type "Where should I go? top-center?" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-top-left.gif b/examples/settings/set-caption-alignment-top-left.gif new file mode 100644 index 00000000..5bf09737 Binary files /dev/null and b/examples/settings/set-caption-alignment-top-left.gif differ diff --git a/examples/settings/set-caption-alignment-top-left.tape b/examples/settings/set-caption-alignment-top-left.tape new file mode 100644 index 00000000..b8d03100 --- /dev/null +++ b/examples/settings/set-caption-alignment-top-left.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-top-left.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "top-left" + +CaptionOn + +Type "Where should I go? top-left?" +Sleep 2s diff --git a/examples/settings/set-caption-alignment-top-right.gif b/examples/settings/set-caption-alignment-top-right.gif new file mode 100644 index 00000000..3c40720d Binary files /dev/null and b/examples/settings/set-caption-alignment-top-right.gif differ diff --git a/examples/settings/set-caption-alignment-top-right.tape b/examples/settings/set-caption-alignment-top-right.tape new file mode 100644 index 00000000..2f9d220c --- /dev/null +++ b/examples/settings/set-caption-alignment-top-right.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-alignment-top-right.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "top-right" + +CaptionOn + +Type "Where should I go? top-right?" +Sleep 2s diff --git a/examples/settings/set-caption-box-color.gif b/examples/settings/set-caption-box-color.gif new file mode 100644 index 00000000..44f46645 Binary files /dev/null and b/examples/settings/set-caption-box-color.gif differ diff --git a/examples/settings/set-caption-box-color.tape b/examples/settings/set-caption-box-color.tape new file mode 100644 index 00000000..94cb60dd --- /dev/null +++ b/examples/settings/set-caption-box-color.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-box-color.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionBoxColor "#777777" + +CaptionOn + +Type "I'm feeling a bit gray today!" +Sleep 2s diff --git a/examples/settings/set-caption-box-opacity-0.gif b/examples/settings/set-caption-box-opacity-0.gif new file mode 100644 index 00000000..56ef3ab9 Binary files /dev/null and b/examples/settings/set-caption-box-opacity-0.gif differ diff --git a/examples/settings/set-caption-box-opacity-0.tape b/examples/settings/set-caption-box-opacity-0.tape new file mode 100644 index 00000000..64b5b3db --- /dev/null +++ b/examples/settings/set-caption-box-opacity-0.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-box-opacity-0.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionBoxOpacity 0 + +CaptionOn + +Type "I don't want to be boxed in!!" +Sleep 2s diff --git a/examples/settings/set-caption-box-opacity-25.gif b/examples/settings/set-caption-box-opacity-25.gif new file mode 100644 index 00000000..416d6bf7 Binary files /dev/null and b/examples/settings/set-caption-box-opacity-25.gif differ diff --git a/examples/settings/set-caption-box-opacity-25.tape b/examples/settings/set-caption-box-opacity-25.tape new file mode 100644 index 00000000..3354d095 --- /dev/null +++ b/examples/settings/set-caption-box-opacity-25.tape @@ -0,0 +1,16 @@ +Output examples/settings/set-caption-box-opacity-25.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionBoxOpacity 0.25 + +CaptionOn + +Enter 8 +Type "You can see this text behind the caption box with opacity!" +Sleep 2s diff --git a/examples/settings/set-caption-box-opacity-75.gif b/examples/settings/set-caption-box-opacity-75.gif new file mode 100644 index 00000000..b11cf14c Binary files /dev/null and b/examples/settings/set-caption-box-opacity-75.gif differ diff --git a/examples/settings/set-caption-box-opacity-75.tape b/examples/settings/set-caption-box-opacity-75.tape new file mode 100644 index 00000000..d56b1236 --- /dev/null +++ b/examples/settings/set-caption-box-opacity-75.tape @@ -0,0 +1,16 @@ +Output examples/settings/set-caption-box-opacity-75.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionBoxOpacity 0.75 + +CaptionOn + +Enter 8 +Type "You can barely see this when using more opacity!" +Sleep 2s diff --git a/examples/settings/set-caption-box-padding-15.gif b/examples/settings/set-caption-box-padding-15.gif new file mode 100644 index 00000000..5bd6a713 Binary files /dev/null and b/examples/settings/set-caption-box-padding-15.gif differ diff --git a/examples/settings/set-caption-box-padding-15.tape b/examples/settings/set-caption-box-padding-15.tape new file mode 100644 index 00000000..a9e81a3c --- /dev/null +++ b/examples/settings/set-caption-box-padding-15.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-box-padding-15.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionBoxPadding 15 + +CaptionOn + +Type "I prefer open and more spacious!" +Sleep 2s diff --git a/examples/settings/set-caption-box-padding-3.gif b/examples/settings/set-caption-box-padding-3.gif new file mode 100644 index 00000000..4ce351b7 Binary files /dev/null and b/examples/settings/set-caption-box-padding-3.gif differ diff --git a/examples/settings/set-caption-box-padding-3.tape b/examples/settings/set-caption-box-padding-3.tape new file mode 100644 index 00000000..003112c0 --- /dev/null +++ b/examples/settings/set-caption-box-padding-3.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-box-padding-3.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionBoxPadding 3 + +CaptionOn + +Type "I like it nice and cozy in here!" +Sleep 2s diff --git a/examples/settings/set-caption-font-color.gif b/examples/settings/set-caption-font-color.gif new file mode 100644 index 00000000..2a32b821 Binary files /dev/null and b/examples/settings/set-caption-font-color.gif differ diff --git a/examples/settings/set-caption-font-color.tape b/examples/settings/set-caption-font-color.tape new file mode 100644 index 00000000..09c2cc1c --- /dev/null +++ b/examples/settings/set-caption-font-color.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-font-color.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionFontColor "#FF893E" + +CaptionOn + +Type "Look at me! I'm orange now!" +Sleep 2s diff --git a/examples/settings/set-caption-font-ibmplex.gif b/examples/settings/set-caption-font-ibmplex.gif new file mode 100644 index 00000000..01bf1de6 Binary files /dev/null and b/examples/settings/set-caption-font-ibmplex.gif differ diff --git a/examples/settings/set-caption-font-ibmplex.tape b/examples/settings/set-caption-font-ibmplex.tape new file mode 100644 index 00000000..0b7a45d6 --- /dev/null +++ b/examples/settings/set-caption-font-ibmplex.tape @@ -0,0 +1,19 @@ +Output examples/settings/set-caption-font-ibmplex.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionFont "IBM Plex Mono" + +CaptionOn + +Type "Change the font" +Sleep 1.1s +Left 4 +Sleep 1.1s +Type "caption " +Sleep 2s diff --git a/examples/settings/set-caption-font-jetbrains.gif b/examples/settings/set-caption-font-jetbrains.gif new file mode 100644 index 00000000..ea1fe1bd Binary files /dev/null and b/examples/settings/set-caption-font-jetbrains.gif differ diff --git a/examples/settings/set-caption-font-jetbrains.tape b/examples/settings/set-caption-font-jetbrains.tape new file mode 100644 index 00000000..27727897 --- /dev/null +++ b/examples/settings/set-caption-font-jetbrains.tape @@ -0,0 +1,19 @@ +Output examples/settings/set-caption-font-jetbrains.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionFont "JetBrains Mono" + +CaptionOn + +Type "Change the font" +Sleep 1.1s +Left 4 +Sleep 1.1s +Type "caption " +Sleep 2s diff --git a/examples/settings/set-caption-font-size-18.gif b/examples/settings/set-caption-font-size-18.gif new file mode 100644 index 00000000..9ab76920 Binary files /dev/null and b/examples/settings/set-caption-font-size-18.gif differ diff --git a/examples/settings/set-caption-font-size-18.tape b/examples/settings/set-caption-font-size-18.tape new file mode 100644 index 00000000..3d198f3d --- /dev/null +++ b/examples/settings/set-caption-font-size-18.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-font-size-18.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionFontSize 18 + +CaptionOn + +Type "Smaller captions" +Sleep 2s diff --git a/examples/settings/set-caption-font-size-26.gif b/examples/settings/set-caption-font-size-26.gif new file mode 100644 index 00000000..543b5d7e Binary files /dev/null and b/examples/settings/set-caption-font-size-26.gif differ diff --git a/examples/settings/set-caption-font-size-26.tape b/examples/settings/set-caption-font-size-26.tape new file mode 100644 index 00000000..46d4f592 --- /dev/null +++ b/examples/settings/set-caption-font-size-26.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-font-size-26.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionFontSize 26 + +CaptionOn + +Type "Bigger captions" +Sleep 2s diff --git a/examples/settings/set-caption-highlight-color.gif b/examples/settings/set-caption-highlight-color.gif new file mode 100644 index 00000000..cf048f2e Binary files /dev/null and b/examples/settings/set-caption-highlight-color.gif differ diff --git a/examples/settings/set-caption-highlight-color.tape b/examples/settings/set-caption-highlight-color.tape new file mode 100644 index 00000000..c9600c07 --- /dev/null +++ b/examples/settings/set-caption-highlight-color.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-highlight-color.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionHighlightColor "#77FFEF" + +CaptionOn + +Type "Follow me if you can!" +Sleep 2s diff --git a/examples/settings/set-caption-inactivity-timer-1200.gif b/examples/settings/set-caption-inactivity-timer-1200.gif new file mode 100644 index 00000000..352122aa Binary files /dev/null and b/examples/settings/set-caption-inactivity-timer-1200.gif differ diff --git a/examples/settings/set-caption-inactivity-timer-1200.tape b/examples/settings/set-caption-inactivity-timer-1200.tape new file mode 100644 index 00000000..78396472 --- /dev/null +++ b/examples/settings/set-caption-inactivity-timer-1200.tape @@ -0,0 +1,23 @@ +Output examples/settings/set-caption-inactivity-timer-1200.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionInactivityTimer 1200ms + +CaptionOn + +Type "T-minus " +Sleep 1s +Type@50ms "3 " +Sleep 1s +Type@50ms "2 " +Sleep 1s +Type@50ms "1 " +Sleep 1s +Type@50ms "Blast off!" +Sleep 2s diff --git a/examples/settings/set-caption-inactivity-timer-900.gif b/examples/settings/set-caption-inactivity-timer-900.gif new file mode 100644 index 00000000..c31175c9 Binary files /dev/null and b/examples/settings/set-caption-inactivity-timer-900.gif differ diff --git a/examples/settings/set-caption-inactivity-timer-900.tape b/examples/settings/set-caption-inactivity-timer-900.tape new file mode 100644 index 00000000..d2bfcf4b --- /dev/null +++ b/examples/settings/set-caption-inactivity-timer-900.tape @@ -0,0 +1,23 @@ +Output examples/settings/set-caption-inactivity-timer-900.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionInactivityTimer 900ms + +CaptionOn + +Type "T-minus " +Sleep 1s +Type@50ms "3 " +Sleep 1s +Type@50ms "2 " +Sleep 1s +Type@50ms "1 " +Sleep 1s +Type@50ms "Blast off!" +Sleep 2s diff --git a/examples/settings/set-caption-key-style.gif b/examples/settings/set-caption-key-style.gif new file mode 100644 index 00000000..2a52da81 Binary files /dev/null and b/examples/settings/set-caption-key-style.gif differ diff --git a/examples/settings/set-caption-key-style.tape b/examples/settings/set-caption-key-style.tape new file mode 100644 index 00000000..4e066bbb --- /dev/null +++ b/examples/settings/set-caption-key-style.tape @@ -0,0 +1,32 @@ +Output examples/settings/set-caption-key-style.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set Shell zsh +Set CaptionKeyStyle "vim" + +CaptionOn + +# Set up line editing to demo control keys +Hide +Type "bindkey -e" +Enter +Ctrl+l +Show + +Type "echo Goodbye, Planet" +Sleep 0.5s +Enter +Sleep 2s +Ctrl+p +Sleep 1.5s +Ctrl+w +Sleep 1.1s +Type "World" +Enter +Sleep 2s diff --git a/examples/settings/set-caption-margin.gif b/examples/settings/set-caption-margin.gif new file mode 100644 index 00000000..be73e21d Binary files /dev/null and b/examples/settings/set-caption-margin.gif differ diff --git a/examples/settings/set-caption-margin.tape b/examples/settings/set-caption-margin.tape new file mode 100644 index 00000000..6cfd935a --- /dev/null +++ b/examples/settings/set-caption-margin.tape @@ -0,0 +1,17 @@ +Output examples/settings/set-caption-margin.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionAlignment "bottom-right" +Set CaptionMarginVertical 10 +Set CaptionMarginRight 10 + +CaptionOn + +Type "Margins? Who wants a margin??" +Sleep 2s diff --git a/examples/settings/set-caption-max-keys-15.gif b/examples/settings/set-caption-max-keys-15.gif new file mode 100644 index 00000000..062789ab Binary files /dev/null and b/examples/settings/set-caption-max-keys-15.gif differ diff --git a/examples/settings/set-caption-max-keys-15.tape b/examples/settings/set-caption-max-keys-15.tape new file mode 100644 index 00000000..753370bd --- /dev/null +++ b/examples/settings/set-caption-max-keys-15.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-max-keys-15.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionMaxKeys 15 + +CaptionOn + +Type "How much do you want to see?" +Sleep 2s diff --git a/examples/settings/set-caption-max-keys-5.gif b/examples/settings/set-caption-max-keys-5.gif new file mode 100644 index 00000000..9849655a Binary files /dev/null and b/examples/settings/set-caption-max-keys-5.gif differ diff --git a/examples/settings/set-caption-max-keys-5.tape b/examples/settings/set-caption-max-keys-5.tape new file mode 100644 index 00000000..6597565b --- /dev/null +++ b/examples/settings/set-caption-max-keys-5.tape @@ -0,0 +1,15 @@ +Output examples/settings/set-caption-max-keys-5.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set CaptionMaxKeys 5 + +CaptionOn + +Type "How much do you want to see?" +Sleep 2s diff --git a/examples/settings/set-caption-toggle-captions.gif b/examples/settings/set-caption-toggle-captions.gif new file mode 100644 index 00000000..6131f050 Binary files /dev/null and b/examples/settings/set-caption-toggle-captions.gif differ diff --git a/examples/settings/set-caption-toggle-captions.tape b/examples/settings/set-caption-toggle-captions.tape new file mode 100644 index 00000000..37a96f9c --- /dev/null +++ b/examples/settings/set-caption-toggle-captions.tape @@ -0,0 +1,31 @@ +Output examples/settings/set-caption-toggle-captions.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set OverlayAlignment "top-right" +Set OverlayBoxColor "#ffffff" +Set OverlayBoxOpacity 1 +Set OverlayFontColor "#000000" +Set OverlayMarginRight 10 +Set OverlayMarginVertical 10 + +Overlay@100s "Selective Captioning" + +CaptionOn +Type "echo captions are enabled" +Enter +Sleep 2s + +CaptionOff +Type "echo captions are disabled" +Enter +Sleep 2s + +CaptionOn +Type "echo captions are re-enabled" +Sleep 2s diff --git a/examples/settings/set-caption-with-overlay-multiline.gif b/examples/settings/set-caption-with-overlay-multiline.gif new file mode 100644 index 00000000..76999093 Binary files /dev/null and b/examples/settings/set-caption-with-overlay-multiline.gif differ diff --git a/examples/settings/set-caption-with-overlay-multiline.tape b/examples/settings/set-caption-with-overlay-multiline.tape new file mode 100644 index 00000000..9f191066 --- /dev/null +++ b/examples/settings/set-caption-with-overlay-multiline.tape @@ -0,0 +1,35 @@ +Output examples/settings/set-caption-with-overlay-multiline.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set Shell zsh +Set OverlayBoxOpacity 0 +Set OverlayAlignment "top-right" + +CaptionOn + +# Set up line editing to demo control keys +Hide +Type "bindkey -e" +Enter +Ctrl+l +Show + +Type "echo Hello, Planet" +Sleep 0.5s +Enter +Sleep 2s +Overlay@10s "Let's now edit\Nthe prior command\Nto show the world!" +Sleep 1s +Ctrl+p +Sleep 1.5s +Ctrl+w +Sleep 1.1s +Type "World" +Enter +Sleep 2s diff --git a/examples/settings/set-caption-with-overlay-title.gif b/examples/settings/set-caption-with-overlay-title.gif new file mode 100644 index 00000000..c3dcb708 Binary files /dev/null and b/examples/settings/set-caption-with-overlay-title.gif differ diff --git a/examples/settings/set-caption-with-overlay-title.tape b/examples/settings/set-caption-with-overlay-title.tape new file mode 100644 index 00000000..dce0150a --- /dev/null +++ b/examples/settings/set-caption-with-overlay-title.tape @@ -0,0 +1,39 @@ +Output examples/settings/set-caption-with-overlay-title.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set Shell zsh +Set OverlayAlignment "top-right" +Set OverlayBoxColor "#ffffff" +Set OverlayBoxOpacity 1 +Set OverlayFontColor "#000000" +Set OverlayMarginRight 10 +Set OverlayMarginVertical 10 + +CaptionOn + +# Set up line editing to demo control keys +Hide +Type "bindkey -e" +Enter +Ctrl+l +Show + +Overlay@100s "Demo of Command Line Editing" +Type "echo Hello, Planet" +Sleep 0.5s +Enter +Sleep 2s +Sleep 1s +Ctrl+p +Sleep 1.5s +Ctrl+w +Sleep 1.1s +Type "World" +Enter +Sleep 2s diff --git a/examples/settings/set-caption-with-overlay.gif b/examples/settings/set-caption-with-overlay.gif new file mode 100644 index 00000000..9e16d94f Binary files /dev/null and b/examples/settings/set-caption-with-overlay.gif differ diff --git a/examples/settings/set-caption-with-overlay.tape b/examples/settings/set-caption-with-overlay.tape new file mode 100644 index 00000000..7631ebb6 --- /dev/null +++ b/examples/settings/set-caption-with-overlay.tape @@ -0,0 +1,34 @@ +Output examples/settings/set-caption-with-overlay.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set Shell zsh +Set OverlayAlignment "top-right" + +CaptionOn + +# Set up line editing to demo control keys +Hide +Type "bindkey -e" +Enter +Ctrl+l +Show + +Type "echo Hello, Planet" +Sleep 0.5s +Enter +Sleep 2s +Overlay@10s "Demo of Command Line Editing" +Sleep 1s +Ctrl+p +Sleep 1.5s +Ctrl+w +Sleep 1.1s +Type "World" +Enter +Sleep 2s diff --git a/examples/settings/set-caption.gif b/examples/settings/set-caption.gif new file mode 100644 index 00000000..0b00dbac Binary files /dev/null and b/examples/settings/set-caption.gif differ diff --git a/examples/settings/set-caption.tape b/examples/settings/set-caption.tape new file mode 100644 index 00000000..a3ce2235 --- /dev/null +++ b/examples/settings/set-caption.tape @@ -0,0 +1,31 @@ +Output examples/settings/set-caption.gif +Set Width 800 +Set Height 225 +Set Padding 30 + +Set FontSize 18 +Set TypingSpeed 0.1 +Set Theme "Catppuccin Macchiato" + +Set Shell zsh + +CaptionOn + +# Set up line editing to demo control keys +Hide +Type "bindkey -e" +Enter +Ctrl+l +Show + +Type "echo Hello, Planet" +Sleep 0.5s +Enter +Sleep 2s +Ctrl+p +Sleep 1.5s +Ctrl+w +Sleep 1.1s +Type "World" +Enter +Sleep 2s diff --git a/ffmpeg.go b/ffmpeg.go index d35823af..2eefcfb5 100644 --- a/ffmpeg.go +++ b/ffmpeg.go @@ -104,6 +104,30 @@ func calcTermDimensions(style StyleOptions) (int, int) { return width, height } +// WithCaptions adds ASS subtitle overlay to ffmpeg filter_complex. +func (fb *FilterComplexBuilder) WithCaptions(assFilePath string) *FilterComplexBuilder { + if assFilePath == "" { + return fb + } + // Escape special characters for ffmpeg filter syntax + escaped := strings.ReplaceAll(assFilePath, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `:`, `\:`) + escaped = strings.ReplaceAll(escaped, `'`, `'\''`) + + fb.filterComplex.WriteString(";") + _, _ = fmt.Fprintf( + fb.filterComplex, + ` + [%s]ass='%s'[captioned] + `, + fb.prevStageName, + escaped, + ) + fb.prevStageName = "captioned" + + return fb +} + // WithWindowBar adds window bar options to ffmepg filter_complex. func (fb *FilterComplexBuilder) WithWindowBar(barStream int) *FilterComplexBuilder { if fb.style.WindowBar != "" { diff --git a/keylogger.go b/keylogger.go new file mode 100644 index 00000000..74078295 --- /dev/null +++ b/keylogger.go @@ -0,0 +1,83 @@ +package main + +import ( + "sync/atomic" +) + +// KeyEvent represents a single key press and timing information. +type KeyEvent struct { + // StartMs is the number of milliseconds relative to the recording start. + StartMs int64 `json:"ms"` + + // Key is the string representation of the key pressed. + Key string `json:"key"` +} + +// KeyLogger tracks key events during tape execution. +type KeyLogger struct { + events []KeyEvent + paused bool + enabled bool + frame *int64 // pointer to shared frame counter + framerate int +} + +// NewKeyLogger creates a new KeyLogger. +func NewKeyLogger() *KeyLogger { + return &KeyLogger{ + events: make([]KeyEvent, 0), + } +} + +// Start begins key recording. +// +// The first parameter is a pointer to the shared frame counter that is being +// incremented asynchronously by VHS as captures are being made. This is used +// with the framerate parameter to compute when a key event was made. +func (l *KeyLogger) Start(frame *int64, framerate int) { + l.frame = frame + l.framerate = framerate +} + +// Pause suspends key logging until Resume is called. +func (l *KeyLogger) Pause() { + l.paused = true +} + +// Resume enables key logging after Pause is called. +func (l *KeyLogger) Resume() { + l.paused = false +} + +// Enable turns on caption key logging. +func (l *KeyLogger) Enable() { + l.enabled = true +} + +// Disable turns off caption key logging. +func (l *KeyLogger) Disable() { + l.enabled = false +} + +// LogKey records the current key and time at which it occurred with respect +// to the current frame being captured. If key logging has not been started or +// if it has been paused, this does nothing. +func (l *KeyLogger) LogKey(key string) { + if l.frame == nil || l.paused || !l.enabled { + return + } + + frameNum := atomic.LoadInt64(l.frame) + timeMs := frameNum * 1000 / int64(l.framerate) + + event := KeyEvent{ + StartMs: timeMs, + Key: key, + } + l.events = append(l.events, event) +} + +// Events returns the recorded key events. +func (l *KeyLogger) Events() []KeyEvent { + return l.events +} diff --git a/keylogger_test.go b/keylogger_test.go new file mode 100644 index 00000000..c93019d4 --- /dev/null +++ b/keylogger_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "testing" +) + +func TestKeyLogger_Basic(t *testing.T) { + l := NewKeyLogger() + + l.LogKey("a") + if len(l.events) != 0 { + t.Error("Expected no events when recorder has not been started") + } + + var frame int64 + l.Start(&frame, 50) // 50 fps + l.Enable() + + l.LogKey("a") + frame = 5 // 100ms at 50fps + l.LogKey("b") + + // Verify correct number of events + if len(l.events) != 2 { + t.Errorf("Expected 2 events, got %d", len(l.events)) + } + + // Verify ordering of events + if l.events[0].StartMs >= l.events[1].StartMs { + t.Error("Expected first event to have earlier timestamp") + } + + // Verify correct timestamps + if l.events[0].StartMs != 0 { + t.Errorf("Expected first event at 0ms, got %d", l.events[0].StartMs) + } + if l.events[1].StartMs != 100 { + t.Errorf("Expected second event at 100ms, got %d", l.events[1].StartMs) + } + + // Verify correct keys + if l.events[0].Key != "a" { + t.Errorf("Expected first event as 'a', got '%s'", l.events[0].Key) + } + if l.events[1].Key != "b" { + t.Errorf("Expected second event as 'b', got '%s'", l.events[1].Key) + } + +} + +func TestKeyLogger_PauseResume(t *testing.T) { + l := NewKeyLogger() + + var frame int64 + l.Start(&frame, 50) // 50 fps + l.Enable() + + l.LogKey("a") + l.Pause() + l.LogKey("b") // this should not be logged + l.Resume() + l.LogKey("c") + + // Verify correct number of events + if len(l.events) != 2 { + t.Errorf("Expected 2 events, got %d", len(l.events)) + } + + // Verify correct keys + if l.events[0].Key != "a" { + t.Errorf("Expected first event as 'a', got '%s'", l.events[0].Key) + } + if l.events[1].Key != "c" { + t.Errorf("Expected second event as 'c', got '%s'", l.events[1].Key) + } +} + +func TestKeyLogger_Events(t *testing.T) { + l := NewKeyLogger() + + var frame int64 + l.Start(&frame, 50) // 50 fps + l.Enable() + + l.LogKey("a") + l.LogKey("b") + l.LogKey("c") + + events := l.Events() + if len(events) != 3 { + t.Errorf("Expected 3 events, got %d", len(events)) + } + + expectedKeys := []string{"a", "b", "c"} + for i, expected := range expectedKeys { + if events[i].Key != expected { + t.Errorf("Event %d: expected key '%s', got '%s'", i, expected, events[i].Key) + } + } +} + +func TestKeyLogger_EnableDisable(t *testing.T) { + l := NewKeyLogger() + + var frame int64 + l.Start(&frame, 50) + + // Not enabled by default — keys should be ignored + l.LogKey("a") + if len(l.events) != 0 { + t.Errorf("Expected 0 events when disabled, got %d", len(l.events)) + } + + l.Enable() + l.LogKey("b") + if len(l.events) != 1 { + t.Errorf("Expected 1 event after Enable, got %d", len(l.events)) + } + + l.Disable() + l.LogKey("c") + if len(l.events) != 1 { + t.Errorf("Expected 1 event after Disable, got %d", len(l.events)) + } + + l.Enable() + l.LogKey("d") + if len(l.events) != 2 { + t.Errorf("Expected 2 events after re-Enable, got %d", len(l.events)) + } + + if l.events[0].Key != "b" || l.events[1].Key != "d" { + t.Errorf("Expected keys 'b' and 'd', got '%s' and '%s'", l.events[0].Key, l.events[1].Key) + } +} + +func TestKeyLogger_PauseAndEnabledInteraction(t *testing.T) { + l := NewKeyLogger() + + var frame int64 + l.Start(&frame, 50) + l.Enable() + + l.LogKey("a") // logged + l.Pause() + l.LogKey("b") // paused — not logged + l.Resume() + l.LogKey("c") // logged (enabled + not paused) + + // Disable while not paused + l.Disable() + l.LogKey("d") // disabled — not logged + + // Pause while disabled, then enable + l.Pause() + l.Enable() + l.LogKey("e") // paused — not logged even though enabled + + l.Resume() + l.LogKey("f") // enabled + not paused — logged + + if len(l.events) != 3 { + t.Errorf("Expected 3 events, got %d", len(l.events)) + } + expectedKeys := []string{"a", "c", "f"} + for i, expected := range expectedKeys { + if l.events[i].Key != expected { + t.Errorf("Event %d: expected key '%s', got '%s'", i, expected, l.events[i].Key) + } + } +} + +func TestKeyLogger_FrameAlignment(t *testing.T) { + tests := []struct { + name string + framerate int + frames []int64 + wantMs []int64 + }{ + { + name: "50fps", + framerate: 50, + frames: []int64{0, 25, 50, 100}, + wantMs: []int64{0, 500, 1000, 2000}, + }, + { + name: "30fps", + framerate: 30, + frames: []int64{0, 15, 30, 60}, + wantMs: []int64{0, 500, 1000, 2000}, + }, + { + name: "60fps", + framerate: 60, + frames: []int64{0, 30, 60, 120}, + wantMs: []int64{0, 500, 1000, 2000}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l := NewKeyLogger() + var frame int64 + l.Start(&frame, tt.framerate) + l.Enable() + + for _, f := range tt.frames { + frame = f + l.LogKey("a") + } + + if len(l.events) != len(tt.wantMs) { + t.Errorf("Expected %d events, got %d", len(tt.wantMs), len(l.events)) + } + + for i, want := range tt.wantMs { + if l.events[i].StartMs != want { + t.Errorf("Event %d: expected %dms, got %dms", i, want, l.events[i].StartMs) + } + } + + }) + } +} diff --git a/parser/parser.go b/parser/parser.go index 39a4dcb4..4fbcda69 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -58,6 +58,9 @@ var CommandTypes = []CommandType{ token.COPY, token.PASTE, token.ENV, + token.OVERLAY, + token.CAPTION_ON, + token.CAPTION_OFF, } // String returns the string representation of the command. @@ -185,6 +188,12 @@ func (p *Parser) parseCommand() []Command { return []Command{p.parsePaste()} case token.ENV: return []Command{p.parseEnv()} + case token.OVERLAY: + return []Command{p.parseOverlay()} + case token.CAPTION_ON: + return []Command{p.parseCaptionOn()} + case token.CAPTION_OFF: + return []Command{p.parseCaptionOff()} default: p.errors = append(p.errors, NewError(p.cur, "Invalid command: "+p.cur.Literal)) return []Command{{Type: token.ILLEGAL}} @@ -496,22 +505,10 @@ func (p *Parser) parseSet() Command { case token.MARGIN_FILL: cmd.Args = p.peek.Literal p.nextToken() - - marginFill := p.cur.Literal - - // Check if margin color is a valid hex string - if strings.HasPrefix(marginFill, "#") { - _, err := strconv.ParseUint(marginFill[1:], 16, 64) - - if err != nil || len(marginFill) != 7 { - p.errors = append( - p.errors, - NewError( - p.cur, - "\""+marginFill+"\" is not a valid color.", - ), - ) - } + // Margin fill can take non-hex values, so only check on hex values + if strings.HasPrefix(p.cur.Literal, "#") && !isValidHexColor(p.cur.Literal) { + p.errors = append(p.errors, + NewError(p.cur, "\""+p.cur.Literal+"\" is not a valid color.")) } case token.CURSOR_BLINK: cmd.Args = p.peek.Literal @@ -524,6 +521,123 @@ func (p *Parser) parseSet() Command { ) } + case token.CAPTION_KEY_STYLE: + cmd.Args = p.peek.Literal + p.nextToken() + if p.cur.Literal != "vim" && p.cur.Literal != "icon" { + p.errors = append( + p.errors, + NewError(p.cur, "CaptionKeyStyle must be \"vim\" or \"icon\""), + ) + } + + case token.CAPTION_ALIGNMENT: + cmd.Args = p.peek.Literal + p.nextToken() + validAlignments := map[string]bool{ + "bottom-left": true, "bottom-center": true, "bottom-right": true, + "middle-left": true, "middle-center": true, "middle-right": true, + "top-left": true, "top-center": true, "top-right": true, + } + if !validAlignments[p.cur.Literal] { + p.errors = append( + p.errors, + NewError(p.cur, "CaptionAlignment must be one of: bottom-left, bottom-center, bottom-right, middle-left, middle-center, middle-right, top-left, top-center, top-right"), + ) + } + + case token.CAPTION_BOX_OPACITY: + cmd.Args = p.peek.Literal + p.nextToken() + val, err := strconv.ParseFloat(p.cur.Literal, 64) + if err != nil || val < 0 || val > 1 { + p.errors = append( + p.errors, + NewError(p.cur, "CaptionBoxOpacity must be a float between 0 and 1"), + ) + } + + case token.CAPTION_FONT: + cmd.Args = p.peek.Literal + p.nextToken() + if len(p.cur.Literal) > 31 { + p.errors = append(p.errors, + NewError(p.cur, "CaptionFont must not be longer than 31 characters per ASS specification")) + } + + case token.CAPTION_HIGHLIGHT_COLOR: + cmd.Args = p.peek.Literal + p.nextToken() + if !isValidHexColor(p.cur.Literal) { + p.errors = append(p.errors, + NewError(p.cur, "CaptionHighlightColor must be a hex color in #RRGGBB format")) + } + + case token.CAPTION_FONT_COLOR: + cmd.Args = p.peek.Literal + p.nextToken() + if !isValidHexColor(p.cur.Literal) { + p.errors = append(p.errors, + NewError(p.cur, "CaptionFontColor must be a hex color in #RRGGBB format")) + } + + case token.CAPTION_BOX_COLOR: + cmd.Args = p.peek.Literal + p.nextToken() + if !isValidHexColor(p.cur.Literal) { + p.errors = append(p.errors, + NewError(p.cur, "CaptionBoxColor must be a hex color in #RRGGBB format")) + } + + case token.CAPTION_INACTIVITY_TIMER: + cmd.Args = p.parseTime() + + case token.OVERLAY_FONT: + cmd.Args = p.peek.Literal + p.nextToken() + if len(p.cur.Literal) > 31 { + p.errors = append(p.errors, + NewError(p.cur, "OverlayFont must not be longer than 31 characters per ASS specification")) + } + + case token.OVERLAY_FONT_COLOR: + cmd.Args = p.peek.Literal + p.nextToken() + if !isValidHexColor(p.cur.Literal) { + p.errors = append(p.errors, + NewError(p.cur, "OverlayFontColor must be a hex color in #RRGGBB format")) + } + + case token.OVERLAY_BOX_COLOR: + cmd.Args = p.peek.Literal + p.nextToken() + if !isValidHexColor(p.cur.Literal) { + p.errors = append(p.errors, + NewError(p.cur, "OverlayBoxColor must be a hex color in #RRGGBB format")) + } + + case token.OVERLAY_BOX_OPACITY: + cmd.Args = p.peek.Literal + p.nextToken() + val, err := strconv.ParseFloat(p.cur.Literal, 64) + if err != nil || val < 0 || val > 1 { + p.errors = append(p.errors, + NewError(p.cur, "OverlayBoxOpacity must be a float between 0 and 1")) + } + + case token.OVERLAY_ALIGNMENT: + cmd.Args = p.peek.Literal + p.nextToken() + validAlignments := map[string]bool{ + "bottom-left": true, "bottom-center": true, "bottom-right": true, + "middle-left": true, "middle-center": true, "middle-right": true, + "top-left": true, "top-center": true, "top-right": true, + } + if !validAlignments[p.cur.Literal] { + p.errors = append(p.errors, + NewError(p.cur, "OverlayAlignment must be one of: bottom-left, bottom-center, bottom-right, middle-left, middle-center, middle-right, top-left, top-center, top-right")) + } + default: cmd.Args = p.peek.Literal p.nextToken() @@ -574,6 +688,20 @@ func (p *Parser) parseShow() Command { return cmd } +// parseCaptionOn parses a CaptionOn command. +// +// CaptionOn +func (p *Parser) parseCaptionOn() Command { + return Command{Type: token.CAPTION_ON} +} + +// parseCaptionOff parses a CaptionOff command. +// +// CaptionOff +func (p *Parser) parseCaptionOff() Command { + return Command{Type: token.CAPTION_OFF} +} + // parseType parses a type command. // A type command takes a string to type. // @@ -665,6 +793,31 @@ func (p *Parser) parseEnv() Command { return cmd } +// parseOverlay parses an overlay command. +// An overlay command takes an optional duration and a string to display. +// +// Overlay[@] "" +func (p *Parser) parseOverlay() Command { + cmd := Command{Type: token.OVERLAY} + + cmd.Options = p.parseSpeed() + + if p.peek.Type != token.STRING { + p.errors = append(p.errors, NewError(p.peek, p.cur.Literal+" expects string")) + } + + for p.peek.Type == token.STRING { + p.nextToken() + cmd.Args += p.cur.Literal + + if p.peek.Type == token.STRING { + cmd.Args += " " + } + } + + return cmd +} + // parseSource parses source command. // Source command takes a tape path to include in current tape. // @@ -796,3 +949,12 @@ func isValidWindowBar(w string) bool { w == "Colorful" || w == "ColorfulRight" || w == "Rings" || w == "RingsRight" } + +// isValidHexColor checks if a string is a valid #RRGGBB hex color. +func isValidHexColor(color string) bool { + if !strings.HasPrefix(color, "#") || len(color) != 7 { + return false + } + _, err := strconv.ParseUint(color[1:], 16, 64) + return err == nil +} diff --git a/syntax.go b/syntax.go index b650cc2c..6131c34e 100644 --- a/syntax.go +++ b/syntax.go @@ -59,7 +59,10 @@ func Highlight(c parser.Command, faint bool) string { case token.TYPE: optionsStyle = TimeStyle argsStyle = StringStyle - case token.HIDE, token.SHOW: + case token.OVERLAY: + optionsStyle = TimeStyle + argsStyle = StringStyle + case token.HIDE, token.SHOW, token.CAPTION_ON, token.CAPTION_OFF: return FaintStyle.Render(c.Type.String()) } diff --git a/token/token.go b/token/token.go index d0d98a1a..080c60e1 100644 --- a/token/token.go +++ b/token/token.go @@ -105,70 +105,126 @@ const ( WAIT_TIMEOUT = "WAIT_TIMEOUT" WAIT_PATTERN = "WAIT_PATTERN" CURSOR_BLINK = "CURSOR_BLINK" + + CAPTION_ON = "CAPTION_ON" + CAPTION_OFF = "CAPTION_OFF" + CAPTION_FONT = "CAPTION_FONT" + CAPTION_FONT_SIZE = "CAPTION_FONT_SIZE" + CAPTION_MAX_KEYS = "CAPTION_MAX_KEYS" + CAPTION_INACTIVITY_TIMER = "CAPTION_INACTIVITY_TIMER" + CAPTION_HIGHLIGHT_COLOR = "CAPTION_HIGHLIGHT_COLOR" + CAPTION_FONT_COLOR = "CAPTION_FONT_COLOR" + CAPTION_BOX_COLOR = "CAPTION_BOX_COLOR" + CAPTION_BOX_OPACITY = "CAPTION_BOX_OPACITY" + CAPTION_KEY_STYLE = "CAPTION_KEY_STYLE" + CAPTION_MARGIN_LEFT = "CAPTION_MARGIN_LEFT" + CAPTION_MARGIN_RIGHT = "CAPTION_MARGIN_RIGHT" + CAPTION_MARGIN_VERTICAL = "CAPTION_MARGIN_VERTICAL" + CAPTION_ALIGNMENT = "CAPTION_ALIGNMENT" + CAPTION_BOX_PADDING = "CAPTION_BOX_PADDING" + + OVERLAY = "OVERLAY" + OVERLAY_FONT = "OVERLAY_FONT" + OVERLAY_FONT_SIZE = "OVERLAY_FONT_SIZE" + OVERLAY_FONT_COLOR = "OVERLAY_FONT_COLOR" + OVERLAY_BOX_COLOR = "OVERLAY_BOX_COLOR" + OVERLAY_BOX_OPACITY = "OVERLAY_BOX_OPACITY" + OVERLAY_BOX_PADDING = "OVERLAY_BOX_PADDING" + OVERLAY_ALIGNMENT = "OVERLAY_ALIGNMENT" + OVERLAY_MARGIN_LEFT = "OVERLAY_MARGIN_LEFT" + OVERLAY_MARGIN_RIGHT = "OVERLAY_MARGIN_RIGHT" + OVERLAY_MARGIN_VERTICAL = "OVERLAY_MARGIN_VERTICAL" ) // Keywords maps keyword strings to tokens. var Keywords = map[string]Type{ - "em": EM, - "px": PX, - "ms": MILLISECONDS, - "s": SECONDS, - "m": MINUTES, - "Set": SET, - "Sleep": SLEEP, - "Type": TYPE, - "Enter": ENTER, - "Space": SPACE, - "Backspace": BACKSPACE, - "Delete": DELETE, - "Insert": INSERT, - "Ctrl": CTRL, - "Alt": ALT, - "Shift": SHIFT, - "Down": DOWN, - "Left": LEFT, - "Right": RIGHT, - "Up": UP, - "PageUp": PAGE_UP, - "PageDown": PAGE_DOWN, - "ScrollUp": SCROLL_UP, - "ScrollDown": SCROLL_DOWN, - "Tab": TAB, - "Escape": ESCAPE, - "End": END, - "Hide": HIDE, - "Require": REQUIRE, - "Show": SHOW, - "Output": OUTPUT, - "Shell": SHELL, - "FontFamily": FONT_FAMILY, - "MarginFill": MARGIN_FILL, - "Margin": MARGIN, - "WindowBar": WINDOW_BAR, - "WindowBarSize": WINDOW_BAR_SIZE, - "BorderRadius": BORDER_RADIUS, - "FontSize": FONT_SIZE, - "Framerate": FRAMERATE, - "Height": HEIGHT, - "LetterSpacing": LETTER_SPACING, - "LineHeight": LINE_HEIGHT, - "PlaybackSpeed": PLAYBACK_SPEED, - "TypingSpeed": TYPING_SPEED, - "Padding": PADDING, - "Theme": THEME, - "Width": WIDTH, - "LoopOffset": LOOP_OFFSET, - "WaitTimeout": WAIT_TIMEOUT, - "WaitPattern": WAIT_PATTERN, - "Wait": WAIT, - "Source": SOURCE, - "CursorBlink": CURSOR_BLINK, - "true": BOOLEAN, - "false": BOOLEAN, - "Screenshot": SCREENSHOT, - "Copy": COPY, - "Paste": PASTE, - "Env": ENV, + "em": EM, + "px": PX, + "ms": MILLISECONDS, + "s": SECONDS, + "m": MINUTES, + "Set": SET, + "Sleep": SLEEP, + "Type": TYPE, + "Enter": ENTER, + "Space": SPACE, + "Backspace": BACKSPACE, + "Delete": DELETE, + "Insert": INSERT, + "Ctrl": CTRL, + "Alt": ALT, + "Shift": SHIFT, + "Down": DOWN, + "Left": LEFT, + "Right": RIGHT, + "Up": UP, + "PageUp": PAGE_UP, + "PageDown": PAGE_DOWN, + "ScrollUp": SCROLL_UP, + "ScrollDown": SCROLL_DOWN, + "Tab": TAB, + "Escape": ESCAPE, + "End": END, + "Hide": HIDE, + "Require": REQUIRE, + "Show": SHOW, + "Output": OUTPUT, + "Shell": SHELL, + "FontFamily": FONT_FAMILY, + "MarginFill": MARGIN_FILL, + "Margin": MARGIN, + "WindowBar": WINDOW_BAR, + "WindowBarSize": WINDOW_BAR_SIZE, + "BorderRadius": BORDER_RADIUS, + "FontSize": FONT_SIZE, + "Framerate": FRAMERATE, + "Height": HEIGHT, + "LetterSpacing": LETTER_SPACING, + "LineHeight": LINE_HEIGHT, + "PlaybackSpeed": PLAYBACK_SPEED, + "TypingSpeed": TYPING_SPEED, + "Padding": PADDING, + "Theme": THEME, + "Width": WIDTH, + "LoopOffset": LOOP_OFFSET, + "WaitTimeout": WAIT_TIMEOUT, + "WaitPattern": WAIT_PATTERN, + "Wait": WAIT, + "Source": SOURCE, + "CursorBlink": CURSOR_BLINK, + "CaptionOn": CAPTION_ON, + "CaptionOff": CAPTION_OFF, + "CaptionFont": CAPTION_FONT, + "CaptionFontSize": CAPTION_FONT_SIZE, + "CaptionMaxKeys": CAPTION_MAX_KEYS, + "CaptionInactivityTimer": CAPTION_INACTIVITY_TIMER, + "CaptionHighlightColor": CAPTION_HIGHLIGHT_COLOR, + "CaptionFontColor": CAPTION_FONT_COLOR, + "CaptionBoxColor": CAPTION_BOX_COLOR, + "CaptionBoxOpacity": CAPTION_BOX_OPACITY, + "CaptionKeyStyle": CAPTION_KEY_STYLE, + "CaptionMarginLeft": CAPTION_MARGIN_LEFT, + "CaptionMarginRight": CAPTION_MARGIN_RIGHT, + "CaptionMarginVertical": CAPTION_MARGIN_VERTICAL, + "CaptionAlignment": CAPTION_ALIGNMENT, + "CaptionBoxPadding": CAPTION_BOX_PADDING, + "Overlay": OVERLAY, + "OverlayFont": OVERLAY_FONT, + "OverlayFontSize": OVERLAY_FONT_SIZE, + "OverlayFontColor": OVERLAY_FONT_COLOR, + "OverlayBoxColor": OVERLAY_BOX_COLOR, + "OverlayBoxOpacity": OVERLAY_BOX_OPACITY, + "OverlayBoxPadding": OVERLAY_BOX_PADDING, + "OverlayAlignment": OVERLAY_ALIGNMENT, + "OverlayMarginLeft": OVERLAY_MARGIN_LEFT, + "OverlayMarginRight": OVERLAY_MARGIN_RIGHT, + "OverlayMarginVertical": OVERLAY_MARGIN_VERTICAL, + "true": BOOLEAN, + "false": BOOLEAN, + "Screenshot": SCREENSHOT, + "Copy": COPY, + "Paste": PASTE, + "Env": ENV, } // IsSetting returns whether a token is a setting. @@ -177,7 +233,16 @@ 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, + CAPTION_FONT, CAPTION_FONT_SIZE, CAPTION_MAX_KEYS, CAPTION_INACTIVITY_TIMER, + CAPTION_HIGHLIGHT_COLOR, CAPTION_FONT_COLOR, CAPTION_BOX_COLOR, + CAPTION_BOX_OPACITY, CAPTION_KEY_STYLE, + CAPTION_MARGIN_LEFT, CAPTION_MARGIN_RIGHT, CAPTION_MARGIN_VERTICAL, + CAPTION_ALIGNMENT, CAPTION_BOX_PADDING, + OVERLAY_FONT, OVERLAY_FONT_SIZE, OVERLAY_FONT_COLOR, + OVERLAY_BOX_COLOR, OVERLAY_BOX_OPACITY, OVERLAY_BOX_PADDING, + OVERLAY_ALIGNMENT, OVERLAY_MARGIN_LEFT, OVERLAY_MARGIN_RIGHT, + OVERLAY_MARGIN_VERTICAL: return true default: return false @@ -190,7 +255,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, + OVERLAY, CAPTION_ON, CAPTION_OFF: return true default: return false diff --git a/vhs.go b/vhs.go index 235e7b42..bed19614 100644 --- a/vhs.go +++ b/vhs.go @@ -12,6 +12,7 @@ import ( "regexp" "strings" "sync" + "sync/atomic" "time" "github.com/go-rod/rod" @@ -21,18 +22,21 @@ import ( // VHS is the object that controls the setup. type VHS struct { - Options *Options - Errors []error - Page *rod.Page - browser *rod.Browser - TextCanvas *rod.Element - CursorCanvas *rod.Element - mutex *sync.Mutex - started bool - recording bool - tty *exec.Cmd - totalFrames int - close func() error + Options *Options + Errors []error + Page *rod.Page + browser *rod.Browser + TextCanvas *rod.Element + CursorCanvas *rod.Element + mutex *sync.Mutex + started bool + recording bool + tty *exec.Cmd + totalFrames int + currentFrame int64 + close func() error + KeyLogger *KeyLogger + OverlayEvents []OverlayEvent } // Options is the set of options for the setup. @@ -52,6 +56,8 @@ type Options struct { CursorBlink bool Screenshot ScreenshotOptions Style StyleOptions + Caption CaptionOptions + Overlay OverlayOptions } const ( @@ -107,6 +113,8 @@ func DefaultVHSOptions() Options { Screenshot: screenshot, WaitTimeout: defaultWaitTimeout, WaitPattern: defaultWaitPattern, + Caption: DefaultCaptionOptions(), + Overlay: DefaultOverlayOptions(), } } @@ -118,6 +126,7 @@ func New() VHS { Options: &opts, recording: true, mutex: mu, + KeyLogger: NewKeyLogger(), } } @@ -359,6 +368,7 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error { } counter++ + atomic.StoreInt64(&vhs.currentFrame, int64(counter)) if err := os.WriteFile( filepath.Join(vhs.Options.Video.Input, fmt.Sprintf(cursorFrameFormat, counter)), cursor, @@ -393,6 +403,7 @@ func (vhs *VHS) ResumeRecording() { defer vhs.mutex.Unlock() vhs.recording = true + vhs.KeyLogger.Resume() } // PauseRecording indicates to VHS that the recording should be paused. @@ -401,6 +412,7 @@ func (vhs *VHS) PauseRecording() { defer vhs.mutex.Unlock() vhs.recording = false + vhs.KeyLogger.Pause() } // ScreenshotNextFrame indicates to VHS that screenshot of next frame must be taken. diff --git a/video.go b/video.go index 5548a527..cd6d154e 100644 --- a/video.go +++ b/video.go @@ -56,6 +56,7 @@ type VideoOptions struct { Output VideoOutputs StartingFrame int Style *StyleOptions + CaptionFile string } const ( @@ -131,6 +132,7 @@ func buildFFopts(opts VideoOptions, targetFile string) []string { WithCorner() filterBuilder := NewVideoFilterBuilder(&opts). + WithCaptions(opts.CaptionFile). WithWindowBar(streamBuilder.barStream). WithBorderRadius(streamBuilder.cornerStream). WithMarginFill(streamBuilder.marginStream)