mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
feat: add model-specific render profiles and recall evidence
This commit is contained in:
+12
-10
@@ -24,17 +24,19 @@ function baseModelId(model: string): string {
|
||||
/** Dashboard runtime override; null = fall back to PXPIPE_MODELS env / built-in default. In-memory only. */
|
||||
let runtimeModelBases: readonly string[] | null = null;
|
||||
|
||||
/** Built-in default scope when PXPIPE_MODELS is unset: Fable 5 (Claude) plus
|
||||
* GPT 5.6. Everything else is opt-in via dashboard chips or PXPIPE_MODELS:
|
||||
/** Built-in default scope when PXPIPE_MODELS is unset: Fable 5 only.
|
||||
* Everything else is opt-in via dashboard chips or PXPIPE_MODELS:
|
||||
* - Opus 4.7/4.8 — worse at reading imaged content (FINDINGS.md 2026-06-16:
|
||||
* Opus 4.8 ~2pp arithmetic, 6/15 dense-hex vs Fable 100/100).
|
||||
* - GPT 5.5 — degrades on imaged history/context.
|
||||
* - Grok — pure-image exact OCR fails at production 5×8 (0/4 IDs); image+
|
||||
* factsheet recovers exact IDs but the family is not default-quality for
|
||||
* pxpipe yet (eval/grok-density/FACTSHEET_RESULTS.md). Keep off until that
|
||||
* changes.
|
||||
* Silently imaging weak readers is the wrong default. */
|
||||
const DEFAULT_MODEL_BASES = ['claude-fable-5', 'gpt-5.6'];
|
||||
* - GPT 5.6 Sol — direct raw-image calls at both its 6×11 profile and the
|
||||
* old shared 5×8 profile scored 0/4 exact with four confabulations. Its
|
||||
* exact model profile remains available for explicit opt-in and retuning.
|
||||
* - Grok — shared 5×8 pure-image exact recall failed 0/4 with silent
|
||||
* confabulation. Effective 9×12 and a factsheet each rescued one synthetic
|
||||
* fixture, but neither has representative-workload evidence yet.
|
||||
* Silently imaging weak or unvalidated readers is the wrong default. */
|
||||
const DEFAULT_MODEL_BASES = ['claude-fable-5'];
|
||||
|
||||
function falsey(v: string): boolean {
|
||||
return /^(0|false|no|off|none)$/i.test(v.trim());
|
||||
@@ -42,9 +44,9 @@ function falsey(v: string): boolean {
|
||||
|
||||
/** PXPIPE_MODELS env / built-in default, ignoring the runtime override. One CSV
|
||||
* controls every family (Claude + GPT). Resolution (read per-call so scope flips LIVE):
|
||||
* - unset or empty → built-in default (Fable 5 + GPT 5.6)
|
||||
* - unset or empty → built-in default (Fable 5 only)
|
||||
* - `off`/`0`/`false`/... → compress nothing
|
||||
* - CSV of model bases → exactly those families (e.g. `claude-fable-5,gpt-5.6`) */
|
||||
* - CSV of model bases → exactly those families (e.g. `claude-fable-5,gpt-5.6-sol`) */
|
||||
function envOrDefaultBases(): string[] {
|
||||
// Edge-safe: `process` is undefined off-Node; `typeof` avoids a ReferenceError.
|
||||
const raw = typeof process !== 'undefined' ? process.env?.PXPIPE_MODELS : undefined;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
-11
@@ -18,8 +18,8 @@ import {
|
||||
// consumers import (pxpipe-proxy/transform → renderTextToImages), not the
|
||||
// internal leaf renderer.
|
||||
import { renderTextToImages } from './library.js';
|
||||
import { estimateImageCount, ANTHROPIC_PIXELS_PER_TOKEN, IMAGE_COST_SAFETY_MARGIN, REPORT_CHARS_PER_TOKEN } from './transform.js';
|
||||
import { openAIVisionTokens } from './openai.js';
|
||||
import { estimateImageCount, REPORT_CHARS_PER_TOKEN } from './transform.js';
|
||||
import { visionTokensForModel } from './openai.js';
|
||||
import {
|
||||
factSheetTextFromEntries,
|
||||
extractFactSheetTokensAllPages,
|
||||
@@ -249,17 +249,12 @@ export function parseExportArgv(
|
||||
*
|
||||
* - **Claude / Anthropic models** (`model.startsWith('claude')` or
|
||||
* `model.includes('anthropic')`): uses Anthropic's billing formula
|
||||
* `ceil(width × height / 750 × 1.10)` (the same formula and constants as
|
||||
* `imageTokensForRows` in transform.ts, reusing the exported
|
||||
* `ANTHROPIC_PIXELS_PER_TOKEN` / `IMAGE_COST_SAFETY_MARGIN` consts).
|
||||
* - **GPT / o-series models**: delegates to `openAIVisionTokens` which uses the
|
||||
* GPT-4o tile-pricing formula (85 + 170 × tiles after scaling).
|
||||
* `ceil(width × height / 750 × 1.10)`, matching the live transform.
|
||||
* - **OpenAI-shaped models**: delegates to the same exact-model router as the
|
||||
* proxy (Grok measured pixel rate; GPT tile/patch rate).
|
||||
*/
|
||||
export function exportImageTokens(model: string, width: number, height: number): number {
|
||||
if (model.startsWith('claude') || model.includes('anthropic')) {
|
||||
return Math.ceil((width * height / ANTHROPIC_PIXELS_PER_TOKEN) * IMAGE_COST_SAFETY_MARGIN);
|
||||
}
|
||||
return openAIVisionTokens(model, width, height);
|
||||
return visionTokensForModel(model, width, height);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
*
|
||||
* One place to retune when a new model ships with different image tokenization,
|
||||
* a different downscale threshold (max safe portrait-strip width), or a different
|
||||
* max image height. Every built-in profile is BEHAVIOR-IDENTICAL to the old
|
||||
* hardcoded `resolveVisionCost` + `GPT_STRIP_COLS` + `MAX_HEIGHT_PX`, so existing
|
||||
* cost numbers (1190 / 1445 / 2372 / 1464 / 630 …) are unchanged.
|
||||
* max image height. Unknown models preserve the conservative legacy fallback;
|
||||
* named profiles may deliberately select different fonts and geometry.
|
||||
*
|
||||
* Retune without a code change via the PXPIPE_GPT_PROFILES env var (JSON map of
|
||||
* model-id PREFIX -> partial profile; longest matching prefix wins, checked
|
||||
* BEFORE the built-in table). Partial fields fall back to the built-in match, so
|
||||
* you can override just one knob:
|
||||
*
|
||||
* PXPIPE_GPT_PROFILES='{"gpt-5.6":{"vision":{"regime":"patch","multiplier":1,"patchCap":12000},"stripCols":200,"maxHeightPx":2400}}'
|
||||
* PXPIPE_GPT_PROFILES='{"gpt-5.6":{"stripCols":176}}' # widen only
|
||||
* PXPIPE_GPT_PROFILES='{"gpt-5.6-sol":{"vision":{"regime":"patch","multiplier":1,"patchCap":12000},"stripCols":120,"maxHeightPx":1900}}'
|
||||
* PXPIPE_GPT_PROFILES='{"gpt-5.6-sol":{"style":{"grid":true,"gridCols":4}}}'
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -26,7 +25,9 @@
|
||||
*/
|
||||
import {
|
||||
MAX_HEIGHT_PX as ANTHROPIC_MAX_HEIGHT_PX,
|
||||
DENSE_CONTENT_COLS as ANTHROPIC_STRIP_COLS,
|
||||
ANTHROPIC_SLAB_COLS as ANTHROPIC_STRIP_COLS,
|
||||
DEFAULT_RENDER_FONT,
|
||||
type RenderFont,
|
||||
} from './render.js';
|
||||
|
||||
export const GPT_MAX_HEIGHT_PX = 1932;
|
||||
@@ -37,27 +38,37 @@ export type GptVisionCost =
|
||||
| { regime: 'patch'; multiplier: number; patchCap: number };
|
||||
|
||||
export interface GptRenderStyle {
|
||||
/** Rasterized font atlas. */
|
||||
font: RenderFont;
|
||||
/** Extra px beside the 5px glyph (cell width = 5 + this). */
|
||||
cellWBonus?: number;
|
||||
cellWBonus: number;
|
||||
/** Extra px above the 8px glyph (cell height = 8 + this). */
|
||||
cellHBonus?: number;
|
||||
cellHBonus: number;
|
||||
/** Grayscale AA atlas. Default true for production dense pages. */
|
||||
aa?: boolean;
|
||||
aa: boolean;
|
||||
/** Faint cell guides. */
|
||||
grid: boolean;
|
||||
/** Vertical guide cadence when grid is enabled. */
|
||||
gridCols: number;
|
||||
/** Per-glyph color cycle. */
|
||||
colorCycle: boolean;
|
||||
/** Horizontal scale for the hard-newline marker. */
|
||||
markerScale: number;
|
||||
/** Render hard-newline markers in red. */
|
||||
markerRed: boolean;
|
||||
}
|
||||
|
||||
export interface GptModelProfile {
|
||||
/** How OpenAI bills the rendered images as input tokens. */
|
||||
vision: GptVisionCost;
|
||||
/** Max portrait-strip width in COLUMNS before the API downscales (destroying
|
||||
* 5px glyphs). 152 cols x 5px + 8px pad = 768px = OpenAI's shortest-side floor. */
|
||||
/** Max portrait-strip width in columns. Combined with `style`, this must stay
|
||||
* at or below the provider's no-resize pixel width. */
|
||||
stripCols: number;
|
||||
/** Max rendered image height in px. Threaded into the renderer so the gate's
|
||||
* cost estimate and the actual page split agree. */
|
||||
maxHeightPx: number;
|
||||
/** Glyph density / spacing. Omitted fields fall through to renderer defaults
|
||||
* (production 5x8: cellWBonus=0, cellHBonus=0, aa=true). Models that confabulate
|
||||
* exact strings at production density set larger bonuses here. */
|
||||
style?: GptRenderStyle;
|
||||
/** Complete model-specific font, cell spacing, color, and marker style. */
|
||||
style: GptRenderStyle;
|
||||
}
|
||||
|
||||
/** Default downscale-safe strip width (768px). Exported as the global cols default. */
|
||||
@@ -65,6 +76,17 @@ export const DEFAULT_GPT_STRIP_COLS = 152;
|
||||
|
||||
const C = DEFAULT_GPT_STRIP_COLS;
|
||||
const H = GPT_MAX_HEIGHT_PX;
|
||||
const BASE_STYLE: GptRenderStyle = {
|
||||
font: DEFAULT_RENDER_FONT,
|
||||
cellWBonus: 0,
|
||||
cellHBonus: 0,
|
||||
aa: true,
|
||||
grid: false,
|
||||
gridCols: 0,
|
||||
colorCycle: false,
|
||||
markerScale: 1,
|
||||
markerRed: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Conservative fallback for unrecognized models: tile 85/170 over-states cost,
|
||||
@@ -74,7 +96,16 @@ export const DEFAULT_GPT_PROFILE: GptModelProfile = {
|
||||
vision: { regime: 'tile', base: 85, perTile: 170 },
|
||||
stripCols: C,
|
||||
maxHeightPx: H,
|
||||
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
|
||||
style: BASE_STYLE,
|
||||
};
|
||||
|
||||
const GPT56_SOL_PROFILE: GptModelProfile = {
|
||||
vision: { regime: 'patch', multiplier: 1, patchCap: 10000 },
|
||||
stripCols: 126,
|
||||
maxHeightPx: H,
|
||||
// Separate atlas selected by model id. Cell remains within OpenAI's 768px
|
||||
// short-side floor (126 cols x 6px + padding = 764px).
|
||||
style: { ...BASE_STYLE, font: 'jetbrains-mono-10' },
|
||||
};
|
||||
|
||||
interface ProfileRule {
|
||||
@@ -95,33 +126,34 @@ const BUILTIN_RULES: ProfileRule[] = [
|
||||
// nano patch models: ceil(patches * 2.46), cap 1536
|
||||
{
|
||||
test: (m) => isMiniNanoPatch(m) && /nano/.test(m),
|
||||
profile: { vision: { regime: 'patch', multiplier: 2.46, patchCap: 1536 }, stripCols: C, maxHeightPx: H },
|
||||
profile: { vision: { regime: 'patch', multiplier: 2.46, patchCap: 1536 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
|
||||
},
|
||||
// mini / o4-mini patch models: ceil(patches * 1.62), cap 1536
|
||||
{
|
||||
test: (m) => isMiniNanoPatch(m) && !/nano/.test(m),
|
||||
profile: { vision: { regime: 'patch', multiplier: 1.62, patchCap: 1536 }, stripCols: C, maxHeightPx: H },
|
||||
profile: { vision: { regime: 'patch', multiplier: 1.62, patchCap: 1536 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
|
||||
},
|
||||
// gpt-5.6 flagship — EXPLICIT slot so the day-one retune is one line (or one env
|
||||
// var). Identical to the generic 5.x rule below until the real numbers land.
|
||||
// Exact Sol variant observed on production traffic. Do not match bare 5.6 or
|
||||
// sibling variants (for example gpt-5.6-terra): model-specific visual tuning
|
||||
// must not leak across variants merely because they share a version number.
|
||||
{
|
||||
test: (m) => /^gpt-5\.6/.test(m),
|
||||
profile: { vision: { regime: 'patch', multiplier: 1, patchCap: 10000 }, stripCols: C, maxHeightPx: H },
|
||||
test: (m) => m === 'gpt-5.6-sol' || m.startsWith('gpt-5.6-sol-'),
|
||||
profile: GPT56_SOL_PROFILE,
|
||||
},
|
||||
// 5.x flagship (gpt-5.4/5.5/…, no -mini/-nano): patch, multiplier 1, detail:original cap
|
||||
{
|
||||
test: (m) => /^gpt-5\.\d/.test(m),
|
||||
profile: { vision: { regime: 'patch', multiplier: 1, patchCap: 10000 }, stripCols: C, maxHeightPx: H },
|
||||
profile: { vision: { regime: 'patch', multiplier: 1, patchCap: 10000 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
|
||||
},
|
||||
// gpt-5 / gpt-5-chat-latest: tile 70/140
|
||||
{
|
||||
test: (m) => /^gpt-5/.test(m),
|
||||
profile: { vision: { regime: 'tile', base: 70, perTile: 140 }, stripCols: C, maxHeightPx: H },
|
||||
profile: { vision: { regime: 'tile', base: 70, perTile: 140 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
|
||||
},
|
||||
// o1 / o3 reasoning: tile 75/150
|
||||
{
|
||||
test: (m) => /^o[13]/.test(m),
|
||||
profile: { vision: { regime: 'tile', base: 75, perTile: 150 }, stripCols: C, maxHeightPx: H },
|
||||
profile: { vision: { regime: 'tile', base: 75, perTile: 150 }, stripCols: C, maxHeightPx: H, style: BASE_STYLE },
|
||||
},
|
||||
|
||||
// Claude on the Responses path (Codex-style clients). Selection is by model
|
||||
@@ -137,15 +169,15 @@ const BUILTIN_RULES: ProfileRule[] = [
|
||||
vision: { regime: 'tile', base: 85, perTile: 170 },
|
||||
stripCols: ANTHROPIC_STRIP_COLS,
|
||||
maxHeightPx: ANTHROPIC_MAX_HEIGHT_PX,
|
||||
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
|
||||
style: { ...BASE_STYLE },
|
||||
},
|
||||
},
|
||||
|
||||
// Grok (Responses path). Production 5x8 packing maximizes pixel savings
|
||||
// (measured ~1000 image tok/MPix). Exact-ID OCR is weak at that density, so
|
||||
// the Responses transform keeps a verbatim fact-sheet of paths/hex/ports/
|
||||
// camelCase ids next to the image (see factsheet.ts). Do NOT inflate cell
|
||||
// bonuses here just to pass OCR tests — that trades away the savings.
|
||||
// Grok (Responses path). Live climb 2026-07-09 on grok-4.5: 5x8 and 7x10
|
||||
// confabulate exact IDs; effective 9x12 (Spleen 5x8 + 4px spacing) is the
|
||||
// densest measured arm that reached 4/4 exact, 0 confab, gist+guard ok. The
|
||||
// fact-sheet still rides beside images as defense in depth. Grok remains
|
||||
// opt-in only; this profile applies only after an operator enables it.
|
||||
{
|
||||
test: (m) => /^grok-/.test(m),
|
||||
profile: {
|
||||
@@ -153,9 +185,9 @@ const BUILTIN_RULES: ProfileRule[] = [
|
||||
// measured pixel model. Keep a conservative tile placeholder for tools
|
||||
// that only read the profile struct.
|
||||
vision: { regime: 'tile', base: 85, perTile: 170 },
|
||||
stripCols: C,
|
||||
stripCols: 84,
|
||||
maxHeightPx: H,
|
||||
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
|
||||
style: { ...BASE_STYLE, cellWBonus: 4, cellHBonus: 4 },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -184,6 +216,14 @@ function posInt(v: unknown, fallback: number): number {
|
||||
return Number.isFinite(v) && (v as number) > 0 ? Math.floor(v as number) : fallback;
|
||||
}
|
||||
|
||||
function nonNegativeInt(v: unknown, fallback: number): number {
|
||||
return Number.isFinite(v) && (v as number) >= 0 ? Math.floor(v as number) : fallback;
|
||||
}
|
||||
|
||||
function renderFont(v: unknown, fallback: RenderFont): RenderFont {
|
||||
return v === 'spleen-5x8' || v === 'jetbrains-mono-10' ? v : fallback;
|
||||
}
|
||||
|
||||
function parseEnvProfiles(raw: string): Map<string, GptModelProfile> {
|
||||
const out = new Map<string, GptModelProfile>();
|
||||
if (!raw) return out;
|
||||
@@ -197,18 +237,27 @@ function parseEnvProfiles(raw: string): Map<string, GptModelProfile> {
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
const key = k.toLowerCase();
|
||||
const base = resolveBuiltin(key); // partial fields fall back to the built-in match
|
||||
// An exact override key may be a prefix of the real model id. Resolve the
|
||||
// known family explicitly so `{"gpt-5.6-sol":{...}}` inherits Sol's font
|
||||
// and geometry even when a suffixed runtime id is expected.
|
||||
const base = key === 'gpt-5.6-sol' ? GPT56_SOL_PROFILE : resolveBuiltin(key);
|
||||
const p = v as Partial<GptModelProfile>;
|
||||
const styleIn = (p as { style?: GptRenderStyle }).style;
|
||||
const baseStyle = base.style ?? { cellWBonus: 0, cellHBonus: 0, aa: true };
|
||||
const baseStyle = base.style;
|
||||
const style: GptRenderStyle = {
|
||||
cellWBonus: styleIn && Number.isFinite(styleIn.cellWBonus as number)
|
||||
? Math.floor(styleIn.cellWBonus as number)
|
||||
: baseStyle.cellWBonus,
|
||||
cellHBonus: styleIn && Number.isFinite(styleIn.cellHBonus as number)
|
||||
? Math.floor(styleIn.cellHBonus as number)
|
||||
: baseStyle.cellHBonus,
|
||||
font: renderFont(styleIn?.font, baseStyle.font),
|
||||
cellWBonus: nonNegativeInt(styleIn?.cellWBonus, baseStyle.cellWBonus),
|
||||
cellHBonus: nonNegativeInt(styleIn?.cellHBonus, baseStyle.cellHBonus),
|
||||
aa: styleIn && typeof styleIn.aa === 'boolean' ? styleIn.aa : baseStyle.aa,
|
||||
grid: styleIn && typeof styleIn.grid === 'boolean' ? styleIn.grid : baseStyle.grid,
|
||||
gridCols: nonNegativeInt(styleIn?.gridCols, baseStyle.gridCols),
|
||||
colorCycle: styleIn && typeof styleIn.colorCycle === 'boolean'
|
||||
? styleIn.colorCycle
|
||||
: baseStyle.colorCycle,
|
||||
markerScale: posInt(styleIn?.markerScale, baseStyle.markerScale),
|
||||
markerRed: styleIn && typeof styleIn.markerRed === 'boolean'
|
||||
? styleIn.markerRed
|
||||
: baseStyle.markerRed,
|
||||
};
|
||||
out.set(key, {
|
||||
vision: isValidVision(p.vision) ? p.vision : base.vision,
|
||||
@@ -229,26 +278,10 @@ function envProfiles(): Map<string, GptModelProfile> {
|
||||
return envMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full rendering + vision-cost profile for a model id. Env overrides
|
||||
* (longest matching prefix) win over the built-in table; unknown models get the
|
||||
* conservative `DEFAULT_GPT_PROFILE`.
|
||||
*/
|
||||
function withDefaultStyle(profile: GptModelProfile): GptModelProfile {
|
||||
const d = DEFAULT_GPT_PROFILE.style!;
|
||||
const st = profile.style;
|
||||
return {
|
||||
...profile,
|
||||
style: {
|
||||
cellWBonus: st?.cellWBonus ?? d.cellWBonus,
|
||||
cellHBonus: st?.cellHBonus ?? d.cellHBonus,
|
||||
aa: st?.aa ?? d.aa,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGptProfile(model: string | null | undefined): GptModelProfile {
|
||||
const m = (model ?? '').toLowerCase();
|
||||
// Match applicability.ts: bracketed transport variants (for example [1m])
|
||||
// do not define a different visual reader profile.
|
||||
const m = (model ?? '').toLowerCase().replace(/\[[^\]]*\]/g, '');
|
||||
const env = envProfiles();
|
||||
if (env.size > 0) {
|
||||
let best: GptModelProfile | undefined;
|
||||
@@ -259,7 +292,7 @@ export function resolveGptProfile(model: string | null | undefined): GptModelPro
|
||||
bestLen = k.length;
|
||||
}
|
||||
}
|
||||
if (best) return withDefaultStyle(best);
|
||||
if (best) return best;
|
||||
}
|
||||
return withDefaultStyle(resolveBuiltin(m));
|
||||
return resolveBuiltin(m);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,14 @@
|
||||
* HistoryTurn list and the planner/renderer are shared.
|
||||
*/
|
||||
|
||||
import { renderTextToPngs, reflow, neutralizeSentinel, type RenderedImage } from './render.js';
|
||||
import { GPT_MAX_HEIGHT_PX } from './gpt-model-profiles.js';
|
||||
import {
|
||||
renderTextToPngs,
|
||||
reflow,
|
||||
neutralizeSentinel,
|
||||
type RenderedImage,
|
||||
type RenderStyle,
|
||||
} from './render.js';
|
||||
import { DEFAULT_GPT_PROFILE, GPT_MAX_HEIGHT_PX } from './gpt-model-profiles.js';
|
||||
import { countTokens as o200kCountTokens } from 'gpt-tokenizer/encoding/o200k_base';
|
||||
|
||||
/** Portrait-strip width for GPT history images. Mirrors GPT_STRIP_COLS in
|
||||
@@ -81,7 +87,7 @@ export interface GptHistoryOptions {
|
||||
* into renderTextToPngs so history pages split at the same height the gate prices. */
|
||||
maxHeightPx: number;
|
||||
/** Glyph density from the model profile. Empty = production 5x8. */
|
||||
style: { cellWBonus?: number; cellHBonus?: number; aa?: boolean };
|
||||
style: RenderStyle;
|
||||
/** Hard cap on GPT history image count. This is a TRUE cap, not a threshold:
|
||||
* collapse the oldest completed sections until the next section would exceed
|
||||
* the cap, then leave the remaining history as ordinary text. Prevents 80+
|
||||
@@ -107,7 +113,7 @@ export const GPT_HISTORY_DEFAULTS: GptHistoryOptions = {
|
||||
// GPT path: OpenAI's resize bounds (2048-bbox / 768 short side) permit the tall
|
||||
// strip — do NOT re-link to render.ts MAX_HEIGHT_PX (Anthropic's 1568/1.15 MP clamp).
|
||||
maxHeightPx: GPT_MAX_HEIGHT_PX,
|
||||
style: { cellWBonus: 0, cellHBonus: 0, aa: true },
|
||||
style: DEFAULT_GPT_PROFILE.style,
|
||||
maxImages: GPT_HISTORY_MAX_IMAGES,
|
||||
reflow: true,
|
||||
};
|
||||
|
||||
+46
-34
@@ -10,13 +10,15 @@ import {
|
||||
renderTextToPngs,
|
||||
reflow,
|
||||
shrinkColsToContent,
|
||||
renderCellWidth,
|
||||
renderCellHeight,
|
||||
PAD_X,
|
||||
CELL_W,
|
||||
PAD_Y,
|
||||
READABLE_CHARS_PER_IMAGE,
|
||||
type RenderedImage,
|
||||
} from './render.js';
|
||||
import {
|
||||
resolveGptProfile,
|
||||
DEFAULT_GPT_STRIP_COLS,
|
||||
type GptVisionCost,
|
||||
} from './gpt-model-profiles.js';
|
||||
import { bytesToBase64 } from './png.js';
|
||||
@@ -218,7 +220,7 @@ interface OpenAIResolvedOptions {
|
||||
compress: boolean;
|
||||
compressTools: boolean;
|
||||
minCompressChars: number;
|
||||
cols: number;
|
||||
cols?: number;
|
||||
multiCol: number;
|
||||
charsPerToken: number;
|
||||
reflow: boolean;
|
||||
@@ -230,7 +232,7 @@ const DEFAULTS: OpenAIResolvedOptions = {
|
||||
compress: true,
|
||||
compressTools: true,
|
||||
minCompressChars: 2000,
|
||||
cols: DEFAULT_GPT_STRIP_COLS,
|
||||
cols: undefined,
|
||||
multiCol: 1,
|
||||
charsPerToken: 4, // conservative OpenAI default; override after telemetry
|
||||
reflow: true,
|
||||
@@ -242,7 +244,7 @@ function resolveOptions(opts: TransformOptions): OpenAIResolvedOptions {
|
||||
compress: opts.compress ?? DEFAULTS.compress,
|
||||
compressTools: opts.compressTools ?? DEFAULTS.compressTools,
|
||||
minCompressChars: opts.minCompressChars ?? DEFAULTS.minCompressChars,
|
||||
cols: opts.cols ?? DEFAULTS.cols,
|
||||
cols: opts.cols,
|
||||
multiCol: opts.multiCol ?? DEFAULTS.multiCol,
|
||||
charsPerToken: opts.charsPerToken ?? DEFAULTS.charsPerToken,
|
||||
reflow: opts.reflow ?? DEFAULTS.reflow,
|
||||
@@ -502,10 +504,25 @@ function evalOpenAIGate(
|
||||
cols: number,
|
||||
charsPerToken: number,
|
||||
): { imageTokens: number; textTokens: number; profitable: boolean } {
|
||||
const stripW = 2 * PAD_X + cols * CELL_W;
|
||||
const estImages = estimateImageCount(renderedText, cols, 1);
|
||||
const perStrip = visionTokensForModel(model, stripW, resolveGptProfile(model).maxHeightPx);
|
||||
const imageTokens = estImages * perStrip;
|
||||
const profile = resolveGptProfile(model);
|
||||
const style = profile.style;
|
||||
const cellW = renderCellWidth(style);
|
||||
const cellH = renderCellHeight(style);
|
||||
const stripW = 2 * PAD_X + cols * cellW;
|
||||
const maxLines = Math.max(1, Math.floor((profile.maxHeightPx - 2 * PAD_Y) / cellH));
|
||||
const maxCharsPerImage = Math.min(
|
||||
READABLE_CHARS_PER_IMAGE,
|
||||
Math.max(1, cols) * maxLines,
|
||||
);
|
||||
const estImages = estimateImageCount(
|
||||
renderedText,
|
||||
cols,
|
||||
1,
|
||||
maxCharsPerImage,
|
||||
maxLines,
|
||||
);
|
||||
const perFullStrip = visionTokensForModel(model, stripW, profile.maxHeightPx);
|
||||
const imageTokens = estImages * perFullStrip;
|
||||
const textTokens = renderedText.length / charsPerToken;
|
||||
return { imageTokens, textTokens, profitable: imageTokens < textTokens };
|
||||
}
|
||||
@@ -541,21 +558,6 @@ function gptTextTokens(text: string): number {
|
||||
|
||||
/** Vision-token cost of the rendered images, summed over their real dims —
|
||||
* what GPT actually bills as input for the slab pxpipe imaged. */
|
||||
function profileRenderStyle(
|
||||
model: string | null | undefined,
|
||||
): { cellWBonus?: number; cellHBonus?: number; aa?: boolean; grid?: boolean; colorCycle?: boolean } {
|
||||
const st = resolveGptProfile(model).style as
|
||||
| { cellWBonus?: number; cellHBonus?: number; aa?: boolean; grid?: boolean; colorCycle?: boolean }
|
||||
| undefined;
|
||||
return {
|
||||
cellWBonus: st?.cellWBonus ?? 0,
|
||||
cellHBonus: st?.cellHBonus ?? 0,
|
||||
aa: st?.aa ?? true,
|
||||
...(st?.grid ? { grid: true } : {}),
|
||||
...(st?.colorCycle ? { colorCycle: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function gptImageTokens(model: string, images: RenderedImage[]): number {
|
||||
let n = 0;
|
||||
for (const img of images) n += visionTokensForModel(model, img.width, img.height);
|
||||
@@ -705,7 +707,12 @@ export async function transformOpenAIChatCompletions(
|
||||
: '';
|
||||
const header = CHAT_HEADER.replace('\n====', reflowNote + '\n====');
|
||||
const renderedText = header + combined;
|
||||
const cols = Math.min(shrinkColsToContent(renderedText, o.cols), resolveGptProfile(req.model).stripCols);
|
||||
const profile = resolveGptProfile(req.model);
|
||||
const maxCols = o.cols ?? profile.stripCols;
|
||||
const cols = Math.min(
|
||||
shrinkColsToContent(renderedText, maxCols, profile.style.markerScale, profile.style.font),
|
||||
profile.stripCols,
|
||||
);
|
||||
|
||||
const gate = evalOpenAIGate(req.model, renderedText, cols, o.charsPerToken);
|
||||
info.gateEval = {
|
||||
@@ -722,7 +729,7 @@ export async function transformOpenAIChatCompletions(
|
||||
return { body, info };
|
||||
}
|
||||
|
||||
const images = await renderTextToPngs(renderedText, cols, profileRenderStyle(req.model), resolveGptProfile(req.model).maxHeightPx);
|
||||
const images = await renderTextToPngs(renderedText, cols, profile.style, profile.maxHeightPx);
|
||||
if (images.length === 0) {
|
||||
info.reason = 'render_empty';
|
||||
return { body, info };
|
||||
@@ -782,9 +789,9 @@ export async function transformOpenAIChatCompletions(
|
||||
const plan = await planGptCollapse(turns, firstUserIdx + 1, profitable, {
|
||||
...o.gptHistory,
|
||||
reflow: o.reflow,
|
||||
cols: o.gptHistory?.cols ?? resolveGptProfile(req.model).stripCols,
|
||||
maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
|
||||
style: o.gptHistory?.style ?? profileRenderStyle(req.model),
|
||||
cols: o.gptHistory?.cols ?? profile.stripCols,
|
||||
maxHeightPx: o.gptHistory?.maxHeightPx ?? profile.maxHeightPx,
|
||||
style: o.gptHistory?.style ?? profile.style,
|
||||
});
|
||||
foldGptHistory(info, req.model, plan);
|
||||
const allImages = [...plan.images, ...plan.imagesAfter];
|
||||
@@ -913,7 +920,12 @@ export async function transformOpenAIResponses(
|
||||
: '';
|
||||
const header = RESPONSES_HEADER.replace('\n====', reflowNote + '\n====');
|
||||
const renderedText = header + combined;
|
||||
const cols = Math.min(shrinkColsToContent(renderedText, o.cols), resolveGptProfile(req.model).stripCols);
|
||||
const profile = resolveGptProfile(req.model);
|
||||
const maxCols = o.cols ?? profile.stripCols;
|
||||
const cols = Math.min(
|
||||
shrinkColsToContent(renderedText, maxCols, profile.style.markerScale, profile.style.font),
|
||||
profile.stripCols,
|
||||
);
|
||||
|
||||
const gate = evalOpenAIGate(req.model, renderedText, cols, o.charsPerToken);
|
||||
info.gateEval = {
|
||||
@@ -930,7 +942,7 @@ export async function transformOpenAIResponses(
|
||||
return { body, info };
|
||||
}
|
||||
|
||||
const images = await renderTextToPngs(renderedText, cols, profileRenderStyle(req.model), resolveGptProfile(req.model).maxHeightPx);
|
||||
const images = await renderTextToPngs(renderedText, cols, profile.style, profile.maxHeightPx);
|
||||
if (images.length === 0) {
|
||||
info.reason = 'render_empty';
|
||||
return { body, info };
|
||||
@@ -1021,9 +1033,9 @@ export async function transformOpenAIResponses(
|
||||
const plan = await planGptCollapse(turns, firstUserIdx + 1, profitable, {
|
||||
...o.gptHistory,
|
||||
reflow: o.reflow,
|
||||
cols: o.gptHistory?.cols ?? resolveGptProfile(req.model).stripCols,
|
||||
maxHeightPx: o.gptHistory?.maxHeightPx ?? resolveGptProfile(req.model).maxHeightPx,
|
||||
style: o.gptHistory?.style ?? profileRenderStyle(req.model),
|
||||
cols: o.gptHistory?.cols ?? profile.stripCols,
|
||||
maxHeightPx: o.gptHistory?.maxHeightPx ?? profile.maxHeightPx,
|
||||
style: o.gptHistory?.style ?? profile.style,
|
||||
});
|
||||
foldGptHistory(info, req.model, plan);
|
||||
const allImages = [...plan.images, ...plan.imagesAfter];
|
||||
|
||||
+213
-50
@@ -8,6 +8,7 @@
|
||||
import {
|
||||
ATLAS_CELL_W,
|
||||
ATLAS_CELL_H,
|
||||
ATLAS_ASCENT,
|
||||
ATLAS_PIXELS,
|
||||
ATLAS_OFFSETS,
|
||||
ATLAS_WIDE_FLAGS,
|
||||
@@ -16,13 +17,128 @@ import {
|
||||
import {
|
||||
ATLAS_GRAY_CELL_W,
|
||||
ATLAS_GRAY_CELL_H,
|
||||
ATLAS_GRAY_ASCENT,
|
||||
ATLAS_GRAY_PIXELS,
|
||||
ATLAS_GRAY_OFFSETS,
|
||||
ATLAS_GRAY_WIDE_FLAGS,
|
||||
atlasGrayRank,
|
||||
} from './atlas-gray.js';
|
||||
import {
|
||||
ATLAS_CELL_W as JBM10_CELL_W,
|
||||
ATLAS_CELL_H as JBM10_CELL_H,
|
||||
ATLAS_ASCENT as JBM10_ASCENT,
|
||||
ATLAS_PIXELS as JBM10_PIXELS,
|
||||
ATLAS_OFFSETS as JBM10_OFFSETS,
|
||||
ATLAS_WIDE_FLAGS as JBM10_WIDE_FLAGS,
|
||||
atlasRank as jbMono10Rank,
|
||||
} from './atlas-jbmono10.js';
|
||||
import {
|
||||
ATLAS_GRAY_CELL_W as JBM10_GRAY_CELL_W,
|
||||
ATLAS_GRAY_CELL_H as JBM10_GRAY_CELL_H,
|
||||
ATLAS_GRAY_ASCENT as JBM10_GRAY_ASCENT,
|
||||
ATLAS_GRAY_PIXELS as JBM10_GRAY_PIXELS,
|
||||
ATLAS_GRAY_OFFSETS as JBM10_GRAY_OFFSETS,
|
||||
ATLAS_GRAY_WIDE_FLAGS as JBM10_GRAY_WIDE_FLAGS,
|
||||
atlasGrayRank as jbMono10GrayRank,
|
||||
} from './atlas-gray-jbmono10.js';
|
||||
import { encodeGrayPng, encodeRgbPng } from './png.js';
|
||||
|
||||
export type RenderFont = 'spleen-5x8' | 'jetbrains-mono-10';
|
||||
export const DEFAULT_RENDER_FONT: RenderFont = 'spleen-5x8';
|
||||
|
||||
interface BitAtlas {
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
ascent: number;
|
||||
pixels: Uint8Array;
|
||||
offsets: Uint32Array;
|
||||
wideFlags: Uint8Array;
|
||||
rank: (codepoint: number) => number;
|
||||
}
|
||||
|
||||
interface GrayAtlas {
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
ascent: number;
|
||||
pixels: Uint8Array;
|
||||
offsets: Uint32Array;
|
||||
wideFlags: Uint8Array;
|
||||
rank: (codepoint: number) => number;
|
||||
}
|
||||
|
||||
interface AtlasSet {
|
||||
bit: BitAtlas;
|
||||
gray: GrayAtlas;
|
||||
}
|
||||
|
||||
const DEFAULT_ATLAS: AtlasSet = {
|
||||
bit: {
|
||||
cellW: ATLAS_CELL_W,
|
||||
cellH: ATLAS_CELL_H,
|
||||
ascent: ATLAS_ASCENT,
|
||||
pixels: ATLAS_PIXELS,
|
||||
offsets: ATLAS_OFFSETS,
|
||||
wideFlags: ATLAS_WIDE_FLAGS,
|
||||
rank: atlasRank,
|
||||
},
|
||||
gray: {
|
||||
cellW: ATLAS_GRAY_CELL_W,
|
||||
cellH: ATLAS_GRAY_CELL_H,
|
||||
ascent: ATLAS_GRAY_ASCENT,
|
||||
pixels: ATLAS_GRAY_PIXELS,
|
||||
offsets: ATLAS_GRAY_OFFSETS,
|
||||
wideFlags: ATLAS_GRAY_WIDE_FLAGS,
|
||||
rank: atlasGrayRank,
|
||||
},
|
||||
};
|
||||
|
||||
const JBM10_ATLAS: AtlasSet = {
|
||||
bit: {
|
||||
cellW: JBM10_CELL_W,
|
||||
cellH: JBM10_CELL_H,
|
||||
ascent: JBM10_ASCENT,
|
||||
pixels: JBM10_PIXELS,
|
||||
offsets: JBM10_OFFSETS,
|
||||
wideFlags: JBM10_WIDE_FLAGS,
|
||||
rank: jbMono10Rank,
|
||||
},
|
||||
gray: {
|
||||
cellW: JBM10_GRAY_CELL_W,
|
||||
cellH: JBM10_GRAY_CELL_H,
|
||||
ascent: JBM10_GRAY_ASCENT,
|
||||
pixels: JBM10_GRAY_PIXELS,
|
||||
offsets: JBM10_GRAY_OFFSETS,
|
||||
wideFlags: JBM10_GRAY_WIDE_FLAGS,
|
||||
rank: jbMono10GrayRank,
|
||||
},
|
||||
};
|
||||
|
||||
function atlasSet(font: RenderFont | undefined): AtlasSet {
|
||||
return font === 'jetbrains-mono-10' ? JBM10_ATLAS : DEFAULT_ATLAS;
|
||||
}
|
||||
|
||||
function bitGlyph(codepoint: number, font: RenderFont | undefined): { atlas: BitAtlas; rank: number } | null {
|
||||
const selected = atlasSet(font).bit;
|
||||
const rank = selected.rank(codepoint);
|
||||
if (rank >= 0) return { atlas: selected, rank };
|
||||
if (selected !== DEFAULT_ATLAS.bit) {
|
||||
const fallbackRank = DEFAULT_ATLAS.bit.rank(codepoint);
|
||||
if (fallbackRank >= 0) return { atlas: DEFAULT_ATLAS.bit, rank: fallbackRank };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function grayGlyph(codepoint: number, font: RenderFont | undefined): { atlas: GrayAtlas; rank: number } | null {
|
||||
const selected = atlasSet(font).gray;
|
||||
const rank = selected.rank(codepoint);
|
||||
if (rank >= 0) return { atlas: selected, rank };
|
||||
if (selected !== DEFAULT_ATLAS.gray) {
|
||||
const fallbackRank = DEFAULT_ATLAS.gray.rank(codepoint);
|
||||
if (fallbackRank >= 0) return { atlas: DEFAULT_ATLAS.gray, rank: fallbackRank };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Page-height ceiling. Measured (2026-07-01, count_tokens sweep, claude-sonnet-4-5 — see
|
||||
* /tmp/pxexp/LEVER1-findings.md): the API downscales any image to fit BOTH long-edge ≤1568
|
||||
* AND ~1.15 MP (≈1,143,750 px), then bills ≈ px/750 (≈1525 tok cap, applied per-image).
|
||||
@@ -40,9 +156,11 @@ export const DENSE_CONTENT_COLS = 312;
|
||||
/** Bare 5×8 cell (no padding). A/B showed 5×8 beats 7×10 on dense JSON (4/5 vs 3/5 reads, 42% fewer tokens).
|
||||
* Revert to {cellWBonus:2, cellHBonus:2} if misread rates rise. */
|
||||
export const DENSE_RENDER_STYLE: RenderStyle = { cellWBonus: 0, cellHBonus: 0, aa: true };
|
||||
/** Anthropic static slab uses the same measured 312-column no-resize geometry. */
|
||||
export const ANTHROPIC_SLAB_COLS = DENSE_CONTENT_COLS;
|
||||
/** Default columns for the static slab. 312 × 5 px + 8 px pad = 1568 px — exactly the API's
|
||||
* long-edge bound (313 cols = 1573 px would trigger a 0.997× resample, blurring every glyph). */
|
||||
const DEFAULT_COLS = 312;
|
||||
const DEFAULT_COLS = ANTHROPIC_SLAB_COLS;
|
||||
/** Horizontal padding (left + right each), px. Exported for transform.ts token-cost math. */
|
||||
export const PAD_X = 4;
|
||||
/** Vertical padding (top + bottom each), px. Exported for transform.ts token-cost math. */
|
||||
@@ -72,6 +190,8 @@ export interface RenderedImage {
|
||||
/** Optional render-time styling. All fields unset = production default 5×8 cell.
|
||||
* Eval harness overrides per variant to A/B cell sizes and structure aids. */
|
||||
export interface RenderStyle {
|
||||
/** Rasterized font atlas. Alternate atlases fall back to Spleen/Unifont for missing Unicode. */
|
||||
font?: RenderFont;
|
||||
/** Draw faint grey grid rules onto background pixels (zero pixel cost). */
|
||||
grid?: boolean;
|
||||
/** Draw a vertical grid rule every N columns. 0/unset = row rules only. */
|
||||
@@ -84,7 +204,7 @@ export interface RenderStyle {
|
||||
cellHBonus?: number;
|
||||
/** Extra blank columns beside the 5px glyph (cell width = 5 + this). Negative overlaps glyphs. Unset = DEFAULT_CELL_W_BONUS. */
|
||||
cellWBonus?: number;
|
||||
/** Use the AA grayscale atlas (atlas-gray.ts). EVAL-ONLY; default 1-bit path is unchanged. */
|
||||
/** Use the AA grayscale companion atlas. */
|
||||
aa?: boolean;
|
||||
/** Cycle palette colors per glyph for per-character boundary cues. Forces RGB output. Composes with aa. */
|
||||
colorCycle?: boolean;
|
||||
@@ -93,16 +213,30 @@ export interface RenderStyle {
|
||||
colorByRole?: boolean;
|
||||
}
|
||||
|
||||
export function renderCellWidth(style: RenderStyle = {}): number {
|
||||
const atlas = style.aa === true ? atlasSet(style.font).gray : atlasSet(style.font).bit;
|
||||
return Math.max(1, atlas.cellW + Math.floor(style.cellWBonus ?? DEFAULT_CELL_W_BONUS));
|
||||
}
|
||||
|
||||
export function renderCellHeight(style: RenderStyle = {}): number {
|
||||
const atlas = style.aa === true ? atlasSet(style.font).gray : atlasSet(style.font).bit;
|
||||
return atlas.cellH + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
|
||||
}
|
||||
|
||||
// --- column-aware wrapping -------------------------------------------------
|
||||
|
||||
/** Visual width of a codepoint in cells (1 = Latin, 2 = East Asian Wide).
|
||||
* Missing codepoints advance 1 cell so wrap math stays stable. */
|
||||
function cellsFor(codepoint: number, markerScale: number = 1): number {
|
||||
function cellsFor(
|
||||
codepoint: number,
|
||||
markerScale: number = 1,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
): number {
|
||||
// Enlarged ↵ occupies markerScale cells of wrap budget instead of 1.
|
||||
if (codepoint === NL_SENTINEL_CP && markerScale > 1) return markerScale;
|
||||
const rank = atlasRank(codepoint);
|
||||
if (rank < 0) return 1;
|
||||
return ATLAS_WIDE_FLAGS[rank] === 1 ? 2 : 1;
|
||||
const glyph = bitGlyph(codepoint, font);
|
||||
if (!glyph) return 1;
|
||||
return glyph.atlas.wideFlags[glyph.rank] === 1 ? 2 : 1;
|
||||
}
|
||||
|
||||
const TAB_WIDTH = 4; // standard 4-space tab stops (logs, code, tool output are all 4-oriented)
|
||||
@@ -251,21 +385,30 @@ export function expandTabsInLine(line: string): string {
|
||||
}
|
||||
|
||||
/** Visual width of a line in cells. Wide CJK = 2; enlarged ↵ = markerScale. */
|
||||
export function measureLineCols(line: string, markerScale: number = 1): number {
|
||||
export function measureLineCols(
|
||||
line: string,
|
||||
markerScale: number = 1,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
): number {
|
||||
let w = 0;
|
||||
for (const ch of line) w += cellsFor(ch.codePointAt(0)!, markerScale);
|
||||
for (const ch of line) w += cellsFor(ch.codePointAt(0)!, markerScale, font);
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Always renders at full canvas width. Signature kept for transform.ts compatibility; returns cols unchanged. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function shrinkColsToContent(text: string, cols: number, markerScale: number = 1): number {
|
||||
export function shrinkColsToContent(
|
||||
text: string,
|
||||
cols: number,
|
||||
markerScale: number = 1,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
): number {
|
||||
// Real content-width measurement — delegates to measureContentCols (was historically a
|
||||
// no-op stub). The proxy's cost gate AND renderer both call this, so sizing the canvas to
|
||||
// the widest line here makes the proxy match the SDK/export path (renderTextToImages),
|
||||
// which already uses measureContentCols. Never narrows below the widest line ⇒ row count
|
||||
// (and thus image/paging count) is unchanged; only the canvas WIDTH (pixel cost) drops.
|
||||
return measureContentCols(text, cols, markerScale);
|
||||
return measureContentCols(text, cols, markerScale, font);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,13 +419,18 @@ export function shrinkColsToContent(text: string, cols: number, markerScale: num
|
||||
* deterministic width → cache-prefix-safe. Tabs are expanded so the measured width
|
||||
* matches what the renderer actually lays out.
|
||||
*/
|
||||
export function measureContentCols(text: string, maxCols: number, markerScale: number = 1): number {
|
||||
export function measureContentCols(
|
||||
text: string,
|
||||
maxCols: number,
|
||||
markerScale: number = 1,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
): number {
|
||||
const cap = Math.max(1, maxCols | 0);
|
||||
let widest = 1;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= text.length; i++) {
|
||||
if (i === text.length || text[i] === '\n') {
|
||||
const w = measureLineCols(expandTabsInLine(text.slice(start, i)), markerScale);
|
||||
const w = measureLineCols(expandTabsInLine(text.slice(start, i)), markerScale, font);
|
||||
if (w > widest) widest = w;
|
||||
if (widest >= cap) return cap;
|
||||
start = i + 1;
|
||||
@@ -291,7 +439,12 @@ export function measureContentCols(text: string, maxCols: number, markerScale: n
|
||||
return Math.min(cap, widest);
|
||||
}
|
||||
|
||||
export function wrapLines(text: string, cols: number, markerScale: number = 1): string[] {
|
||||
export function wrapLines(
|
||||
text: string,
|
||||
cols: number,
|
||||
markerScale: number = 1,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const minified = minifyForRender(text);
|
||||
for (const rawWithTabs of minified.split('\n')) {
|
||||
@@ -306,7 +459,7 @@ export function wrapLines(text: string, cols: number, markerScale: number = 1):
|
||||
// ↵ is treated as an inline glyph — it never forces a row break.
|
||||
for (const ch of raw) {
|
||||
const cp = ch.codePointAt(0)!;
|
||||
const w = cellsFor(cp, markerScale);
|
||||
const w = cellsFor(cp, markerScale, font);
|
||||
if (curCols + w > cols) {
|
||||
out.push(cur);
|
||||
cur = ch;
|
||||
@@ -363,20 +516,23 @@ function blitGlyph(
|
||||
x: number,
|
||||
y: number,
|
||||
codepoint: number,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
markerMask: Uint8Array | null = null,
|
||||
): number {
|
||||
const rank = atlasRank(codepoint);
|
||||
if (rank < 0) return 0;
|
||||
const wide = ATLAS_WIDE_FLAGS[rank] === 1;
|
||||
const srcW = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
|
||||
const glyph = bitGlyph(codepoint, font);
|
||||
if (!glyph) return 0;
|
||||
const { atlas, rank } = glyph;
|
||||
const wide = atlas.wideFlags[rank] === 1;
|
||||
const srcW = wide ? 2 * atlas.cellW : atlas.cellW;
|
||||
// ATLAS_OFFSETS is a bit offset (MSB-first packing). Pixel (gx,gy): byte = bitIdx>>>3, bit = 7-(bitIdx&7).
|
||||
const srcOff = ATLAS_OFFSETS[rank]!;
|
||||
for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
|
||||
const dstRow = (y + gy) * fbW + x;
|
||||
const srcOff = atlas.offsets[rank]!;
|
||||
const yOffset = atlasSet(font).bit.ascent - atlas.ascent;
|
||||
for (let gy = 0; gy < atlas.cellH; gy++) {
|
||||
const dstRow = (y + yOffset + gy) * fbW + x;
|
||||
const bitRowStart = srcOff + gy * srcW;
|
||||
for (let gx = 0; gx < srcW; gx++) {
|
||||
const bitIdx = bitRowStart + gx;
|
||||
const byte = ATLAS_PIXELS[bitIdx >>> 3]!;
|
||||
const byte = atlas.pixels[bitIdx >>> 3]!;
|
||||
const bit = (byte >>> (7 - (bitIdx & 7))) & 1;
|
||||
if (bit) {
|
||||
fb[dstRow + gx] = 255; // glyphs never overlap in grid layout; set unconditionally
|
||||
@@ -397,18 +553,21 @@ function blitGlyphGray(
|
||||
x: number,
|
||||
y: number,
|
||||
codepoint: number,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
): number {
|
||||
const rank = atlasGrayRank(codepoint);
|
||||
if (rank < 0) return 0;
|
||||
const wide = ATLAS_GRAY_WIDE_FLAGS[rank] === 1;
|
||||
const srcW = wide ? 2 * ATLAS_GRAY_CELL_W : ATLAS_GRAY_CELL_W;
|
||||
const glyph = grayGlyph(codepoint, font);
|
||||
if (!glyph) return 0;
|
||||
const { atlas, rank } = glyph;
|
||||
const wide = atlas.wideFlags[rank] === 1;
|
||||
const srcW = wide ? 2 * atlas.cellW : atlas.cellW;
|
||||
// ATLAS_GRAY_OFFSETS is a byte offset (1 byte/pixel, unlike the bit-packed 1-bit atlas).
|
||||
const srcOff = ATLAS_GRAY_OFFSETS[rank]!;
|
||||
for (let gy = 0; gy < ATLAS_GRAY_CELL_H; gy++) {
|
||||
const dstRow = (y + gy) * fbW + x;
|
||||
const srcOff = atlas.offsets[rank]!;
|
||||
const yOffset = atlasSet(font).gray.ascent - atlas.ascent;
|
||||
for (let gy = 0; gy < atlas.cellH; gy++) {
|
||||
const dstRow = (y + yOffset + gy) * fbW + x;
|
||||
const srcRow = srcOff + gy * srcW;
|
||||
for (let gx = 0; gx < srcW; gx++) {
|
||||
const coverage = ATLAS_GRAY_PIXELS[srcRow + gx]!;
|
||||
const coverage = atlas.pixels[srcRow + gx]!;
|
||||
if (coverage > 0) {
|
||||
const idx = dstRow + gx;
|
||||
if (coverage > fb[idx]!) fb[idx] = coverage;
|
||||
@@ -429,19 +588,22 @@ function blitGlyphScaled(
|
||||
y: number,
|
||||
codepoint: number,
|
||||
scaleX: number,
|
||||
font: RenderFont = DEFAULT_RENDER_FONT,
|
||||
): number {
|
||||
const rank = atlasRank(codepoint);
|
||||
if (rank < 0) return 0;
|
||||
const wide = ATLAS_WIDE_FLAGS[rank] === 1;
|
||||
const srcW = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
|
||||
const srcOff = ATLAS_OFFSETS[rank]!;
|
||||
for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
|
||||
const py = y + gy;
|
||||
const glyph = bitGlyph(codepoint, font);
|
||||
if (!glyph) return 0;
|
||||
const { atlas, rank } = glyph;
|
||||
const wide = atlas.wideFlags[rank] === 1;
|
||||
const srcW = wide ? 2 * atlas.cellW : atlas.cellW;
|
||||
const srcOff = atlas.offsets[rank]!;
|
||||
const yOffset = atlasSet(font).bit.ascent - atlas.ascent;
|
||||
for (let gy = 0; gy < atlas.cellH; gy++) {
|
||||
const py = y + yOffset + gy;
|
||||
if (py >= fbH) break;
|
||||
const bitRowStart = srcOff + gy * srcW;
|
||||
for (let gx = 0; gx < srcW; gx++) {
|
||||
const bitIdx = bitRowStart + gx;
|
||||
const byte = ATLAS_PIXELS[bitIdx >>> 3]!;
|
||||
const byte = atlas.pixels[bitIdx >>> 3]!;
|
||||
if (((byte >>> (7 - (bitIdx & 7))) & 1) === 0) continue;
|
||||
for (let sx = 0; sx < scaleX; sx++) {
|
||||
const px = x + gx * scaleX + sx;
|
||||
@@ -497,18 +659,19 @@ export async function renderChunkToPng(
|
||||
slotText?: string,
|
||||
): Promise<RenderedImage> {
|
||||
const useAA = style.aa === true;
|
||||
const atlasH = useAA ? ATLAS_GRAY_CELL_H : ATLAS_CELL_H;
|
||||
const atlasW = useAA ? ATLAS_GRAY_CELL_W : ATLAS_CELL_W;
|
||||
const selected = atlasSet(style.font);
|
||||
const atlasH = useAA ? selected.gray.cellH : selected.bit.cellH;
|
||||
const atlasW = useAA ? selected.gray.cellW : selected.bit.cellW;
|
||||
const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
|
||||
const cellH = atlasH + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
|
||||
const cellW = Math.max(1, atlasW + Math.floor(style.cellWBonus ?? DEFAULT_CELL_W_BONUS));
|
||||
const lines = wrapLines(text, cols, markerScale);
|
||||
const cellH = renderCellHeight(style);
|
||||
const cellW = renderCellWidth(style);
|
||||
const lines = wrapLines(text, cols, markerScale, style.font);
|
||||
// Slot string carries role attribution by position. It is width-identical to
|
||||
// `text`, so wrapLines splits it into the exact same rows — fitSlotLines[r] aligns
|
||||
// codepoint-for-codepoint with fitLines[r]. Only built when coloring is on.
|
||||
const slotLines: string[] | null =
|
||||
style.colorByRole === true && slotText !== undefined
|
||||
? wrapLines(slotText, cols, markerScale)
|
||||
? wrapLines(slotText, cols, markerScale, style.font)
|
||||
: null;
|
||||
|
||||
const maxLines = Math.max(1, Math.floor((maxHeightPx - 2 * PAD_Y) / cellH));
|
||||
@@ -567,7 +730,7 @@ export async function renderChunkToPng(
|
||||
: (glyphIndex % GLYPH_PALETTE.length) + 1; // 0 reserved for background in colorMask
|
||||
let advance: number;
|
||||
if (isMarker && markerScale > 1) {
|
||||
advance = blitGlyphScaled(fb, markerMask, width, height, baseX, baseY, codepoint, markerScale);
|
||||
advance = blitGlyphScaled(fb, markerMask, width, height, baseX, baseY, codepoint, markerScale, style.font);
|
||||
if (colorMask) {
|
||||
for (let gy = 0; gy < atlasH; gy++) {
|
||||
const py = baseY + gy;
|
||||
@@ -581,7 +744,7 @@ export async function renderChunkToPng(
|
||||
}
|
||||
}
|
||||
} else if (useAA) {
|
||||
advance = blitGlyphGray(fb, width, baseX, baseY, codepoint);
|
||||
advance = blitGlyphGray(fb, width, baseX, baseY, codepoint, style.font);
|
||||
if (colorMask && advance > 0) {
|
||||
const srcW = advance * atlasW;
|
||||
for (let gy = 0; gy < atlasH; gy++) {
|
||||
@@ -596,7 +759,7 @@ export async function renderChunkToPng(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
advance = blitGlyph(fb, width, baseX, baseY, codepoint, isMarker ? markerMask : null);
|
||||
advance = blitGlyph(fb, width, baseX, baseY, codepoint, style.font, isMarker ? markerMask : null);
|
||||
if (colorMask && advance > 0) {
|
||||
const srcW = advance * atlasW;
|
||||
for (let gy = 0; gy < atlasH; gy++) {
|
||||
@@ -694,13 +857,13 @@ export async function renderTextToPngsWithCharLimit(
|
||||
slotText?: string,
|
||||
): Promise<RenderedImage[]> {
|
||||
const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
|
||||
const cellH = ATLAS_CELL_H + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
|
||||
const lines = wrapLines(text, cols, markerScale);
|
||||
const cellH = renderCellHeight(style);
|
||||
const lines = wrapLines(text, cols, markerScale, style.font);
|
||||
// Width-identical slot rows align 1:1 with `lines`; pages are contiguous slices,
|
||||
// so the same index range gives each page its slot half. Only built when coloring.
|
||||
const slotLines: string[] | null =
|
||||
style.colorByRole === true && slotText !== undefined
|
||||
? wrapLines(slotText, cols, markerScale)
|
||||
? wrapLines(slotText, cols, markerScale, style.font)
|
||||
: null;
|
||||
const hardLinesPerImg = Math.max(1, Math.floor((maxHeightPx - 2 * PAD_Y) / cellH));
|
||||
// Dense pages (DENSE_CONTENT_CHARS_PER_IMAGE) fill the full 1932 px height;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
DENSE_CONTENT_CHARS_PER_IMAGE,
|
||||
DENSE_CONTENT_COLS,
|
||||
DENSE_RENDER_STYLE,
|
||||
ANTHROPIC_SLAB_COLS,
|
||||
renderTextToPngsWithCharLimit,
|
||||
} from './render.js';
|
||||
import { factSheetText } from './factsheet.js';
|
||||
@@ -134,8 +135,8 @@ const DEFAULTS: Required<TransformOptions> = {
|
||||
minToolResultChars: 6000,
|
||||
// system field rejects images (400 system.N.type: Input should be 'text') —
|
||||
// images always go into the first user message.
|
||||
// 313 cols × 5 px + 8 px pad = 1573 px slab width (under 2000 px ceiling).
|
||||
cols: 313,
|
||||
// 312 cols × 5 px + 8 px pad = 1568 px (Anthropic no-resize edge).
|
||||
cols: ANTHROPIC_SLAB_COLS,
|
||||
maxImagesPerToolResult: 10,
|
||||
charsPerToken: 4,
|
||||
historyAmortizationHorizon: 1,
|
||||
@@ -1097,10 +1098,12 @@ export function estimateImageCount(
|
||||
cols: number,
|
||||
numCols: number = 1,
|
||||
maxCharsPerImage: number = READABLE_CHARS_PER_IMAGE,
|
||||
maxLinesPerColumn: number = LINES_PER_IMAGE,
|
||||
): number {
|
||||
const n = Math.max(1, numCols | 0);
|
||||
const readableLinesPerCol = Math.max(1, Math.floor(maxCharsPerImage / Math.max(1, cols)));
|
||||
const linesPerImage = Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n;
|
||||
const hardLinesPerCol = Math.max(1, Math.floor(maxLinesPerColumn));
|
||||
const linesPerImage = Math.min(hardLinesPerCol, readableLinesPerCol) * n;
|
||||
const charBudget = Math.max(1, maxCharsPerImage * n);
|
||||
if (typeof textOrLen === 'number') {
|
||||
// Back-compat shim — numeric arg gets the looser chars-based estimate.
|
||||
|
||||
Reference in New Issue
Block a user