diff --git a/src/VcrSharp.Core/Session/SessionOptions.cs b/src/VcrSharp.Core/Session/SessionOptions.cs
index 93efd6e..af35c78 100644
--- a/src/VcrSharp.Core/Session/SessionOptions.cs
+++ b/src/VcrSharp.Core/Session/SessionOptions.cs
@@ -50,6 +50,15 @@ public class SessionOptions
///
public bool FitToContent { get; set; }
+ ///
+ /// Gets or sets whether fit-to-content crops only the height, keeping the full grid
+ /// width (Cols × cell width). Set via Set Size "fit-height". Use this to keep a
+ /// uniform width across a batch of screenshots — so apparent font size stays constant
+ /// when they are scaled to a fixed display width — while still trimming trailing blank
+ /// rows. Implies . Defaults to false.
+ ///
+ public bool FitHeightOnly { get; set; }
+
// Font Settings
///
@@ -625,9 +634,14 @@ private static void ApplySetting(SessionOptions options, string name, object val
case "mode": // capture mode: "animated" (default, capture frames) or "static" (one settled frame)
options.StaticOutput = string.Equals(value.ToString(), "static", StringComparison.OrdinalIgnoreCase);
break;
- case "size": // canvas sizing: "grid" (default, exact Cols×Rows) or "fit" (crop to content + scale)
- options.FitToContent = string.Equals(value.ToString(), "fit", StringComparison.OrdinalIgnoreCase);
+ case "size": // canvas sizing: "grid" (viewport), "fit" (crop to content), "fit-height" (keep Cols width, crop height)
+ {
+ var size = (value.ToString() ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty).Trim();
+ options.FitHeightOnly = size.Equals("fitheight", StringComparison.OrdinalIgnoreCase)
+ || size.Equals("fitrows", StringComparison.OrdinalIgnoreCase);
+ options.FitToContent = size.StartsWith("fit", StringComparison.OrdinalIgnoreCase);
break;
+ }
}
}
diff --git a/src/VcrSharp.Core/Settings/SettingDeprecations.cs b/src/VcrSharp.Core/Settings/SettingDeprecations.cs
index f4f43ca..992d242 100644
--- a/src/VcrSharp.Core/Settings/SettingDeprecations.cs
+++ b/src/VcrSharp.Core/Settings/SettingDeprecations.cs
@@ -33,9 +33,20 @@ public static class SettingDeprecations
new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["Mode"] = new[] { "animated", "static" },
- ["Size"] = new[] { "grid", "fit" },
+ // Size accepts grid | fit | fit-height (alias fit-rows). Separators are normalized away
+ // before comparison (see NormalizeEnumValue), mirroring SessionOptions.ApplySetting, so
+ // "fit-height", "fit_height" and "fitheight" all validate.
+ ["Size"] = new[] { "grid", "fit", "fit-height", "fit-rows" },
};
+ ///
+ /// Strips separators and case so enum-value comparison matches how SessionOptions.ApplySetting
+ /// reads the value (e.g. Set Size "fit-height" ≡ fitheight). Keeps the typo lint from
+ /// false-flagging a spelling the engine actually honors.
+ ///
+ private static string NormalizeEnumValue(string? value) =>
+ (value ?? string.Empty).Replace("-", string.Empty).Replace("_", string.Empty).Trim();
+
///
/// Collects deprecation warnings (and Mode/Size typo warnings) for the commands a user authored.
/// Pass the parsed tape commands (before preset resolution) so line numbers point at the tape.
@@ -55,7 +66,8 @@ public static List Collect(IEnumerable commands)
warnings.Add($"{where}'Set {set.SettingName}' is deprecated — {settingMessage}.");
}
else if (EnumSettings.TryGetValue(set.SettingName, out var allowed)
- && !allowed.Contains(set.Value.ToString(), StringComparer.OrdinalIgnoreCase))
+ && !allowed.Any(a => NormalizeEnumValue(a)
+ .Equals(NormalizeEnumValue(set.Value.ToString()), StringComparison.OrdinalIgnoreCase)))
{
warnings.Add($"{where}'Set {set.SettingName} {set.Value}' is not recognized — use {string.Join(" or ", allowed)}.");
}
diff --git a/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs b/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs
index b998518..213686f 100644
--- a/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs
+++ b/src/VcrSharp.Infrastructure/Rendering/SvgRenderer.cs
@@ -1026,6 +1026,11 @@ private void CalculateDimensions()
///
public void SetContentExtent(int cols, int rows)
{
+ // Fit-height mode keeps the full grid width (Cols) and crops only the height, so a
+ // batch of screenshots shares one width — and thus one apparent font size when scaled
+ // to a fixed display width. Grow, never shrink, so content wider than Cols isn't clipped.
+ if (_options.FitHeightOnly && _options.Cols.HasValue)
+ cols = Math.Max(cols, _options.Cols.Value);
_cropCols = Math.Max(cols, 1);
_cropRows = Math.Max(rows, 1);
CalculateDimensions();
diff --git a/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs b/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs
index 221c193..0063209 100644
--- a/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs
+++ b/src/VcrSharp.Infrastructure/Rendering/SvgWriter.cs
@@ -51,9 +51,19 @@ public static async Task WriteAnimatedAsync(IReadOnlyList
/// Down-samples a timestamp-ordered state stream to at most frames per second
- /// by keeping the first state in each 1/fps window and skipping everything until the window elapses.
- /// The final state is always kept so the settled end-of-recording output is never dropped. Returns
- /// the input unchanged when there is nothing to thin (fps <= 0 or two-or-fewer states).
+ /// by keeping the LAST state in each 1/fps window (plus the first and final states). Returns the
+ /// input unchanged when there is nothing to thin (fps <= 0 or two-or-fewer states).
+ ///
+ /// Keeping the last — not the first — state in each window is what makes this safe for the
+ /// event-driven capture stream. A screen redraw (a table scrolling in, a TUI repaint) is captured
+ /// as a short-lived torn intermediate frame immediately followed by the settled frame, often only
+ /// ~10–15 ms apart. Both fall inside one 1/fps window. Keeping the first would freeze the
+ /// torn frame for the whole window (~one display slot, e.g. a 2 s plateau) — visible corruption
+ /// where rows from two different screens overlap. Keeping the last drops the transient and shows the
+ /// settled screen, which is also what the un-quantized raster/GIF path effectively displays (the tear
+ /// only ever flashes for its true sub-frame duration there). The final settled frame is always kept so
+ /// end-of-recording output survives.
+ ///
///
internal static List QuantizeToFramerate(List states, int fps)
{
@@ -61,16 +71,22 @@ internal static List QuantizeToFramerate(List(states.Count);
- var lastKept = double.NegativeInfinity;
+
+ static long Window(double ts, double minInterval) => (long)(ts / minInterval);
for (var i = 0; i < states.Count; i++)
{
+ // Keep the first state (so t=0 already shows content) and the last state (settled end).
+ // Otherwise keep a state only when it is the last one in its frame window — i.e. the next
+ // state belongs to a later window. A state superseded within the same window is a transient
+ // mid-redraw frame and is skipped in favor of the settled state it resolves into.
+ var isFirst = i == 0;
var isLast = i == states.Count - 1;
- if (isLast || states[i].TimestampSeconds - lastKept >= minInterval)
- {
+ var lastInWindow = isLast ||
+ Window(states[i + 1].TimestampSeconds, minInterval) > Window(states[i].TimestampSeconds, minInterval);
+
+ if (isFirst || lastInWindow)
result.Add(states[i]);
- lastKept = states[i].TimestampSeconds;
- }
}
return result;
diff --git a/tests/VcrSharp.Core.Tests/Rendering/SvgQuantizeTests.cs b/tests/VcrSharp.Core.Tests/Rendering/SvgQuantizeTests.cs
new file mode 100644
index 0000000..53ffc48
--- /dev/null
+++ b/tests/VcrSharp.Core.Tests/Rendering/SvgQuantizeTests.cs
@@ -0,0 +1,88 @@
+using Shouldly;
+using VcrSharp.Core.Rendering;
+using VcrSharp.Infrastructure.Rendering;
+
+namespace VcrSharp.Core.Tests.Rendering;
+
+///
+/// Tests for , the framerate down-sampler for animated SVG.
+/// The critical contract is that when a settled frame is preceded by a short-lived transient inside the
+/// same frame window (the event-driven capture's mid-redraw tear), the SETTLED frame is the one kept —
+/// otherwise the torn intermediate is frozen for the whole display slot, producing visible corruption.
+///
+public class SvgQuantizeTests
+{
+ private static TerminalStateWithTime State(double ts, string tag = "x")
+ {
+ // Content identity doesn't matter for quantization (it keys purely on timestamps); the tag just
+ // lets a test assert which frame survived.
+ var cells = new[] { new[] { new TerminalCell { Character = tag, Width = 1 } } };
+ return new TerminalStateWithTime
+ {
+ Content = new TerminalContent { Cols = 1, Rows = 1, Cells = cells },
+ TimestampSeconds = ts,
+ };
+ }
+
+ private static string Tag(TerminalStateWithTime s) => s.Content.Cells[0][0].Character!;
+
+ [Fact]
+ public void TransientThenSettled_InSameWindow_KeepsSettled()
+ {
+ // Mirrors the real capture stream: each ~2 s plateau is a short transient tear immediately
+ // followed (here ~14 ms later, inside one 1/50 s = 20 ms window) by the settled frame.
+ const int fps = 50;
+ var states = new List
+ {
+ State(0.000, "settled0"),
+ State(2.000, "torn1"), State(2.014, "settled1"),
+ State(4.000, "torn2"), State(4.013, "settled2"),
+ State(6.000, "torn3"), State(6.015, "settled3"),
+ };
+
+ var kept = SvgWriter.QuantizeToFramerate(states, fps).Select(Tag).ToList();
+
+ // The torn intermediates are dropped; the settled frame of each plateau survives.
+ kept.ShouldBe(new[] { "settled0", "settled1", "settled2", "settled3" });
+ }
+
+ [Fact]
+ public void FinalSettledFrame_IsAlwaysKept()
+ {
+ // The end-of-recording settled frame must survive even when it trails a transient within a window.
+ var states = new List
+ {
+ State(0.000, "a"),
+ State(5.000, "torn"), State(5.012, "final"),
+ };
+
+ SvgWriter.QuantizeToFramerate(states, 50).Select(Tag).Last().ShouldBe("final");
+ }
+
+ [Fact]
+ public void ContinuousFastStream_IsDownsampledTowardFramerate()
+ {
+ // A progress-bar-style emitter that changes every ~16 ms over 2 s should be thinned to roughly
+ // the target framerate (one frame per 1/fps window), not collapsed and not left untouched.
+ const int fps = 25; // 40 ms window
+ var states = new List();
+ for (var i = 0; i <= 120; i++) // 0..1.92 s in 16 ms steps => 121 states
+ states.Add(State(i * 0.016, $"f{i}"));
+
+ var kept = SvgWriter.QuantizeToFramerate(states, fps);
+
+ // ~2 s at 25 fps ≈ 50 frames; allow slack but it must be a real reduction from 121 and not a collapse.
+ kept.Count.ShouldBeGreaterThan(30);
+ kept.Count.ShouldBeLessThan(70);
+ // Timestamps stay strictly increasing and the endpoints are preserved.
+ Tag(kept[0]).ShouldBe("f0");
+ Tag(kept[^1]).ShouldBe("f120");
+ }
+
+ [Fact]
+ public void TwoOrFewerStates_ReturnedUnchanged()
+ {
+ var states = new List { State(0.0, "a"), State(1.0, "b") };
+ SvgWriter.QuantizeToFramerate(states, 50).ShouldBeSameAs(states);
+ }
+}
diff --git a/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs b/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs
index 02e8409..454bfd4 100644
--- a/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs
+++ b/tests/VcrSharp.Core.Tests/Settings/SettingDeprecationsTests.cs
@@ -82,6 +82,16 @@ public void Collect_FlagsModeAndSizeTypos()
.ShouldContain(w => w.Contains("Size") && w.Contains("grid or fit"));
}
+ [Fact]
+ public void Collect_DoesNotFlagFitHeightSizeVariants()
+ {
+ // fit-height (and its separator/case variants and the fit-rows alias) are honored by
+ // ApplySetting, so the typo lint must not flag them.
+ foreach (var value in new[] { "fit-height", "fit_height", "fitheight", "fit-rows" })
+ SettingDeprecations.Collect(Parser.ParseTape($"Set Size \"{value}\"\nExec \"x\""))
+ .ShouldNotContain(w => w.Contains("not recognized"), $"Size {value} should be accepted");
+ }
+
[Fact]
public void Collect_CleanModernTape_NoWarnings()
{