nixamp 0.65.1 → 0.66.3

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.
Files changed (37) hide show
  1. package/dist/ai-label.js +18 -4
  2. package/dist/discovery.d.ts +6 -3
  3. package/dist/discovery.js +16 -9
  4. package/dist/owner.d.ts +2 -0
  5. package/dist/owner.js +4 -0
  6. package/dist/server.js +56 -8
  7. package/dist/translate-jobs.d.ts +5 -2
  8. package/dist/translate-jobs.js +11 -4
  9. package/dist/translation-passes.d.ts +55 -3
  10. package/dist/translation-passes.js +138 -3
  11. package/package.json +4 -4
  12. package/src/ai-label.ts +14 -4
  13. package/src/discovery.ts +15 -8
  14. package/src/owner.ts +5 -0
  15. package/src/server.ts +51 -9
  16. package/src/translate-jobs.ts +13 -4
  17. package/src/translation-passes.ts +155 -3
  18. package/web/dist/assets/{ar-DsLCtn6q.js → ar-BQfRAPxT.js} +1 -1
  19. package/web/dist/assets/{cs-CDebRTvk.js → cs-CIlyIUyM.js} +1 -1
  20. package/web/dist/assets/{da-CtIZY0F_.js → da-B73sEH_l.js} +1 -1
  21. package/web/dist/assets/{de-Ce4XnPH4.js → de-CR_qS1kD.js} +1 -1
  22. package/web/dist/assets/{es-DuTsv2ty.js → es-CWcxhMQ1.js} +1 -1
  23. package/web/dist/assets/{fi-BxzgeUzQ.js → fi-sw-rgeRG.js} +1 -1
  24. package/web/dist/assets/{fr-1e-Cafh4.js → fr-DWwCpFG-.js} +1 -1
  25. package/web/dist/assets/{hi-yKzlszV2.js → hi-BDa2YD-x.js} +1 -1
  26. package/web/dist/assets/{hu-DEEC9-i5.js → hu-CsYXXp0t.js} +1 -1
  27. package/web/dist/assets/{id-Da47jrbA.js → id-CzrnSbAL.js} +1 -1
  28. package/web/dist/assets/{it-DyEoFcC_.js → it-DT6eQX_8.js} +1 -1
  29. package/web/dist/assets/{main-C_3KEwHZ.js → main-B-WZ8VbE.js} +2 -2
  30. package/web/dist/assets/{nl-uTGCd9L3.js → nl-C57z9Sux.js} +1 -1
  31. package/web/dist/assets/{ru-BJ7gUalz.js → ru-CW9If0BU.js} +1 -1
  32. package/web/dist/assets/{sv-C9ZiAm2Q.js → sv-CiW_iXWQ.js} +1 -1
  33. package/web/dist/assets/{uk-DIcSDuMA.js → uk-D_agLCru.js} +1 -1
  34. package/web/dist/assets/{vi-4MI2s3Z9.js → vi-C4wrS-qx.js} +1 -1
  35. package/web/dist/assets/{zh-DI1PPrRY.js → zh-TOIwUGfV.js} +1 -1
  36. package/web/dist/index.html +1 -1
  37. package/web/dist/sw.js +19 -19
package/dist/ai-label.js CHANGED
@@ -492,6 +492,8 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
492
492
  /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
493
493
  export class VeoUnavailable extends Error {
494
494
  }
495
+ /** nixamp.com briefly away (a deploy: 502-504) or the network: worth asking again. */
496
+ const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
495
497
  // Nobody sings on camera: Veo's singers mouth a melody of their own, never the song's,
496
498
  // so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
497
499
  // behind, in silhouette or between lines, never a close-up of singing.
@@ -1246,15 +1248,27 @@ export class AiLabel {
1246
1248
  // The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
1247
1249
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i].path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
1248
1250
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
1249
- const bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
1251
+ let bytes = null;
1252
+ for (let attempt = 0;; attempt++) {
1253
+ try {
1254
+ bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
1255
+ break;
1256
+ }
1257
+ catch (error) {
1258
+ if (attempt >= 2 || !TRANSIENT.test(error.message))
1259
+ throw error;
1260
+ await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
1261
+ }
1262
+ }
1250
1263
  writeFileSync(`${synced}.part`, bytes);
1251
1264
  renameSync(`${synced}.part`, synced);
1252
1265
  pinned.push({ path: synced, at, seconds: 8 });
1253
1266
  }
1254
1267
  catch (error) {
1268
+ // nixamp.com away (a deploy) or the month spent: nothing is cut yet; the shots wait for the next try.
1269
+ if (error instanceof VeoUnavailable || TRANSIENT.test(error.message))
1270
+ throw new Error(`lip sync ${i + 1} must wait: ${error.message}`);
1255
1271
  this.log(`lip sync ${i + 1} for "${song.title}": ${error.message}; the band plays there instead`);
1256
- if (error instanceof VeoUnavailable)
1257
- break;
1258
1272
  }
