diff --git a/svg.go b/svg.go index 64828f84..41d0c81e 100644 --- a/svg.go +++ b/svg.go @@ -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 @@ -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 @@ -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 @@ -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; @@ -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, } diff --git a/svg_test.go b/svg_test.go index 487f6cc4..28662e06 100644 --- a/svg_test.go +++ b/svg_test.go @@ -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) { diff --git a/vhs.go b/vhs.go index fc1b5893..c55a9a6a 100644 --- a/vhs.go +++ b/vhs.go @@ -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)) @@ -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(): @@ -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 { @@ -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 { diff --git a/video.go b/video.go index d90168b0..abe9de5a 100644 --- a/video.go +++ b/video.go @@ -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 ( @@ -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), ) @@ -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{