From 2941f689bf4d90e34262a0bfe5977a147fb68970 Mon Sep 17 00:00:00 2001 From: abdulsaboor Date: Thu, 2 Jul 2026 12:52:39 -0500 Subject: [PATCH] [RealtimeKit] Add interactive pricing calculator to pricing page Adds an interactive calculator to the RealtimeKit pricing page so users can estimate monthly cost based on expected participant, export, and transcription minutes. - RTKPricingCalculator: React component with bidirectional sliders, a cost-share donut chart, and a line-item breakdown. Sticky estimate panel matches the input column's height. - pricing-utils: pure pricing math (rate table, piecewise slider mapping, Workers AI neuron billing) extracted for unit testing, independent of React/DOM. - pricing-utils.node.test.ts: Vitest coverage for billing math and slider/minutes round-tripping. --- .../RTKPricingCalculator.astro | 5 + .../RTKPricingCalculator.tsx | 385 ++++++++++++++++++ .../pricing-utils.node.test.ts | 93 +++++ .../RTKPricingCalculator/pricing-utils.ts | 294 +++++++++++++ .../docs/realtime/realtimekit/pricing.mdx | 8 + 5 files changed, 785 insertions(+) create mode 100644 src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.astro create mode 100644 src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.tsx create mode 100644 src/components/realtimekit/RTKPricingCalculator/pricing-utils.node.test.ts create mode 100644 src/components/realtimekit/RTKPricingCalculator/pricing-utils.ts diff --git a/src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.astro b/src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.astro new file mode 100644 index 00000000000..6329352a7a7 --- /dev/null +++ b/src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.astro @@ -0,0 +1,5 @@ +--- +import PricingCalculator from "./RTKPricingCalculator.tsx"; +--- + + diff --git a/src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.tsx b/src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.tsx new file mode 100644 index 00000000000..960d93340e9 --- /dev/null +++ b/src/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.tsx @@ -0,0 +1,385 @@ +import { useMemo, useState } from "react"; +import { track } from "~/util/zaraz"; +import { + EMPTY_USAGE_INPUTS, + USAGE_ROWS, + type UsageInputs, + type UsageRowConfig, + type UsageRowKey, + computeEstimate, + formatMoney, + formatNumber, + includedZoneEnd, + minutesToSliderForRow, + sliderToMinutesForRow, +} from "./pricing-utils"; + +const GROUP_COLOR: Record = { + video: "#f6821f", + audio: "#c05d08", + export: "#521000", + transcription: "#a37b71", +}; + +const GROUP_LABEL: Record = { + video: "A/V users", + audio: "Audio-only", + export: "Export", + transcription: "Transcribe", +}; + +const GROUPS: UsageRowConfig["group"][] = [ + "video", + "audio", + "export", + "transcription", +]; + +function trackInteraction() { + track("interacted with docs calculator", { + value: "realtimekit pricing calculator", + }); +} + +function sliderScaleLabels(row: UsageRowKey): string[] { + switch (row) { + case "av": + case "ao": + return ["0", "10k", "100k", "1M+"]; + case "ev": + case "eo": + case "er": + return ["0", "100", "5k", "50k+"]; + default: + return ["0", "10k", "100k+"]; + } +} + +function formatRate(rate: number): string { + return rate.toString(); +} + +interface UsageSliderRowProps { + row: UsageRowConfig; + minutes: number; + onChange: (minutes: number) => void; +} + +function UsageSliderRow({ row, minutes, onChange }: UsageSliderRowProps) { + const sliderValue = minutesToSliderForRow(row.key, minutes); + const hasFreeTier = row.included !== undefined; + const freeZoneEnd = hasFreeTier ? includedZoneEnd(row.key) : 0; + const inputId = `rtk-pricing-${row.key}`; + + const trackBackground = hasFreeTier + ? `linear-gradient(to right, var(--color-cl1-orange-7) 0%, var(--color-cl1-orange-7) ${freeZoneEnd}%, var(--color-cl1-gray-8) ${freeZoneEnd}%, var(--color-cl1-gray-8) 100%)` + : "var(--color-cl1-gray-8)"; + + return ( +
+
+ + + {hasFreeTier ? ( + <> + + {formatNumber(row.included ?? 0)} min free + + , then ${formatRate(row.rate ?? 0)}/min + + ) : ( + row.description + )} + +
+
+ { + onChange( + sliderToMinutesForRow(row.key, Number(event.target.value)), + ); + }} + onMouseUp={trackInteraction} + onTouchEnd={trackInteraction} + onKeyUp={trackInteraction} + className="[&::-moz-range-thumb]:bg-cl1-brand-orange [&::-webkit-slider-thumb]:bg-cl1-brand-orange h-1.5 flex-grow cursor-pointer appearance-none rounded-full outline-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-sm" + /> + +
+
+ {sliderScaleLabels(row.key).map((label) => ( + {label} + ))} +
+
+ ); +} + +function SectionCard({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +export default function RTKPricingCalculator() { + const [inputs, setInputs] = useState(EMPTY_USAGE_INPUTS); + const [transcriptionDays, setTranscriptionDays] = useState(30); + + const estimate = useMemo( + () => computeEstimate(inputs, transcriptionDays), + [inputs, transcriptionDays], + ); + + const circumference = 2 * Math.PI * 40; + const total = estimate.total; + + function updateMinutes(row: UsageRowKey, minutes: number) { + setInputs((prev) => ({ ...prev, [row]: minutes })); + } + + const participantRows = USAGE_ROWS.filter( + (row) => row.key === "av" || row.key === "ao", + ); + const exportRows = USAGE_ROWS.filter((row) => + ["ev", "eo", "er"].includes(row.key), + ); + const transcriptionRows = USAGE_ROWS.filter( + (row) => row.key === "rt" || row.key === "pt", + ); + + let offset = 0; + const donutSegments = GROUPS.map((group) => { + const share = total > 0 ? estimate.costShares[group] / total : 0; + const dash = share * circumference; + const segment = { group, dash, offset }; + offset -= dash; + return segment; + }); + + return ( +
+
+
+ + {participantRows.map((row) => ( + updateMinutes(row.key, minutes)} + /> + ))} + + + + {exportRows.map((row) => ( + updateMinutes(row.key, minutes)} + /> + ))} + + + + {transcriptionRows.map((row) => ( + updateMinutes(row.key, minutes)} + /> + ))} +
+
+ + + Workers AI includes 10,000 free Neurons/day + +
+
+ + setTranscriptionDays(Number(event.target.value)) + } + onMouseUp={trackInteraction} + onTouchEnd={trackInteraction} + onKeyUp={trackInteraction} + className="bg-cl1-gray-8 [&::-moz-range-thumb]:bg-cl1-brand-orange [&::-webkit-slider-thumb]:bg-cl1-brand-orange h-1.5 flex-grow cursor-pointer appearance-none rounded-full outline-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-sm" + /> + +
+
+
+
+ +
+ +
+

