@xuda.io/ai_module 1.1.5656 → 1.1.5658

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -98,6 +98,27 @@ const run_process = function (command, args, input, options = {}) {
98
98
  });
99
99
  };
100
100
 
101
+ // UI-228 (xudex). The runtime seam: every xudex layer asks this for a WORKSPACE and never learns
102
+ // whether it got a VM, an ephemeral runner or a local working copy. See xudex_runtime.mjs and
103
+ // docs/plans/xudex.md section 3.8. Instantiated here rather than inside that file because
104
+ // run_process above is the only spawn wrapper whose detached process-group kill actually stops a
105
+ // Codex run, and a second copy of that fix is the last thing this codebase needs.
106
+ const { create_runtime: _create_xudex_runtime } = await import('./xudex_runtime.mjs');
107
+ const { create_mirror: _create_xudex_mirror } = await import('./xudex_mirror.mjs');
108
+ // The run tracker watches a workspace through the same handle everything else uses, so it
109
+ // works identically on a VM, an ephemeral runner or a container. See xudex_tracker.mjs.
110
+ const { create_run_tracker: create_xudex_run_tracker } = await import('./xudex_tracker.mjs');
111
+ // The engine registry: codex and Claude Code behind one launch/parse contract, so nothing above
112
+ // this line branches on which CLI is running. See xudex_engines.mjs.
113
+ const xudex_engines = await import('./xudex_engines.mjs');
114
+ // The verify loop: what makes a xudex run different from a terminal that says "done" and
115
+ // leaves your build broken. See xudex_verify.mjs.
116
+ const xudex_verify = await import('./xudex_verify.mjs');
117
+ // The last gate of that loop: is the app still actually running. See xudex_preview.mjs.
118
+ const { create_preview: _create_xudex_preview } = await import('./xudex_preview.mjs');
119
+ const xudex_preview = _create_xudex_preview({});
120
+ const xudex_runtime = _create_xudex_runtime({ run_process });
121
+
101
122
  // UI-220. Same spawn, opposite lifetime: start the process, hand back its pid and walk away, with
102
123
  // stdout and stderr going straight to a file that never passes through this process. A code run
103
124
  // has to survive a pm2 restart, and on dev pm2 watches cpi/ai_module so every deploy is one.
@@ -270,11 +291,23 @@ const get_cheapest_ai_model_code = function () {
270
291
  return best?.code || _conf.default_ai_model || null;
271
292
  };
272
293
 
294
+ // Default model for a Codex run nobody picked a model for (the picker's "Auto").
295
+ // Deliberately NOT the cheapest catalog entry. A Codex run is not a chat turn: it
296
+ // drives real tools over many turns, and the cheapest model cannot hold that shape.
297
+ // Measured on dev 2026-08-13, a from-scratch site build on gpt-5-nano emitted three
298
+ // shell calls the CLI refused to parse ("failed to parse function arguments:
299
+ // duplicate field `cmd`"), fell back to writing the page through a bash heredoc that
300
+ // died on "syntax error near unexpected token `('", and spent its entire wall-clock
301
+ // budget with nothing written to disk. A run that fails costs its full spend and
302
+ // ships nothing, so the cheap default was never actually the cheap option.
303
+ const CODEX_DEFAULT_MODEL_CODE = 'gpt-2';
273
304
  const get_openai_codex_model = function () {
274
305
  // Codex is user-selectable per request (req.codex_model); when nothing is picked
275
- // the default is the CHEAPEST catalog model. Returns a catalog code (resolved to a
276
- // real OpenAI id at exec time by get_openai_codex_exec_args).
277
- return _conf.openai_codex_model || get_cheapest_ai_model_code();
306
+ // we use the default above. Returns a catalog code (resolved to a real OpenAI id
307
+ // at exec time by get_openai_codex_exec_args). Falls back to the cheapest
308
+ // codex-eligible entry only if that code is missing from the catalog.
309
+ const fallback = _conf.openai_codex_default_model || CODEX_DEFAULT_MODEL_CODE;
310
+ return _conf.openai_codex_model || (_is_codex_model(fallback) ? fallback : get_cheapest_ai_model_code());
278
311
  };
279
312
 
280
313
  // Map a legacy ai_models key still stored on old profiles/agents (e.g. 'gpt-5.4') to
@@ -498,6 +531,19 @@ const bot_ms = await import(`${module_path}/bot_protection_module/index_ms.mjs`)
498
531
  // Owns whether the AI answers, and with what, on every channel. This module used to decide
499
532
  // it from flat fields on the profile doc; see auto_response() below.
500
533
  const auto_response_ms = await import(`${module_path}/auto_response_module/index_ms.mjs`);
534
+ // UI-228: the `xudex_run` identity gate. Declared at level 2 in verify_policy and currently
535
+ // SHADOWED, so this reports and never denies until the platform enforcement floor moves.
536
+ const verify_ms = await import(`${module_path}/verify_module/index_ms.mjs`);
537
+ // UI-228: `xudex_touch`, which is what the idle clock that suspends a machine measures from.
538
+ // The ASYNC wrapper deliberately: nothing about a run should wait on a timestamp being written.
539
+ const deploy_msa = await import(`${module_path}/deploy_module/index_msa.mjs`);
540
+ // The SYNC twin: the VM substrate has to know which machine it got and what Proxmox answered,
541
+ // so those two calls need a reply rather than a fire-and-forget.
542
+ const deploy_ms = await import(`${module_path}/deploy_module/index_ms.mjs`);
543
+ // The entitlement rules themselves, imported as plain logic rather than called over the broker.
544
+ // They are a pure function of the account document, so a queue round trip would buy nothing and
545
+ // cost a hop on the path of every single run.
546
+ const xudex_deploy_rules = await import(`${module_path}/deploy_module/xudex.mjs`);
501
547
 
502
548
  const ws_dashboard_msa = await import(`${module_path}/ws_dashboard_module/index_msa.mjs`);
503
549
  // Sync twin of the above: the chat-finished alert has to ASK whether the user is
@@ -2135,9 +2181,11 @@ export const execute_codex_request = async function (req_or_ip, prompt_arg, atta
2135
2181
  ? `You are OpenAI Codex editing a static website on the local Xuda server.
2136
2182
 
2137
2183
  The site's source files are in your current working directory: ${local_cwd}
2138
- Edit them directly with your normal file tools (read, search, apply patches) everything is local; do NOT use SSH or scp. Keep changes scoped to the user's request and preserve the rest of the site.
2184
+ Edit them directly with your normal file tools (read, search, apply patches). Everything is local, so do NOT use SSH or scp. Keep changes scoped to the user's request and preserve the rest of the site.
2185
+
2186
+ WRITE FILES WITH apply_patch, NEVER THROUGH THE SHELL. Do not create or overwrite HTML, CSS, JS or JSON with heredocs (cat <<'EOF' > file), echo, printf or sed. Site markup is full of quotes, parentheses and $ characters, and the shell mangles them: the file lands corrupted, or bash dies on "syntax error near unexpected token" and the whole build burns its time budget retrying. Use the shell only to list, read and check files.
2139
2187
 
2140
- This is the editable source, not the live site. Do NOT deploy or publish anything the user publishes separately by clicking Publish. When you finish, briefly summarize what you changed.
2188
+ This is the editable source, not the live site. Do NOT deploy or publish anything, the user publishes separately by clicking Publish. When you finish, briefly summarize what you changed.
2141
2189
 
2142
2190
  User request:
2143
2191
  ${prompt}`
@@ -3633,6 +3681,137 @@ export const git_repo_list = async function (req) {
3633
3681
  }
3634
3682
  };
3635
3683
 
3684
+ // ── UI-228: BYOK provider keys ─────────────────────────────────────────────────────────────
3685
+ // docs/plans/xudex.md 5.2. Claude Code and every engine after it run on the CUSTOMER'S key, not
3686
+ // ours. That removes model cost from our books entirely and turns efficiency work into their
3687
+ // saving rather than our margin, which is a better product and an honest one.
3688
+ //
3689
+ // The key is stored exactly where UI-226 puts a git token, and for exactly the same reason: on a
3690
+ // doc in the ACCOUNT'S project database, never on the app doc in xuda_master, because control DBs
3691
+ // replicate bidirectionally fleet-wide through the master hub and a secret there would be copied
3692
+ // to every region. No read path returns more than the last four characters.
3693
+ const XUDEX_KEY_PROVIDERS = ['anthropic', 'openai'];
3694
+
3695
+ // What a key looks like, checked only enough to catch a paste that obviously is not one. This is
3696
+ // deliberately not strict: providers change their prefixes, and refusing a valid key is worse than
3697
+ // accepting an invalid one that fails clearly on first use.
3698
+ const xudex_key_shape_error = function (provider, api_key) {
3699
+ const key = String(api_key || '').trim();
3700
+ if (key.length < 20) return 'That does not look like an API key. Paste the whole key.';
3701
+ if (provider === 'anthropic' && !key.startsWith('sk-ant-')) {
3702
+ // The single most common wrong thing to paste, so it is worth naming: a Claude Pro or Max
3703
+ // SUBSCRIPTION cannot drive Claude Code on our machines, whatever it can do on a laptop.
3704
+ // Saying it here costs one line and saves a support conversation with every team that tries.
3705
+ return 'That does not look like an Anthropic API key (they start with sk-ant-). A Claude Pro or Max subscription will not work here: Xudex needs an API key with credits on it, from console.anthropic.com.';
3706
+ }
3707
+ if (provider === 'openai' && !key.startsWith('sk-')) return 'That does not look like an OpenAI API key (they start with sk-).';
3708
+ return null;
3709
+ };
3710
+
3711
+ const xudex_key_safe = function (doc) {
3712
+ if (!doc) return null;
3713
+ return { provider: doc.provider, last4: doc.key_last4 || null, added_by_uid: doc.added_by_uid, ts: doc.ts };
3714
+ };
3715
+
3716
+ export const xudex_key_set = async function (req) {
3717
+ const { uid, profile_id, provider, api_key } = req;
3718
+ try {
3719
+ if (!XUDEX_KEY_PROVIDERS.includes(provider)) return { code: -1, data: 'that provider is not supported' };
3720
+ const shape_error = xudex_key_shape_error(provider, api_key);
3721
+ if (shape_error) return { code: -1, data: shape_error };
3722
+
3723
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
3724
+ // One key per provider per account: adding a new one replaces the old rather than stacking,
3725
+ // so there is never a question of which of two keys a run used.
3726
+ const existing = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', provider, stat: 3 }, limit: 5 });
3727
+ for (const doc of existing?.docs || []) {
3728
+ doc.stat = 4;
3729
+ doc.ts = Date.now();
3730
+ await db_module.save_app_couch_doc(account_profile_info.app_id, doc);
3731
+ }
3732
+
3733
+ const key_doc = {
3734
+ _id: await _common.xuda_get_uuid('xudex_key'),
3735
+ docType: 'xudex_key',
3736
+ stat: 3,
3737
+ provider,
3738
+ api_key: String(api_key).trim(),
3739
+ key_last4: String(api_key).trim().slice(-4),
3740
+ added_by_uid: uid,
3741
+ date_created_ts: Date.now(),
3742
+ ts: Date.now(),
3743
+ };
3744
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, key_doc);
3745
+ return { code: 1, data: { key: xudex_key_safe(key_doc) } };
3746
+ } catch (err) {
3747
+ console.error(`[xudex] key set failed: ${err?.message || err}`);
3748
+ return { code: -1, data: 'could not save that key' };
3749
+ }
3750
+ };
3751
+
3752
+ export const xudex_key_list = async function (req) {
3753
+ const { uid, profile_id } = req;
3754
+ try {
3755
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
3756
+ const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', stat: 3 }, limit: 20 });
3757
+ return { code: 1, data: { keys: (q?.docs || []).map(xudex_key_safe) } };
3758
+ } catch (err) {
3759
+ return { code: -1, data: 'could not read your keys' };
3760
+ }
3761
+ };
3762
+
3763
+ export const xudex_key_delete = async function (req) {
3764
+ const { uid, profile_id, provider } = req;
3765
+ try {
3766
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
3767
+ const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', provider, stat: 3 }, limit: 5 });
3768
+ let removed = 0;
3769
+ for (const doc of q?.docs || []) {
3770
+ doc.stat = 4;
3771
+ doc.api_key = null;
3772
+ doc.ts = Date.now();
3773
+ await db_module.save_app_couch_doc(account_profile_info.app_id, doc);
3774
+ removed++;
3775
+ }
3776
+ return { code: 1, data: { removed } };
3777
+ } catch (err) {
3778
+ return { code: -1, data: 'could not remove that key' };
3779
+ }
3780
+ };
3781
+
3782
+ // Internal: the key itself, for a run that is about to start. The only path that reads it, and it
3783
+ // never leaves this process except as an environment variable on the engine's own child.
3784
+ const xudex_resolve_api_key = async function (uid, profile_id, provider) {
3785
+ try {
3786
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
3787
+ const q = await db_module.find_app_couch_query(account_profile_info.app_id, { selector: { docType: 'xudex_key', provider, stat: 3 }, limit: 1 });
3788
+ return q?.docs?.[0]?.api_key || null;
3789
+ } catch (err) {
3790
+ return null;
3791
+ }
3792
+ };
3793
+
3794
+ // What the run path asks for: everything needed to launch, or a refusal a customer can act on.
3795
+ // Kept here rather than in xudex_engines.mjs because only this file can read the key store.
3796
+ //
3797
+ // DELIBERATELY NOT EXPORTED. Its return value contains `launch.env`, which holds the customer's
3798
+ // provider API key, and every `export const` in this module gets a generated queue wrapper and is
3799
+ // therefore callable over the broker. Exporting this would put a customer's key one internal
3800
+ // broker call away from anybody, for no benefit at all: the only caller is the run path, which
3801
+ // lives in this same file.
3802
+ const xudex_prepare_engine = async function ({ uid, profile_id, engine, model, resume_session_id, sandbox } = {}) {
3803
+ const descriptor = xudex_engines.get_engine(engine);
3804
+ if (!descriptor) return { code: -1, data: 'That engine is not available.' };
3805
+ const api_key = descriptor.byok ? await xudex_resolve_api_key(uid, profile_id, descriptor.provider) : null;
3806
+ const launch = xudex_engines.prepare_launch({ engine, model, resume_session_id, api_key, sandbox });
3807
+ if (launch.error) return { code: -1, data: launch.error, needs_key: launch.needs_key || null };
3808
+ return { code: 1, launch, has_key: !!api_key };
3809
+ };
3810
+
3811
+ export const xudex_engine_list = async function () {
3812
+ return { code: 1, data: { engines: xudex_engines.list_engines() } };
3813
+ };
3814
+
3636
3815
  export const git_repo_disconnect = async function (req) {
3637
3816
  const { uid, profile_id, repo_id } = req;
3638
3817
  try {
@@ -3728,6 +3907,234 @@ const ensure_git_working_copy = async function ({ uid, repo, conversation_id })
3728
3907
  };
3729
3908
  };
