@@ -51,6 +51,10 @@ export interface ModelParamPrice extends ModelUnitPricingLike {
5151 warnings ?: string [ ] ;
5252 /** Set instead of a price when we refuse to extrapolate. */
5353 declined ?: string ;
54+ /** The duration the figure was billed for, when one applied. */
55+ seconds ?: number ;
56+ /** The rung the figure was billed at, in the catalog's own spelling. */
57+ resolution ?: string ;
5458}
5559
5660/**
@@ -104,10 +108,75 @@ export function normalizeResolution(value: string | undefined): string | null {
104108
105109const PER_SECOND_CLASSES = new Set ( [ "per-video-second" , "per-audio-second" ] ) ;
106110
107- const money = ( usd : number ) : string =>
108- usd >= 0.01
109- ? `$${ usd . toFixed ( 3 ) . replace ( / 0 + $ / , "" ) . replace ( / \. $ / , "" ) } `
110- : `$${ usd } ` ;
111+ /**
112+ * The megapixel rungs image grids are published at. `1K` is GenSpend's name for
113+ * a one-megapixel deliverable, which `normalizeResolution` folds to `1MP`.
114+ */
115+ const MEGAPIXEL_TIERS : Array < { megapixels : number ; tier : string } > = [
116+ { megapixels : 0.25 , tier : "512×512" } ,
117+ { megapixels : 1 , tier : "1MP" } ,
118+ { megapixels : 4 , tier : "2K" } ,
119+ { megapixels : 16 , tier : "4K" }
120+ ] ;
121+
122+ /** The rung a megapixel count sits closest to, inside a 1.5× band. */
123+ function tierForMegapixels ( megapixels : number ) : string | null {
124+ if ( ! Number . isFinite ( megapixels ) || megapixels <= 0 ) return null ;
125+ let best = MEGAPIXEL_TIERS [ 0 ] ;
126+ let bestRatio = Infinity ;
127+ for ( const candidate of MEGAPIXEL_TIERS ) {
128+ const ratio =
129+ megapixels > candidate . megapixels
130+ ? megapixels / candidate . megapixels
131+ : candidate . megapixels / megapixels ;
132+ if ( ratio < bestRatio ) {
133+ bestRatio = ratio ;
134+ best = candidate ;
135+ }
136+ }
137+ return bestRatio <= 1.5 ? best . tier : null ;
138+ }
139+
140+ const trimZeros = ( text : string ) : string =>
141+ text . includes ( "." ) ? text . replace ( / 0 + $ / , "" ) . replace ( / \. $ / , "" ) : text ;
142+
143+ /**
144+ * A price as text. Sub-cent figures get as many decimal places as they need:
145+ * `String(3e-7)` is `3e-7`, and a breakdown reading "$3e-7/s" is unreadable.
146+ */
147+ export const formatUsd = ( usd : number ) : string => {
148+ if ( ! Number . isFinite ( usd ) ) return "$0" ;
149+ if ( usd >= 0.01 ) return `$${ trimZeros ( usd . toFixed ( 3 ) ) } ` ;
150+ if ( usd <= 0 ) return "$0" ;
151+ const places = Math . min ( 12 , Math . ceil ( - Math . log10 ( usd ) ) + 2 ) ;
152+ return `$${ trimZeros ( usd . toFixed ( places ) ) } ` ;
153+ } ;
154+
155+ const money = formatUsd ;
156+
157+ /**
158+ * Whether the catalog can price this entry off stated parameters — a published
159+ * grid, a per-second class the duration multiplies, or an input surcharge.
160+ * A flat per-generation entry with none of those answers the same number the
161+ * provider catalogs carry, so nothing is gained by routing it here.
162+ */
163+ export function isParameterPriceable ( entry : GenspendPrice ) : boolean {
164+ const rows = entry . variants ?? [ ] ;
165+ if (
166+ rows . some (
167+ ( row ) =>
168+ row . resolution !== undefined ||
169+ row . duration_seconds !== undefined ||
170+ row . with_audio !== undefined ||
171+ row . tier !== undefined ||
172+ row . video_input !== undefined
173+ )
174+ ) {
175+ return true ;
176+ }
177+ if ( PER_SECOND_CLASSES . has ( entry . unit_class ) ) return true ;
178+ return ( entry . surcharges ?? [ ] ) . length > 0 ;
179+ }
111180
112181/** The shortest duration the model is receipted for, or 1 s when unbounded. */
113182function shortestClipSecond ( clip : GenspendPrice [ "clip_seconds" ] ) : number {
@@ -232,7 +301,7 @@ export function priceGenspendEntry(
232301 const assumptions : string [ ] = [ ] ;
233302 const warnings : string [ ] = [ ] ;
234303
235- const resolution =
304+ let resolution =
236305 params . resolution === undefined || params . resolution === null
237306 ? null
238307 : normalizeResolution ( params . resolution ) ;
@@ -242,6 +311,20 @@ export function priceGenspendEntry(
242311 ) ;
243312 }
244313
314+ // An image grid is laddered by megapixels, and a node that states a pixel
315+ // size states that count without naming a rung. Only read it when the node
316+ // named no resolution and the grid really is laddered.
317+ if ( resolution === null && params . megapixels !== undefined ) {
318+ const laddered = ( entry . variants ?? [ ] ) . some ( ( row ) => row . resolution ) ;
319+ const derived = laddered ? tierForMegapixels ( params . megapixels ) : null ;
320+ if ( derived ) {
321+ resolution = derived ;
322+ assumptions . push (
323+ `priced at the ${ derived } rung, from the node's ${ params . megapixels } MP output size`
324+ ) ;
325+ }
326+ }
327+
245328 const askedSeconds = params . seconds ?? 0 ;
246329 const statedSeconds =
247330 Number . isFinite ( askedSeconds ) && askedSeconds > 0 ? askedSeconds : null ;
@@ -368,6 +451,13 @@ export function priceGenspendEntry(
368451 source : "bundle" ,
369452 breakdown
370453 } ;
454+ if ( seconds !== null ) {
455+ price . seconds = seconds ;
456+ }
457+ const rungResolution = row ?. resolution ?? ( resolution ?? undefined ) ;
458+ if ( rungResolution !== undefined ) {
459+ price . resolution = rungResolution ;
460+ }
371461 if ( assumptions . length > 0 ) {
372462 price . assumptions = assumptions ;
373463 }
0 commit comments