/** * Pacing for the try-on processing view. * * Measured generation times (photo-ranker-be, gemini provider): p50 ~11s, * p90 ~35s, worst observed 48s, and identical inputs on the same day * produced both 10.1s and 35.7s, so nothing we hold predicts which we get. * * That rules out a countdown: any estimate would be wrong most of the time. * Instead both the bar and the status copy are pure functions of elapsed * time. There is no estimate to be wrong about, and because they share one * input they cannot drift out of sync with each other. */ /** The bar approaches but never reaches this. It has nowhere to stall. */ export const BAR_CEILING = 0.92; /** * Time constant of the asymptotic fill, in seconds. Chosen so the bar reads * half-full at our measured p50 of 11s: * 0.92 * (1 - e^(-11/14)) ≈ 0.50 * Tune this if the measured p50 moves. */ export const TAU_SECONDS = 14; /** Elapsed seconds at which each status string takes over. */ export const STATUS_SCHEDULE_SECONDS = [0, 4, 8, 13, 19, 26] as const; export const STATUS_COUNT = STATUS_SCHEDULE_SECONDS.length; /** * Fraction of the progress bar to fill. Strictly increasing, asymptotic to * BAR_CEILING, so it is always still moving no matter how long generation * takes. */ export function barProgress(elapsedMs: number): number { const seconds = Math.max(0, elapsedMs) / 1000; return BAR_CEILING * (1 - Math.exp(-seconds / TAU_SECONDS)); } /** * Index of the status string to show. Non-decreasing, and holds on the last * string forever rather than wrapping, a visible loop would give the game * away, a held line just reads as "still working". */ export function statusIndexFor(elapsedMs: number): number { const seconds = Math.max(0, elapsedMs) / 1000; let index = 0; for (let i = 0; i < STATUS_SCHEDULE_SECONDS.length; i++) { if (seconds >= STATUS_SCHEDULE_SECONDS[i]) { index = i; } } return index; } /** Elapsed seconds truncated to one decimal, e.g. 12.4. */ export function elapsedTenths(elapsedMs: number): number { return Math.floor(Math.max(0, elapsedMs) / 100) / 10; }