3730
3909
 
3910
+ // ── UI-228: the bare mirror ────────────────────────────────────────────────────────────────
3911
+ // See xudex_mirror.mjs for why this exists: it is what lets a working copy live on a machine that
3912
+ // never holds the customer's token. git_exec is handed over rather than re-implemented, because it is
3913
+ // the only thing here that knows how to give git a credential without it landing in an argv.
3914
+ // UI-228: the VM substrate, registered as the second implementation of the runtime interface.
3915
+ // It reaches the machine over the Proxmox guest agent rather than SSH, because a xudex box is
3916
+ // app_type 'vps' and customer VPS on this platform are keyless by design. See xudex_vm.mjs.
3917
+ //
3918
+ // `resolve_machine` and the Proxmox client are injected rather than imported: only deploy_module
3919
+ // owns Proxmox, and asking it over the broker keeps that ownership where it belongs.
3920
+ const { create_vm_substrate: _create_xudex_vm } = await import('./xudex_vm.mjs');
3921
+ xudex_runtime.register(
3922
+ _create_xudex_vm({
3923
+ pve_request: async (node_doc, method, url, body) => {
3924
+ const ret = await deploy_ms.proxmox_api_request({ node_doc, method, url, body });
3925
+ if (!ret || ret.code < 0) throw new Error(ret?.data || 'proxmox request failed');
3926
+ return ret.data;
3927
+ },
3928
+ resolve_machine: async ({ uid, app_id }) => {
3929
+ const ret = await deploy_ms.xudex_machine_for({ uid, app_id });
3930
+ return ret?.code > 0 ? ret.data : null;
3931
+ },
3932
+ }),
3933
+ );
3934
+
3935
+ // One line at boot so a box can be asked what it can actually run without adding an endpoint for
3936
+ // it. Logged HERE rather than beside the runtime's construction: the VM substrate registers at this
3937
+ // point in the file, so a log line further up would always report `local` alone and read like the
3938
+ // VM support had failed to load.
3939
+ //
3940
+ // A substrate that is registered but not available is the normal case, not a fault: `local` is
3941
+ // gated off outside dev on purpose (plan 3.1), and `vm` is unavailable on a box with no Proxmox.
3942
+ console.log(`[xudex] runtime substrates: ${xudex_runtime.list().map((s) => `${s.kind}=${s.available ? 'available' : 'off'}`).join(' ') || 'none'}`);
3943
+
3944
+ const xudex_mirror = _create_xudex_mirror({
3945
+ git_exec,
3946
+ du_mb: async (dir) =>
3947
+ Number(((await run_process('bash', ['-lc', `du -sm ${JSON.stringify(dir)} | cut -f1`], null, { timeout: 60000 })).stdout || '').trim()) || 0,
3948
+ mirror_root: () => _conf.xudex?.mirror_path || path.join(git_repos_root(), '_mirrors'),
3949
+ clone_timeout_ms: GIT_CLONE_TIMEOUT_MS,
3950
+ clone_max_mb: GIT_CLONE_MAX_MB,
3951
+ });
3952
+
3953
+ // UI-228: the whole chain in one place. Deliberately constructed HERE rather than beside the
3954
+ // other xudex imports at the top of the file: it needs `xudex_mirror` and `git_exec`, both of
3955
+ // which are defined further down, and a top-level const that reads them earlier is a temporal
3956
+ // dead zone error that takes the whole module down at boot. `node --check` does not catch it.
3957
+ const { create_runner: _create_xudex_runner } = await import('./xudex_run.mjs');
3958
+ const xudex_runner = _create_xudex_runner({
3959
+ runtime: xudex_runtime,
3960
+ mirror: xudex_mirror,
3961
+ engines: xudex_engines,
3962
+ verify: xudex_verify,
3963
+ tracker_factory: ({ workspace }) => create_xudex_run_tracker({ workspace }),
3964
+ run_process,
3965
+ git_exec,
3966
+ });
3967
+
3968
+ // ── UI-228: the run, as a method ───────────────────────────────────────────────────────────
3969
+ // Everything the runner does not decide: may this account run at all, has it run too much this
3970
+ // month, which repository, which engine, and where the record goes afterwards.
3971
+
3972
+ // The monthly cap from the plan (50 on free, unlimited above it). Counted from the tracker's own
3973
+ // records, which is the second of the three jobs section 9.4 gives that component: one meter, not
3974
+ // a separate counter that can drift away from what actually ran.
3975
+ const xudex_runs_this_month = async function (app_db_id) {
3976
+ const since = new Date();
3977
+ since.setUTCDate(1);
3978
+ since.setUTCHours(0, 0, 0, 0);
3979
+ try {
3980
+ const q = await db_module.find_app_couch_query(app_db_id, { selector: { docType: 'xudex_run', ts: { $gte: since.getTime() } }, limit: 1000 });
3981
+ return (q?.docs || []).length;
3982
+ } catch (err) {
3983
+ // A failed count must not block a paying customer's work. Erring toward letting the run
3984
+ // happen is the right way round: the cap protects margin, and the abuse layers protect the
3985
+ // thing that actually matters.
3986
+ console.warn(`[xudex] run count failed: ${err.message}`);
3987
+ return 0;
3988
+ }
3989
+ };
3990
+
3991
+ export const xudex_run = async function (req) {
3992
+ const { uid, profile_id, app_id, conversation_id, prompt, engine, model } = req;
3993
+ try {
3994
+ if (_conf.xudex?.enabled !== true) return { code: -1, data: 'Xudex is not available yet.' };
3995
+ if (!app_id) return { code: -1, data: 'app_id is required' };
3996
+ if (!prompt || !String(prompt).trim()) return { code: -1, data: 'There is nothing to do: the request was empty.' };
3997
+
3998
+ // 1. Entitlement. The same rules deploy_xudex uses to hand out a machine, asked again here,
3999
+ // because a membership can lapse between provisioning a machine and using it.
4000
+ //
4001
+ // Asked BEFORE resolving the profile's project database, deliberately. That lookup throws on
4002
+ // an account it cannot find, and a throw lands in the catch at the bottom as "please try
4003
+ // again", which is the least useful thing we could say to someone whose real problem is that
4004
+ // their membership does not include Xudex. Cheap checks first, and each with its own answer.
4005
+ let account_doc = null;
4006
+ try {
4007
+ const acct = await db_module.get_couch_doc('xuda_accounts', uid);
4008
+ account_doc = acct?.code > -1 ? acct.data : null;
4009
+ } catch (e) {}
4010
+ if (!account_doc) return { code: -1, data: 'Xuda could not read your account just now. Please try again.' };
4011
+ const ent = xudex_deploy_rules.xudex_entitlement(account_doc);
4012
+ if (!ent.allowed) return { code: -1, data: ent.reason, needs_membership: true };
4013
+
4014
+ // 2. The identity gate. It is SHADOWED today (verify_policy declares xudex_run at level 2 and
4015
+ // the platform enforcement floor is above it), so this reports what it would have refused and
4016
+ // lets the run proceed. When the floor moves, this line starts denying without being touched.
4017
+ try {
4018
+ const gate = await verify_ms.verify_gate_check_for_uid({ data: { uid, product: 'xudex_run' } });
4019
+ if (gate?.code > 0 && gate.data && gate.data.allow === false) {
4020
+ return {
4021
+ code: -412,
4022
+ data: gate.data.message || 'Identity verification is required before running code on Xudex.',
4023
+ error: 'id_verification_required',
4024
+ required_level: gate.data.required_level,
4025
+ level: gate.data.level,
4026
+ missing_claims: gate.data.missing_claims || [],
4027
+ };
4028
+ }
4029
+ } catch (e) {
4030
+ // A verification service that is down must not stop paying customers working.
4031
+ console.warn(`[xudex] gate check failed, allowing: ${e.message}`);
4032
+ }
4033
+
4034
+ // 3. The monthly cap. The project database is resolved here, at the first point that actually
4035
+ // needs it.
4036
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
4037
+ const cap = Number(ent.flags?.runs_per_month);
4038
+ if (Number.isFinite(cap) && cap > 0) {
4039
+ const used = await xudex_runs_this_month(account_profile_info.app_id);
4040
+ if (used >= cap) {
4041
+ return { code: -1, data: `You have used all ${cap} Xudex runs on your plan this month. They reset at the start of next month, or you can upgrade for unlimited runs.`, cap_reached: true, used, cap };
4042
+ }
4043
+ }
4044
+
4045
+ // 4. The repository. A xudex run without one has nothing to work on, and saying so is better
4046
+ // than starting an engine in an empty directory and letting it improvise.
4047
+ const repo_list = await git_repo_list({ uid, profile_id, app_id });
4048
+ const repo_ref = repo_list?.data?.repos?.[0];
4049
+ if (!repo_ref) return { code: -1, data: 'Connect a repository to this project first, then try again.', needs_repo: true };
4050
+ const repo_full = await load_git_repo(uid, profile_id, repo_ref._id);
4051
+ if (repo_full.error) return repo_full.error;
4052
+
4053
+ // 5. The engine, and its key. A BYOK engine with no key is refused here, before a workspace is
4054
+ // prepared or a machine is woken.
4055
+ const prepared = await xudex_prepare_engine({ uid, profile_id, engine, model, resume_session_id: req.resume_session_id || null });
4056
+ if (prepared.code < 0) return { code: -1, data: prepared.data, needs_key: prepared.needs_key || null };
4057
+
4058
+ // 6. Run it.
4059
+ const emit = typeof req.on_event === 'function' ? req.on_event : null;
4060
+ const ret = await xudex_runner.run({
4061
+ uid,
4062
+ repo: repo_full.repo,
4063
+ project_id: app_id,
4064
+ conversation_id,
4065
+ prompt,
4066
+ launch: prepared.launch,
4067
+ emit,
4068
+ max_repair_attempts: req.max_repair_attempts,
4069
+ verify_ctx: await xudex_verify_context(app_id),
4070
+ });
4071
+
4072
+ // 7. The record. It is the run meter, the abuse evidence and the benchmark sample all at once,
4073
+ // so it is written whether the run succeeded or not.
4074
+ if (ret.record) {
4075
+ try {
4076
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, { _id: await _common.xuda_get_uuid('xudex_run'), ...ret.record, app_id, uid });
4077
+ } catch (e) {
4078
+ console.error(`[xudex] could not persist the run record: ${e.message}`);
4079
+ }
4080
+ if (ret.record.verdict === 'flag') {
4081
+ // Log-only until the thresholds have been tuned against real builds (9.4). Loud, because
4082
+ // this is the line somebody will grep for when an abuse complaint arrives.
4083
+ console.warn(`[xudex] RUN FLAGGED uid=${uid} app=${app_id} reasons=${(ret.record.reasons || []).join('; ')} enforced=${ret.record.enforced}`);
4084
+ }
4085
+ }
4086
+
4087
+ // 8. Keep the machine awake for as long as it is being used.
4088
+ try {
4089
+ await deploy_msa.xudex_touch({ app_id });
4090
+ } catch (e) {}
4091
+
4092
+ return ret;
4093
+ } catch (err) {
4094
+ console.error(`[xudex] run failed: ${err?.message || err}`);
4095
+ return { code: -1, data: 'That run could not be completed. Please try again.' };
4096
+ }
4097
+ };
4098
+
4099
+ // What the verify loop needs to know about the project: its manifest, its top-level files and
4100
+ // whether dependencies are installed. Read from the working copy rather than assumed, because a
4101
+ // project whose package.json changed between runs plans different gates.
4102
+ const xudex_verify_context = async function (project_id) {
4103
+ const dir = path.join(_conf.xudex?.projects_root || '/srv/xudex', String(project_id));
4104
+ try {
4105
+ const files = await fs.promises.readdir(dir);
4106
+ let manifest = null;
4107
+ if (files.includes('package.json')) {
4108
+ try {
4109
+ manifest = JSON.parse(await fs.promises.readFile(path.join(dir, 'package.json'), 'utf8'));
4110
+ } catch (e) {
4111
+ // A malformed package.json is the project's problem to fix, and the verify loop will say
4112
+ // so through whatever gate trips on it. It is not a reason to refuse the run.
4113
+ console.warn(`[xudex] unreadable package.json in ${project_id}`);
4114
+ }
4115
+ }
4116
+ return {
4117
+ manifest,
4118
+ files,
4119
+ node_modules_present: files.includes('node_modules'),
4120
+ // The runtime gate. Built here rather than inside the verify loop because only this layer
4121
+ // knows which project it is and how to reach its dev server; the loop just calls it and
4122
+ // treats a dead app like any other failing step, which means a broken preview goes back to
4123
+ // the engine through the same repair path as a broken test.
4124
+ runtime_check: xudex_preview.runtime_check({ project_id, manifest, package_manager: detect_pm(files) }),
4125
+ };
4126
+ } catch (err) {
4127
+ return { manifest: null, files: [], node_modules_present: false };
4128
+ }
4129
+ };
4130
+
4131
+ // Same rule the verify loop uses: the lockfile decides, not a preference.
4132
+ const detect_pm = function (files = []) {
4133
+ if (files.includes('pnpm-lock.yaml')) return 'pnpm';
4134
+ if (files.includes('yarn.lock')) return 'yarn';
4135
+ return 'npm';
4136
+ };
4137
+
3731
4138
  // ── Status, commit, push, pull request ─────────────────────────────────────────────────────
