From dcb970aee1765a3cd6c86fcd3bed064c14a08a93 Mon Sep 17 00:00:00 2001 From: Navesz <58537948+Navesz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:42:18 -0300 Subject: [PATCH 1/4] M3: the index reads like a product, and stops claiming what it cannot prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen was a spreadsheet with CSS: 648 flat rows, the identifier in the third column, six decimals in every cell, and the product's whole reason to exist invisible. Four changes, three of which are not visual. THE MODEL IS THE ENTITY, THE PROVIDER IS THE OFFER `FLUX.1 [schnell]` at fal and `black-forest-labs/FLUX-1-schnell` at DeepInfra are one row now. Without that, the question this project exists to answer — where do I run THIS model cheapest — had nowhere to be asked. `model-identity.ts` is an explicit rule table, never fuzzy matching: guessing here tells somebody they can pay US$ 0.0005 for a model that costs US$ 0.009, and they find out from their invoice. No match is a good answer. THE GUARD, WHICH IS THE PART THAT MATTERS Matching alone was not enough, and the screen proved it with numbers on it: - `Qwen Image` (fal, US$ 20) and `Qwen-Image-Max` (DeepInfra, US$ 75) sat in one row announcing "3.8x mais caro" about two different models. - `FLUX 2 Lora` (fal, US$ 21) and `FLUX-2-max` (DeepInfra, US$ 100) — 4.8x, also two different models, merged by a `/flux.*2/` catch-all. - `Flux 2 Pro` and `FLUX 2 Pro Outpaint` shared a key. So a variant now only matches when nothing in the DISPLAY NAME is left unexplained. The endpoint id is read for matching and ignored for the guard, because `fal-ai/flux-2/klein/4b/base/edit` is a route and `Qwen-Image-Edit` is a model — reading them the same way cost the one honest comparison that existed. Twelve cross-provider comparisons became three. Three is the true number, and each one was checked name by name: klein 4B 1.6x, klein 9B 1.4x, pro 2.0x. The "×N mais caro" figure was also wrong on its own terms: it divided the group's cheapest offer by its dearest without checking whose they were, and fal lists one model under several routes. It now compares each provider's best price, which is what the column header always promised. WHAT A PERSON SEES - The unit is a THOUSAND images. `US$ 0,200` instead of `0.000200` — nobody compares six decimals in their head, and nobody buys one image. - Portuguese decimals. `US$ 0.200` reads as two hundred where the dot is a thousands separator. - A "dá para comparar" filter. The headline announced a number the person had no way to isolate out of eighty-one rows. - Provenance became a link. The provider's price page was already collected, already travelled in the contract, and was thrown away — the one thing that closes the trust loop was on the wire and in the bin. - Cards below md, a real table above it, sticky header, row headers first for a screen reader, named expanders, 40px targets, `--color-edge` at 3.38:1. A NEW GATE: `controle` The rule `/…klein.*4\s*b\b/i` reached the file with the `\b` collapsed into U+0008, the literal BACKSPACE character. The regex stayed valid and silently never matched again. Prettier formatted it, tsc compiled it, oxlint approved it, and the test written from the same wrong assumption passed. The only symptom was a smaller number on a screen. `ferramental/controle` refuses any control character in source, and `portao` proves it closes on exactly that byte. --- apps/server/src/app/catalog.ts | 17 + apps/server/src/domain/model-identity.ts | 291 ++++++++ apps/server/src/http/router.ts | 4 + apps/server/tests/model-identity.test.ts | 137 ++++ apps/web/package.json | 2 + apps/web/src/app.tsx | 23 +- apps/web/src/credentials.tsx | 10 +- apps/web/src/index-de-precos.tsx | 229 ------ apps/web/src/index.css | 106 ++- apps/web/src/indice.tsx | 844 +++++++++++++++++++++++ apps/web/src/preco.ts | 109 +++ apps/web/tests/preco.test.ts | 106 +++ ferramental/controle/varrer-controle.mjs | 81 +++ ferramental/portao/provar-portao.mjs | 18 + package-lock.json | 20 + packages/contract/src/primitives.ts | 13 + verificar.config.mjs | 6 + 17 files changed, 1764 insertions(+), 252 deletions(-) create mode 100644 apps/server/src/domain/model-identity.ts create mode 100644 apps/server/tests/model-identity.test.ts delete mode 100644 apps/web/src/index-de-precos.tsx create mode 100644 apps/web/src/indice.tsx create mode 100644 apps/web/src/preco.ts create mode 100644 apps/web/tests/preco.test.ts create mode 100644 ferramental/controle/varrer-controle.mjs diff --git a/apps/server/src/app/catalog.ts b/apps/server/src/app/catalog.ts index 4d5f531..7189cc1 100644 --- a/apps/server/src/app/catalog.ts +++ b/apps/server/src/app/catalog.ts @@ -1,3 +1,4 @@ +import { identifyModel } from '../domain/model-identity.js' import { costPerImage, isFresh, REFERENCE_TARGET, type Target } from '../domain/pricing.js' import type { CatalogRow } from './ports.js' import type { UnitOfWork } from './unit-of-work.js' @@ -9,6 +10,10 @@ export interface CatalogDeps { export interface IndexEntry { readonly modelId: string + readonly modelKey: string + readonly modelLabel: string + readonly maker: string | null + readonly matchedBy: 'known-family' | 'unresolved' readonly provider: string readonly providerName: string readonly name: string @@ -61,9 +66,17 @@ export function createCatalog(deps: CatalogDeps) { } function toEntry(row: CatalogRow, target: Target, now: Date): IndexEntry { + // The endpoint id matters as much as the display name: fal titles a model + // "V4.0q [fast]", which identifies nothing on its own. + const identity = identifyModel(row.name, row.modelId) + if (!row.price) { return { modelId: row.modelId, + modelKey: identity.key, + modelLabel: identity.label, + maker: identity.maker, + matchedBy: identity.matchedBy, provider: row.provider, providerName: row.providerName, name: row.name, @@ -93,6 +106,10 @@ function toEntry(row: CatalogRow, target: Target, now: Date): IndexEntry { return { modelId: row.modelId, + modelKey: identity.key, + modelLabel: identity.label, + maker: identity.maker, + matchedBy: identity.matchedBy, provider: row.provider, providerName: row.providerName, name: row.name, diff --git a/apps/server/src/domain/model-identity.ts b/apps/server/src/domain/model-identity.ts new file mode 100644 index 0000000..96f6f75 --- /dev/null +++ b/apps/server/src/domain/model-identity.ts @@ -0,0 +1,291 @@ +/** + * Recognising that two providers are selling the same model. + * + * This is the product's reason to exist, and until now the screen could not show + * it. fal calls it "FLUX.1 [schnell]"; DeepInfra calls it + * "black-forest-labs/FLUX-1-schnell". Same weights, same output, different price + * — and the table treated them as two unrelated rows, which is exactly the fact + * the whole index was built to surface. + * + * Pure: no clock, no I/O, no network. A name goes in, an identity comes out. + * + * ## Why this is a table of rules and not a clever algorithm + * + * There is no derivable id across providers. A path at WaveSpeed, a bare string + * at KIE, a versioned AIR at Runware, `owner/name` plus a hash at Replicate. + * Fuzzy matching on names is worse than useless here: matching FLUX.1 [dev] to + * FLUX.1 [schnell] would tell somebody they can pay US$ 0.0005 for a model that + * actually costs US$ 0.009, and they would find out from their invoice. + * + * So: an explicit table, a conservative parser, and NO MATCH is a perfectly good + * answer. A model that does not resolve keeps its own row, which is the current + * behaviour and is never wrong. + */ + +export interface ModelIdentity { + /** Stable key for grouping. `flux.1-schnell`, `nano-banana-pro`. */ + readonly key: string + /** What a person calls it. `FLUX.1 [schnell]`. */ + readonly label: string + /** Who made the weights, when it is known. */ + readonly maker: string | null + /** How the match was made, so the screen can be honest about confidence. */ + readonly matchedBy: 'known-family' | 'unresolved' +} + +interface FamilyRule { + readonly key: string + readonly label: string + readonly maker: string + /** + * Every variant is listed, and the ORDER MATTERS: the most specific pattern + * first, because `flux.2 klein` must not be swallowed by `flux.2`. + */ + readonly variants: ReadonlyArray<{ + readonly key: string + readonly label: string + readonly match: RegExp + }> +} + +/** + * The families worth grouping, chosen from what the collectors actually return. + * + * THREE model variants currently carry a price from more than one provider: + * FLUX.2 [klein] 4B, FLUX.2 [klein] 9B and FLUX.2 [pro]. Three is the honest + * number and it used to read as twelve, which is the more interesting fact — the + * other nine were rules matching names they did not explain, and each one put a + * "×N mais caro" on the screen comparing two different models. + * + * It is thin for a reason worth stating: ten of the thirteen providers publish no + * machine-readable price at all, so there is little to cross-reference yet. The + * grouping gets valuable as those arrive by hand, and it gets valuable HONESTLY + * only because the guard below refuses everything it cannot explain. + */ +const FAMILIES: readonly FamilyRule[] = [ + { + key: 'flux', + label: 'FLUX', + maker: 'Black Forest Labs', + variants: [ + // 4B e 9B são pesos diferentes com preços diferentes, e o grupo os juntava: + // o fal vende o 4B a US$ 9 e o 9B a US$ 11 sob a mesma chave. + { + key: 'flux.2-klein-9b', + label: 'FLUX.2 [klein] 9B', + match: /flux[.\s-]*2.*klein.*9\s*b\b/i, + }, + { + key: 'flux.2-klein-4b', + label: 'FLUX.2 [klein] 4B', + match: /flux[.\s-]*2.*klein.*4\s*b\b/i, + }, + { key: 'flux.2-klein', label: 'FLUX.2 [klein]', match: /flux[.\s-]*2.*klein/i }, + { key: 'flux.2-flex', label: 'FLUX.2 [flex]', match: /flux[.\s-]*2.*flex/i }, + { key: 'flux.2-pro', label: 'FLUX.2 [pro]', match: /flux[.\s-]*2.*pro/i }, + { key: 'flux.2-dev', label: 'FLUX.2 [dev]', match: /flux[.\s-]*2.*dev/i }, + { key: 'flux.2-max', label: 'FLUX.2 [max]', match: /flux[.\s-]*2.*max/i }, + { key: 'flux.2-flash', label: 'FLUX.2 [flash]', match: /flux[.\s-]*2.*flash/i }, + { key: 'flux.2-turbo', label: 'FLUX.2 [turbo]', match: /flux[.\s-]*2.*turbo/i }, + { key: 'flux.2-edit', label: 'FLUX.2 Edit', match: /flux[.\s-]*2.*edit/i }, + { key: 'flux.2', label: 'FLUX.2', match: /flux[.\s-]*2(?![\s.-]*\w)/i }, + { key: 'flux.1-kontext', label: 'FLUX.1 Kontext', match: /flux[.\s-]*1?.*kontext/i }, + { key: 'flux.1-schnell', label: 'FLUX.1 [schnell]', match: /flux[.\s-]*1?.*schnell/i }, + { key: 'flux.1-dev', label: 'FLUX.1 [dev]', match: /flux[.\s-]*1?.*dev/i }, + { key: 'flux.1-pro', label: 'FLUX.1 [pro]', match: /flux[.\s-]*1?[\s.-]*pro\b/i }, + { key: 'flux.1', label: 'FLUX.1', match: /flux[.\s-]*1(?![\s.-]*\w)/i }, + ], + }, + { + key: 'nano-banana', + label: 'Nano Banana', + maker: 'Google', + variants: [ + { key: 'nano-banana-pro', label: 'Nano Banana Pro', match: /nano[\s-]*banana.*pro/i }, + { key: 'nano-banana-2', label: 'Nano Banana 2', match: /nano[\s-]*banana.*2/i }, + { key: 'nano-banana', label: 'Nano Banana', match: /nano[\s-]*banana/i }, + ], + }, + { + key: 'gemini-image', + label: 'Gemini Image', + maker: 'Google', + variants: [ + { + key: 'gemini-3-pro-image', + label: 'Gemini 3 Pro Image', + match: /gemini[\s-]*3.*pro.*image/i, + }, + { + key: 'gemini-3-flash-image', + label: 'Gemini 3 Flash Image', + match: /gemini[\s-]*3.*flash.*image/i, + }, + { key: 'gemini-image', label: 'Gemini Image', match: /gemini.*image/i }, + ], + }, + { + key: 'qwen-image', + label: 'Qwen-Image', + maker: 'Alibaba', + variants: [ + { key: 'qwen-image-edit', label: 'Qwen-Image Edit', match: /qwen[\s-]*image.*edit/i }, + { key: 'qwen-image', label: 'Qwen-Image', match: /qwen[\s-]*image/i }, + ], + }, + { + key: 'seedream', + label: 'Seedream', + maker: 'ByteDance', + variants: [ + { key: 'seedream-4', label: 'Seedream 4', match: /seedream[\s-]*4/i }, + { key: 'seedream-3', label: 'Seedream 3', match: /seedream[\s-]*3/i }, + { key: 'seedream', label: 'Seedream', match: /seedream/i }, + ], + }, + { + key: 'ideogram', + label: 'Ideogram', + maker: 'Ideogram', + variants: [ + { key: 'ideogram-v4', label: 'Ideogram V4', match: /ideogram.*v?4/i }, + { key: 'ideogram-v3', label: 'Ideogram V3', match: /ideogram.*v?3/i }, + { key: 'ideogram', label: 'Ideogram', match: /ideogram/i }, + ], + }, + { + key: 'sdxl', + label: 'SDXL', + maker: 'Stability AI', + variants: [ + { + key: 'sdxl-turbo', + label: 'SDXL Turbo', + match: /sdxl.*turbo|stable[\s-]*diffusion.*xl.*turbo/i, + }, + { key: 'sdxl', label: 'SDXL', match: /sdxl|stable[\s-]*diffusion[\s-]*xl/i }, + ], + }, + { + key: 'gpt-image', + label: 'GPT Image', + maker: 'OpenAI', + variants: [ + { key: 'gpt-image-2', label: 'GPT Image 2', match: /gpt[\s-]*image[\s-]*2/i }, + { key: 'gpt-image-1', label: 'GPT Image 1', match: /gpt[\s-]*image[\s-]*1?/i }, + ], + }, + { + key: 'recraft', + label: 'Recraft', + maker: 'Recraft', + variants: [ + { key: 'recraft-v3', label: 'Recraft V3', match: /recraft.*v?3/i }, + { key: 'recraft', label: 'Recraft', match: /recraft/i }, + ], + }, + { + key: 'imagen', + label: 'Imagen', + maker: 'Google', + variants: [ + { key: 'imagen-4', label: 'Imagen 4', match: /imagen[\s-]*4/i }, + { key: 'imagen', label: 'Imagen', match: /imagen/i }, + ], + }, +] + +/** + * Words that describe a TASK or a knob, not a model. + * + * Stripped before matching so "FLUX.2 [klein] 4B LoRA" and + * "black-forest-labs/FLUX-2-dev" reach the same rule. Left in, they would split + * one model into a handful of near-duplicate rows and hide the very comparison + * this file exists to produce. + * + * `edit`, `lora`, `controlnet`, `redux` and `fill` are deliberately NOT in this + * list, and a test enforces it. They look like task words and are not: each one + * names a SEPARATE ENDPOINT with a SEPARATE PRICE. The screen caught it live — + * with `lora` stripped, fal's "FLUX 2 Lora" at US$ 21 and DeepInfra's + * "FLUX-2-max" at US$ 100 landed in one row and the table announced "o mais caro + * custa 4,8×" about two models that are not the same thing. Where a family has a + * real variant, it gets its own rule above the general one. + */ +const NOISE = + /\b(text[\s-]?to[\s-]?image|image[\s-]?to[\s-]?image|t2i|i2i|txt2img|img2img|inpainting|outpainting|upscal\w*|api|endpoint|model|preview|latest|beta)\b/gi + +/** + * Palavras que, SOBRANDO depois do casamento, dizem "isto é outro modelo". + * + * A guarda que faltava, e a razão de ela existir é uma lista de mentiras que a + * tela chegou a exibir com número e tudo: + * + * - `Qwen Image` (fal, US$ 20) e `Qwen-Image-Max` (DeepInfra, US$ 75) viraram uma + * linha só anunciando "3,8× mais caro". Max é outro modelo. + * - `Flux 2 Pro` e `FLUX 2 Pro Outpaint` caíram na mesma chave — um roda a partir + * de um prompt, o outro exige uma imagem e uma máscara. + * - `Ideogram V4` e `Ideogram V4 (LoRA)` são endpoints com preços próprios. + * + * Cada um desses era uma regra específica a mais na tabela, para sempre, uma por + * palavra que um provedor inventasse. Isto é uma regra só, e ela erra para o lado + * certo: sobrou algo que a regra não explica, o modelo fica sem identidade e + * ganha a própria linha — que é o comportamento de hoje e nunca está errado. + * + * `nano` fica de fora de propósito: é metade do nome de Nano Banana. + * + * A guarda olha o NOME EXIBIDO, nunca o id do endpoint, e a diferença tem custo. + * `fal-ai/flux-2/klein/4b/base/edit` é a rota imagem→imagem do klein 4B Base: o + * `/edit` ali é caminho, não modelo. `Qwen/Qwen-Image-Edit` traz `Edit` no nome, e + * ali é modelo. Olhando os dois juntos, "FLUX.2 [klein] 4B Base" perdia a + * identidade por causa de um segmento de rota e a única comparação honesta que + * existia entre fal e DeepInfra sumia da tela. + */ +const QUALIFIERS = + /\b(max|plus|lite|mini|ultra|outpaint\w*|inpaint\w*|lora|controlnet|redux|fill|gallery|distill\w*|realism|hdr|reference|multi|edit)\b/i + +export function identifyModel(rawName: string, endpointId?: string): ModelIdentity { + // Both the display name and the endpoint id get a look. fal's title is + // "V4.0q [fast]" for a model whose id is "fal-ai/ideogram/v4"; the name alone + // is not enough to tell what it is. + const limpar = (t: string): string => + t + .replace(/^[a-z0-9.-]+:/i, '') + .replace(NOISE, ' ') + .replace(/[_/]/g, ' ') + + const haystack = limpar(`${rawName} ${endpointId ?? ''}`) + const nome = limpar(rawName) + + for (const family of FAMILIES) { + for (const variant of family.variants) { + if (!variant.match.test(haystack)) continue + + // O que a regra NÃO explicou do nome exibido. Se sobrar um qualificador + // ali, a regra casou com o nome errado e um preço de outro modelo entraria + // nesta linha. + if (QUALIFIERS.test(nome.replace(variant.match, ' '))) continue + + return { + key: variant.key, + label: variant.label, + maker: family.maker, + matchedBy: 'known-family', + } + } + } + + // No match is a good answer. The model keeps its own identity and its own row, + // which is exactly what the table does today and is never wrong. Guessing here + // would tell somebody they can pay US$ 0.0005 for a model that costs US$ 0.009. + return { + key: `unresolved:${rawName.toLowerCase().trim()}`, + label: rawName, + maker: null, + matchedBy: 'unresolved', + } +} + +/** Every family the table knows how to group, for a filter that offers real options. */ +export function knownFamilies(): ReadonlyArray<{ key: string; label: string; maker: string }> { + return FAMILIES.map((f) => ({ key: f.key, label: f.label, maker: f.maker })) +} diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts index ae9fdfb..1d20597 100644 --- a/apps/server/src/http/router.ts +++ b/apps/server/src/http/router.ts @@ -106,6 +106,10 @@ const catalogIndex = os.catalog.index.handler(async ({ input, context }) => { target: { width: input.width, height: input.height, steps: input.steps }, entries: entries.map((entry) => ({ modelId: entry.modelId, + modelKey: entry.modelKey, + modelLabel: entry.modelLabel, + maker: entry.maker, + matchedBy: entry.matchedBy, provider: entry.provider, providerName: entry.providerName, name: entry.name, diff --git a/apps/server/tests/model-identity.test.ts b/apps/server/tests/model-identity.test.ts new file mode 100644 index 0000000..44b414c --- /dev/null +++ b/apps/server/tests/model-identity.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { identifyModel, knownFamilies } from '../src/domain/model-identity.js' + +/** + * The tests that decide whether the price comparison is trustworthy. + * + * A false match here is not a cosmetic bug: it tells somebody they can pay + * US$ 0.0005 for a model that actually costs US$ 0.009, and they find out from + * their invoice. So the bar is asymmetric — missing a match is fine, inventing + * one is not. + */ + +describe('recognising the same model across providers', () => { + it('matches the pair the whole product exists to show', () => { + // fal's title and DeepInfra's model_name for the same weights. + const fal = identifyModel('FLUX.1 [schnell]', 'fal-ai/flux/schnell') + const deepinfra = identifyModel('black-forest-labs/FLUX-1-schnell') + + expect(fal.key).toBe('flux.1-schnell') + expect(deepinfra.key).toBe(fal.key) + expect(fal.maker).toBe('Black Forest Labs') + }) + + it('matches FLUX.2, which is where the real cross-provider data is today', () => { + expect(identifyModel('FLUX.2 [dev]').key).toBe('flux.2-dev') + expect(identifyModel('black-forest-labs/FLUX-2-dev').key).toBe('flux.2-dev') + expect(identifyModel('FLUX.2 [klein] 4B Base').key).toBe('flux.2-klein-4b') + }) + + it('does NOT confuse variants that cost different amounts', () => { + // The failure that would matter. schnell is US$ 0.0005 on DeepInfra and dev + // is US$ 0.009 — eighteen times more. Collapsing them would be a lie with a + // price tag on it. + const schnell = identifyModel('FLUX.1 [schnell]') + const dev = identifyModel('FLUX.1 [dev]') + const kontext = identifyModel('black-forest-labs/FLUX.1-Kontext-dev') + + expect(schnell.key).not.toBe(dev.key) + expect(dev.key).not.toBe(kontext.key) + expect(kontext.key).toBe('flux.1-kontext') + }) + + it('does not let a general rule swallow a more specific one', () => { + // `flux.2` would match "FLUX.2 [klein]" too. Order in the rule table is what + // stops that, and this is the test that fails if somebody reorders it. + expect(identifyModel('FLUX.2 [klein] 9B').key).toBe('flux.2-klein-9b') + expect(identifyModel('FLUX.2 [flex]').key).toBe('flux.2-flex') + expect(identifyModel('FLUX.2 Pro').key).toBe('flux.2-pro') + }) + + it('strips the task from the name, because a task is not a model', () => { + // fal lists the same weights under several endpoints. Left in, "Text to + // Image" and "Image to Image" would split one model into rows that cannot be + // compared to anything. + const t2i = identifyModel('Ideogram V4.0 Text to Image', 'fal-ai/ideogram/v4') + const i2i = identifyModel('Ideogram V4.0q Image to Image', 'fal-ai/ideogram/v4/image-to-image') + + expect(t2i.key).toBe('ideogram-v4') + expect(i2i.key).toBe(t2i.key) + }) + + it('refuses the match when a qualifier is left over', () => { + // A guarda que existe porque a tela chegou a exibir estas três mentiras com + // número: fal `Qwen Image` US$ 20 contra DeepInfra `Qwen-Image-Max` US$ 75, + // anunciado como "3,8× mais caro" sobre dois modelos diferentes. + expect(identifyModel('Qwen Image').key).toBe('qwen-image') + expect(identifyModel('Qwen/Qwen-Image-Max').matchedBy).toBe('unresolved') + expect(identifyModel('FLUX 2 Pro Outpaint').matchedBy).toBe('unresolved') + expect(identifyModel('Ideogram V4.0q Text to Image (LoRA)').matchedBy).toBe('unresolved') + }) + + it('does not let the guard reject a name the rule fully explains', () => { + // A guarda olha só o que SOBRA depois do casamento. `edit` é qualificador e + // ainda assim Qwen-Image Edit casa, porque a regra do edit consumiu a palavra. + expect(identifyModel('Qwen-Image Edit').key).toBe('qwen-image-edit') + expect(identifyModel('SDXL Turbo').key).toBe('sdxl-turbo') + expect(identifyModel('FLUX.2 [klein] 4B Base').key).toBe('flux.2-klein-4b') + expect(identifyModel('black-forest-labs/FLUX-2-klein-4b').key).toBe('flux.2-klein-4b') + }) + + it('separa as variantes de FLUX.2 que um coringa juntava', () => { + // `/flux.*2/` engolia tudo: `FLUX 2 Lora` (fal, US$ 21) e `FLUX-2-max` + // (DeepInfra, US$ 100) saíram na mesma linha dizendo "4,8× mais caro". + const chaves = [ + identifyModel('black-forest-labs/FLUX-2-max').key, + identifyModel('FLUX 2 Flash').key, + identifyModel('FLUX 2 Turbo').key, + identifyModel('FLUX 2 Lora Gallery Realism').key, + ] + + expect(new Set(chaves).size).toBe(chaves.length) + expect(chaves[0]).toBe('flux.2-max') + expect(identifyModel('FLUX 2 Lora Gallery Realism').matchedBy).toBe('unresolved') + }) + + it('reads the endpoint id when the display name says nothing useful', () => { + // fal titles some models "V4.0q [fast]", which identifies nothing on its own. + const bare = identifyModel('V4.0q [fast]', 'fal-ai/ideogram/v4') + expect(bare.key).toBe('ideogram-v4') + }) + + it('matches the other families that appear at more than one provider', () => { + expect(identifyModel('Nano Banana Pro').key).toBe('nano-banana-pro') + expect(identifyModel('google/nano-banana-2').key).toBe('nano-banana-2') + expect(identifyModel('Qwen-Image Edit').key).toBe('qwen-image-edit') + expect(identifyModel('Qwen/Qwen-Image').key).toBe('qwen-image') + expect(identifyModel('stabilityai/sdxl-turbo').key).toBe('sdxl-turbo') + expect(identifyModel('ByteDance/Seedream-4').key).toBe('seedream-4') + }) + + it('leaves an unknown model alone instead of guessing', () => { + // No match is a good answer: the model keeps its own row, which is what the + // table does today and is never wrong. + const unknown = identifyModel('PrunaAI/p-image') + + expect(unknown.matchedBy).toBe('unresolved') + expect(unknown.label).toBe('PrunaAI/p-image') + expect(unknown.maker).toBeNull() + + // And two different unknowns never collapse into each other. + expect(unknown.key).not.toBe(identifyModel('deepseek-ai/Janus-Pro-1B').key) + }) + + it('is stable: the same name always yields the same key', () => { + for (const name of ['FLUX.1 [schnell]', 'PrunaAI/p-image', 'Recraft V3']) { + expect(identifyModel(name).key).toBe(identifyModel(name).key) + } + }) + + it('offers a filter list of real families, not of raw strings', () => { + const families = knownFamilies() + + expect(families.length).toBeGreaterThan(5) + expect(families.map((f) => f.key)).toContain('flux') + expect(families.every((f) => f.label !== '' && f.maker !== '')).toBe(true) + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index d225635..4d5979f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,8 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@orpc/client": "^1.15.0", "@orpc/openapi-client": "^1.15.0", "@orpc/tanstack-query": "^1.15.0", diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 295f900..2108615 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { api, formatUsd, newCommandId } from './api.js' import { Credentials } from './credentials.js' -import { PriceIndex } from './index-de-precos.js' +import { Indice } from './indice.js' /** * The shape of the product, in the order a person actually needs it. @@ -58,7 +58,7 @@ export function App() { {screen === 'index' ? ( - + ) : user ? ( ) : ( @@ -100,7 +100,7 @@ function Settings({ user }: { user: { email: string; role: string; timezone: str @@ -194,7 +194,7 @@ function SignedOut({ registrationOpen }: { registrationOpen: boolean }) { required value={email} onChange={(event) => setEmail(event.target.value)} - className="w-full rounded border border-(--color-line) bg-(--color-ground) px-3 py-2 text-sm" + className="w-full rounded-lg border border-(--color-edge) bg-(--color-ground) px-3 py-2.5 text-sm" /> @@ -207,7 +207,7 @@ function SignedOut({ registrationOpen }: { registrationOpen: boolean }) { minLength={12} value={password} onChange={(event) => setPassword(event.target.value)} - className="w-full rounded border border-(--color-line) bg-(--color-ground) px-3 py-2 text-sm" + className="w-full rounded-lg border border-(--color-edge) bg-(--color-ground) px-3 py-2.5 text-sm" /> Mínimo de 12 caracteres. @@ -277,12 +277,12 @@ function BudgetRow({ aria-label={`Novo teto em dólares para ${budget.period === 'month' ? 'o mês' : 'o dia'}`} value={draft} onChange={(event) => setDraft(event.target.value)} - className="w-24 rounded border border-(--color-line) bg-(--color-ground) px-2 py-1 text-right font-mono text-sm tabular-nums" + className="w-24 min-h-10 rounded-lg border border-(--color-edge) bg-(--color-ground) px-2 text-right font-mono text-sm tabular-nums" /> @@ -303,11 +303,14 @@ function Tab({ return ( diff --git a/apps/web/src/index-de-precos.tsx b/apps/web/src/index-de-precos.tsx deleted file mode 100644 index e8161ca..0000000 --- a/apps/web/src/index-de-precos.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useMemo, useState } from 'react' -import { api } from './api.js' - -/** - * The price index, and the first thing anybody sees. - * - * No account, no key. Handing a service an API key is a decision, and nobody - * should have to make it before seeing a single price — which is exactly what the - * previous version of this screen asked for, and why it was wrong. - * - * One table with every combination of model and provider, cheapest first, because - * the fact worth paying for is that the SAME model costs different amounts in - * different places. - */ - -type Entry = Awaited>['entries'][number] - -export function PriceIndex() { - const [provider, setProvider] = useState('') - const [search, setSearch] = useState('') - const [ceiling, setCeiling] = useState('') - const [showUnpriced, setShowUnpriced] = useState(false) - - const catalog = useQuery({ - queryKey: ['catalog'], - queryFn: () => api.catalog.index({ width: 1024, height: 1024, steps: 25 }), - staleTime: 5 * 60_000, - }) - - const entries = catalog.data?.entries ?? [] - - const providers = useMemo(() => { - const names = new Map() - for (const entry of entries) names.set(entry.provider, entry.providerName) - return [...names.entries()].sort((a, b) => a[1].localeCompare(b[1])) - }, [entries]) - - const filtered = useMemo(() => { - const needle = search.trim().toLowerCase() - const cap = ceiling.trim() === '' ? null : Number(ceiling) * 1e9 - - return entries.filter((entry) => { - if (provider !== '' && entry.provider !== provider) return false - if (needle !== '' && !`${entry.name} ${entry.family ?? ''}`.toLowerCase().includes(needle)) { - return false - } - if (entry.costNanoUsd === null) return showUnpriced && cap === null - if (cap !== null && Number(entry.costNanoUsd) > cap) return false - return true - }) - }, [entries, provider, search, ceiling, showUnpriced]) - - const priced = entries.filter((e) => e.costNanoUsd !== null).length - - return ( -
-
-

Preço por imagem, 1024×1024

-

- Todos os provedores e modelos, do mais barato para o mais caro, sem precisar de conta nem - de chave. Coletado das APIs públicas de cada um — cada linha diz de onde veio e quando. - Você põe chave só nos que valerem a pena. -

-
- -
- - - - - - - -
- - {catalog.isPending ? ( -

Carregando o índice…

- ) : catalog.isError ? ( -

- Não deu para carregar o índice. O servidor respondeu com erro. -

- ) : ( - <> -

- {filtered.length} de {entries.length} linhas · {priced} com preço comparável ·{' '} - {entries.length - priced} sem preço publicado pelo provedor -

- -
- - - - - - - - - - - - {filtered.slice(0, 300).map((entry) => ( - - ))} - -
US$ / imagemProvedorModeloComo é cobradoFonte
-
- - {filtered.length > 300 && ( -

- Mostrando as 300 primeiras de {filtered.length}. Filtre para chegar no que interessa. -

- )} - - {catalog.data && catalog.data.providersWithoutMachineReadablePrices.length > 0 && ( -

- O que ainda não está aqui. Estes - provedores não publicam preço em nenhum formato que uma máquina leia:{' '} - {catalog.data.providersWithoutMachineReadablePrices.join(', ')}. Os preços deles - entram à mão, por pull request, com a URL da fonte e a data. Uma tabela que mostra só - o que é fácil de coletar se lê como “estes são todos os preços que existem”. -

- )} - - )} -
- ) -} - -function Th({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( - - {children} - - ) -} - -function Row({ entry }: { entry: Entry }) { - // Always six decimals. A price column where one row shows 0.000200 and the next - // shows 0.0100 cannot be scanned down: the eye compares digit positions, not - // values, and the cheapest row stops looking cheapest. - const price = entry.costNanoUsd === null ? null : (Number(entry.costNanoUsd) / 1e9).toFixed(6) - - return ( - - - {price === null ? ( - // Never a blank cell: an empty price reads as "free" or as a bug, and - // the reason is the useful part. - - não comparável - - ) : ( - {price} - )} - - {entry.provider} - - {entry.name} - {entry.watermark === 'synthid' && ( - - SynthID - - )} - - - {entry.basis ?? '—'} - {entry.note && {entry.note}} - - - {entry.collectedAt ? ( - <> - {entry.method} · {new Date(entry.collectedAt).toLocaleDateString('pt-BR')} - {!entry.fresh && ( - - acima de 30 dias — fora do ranking - - )} - - ) : ( - '—' - )} - - - ) -} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f99e87b..898a56e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,5 +1,21 @@ @import 'tailwindcss'; +/* + * Self-hosted, not Google Fonts. A stylesheet fetched from a third party on every + * page load is a request this project does not need to make on somebody else's + * behalf, and a self-hosted instance behind a firewall would silently fall back + * to system-ui. + * + * IBM Plex, not Inter. Inter is the default answer for any dashboard and would + * make this look like every other one; Plex was drawn for engineering + * documentation, its mono has real tabular figures, and the pairing carries the + * instrument metaphor the project is named after. The variable weight of the sans + * costs one file for the whole range. + */ +@import '@fontsource-variable/ibm-plex-sans'; +@import '@fontsource/ibm-plex-mono/400.css'; +@import '@fontsource/ibm-plex-mono/500.css'; + /** * Without this line `dark:` simply does not work in Tailwind 4. It is the single * most expensive missing line in a v3-to-v4 migration, because everything still @@ -16,10 +32,30 @@ */ --color-ground: oklch(0.185 0.005 258); --color-surface: oklch(0.225 0.006 258); - --color-line: oklch(0.31 0.008 258); + --color-raised: oklch(0.262 0.007 258); + + /* + * TWO border tokens, and the split is an accessibility requirement rather than + * a preference. + * + * WCAG 2.2 SC 1.4.11 asks for 3:1 on the visual information needed to identify + * a user interface component. The old single `--color-line` measured 1.42:1 + * against the page and 1.30:1 against a panel — it was the only thing saying + * where an input began, and at low brightness or in sunlight the filters read + * as text floating in the dark. + * + * `--color-edge` is measured at 3.38:1 / 3.11:1 and belongs on anything a + * person operates: input, select, checkbox, button, tab. + * `--color-line` stays quiet and is for structure a component does not depend + * on — the rule between two table rows, a divider inside a panel. + */ + --color-edge: oklch(0.52 0.008 258); + --color-line: oklch(0.34 0.008 258); + --color-ink: oklch(0.94 0.004 258); - --color-ink-muted: oklch(0.68 0.008 258); + --color-ink-muted: oklch(0.7 0.008 258); --color-brass: oklch(0.78 0.11 82); + --color-brass-dim: oklch(0.44 0.06 82); /* Semantic state colours. Every state also carries an icon or a word: colour alone never communicates state — around 8% of men have some colour vision @@ -31,6 +67,9 @@ --color-state-failed: oklch(0.7 0.16 25); --color-state-capped: oklch(0.78 0.13 75); --color-state-doubtful: oklch(0.74 0.12 320); + + --font-sans: 'IBM Plex Sans Variable', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace; } @layer base { @@ -42,15 +81,20 @@ body { background: var(--color-ground); color: var(--color-ink); - font-family: - ui-sans-serif, - system-ui, - -apple-system, - 'Segoe UI', - sans-serif; + font-family: var(--font-sans); -webkit-font-smoothing: antialiased; } + /* + * Every digit the same width, everywhere, without asking each component to + * remember. In a price column the eye compares digit POSITION, and proportional + * figures make the comparison wrong before it starts. + */ + .font-mono, + table { + font-variant-numeric: tabular-nums; + } + /* * Tailwind 4 removed `cursor: pointer` from preflight and shadcn does not put it * back, so nothing clickable changes the cursor until somebody notices. These @@ -73,5 +117,51 @@ :focus-visible { outline: 2px solid var(--color-brass); outline-offset: 2px; + border-radius: 2px; + } + + /* Someone who asked their system to stop moving things means it. */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } +} + +@layer components { + /* + * The price bar: magnitude you can see without reading a digit. + * + * Prices span four orders of magnitude — US$ 0.0002 to US$ 0.15, a factor of + * 750. No numeric format renders that difference: `0.000200` and `0.150000` + * occupy the same width and the cheapest row stops looking cheapest. Length is + * the one channel that carries magnitude pre-attentively, and a log scale is + * what keeps 750× inside one column. + * + * A `
` with a width, so it costs zero kilobytes — which is what the + * project's refusal of a charting library requires. + */ + .barra-preco { + height: 3px; + border-radius: 999px; + background: var(--color-brass-dim); + min-width: 2px; + } + + /* + * A table header that stays put. Scrolling past thirty rows without it means + * five columns of similar-looking numbers and dates with nothing saying which + * is which. + */ + .cabecalho-fixo { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-surface); } } diff --git a/apps/web/src/indice.tsx b/apps/web/src/indice.tsx new file mode 100644 index 0000000..8a0ca4f --- /dev/null +++ b/apps/web/src/indice.tsx @@ -0,0 +1,844 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { api } from './api.js' +import { + formatarPorMil, + formatarUnitario, + formatarVezes, + custoMensal, + larguraDaBarra, + nomeDaTarefa, + quantasVezes, +} from './preco.js' + +/** + * O índice de preços. + * + * A tela anterior era uma planilha com CSS: 648 linhas planas, o identificador na + * terceira coluna, seis casas decimais em toda célula, e a razão de existir do + * produto invisível. Quatro coisas mudam isso, e três delas não são visuais: + * + * 1. **O modelo é a entidade, o provedor é a oferta.** `FLUX.1 [schnell]` no fal + * e `black-forest-labs/FLUX-1-schnell` na DeepInfra viram uma linha só, que + * diz "a partir de X, em N provedores, o mais caro é 3,2× o mais barato". + * Sem isso a pergunta que o produto existe para responder — onde eu rodo ESTE + * modelo mais barato — não tem onde ser feita. + * 2. **A unidade é mil imagens.** Ver `preco.ts`. + * 3. **A procedência vira link.** A URL da página de preço do provedor já era + * coletada, já viajava no contrato, e era jogada fora. Era a única coisa que + * fechava o laço de confiança. + * 4. **A tarefa aparece.** Um modelo de inpaint sentava na mesma lista, no mesmo + * preço, que um texto→imagem, e nada avisava que ele não roda a partir de um + * prompt. + */ + +type Entry = Awaited>['entries'][number] + +interface Grupo { + readonly chave: string + readonly titulo: string + readonly maker: string | null + readonly ofertas: Entry[] + readonly maisBarata: Entry | null + readonly maisCara: Entry | null + readonly provedores: number + /** Vale abrir: mais de uma oferta, em mais de um provedor. */ + readonly agrupado: boolean + /** + * Dá para comparar DE VERDADE: dois provedores que publicaram preço. + * + * Não é o mesmo que `agrupado`, e a diferença é o que separa uma promessa + * cumprida de uma quebrada. FLUX.1 [schnell] está em dois provedores e só um + * deles publica preço — abrir a linha é útil, mas prometer uma comparação de + * preço ali é prometer o que não existe. + */ + readonly comparavel: boolean + /** + * Quanto o provedor mais caro cobra em cima do mais barato, cada um no MELHOR + * preço que ele publica. + * + * Antes isso era a razão entre a oferta mais barata e a mais cara do grupo, sem + * olhar de quem eram — e o fal publica o mesmo modelo em várias rotas com preços + * diferentes. A tela dizia "o mais caro custa 2,2×" comparando fal com fal, numa + * coluna intitulada "provedores". Era uma frase verdadeira sobre números e falsa + * sobre o que ela parecia dizer. + */ + readonly fator: number +} + +const IMAGENS_POR_MES = 1000 + +export function Indice() { + const [provedor, setProvedor] = useState('') + const [busca, setBusca] = useState('') + const [buscaEcoada, setBuscaEcoada] = useState('') + const [mostrarSemPreco, setMostrarSemPreco] = useState(false) + const [soComparaveis, setSoComparaveis] = useState(false) + const [aberto, setAberto] = useState(null) + + const catalogo = useQuery({ + queryKey: ['catalog'], + queryFn: () => api.catalog.index({ width: 1024, height: 1024, steps: 25 }), + staleTime: 5 * 60_000, + }) + + const entries = useMemo(() => catalogo.data?.entries ?? [], [catalogo.data]) + + /* + * O contador é anunciado depois que a digitação para. + * + * `aria-live` ligado direto no estado da busca enfileira um anúncio por tecla: + * digitar "flux" faz o leitor de tela falar quatro números por cima da própria + * digitação, e a fila é FIFO — não descarta o que já venceu. Meio segundo de + * silêncio transforma quatro anúncios em um. + */ + useEffect(() => { + const timer = setTimeout(() => setBuscaEcoada(busca), 500) + return () => clearTimeout(timer) + }, [busca]) + + const grupos = useMemo(() => agrupar(entries), [entries]) + + const filtrados = useMemo(() => { + const agulha = busca.trim().toLowerCase() + + return grupos.filter((g) => { + // O filtro que corresponde à razão de existir do produto. Sem ele, os + // modelos com preço em dois provedores — a única resposta para "onde rodo + // ESTE modelo mais barato" — ficam dispersos entre oitenta e uma linhas, e a + // manchete anuncia um número que a pessoa não tem como isolar. + if (soComparaveis && !g.comparavel) return false + + if (provedor !== '' && !g.ofertas.some((o) => o.provider === provedor)) return false + + if (agulha !== '') { + const palheiro = `${g.titulo} ${g.maker ?? ''} ${g.ofertas.map((o) => o.name).join(' ')}` + // Pontuação fora: buscar "flux 2" precisa achar `FLUX-2-pro`, e cada + // provedor pontua o mesmo nome de um jeito. + if (!semPontuacao(palheiro).includes(semPontuacao(agulha))) return false + } + + if (g.maisBarata === null) return mostrarSemPreco + return true + }) + }, [grupos, provedor, busca, mostrarSemPreco, soComparaveis]) + + const comPreco = grupos.filter((g) => g.maisBarata !== null) + + const faixa = useMemo(() => { + const valores = comPreco.map((g) => Number(g.maisBarata?.costNanoUsd ?? 0)).filter((v) => v > 0) + return valores.length > 0 + ? { min: Math.min(...valores), max: Math.max(...valores) } + : { min: 1, max: 1 } + }, [comPreco]) + + const contagemPorProvedor = useMemo(() => { + const mapa = new Map() + for (const entry of entries) { + const atual = mapa.get(entry.provider) ?? { nome: entry.providerName, total: 0 } + atual.total += 1 + mapa.set(entry.provider, atual) + } + return [...mapa.entries()].sort((a, b) => b[1].total - a[1].total) + }, [entries]) + + if (catalogo.isPending) return + if (catalogo.isError) return + + const comparaveis = comPreco.length + const disputados = grupos.filter((g) => g.comparavel).length + + /* + * O denominador é o que a lista mostraria SEM filtro, não o total do banco. + * "12 de 413" com a lista sem filtro dizendo 81 faz a pessoa procurar por um + * erro que não existe: 413 conta os grupos sem preço, que ela nunca viu. + */ + const universo = mostrarSemPreco ? grupos.length : comparaveis + + return ( +
+ + + + +

+ {buscaEcoada === busca && ( + <> + {filtrados.length}{' '} + {filtrados.length === 1 ? 'modelo' : 'modelos'} + {provedor !== '' || busca !== '' || soComparaveis ? ` de ${universo}` : ''} + + )} +

+ + {filtrados.length === 0 ? ( + { + setProvedor('') + setBusca('') + setSoComparaveis(false) + }} + /> + ) : ( + <> + {/* Acima de md: tabela. Abaixo: cartão. Cinco colunas em 375px viram + 672px de rolagem horizontal, e a coluna que explica o preço fica a + 337px de distância da coluna do preço — a cor sozinha passa a ser a + única marca de que o dado envelheceu, o que é a 1.4.1 falhando. */} + + + + )} + + +
+ ) +} + +// --- a manchete ---------------------------------------------------------------- + +function Manchete({ + modelos, + comparaveis, + disputados, + maisBarato, +}: { + modelos: number + comparaveis: number + disputados: number + maisBarato: Grupo | null +}) { + return ( +
+
+

+ Quanto custa mil imagens, em cada lugar que gera +

+

+ O mesmo modelo custa preços diferentes em provedores diferentes. Esta tabela mostra a + diferença — sem conta e sem chave. Cada preço carrega de onde veio e de quando é. +

+
+ +
+ + + + +
+
+ ) +} + +function Numero({ + rotulo, + valor, + destaque = false, +}: { + rotulo: string + valor: string + destaque?: boolean +}) { + return ( +
+
+ {valor} +
+
{rotulo}
+
+ ) +} + +// --- filtros ------------------------------------------------------------------- + +function Filtros({ + provedor, + aoTrocarProvedor, + busca, + aoBuscar, + mostrarSemPreco, + aoAlternarSemPreco, + soComparaveis, + aoAlternarComparaveis, + comparaveisEntreProvedores, + provedores, + semPreco, +}: { + provedor: string + aoTrocarProvedor: (v: string) => void + busca: string + aoBuscar: (v: string) => void + mostrarSemPreco: boolean + aoAlternarSemPreco: (v: boolean) => void + soComparaveis: boolean + aoAlternarComparaveis: (v: boolean) => void + comparaveisEntreProvedores: number + provedores: ReadonlyArray + semPreco: number +}) { + return ( +
+ + + {/* Chips com a contagem dentro. Quem escolhe já sabe o tamanho do resultado, + e nenhum filtro devolve zero de surpresa. */} +
+ aoTrocarProvedor('')}> + todos + + {comparaveisEntreProvedores > 0 && ( + aoAlternarComparaveis(!soComparaveis)}> + dá para comparar{' '} + {comparaveisEntreProvedores} + + )} + {provedores.map(([slug, { nome, total }]) => ( + aoTrocarProvedor(slug)}> + {nome} {total} + + ))} + {semPreco > 0 && ( + aoAlternarSemPreco(!mostrarSemPreco)}> + sem preço publicado {semPreco} + + )} +
+
+ ) +} + +/** 40px de altura: a 2.5.8 exige 24, e um dedo em pé no ônibus precisa de mais. */ +function Chip({ + ativo, + aoClicar, + children, +}: { + ativo: boolean + aoClicar: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +// --- a tabela ------------------------------------------------------------------ + +function Tabela({ + grupos, + faixa, + aberto, + aoAbrir, +}: { + grupos: Grupo[] + faixa: { min: number; max: number } + aberto: string | null + aoAbrir: (v: string | null) => void +}) { + return ( +
+
+ + + + + + + + + + + + {grupos.slice(0, 200).map((g) => ( + aoAbrir(aberto === g.chave ? null : g.chave)} + /> + ))} + +
+ Modelos de geração de imagem e o preço de mil imagens em cada provedor, ordenados do + mais barato para o mais caro. +
+ Modelo + + Mil imagens ↑ + + Onde + + Procedência +
+
+ + {grupos.length > 200 && ( +

+ Mostrando 200 de {grupos.length}. Filtre para chegar no que interessa — o resto não é + escondido, só não cabe numa tela. +

+ )} +
+ ) +} + +function Linha({ + grupo, + faixa, + aberto, + aoAbrir, +}: { + grupo: Grupo + faixa: { min: number; max: number } + aberto: boolean + aoAbrir: () => void +}) { + const barata = grupo.maisBarata + + return ( + <> + + {/* O identificador é cabeçalho de linha, e vem primeiro. Numa tabela cuja + razão de existir é comparar o preço do MESMO modelo entre provedores, + navegar a coluna de preço e ouvir "0,20" sem saber de qual modelo é + não serve para nada. */} + + {grupo.titulo} + + {grupo.maker && {grupo.maker}} + {barata?.tasks.slice(0, 2).map((t) => ( + {nomeDaTarefa(t)} + ))} + {barata?.watermark === 'synthid' && marca d'água} + + + + + {barata?.costNanoUsd ? ( + <> + {formatarPorMil(barata.costNanoUsd)} +