nixamp 0.65.0 → 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.
- package/dist/ai-label.js +18 -4
- package/dist/discovery.d.ts +6 -3
- package/dist/discovery.js +17 -10
- package/dist/owner.d.ts +2 -0
- package/dist/owner.js +4 -0
- package/dist/server.js +73 -17
- package/dist/translate-jobs.d.ts +5 -2
- package/dist/translate-jobs.js +11 -4
- package/dist/translation-passes.d.ts +55 -3
- package/dist/translation-passes.js +138 -3
- package/package.json +4 -4
- package/src/ai-label.ts +14 -4
- package/src/discovery.ts +16 -9
- package/src/owner.ts +5 -0
- package/src/server.ts +67 -18
- package/src/translate-jobs.ts +13 -4
- package/src/translation-passes.ts +155 -3
- package/web/dist/assets/{ar-T_s9IpDl.js → ar-BQfRAPxT.js} +1 -1
- package/web/dist/assets/{cs-u6VmDGRj.js → cs-CIlyIUyM.js} +1 -1
- package/web/dist/assets/{da-CtKMj9yA.js → da-B73sEH_l.js} +1 -1
- package/web/dist/assets/{de-DUIp6cPq.js → de-CR_qS1kD.js} +1 -1
- package/web/dist/assets/{es-CDKeGB_J.js → es-CWcxhMQ1.js} +1 -1
- package/web/dist/assets/{fi-Bm5mnEJW.js → fi-sw-rgeRG.js} +1 -1
- package/web/dist/assets/{fr-C-wTfG2I.js → fr-DWwCpFG-.js} +1 -1
- package/web/dist/assets/{hi-Cj5DBYQn.js → hi-BDa2YD-x.js} +1 -1
- package/web/dist/assets/{hu-l11hhWja.js → hu-CsYXXp0t.js} +1 -1
- package/web/dist/assets/{id-Y_NtfdKr.js → id-CzrnSbAL.js} +1 -1
- package/web/dist/assets/{it-DPsBnd04.js → it-DT6eQX_8.js} +1 -1
- package/web/dist/assets/{main-wXq7DAXz.js → main-B-WZ8VbE.js} +2 -2
- package/web/dist/assets/{nl-BJnRKhqE.js → nl-C57z9Sux.js} +1 -1
- package/web/dist/assets/{ru-CLRFRTHU.js → ru-CW9If0BU.js} +1 -1
- package/web/dist/assets/{sv-BarkkLkN.js → sv-CiW_iXWQ.js} +1 -1
- package/web/dist/assets/{uk-DlOj9CHi.js → uk-D_agLCru.js} +1 -1
- package/web/dist/assets/{vi-DqXccGG3.js → vi-C4wrS-qx.js} +1 -1
- package/web/dist/assets/{zh-CfnJPLyy.js → zh-TOIwUGfV.js} +1 -1
- package/web/dist/index.html +1 -1
- 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
|
-
|
|
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 ||
|
|
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
|
}
|
package/dist/discovery.d.ts
CHANGED
|
@@ -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,
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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,
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 };
|
|
@@ -78,7 +85,7 @@ export function llmsTxt(site) {
|
|
|
78
85
|
## Desktop app
|
|
79
86
|
Downloads: ${site}/download
|
|
80
87
|
${desktop}
|
|
81
|
-
|
|
88
|
+
Every release: ${RELEASES}
|
|
82
89
|
`;
|
|
83
90
|
}
|
|
84
91
|
const ok = { description: "OK", content: { "application/json": { schema: { type: "object" } } } };
|
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
|
@@ -197,7 +197,8 @@ export function parseServeArgs(argv) {
|
|
|
197
197
|
tlsCert: process.env["NIXAMP_TLS_CERT"] ?? "",
|
|
198
198
|
tlsKey: process.env["NIXAMP_TLS_KEY"] ?? "",
|
|
199
199
|
x402: false,
|
|
200
|
-
|
|
200
|
+
// The account that administers this server, for one with no key but accounts (nixamp.com).
|
|
201
|
+
owner: process.env["NIXAMP_OWNER"] ?? "",
|
|
201
202
|
ingest: false,
|
|
202
203
|
rtmpIn: 0,
|
|
203
204
|
rtmpStreams: 3,
|
|
@@ -1479,6 +1480,10 @@ async function readBytes(request, limit) {
|
|
|
1479
1480
|
export function createHandler(engine, options) {
|
|
1480
1481
|
// One per server, so the counters survive between requests and die with it.
|
|
1481
1482
|
const guard = new Guard();
|
|
1483
|
+
// --no-key opens a server to whoever reaches it, unless it has an owner: then
|
|
1484
|
+
// anyone may listen and only the owner (or the control key) administers.
|
|
1485
|
+
// nixamp.com is that server; without this every visitor was its admin.
|
|
1486
|
+
const openToAll = (key) => key === null && !options.owner?.claimed;
|
|
1482
1487
|
// nixamp.com's /download asks GitHub which release is latest; one answer per ten minutes.
|
|
1483
1488
|
const release = latestRelease();
|
|
1484
1489
|
const tracker = options.connections ?? new Connections();
|
|
@@ -1634,7 +1639,7 @@ export function createHandler(engine, options) {
|
|
|
1634
1639
|
cors: CORS,
|
|
1635
1640
|
watch: (incoming, outgoing, track) => watch(incoming, outgoing, "stream", track),
|
|
1636
1641
|
controls: async () => {
|
|
1637
|
-
if (key
|
|
1642
|
+
if (openToAll(key))
|
|
1638
1643
|
return true;
|
|
1639
1644
|
if (scopeOf(keyFrom(request, url), key, null) === "control")
|
|
1640
1645
|
return true;
|
|
@@ -2399,7 +2404,7 @@ export function createHandler(engine, options) {
|
|
|
2399
2404
|
// or the owner's nixamp.com session. Asked by the routes that do not go
|
|
2400
2405
|
// through the admin gate further down.
|
|
2401
2406
|
const controlOf = async () => {
|
|
2402
|
-
if (key
|
|
2407
|
+
if (openToAll(key))
|
|
2403
2408
|
return { allowed: true, account: "" };
|
|
2404
2409
|
if (scopeOf(keyFrom(request, url), key, listenKey) === "control")
|
|
2405
2410
|
return { allowed: true, account: "" };
|
|
@@ -3394,6 +3399,18 @@ export function createHandler(engine, options) {
|
|
|
3394
3399
|
* OpenProfile. Never awaited by the poster: the phone is a side effect
|
|
3395
3400
|
* of the chat, and a slow Telnyx must not slow the box.
|
|
3396
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
|
+
};
|
|
3397
3414
|
const readOnThePhone = (where, authorId, handle, body) => {
|
|
3398
3415
|
const partyLine = options.partyLine;
|
|
3399
3416
|
const listing = (options.directory?.list() ?? []).find((one) => {
|
|
@@ -3414,7 +3431,11 @@ export function createHandler(engine, options) {
|
|
|
3414
3431
|
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
3415
3432
|
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro", female: [], male: [] };
|
|
3416
3433
|
const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
|
|
3417
|
-
|
|
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);
|
|
3418
3439
|
})().catch(() => undefined);
|
|
3419
3440
|
};
|
|
3420
3441
|
/*
|
|
@@ -3733,13 +3754,15 @@ export function createHandler(engine, options) {
|
|
|
3733
3754
|
json(response, 400, { error: "a room is a server address and a channel" });
|
|
3734
3755
|
return;
|
|
3735
3756
|
}
|
|
3736
|
-
|
|
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, {
|
|
3737
3760
|
language: languageOf(url.searchParams.get("language")),
|
|
3738
3761
|
// Pieces with their timing, for a whole file being written down.
|
|
3739
3762
|
timestamps: url.searchParams.get("timestamps") === "1",
|
|
3740
3763
|
by: who.id,
|
|
3741
3764
|
...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}),
|
|
3742
|
-
});
|
|
3765
|
+
}));
|
|
3743
3766
|
// The language travels back: as told, or as the ear guessed it, so
|
|
3744
3767
|
// a captioner can say it next time and a transcript can be kept as it.
|
|
3745
3768
|
const said = {
|
|
@@ -3959,7 +3982,9 @@ export function createHandler(engine, options) {
|
|
|
3959
3982
|
return;
|
|
3960
3983
|
}
|
|
3961
3984
|
try {
|
|
3962
|
-
|
|
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 } : {}) })));
|
|
3963
3988
|
}
|
|
3964
3989
|
catch (error) {
|
|
3965
3990
|
if (error instanceof SpeechError)
|
|
@@ -4821,10 +4846,10 @@ export function createHandler(engine, options) {
|
|
|
4821
4846
|
(request.method === "DELETE" && channelAction === undefined)) &&
|
|
4822
4847
|
eventChannelRequest);
|
|
4823
4848
|
if (options.owner && needsAdmin(path, request.method ?? "GET") && !eventMediaOperation) {
|
|
4824
|
-
// A server started with --no-key has said that anyone who
|
|
4825
|
-
// port may drive it, and prints exactly that.
|
|
4826
|
-
//
|
|
4827
|
-
const holdsControl = key
|
|
4849
|
+
// A server started with --no-key and no owner has said that anyone who
|
|
4850
|
+
// can reach the port may drive it, and prints exactly that. With an owner
|
|
4851
|
+
// (nixamp.com), --no-key only means anyone may listen: the owner drives.
|
|
4852
|
+
const holdsControl = openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
4828
4853
|
// A session in the query as well as in a header or a cookie: from
|
|
4829
4854
|
// nixamp.com's page a server is another origin, and the cookie stays
|
|
4830
4855
|
// home, so the session travels the way the key does.
|
|
@@ -4980,7 +5005,7 @@ export function createHandler(engine, options) {
|
|
|
4980
5005
|
if (path === "/api/catalogs" && request.method === "GET") {
|
|
4981
5006
|
// Where a list is read from is the administrator's business, not a
|
|
4982
5007
|
// listener's: it can carry a provider's credentials in the URL.
|
|
4983
|
-
const holdsControl = key
|
|
5008
|
+
const holdsControl = openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
4984
5009
|
const admin = options.owner
|
|
4985
5010
|
? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
|
|
4986
5011
|
: holdsControl;
|
|
@@ -7521,7 +7546,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7521
7546
|
// Account servers meter paid providers by default, including when checkout
|
|
7522
7547
|
// is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
|
|
7523
7548
|
const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
|
|
7524
|
-
|
|
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;
|
|
7525
7551
|
// Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
|
|
7526
7552
|
const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
|
|
7527
7553
|
// Monthly support: each month a new CoinPay payment and its link by email.
|
|
@@ -7603,6 +7629,16 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7603
7629
|
return { status: 403, body: { error: "only the owner of a listed server" } };
|
|
7604
7630
|
if (request.method === "GET")
|
|
7605
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;
|
|
7606
7642
|
if (request.method !== "POST")
|
|
7607
7643
|
return { status: 405, body: { error: "GET or POST" } };
|
|
7608
7644
|
let input;
|
|
@@ -7621,9 +7657,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7621
7657
|
if (video.length < 10_000 || audio.length < 1_000)
|
|
7622
7658
|
return { status: 400, body: { error: "a shot and its audio" } };
|
|
7623
7659
|
try {
|
|
7624
|
-
|
|
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 })) };
|
|
7625
7663
|
}
|
|
7626
7664
|
catch (error) {
|
|
7665
|
+
const owed = unpaid(error);
|
|
7666
|
+
if (owed)
|
|
7667
|
+
return owed;
|
|
7627
7668
|
if (error instanceof VeoBudgetError)
|
|
7628
7669
|
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7629
7670
|
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
@@ -7634,9 +7675,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7634
7675
|
return { status: 400, body: { error: "describe the shot" } };
|
|
7635
7676
|
const references = (Array.isArray(input.references) ? input.references : []).slice(0, 3).map((one) => Buffer.from(String(one), "base64")).filter((one) => one.length > 1000);
|
|
7636
7677
|
try {
|
|
7637
|
-
|
|
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 })) };
|
|
7638
7681
|
}
|
|
7639
7682
|
catch (error) {
|
|
7683
|
+
const owed = unpaid(error);
|
|
7684
|
+
if (owed)
|
|
7685
|
+
return owed;
|
|
7640
7686
|
if (error instanceof VeoBudgetError)
|
|
7641
7687
|
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7642
7688
|
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
@@ -7699,7 +7745,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7699
7745
|
}
|
|
7700
7746
|
// What was heard, kept: where the accounts are, under the media's identity.
|
|
7701
7747
|
const transcripts = pool ? new Transcripts(pool, (message) => console.log(message)) : undefined;
|
|
7702
|
-
|
|
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;
|
|
7703
7756
|
// The record of every file nixamp has met, by its bytes: nixamp.com/hash/<id>.
|
|
7704
7757
|
const records = pool ? new Media(pool, (message) => console.log(message)) : undefined;
|
|
7705
7758
|
// nixamp as an OAuth 2.1 authorization server, and the watch parties a
|
|
@@ -8444,7 +8497,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
8444
8497
|
console.log(` Nothing outside reaches it until ${port} is forwarded here.`);
|
|
8445
8498
|
}
|
|
8446
8499
|
console.log("");
|
|
8447
|
-
if (key === null) {
|
|
8500
|
+
if (key === null && owner.claimed) {
|
|
8501
|
+
console.log(" No key: anyone who can reach this port can hear it; only its owner can drive it.");
|
|
8502
|
+
}
|
|
8503
|
+
else if (key === null) {
|
|
8448
8504
|
console.log(" No key: anyone who can reach this port can drive it and hear it.");
|
|
8449
8505
|
}
|
|
8450
8506
|
else {
|
package/dist/translate-jobs.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/translate-jobs.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
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
|
}
|