3732
4139
  export const git_repo_status = async function (req) {
3733
4140
  const { uid, profile_id, repo_id, conversation_id } = req;
@@ -3905,6 +4312,16 @@ export const git_open_pr = async function (req) {
3905
4312
  // run, so this caps OpenAI cost and protects the shared host from spawn floods.
3906
4313
  const SW_GEN_RATE_PER_HOUR = _conf.sw_gen_rate_per_hour || 20;
3907
4314
  const SW_GEN_MAX_CONCURRENT = _conf.sw_gen_max_concurrent || 2;
4315
+ // Wall-clock budget for the Codex run itself. A site build is not a chat turn:
4316
+ // a from-scratch multi-page site (design system, responsive pass, generated
4317
+ // favicon) routinely ran past the old 240s ceiling, and every one of those came
4318
+ // back as "The AI took too long to respond" with nothing to show. Only safe
4319
+ // alongside the heartbeat in generate_site_draft — see the comment there.
4320
+ const SW_GEN_CODEX_TIMEOUT = _conf.sw_gen_codex_timeout || 480000;
4321
+ // How often the run touches its job while Codex works. Must stay well under
4322
+ // BOTH the inactivity sweeper's job_timeout and the 600s memcached TTL on the
4323
+ // job doc, since a single update_job refreshes both.
4324
+ const SW_GEN_HEARTBEAT_MS = 30000;
3908
4325
  const _sw_gen_acquire = async (uid) => {
3909
4326
  const now = Date.now();
3910
4327
  const rl_key = `sw_gen_rl_${uid}`;
@@ -4054,7 +4471,33 @@ When done, briefly summarize what you built.`;
4054
4471
  // Model pick from the generate screen (UI-13 #4): forward only a codex-eligible
4055
4472
  // catalog code; anything else (or none) uses the default model.
4056
4473
  const picked_model = _is_codex_model(data.model || data.codex_model) ? (data.model || data.codex_model) : null;
4057
- const codex_ret = await execute_codex_request({ uid, ip: undefined, local_cwd: dir, prompt: built_prompt, stream: false, sandbox: 'workspace-write', codex_timeout: 240000, codex_model: picked_model });
4474
+ // The Codex step is the long one and it reports nothing while it runs
4475
+ // (stream: false), so the job would sit untouched for its whole duration.
4476
+ // Two things kill a silent job: kill_inactive_jobs fails anything whose
4477
+ // `ts` is older than job_timeout, and the job doc itself is memcached on a
4478
+ // 600s TTL — once that expires, update_job's get_job finds nothing and
4479
+ // returns early, so the finalize at the end of this function lands on a
4480
+ // job that no longer exists and the browser is never told anything. One
4481
+ // beat every 30s refreshes both (set_job rewrites the doc with a fresh
4482
+ // TTL) and doubles as liveness on the wire for the builder's panel. The
4483
+ // sweeper is deliberately left armed rather than opting out with
4484
+ // is_background: if this run truly wedges, the beat stops with it and the
4485
+ // sweeper still closes the job.
4486
+ const beat_until = Date.now() + SW_GEN_CODEX_TIMEOUT + 60000;
4487
+ const beat = setInterval(() => {
4488
+ // Stop beating once the run is past any budget it could legitimately be
4489
+ // using. Beating forever would keep re-arming the job's clock, which is
4490
+ // exactly how a wedged run becomes invisible: the sweeper only fires on
4491
+ // a job that has gone quiet.
4492
+ if (Date.now() > beat_until) return clearInterval(beat);
4493
+ if (job_id) jobs_ms.update_job({ job_id, current_step: 2, current_step_name: 'Generating with AI' }).catch(() => {});
4494
+ }, SW_GEN_HEARTBEAT_MS);
4495
+ let codex_ret;
4496
+ try {
4497
+ codex_ret = await execute_codex_request({ uid, ip: undefined, local_cwd: dir, prompt: built_prompt, stream: false, sandbox: 'workspace-write', codex_timeout: SW_GEN_CODEX_TIMEOUT, codex_model: picked_model });
4498
+ } finally {
4499
+ clearInterval(beat);
4500
+ }
4058
4501
  if (!codex_ret || codex_ret.code < 0) {
4059
4502
  // On a non-zero codex exit, codex_ret.data is an object whose real reason
4060
4503
  // lives in `stderr` (not `message`); only the early guards return a plain
@@ -4723,6 +5166,51 @@ export const unarchive_ai_chat = async function (req) {
4723
5166
  }
4724
5167
  };
4725
5168
 
5169
+ // UI-235: a chat is named once, by the model, out of the opening words, and that name is
5170
+ // then the only thing the card, the Recent row and every search hit carry. Until now there
5171
+ // was no way to change it. The rename is recorded on the chat activity trail next to the
5172
+ // "Named by the AI" row it replaces, so the trail says who called it what and when.
5173
+ //
5174
+ // The caller's own copy only. A shared chat draws its title from the OWNER's doc
5175
+ // (get_ai_chat_info resolves the source), so renaming a recipient's pointer would change
5176
+ // nothing on screen and is refused with a sentence saying so rather than silently.
5177
+ export const rename_ai_chat = async function (req) {
5178
+ const { conversation_id, title, uid } = req;
5179
+
5180
+ try {
5181
+ if (!conversation_id) throw new Error('conversation_id is missing');
5182
+
5183
+ const new_title = String(title ?? '')
5184
+ .replace(/\s+/g, ' ')
5185
+ .trim()
5186
+ .slice(0, 120);
5187
+ if (!new_title) throw new Error('A name is required');
5188
+
5189
+ const account_profile_info = await get_active_account_profile_info(uid);
5190
+ const conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
5191
+
5192
+ if (!conversation_doc || conversation_doc.docType !== 'chat_conversation') throw new Error(`chat ${conversation_id} not found`);
5193
+ if (conversation_doc.shared_from_uid) throw new Error('A shared chat is renamed by the person who owns it');
5194
+ if (conversation_doc.uid !== uid && conversation_doc?.account_profile_info?.uid !== uid) throw new Error('Operation not allowed');
5195
+
5196
+ const previous_title = conversation_doc.title || '';
5197
+ if (previous_title === new_title) return { code: 1, data: { conversation_id, title: new_title } };
5198
+
5199
+ conversation_doc.title = new_title;
5200
+ // The model names a new chat a few seconds after it is created, which can land AFTER a
5201
+ // rename typed straight away. This is what tells it to leave the name alone.
5202
+ conversation_doc.title_set_by = 'user';
5203
+ conversation_doc.ts = Date.now();
5204
+ const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
5205
+
5206
+ log_chat_activity(uid, conversation_id, 'renamed', { by: 'user', from: previous_title, title: new_title }, account_profile_info.app_id);
5207
+
5208
+ return { code: 1, data: { conversation_id, title: new_title, save_ret } };
5209
+ } catch (err) {
5210
+ return { code: -3, data: err.message };
5211
+ }
5212
+ };
5213
+
4726
5214
  // export const create_studio_app = async function (subject) {
4727
5215
  // try {
4728
5216
  // let prompt = `
@@ -5773,6 +6261,44 @@ export const delete_mini_app = async function (req) {
5773
6261
  }
5774
6262
  };
5775
6263
 
6264
+ // UI-235: the mini app half of the rename. Same card, same kebab, so the same action has to
6265
+ // be there whichever tab it is drawn in. menuName is what every surface shows and menuTitle
6266
+ // is what the app's own header shows, and create_mini_app writes the model's name into both,
6267
+ // so a rename that moved only one of them would leave the app titled its old name inside.
6268
+ export const rename_mini_app = async function (req) {
6269
+ const { prog_id, title, uid } = req;
6270
+
6271
+ try {
6272
+ if (!prog_id) throw new Error('prog_id is missing');
6273
+
6274
+ const new_title = String(title ?? '')
6275
+ .replace(/\s+/g, ' ')
6276
+ .trim()
6277
+ .slice(0, 120);
6278
+ if (!new_title) throw new Error('A name is required');
6279
+
6280
+ const account_profile_info = await get_active_account_profile_info(uid);
6281
+ const prog_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, prog_id);
6282
+
6283
+ if (!prog_doc?.studio_meta?.miniApp) throw new Error(`mini app ${prog_id} not found`);
6284
+ if (prog_doc.studio_meta.shared_from_uid) throw new Error('A shared mini app is renamed by the person who owns it');
6285
+ if (prog_doc.studio_meta.createdByUid !== uid && prog_doc?.studio_meta?.account_profile_info?.uid !== uid) throw new Error('Operation not allowed');
6286
+
6287
+ const previous_title = prog_doc.properties?.menuName || '';
6288
+ if (previous_title === new_title) return { code: 1, data: { prog_id, title: new_title } };
6289
+
6290
+ prog_doc.properties = prog_doc.properties || {};
6291
+ prog_doc.properties.menuName = new_title;
6292
+ prog_doc.properties.menuTitle = new_title;
6293
+ prog_doc.ts = Date.now();
6294
+ const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, prog_doc);
6295
+
6296
+ return { code: 1, data: { prog_id, title: new_title, save_ret } };
6297
+ } catch (err) {
6298
+ return { code: -9, data: err.message };
6299
+ }
6300
+ };
6301
+
5776
6302
  export const start_cli_agent_conversation = async function (req) {
5777
6303
  const { uid, agent_id } = req;
5778
6304
  if (!uid) return { code: -1, data: 'uid required' };
@@ -6662,7 +7188,10 @@ export const get_ai_chat_activity = async function (req) {
6662
7188
  });
