/** * reapi-quote.ts — the pure pricing of a `reapi-seedance` payload from treg's * LIVE cost table for `reapi.video-gen.seedance-2-5.unrestricted` * (`GET https://treg.to/catalog/endpoints/`, unauthenticated), so the * durable cinema queue can authorise an exact quote that equals the charge. * * The table (read 2026-09-21) carries, per resolution, a per-second USD rate * with a minimum of billable seconds (`times: "body.duration"`, * `times_min: 4`) and separate reserve rows for `duration: -1`, which this * route never sends. reAPI settles per second of OUTPUT plus, when a * reference VIDEO is sent, the reference footage on top with a floor: * * billable = max(ceil(Σ reference video seconds) + duration, ceil(5·duration/3)) * credits = ceil(rate × billable × 1000) (1 credit = $0.001) * * Images and audio add nothing. Nothing here does I/O; the adapter binary * feeds it the table, the reference lengths and the balance it measured. */ import { REAPI_MIN_DURATION_SEC } from './provider-platform/route-capabilities.js'; import { reapiCreditsToUsd, type ReapiResolution } from './providers/reapi-seedance.js'; import type { VideoExecutionPayload } from './types.js'; export interface ReapiRateRow { resolution: ReapiResolution; /** USD per billable second. */ usdPerSecond: number; /** The fewest seconds a task is billed for. */ minimumSeconds: number; } export interface ReapiCostTable { rows: ReapiRateRow[]; /** treg's own provenance stamp for the table, when present. */ checked: string | null; source: string | null; } /** * Read the per-second rows out of a treg catalog endpoint document (the JSON * `GET /catalog/endpoints/` returns, or the `endpoint` object inside it). * Reserve rows (`body.duration: -1`) are skipped: the route never sends -1. * Throws when no per-second row exists, because a quote with no rate is a guess. */ export function parseTregCostTable(document: unknown): ReapiCostTable { const root = (document && typeof document === 'object' ? document : {}) as Record; const endpoint = (root.endpoint && typeof root.endpoint === 'object' ? root.endpoint : root) as Record; const cost = (endpoint.cost && typeof endpoint.cost === 'object' ? endpoint.cost : {}) as Record; const table = Array.isArray(cost.table) ? cost.table : []; const rows: ReapiRateRow[] = []; for (const entry of table) { if (!entry || typeof entry !== 'object') continue; const row = entry as Record; const when = (row.when && typeof row.when === 'object' ? row.when : {}) as Record; if (row.times !== 'body.duration') continue; const resolution = when['body.resolution']; if (resolution !== '480p' && resolution !== '720p' && resolution !== '1080p') continue; if (typeof row.value !== 'number' || !Number.isFinite(row.value) || row.value <= 0) continue; const minimum = typeof row.times_min === 'number' && row.times_min > 0 ? row.times_min : REAPI_MIN_DURATION_SEC; rows.push({ resolution, usdPerSecond: row.value, minimumSeconds: minimum }); } if (rows.length === 0) { throw new Error('treg cost table for reapi.video-gen.seedance-2-5.unrestricted carries no per-second row; refusing to quote without a published rate.'); } if (cost.currency !== undefined && cost.currency !== 'USD') { throw new Error(`treg cost table is priced in ${String(cost.currency)}, not USD; the quote adapter prices in USD only.`); } return { rows, checked: typeof cost.checked === 'string' ? cost.checked : null, source: typeof cost.source_url === 'string' ? cost.source_url : null, }; } /** reAPI's billable seconds for one task (docs/seedance-2-5 "Pricing"). */ export function reapiBillableSeconds(durationSeconds: number, referenceVideoSeconds: number): number { if (referenceVideoSeconds <= 0) return durationSeconds; return Math.max(Math.ceil(referenceVideoSeconds) + durationSeconds, Math.ceil((5 * durationSeconds) / 3)); } /** The credits reAPI settles a task at: ceil(rate × seconds × 1000). */ export function reapiCreditsFor(usdPerSecond: number, billableSeconds: number): number { // Round the product to a nanocredit first so 0.1186 × 4 × 1000 = 474.4 does // not read 474.40000000000003 and ceil to 475. return Math.ceil(Math.round(usdPerSecond * billableSeconds * 1000 * 1e6) / 1e6); } export interface ReapiTaskQuote { sceneIndex: number; resolution: ReapiResolution; durationSeconds: number; referenceVideoSeconds: number; billableSeconds: number; usdPerSecond: number; credits: number; usd: number; } export interface ReapiPayloadQuote { /** Total in USD, the currency `actualCost` is stated in. */ unitCostUsd: number; totalCredits: number; perTask: ReapiTaskQuote[]; } export interface QuoteReapiPayloadOptions { /** `VCLAW_REAPI_SEEDANCE_RESOLUTION` when set — the same override the transport reads. */ resolutionOverride?: string; /** Measured length of every reference VIDEO per scene, in seconds (0 when none). */ referenceVideoSecondsByScene: Map; } function resolutionFor(profile: VideoExecutionPayload['executionProfile']['resolution'], override: string | undefined): ReapiResolution { const raw = (override ?? '').trim().toLowerCase(); if (raw === '480p' || raw === '720p' || raw === '1080p') return raw; return profile === '1080p' ? '1080p' : '720p'; } /** * Price every task of a payload exactly as reAPI will settle it. A duration * that is not a whole second 4–30 is refused here too (the transport refuses * it as well; a quote for a render that cannot submit is worthless), as is a * resolution the table does not price. */ export function quoteReapiPayload(table: ReapiCostTable, payload: VideoExecutionPayload, options: QuoteReapiPayloadOptions): ReapiPayloadQuote { const profileResolution = resolutionFor(payload.executionProfile.resolution, options.resolutionOverride); const overridden = /^(480p|720p|1080p)$/.test((options.resolutionOverride ?? '').trim().toLowerCase()); const perTask: ReapiTaskQuote[] = payload.tasks.map((task) => { // The same precedence as the transport: the env probe override, else the // packet's own per-render resolution, else the profile. const taskResolution = task.resolution?.trim().toLowerCase(); const resolution: ReapiResolution = !overridden && (taskResolution === '480p' || taskResolution === '720p' || taskResolution === '1080p') ? taskResolution : profileResolution; const row = table.rows.find((entry) => entry.resolution === resolution); if (!row) { throw new Error(`treg cost table prices ${table.rows.map((entry) => entry.resolution).join(', ')} but scene ${task.sceneIndex} asks for ${resolution}.`); } const duration = task.durationSeconds; if (duration === undefined || duration === null) { throw new Error(`reapi-seedance scene ${task.sceneIndex}: no durationSeconds — a per-second route is not quoted on an assumed length; set the scene length and re-plan.`); } if (!Number.isInteger(duration) || duration < REAPI_MIN_DURATION_SEC || duration > 30) { throw new Error(`reapi-seedance scene ${task.sceneIndex}: duration ${duration}s is not a whole second 4–30, so it cannot be priced (or submitted).`); } const referenceVideoSeconds = options.referenceVideoSecondsByScene.get(task.sceneIndex) ?? 0; const billable = Math.max(reapiBillableSeconds(duration, referenceVideoSeconds), row.minimumSeconds); const credits = reapiCreditsFor(row.usdPerSecond, billable); return { sceneIndex: task.sceneIndex, resolution, durationSeconds: duration, referenceVideoSeconds, billableSeconds: billable, usdPerSecond: row.usdPerSecond, credits, usd: reapiCreditsToUsd(credits), }; }); const totalCredits = perTask.reduce((sum, entry) => sum + entry.credits, 0); return { unitCostUsd: reapiCreditsToUsd(totalCredits), totalCredits, perTask }; }