+ RealtimeKit is currently in Beta and free to use. This calculator + estimates costs based on the pricing model planned for general + availability (GA) and is provided for informational purposes only — all + results are estimates and actual GA pricing may differ. +

+
+ ); +} diff --git a/src/components/realtimekit/RTKPricingCalculator/pricing-utils.node.test.ts b/src/components/realtimekit/RTKPricingCalculator/pricing-utils.node.test.ts new file mode 100644 index 00000000000..29bb60a0658 --- /dev/null +++ b/src/components/realtimekit/RTKPricingCalculator/pricing-utils.node.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + EMPTY_USAGE_INPUTS, + computeEstimate, + includedZoneEnd, + minutesToSliderForRow, + sliderToMinutesForRow, +} from "./pricing-utils"; + +describe("computeEstimate", () => { + it("returns zero cost when there is no usage", () => { + const estimate = computeEstimate(EMPTY_USAGE_INPUTS, 30); + expect(estimate.total).toBe(0); + expect(estimate.breakdown).toHaveLength(0); + }); + + it("only bills participant minutes beyond the included allowance", () => { + const estimate = computeEstimate({ ...EMPTY_USAGE_INPUTS, av: 10_000 }, 30); + expect(estimate.total).toBe(0); + + const overIncluded = computeEstimate( + { ...EMPTY_USAGE_INPUTS, av: 11_000 }, + 30, + ); + expect(overIncluded.total).toBeCloseTo(1000 * 0.002, 6); + expect(overIncluded.costShares.video).toBeCloseTo(2, 6); + }); + + it("bills export minutes at the correct group rate", () => { + const estimate = computeEstimate({ ...EMPTY_USAGE_INPUTS, ev: 1_100 }, 30); + // 1,100 - 100 included = 1,000 billable minutes at $0.010/min + expect(estimate.costShares.export).toBeCloseTo(10, 6); + expect(estimate.total).toBeCloseTo(10, 6); + }); + + it("only bills Workers AI neurons beyond the free daily allowance", () => { + const withinFreeTier = computeEstimate( + { ...EMPTY_USAGE_INPUTS, rt: 100 }, + 30, + ); + // 100 min * 836.36 neurons/min is well within 10,000 * 30 free neurons + expect(withinFreeTier.total).toBe(0); + expect(withinFreeTier.billableNeurons).toBe(0); + + const overFreeTier = computeEstimate( + { ...EMPTY_USAGE_INPUTS, rt: 100_000 }, + 1, + ); + expect(overFreeTier.billableNeurons).toBeGreaterThan(0); + expect(overFreeTier.total).toBeGreaterThan(0); + expect(overFreeTier.costShares.transcription).toBeCloseTo( + overFreeTier.total, + 6, + ); + }); + + it("annualizes the monthly total", () => { + const estimate = computeEstimate({ ...EMPTY_USAGE_INPUTS, av: 50_000 }, 30); + expect(estimate.annualized).toBeCloseTo(estimate.total * 12, 6); + }); +}); + +describe("slider <-> minutes mapping", () => { + it("round-trips participant minutes through the slider and back", () => { + for (const minutes of [0, 10_000, 55_000, 100_000, 500_000, 1_000_000]) { + const slider = minutesToSliderForRow("av", minutes); + const roundTripped = sliderToMinutesForRow("av", slider); + expect(roundTripped).toBeCloseTo(minutes, -1); + } + }); + + it("round-trips export minutes through the slider and back", () => { + for (const minutes of [0, 100, 2_500, 5_000, 25_000, 50_000]) { + const slider = minutesToSliderForRow("ev", minutes); + const roundTripped = sliderToMinutesForRow("ev", slider); + expect(roundTripped).toBeCloseTo(minutes, -1); + } + }); + + it("round-trips transcription minutes through the slider and back", () => { + for (const minutes of [0, 5_000, 10_000, 50_000, 100_000]) { + const slider = minutesToSliderForRow("rt", minutes); + const roundTripped = sliderToMinutesForRow("rt", slider); + expect(roundTripped).toBeCloseTo(minutes, -1); + } + }); + + it("marks the included free zone at the expected slider position", () => { + expect(includedZoneEnd("av")).toBe(30); + expect(includedZoneEnd("ev")).toBe(30); + expect(includedZoneEnd("rt")).toBe(50); + }); +}); diff --git a/src/components/realtimekit/RTKPricingCalculator/pricing-utils.ts b/src/components/realtimekit/RTKPricingCalculator/pricing-utils.ts new file mode 100644 index 00000000000..59312f1f44f --- /dev/null +++ b/src/components/realtimekit/RTKPricingCalculator/pricing-utils.ts @@ -0,0 +1,294 @@ +/** + * Pure pricing math for the RealtimeKit pricing calculator. + * + * Kept free of React/DOM so it can be unit tested independently and reused + * if the calculator ever needs a non-React host (e.g. a different framework + * or a server-rendered estimate). + * + * Rates mirror /realtime/realtimekit/pricing/. Update both together. + */ + +export type UsageRowKey = "av" | "ao" | "ev" | "eo" | "er" | "rt" | "pt"; + +export interface UsageRowConfig { + key: UsageRowKey; + label: string; + description: string; + /** $ per minute once the included allowance is exceeded. Omit for Workers AI rows. */ + rate?: number; + /** Minutes included for free each month. Omit for Workers AI rows. */ + included?: number; + /** Workers AI neurons consumed per minute of audio. Omit for flat-rate rows. */ + neuronsPerMinute?: number; + /** Which cost-share bucket this row rolls up into for the donut chart. */ + group: "video" | "audio" | "export" | "transcription"; +} + +export const USAGE_ROWS: UsageRowConfig[] = [ + { + key: "av", + label: "Audio/video participants", + description: "$0.002/min after 10,000 min included", + rate: 0.002, + included: 10_000, + group: "video", + }, + { + key: "ao", + label: "Audio-only participants", + description: "$0.0005/min after 10,000 min included", + rate: 0.0005, + included: 10_000, + group: "audio", + }, + { + key: "ev", + label: "Audio/video recordings, RTMP, or HLS", + description: "$0.010/min after 100 min included", + rate: 0.01, + included: 100, + group: "export", + }, + { + key: "eo", + label: "Audio-only recordings, RTMP, or HLS", + description: "$0.003/min after 100 min included", + rate: 0.003, + included: 100, + group: "export", + }, + { + key: "er", + label: "Raw RTP into R2", + description: "$0.0005/min after 100 min included", + rate: 0.0005, + included: 100, + group: "export", + }, + { + key: "rt", + label: "Real-time transcription", + description: "Workers AI pricing, ~ $0.0092/audio min", + neuronsPerMinute: 836.36, + group: "transcription", + }, + { + key: "pt", + label: "Post-meeting transcription", + description: "Workers AI pricing, ~ $0.0005/audio min", + neuronsPerMinute: 46.63, + group: "transcription", + }, +]; + +export const WORKERS_AI_FREE_NEURONS_PER_DAY = 10_000; +export const WORKERS_AI_NEURON_RATE = 0.011 / 1000; + +export type UsageInputs = Record; + +export const EMPTY_USAGE_INPUTS: UsageInputs = { + av: 0, + ao: 0, + ev: 0, + eo: 0, + er: 0, + rt: 0, + pt: 0, +}; + +/** Piecewise breakpoints shared by the three slider families below. */ +interface PiecewiseSegment { + /** Slider position (0-100) where this segment ends. */ + sliderMax: number; + /** Minutes value where this segment ends. */ + minutesMax: number; +} + +function sliderToMinutes(value: number, segments: PiecewiseSegment[]): number { + const clamped = Math.max(0, Math.min(100, value)); + let sliderMin = 0; + let minutesMin = 0; + + for (const segment of segments) { + if (clamped <= segment.sliderMax) { + const ratio = + segment.sliderMax === sliderMin + ? 0 + : (clamped - sliderMin) / (segment.sliderMax - sliderMin); + return Math.round(minutesMin + ratio * (segment.minutesMax - minutesMin)); + } + sliderMin = segment.sliderMax; + minutesMin = segment.minutesMax; + } + + return segments[segments.length - 1].minutesMax; +} + +function minutesToSlider( + minutes: number, + segments: PiecewiseSegment[], +): number { + const clamped = Math.max(0, minutes); + let sliderMin = 0; + let minutesMin = 0; + + for (const segment of segments) { + if (clamped <= segment.minutesMax) { + if (segment.minutesMax === minutesMin) return segment.sliderMax; + const ratio = (clamped - minutesMin) / (segment.minutesMax - minutesMin); + return sliderMin + ratio * (segment.sliderMax - sliderMin); + } + sliderMin = segment.sliderMax; + minutesMin = segment.minutesMax; + } + + return 100; +} + +// Participant minutes: 0-30 => 0-10k (included zone), 30-70 => 10k-100k, 70-100 => 100k-1M +const PARTICIPANT_SEGMENTS: PiecewiseSegment[] = [ + { sliderMax: 30, minutesMax: 10_000 }, + { sliderMax: 70, minutesMax: 100_000 }, + { sliderMax: 100, minutesMax: 1_000_000 }, +]; + +// Export minutes: 0-30 => 0-100 (included zone), 30-70 => 100-5k, 70-100 => 5k-50k +const EXPORT_SEGMENTS: PiecewiseSegment[] = [ + { sliderMax: 30, minutesMax: 100 }, + { sliderMax: 70, minutesMax: 5_000 }, + { sliderMax: 100, minutesMax: 50_000 }, +]; + +// Transcription minutes: 0-50 => 0-10k, 50-100 => 10k-100k +const TRANSCRIPTION_SEGMENTS: PiecewiseSegment[] = [ + { sliderMax: 50, minutesMax: 10_000 }, + { sliderMax: 100, minutesMax: 100_000 }, +]; + +const SEGMENTS_BY_ROW: Record = { + av: PARTICIPANT_SEGMENTS, + ao: PARTICIPANT_SEGMENTS, + ev: EXPORT_SEGMENTS, + eo: EXPORT_SEGMENTS, + er: EXPORT_SEGMENTS, + rt: TRANSCRIPTION_SEGMENTS, + pt: TRANSCRIPTION_SEGMENTS, +}; + +export function sliderToMinutesForRow( + row: UsageRowKey, + sliderValue: number, +): number { + return sliderToMinutes(sliderValue, SEGMENTS_BY_ROW[row]); +} + +export function minutesToSliderForRow( + row: UsageRowKey, + minutes: number, +): number { + return minutesToSlider(minutes, SEGMENTS_BY_ROW[row]); +} + +/** The slider position (0-100) marking the end of the free/included zone, for visual affordance. */ +export function includedZoneEnd(row: UsageRowKey): number { + return SEGMENTS_BY_ROW[row][0].sliderMax; +} + +export interface BreakdownRow { + label: string; + cost: number; +} + +export interface CostShares { + video: number; + audio: number; + export: number; + transcription: number; +} + +export interface PricingEstimate { + total: number; + annualized: number; + costShares: CostShares; + breakdown: BreakdownRow[]; + billableNeurons: number; + totalNeurons: number; +} + +export function computeEstimate( + inputs: UsageInputs, + transcriptionDays: number, +): PricingEstimate { + const days = Math.min(31, Math.max(1, transcriptionDays)); + const freeNeurons = WORKERS_AI_FREE_NEURONS_PER_DAY * days; + + const costShares: CostShares = { + video: 0, + audio: 0, + export: 0, + transcription: 0, + }; + const breakdown: BreakdownRow[] = []; + + let total = 0; + let totalNeurons = 0; + const neuronRows: { label: string; neurons: number }[] = []; + + for (const row of USAGE_ROWS) { + const minutes = Math.max(0, inputs[row.key] ?? 0); + + if (row.neuronsPerMinute !== undefined) { + const neurons = minutes * row.neuronsPerMinute; + totalNeurons += neurons; + neuronRows.push({ label: row.label, neurons }); + continue; + } + + const included = row.included ?? 0; + const rate = row.rate ?? 0; + const billableMinutes = Math.max(0, minutes - included); + const cost = billableMinutes * rate; + total += cost; + costShares[row.group] += cost; + + if (minutes > 0 || cost > 0) { + breakdown.push({ label: row.label, cost }); + } + } + + const billableNeurons = Math.max(0, totalNeurons - freeNeurons); + const transcriptionCost = billableNeurons * WORKERS_AI_NEURON_RATE; + total += transcriptionCost; + costShares.transcription += transcriptionCost; + + for (const { label, neurons } of neuronRows) { + const rowCost = + totalNeurons > 0 ? transcriptionCost * (neurons / totalNeurons) : 0; + if (neurons > 0 || rowCost > 0) { + breakdown.push({ label, cost: rowCost }); + } + } + + return { + total, + annualized: total * 12, + costShares, + breakdown, + billableNeurons, + totalNeurons, + }; +} + +export function formatMoney(value: number): string { + const formatted = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); + return formatted.replace(/\.00$/, ""); +} + +export function formatNumber(value: number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format( + value, + ); +} diff --git a/src/content/docs/realtime/realtimekit/pricing.mdx b/src/content/docs/realtime/realtimekit/pricing.mdx index f47989251c5..ad6aa6cb66e 100644 --- a/src/content/docs/realtime/realtimekit/pricing.mdx +++ b/src/content/docs/realtime/realtimekit/pricing.mdx @@ -7,6 +7,8 @@ sidebar: order: 17 --- +import RTKPricingCalculator from "~/components/realtimekit/RTKPricingCalculator/RTKPricingCalculator.astro"; + Cloudflare RealtimeKit is currently in Beta and is available at no cost during this period. When RealtimeKit reaches general availability (GA), usage will be charged according to the pricing model below: @@ -21,3 +23,9 @@ When RealtimeKit reaches general availability (GA), usage will be charged accord | Transcription (Real-time) | Standard model pricing via Workers AI | Whether a participant is an audio-only participant or an audio/video participant is determined by the `Meeting Type` of their [preset](/realtime/realtimekit/concepts/preset/). + +## Pricing calculator + +Use the calculator below to estimate your monthly cost based on expected usage. + +