6663
7189
  // The title only ever differs from the opening words when the AI named it, which is the
6664
7190
  // whole reason the row is worth having.
6665
- if (conversation_doc.title && conversation_doc.prompt && conversation_doc.title !== getFirstNWords(conversation_doc.prompt, 10)) {
7191
+ // UI-235: unless a person renamed it. This row is reconstructed from the title the doc
7192
+ // holds NOW, so after a rename it would put the user's name in the AI's mouth. Nothing is
7193
+ // lost by dropping it: the recorded rename row carries the name it replaced.
7194
+ if (conversation_doc.title_set_by !== 'user' && conversation_doc.title && conversation_doc.prompt && conversation_doc.title !== getFirstNWords(conversation_doc.prompt, 10)) {
6666
7195
  add('title_set', conversation_doc.ts || created_ts, { by: 'ai', title: conversation_doc.title });
6667
7196
  }
6668
7197
  if (conversation_doc?.category_info?.category) add('categorized', conversation_doc.ts || created_ts, { by: 'ai', category: conversation_doc.category_info.category });
@@ -8534,12 +9063,17 @@ const process_conversation = async function (uid, conversation_id, account_profi
8534
9063
  const title = await submit_chat_gpt_prompt({ uid, prompt: `create title for the prompt return 5 words result maximum text only without options : ${prompt}`, model: _conf.default_ai_model, metadata: { conversation_id: conversation_doc._id, func: 'create_conversation' }, account_profile_info });
8535
9064
 
8536
9065
  conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_doc._id);
8537
- conversation_doc.title = title.data;
9066
+ // UI-235: the name the model comes back with is seconds late, and a user who renamed
9067
+ // the chat in the meantime would watch their name be overwritten. A name typed by a
9068
+ // person wins over a name guessed from the opening words.
9069
+ if (conversation_doc.title_set_by !== 'user') {
9070
+ conversation_doc.title = title.data;
8538
9071
 
8539
- await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
8540
- // UI-202: the chat is renamed from the opening words to whatever the model calls it,
8541
- // which is the one thing about a chat people ask "where did that come from" about.
8542
- log_chat_activity(uid, conversation_doc._id, 'title_set', { by: 'ai', title: conversation_doc.title }, account_profile_info.app_id);
9072
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
9073
+ // UI-202: the chat is renamed from the opening words to whatever the model calls it,
9074
+ // which is the one thing about a chat people ask "where did that come from" about.
9075
+ log_chat_activity(uid, conversation_doc._id, 'title_set', { by: 'ai', title: conversation_doc.title }, account_profile_info.app_id);
9076
+ }
8543
9077
  }
8544
9078
  }
8545
9079
  /// categorize prompt
@@ -10800,6 +11334,68 @@ ${conversation_history || `User (dashboard): ${prompt}`}
10800
11334
  // opened from the project's own AI panel never takes this path, so VPS work is untouched.
10801
11335
  const from_code_surface = get_dashboard_context_obj(req, conversation_doc)?.code_surface === true;
10802
11336
  const project_repo = from_code_surface ? (await git_repo_list({ uid, profile_id, app_id: target_app_id })).data?.repos?.[0] : null;
