diff --git a/remotion-composer/src/components/charts/KPIGrid.tsx b/remotion-composer/src/components/charts/KPIGrid.tsx index 6c16a7ad67..c6f0ec7d21 100644 --- a/remotion-composer/src/components/charts/KPIGrid.tsx +++ b/remotion-composer/src/components/charts/KPIGrid.tsx @@ -5,6 +5,7 @@ import { useCurrentFrame, useVideoConfig, } from "remotion"; +import { formatKpiValue } from "./format"; interface Metric { label: string; @@ -275,8 +276,7 @@ const KPICardContent: React.FC = ({ config: { damping: 18, stiffness: 60 }, }); - const displayValue = Math.round(metric.value * countProgress); - const formattedValue = formatDisplayValue(displayValue, metric.value); + const formattedValue = formatKpiValue(metric.value, countProgress); // Change indicator animation const changeOpacity = interpolate( @@ -353,16 +353,3 @@ const KPICardContent: React.FC = ({ ); }; - -/** - * Format the animated counter value to match the scale of the target value. - */ -function formatDisplayValue(current: number, target: number): string { - if (target >= 1_000_000) { - return `${(current / 1_000_000).toFixed(1)}M`; - } - if (target >= 1_000) { - return `${(current / 1_000).toFixed(1)}K`; - } - return String(current); -} diff --git a/remotion-composer/src/components/charts/format.ts b/remotion-composer/src/components/charts/format.ts new file mode 100644 index 0000000000..b1690ee22f --- /dev/null +++ b/remotion-composer/src/components/charts/format.ts @@ -0,0 +1,23 @@ +// Number formatting for the chart components. +// +// A KPI keeps the precision it was written with. Rounding the count-up to an integer +// showed 1.76 as "2", and remotion's spring settles at 0.9999999999999998, so 12.5 +// stayed at "12" on every frame. + +/** Decimal places a value is written with; 20 is the limit of toFixed. */ +export const decimalsOf = (n: number): number => { + if (!Number.isFinite(n) || Number.isInteger(n)) return 0; + const [mantissa, exponent] = String(n).split("e"); + const fraction = (mantissa.split(".")[1] ?? "").length; + return Math.min(Math.max(fraction - Number(exponent ?? 0), 0), 20); +}; + +/** KPI counter at `progress` (0..1). Scale and digits come from the target, so they never jump. */ +export function formatKpiValue(target: number, progress: number): string { + const decimals = decimalsOf(target); + const factor = 10 ** decimals; + const current = Math.round(target * progress * factor) / factor; + if (target >= 1_000_000) return `${(current / 1_000_000).toFixed(1)}M`; + if (target >= 1_000) return `${(current / 1_000).toFixed(1)}K`; + return current.toFixed(decimals); +} diff --git a/tests/contracts/test_kpi_grid_value_format_contract.py b/tests/contracts/test_kpi_grid_value_format_contract.py new file mode 100644 index 0000000000..9758383b5e --- /dev/null +++ b/tests/contracts/test_kpi_grid_value_format_contract.py @@ -0,0 +1,84 @@ +"""KPIGrid's count-up shows each value at the precision it was written with.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +FORMAT = Path(__file__).resolve().parents[2] / "remotion-composer/src/components/charts/format.ts" +# Where remotion's spring() settles for KPIGrid's two configs: just under 1. +SPRING_ENDS = (0.9999999999999998, 0.9999999999999999) + + +def _format(calls: list[tuple[float, float]]) -> list[str]: + """Run the production formatter in Node, not a copy of it.""" + node = shutil.which("node") + if not node: + pytest.skip("Node unavailable") + script = ( + f"import {{ formatKpiValue }} from {json.dumps(FORMAT.as_uri())};" + f"console.log(JSON.stringify({json.dumps(calls)}.map(([t, p]) => formatKpiValue(t, p))));" + ) + proc = subprocess.run( + [node, "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", script], + capture_output=True, + text=True, + ) + if proc.returncode != 0 and "bad option" in proc.stderr: + pytest.skip("This Node cannot strip TypeScript types") + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize( + "target, shown", + [ + (12.5, "12.5"), + (1.76, "1.76"), + (0.72, "0.72"), + (0.4, "0.4"), + (42, "42"), + (14_000, "14.0K"), + (2_500_000, "2.5M"), + ], +) +def test_final_value_keeps_its_precision(target, shown) -> None: + assert _format([(target, end) for end in SPRING_ENDS] + [(target, 1)]) == [shown] * 3 + + +def test_count_up_keeps_the_target_digits_and_scale() -> None: + assert _format([(1.76, 0), (1.76, 0.5), (14_000, 0.5), (2_500_000, 0.5)]) == [ + "0.00", "0.88", "7.0K", "1.3M", + ] + + +def test_rendered_card_shows_the_decimals(tmp_path) -> None: + """12.5, 1.76 and 0.72 used to render exactly like 12, 2 and 1.""" + image = pytest.importorskip("PIL.Image") + chops = pytest.importorskip("PIL.ImageChops") + composer = FORMAT.parents[3] + if not (shutil.which("npx") and (composer / "node_modules").exists()): + pytest.skip("Remotion is not installed") + bundle = tmp_path / "bundle" + subprocess.run( + ["npx", "remotion", "bundle", "src/index.tsx", f"--out-dir={bundle}", "--log=error"], + cwd=composer, check=True, capture_output=True, + ) + + def still(values: list[float]): + cut = { + "id": "k", "in_seconds": 0, "out_seconds": 6, "type": "kpi_grid", "title": "KPI", + "chartData": [{"label": f"M{i}", "value": v} for i, v in enumerate(values)], + } + props = tmp_path / "props.json" + props.write_text(json.dumps({"cuts": [cut], "theme": "clean-professional"})) + png = tmp_path / f"{len(list(tmp_path.glob('*.png')))}.png" + subprocess.run( + ["npx", "remotion", "still", str(bundle), "Explainer", str(png), f"--props={props}", + "--frame=120", "--log=error"], + cwd=composer, check=True, capture_output=True, + ) + return image.open(png).convert("RGB") + + assert chops.difference(still([12.5, 1.76, 0.72]), still([12, 2, 1])).getbbox() is not None