diff --git a/packages/app/src/app/compare-per-dollar/[slug]/page.tsx b/packages/app/src/app/compare-per-dollar/[slug]/page.tsx index 5e58e807..deede577 100644 --- a/packages/app/src/app/compare-per-dollar/[slug]/page.tsx +++ b/packages/app/src/app/compare-per-dollar/[slug]/page.tsx @@ -14,9 +14,11 @@ import { import { getAllComparableCompareSlugs } from '@/lib/compare-availability'; import { getGpuSpecs } from '@/lib/constants'; import { + buildBreadcrumbJsonLd, buildJsonLd, compareTableNarrative, computeCompareTableData, + dateRangeForPair, getCachedBenchmarks, KNOWN_MODELS, KNOWN_PRECISIONS, @@ -121,6 +123,7 @@ export default async function ComparePerDollarPage({ params, searchParams }: Pro const url = `${SITE_URL}/compare-per-dollar/${canonical}`; const imageUrl = `${url}/performance-per-dollar.png`; + const { oldest, newest } = dateRangeForPair(rows, parsed.a, parsed.b); const jsonLd = buildJsonLd( 'per-dollar', parsed.model, @@ -131,6 +134,14 @@ export default async function ComparePerDollarPage({ params, searchParams }: Pro summaryB, ssrRows, imageUrl, + oldest, + newest, + parsed.model.displayName, + ); + const breadcrumbJsonLd = buildBreadcrumbJsonLd( + 'per-dollar', + compareModelDisplayLabel(parsed.model, parsed.a, parsed.b), + url, ); const label = compareModelDisplayLabel(parsed.model, parsed.a, parsed.b); const aMeta = HW_REGISTRY[parsed.a]; @@ -155,6 +166,7 @@ export default async function ComparePerDollarPage({ params, searchParams }: Pro return ( <> + = 10) return `$${value.toFixed(1)}`; if (value >= 1) return `$${value.toFixed(2)}`; return `$${value.toFixed(3)}`; } +/** Decimals chosen from the tick step so every label in the axis prints with + * the same precision (no $0.000/$9.01/$18.0 mix). */ +function decimalsForStep(step: number): number { + if (step >= 1) return 0; + return Math.max(0, Math.ceil(-Math.log10(step))); +} + +function moneyForStep(value: number, step: number): string { + return `$${value.toFixed(decimalsForStep(step))}`; +} + +/** "Nice" step in the 1/2/5 × 10ⁿ family, the same convention d3 uses. */ +function niceStep(span: number, targetCount: number): number { + const rawStep = span / Math.max(1, targetCount - 1); + const mag = 10 ** Math.floor(Math.log10(rawStep)); + const normalized = rawStep / mag; + if (normalized < 1.5) return mag; + if (normalized < 3) return 2 * mag; + if (normalized < 7) return 5 * mag; + return 10 * mag; +} + +function niceAxis( + min: number, + max: number, + targetCount = 5, +): { min: number; max: number; step: number; ticks: number[] } { + if (max <= min) return { min, max: min + 1, step: 1, ticks: [min] }; + const step = niceStep(max - min, targetCount); + const niceMin = Math.floor(min / step) * step; + const niceMax = Math.ceil(max / step) * step; + const ticks: number[] = []; + for (let t = niceMin; t <= niceMax + step * 1e-6; t += step) { + ticks.push(Number(t.toFixed(10))); + } + return { min: niceMin, max: niceMax, step, ticks }; +} + function pointsPath(points: Point[]): string { return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '); } @@ -75,6 +118,7 @@ export async function GET( sequence, precision, interactivityRange, + plottedRows.map((r) => r.target), ).filter((row) => row.a || row.b); const curveRows = imageRows.length > 0 ? imageRows : plottedRows; @@ -85,11 +129,16 @@ export async function GET( .filter((cost): cost is number => typeof cost === 'number' && Number.isFinite(cost)); const costMin = costs.length > 0 ? Math.min(...costs) : 0; const costMax = costs.length > 0 ? Math.max(...costs) : 1; - const costPadding = Math.max((costMax - costMin) * 0.18, costMax * 0.08, 0.02); - const yMin = Math.max(0, costMin - costPadding); - const yMax = costMax + costPadding; + const yAxis = niceAxis(Math.min(0, costMin), costMax); + const yMin = yAxis.min; + const yMax = yAxis.max; + const yStep = yAxis.step; const xMin = curveRows.at(0)?.target ?? 0; const xMax = curveRows.at(-1)?.target ?? 100; + const matchedMin = plottedRows.at(0)?.target ?? xMin; + const matchedMax = plottedRows.at(-1)?.target ?? xMax; + const hasLeftExtension = matchedMin - xMin >= 0.5; + const hasRightExtension = xMax - matchedMax >= 0.5; const scaleX = (value: number) => CHART.left + (xMax === xMin ? CHART.width / 2 : ((value - xMin) / (xMax - xMin)) * CHART.width); const scaleY = (value: number) => @@ -97,42 +146,69 @@ export async function GET( CHART.height - (yMax === yMin ? CHART.height / 2 : ((value - yMin) / (yMax - yMin)) * CHART.height); - const aPoints = curveRows - .filter((row) => row.a) - .map((row) => ({ x: scaleX(row.target), y: scaleY(row.a!.cost) })); - const bPoints = curveRows - .filter((row) => row.b) - .map((row) => ({ x: scaleX(row.target), y: scaleY(row.b!.cost) })); + function buildSeriesPoints(getCost: (row: SsrInterpolatedRow) => number | null): TargetedPoint[] { + return curveRows + .map((row) => ({ target: row.target, cost: getCost(row) })) + .filter((p): p is { target: number; cost: number } => p.cost !== null) + .map((p) => ({ x: scaleX(p.target), y: scaleY(p.cost), target: p.target })); + } + + function splitByMatchRange(points: TargetedPoint[]) { + return { + matched: points.filter((p) => p.target >= matchedMin && p.target <= matchedMax), + leftExt: points.filter((p) => p.target <= matchedMin), + rightExt: points.filter((p) => p.target >= matchedMax), + }; + } + + const aSeries = splitByMatchRange(buildSeriesPoints((r) => r.a?.cost ?? null)); + const bSeries = splitByMatchRange(buildSeriesPoints((r) => r.b?.cost ?? null)); const aHighlightPoints = plottedRows .filter((row) => row.a) .map((row) => ({ x: scaleX(row.target), y: scaleY(row.a!.cost) })); const bHighlightPoints = plottedRows .filter((row) => row.b) .map((row) => ({ x: scaleX(row.target), y: scaleY(row.b!.cost) })); - const yTicks = Array.from({ length: 4 }, (_, index) => yMin + ((yMax - yMin) * index) / 3); const workload = [sequence, precision?.toUpperCase()].filter(Boolean).join(' / '); + const showRangeEndpoints = hasLeftExtension || hasRightExtension; + const svgWidth = 760 * R; + const svgHeight = 406 * R; + + function renderSeriesPath(points: Point[], stroke: string, dashed: boolean) { + if (points.length < 2) return null; + return ( + + ); + } return new ImageResponse(
-
+
InferenceX Performance per Dollar
-
{parsed.model.label}
-
+
+ {parsed.model.label} +
+
{aLabel} vs {bLabel} | Cost per Million Tokens
@@ -151,29 +229,31 @@ export async function GET( display: 'flex', flexDirection: 'column', alignItems: 'flex-end', - border: `1px solid ${COLORS.border}`, - borderRadius: 12, - padding: '13px 17px', + border: `${R}px solid ${COLORS.border}`, + borderRadius: 12 * R, + padding: `${13 * R}px ${17 * R}px`, background: COLORS.panel, - gap: 5, + gap: 5 * R, }} > -
DEFAULT WORKLOAD
-
+
+ DEFAULT WORKLOAD +
+
{workload || 'Default comparison'}
-
+
Lower cost is better
-
-
+
+
- {yTicks.map((tick) => { + {yAxis.ticks.map((tick) => { const y = scaleY(tick); return ( ); })} - {aPoints.length > 1 && ( - - )} - {bPoints.length > 1 && ( - - )} + {plottedRows.map((row) => { + const x = scaleX(row.target); + return ( + + ); + })} + {renderSeriesPath(aSeries.leftExt, COLORS.a, true)} + {renderSeriesPath(aSeries.rightExt, COLORS.a, true)} + {renderSeriesPath(aSeries.matched, COLORS.a, false)} + {renderSeriesPath(bSeries.leftExt, COLORS.b, true)} + {renderSeriesPath(bSeries.rightExt, COLORS.b, true)} + {renderSeriesPath(bSeries.matched, COLORS.b, false)} {aHighlightPoints.map((point, index) => ( ))} {bHighlightPoints.map((point, index) => ( @@ -235,28 +315,28 @@ export async function GET( key={`b-${index}`} cx={point.x} cy={point.y} - r="10" + r={10 * R} fill={COLORS.b} stroke={COLORS.background} - strokeWidth="4" + strokeWidth={4 * R} /> ))} - {yTicks.map((tick) => ( + {yAxis.ticks.map((tick) => (
- {money(tick)} + {moneyForStep(tick, yStep)}
))} {plottedRows.map((row) => ( @@ -265,33 +345,84 @@ export async function GET( style={{ display: 'flex', position: 'absolute', - left: scaleX(row.target) - 32, - top: CHART.top + CHART.height + 15, - width: 64, + left: scaleX(row.target) - 32 * R, + top: CHART.top + CHART.height + 15 * R, + width: 64 * R, justifyContent: 'center', color: COLORS.muted, - fontSize: 16, + fontSize: 16 * R, fontWeight: 600, }} > {row.target}
))} + {showRangeEndpoints && hasLeftExtension && ( +
+ {Math.round(xMin)} +
+ )} + {showRangeEndpoints && hasRightExtension && ( +
+ {Math.round(xMax)} +
+ )}
Interactivity (tok/s/user)
+ {showRangeEndpoints && ( +
+ Dashed segments extend to each SKU's operating envelope, where cost rises steeply +
+ )}
-
+
Matched Interactivity
-
- +
+ {aLabel} - + @@ -339,17 +470,17 @@ export async function GET( style={{ display: 'flex', flexDirection: 'column', - gap: 6, - border: `1px solid ${COLORS.border}`, - borderRadius: 10, - padding: '11px 13px', + gap: 6 * R, + border: `${R}px solid ${COLORS.border}`, + borderRadius: 10 * R, + padding: `${11 * R}px ${13 * R}px`, background: COLORS.panel, }} > -
+
{row.target} tok/s/user
-
+
{row.a ? money(row.a.cost) : 'N/A'} @@ -360,7 +491,7 @@ export async function GET(
)) ) : ( -
+
No matched cost data available.
)} @@ -371,8 +502,8 @@ export async function GET( style={{ display: 'flex', justifyContent: 'space-between', - paddingTop: 9, - fontSize: 15, + paddingTop: 9 * R, + fontSize: 15 * R, color: COLORS.muted, }} > diff --git a/packages/app/src/app/compare/[slug]/page.tsx b/packages/app/src/app/compare/[slug]/page.tsx index 5158d6c9..d6c660ec 100644 --- a/packages/app/src/app/compare/[slug]/page.tsx +++ b/packages/app/src/app/compare/[slug]/page.tsx @@ -13,9 +13,11 @@ import { } from '@/lib/compare-slug'; import { getAllComparableCompareSlugs } from '@/lib/compare-availability'; import { + buildBreadcrumbJsonLd, buildJsonLd, compareTableNarrative, computeCompareTableData, + dateRangeForPair, getCachedBenchmarks, KNOWN_MODELS, KNOWN_PRECISIONS, @@ -134,6 +136,7 @@ export default async function ComparePage({ params, searchParams }: Props) { ); const url = `${SITE_URL}/compare/${canonical}`; + const { oldest, newest } = dateRangeForPair(rows, parsed.a, parsed.b); const jsonLd = buildJsonLd( 'full', parsed.model, @@ -143,6 +146,15 @@ export default async function ComparePage({ params, searchParams }: Props) { summaryA, summaryB, ssrRows, + undefined, + oldest, + newest, + parsed.model.displayName, + ); + const breadcrumbJsonLd = buildBreadcrumbJsonLd( + 'full', + compareModelDisplayLabel(parsed.model, parsed.a, parsed.b), + url, ); const label = compareModelDisplayLabel(parsed.model, parsed.a, parsed.b); const aMeta = HW_REGISTRY[parsed.a]; @@ -161,6 +173,7 @@ export default async function ComparePage({ params, searchParams }: Props) { return ( <> + = {}): BenchmarkRow { + return { + hardware: 'h200', + framework: 'sglang', + model: 'dsr1', + precision: 'fp8', + spec_method: 'none', + disagg: false, + is_multinode: false, + prefill_tp: 8, + prefill_ep: 1, + prefill_dp_attention: false, + prefill_num_workers: 0, + decode_tp: 8, + decode_ep: 1, + decode_dp_attention: false, + decode_num_workers: 0, + num_prefill_gpu: 8, + num_decode_gpu: 8, + isl: 1024, + osl: 1024, + conc: 128, + image: null, + metrics: { tput_per_gpu: 100, median_intvty: 30 }, + date: '2026-03-01', + run_url: null, + ...overrides, + }; +} + +function pairRows(): BenchmarkRow[] { + return [ + stubRow({ hardware: 'h200', conc: 16, metrics: { tput_per_gpu: 800, median_intvty: 10 } }), + stubRow({ hardware: 'h200', conc: 32, metrics: { tput_per_gpu: 600, median_intvty: 20 } }), + stubRow({ hardware: 'h200', conc: 64, metrics: { tput_per_gpu: 400, median_intvty: 30 } }), + stubRow({ hardware: 'h200', conc: 128, metrics: { tput_per_gpu: 200, median_intvty: 40 } }), + stubRow({ hardware: 'b200', conc: 16, metrics: { tput_per_gpu: 900, median_intvty: 10 } }), + stubRow({ hardware: 'b200', conc: 32, metrics: { tput_per_gpu: 700, median_intvty: 20 } }), + stubRow({ hardware: 'b200', conc: 64, metrics: { tput_per_gpu: 500, median_intvty: 30 } }), + stubRow({ hardware: 'b200', conc: 128, metrics: { tput_per_gpu: 250, median_intvty: 40 } }), + ]; +} + +describe('computeCompareImageRows', () => { + const range = { min: 10, max: 40 }; + + it('returns 17 evenly-spaced samples when no includeTargets are passed', () => { + const rows = computeCompareImageRows(pairRows(), 'h200', 'b200', '1k/1k', 'fp8', range); + expect(rows.length).toBe(17); + expect(rows.at(0)?.target).toBe(10); + expect(rows.at(-1)?.target).toBe(40); + }); + + it('inserts includeTargets as exact samples without dropping the even grid', () => { + const rows = computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + range, + [17, 25, 33], + ); + const targets = rows.map((r) => r.target); + expect(targets).toContain(17); + expect(targets).toContain(25); + expect(targets).toContain(33); + // Endpoints from the even grid still present. + expect(targets).toContain(10); + expect(targets).toContain(40); + // Strictly increasing — required so curve-partition by target works. + for (let i = 1; i < targets.length; i++) { + expect(targets[i]).toBeGreaterThan(targets[i - 1]); + } + }); + + it('drops includeTargets that fall outside the interactivity range', () => { + const rows = computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + range, + [-5, 9, 41, 1000], + ); + const targets = rows.map((r) => r.target); + expect(targets).not.toContain(-5); + expect(targets).not.toContain(9); + expect(targets).not.toContain(41); + expect(targets).not.toContain(1000); + // The even grid is unaffected when every includeTarget is rejected. + expect(rows.length).toBe(17); + }); + + it('dedupes includeTargets that already coincide with an even-grid sample', () => { + const rows = computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + range, + [10, 40], + ); + expect(rows.length).toBe(17); + expect(rows.filter((r) => r.target === 10).length).toBe(1); + expect(rows.filter((r) => r.target === 40).length).toBe(1); + }); + + it('returns an empty array when the interactivity range is degenerate', () => { + expect( + computeCompareImageRows( + pairRows(), + 'h200', + 'b200', + '1k/1k', + 'fp8', + { min: 20, max: 20 }, + [20], + ), + ).toEqual([]); + }); +}); diff --git a/packages/app/src/lib/compare-ssr.ts b/packages/app/src/lib/compare-ssr.ts index 829a2985..e6e87dd7 100644 --- a/packages/app/src/lib/compare-ssr.ts +++ b/packages/app/src/lib/compare-ssr.ts @@ -10,7 +10,13 @@ * and JSON-LD shape — with a `variant` knob that swaps the headline * framing between the latency+throughput view and the per-dollar view. */ -import { HW_REGISTRY, sequenceToIslOsl } from '@semianalysisai/inferencex-constants'; +import { + AUTHOR_NAME, + AUTHOR_URL, + HW_REGISTRY, + SITE_URL, + sequenceToIslOsl, +} from '@semianalysisai/inferencex-constants'; import { FIXTURES_MODE, JSON_MODE, getDb } from '@semianalysisai/inferencex-db/connection'; import * as jsonProvider from '@semianalysisai/inferencex-db/json-provider'; import { @@ -75,7 +81,7 @@ export function pickString(value: string | string[] | undefined): string | undef } // --------------------------------------------------------------------------- -// Pair summary (JSON-LD Product additionalProperty) +// Pair summary (JSON-LD additionalProperty) // --------------------------------------------------------------------------- export interface PairSummary { @@ -260,7 +266,12 @@ export function computeCompareTableData( /** Sample the same interpolated cost curve used for the comparison table for * server-rendered image assets. More samples make the static PNG read like the - * interactive roofline without requiring browser-based chart capture. */ + * interactive roofline without requiring browser-based chart capture. + * + * `includeTargets` are merged into the even sampling grid so callers can + * guarantee the curve has exact samples at specific targets (e.g. the plotted + * comparison dots), making it safe to partition the curve into solid / + * dashed segments without interpolation gaps at the boundary. */ export function computeCompareImageRows( rows: BenchmarkRow[], a: string, @@ -268,6 +279,7 @@ export function computeCompareImageRows( sequence: string | null, precision: string | null, interactivityRange: { min: number; max: number }, + includeTargets: number[] = [], ): SsrInterpolatedRow[] { if (!sequence || !precision || interactivityRange.max <= interactivityRange.min) return []; @@ -280,20 +292,26 @@ export function computeCompareImageRows( const sampleCount = 17; const span = interactivityRange.max - interactivityRange.min; - return Array.from({ length: sampleCount }, (_, index) => { - const target = interactivityRange.min + (span * index) / (sampleCount - 1); - return { - target, - a: - pointsA.length > 0 - ? interpolateForGPU(pointsA, target, 'interactivity_to_throughput', 'costh') - : null, - b: - pointsB.length > 0 - ? interpolateForGPU(pointsB, target, 'interactivity_to_throughput', 'costh') - : null, - }; - }); + const evenTargets = Array.from( + { length: sampleCount }, + (_, index) => interactivityRange.min + (span * index) / (sampleCount - 1), + ); + const clamped = includeTargets.filter( + (t) => t >= interactivityRange.min && t <= interactivityRange.max, + ); + const targets = [...new Set([...evenTargets, ...clamped])].toSorted((x, y) => x - y); + + return targets.map((target) => ({ + target, + a: + pointsA.length > 0 + ? interpolateForGPU(pointsA, target, 'interactivity_to_throughput', 'costh') + : null, + b: + pointsB.length > 0 + ? interpolateForGPU(pointsB, target, 'interactivity_to_throughput', 'costh') + : null, + })); } // --------------------------------------------------------------------------- @@ -303,7 +321,7 @@ export function computeCompareImageRows( function jsonLdEntryFor(key: string, summary: PairSummary, position: number) { const meta = HW_REGISTRY[key]; const label = meta?.label ?? key.toUpperCase(); - const props: { name: string; value: string | number }[] = []; + const props: { name: string; value: string | number }[] = [{ name: 'Category', value: 'GPU' }]; if (meta) { props.push({ name: 'Vendor', value: meta.vendor }); props.push({ name: 'Architecture', value: meta.arch }); @@ -332,10 +350,8 @@ function jsonLdEntryFor(key: string, summary: PairSummary, position: number) { '@type': 'ListItem', position, item: { - '@type': 'Product', + '@type': 'Thing', name: label, - brand: { '@type': 'Brand', name: meta?.vendor ?? 'Unknown' }, - category: 'GPU', ...(props.length > 0 && { additionalProperty: props.map((p) => ({ '@type': 'PropertyValue', @@ -758,6 +774,46 @@ export function bucketComparePairsByVendor(modelSlug: string, pairs: ComparePair return { cross, nvidia, amd }; } +/** Breadcrumb trail for a compare slug page. Emitted alongside the main + * Dataset/ItemList JSON-LD so Google can render the Home → Compare → A vs B + * trail in search results. Variant chooses /compare vs /compare-per-dollar. */ +export function buildBreadcrumbJsonLd( + variant: CompareJsonLdVariant, + pairLabel: string, + url: string, +) { + const indexUrl = + variant === 'per-dollar' ? `${SITE_URL}/compare-per-dollar` : `${SITE_URL}/compare`; + const indexName = variant === 'per-dollar' ? 'GPU Performance per Dollar' : 'GPU Comparisons'; + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL }, + { '@type': 'ListItem', position: 2, name: indexName, item: indexUrl }, + { '@type': 'ListItem', position: 3, name: pairLabel, item: url }, + ], + }; +} + +/** Pick the oldest and newest benchmark dates among rows whose hardware matches + * the compared pair — used to populate Dataset.datePublished / dateModified. */ +export function dateRangeForPair( + rows: BenchmarkRow[], + a: string, + b: string, +): { oldest?: string; newest?: string } { + let oldest: string | undefined; + let newest: string | undefined; + for (const row of rows) { + if (row.hardware !== a && row.hardware !== b) continue; + if (!row.date) continue; + if (oldest === undefined || row.date < oldest) oldest = row.date; + if (newest === undefined || row.date > newest) newest = row.date; + } + return { oldest, newest }; +} + export function buildJsonLd( variant: CompareJsonLdVariant, model: CompareModelSlug, @@ -768,6 +824,13 @@ export function buildJsonLd( summaryB: PairSummary, ssrRows: SsrInterpolatedRow[], imageUrl?: string, + /** ISO date of oldest benchmark row contributing to this dataset. */ + datePublished?: string, + /** ISO date of newest benchmark row — drives Google Dataset Search freshness. */ + dateModified?: string, + /** Display model name accepted by /api/v1/benchmarks?model=…, used to wire the + * Dataset's `distribution: DataDownload` to a real machine-readable export. */ + modelApiKey?: string, ) { const aLabel = HW_REGISTRY[a]?.label ?? a.toUpperCase(); const bLabel = HW_REGISTRY[b]?.label ?? b.toUpperCase(); @@ -814,7 +877,7 @@ export function buildJsonLd( ); } return { - '@type': 'Observation', + '@type': 'Dataset', name: `${model.label} comparison at ${row.target} tok/s/user interactivity`, variableMeasured: metrics.map((m) => ({ '@type': 'PropertyValue', @@ -844,6 +907,40 @@ export function buildJsonLd( name: datasetName, description: datasetDescription, url, + license: 'https://www.apache.org/licenses/LICENSE-2.0', + isAccessibleForFree: true, + measurementTechnique: + 'Open-source automated GPU CI/CD inference benchmark (github.com/SemiAnalysisAI/InferenceX)', + keywords: [ + ...new Set( + [ + 'AI inference benchmark', + 'GPU comparison', + variant === 'per-dollar' ? 'cost per million tokens' : 'inference latency', + variant === 'per-dollar' ? 'performance per dollar' : 'tokens per second', + model.label, + aLabel, + bLabel, + HW_REGISTRY[a]?.vendor, + HW_REGISTRY[b]?.vendor, + ].filter(Boolean), + ), + ].join(', '), + ...(datePublished && { datePublished }), + ...(dateModified && { dateModified }), + creator: { + '@type': 'Organization', + name: AUTHOR_NAME, + url: AUTHOR_URL, + }, + ...(modelApiKey && { + distribution: { + '@type': 'DataDownload', + encodingFormat: 'application/json', + contentUrl: `${SITE_URL}/api/v1/benchmarks?model=${encodeURIComponent(modelApiKey)}`, + name: `${model.label} latest benchmark rows (JSON)`, + }, + }), ...(imageUrl && { image: { '@type': 'ImageObject',