1259
1273
  finally {
1260
1274
  rmSync(shot, { force: true });
@@ -1318,7 +1332,7 @@ export class AiLabel {
1318
1332
  break;
1319
1333
  }
1320
1334
  catch (error) {
1321
- if (attempt >= 2 || error instanceof VeoUnavailable || !/\b50[234]\b|fetch failed|ECONN|timed? ?out/i.test(error.message))
1335
+ if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test(error.message))
1322
1336
  throw error;
1323
1337
  await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
1324
1338
  }
@@ -29,9 +29,12 @@ export interface Release {
29
29
  /** The installer for `target` among a release's files, or null when that build is missing. */
30
30
  export declare function desktopAsset(release: Release | null, target: string): string | null;
31
31
  /**
32
- * The latest published release, asked of GitHub at most every ten minutes. A
33
- * failed ask keeps the last answer; with none, the downloads fall back to the
34
- * releases page, which is never wrong, only one click longer.
32
+ * The latest published release, from where github.com/.../releases/latest
33
+ * redirects (the tag), asked at most every ten minutes. Not the REST API: its
34
+ * 60 an hour unauthenticated are shared by every site on the box, and ran out.
35
+ * The installers are named by version (release.yml), and a release is only
36
+ * published once every one of them is attached. A failed ask keeps the last
37
+ * answer; with none, the downloads fall back to the releases page.
35
38
  */
36
39
  export declare function latestRelease(fetcher?: typeof fetch, now?: () => number): () => Promise<Release | null>;
37
40
  export declare function llmsTxt(site: string): string;
package/dist/discovery.js CHANGED
@@ -1,5 +1,4 @@
1
1
  export const RELEASES = "https://github.com/profullstack/nixamp/releases";
2
- const LATEST_API = "https://api.github.com/repos/profullstack/nixamp/releases/latest";
3
2
  /** Each installer release.yml builds, by the address /download/<target> gives it. */
4
3
  export const DESKTOP_TARGETS = [
5
4
  { target: "mac-arm64", label: "macOS (Apple silicon)", suffix: "-mac-arm64.dmg" },
@@ -21,9 +20,12 @@ export function desktopAsset(release, target) {
21
20
  return release.assets.find((asset) => asset.name.startsWith("nixamp-") && asset.name.endsWith(spec.suffix))?.browser_download_url ?? null;
22
21
  }
23
22
  /**
24
- * The latest published release, asked of GitHub at most every ten minutes. A
25
- * failed ask keeps the last answer; with none, the downloads fall back to the
26
- * releases page, which is never wrong, only one click longer.
23
+ * The latest published release, from where github.com/.../releases/latest
24
+ * redirects (the tag), asked at most every ten minutes. Not the REST API: its
25
+ * 60 an hour unauthenticated are shared by every site on the box, and ran out.
26
+ * The installers are named by version (release.yml), and a release is only
27
+ * published once every one of them is attached. A failed ask keeps the last
28
+ * answer; with none, the downloads fall back to the releases page.
27
29
  */
28
30
  export function latestRelease(fetcher = fetch, now = Date.now) {
29
31
  let kept = { at: -Infinity, release: null };
@@ -31,11 +33,16 @@ export function latestRelease(fetcher = fetch, now = Date.now) {
31
33
  if (now() - kept.at < 10 * 60_000)
32
34
  return kept.release;
33
35
  try {
34
- const response = await fetcher(LATEST_API, { headers: { accept: "application/vnd.github+json", "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
35
- if (!response.ok)
36
- throw new Error(`HTTP ${response.status}`);
37
- const body = await response.json();
38
- kept = { at: now(), release: { tag_name: String(body.tag_name ?? ""), assets: Array.isArray(body.assets) ? body.assets : [] } };
36
+ const response = await fetcher(`${RELEASES}/latest`, { method: "HEAD", redirect: "manual", headers: { "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
37
+ const tag = /\/releases\/tag\/(v\d+\.\d+\.\d+)$/.exec(response.headers.get("location") ?? "")?.[1];
38
+ if (!tag)
39
+ throw new Error(`no release tag (HTTP ${response.status})`);
40
+ const version = tag.slice(1);
41
+ const assets = DESKTOP_TARGETS.map((one) => {
42
+ const name = `nixamp-${version}${one.suffix}`;
43
+ return { name, browser_download_url: `${RELEASES}/download/${tag}/${name}` };
44
+ });
45
+ kept = { at: now(), release: { tag_name: tag, assets } };
39
46
  }
40
47
  catch {
41
48
  kept = { at: now(), release: kept.release };
package/dist/owner.d.ts CHANGED
@@ -42,6 +42,8 @@ export declare class Owner {
42
42
  private readonly cache;
43
43
  constructor(options: OwnerOptions);
44
44
  get claimed(): boolean;
45
+ /** The owner's account id, or "" when nobody has claimed this server. */
46
+ get id(): string;
45
47
  /** The account a token belongs to, or "" for one nixamp.com does not accept. */
46
48
  accountFor(token: string): Promise<string>;
47
49
  check(hasControlKey: boolean, token: string): Promise<AdminCheck>;
package/dist/owner.js CHANGED
@@ -32,6 +32,10 @@ export class Owner {
32
32
  get claimed() {
33
33
  return this.options.ownerId !== "";
34
34
  }
35
+ /** The owner's account id, or "" when nobody has claimed this server. */
36
+ get id() {
37
+ return this.options.ownerId;
38
+ }
35
39
  /** The account a token belongs to, or "" for one nixamp.com does not accept. */
36
40
  async accountFor(token) {
37
41
  if (!token)
package/dist/server.js CHANGED
@@ -3399,6 +3399,18 @@ export function createHandler(engine, options) {
3399
3399
  * OpenProfile. Never awaited by the poster: the phone is a side effect
3400
3400
  * of the chat, and a slow Telnyx must not slow the box.
3401
3401
  */
3402
+ /**
3403
+ * AI work done for a signed-in account, paid as it goes from their credit:
3404
+ * charged first, refunded if the work fails, a 402 with no credit. Only the
3405
+ * admin (the owner) is never charged; with billing off, only the admin may.
3406
+ */
3407
+ const payForAi = (by, kind, units, work) => {
3408
+ if (options.translationPasses)
3409
+ return options.translationPasses.spend(by, kind, units, work);
3410
+ if (by !== "" && by === options.owner?.id)
3411
+ return work();
3412
+ throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
3413
+ };
3402
3414
  const readOnThePhone = (where, authorId, handle, body) => {
3403
3415
  const partyLine = options.partyLine;
3404
3416
  const listing = (options.directory?.list() ?? []).find((one) => {
@@ -3419,7 +3431,11 @@ export function createHandler(engine, options) {
3419
3431
  const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
3420
3432
  const pools = options.voices ? await options.voices.pools() : { provider: "kokoro", female: [], male: [] };
3421
3433
  const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
3422
- await partyLine.say(code, spokenLine(handle, body), spoken.voice, spoken.settings);
3434
+ const words = spokenLine(handle, body);
3435
+ // Read aloud is AI speech, paid per character from the author's credit (the admin's never is).
3436
+ // No credit: the line stays in the chat and is not read.
3437
+ const say = () => partyLine.say(code, words, spoken.voice, spoken.settings);
3438
+ await payForAi(authorId, "phone_voice", words.length, say);
3423
3439
  })().catch(() => undefined);
3424
3440
  };
3425
3441
  /*
@@ -3738,13 +3754,15 @@ export function createHandler(engine, options) {
3738
3754
  json(response, 400, { error: "a room is a server address and a channel" });
3739
3755
  return;
3740
3756
  }
3741
- const heard = await speech.transcribe(bytes, {
3757
+ // Pay as you go per second of sound, from the WAV's own byte rate (the admin is never charged).
3758
+ const byteRate = bytes.length > 44 ? new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(28, true) : 0;
3759
+ const heard = await payForAi(who.id, "transcription_local", byteRate > 0 ? (bytes.length - 44) / byteRate : 1, () => speech.transcribe(bytes, {
3742
3760
  language: languageOf(url.searchParams.get("language")),
3743
3761
  // Pieces with their timing, for a whole file being written down.
3744
3762
  timestamps: url.searchParams.get("timestamps") === "1",
3745
3763
  by: who.id,
3746
3764
  ...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}),
3747
- });
3765
+ }));
3748
3766
  // The language travels back: as told, or as the ear guessed it, so
3749
3767
  // a captioner can say it next time and a transcript can be kept as it.
3750
3768
  const said = {
@@ -3964,7 +3982,9 @@ export function createHandler(engine, options) {
3964
3982
  return;
3965
3983
  }
3966
3984
  try {
3967
- json(response, 200, await translator.translate(texts, from, to, { by: who.id, ...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}) }));
3985
+ // Pay as you go per character sent (the admin is never charged).
3986
+ const characters = texts.reduce((sum, one) => sum + one.length, 0);
3987
+ json(response, 200, await payForAi(who.id, "translation_local", characters, () => translator.translate(texts, from, to, { by: who.id, ...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}) })));
3968
3988
  }
3969
3989
  catch (error) {
3970
3990
  if (error instanceof SpeechError)
@@ -7526,7 +7546,8 @@ export async function serve(argv, version = "0.1.0") {
7526
7546
  // Account servers meter paid providers by default, including when checkout
7527
7547
  // is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
7528
7548
  const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
7529
- ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
7549
+ // Every AI use is paid from this credit; only the admin (--owner / NIXAMP_OWNER) is never charged.
7550
+ ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
7530
7551
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
7531
7552
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
7532
7553
  // Monthly support: each month a new CoinPay payment and its link by email.
@@ -7608,6 +7629,16 @@ export async function serve(argv, version = "0.1.0") {
7608
7629
  return { status: 403, body: { error: "only the owner of a listed server" } };
7609
7630
  if (request.method === "GET")
7610
7631
  return { status: 200, body: await veoStudio.status() };
7632
+ // Pay as you go from the caller's AI credit, per second made; the admin is never charged.
7633
+ const payer = accounts ? (await accounts.whoIs(tokenFrom(request.headers)).catch(() => null))?.id ?? "" : "";
7634
+ const paid = (kind, seconds, work) => {
7635
+ if (translationPasses)
7636
+ return translationPasses.spend(payer, kind, seconds, work);
7637
+ if (payer !== "" && payer === options.owner)
7638
+ return work();
7639
+ throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
7640
+ };
7641
+ const unpaid = (error) => error instanceof SpeechError ? { status: error.status, body: { error: error.message, code: "credit" } } : null;
7611
7642
  if (request.method !== "POST")
7612
7643
  return { status: 405, body: { error: "GET or POST" } };
7613
7644
  let input;
@@ -7626,9 +7657,14 @@ export async function serve(argv, version = "0.1.0") {
7626
7657
  if (video.length < 10_000 || audio.length < 1_000)
7627
7658
  return { status: 400, body: { error: "a shot and its audio" } };
7628
7659
  try {
7629
- return { status: 200, body: null, video: await veoStudio.lipsync({ video, audio, seconds: Number(input.seconds ?? 8) }) };
7660
+ // Charged as veo-studio.ts makes it: 1 to 10 s.
7661
+ const seconds = Math.max(1, Math.min(10, Number(input.seconds ?? 8) || 8));
7662
+ return { status: 200, body: null, video: await paid("lipsync", seconds, () => veoStudio.lipsync({ video, audio, seconds })) };
7630
7663
  }
7631
7664
  catch (error) {
7665
+ const owed = unpaid(error);
7666
+ if (owed)
7667
+ return owed;
7632
7668
  if (error instanceof VeoBudgetError)
7633
7669
  return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
7634
7670
  return { status: 422, body: { error: error.message, code: "refused" } };
@@ -7639,9 +7675,14 @@ export async function serve(argv, version = "0.1.0") {
7639
7675
  return { status: 400, body: { error: "describe the shot" } };
7640
7676
  const references = (Array.isArray(input.references) ? input.references : []).slice(0, 3).map((one) => Buffer.from(String(one), "base64")).filter((one) => one.length > 1000);
7641
7677
  try {
7642
- return { status: 200, body: null, video: await veoStudio.clip({ prompt, seconds: Number(input.seconds ?? 8), references }) };
7678
+ // Charged as veo-studio.ts makes it: 4, 6 or 8 s.
7679
+ const seconds = [4, 6, 8].includes(Number(input.seconds)) ? Number(input.seconds) : 8;
7680
+ return { status: 200, body: null, video: await paid("video", seconds, () => veoStudio.clip({ prompt, seconds, references })) };
7643
7681
  }
7644
7682
  catch (error) {
7683
+ const owed = unpaid(error);
7684
+ if (owed)
7685
+ return owed;
7645
7686
  if (error instanceof VeoBudgetError)
7646
7687
  return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
7647
7688
  return { status: 422, body: { error: error.message, code: "refused" } };
@@ -7704,7 +7745,14 @@ export async function serve(argv, version = "0.1.0") {
7704
7745
  }
7705
7746
  // What was heard, kept: where the accounts are, under the media's identity.
7706
7747
  const transcripts = pool ? new Transcripts(pool, (message) => console.log(message)) : undefined;
7707
- const translations = transcripts ? new StoredTranslations(transcripts, translator, (message) => console.log(message)) : undefined;
7748
+ // Translating a stored transcript is AI: paid per character by whoever asked; only the admin is never charged.
7749
+ const translations = transcripts ? new StoredTranslations(transcripts, translator, (message) => console.log(message), (by, characters, work) => {
7750
+ if (translationPasses)
7751
+ return translationPasses.spend(by, "translation_local", characters, work);
7752
+ if (by !== "" && by === options.owner)
7753
+ return work();
7754
+ throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
7755
+ }) : undefined;
7708
7756
  // The record of every file nixamp has met, by its bytes: nixamp.com/hash/<id>.
7709
7757
  const records = pool ? new Media(pool, (message) => console.log(message)) : undefined;
7710
7758
  // nixamp as an OAuth 2.1 authorization server, and the watch parties a
@@ -4,6 +4,8 @@ export interface Progress {
4
4
  done: number;
5
5
  total: number;
6
6
  }
7
+ /** Runs some translating for an account, paid from its credit (server.ts payForAi). */
8
+ export type PayForTranslation = <T>(by: string, characters: number, work: () => Promise<T>) => Promise<T>;
7
9
  /** What a request should answer: the status and the body's extra fields. */
8
10
  export type Answer = {
9
11
  status: 200;
@@ -13,15 +15,16 @@ export type Answer = {
13
15
  transcript: Transcript | null;
14
16
  translating: Progress;
15
17
  } | {
16
- status: 404 | 409 | 503;
18
+ status: 402 | 404 | 409 | 503;
17
19
  error: string;
18
20
  };
19
21
  export declare class StoredTranslations {
20
22
  private readonly store;
21
23
  private readonly translator;
22
24
  private readonly onEvent;
25
+ private readonly pay;
23
26
  private readonly jobs;
24
- constructor(store: Transcripts, translator: Translator | undefined, onEvent?: (message: string) => void);
27
+ constructor(store: Transcripts, translator: Translator | undefined, onEvent?: (message: string) => void, pay?: PayForTranslation);
25
28
  /** The lines of the original that the translation does not yet have. */
26
29
  static missing(original: Transcript, translation: Transcript | null): TranscriptLine[];
27
30
  /**
@@ -20,11 +20,15 @@ export class StoredTranslations {
20
20
  store;
21
21
  translator;
22
22
  onEvent;
23
+ pay;
23
24
  jobs = new Map();
24
- constructor(store, translator, onEvent = () => { }) {
25
+ constructor(store, translator, onEvent = () => { },
26
+ // Pay as you go: each batch is charged to whoever asked, before it is translated.
27
+ pay = (_by, _characters, work) => work()) {
25
28
  this.store = store;
26
29
  this.translator = translator;
27
30
  this.onEvent = onEvent;
31
+ this.pay = pay;
28
32
  }
29
33
  /** The lines of the original that the translation does not yet have. */
30
34
  static missing(original, translation) {
@@ -58,7 +62,7 @@ export class StoredTranslations {
58
62
  let job = this.jobs.get(key);
59
63
  if (job?.error) {
60
64
  this.jobs.delete(key);
61
- return { status: 503, error: job.error };
65
+ return { status: job.status ?? 503, error: job.error };
62
66
  }
63
67
  if (!job) {
64
68
  job = this.start(key, original, translation, missing, language, by);
@@ -67,7 +71,7 @@ export class StoredTranslations {
67
71
  await job.finished;
68
72
  if (job.error) {
69
73
  this.jobs.delete(key);
70
- return { status: 503, error: job.error };
74
+ return { status: job.status ?? 503, error: job.error };
71
75
  }
72
76
  const made = await this.store.get(id, language);
73
77
  if (made)
@@ -81,6 +85,8 @@ export class StoredTranslations {
81
85
  job.finished = this.run(original, translation, missing, language, by, job)
82
86
  .catch((error) => {
83
87
  job.error = error instanceof SpeechError ? error.message : `translating failed: ${error.message}`;
88
+ if (error instanceof SpeechError && error.status === 402)
89
+ job.status = 402;
84
90
  this.onEvent(` translating ${original.title || original.media} to ${language}: ${job.error}`);
85
91
  })
86
92
  .finally(() => {
@@ -99,7 +105,8 @@ export class StoredTranslations {
99
105
  const lines = [];
100
106
  for (const source of new Set(batch.map((line) => line.language || original.language))) {
101
107
  const group = batch.filter((line) => (line.language || original.language) === source);
102
- const done = await translator.translate(group.map((line) => line.text), source, language);
108
+ const texts = group.map((line) => line.text);
109
+ const done = await this.pay(by, texts.reduce((sum, one) => sum + one.length, 0), () => translator.translate(texts, source, language));
103
110
  model = done.model;
104
111
  lines.push(...group.map((line, i) => ({ ...line, language, original: line.text, text: done.texts[i] ?? "" })).filter((line) => line.text !== ""));
105
112
  }
@@ -1,6 +1,58 @@
1
- /** Prepaid translation credit, from @profullstack/live-translate, sold as nixamp. */
2
- import { TranslationPasses as Base } from "@profullstack/live-translate";
1
+ /**
2
+ * Prepaid AI credit, from @profullstack/live-translate, sold as nixamp.
3
+ *
4
+ * Every AI feature on nixamp.com is pay as you go out of this one wallet: a
5
+ * pass adds dollars, each use takes what it costs times the multiplier, and a
6
+ * use that fails is refunded. There is no free allowance. The one exception is
7
+ * the site's admin (NIXAMP_OWNER), whose own use is never charged.
8
+ */
9
+ import { TranslationPasses as Base, type TranslationAccess, type TranslationUsage } from "@profullstack/live-translate";
3
10
  export { TRANSLATION_MULTIPLIER, TRANSLATION_PLANS, translationCost, verifyTranslationPayment, coinpayKeyFrom, verifyCoinpaySignature, readCoinpayWebhook, type TranslationPlan, type TranslationUsage, type TranslationMeter, type TranslationAccess } from "@profullstack/live-translate";
11
+ /** The AI work billed here beyond translated audio, and what one unit costs nixamp.com in dollars. */
12
+ export type AiKind = "video" | "lipsync" | "phone_voice" | "transcription_local" | "translation_local";
13
+ export declare const AI_RATES: Readonly<Record<AiKind, {
14
+ usd: number;
15
+ per: string;
16
+ }>>;
17
+ /** A charge taken before AI work runs: kept when the work is done, given back when it fails. */
18
+ export interface AiCharge {
19
+ commit(): Promise<void>;
20
+ refund(): Promise<void>;
21
+ }
22
+ export declare const ADMIN_USE = "admin:";
4
23
  export declare class TranslationPasses extends Base {
5
- constructor(options: ConstructorParameters<typeof Base>[0]);
24
+ private readonly admins;
25
+ private readonly db;
26
+ private readonly clock;
27
+ constructor(options: ConstructorParameters<typeof Base>[0] & {
28
+ admins?: string[];
29
+ });
30
+ isAdmin(by: string | undefined): boolean;
31
+ private tables;
32
+ access(by?: string): Promise<TranslationAccess>;
33
+ begin(by: string, resource: string): Promise<void>;
34
+ require(by: string, _resource?: string): Promise<void>;
35
+ eligible(accounts: string[], _resource?: string): Promise<string[]>;
36
+ reserve(by: string, kind: TranslationUsage, units: number, _resource?: string): Promise<string>;
37
+ reserveMany(accounts: string[], kind: TranslationUsage, units: number, _resource?: string): Promise<{
38
+ by: string;
39
+ id: string;
40
+ }[]>;
41
+ commit(id: string): Promise<void>;
42
+ refund(id: string): Promise<void>;
43
+ commitMany(ids: string[]): Promise<void>;
44
+ refundMany(ids: string[]): Promise<void>;
45
+ /** What `units` of `kind` cost, and what is charged for them, in micro-dollars. */
46
+ static price(kind: AiKind, units: number): {
47
+ cost: number;
48
+ charge: number;
49
+ };
50
+ /**
51
+ * Take the price of some AI work from `by`'s credit before it runs, in one
52
+ * statement, so two tabs cannot both spend the last cent. A 402 when there
53
+ * is not enough. The admin is never charged.
54
+ */
55
+ pay(by: string, kind: AiKind, units: number): Promise<AiCharge>;
56
+ /** Pay, do the work, and keep the charge only if the work succeeded. */
57
+ spend<T>(by: string, kind: AiKind, units: number, work: () => Promise<T>): Promise<T>;
6
58
  }
@@ -1,6 +1,141 @@
1
- /** Prepaid translation credit, from @profullstack/live-translate, sold as nixamp. */
2
- import { TranslationPasses as Base } from "@profullstack/live-translate";
1
+ /**
2
+ * Prepaid AI credit, from @profullstack/live-translate, sold as nixamp.
3
+ *
4
+ * Every AI feature on nixamp.com is pay as you go out of this one wallet: a
5
+ * pass adds dollars, each use takes what it costs times the multiplier, and a
6
+ * use that fails is refunded. There is no free allowance. The one exception is
7
+ * the site's admin (NIXAMP_OWNER), whose own use is never charged.
8
+ */
9
+ import { TranslationPasses as Base, TRANSLATION_MULTIPLIER } from "@profullstack/live-translate";
10
+ import { randomUUID } from "node:crypto";
11
+ import { SpeechError } from "./speech.js";
3
12
  export { TRANSLATION_MULTIPLIER, TRANSLATION_PLANS, translationCost, verifyTranslationPayment, coinpayKeyFrom, verifyCoinpaySignature, readCoinpayWebhook } from "@profullstack/live-translate";
13
+ const number = (name, fallback) => {
14
+ const value = Number(process.env[name]);
15
+ return Number.isFinite(value) && value > 0 ? value : fallback;
16
+ };
17
+ export const AI_RATES = {
18
+ // Veo 3.1 Fast and fal.ai lip sync, per second of video: what veo-studio.ts already budgets with.
19
+ video: { usd: number("NIXAMP_VEO_USD_PER_SECOND", 0.15), per: "second" },
20
+ lipsync: { usd: number("NIXAMP_LIPSYNC_USD_PER_SECOND", 0.1), per: "second" },
21
+ // A chat line read aloud on the party line: the ElevenLabs Flash rate, per character.
22
+ phone_voice: { usd: 0.05 / 1000, per: "character" },
23
+ // Whisper and OPUS-MT run on nixamp.com's own machine; priced like the hosted kind they replace.
24
+ transcription_local: { usd: 0.22 / 3600, per: "second" },
25
+ translation_local: { usd: number("NIXAMP_TRANSLATE_USD_PER_1K", 0.01) / 1000, per: "character" },
26
+ };
27
+ const FREE = { commit: async () => { }, refund: async () => { } };
28
+ export const ADMIN_USE = "admin:";
4
29
  export class TranslationPasses extends Base {
5
- constructor(options) { super({ app: "nixamp", label: "Nixamp translated audio", ...options }); }
30
+ admins;
31
+ db;
32
+ clock;
33
+ constructor(options) {
34
+ const { admins = [], ...rest } = options;
35
+ super({ app: "nixamp", label: "Nixamp AI credit", ...rest });
36
+ this.admins = new Set(admins.filter(Boolean));
37
+ this.db = rest.db;
38
+ this.clock = rest.now ?? Date.now;
39
+ }
40
+ isAdmin(by) {
41
+ return Boolean(by) && this.admins.has(by);
42
+ }
43
+ async tables() {
44
+ await this.ensure();
45
+ }
46
+ async access(by) {
47
+ const access = await super.access(by);
48
+ // No free sessions: the page offers credit, never "start a free session".
49
+ const free = { ...access.free, limit: 0, remaining: 0, activeUntil: null };
50
+ return this.isAdmin(by) ? { ...access, required: false, free } : { ...access, free };
51
+ }
52
+ async begin(by, resource) {
53
+ await this.require(by, resource);
54
+ }
55
+ async require(by, _resource = "") {
56
+ if (this.isAdmin(by))
57
+ return;
58
+ await this.tables();
59
+ const found = await this.db.query("SELECT 1 FROM translation_wallets WHERE by_account = $1 AND expires_at > $2 AND balance_micros > 0", [by, new Date(this.clock())]);
60
+ if (!found.rows.length)
61
+ throw new SpeechError("Buy AI credit: AI on nixamp is pay as you go.", 402);
62
+ }
63
+ async eligible(accounts, _resource = "") {
64
+ await this.tables();
65
+ const result = await this.db.query("SELECT by_account FROM translation_wallets WHERE by_account = ANY($1::text[]) AND expires_at > $2 AND balance_micros > 0", [accounts, new Date(this.clock())]);
66
+ return [...new Set([...accounts.filter((one) => this.isAdmin(one)), ...result.rows.map((row) => String(row["by_account"]))])];
67
+ }
68
+ async reserve(by, kind, units, _resource = "") {
69
+ const [taken] = await this.reserveMany([by], kind, units);
70
+ if (!taken)
71
+ throw new SpeechError("Buy AI credit: yours is used up.", 402);
72
+ return taken.id;
73
+ }
74
+ async reserveMany(accounts, kind, units, _resource = "") {
75
+ const admins = accounts.filter((one) => this.isAdmin(one)).map((by) => ({ by, id: `${ADMIN_USE}${randomUUID()}` }));
76
+ const paying = accounts.filter((one) => !this.isAdmin(one));
77
+ // No resource: the package's free-session lookup keys on it, and there are no free sessions here.
78
+ return [...admins, ...(paying.length ? await super.reserveMany(paying, kind, units, "") : [])];
79
+ }
80
+ async commit(id) {
81
+ if (!id.startsWith(ADMIN_USE))
82
+ await super.commit(id);
83
+ }
84
+ async refund(id) {
85
+ if (!id.startsWith(ADMIN_USE))
86
+ await super.refund(id);
87
+ }
88
+ async commitMany(ids) {
89
+ const paid = ids.filter((id) => !id.startsWith(ADMIN_USE));
90
+ if (paid.length)
91
+ await super.commitMany(paid);
92
+ }
93
+ async refundMany(ids) {
94
+ const paid = ids.filter((id) => !id.startsWith(ADMIN_USE));
95
+ if (paid.length)
96
+ await super.refundMany(paid);
97
+ }
98
+ /** What `units` of `kind` cost, and what is charged for them, in micro-dollars. */
99
+ static price(kind, units) {
100
+ const cost = Math.max(1, Math.ceil(AI_RATES[kind].usd * units * 1_000_000));
101
+ return { cost, charge: cost * TRANSLATION_MULTIPLIER };
102
+ }
103
+ /**
104
+ * Take the price of some AI work from `by`'s credit before it runs, in one
105
+ * statement, so two tabs cannot both spend the last cent. A 402 when there
106
+ * is not enough. The admin is never charged.
107
+ */
108
+ async pay(by, kind, units) {
109
+ if (this.isAdmin(by))
110
+ return FREE;
111
+ if (!by)
112
+ throw new SpeechError("Sign in to use AI on nixamp.", 401);
113
+ if (!Number.isFinite(units) || units <= 0)
114
+ return FREE;
115
+ await this.tables();
116
+ const { cost, charge } = TranslationPasses.price(kind, Math.ceil(units));
117
+ const now = new Date(this.clock());
118
+ const result = await this.db.query(`WITH debit AS (
119
+ UPDATE translation_wallets SET balance_micros = balance_micros - $2
120
+ WHERE by_account = $1 AND expires_at > $3 AND balance_micros >= $2 RETURNING by_account)
121
+ INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
122
+ SELECT gen_random_uuid()::text, by_account, $4, $5, $6, $2, $3 FROM debit RETURNING id`, [by, charge, now, kind, Math.ceil(units), cost]);
123
+ const id = result.rows[0]?.["id"];
124
+ if (!id)
125
+ throw new SpeechError(`Buy AI credit: AI on nixamp is pay as you go, and this costs $${(charge / 1_000_000).toFixed(4)}.`, 402);
126
+ return { commit: () => super.commit(String(id)), refund: () => super.refund(String(id)) };
127
+ }
128
+ /** Pay, do the work, and keep the charge only if the work succeeded. */
129
+ async spend(by, kind, units, work) {
130
+ const charge = await this.pay(by, kind, units);
131
+ try {
132
+ const done = await work();
133
+ await charge.commit();
134
+ return done;
135
+ }
136
+ catch (error) {
137
+ await charge.refund().catch(() => undefined);
138
+ throw error;
139
+ }
140
+ }
6
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.65.1",
3
+ "version": "0.66.3",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,8 +50,8 @@
50
50
  "@profullstack/live-translate": "^0.1.0",
51
51
  "@profullstack/notifications": "^0.1.0",
52
52
  "@profullstack/synconfig": "^0.1.2",
53
- "@profullstack/throttle": "0.2.2",
54
- "@profullstack/x402-gateway": "^0.10.1",
53
+ "@profullstack/throttle": "^0.3.3",
54
+ "@profullstack/x402-gateway": "^0.11.0",
55
55
  "acme-client": "5.4.0",
56
56
  "pg": "^8.23.0"
57
57
  },
@@ -84,6 +84,6 @@
84
84
  "overrides": {
85
85
  "https-proxy-agent": "7.0.6",
86
86
  "tslib": "2.8.1",
87
- "@profullstack/x402-gateway": "^0.10.1"
87
+ "@profullstack/x402-gateway": "^0.11.0"
88
88
  }
89
89
  }
package/src/ai-label.ts CHANGED
@@ -629,6 +629,9 @@ export async function writeStoryboard(
629
629
  /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
630
630
  export class VeoUnavailable extends Error {}
631
631
 
632
+ /** nixamp.com briefly away (a deploy: 502-504) or the network: worth asking again. */
633
+ const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
634
+
632
635
  // Nobody sings on camera: Veo's singers mouth a melody of their own, never the song's,
633
636
  // so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
634
637
  // behind, in silhouette or between lines, never a close-up of singing.
@@ -1378,13 +1381,20 @@ export class AiLabel {
1378
1381
  // The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
1379
1382
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i]!.path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
1380
1383
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
1381
- const bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
1382
- writeFileSync(`${synced}.part`, bytes);
1384
+ let bytes: Buffer | null = null;
1385
+ for (let attempt = 0; ; attempt++) {
1386
+ try { bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 }); break; } catch (error) {
1387
+ if (attempt >= 2 || !TRANSIENT.test((error as Error).message)) throw error;
1388
+ await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
1389
+ }
1390
+ }
1391
+ writeFileSync(`${synced}.part`, bytes!);
1383
1392
  renameSync(`${synced}.part`, synced);
1384
1393
  pinned.push({ path: synced, at, seconds: 8 });
1385
1394
  } catch (error) {
1395
+ // nixamp.com away (a deploy) or the month spent: nothing is cut yet; the shots wait for the next try.
1396
+ if (error instanceof VeoUnavailable || TRANSIENT.test((error as Error).message)) throw new Error(`lip sync ${i + 1} must wait: ${(error as Error).message}`);
1386
1397
  this.log(`lip sync ${i + 1} for "${song.title}": ${(error as Error).message}; the band plays there instead`);
1387
- if (error instanceof VeoUnavailable) break;
1388
1398
  } finally {
1389
1399
  rmSync(shot, { force: true });
1390
1400
  rmSync(audio, { force: true });
@@ -1438,7 +1448,7 @@ export class AiLabel {
1438
1448
  let bytes: Buffer | null = null;
1439
1449
  for (let attempt = 0; ; attempt++) {
1440
1450
  try { bytes = await veo({ prompt, seconds: 8, references }); break; } catch (error) {
1441
- if (attempt >= 2 || error instanceof VeoUnavailable || !/\b50[234]\b|fetch failed|ECONN|timed? ?out/i.test((error as Error).message)) throw error;
1451
+ if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test((error as Error).message)) throw error;
1442
1452
  await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
1443
1453
  }
1444
1454
  }