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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,15 @@ func (g *SVGGenerator) Generate() string {
func (g *SVGGenerator) processFrames() {
// First, detect patterns for optimization
g.detectPatterns()


// Determine whether frames carry real wall-clock timestamps.
// If the last frame has a non-zero timestamp and Duration > 0 we use
// timestamps for keyframe percentages; otherwise fall back to frame index
// (which is uniform and correct for unit tests that don't set Timestamp).
useTimestamps := g.options.Duration > 0 &&
len(g.options.Frames) > 0 &&
g.options.Frames[len(g.options.Frames)-1].Timestamp > 0

// First pass: collect all unique states and track when they change
lastStateIndex := -1
lastCursorIdleTime := 0.0
Expand Down Expand Up @@ -401,12 +409,24 @@ func (g *SVGGenerator) processFrames() {
hash := g.hashState(&state)
state.Hash = hash

// Compute keyframe percentage. When real wall-clock timestamps are
// available (useTimestamps), use them so that Sleep pauses appear
// at the correct proportional position regardless of capture rate.
// Fall back to uniform frame-index when frames have no timestamps
// (e.g. unit tests).
pct := 0.0
if useTimestamps {
pct = frame.Timestamp / g.options.Duration * 100
} else if len(g.options.Frames) > 1 {
pct = float64(i) / float64(len(g.options.Frames)-1) * 100
}

// Check if we've seen this state before
if idx, exists := g.stateMap[hash]; exists {
// Reuse existing state - only add to timeline if state changed
if idx != lastStateIndex {
g.timeline = append(g.timeline, KeyframeStop{
Percentage: float64(i) / float64(len(g.options.Frames)-1) * 100,
Percentage: pct,
StateIndex: idx,
})
lastStateIndex = idx
Expand All @@ -433,7 +453,7 @@ func (g *SVGGenerator) processFrames() {
}

g.timeline = append(g.timeline, KeyframeStop{
Percentage: float64(i) / float64(len(g.options.Frames)-1) * 100,
Percentage: pct,
StateIndex: idx,
})
lastStateIndex = idx
Expand Down Expand Up @@ -1754,7 +1774,10 @@ func (g *SVGGenerator) generateWindowBar() string {
}

// CaptureSVGFrame captures the current terminal state and returns an SVGFrame.
func CaptureSVGFrame(page *rod.Page, counter int, framerate int) (*SVGFrame, error) {
// elapsedSeconds is the actual wall-clock time elapsed since recording started,
// used directly as the frame timestamp to avoid drift when the capture call
// itself takes longer than the target frame interval.
func CaptureSVGFrame(page *rod.Page, elapsedSeconds float64) (*SVGFrame, error) {
// Get cursor position and exact character positions from xterm.js
termInfo, err := page.Eval(`() => {
const term = window.term;
Expand Down Expand Up @@ -2021,7 +2044,7 @@ func CaptureSVGFrame(page *rod.Page, counter int, framerate int) (*SVGFrame, err
CursorY: cursorY,
CharWidth: charWidth,
CharHeight: charHeight,
Timestamp: float64(counter) / float64(framerate),
Timestamp: elapsedSeconds,
CursorChar: cursorChar,
}

Expand Down
51 changes: 51 additions & 0 deletions svg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,57 @@ func TestSVGGenerator_BackgroundColors(t *testing.T) {
})
}

// TestSVGGenerator_SlowCaptureTimestamp verifies that when CaptureSVGFrame
// runs slower than the target frame interval (e.g. 80ms per call at 50fps),
// the animation duration and keyframe percentages are derived from the actual
// frame timestamps rather than from len(frames)/framerate. Without this fix,
// a 2s Sleep captured at 80ms/frame (25 frames) would produce an animation
// duration of 25/50=0.5s instead of the correct 2s.
func TestSVGGenerator_SlowCaptureTimestamp(t *testing.T) {
// Simulate a 2-second Sleep captured at ~80ms effective rate (slow capture).
// At 50fps the target interval is 20ms, but if CaptureSVGFrame takes 80ms
// the goroutine fires at 80ms intervals → 25 frames over 2 seconds.
// Timestamps reflect real elapsed time, not counter/framerate.
slowFrames := make([]SVGFrame, 0, 26)
for i := range 25 {
slowFrames = append(slowFrames, SVGFrame{
Lines: []string{"$ "},
CursorX: 2,
CursorY: 0,
Timestamp: float64(i) * 0.08, // 80ms per frame
CharWidth: 8.8,
CharHeight: 20,
})
}
// New state after the Sleep.
lastTimestamp := float64(24)*0.08 + 0.08 // 2.0s
slowFrames = append(slowFrames, SVGFrame{
Lines: []string{"$ hello"},
CursorX: 7,
CursorY: 0,
Timestamp: lastTimestamp,
CharWidth: 8.8,
CharHeight: 20,
})

opts := createTestSVGConfig()
// Duration must come from the last frame's timestamp (as MakeSVG now does),
// not from len(frames)/framerate which would give 26/50 = 0.52s.
opts.Duration = slowFrames[len(slowFrames)-1].Timestamp
opts.Frames = slowFrames

gen := NewSVGGenerator(opts)
svg := gen.Generate()

// The animation should play for ~2s, not 0.52s (len/framerate).
assertContains(t, svg, "animation: slide 2s", "Animation duration reflects real elapsed time")

// The keyframe for the new state ($ hello) should appear at ~100% since
// it's the last frame. The preceding Sleep frames are deduplicated but
// their duration is preserved via the percentage gap.
assertNotContains(t, svg, "animation: slide 0.52s", "Duration must not use len/framerate")
}

// Animation and Timing Tests
func TestSVGGenerator_AnimationTiming(t *testing.T) {
t.Run("applies PlaybackSpeed", func(t *testing.T) {
Expand Down
37 changes: 32 additions & 5 deletions vhs.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,20 @@ func (vhs *VHS) Render() error {
vhs.Options.Video.Style.FontSize = vhs.Options.FontSize
}

// Compute actual capture framerate from wall-clock timestamps so
// ffmpeg uses the right input rate (CanvasToImage is slower than
// the target framerate, so we capture fewer frames than expected).
// NOTE: totalFrames counts PNG frames written to disk, while
// svgFrames may have fewer entries if CaptureSVGFrame failed for
// some frames. We intentionally use totalFrames here because ffmpeg
// consumes the PNG sequence, so its input rate must match the PNG count.
if len(vhs.svgFrames) > 0 {
wallDuration := vhs.svgFrames[len(vhs.svgFrames)-1].Timestamp
if wallDuration > 0 && vhs.totalFrames > 0 {
vhs.Options.Video.ActualFramerate = float64(vhs.totalFrames) / wallDuration
}
}

// Generate the video(s) with the frames.
var cmds []*exec.Cmd
cmds = append(cmds, MakeGIF(vhs.Options.Video))
Expand Down Expand Up @@ -363,7 +377,13 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error {
//nolint: mnd
go func() {
counter := 0
start := time.Now()
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Track wall-clock elapsed recording time so SVG timestamps
// reflect real duration even when CanvasToImage is slower
// than the tick interval (Go's Ticker drops missed ticks).
var svgElapsed time.Duration
var lastFrameTime time.Time
for {
select {
case <-ctx.Done():
Expand All @@ -376,17 +396,24 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error {
close(ch)
return

case <-time.After(interval - time.Since(start)):
// record last attempt
start = time.Now()
case <-ticker.C:

if !vhs.recording {
// Reset frame timer when hidden so we don't count the gap.
lastFrameTime = time.Time{}
continue
}
if vhs.Page == nil {
continue
}

// Accumulate only the time spent while visible.
now := time.Now()
if !lastFrameTime.IsZero() {
svgElapsed += now.Sub(lastFrameTime)
}
lastFrameTime = now

cursor, cursorErr := vhs.CursorCanvas.CanvasToImage("image/png", quality)
text, textErr := vhs.TextCanvas.CanvasToImage("image/png", quality)
if textErr != nil || cursorErr != nil {
Expand Down Expand Up @@ -414,7 +441,7 @@ func (vhs *VHS) Record(ctx context.Context) <-chan error {

// Capture SVG frame data if SVG output is requested
if vhs.Options.Video.Output.SVG != "" {
svgFrame, err := CaptureSVGFrame(vhs.Page, counter, vhs.Options.Video.Framerate)
svgFrame, err := CaptureSVGFrame(vhs.Page, svgElapsed.Seconds())
if err != nil {
log.Printf("Error capturing SVG frame %d: %v", counter, err)
} else if svgFrame != nil {
Expand Down
37 changes: 26 additions & 11 deletions video.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ type VideoOutputs struct {

// VideoOptions is the set of options for converting frames to a GIF.
type VideoOptions struct {
Framerate int
PlaybackSpeed float64
Input string
MaxColors int
Output VideoOutputs
StartingFrame int
Style *StyleOptions
Framerate int
ActualFramerate float64 // Actual capture rate (totalFrames / wallDuration)
PlaybackSpeed float64
Input string
MaxColors int
Output VideoOutputs
StartingFrame int
Style *StyleOptions
}

const (
Expand Down Expand Up @@ -117,12 +118,18 @@ func buildFFopts(opts VideoOptions, targetFile string) []string {
// Input frame options, used no matter what
// Stream 0: text frames
// Stream 1: cursor frames
// Use actual capture rate if available so video duration matches
// wall-clock time even when CanvasToImage is slower than the target.
inputRate := fmt.Sprint(opts.Framerate)
if opts.ActualFramerate > 0 {
inputRate = fmt.Sprintf("%.4f", opts.ActualFramerate)
}
streamBuilder.args = append(streamBuilder.args,
"-y",
"-r", fmt.Sprint(opts.Framerate),
"-r", inputRate,
"-start_number", fmt.Sprint(opts.StartingFrame),
"-i", filepath.Join(opts.Input, textFrameFormat),
"-r", fmt.Sprint(opts.Framerate),
"-r", inputRate,
"-start_number", fmt.Sprint(opts.StartingFrame),
"-i", filepath.Join(opts.Input, cursorFrameFormat),
)
Expand Down Expand Up @@ -183,8 +190,16 @@ func MakeSVG(v *VHS) error {
log.Println(GrayStyle.Render("Creating " + v.Options.Video.Output.SVG + "..."))
ensureDir(v.Options.Video.Output.SVG)

// Calculate total duration based on frame count and framerate
duration := float64(len(v.svgFrames)) / float64(v.Options.Video.Framerate)
// Use wall-clock duration from the last frame's timestamp.
// CanvasToImage is slower than the target frame interval, so we
// capture fewer frames than framerate × duration would predict.
// The SVG animation must span the real elapsed time so playback
// speed is correct, and we pass the actual capture rate to ffmpeg
// so the MP4 matches.
duration := v.svgFrames[len(v.svgFrames)-1].Timestamp
if duration <= 0 {
duration = float64(v.totalFrames) / float64(v.Options.Video.Framerate)
}

// Create SVG config
svgOpts := SVGConfig{
Expand Down