11337
+
11338
+ // ── UI-228: the Xudex route ─────────────────────────────────────────────────────────
11339
+ // The same surface, a different engine underneath. A Xudex run adds what the Code tab
11340
+ // cannot do today: it verifies the change against the project's own build and tests before
11341
+ // answering, hands failures back for a bounded repair, and records what the run cost.
11342
+ //
11343
+ // Behind a config flag, and the fallback is deliberate rather than defensive. The UI-226
11344
+ // path below is dev-verified and working; this one is newer. `route_code_tab` is false in
11345
+ // prod, true on dev, so it can be turned on for real once it has been watched, and turned
11346
+ // off in one config edit if it misbehaves, with no deploy and no code change.
11347
+ if (project_repo && _conf.xudex?.enabled === true && _conf.xudex?.route_code_tab === true) {
11348
+ emitToDashboard('stream_phase', 'Starting Xudex run');
11349
+ const xret = await xudex_run({
11350
+ uid,
11351
+ profile_id,
11352
+ app_id: target_app_id,
11353
+ conversation_id,
11354
+ prompt,
11355
+ engine: req.xudex_engine || null,
11356
+ model: req.codex_model || req.ai_model || null,
11357
+ resume_session_id: conversation_doc.codex_session_id && conversation_doc.codex_session_host === code_run_host() ? conversation_doc.codex_session_id : null,
11358
+ on_event: (event) => {
11359
+ if (event.type === 'phase') emitToDashboard('stream_phase', event.text, { update: true });
11360
+ else if (event.type === 'command') emitToDashboard('stream_phase', `Running ${event.command}`, { update: true });
11361
+ },
11362
+ });
11363
+
11364
+ if (xret.code < 0) {
11365
+ // Refusals here are things the customer can act on (no repository, no API key, out of
11366
+ // runs, membership too low), so they are shown as written rather than flattened into a
11367
+ // generic failure.
11368
+ const msg = typeof xret.data === 'string' ? xret.data : 'That run could not be completed.';
11369
+ emitToDashboard('response_start');
11370
+ streamText(msg);
11371
+ emitToDashboard('stream_end', undefined, { error: true });
11372
+ return await saveAssistantItem(msg, { is_request_error: true });
11373
+ }
11374
+
11375
+ // The engine's words and OUR verdict, kept apart on the way out exactly as they are kept
11376
+ // apart inside the runner: an engine that declares victory over a red build must not be
11377
+ // able to borrow the verification's credibility for it.
11378
+ const parts = [xret.data.message || '', '', xret.data.ok ? `Verified: ${xret.data.verdict}.` : `Not verified: ${xret.data.verdict}.`];
11379
+ if (!xret.data.ok && xret.data.failed) {
11380
+ parts.push('', `The ${xret.data.failed.name} step said:`, '```', xret.data.failed.output || '', '```');
11381
+ if (xret.data.pre_existing_failure) parts.push('', 'This looks like it was already failing before this change, so it may not be caused by what you asked for.');
11382
+ }
11383
+ const answer = parts.join('\n').trim();
11384
+
11385
+ if (xret.data.session_id) {
11386
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
11387
+ conversation_doc.codex_session_id = xret.data.session_id;
11388
+ conversation_doc.codex_session_host = code_run_host();
11389
+ conversation_doc.ts = Date.now();
11390
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
11391
+ }
11392
+
11393
+ emitToDashboard('response_start');
11394
+ streamText(answer);
11395
+ emitToDashboard('stream_end');
11396
+ return await saveAssistantItem(answer, { xudex: true, verified: xret.data.ok, files: xret.data.files, branch: xret.data.branch });
11397
+ }
11398
+
10803
11399
  if (project_repo) {
10804
11400
  const repo_full = await load_git_repo(uid, profile_id, project_repo._id);
10805
11401
  const prepared = repo_full.repo ? await ensure_git_working_copy({ uid, repo: repo_full.repo, conversation_id }) : { error: 'repository not found' };
@@ -13942,7 +14538,7 @@ const create_and_upload_image_to_drive = async function (drive_type, file_path,
13942
14538
  };
13943
14539
 
13944
14540
  export const create_avatar = async function (req, job_id, headers) {
13945
- const { docType, uid, url, _id, name, account_type } = req;
14541
+ const { docType, uid, url, _id, name, account_type, age } = req;
13946
14542
  let { metadata } = req;
13947
14543
  try {
13948
14544
  if (!['account', 'account_profile'].includes(docType)) {
@@ -13953,6 +14549,14 @@ export const create_avatar = async function (req, job_id, headers) {
13953
14549
  throw new Error('invalid account_type value');
13954
14550
  }
13955
14551
 
14552
+ // Make older / make younger, from the avatar window. Validated as one of two
14553
+ // words rather than carried through as free text: what the client picks here
14554
+ // ends up in an instruction to an image model, and the wording of that
14555
+ // instruction belongs to this module.
14556
+ if (age && !AVATAR_AGE_PROMPTS[age]) {
14557
+ throw new Error('invalid age value');
14558
+ }
14559
+
13956
14560
  if (!url || url.substr(0, 8) !== 'https://') {
13957
14561
  throw new Error('invalid url');
13958
14562
  }
@@ -13969,7 +14573,7 @@ export const create_avatar = async function (req, job_id, headers) {
13969
14573
  metadata = account_info;
13970
14574
  }
13971
14575
  metadata.is_user = true;
13972
- return await get_profile_avatar(url, uid, null, account_profile_info, account_type, docType, _id, metadata, undefined, name, account_info.email, job_id, headers);
14576
+ return await get_profile_avatar(url, uid, null, account_profile_info, account_type, docType, _id, metadata, undefined, name, account_info.email, job_id, headers, age);
13973
14577
  } catch (err) {
13974
14578
  return {
13975
14579
  code: -25,
@@ -14035,12 +14639,70 @@ export const generate_login_screen = async function (req, job_id, headers = {})
14035
14639
  }
14036
14640
  };
14037
14641
 
14038
- export const get_profile_avatar = async function (profile_picture, uid, prompt, account_profile_info, account_type, docType = 'account', _id, metadata = {}, business_size = 'unknown', name, email, job_id, headers) {
14642
+ // The stages this run goes through, in the order it goes through them, in the
14643
+ // words the person waiting is shown. They are written onto the job, which is
14644
+ // what carries them to the browser over the job_info socket.
14645
+ //
14646
+ // Boaz, 2026-08-13: "i cant see the progress and also it takes forever". Both
14647
+ // halves of that were true and the first made the second worse: the work is a
14648
+ // download, two vision calls, an optional image-model restoration, a background
14649
+ // removal and an upload, all behind a single job id that reported nothing until
14650
+ // it was finished, so a two minute run and a stuck one looked exactly alike.
14651
+ const AVATAR_STEP_READ = 'Reading your picture';
14652
+ const AVATAR_STEP_CHECK = 'Checking the picture';
14653
+ const AVATAR_STEP_RESTORE = 'Restoring the picture';
14654
+ const AVATAR_STEP_FACE = 'Turning it to the camera';
14655
+ const AVATAR_STEP_MAKE = 'Making the portrait';
14656
+ const AVATAR_STEP_SAVE = 'Saving it';
14657
+ const AVATAR_STEPS = [AVATAR_STEP_READ, AVATAR_STEP_CHECK, AVATAR_STEP_RESTORE, AVATAR_STEP_FACE, AVATAR_STEP_MAKE, AVATAR_STEP_SAVE];
14658
+
14659
+ // How big the SOURCE picture is allowed to be on its way through here. The
14660
+ // avatar is rendered at 1024, restoration downsizes to 1536 on its own account,
14661
+ // and nothing in the pipeline reads more detail than that, so carrying a 12
14662
+ // megapixel phone photo the whole way buys nothing but a slow lossless PNG
14663
+ // re-encode, a multi-megabyte upload to the vision model, and a background
14664
+ // removal over four times the pixels that survive to the end.
14665
+ const AVATAR_SOURCE_MAX_DIM = 1536;
14666
+ // What the inspections are shown. They answer "is there a real face here, is it
14667
+ // blurry, is it cropped" (questions a thumbnail settles) and shipping the
14668
+ // picture is the slow half of that call.
14669
+ const AVATAR_INSPECT_MAX_DIM = 768;
14670
+
14671
+ // Boaz, 2026-08-13: "add to this screen make older / younger / regenerate".
14672
+ // The wording lives here rather than travelling from the browser, so the client
14673
+ // picks a variant and never writes an instruction for the image model itself.
14674
+ const AVATAR_PASSPORT_PROMPT =
14675
+ 'Return a passport style portrait of the same person on a fully transparent background: head and shoulders only, facing the camera, eyes open, neutral expression, even lighting, no scenery, no props, no text, no border. Keep it unmistakably the same person.';
14676
+ const AVATAR_AGE_PROMPTS = {
14677
+ older: `${AVATAR_PASSPORT_PROMPT} Age them by roughly twenty years: grey the hair, add natural lines and skin texture for their later years. Keep the same face shape, eyes, nose, mouth, hairline, skin tone, gender and clothing.`,
14678
+ younger: `${AVATAR_PASSPORT_PROMPT} Take roughly twenty years off them: smooth age lines, restore younger hair colour and fuller skin. Keep the same face shape, eyes, nose, mouth, hairline, skin tone, gender and clothing.`,
14679
+ };
14680
+
14681
+ export const get_profile_avatar = async function (profile_picture, uid, prompt, account_profile_info, account_type, docType = 'account', _id, metadata = {}, business_size = 'unknown', name, email, job_id, headers, age) {
14039
14682
  const tempDir = os.tmpdir();
14040
14683
  const uniqueId = Date.now() + '_' + Math.random().toString(36).substring(7);
14041
14684
  const tempOutputPath = path.join(tempDir, `output_${uniqueId}.png`);
14042
14685
  const { is_user } = metadata;
14043
14686
  let filename = email || _id || name;
14687
+
14688
+ // Progress, and the timings behind it. Fire and forget in both directions: a
14689
+ // progress update that fails must never take the avatar down with it, and the
14690
+ // log line is what says WHICH stage is the slow one on any given photo.
14691
+ const started_ts = Date.now();
14692
+ let stage_ts = started_ts;
14693
+ const step = function (current_step_name) {
14694
+ console.log(`[avatar] ${current_step_name} (+${((Date.now() - started_ts) / 1000).toFixed(1)}s)`);
14695
+ if (!job_id) return;
14696
+ jobs_ms.update_job({ job_id, steps: AVATAR_STEPS, total_steps: AVATAR_STEPS.length, current_step_name }).catch(() => {});
14697
+ };
14698
+ const mark = function (what) {
14699
+ console.log(`[avatar] ${what} took ${((Date.now() - stage_ts) / 1000).toFixed(1)}s`);
14700
+ stage_ts = Date.now();
14701
+ };
14702
+
14703
+ // An age variant is a prompted edit of the picture the caller handed us, so
14704
+ // it rides the same route the legacy `prompt` argument already had.
14705
+ if (!prompt && AVATAR_AGE_PROMPTS[age]) prompt = AVATAR_AGE_PROMPTS[age];
14044
14706
  // inspect_profile_picture tests ret.code before reading it; this one did not.
14045
14707
  // submit_chat_gpt_prompt reports a FAILED call as
14046
14708
  // { code: -5, data: err.message }, putting prose in the very field the success
@@ -14068,7 +14730,10 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14068
14730
  needs_restoration: false,
14069
14731
  };
14070
14732
 
14071
- const inspect_person_in_image = async function (base64) {
14733
+ // Takes a ready data url rather than raw base64: what this call is shown is a
14734
+ // thumbnail (shrink_for_inspection), which is a JPEG, and hard-coding the png
14735
+ // mime type here would describe it wrongly.
14736
+ const inspect_person_in_image = async function (image_url) {
14072
14737
  try {
14073
14738
  const ret = await submit_chat_gpt_prompt({
14074
14739
  uid,
@@ -14083,7 +14748,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14083
14748
  },
14084
14749
  {
14085
14750
  type: 'input_image',
14086
- image_url: `data:image/png;base64,${base64}`,
14751
+ image_url,
14087
14752
  },
14088
14753
  ],
14089
14754
  },
@@ -14122,9 +14787,12 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14122
14787
 
14123
14788
  try {
14124
14789
  let imageBase64;
14125
- const image_blob_ret = await get_image_blob_from_downloaded_image(profile_picture);
14790
+ step(AVATAR_STEP_READ);
14791
+ const image_blob_ret = await get_image_blob_from_downloaded_image(profile_picture, { max_dim: AVATAR_SOURCE_MAX_DIM });
14792
+ mark('reading the picture');
14126
14793
  let avatar_source = '';
14127
14794
  if (prompt) {
14795
+ step(AVATAR_STEP_MAKE);
14128
14796
  const model = transparent_image_model();
14129
14797
  let ai_avatar_response;
14130
14798
  try {
@@ -14139,7 +14807,24 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14139
14807
  throw err;
14140
14808
  }
14141
14809
  imageBase64 = ai_avatar_response.data[0].b64_json;
14810
+ mark('the image model');
14142
14811
  account_msa.record_ai_usage(uid, ai_avatar_response.usage.input_tokens, ai_avatar_response.usage.output_tokens, 'profile avatar', prompt, model, { profile_picture }, account_profile_info);
14812
+ // Framed like every other avatar rather than however the model happened to
14813
+ // compose it. An age variant is the SAME portrait a moment later in life,
14814
+ // so it has to sit in the frame the same way the one it replaces did, or
14815
+ // switching between them reads as two different pictures of two different
14816
+ // people. Background removal only if the model would not draw one through:
14817
+ // edit_image_transparent degrades to an opaque render when the resolved
14818
+ // model refuses a transparent background (UI-138).
14819
+ imageBase64 = await normalizeAuthenticProfileAvatar(imageBase64, {
14820
+ remove_background: !(await has_transparent_pixels(Buffer.from(imageBase64, 'base64'))),
14821
+ });
14822
+ mark('framing the portrait');
14823
+ // An age variant is still the account holder's own face, so it must not be
14824
+ // labelled a stand-in: 'fictional' is what the window warns about, and it
14825
+ // is reserved for a likeness invented because the photo could not carry a
14826
+ // real one.
14827
+ if (age) avatar_source = 'authentic profile';
14143
14828
  } else {
14144
14829
  imageBase64 = Buffer.from(await image_blob_ret.image_blob.arrayBuffer()).toString('base64');
14145
14830
  if (account_type === 'business') {
@@ -14317,14 +15002,19 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14317
15002
  avatar_source = 'fictional';
14318
15003
  };
14319
15004
  // personal account
14320
- let person_inspection = await inspect_person_in_image(imageBase64);
15005
+ step(AVATAR_STEP_CHECK);
15006
+ let person_inspection = await inspect_person_in_image(await shrink_for_inspection(imageBase64));
15007
+ mark('the inspection');
14321
15008
  const wants_restoration = person_inspection.needs_restoration || person_inspection.is_too_blurry;
14322
15009
 
14323
15010
  if (wants_restoration && person_inspection.is_real_person_in_picture && !person_inspection.is_too_small) {
14324
15011
  console.log('Source photo flagged for restoration, calling gpt-image-1...');
15012
+ step(AVATAR_STEP_RESTORE);
14325
15013
  try {
14326
15014
  const restored_base64 = await restoreFaceWithOpenAI(imageBase64, { uid, account_profile_info, profile_picture });
14327
- const verify = await inspect_person_in_image(restored_base64);
15015
+ mark('the restoration');
15016
+ const verify = await inspect_person_in_image(await shrink_for_inspection(restored_base64));
15017
+ mark('the restoration check');
14328
15018
  if (verify.is_real_person_in_picture && !verify.is_too_blurry && !verify.is_face_too_cropped) {
14329
15019
  imageBase64 = restored_base64;
14330
15020
  person_inspection = verify;
@@ -14340,19 +15030,68 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14340
15030
  const can_create_authentic_avatar = source_quality.is_usable && person_inspection.is_real_person_in_picture && !person_inspection.is_too_blurry && !person_inspection.is_too_small;
14341
15031
 
14342
15032
  if (can_create_authentic_avatar) {
15033
+ // Boaz, 2026-08-13: "the head should face to the camera and not
15034
+ // tilted". The lean is a rotation and is handled in the framing; which
15035
+ // way someone FACES is not. A three-quarter photo has an ear and half
15036
+ // a jaw hidden, and no crop, rotation or cut-out brings them back, so
15037
+ // the only honest way to turn a head towards the camera is to redraw
15038
+ // it. That is what the image model does here, on the one signal that
15039
+ // says it is needed: the inspection above already reports whether the
15040
+ // face is front-facing, and until now nothing read it.
15041
+ //
15042
+ // Deliberately narrow, because it is the expensive path: about a
15043
+ // minute and one image generation against the account's credits,
15044
+ // where the cut-out route takes ten seconds and costs nothing. A photo
15045
+ // that already faces the camera never comes near it.
15046
+ let turned = false;
15047
+ if (!person_inspection.is_front_facing) {
15048
+ step(AVATAR_STEP_FACE);
15049
+ console.log('Photo is not front-facing, redrawing it towards the camera...');
15050
+ const model = transparent_image_model();
15051
+ try {
15052
+ const ai_ret = await edit_image_transparent({
15053
+ model: resolve_ai_model(model),
15054
+ image: await toFile(Buffer.from(imageBase64, 'base64'), 'portrait.png', { type: 'image/png' }),
15055
+ prompt: `${AVATAR_PASSPORT_PROMPT} Turn the head to face the camera straight on. Change nothing else about them: same face, same age, same skin tone, same hair, same clothing, same expression.`,
15056
+ });
15057
+ report_ai_status(model);
15058
+ const drawn = ai_ret?.data?.[0]?.b64_json;
15059
+ if (drawn) {
15060
+ imageBase64 = drawn;
15061
+ turned = true;
15062
+ if (ai_ret.usage) {
15063
+ account_msa.record_ai_usage(uid, ai_ret.usage.input_tokens, ai_ret.usage.output_tokens, 'avatar front facing', 'face the camera', model, { profile_picture }, account_profile_info);
15064
+ }
15065
+ }
15066
+ } catch (err) {
15067
+ // The photo they gave us is still a photo of them. Carrying on
15068
+ // with it beats failing the avatar over the angle of a head.
15069
+ report_ai_status(model, err);
15070
+ console.error('Could not turn the portrait to the camera, using the photo as it is:', err.message);
15071
+ }
15072
+ mark('turning it to the camera');
15073
+ }
15074
+
14343
15075
  // No face box any more. The framing is measured off the cut-out
14344
15076
  // itself (frameSubjectAsAvatar), so asking a vision model where the
14345
15077
  // face is bought nothing but a round trip and a number that could be
14346
15078
  // wrong — and being wrong is what cropped a real avatar's head off.
15079
+ step(AVATAR_STEP_MAKE);
14347
15080
  imageBase64 = await normalizeAuthenticProfileAvatar(imageBase64, {
14348
- remove_background: !image_blob_ret.is_transparent,
15081
+ // A redrawn portrait comes back on its own transparent background,
15082
+ // so there is nothing left to remove.
15083
+ remove_background: turned ? !(await has_transparent_pixels(Buffer.from(imageBase64, 'base64'))) : !image_blob_ret.is_transparent,
14349
15084
  });
15085
+ mark('the cut-out and framing');
14350
15086
  avatar_source = 'authentic profile';
14351
15087
  } else {
15088
+ step(AVATAR_STEP_MAKE);
14352
15089
  await create_fictional_avatar();
15090
+ mark('the invented likeness');
14353
15091
  }
14354
15092
  }
14355
15093
  }
15094
+ step(AVATAR_STEP_SAVE);
14356
15095
  console.log('Normalizing final avatar to 1024 with transparent padding...');
14357
15096
  imageBase64 = await normalizeBase64To1024(imageBase64);
14358
15097
 
@@ -14373,6 +15112,8 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14373
15112
 
14374
15113
  let drive_ret = await drive_ms.upload_drive_file_user({ uid, path: '/Profile Images' }, job_id, headers, file_obj);
14375
15114
  drive_ret.data.avatar_source = avatar_source;
15115
+ mark('saving the file');
15116
+ console.log(`[avatar] done in ${((Date.now() - started_ts) / 1000).toFixed(1)}s`);
14376
15117
 
14377
15118
  return drive_ret;
14378
15119
  } catch (error) {
@@ -14380,6 +15121,7 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
14380
15121
  if (error.response?.data) {
14381
15122
  console.error('OpenAI API response:', error.response.data);
14382
15123
  }
15124
+ console.log(`[avatar] failed after ${((Date.now() - started_ts) / 1000).toFixed(1)}s: ${error.message}`);
14383
15125
  return { code: -56, data: error.message };
14384
15126
  }
14385
15127
  };
@@ -15136,6 +15878,47 @@ const get_mini_app_info = async function (uid, prog_doc) {
15136
15878
  return doc;
15137
15879
  };
15138
15880
 
15881
+ // UI-236: the apps grid's live-update path. get_apps answers a fetch and nothing was ever
15882
+ // pushed afterwards, so a mini app renamed, archived or re-thumbnailed elsewhere sat stale
15883
+ // on screen while the chats grid moved on its own. Results.vue has carried the
15884
+ // mini_app_doc_updated handler all along; this is the emit it was waiting for.
15885
+ //
15886
+ // The audience is the account that owns the PROJECT DATABASE the doc lives in, and the
15887
+ // caller passes it (controller_module already holds app_obj.app_uId). It is never read off
15888
+ // the doc, because no field on the doc answers the question: across the 98 mini app studio
15889
+ // docs on dev, doc.uid is unset on every single one, createdByUid is the ORIGINAL author and
15890
+ // points at a different account on 33 of them (a marketplace install or a share keeps the
15891
+ // author's uid), and a shared reference copy carries no createdByUid at all. Emitting to
15892
+ // createdByUid would push one account's row into another account's dashboard.
15893
+ export const emit_mini_app_doc_updated = async function (req) {
15894
+ const { app_id, uid, doc } = req || {};
15895
+ try {
15896
+ if (!app_id || !uid || !doc?._id) return { code: 0, data: 'nothing to emit' };
15897
+ // Exactly what get_apps selects on, so this fires for precisely the docs that can be
15898
+ // rows in that grid and for nothing else.
15899
+ if (doc.docType !== 'studio' || !doc?.studio_meta?.miniApp) return { code: 0, data: 'not a mini app' };
15900
+
15901
+ // The shape get_apps builds its rows from (the shared-copy swap onto the origin doc plus
15902
+ // apply_recipient_marks, user_contact, privilege), because update_fn in the dashboard
15903
+ // Object.assigns this straight into the row it already has, and unshifts it when the row
15904
+ // is new. The raw prog doc would overwrite a good row with a half one.
15905
+ const data = await get_mini_app_info(uid, doc);
15906
+
15907
+ ws_dashboard_msa.emit_message_to_dashboard({
15908
+ service: 'mini_app_doc_updated',
15909
+ to: [uid],
15910
+ data,
15911
+ });
15912
+
15913
+ return { code: 1, data: { prog_id: doc._id, to: uid } };
15914
+ } catch (err) {
15915
+ // Never throws: this hangs off the change reader, and a grid that fails to move is not a
15916
+ // reason to break the checker and history pipeline running beside it.
15917
+ console.error('[emit_mini_app_doc_updated]', doc?._id, err?.message || err);
15918
+ return { code: -1, data: err?.message || String(err) };
15919
+ }
15920
+ };
15921
+
15139
15922
  export const pin_ai_chat = async function (req) {
15140
15923
  const { uid, conversation_id } = req;
15141
15924
  try {
@@ -15812,6 +16595,55 @@ function normalizeFilename(name) {
15812
16595
  .replace(/^_+|_+$/g, ''); // trim leading/trailing underscores
15813
16596
  }
15814
16597
 
16598
+ // A thumbnail to ask a vision model about, as a ready data url.
16599
+ //
16600
+ // The inspections decide whether a real face is visible and whether it is
16601
+ // blurry, cropped or degraded, all of which a 768px JPEG answers exactly as
16602
+ // well as the full picture, at a fortieth of the bytes. Uploading the original
16603
+ // was pure waiting: a phone photo re-encoded to lossless PNG is megabytes, and
16604
+ // it went up the wire twice on any photo that got restored.
16605
+ //
16606
+ // Only ever shrinks. A picture already smaller than this is passed through, so
16607
+ // "is it too small" still sees the size the person actually uploaded.
16608
+ async function shrink_for_inspection(base64Image) {
16609
+ try {
16610
+ const jpeg = await sharp(Buffer.from(base64Image, 'base64'))
16611
+ .flatten({ background: { r: 255, g: 255, b: 255 } })
16612
+ .resize({ width: AVATAR_INSPECT_MAX_DIM, height: AVATAR_INSPECT_MAX_DIM, fit: 'inside', withoutEnlargement: true })
16613
+ .jpeg({ quality: 82 })
16614
+ .toBuffer();
16615
+ return `data:image/jpeg;base64,${jpeg.toString('base64')}`;
16616
+ } catch (err) {
16617
+ // Never lose the inspection over the optimisation that was meant to speed
16618
+ // it up: fall back to shipping the picture as it stands.
16619
+ console.error('shrink_for_inspection failed, sending the full picture:', err.message);
16620
+ return `data:image/png;base64,${base64Image}`;
16621
+ }
16622
+ }
16623
+
16624
+ // Does this image actually carry transparency? An alpha channel on its own does
16625
+ // not mean it is used, which is the whole question when deciding whether a
16626
+ // picture still needs its background removed.
16627
+ async function has_transparent_pixels(inputBuffer) {
16628
+ try {
16629
+ const image = sharp(inputBuffer);
16630
+ const metadata = await image.metadata();
16631
+ if (!metadata.hasAlpha) return false;
16632
+
16633
+ const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
16634
+ const channels = info.channels;
16635
+ for (let i = channels - 1; i < data.length; i += channels) {
16636
+ if (data[i] < 255) return true;
16637
+ }
16638
+ return false;
16639
+ } catch (err) {
16640
+ // Unreadable: assume it is opaque, which only means the background removal
16641
+ // runs on something that may not need it. The other way round leaves a
16642
+ // photograph's own background sitting in the avatar.
16643
+ return false;
16644
+ }
16645
+ }
16646
+
15815
16647
  async function inspectAvatarSourceQuality(base64Image) {
15816
16648
  const inputBuffer = Buffer.from(base64Image, 'base64');
15817
16649
  const metadata = await sharp(inputBuffer).metadata();
@@ -15839,18 +16671,56 @@ async function inspectAvatarSourceQuality(base64Image) {
15839
16671
  //
15840
16672
  // That describes the PRODUCT, not one route to it, so this route — which reaches
15841
16673
  // the same result with sharp instead of a model — has to land in the same place.
15842
- // It did not. It framed to a "passport" geometry of its own invention: the head
15843
- // at 62% of the frame, sized and positioned from a vision model's face box, with
15844
- // no margin guaranteed anywhere. Two of the four requirements were missed
15845
- // outright, and when the face box came back short the crop took the crown off
15846
- // the top of a real account's avatar.
15847
16674
  //
15848
- // These two numbers are the "top margin" and a little air at the sides.
15849
- // Everything else the spec asks for centred, covering the picture,
15850
- // transparent falls out of the composition rather than being tuned.
16675
+ // These two numbers are the "top margin" and a little air at the sides, used
16676
+ // when the whole SUBJECT is what gets fitted: a cut-out with no readable head in
16677
+ // it, where passport proportions have nothing to measure from.
16678
+ // The margins are what "centered with paddind" (Boaz) means on this route: the
16679
+ // subject is fitted whole, so without them it touches the frame, and a circle
16680
+ // drawn over that clips the very edges of a person who was already fully in
16681
+ // shot. Roomier than they were for that reason.
15851
16682
  const AVATAR_SIZE = 1024;
15852
- const AVATAR_TOP_MARGIN = 0.06;
15853
- const AVATAR_SIDE_MARGIN = 0.02;
16683
+ const AVATAR_TOP_MARGIN = 0.12;
16684
+ const AVATAR_SIDE_MARGIN = 0.08;
16685
+
16686
+ // Boaz, 2026-08-13: "fix the avatar (base on the profile picture) to transform
16687
+ // as passport portraite picture transparent".
16688
+ //
16689
+ // A passport portrait is a stated geometry, not a style: the head is a fixed
16690
+ // share of the frame, sits square in the middle of it, and has air above the
16691
+ // crown. These are the ICAO proportions, at the roomy end of the band because
16692
+ // every surface in Xuda draws this inside a CIRCLE, and a crop tuned for a
16693
+ // rectangle loses its corners there.
16694
+ //
16695
+ // This is the second attempt at passport framing here and it fails differently
16696
+ // from the first. That one sized the head from a VISION MODEL's face box, and
16697
+ // when the box came back short the crop took the crown off the top of a real
16698
+ // account's avatar. Nothing is asked of a model here: the background is already
16699
+ // gone, so the cut-out's own silhouette says where the head is, and the frame is
16700
+ // positioned FROM THE CROWN: the top of the head is placed, never computed as
16701
+ // a leftover, so it cannot be cropped away.
16702
+ // Boaz, 2026-08-13, on the first passport avatar: "the avatar should show head
16703
+ // and shoulders centered with paddind". These numbers are lower than the ICAO
16704
+ // band on purpose, because the frame is not what anyone sees: every surface in
16705
+ // Xuda draws this file inside a CIRCLE, and the inscribed circle of a square
16706
+ // keeps only the middle of it. At a passport-tight 0.6 the shoulders live in the
16707
+ // corners, which is exactly where the circle cuts, so a correctly framed
16708
+ // head-and-shoulders portrait arrived on screen as a head floating on its own.
16709
+ // Sized so the shoulders survive the circle and there is air above the crown.
16710
+ const PASSPORT_HEAD_HEIGHT = 0.46; // crown to chin, as a share of the frame
16711
+ const PASSPORT_CROWN_TOP = 0.17; // air above the crown, same
16712
+ // A head that measures wildly out of proportion means the silhouette was read
16713
+ // wrong (a hat, a raised hand, a segmenter that kept a chair back). Sizing off a
16714
+ // bad measurement is what produces a nose filling the frame, so the scale is
16715
+ // held to a band where the head stays recognisably a head.
16716
+ const PASSPORT_HEAD_WIDTH_MIN = 0.3;
16717
+ const PASSPORT_HEAD_WIDTH_MAX = 0.75;
16718
+ // The shoulder line lands a little below the chin, in the middle of the flare
16719
+ // rather than at its start, so the chin is this far back up towards the crown.
16720
+ // Measured against real cut-outs (a full-length selfie and a tight portrait):
16721
+ // it lands within a few per cent, and what error is left runs SHORT of the chin,
16722
+ // which frames a slightly larger head rather than cropping into the face.
16723
+ const SHOULDER_TO_CHIN = 0.9;
15854
16724
 
15855
16725
  // Frame a background-removed portrait to that spec.
15856
16726
  //
@@ -15861,6 +16731,16 @@ async function frameSubjectAsAvatar(segmentedBuffer) {
15861
16731
  const bounds = await measureOpaqueBounds(segmentedBuffer);
15862
16732
  if (!bounds) return sharp(segmentedBuffer).png({ force: true }).toBuffer();
15863
16733
 
16734
+ // Passport geometry when the head could be found, which is the normal case
16735
+ // for a portrait. Everything below is the fallback for a cut-out with no
16736
+ // readable head in it, and fits the whole subject instead.
16737
+ const head = resolveHeadGeometry(bounds);
16738
+ console.log(
16739
+ `[avatar] framing ${bounds.width}x${bounds.height}: ${head ? `passport, head ${head.crownY} to ${head.chinY}, centre ${Math.round(head.headCenterX)}` : 'no head measured, fitting the whole subject'}`,
16740
+ );
16741
+ const passport = await framePassportPortrait(segmentedBuffer, bounds);
16742
+ if (passport) return passport;
16743
+
15864
16744
  // The subject's own edges rather than every opaque pixel, on BOTH axes. A
15865
16745
  // speck above the head shrinks the person to make room for a stray pixel; a
15866
16746
  // speck beside them widens the frame and pushes them off centre, which breaks
@@ -15890,22 +16770,151 @@ async function frameSubjectAsAvatar(segmentedBuffer) {
15890
16770
  .toBuffer();
15891
16771
  }
15892
16772
 
16773
+ // Passport proportions, measured off the silhouette.
16774
+ //
16775
+ // The frame is placed rather than cropped to: the crown goes at
16776
+ // PASSPORT_CROWN_TOP and the head is scaled to PASSPORT_HEAD_HEIGHT, so the top
16777
+ // of the head has a guaranteed position instead of being whatever survives a
16778
+ // crop. Whatever falls outside the frame after that is shoulder, which is what a
16779
+ // passport photo cuts anyway.
16780
+ //
16781
+ // Returns null when the head could not be measured, so the caller can fall back
16782
+ // to fitting the whole subject.
16783
+ // Was a head measured at all? measureHead reports nothing rather than a guess
16784
+ // when the silhouette does not say, and this is the one place that reading is
16785
+ // turned into "frame it as a passport portrait" or "leave the framing alone".
16786
+ function resolveHeadGeometry(bounds) {
16787
+ if (!bounds || !(bounds.chinY > bounds.crownY) || !(bounds.headWidth > 1)) return null;
16788
+ return { crownY: bounds.crownY, chinY: bounds.chinY, headCenterX: bounds.headCenterX, headWidth: bounds.headWidth };
16789
+ }
16790
+
16791
+ async function framePassportPortrait(segmentedBuffer, bounds) {
16792
+ const head = resolveHeadGeometry(bounds);
16793
+ if (!head) return null;
16794
+ const { crownY, chinY, headCenterX, headWidth } = head;
16795
+
16796
+ // The square of SOURCE pixels that becomes the 1024 frame. Solved for rather
16797
+ // than scaled to, so the crop happens at the picture's own size and the resize
16798
+ // is one bounded step at the end: a small head in a large photo needs a four
16799
+ // times enlargement, and doing that to the whole picture first would mean
16800
+ // resampling a hundred megapixels to throw nearly all of them away.
16801
+ const headHeight = chinY - crownY + 1;
16802
+ let window = headHeight / PASSPORT_HEAD_HEIGHT;
16803
+
16804
+ // Sanity, not taste, and only where it means anything: a head measured off the
16805
+ // SILHOUETTE that lands outside this width band was misread, and the clamp is
16806
+ // what keeps a bad reading from becoming an avatar of somebody's nose. There
16807
+ // is no width to check when the head came from the inspection, which reports a
16808
+ // height and has already been held to it.
16809
+ if (headWidth > 1) {
16810
+ if (headWidth / window > PASSPORT_HEAD_WIDTH_MAX) window = headWidth / PASSPORT_HEAD_WIDTH_MAX;
16811
+ if (headWidth / window < PASSPORT_HEAD_WIDTH_MIN) window = headWidth / PASSPORT_HEAD_WIDTH_MIN;
16812
+ }
16813
+
16814
+ // Never further out than the framing this replaces. Whatever the measurement
16815
+ // does, the worst case is then the whole subject fitted to the frame, which is
16816
+ // what every avatar looked like before passport framing existed. It bounds the
16817
+ // damage of a bad reading to "no better than yesterday" instead of "the face
16818
+ // is a stamp in the middle of a body".
16819
+ const subject_window = (bounds.footY - bounds.crownY + 1) / (1 - AVATAR_TOP_MARGIN);
16820
+ window = Math.max(2, Math.round(Math.min(window, subject_window)));
16821
+ if (!Number.isFinite(window)) return null;
16822
+
16823
+ // Centred on the head, with the crown at its mark.
16824
+ const windowLeft = Math.round(headCenterX - window / 2);
16825
+ const windowTop = Math.round(crownY - window * PASSPORT_CROWN_TOP);
16826
+
16827
+ // The window can hang off any edge: a head near the top of its photo, a
16828
+ // portrait narrower than the frame. So the picture is padded with transparency
16829
+ // first and the window then always lands inside it. Padded rather than nudged
16830
+ // back in: moving the window would put the head somewhere other than where the
16831
+ // spec says it goes, which is the whole point of framing this way.
16832
+ const pad_left = Math.max(0, -windowLeft);
16833
+ const pad_top = Math.max(0, -windowTop);
16834
+ const pad_right = Math.max(0, windowLeft + window - bounds.width);
16835
+ const pad_bottom = Math.max(0, windowTop + window - bounds.height);
16836
+
16837
+ const padded =
16838
+ pad_left || pad_top || pad_right || pad_bottom
16839
+ ? await sharp(segmentedBuffer)
16840
+ .ensureAlpha()
16841
+ .extend({ top: pad_top, bottom: pad_bottom, left: pad_left, right: pad_right, background: { r: 0, g: 0, b: 0, alpha: 0 } })
16842
+ .png({ force: true })
16843
+ .toBuffer()
16844
+ : segmentedBuffer;
16845
+
16846
+ return sharp(padded)
16847
+ .ensureAlpha()
16848
+ .extract({ left: windowLeft + pad_left, top: windowTop + pad_top, width: window, height: window })
16849
+ .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'fill' })
16850
+ .png({ force: true })
16851
+ .toBuffer();
16852
+ }
16853
+
16854
+ // Boaz, 2026-08-13: "the head should face to the camera and not tilted".
16855
+ //
16856
+ // The half of that a crop can honestly do is the LEAN. On a portrait the line
16857
+ // from the middle of the chest to the middle of the head is vertical; when
16858
+ // someone tips their head, or the camera was not level, that line tilts, and the
16859
+ // angle it is off by is the correction. Both centres come out of the measurement
16860
+ // the framing already does, so this costs one rotation and nothing else.
16861
+ //
16862
+ // Deliberately timid. Only acts past a couple of degrees (below that there is
16863
+ // nothing to see and the estimate is noise), never turns more than fifteen (a
16864
+ // bigger reading means the silhouette was misread, not that the person is nearly
16865
+ // horizontal), and rotates about the head rather than the picture, so what is
16866
+ // straightened is the face.
16867
+ //
16868
+ // Which way the person FACES is the other half, and it is not a rotation: a
16869
+ // three-quarter view has one ear hidden, and no amount of turning the picture
16870
+ // brings it back. That needs the image model, which is what the older/younger
16871
+ // route already uses.
16872
+ const AVATAR_TILT_MIN_DEG = 2;
16873
+ const AVATAR_TILT_MAX_DEG = 15;
16874
+ async function straightenSubject(segmentedBuffer, bounds) {
16875
+ try {
16876
+ if (!bounds || bounds.chestCenterX == null || bounds.headCenterX == null || bounds.chinY == null) return segmentedBuffer;
16877
+ const rise = bounds.chestY - (bounds.crownY + bounds.chinY) / 2;
16878
+ if (!(rise > 1)) return segmentedBuffer;
16879
+ const deg = (Math.atan2(bounds.headCenterX - bounds.chestCenterX, rise) * 180) / Math.PI;
16880
+ if (!Number.isFinite(deg) || Math.abs(deg) < AVATAR_TILT_MIN_DEG || Math.abs(deg) > AVATAR_TILT_MAX_DEG) return segmentedBuffer;
16881
+
16882
+ console.log(`[avatar] straightening a ${deg.toFixed(1)} degree lean`);
16883
+ return await sharp(segmentedBuffer)
16884
+ .ensureAlpha()
16885
+ .rotate(-deg, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
16886
+ .png({ force: true })
16887
+ .toBuffer();
16888
+ } catch (err) {
16889
+ // A picture that is not straightened is a picture that is not straightened.
16890
+ // Losing the avatar over it would be the worse trade.
16891
+ console.error('[avatar] could not straighten the subject:', err.message);
16892
+ return segmentedBuffer;
16893
+ }
16894
+ }
16895
+
15893
16896
  async function normalizeAuthenticProfileAvatar(base64Image, options = {}) {
15894
16897
  const { remove_background = true } = options;
15895
16898
  const inputBuffer = Buffer.from(base64Image, 'base64');
15896
16899
  const orientedBuffer = await sharp(inputBuffer).rotate().png({ force: true }).toBuffer();
15897
16900
  const segmentedBuffer = remove_background ? await removePortraitBackground(orientedBuffer) : orientedBuffer;
15898
16901
 
15899
- const framedBuffer = await frameSubjectAsAvatar(segmentedBuffer);
16902
+ // Level first, frame second: the framing measures a crown, a chin and a
16903
+ // centre, and all three move when the picture turns.
16904
+ const uprightBuffer = await straightenSubject(segmentedBuffer, await measureOpaqueBounds(segmentedBuffer));
16905
+
16906
+ const framedBuffer = await frameSubjectAsAvatar(uprightBuffer);
15900
16907
 
15901
16908
  const subjectBuffer = await sharp(framedBuffer).modulate({ brightness: 1.02, saturation: 1.04 }).sharpen({ sigma: 0.35, m1: 0.4, m2: 0.2 }).png({ quality: 98, compressionLevel: 8, force: true }).toBuffer();
15902
16909
 
15903
16910
  return subjectBuffer.toString('base64');
15904
16911
  }
15905
16912
 
16913
+ // Takes an already oriented PNG buffer. It used to rotate and re-encode one of
16914
+ // its own, which on a multi-megapixel photo is a second full lossless PNG round
16915
+ // trip for a picture its only caller had just produced exactly that way.
15906
16916
  async function removePortraitBackground(inputBuffer) {
15907
- const orientedBuffer = await sharp(inputBuffer).rotate().png({ force: true }).toBuffer();
15908
- const inputBlob = new Blob([orientedBuffer], { type: 'image/png' });
16917
+ const inputBlob = new Blob([inputBuffer], { type: 'image/png' });
15909
16918
 
15910
16919
  const outputBlob = await imglyRemoveBackground(inputBlob, {
15911
16920
  model: 'medium',
@@ -15977,21 +16986,237 @@ async function measureOpaqueBounds(inputBuffer) {
15977
16986
  return to;
15978
16987
  };
15979
16988
 
16989
+ // The subject's own edges, specks excluded. crownY is the top of the head.
16990
+ const crownY = firstIndex(rowCounts, minY, maxY, rowFloor);
16991
+ const footY = lastIndex(rowCounts, minY, maxY, rowFloor);
16992
+
15980
16993
  return {
15981
16994
  minX,
15982
16995
  minY,
15983
16996
  maxX,
15984
16997
  maxY,
15985
- // The subject's own edges, specks excluded. crownY is the top of the head.
15986
- crownY: firstIndex(rowCounts, minY, maxY, rowFloor),
15987
- footY: lastIndex(rowCounts, minY, maxY, rowFloor),
16998
+ crownY,
16999
+ footY,
15988
17000
  bodyMinX: firstIndex(colCounts, minX, maxX, colFloor),
15989
17001
  bodyMaxX: lastIndex(colCounts, minX, maxX, colFloor),
17002
+ ...measureHead(data, width, channels, alphaThreshold, rowCounts, crownY, footY),
15990
17003
  width,
15991
17004
  height,
15992
17005
  };
15993
17006
  }
15994
17007
 
17008
+ // The HEAD inside that silhouette: where it ends, and where its middle is.
17009
+ //
17010
+ // There is no face model in this pipeline and one is not worth a megabyte of
17011
+ // download to answer this. A background-removed portrait gives the head away by
17012
+ // its shape: the head is the narrow mass at the top and the shoulders below it
17013
+ // are markedly wider, so the row where the silhouette pinches is the neck, and
17014
+ // the chin is just above it. Exactly the reading the picture window already
17015
+ // makes on the client to centre a face in its circle.
17016
+ //
17017
+ // Returns nothing measurable (chinY null) rather than a guess when the shape
17018
+ // says nothing, so the caller can frame the whole subject instead of trusting a
17019
+ // number it invented.
17020
+ function measureHead(data, width, channels, alphaThreshold, rowCounts, crownY, footY) {
17021
+ const none = { chinY: null, headCenterX: null, headWidth: 0 };
17022
+ const height = footY - crownY + 1;
17023
+ if (height < 8) return none;
17024
+
17025
+ // Smoothed, because the reading below is about the SHAPE of the silhouette,
17026
+ // and a strand of hair or a gap in the cut-out is not part of the shape.
17027
+ const span = Math.max(1, Math.round(height * 0.01));
17028
+ const smooth = new Float64Array(height);
17029
+ for (let i = 0; i < height; i++) {
17030
+ let sum = 0;
17031
+ let n = 0;
17032
+ for (let k = -span; k <= span; k++) {
17033
+ const j = i + k;
17034
+ if (j >= 0 && j < height) {
17035
+ sum += rowCounts[crownY + j];
17036
+ n++;
17037
+ }
17038
+ }
17039
+ smooth[i] = sum / n;
17040
+ }
17041
+
17042
+ // Walk down from the crown looking for the first widen, pinch, widen: out
17043
+ // through the head, in at the neck, out again at the shoulders.
17044
+ //
17045
+ // Deliberately not "the widest row in the top third", which is what this used
17046
+ // to do. That assumes the head is a known share of whatever is in frame, and
17047
+ // it is not: on a photo taken a few steps back the shoulders are already
17048
+ // inside the top third, so they won the "widest row" and the head then
17049
+ // measured as the entire body. That assumption is why one photo framed well
17050
+ // and the next one did not, so it is the assumption that had to go.
17051
+ const DIP = 0.85; // how far below the head's own width the neck has to pinch
17052
+ const RISE = 1.2; // and how much wider the shoulders have to come back
17053
+ let peak = 0;
17054
+ let neck = -1;
17055
+ let neckW = Infinity;
17056
+ for (let i = 0; i < height; i++) {
17057
+ const w = smooth[i];
17058
+ // Still on the way out through the head. A new peak well above a candidate
17059
+ // pinch means that pinch was noise on the way up, so the search restarts.
17060
+ if (w > peak && (neck < 0 || w > neckW * RISE)) {
17061
+ peak = w;
17062
+ neck = -1;
17063
+ neckW = Infinity;
17064
+ }
17065
+ if (peak > 0 && w <= peak * DIP) {
17066
+ if (w < neckW) {
17067
+ neckW = w;
17068
+ neck = crownY + i;
17069
+ } else if (w >= neckW * RISE) {
17070
+ break; // pinched and widened again: that was the neck
17071
+ }
17072
+ }
17073
+ }
17074
+ // A pinch needs something wider BELOW it to be a neck rather than a chin at
17075
+ // the bottom edge of the picture. Measured against the widest row under the
17076
+ // pinch, not the last one: a portrait can taper again at the very bottom (an
17077
+ // arm leaving the frame, a jacket cut off) and reading only the final row
17078
+ // called a perfectly good neck unconfirmed.
17079
+ let below = 0;
17080
+ for (let i = neck > -1 ? neck - crownY + 1 : height; i < height; i++) if (smooth[i] > below) below = smooth[i];
17081
+ const confirmed = neck > -1 && below >= neckW * RISE;
17082
+
17083
+ let widest = 0;
17084
+ let widestAt = crownY;
17085
+ for (let y = crownY; y <= footY; y++) {
17086
+ if (rowCounts[y] > widest) {
17087
+ widest = rowCounts[y];
17088
+ widestAt = y;
17089
+ }
17090
+ }
17091
+ // Is there a BODY below the head at all? The two cases need opposite answers,
17092
+ // so this comes before anything else.
17093
+ //
17094
+ // Read at the BOTTOM EDGE rather than by asking where the widest row is. A
17095
+ // head tapers: the lowest rows of a head-only cut-out are a jaw or a neck, a
17096
+ // fraction of the width at the cheeks. A body does not: shoulders, arms and
17097
+ // clothing run out of the frame at close to full width. Asking instead
17098
+ // "is the widest row near the bottom" reads a portrait whose hair flares at
17099
+ // chest height as head-only, and that mistake frames a whole person as if
17100
+ // they were one head.
17101
+ // Read just ABOVE the bottom edge, and take the widest row there rather than
17102
+ // the average. Straightening a lean rotates the cut-out, which leaves
17103
+ // transparent wedges in the bottom corners: averaging the last rows counts
17104
+ // those wedges as the person getting narrower, and a straightened portrait
17105
+ // then reads as head-only. A band a little higher up is past them.
17106
+ let bottom_width = 0;
17107
+ const bottom_from = Math.max(crownY, footY - Math.round(height * 0.15));
17108
+ const bottom_to = Math.max(bottom_from, footY - Math.round(height * 0.03));
17109
+ for (let y = bottom_from; y <= bottom_to; y++) if (rowCounts[y] > bottom_width) bottom_width = rowCounts[y];
17110
+ // Two thirds of the widest row is the line between the two. Shoulders, arms
17111
+ // and clothing leave the frame at nearly full width; a head is well past its
17112
+ // cheeks by the time it reaches its own last rows, so it never gets there.
17113
+ const has_body = bottom_width >= widest * 0.7;
17114
+
17115
+ let chinY;
17116
+ if (confirmed) {
17117
+ // The chin sits a little ABOVE the narrowest point, which is mid-neck.
17118
+ // Clamped to a band as a backstop: a head is roughly two fifths of a
17119
+ // head-and-shoulders subject and never two thirds, so one freak narrow row
17120
+ // cannot drag the chin down onto the chest.
17121
+ const pinch = Math.min(Math.max(neck - crownY, height * 0.2), height * 0.6);
17122
+ chinY = Math.round(crownY + pinch * 0.94);
17123
+ } else if (has_body) {
17124
+ // No pinch, but there IS a body: hair over the shoulders, a scarf, a collar.
17125
+ // Very common, and the case that broke the first version of this, which read
17126
+ // the whole person as one head and framed a body's worth of picture with the
17127
+ // face a thumbnail in the middle of it.
17128
+ //
17129
+ // Read the SHOULDER LINE instead: the row where the silhouette widens
17130
+ // FASTEST. Above it the outline is a head, which widens gently and then
17131
+ // holds; at the shoulders it flares. The steepest row sits a little below
17132
+ // the chin, in the middle of that flare, so the chin is a fraction of the
17133
+ // way back up towards the crown.
17134
+ //
17135
+ // Measured as a rate of change rather than against a threshold width, and
17136
+ // that is the point: a threshold has to be a fraction of SOMETHING, and
17137
+ // every candidate (the widest row, the frame) changes meaning between a
17138
+ // full-length photo and a tight portrait. A tight portrait is not
17139
+ // hypothetical, it is what this same function produces, so the rule has to
17140
+ // survive being pointed at its own output.
17141
+ const shoulder = steepestWidening(smooth, height);
17142
+ if (shoulder < 0) return none;
17143
+ chinY = Math.round(crownY + Math.min(Math.max(shoulder * SHOULDER_TO_CHIN, height * 0.12), height * 0.7));
17144
+ } else {
17145
+ // A head-only cut-out with no shoulders in frame. The whole subject IS the
17146
+ // head, which is the honest reading and the one that keeps the crown where
17147
+ // it belongs.
17148
+ chinY = footY;
17149
+ }
17150
+
17151
+ // Middle and width of the head itself, measured over the head band only, so
17152
+ // wide shoulders cannot drag either sideways.
17153
+ let sumX = 0;
17154
+ let count = 0;
17155
+ let headMinX = width;
17156
+ let headMaxX = -1;
17157
+ for (let y = crownY; y <= chinY; y++) {
17158
+ for (let x = 0; x < width; x++) {
17159
+ if (data[(y * width + x) * channels + (channels - 1)] > alphaThreshold) {
17160
+ sumX += x;
17161
+ count++;
17162
+ if (x < headMinX) headMinX = x;
17163
+ if (x > headMaxX) headMaxX = x;
17164
+ }
17165
+ }
17166
+ }
17167
+ if (!count || headMaxX < 0) return none;
17168
+
17169
+ // The same reading again over the CHEST, which is what the head is leaning
17170
+ // against. Two centres and the distance between them is a line that should be
17171
+ // vertical on a portrait, and the angle it is off by is the lean.
17172
+ let chestSumX = 0;
17173
+ let chestCount = 0;
17174
+ const chestTop = Math.min(footY, chinY + Math.round((chinY - crownY) * 0.15));
17175
+ const chestBottom = Math.min(footY, chinY + Math.round((chinY - crownY) * 0.6));
17176
+ for (let y = chestTop; y <= chestBottom; y++) {
17177
+ for (let x = 0; x < width; x++) {
17178
+ if (data[(y * width + x) * channels + (channels - 1)] > alphaThreshold) {
17179
+ chestSumX += x;
17180
+ chestCount++;
17181
+ }
17182
+ }
17183
+ }
17184
+
17185
+ // Last check, and the one that catches the reading going wrong rather than
17186
+ // being imprecise: a head is never three times wider than it is tall. When it
17187
+ // measures that way the "shoulder line" was the hair flaring out below the
17188
+ // crown, which happens on a picture that is ALREADY a tight portrait, where
17189
+ // the head fills the frame and its own widening is the steepest thing in it.
17190
+ // Hair keeps this loose: a spread of hair genuinely makes a head wider than it
17191
+ // is tall, so only the absurd is rejected.
17192
+ const headWidth = headMaxX - headMinX + 1;
17193
+ if (chinY - crownY + 1 < headWidth * 0.5) return none;
17194
+
17195
+ const headCenterX = sumX / count;
17196
+ return { chinY, headCenterX, headWidth, chestCenterX: chestCount ? chestSumX / chestCount : null, chestY: chestCount ? (chestTop + chestBottom) / 2 : null };
17197
+ }
17198
+
17199
+ // Where the silhouette widens fastest, as an offset below the crown. Measured
17200
+ // over a window rather than row to row, so a ragged edge cannot win, and
17201
+ // searched between a twelfth and seven tenths of the way down: the chin is never
17202
+ // in the top of the hair, and anything past two thirds is chest.
17203
+ //
17204
+ // Returns -1 if there is nothing to measure.
17205
+ function steepestWidening(smooth, height) {
17206
+ const span = Math.max(2, Math.round(height * 0.03));
17207
+ const at = (i) => smooth[Math.min(height - 1, Math.max(0, i))];
17208
+ let best = 0;
17209
+ let bestAt = -1;
17210
+ for (let i = Math.round(height * 0.08); i <= Math.round(height * 0.7); i++) {
17211
+ const rise = at(i + span) - at(i - span);
17212
+ if (rise > best) {
17213
+ best = rise;
17214
+ bestAt = i;
17215
+ }
17216
+ }
17217
+ return bestAt;
17218
+ }
17219
+
15995
17220
  async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
15996
17221
  const { uid, account_profile_info, profile_picture } = ctx;
15997
17222
  const inputBuffer = Buffer.from(base64Image, 'base64');
@@ -16213,9 +17438,15 @@ export const inspect_profile_picture = async function (req, job_id, headers) {
16213
17438
  }
16214
17439
  };
16215
17440
 
16216
- const get_image_blob_from_downloaded_image = async function (url) {
16217
- async function hasTransparentBackground(filePath) {
16218
- const image = sharp(filePath);
17441
+ // `max_dim`, when a caller asks for it, caps the longest side of the picture
17442
+ // this hands back. Nothing is lost by it as long as the caller renders smaller
17443
+ // than the cap; what is saved is real, because everything downstream (a
17444
+ // lossless PNG re-encode, an upload to an image model, a background removal, an
17445
+ // alpha scan) costs in proportion to the pixel count, and a phone camera hands
17446
+ // us sixteen times the pixels a 1024 avatar can use.
17447
+ const get_image_blob_from_downloaded_image = async function (url, { max_dim } = {}) {
17448
+ async function hasTransparentBackground(input) {
17449
+ const image = sharp(input);
16219
17450
  const metadata = await image.metadata();
16220
17451
 
16221
17452
  // If the image doesn't have an alpha channel at all → no transparency possible
@@ -16250,7 +17481,29 @@ const get_image_blob_from_downloaded_image = async function (url) {
16250
17481
  let buffer;
16251
17482
 
16252
17483
  let is_transparent = false;
16253
- if (ext === '.png') {
17484
+ let oversized = false;
17485
+ if (max_dim) {
17486
+ try {
17487
+ const meta = await sharp(filePath).metadata();
17488
+ oversized = Math.max(meta.width || 0, meta.height || 0) > max_dim;
17489
+ } catch (e) {
17490
+ /* unreadable metadata: leave it to the paths below, which throw properly */
17491
+ }
17492
+ }
17493
+
17494
+ if (oversized) {
17495
+ console.log(`Capping ${path.basename(filePath)} to ${max_dim}px...`);
17496
+ // compressionLevel 6 rather than 9: this is a lossless format either way,
17497
+ // so the top level only trades seconds of CPU for bytes on a file that
17498
+ // never leaves this box.
17499
+ buffer = await sharp(filePath)
17500
+ .resize({ width: max_dim, height: max_dim, fit: 'inside', withoutEnlargement: true })
17501
+ .png({ compressionLevel: 6, force: true })
17502
+ .toBuffer();
17503
+ // Measured on the capped copy, which is the one that goes on: sixteen
17504
+ // times fewer pixels to walk for the same answer.
17505
+ is_transparent = await hasTransparentBackground(buffer);
17506
+ } else if (ext === '.png') {
16254
17507
  buffer = await fs.promises.readFile(filePath);
16255
17508
  is_transparent = await hasTransparentBackground(filePath);
16256
17509
  } else {