nixamp 0.65.1 → 0.67.0
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.d.ts +5 -1
- package/dist/ai-label.js +115 -37
- package/dist/discovery.d.ts +6 -3
- package/dist/discovery.js +16 -9
- package/dist/owner.d.ts +2 -0
- package/dist/owner.js +4 -0
- package/dist/server.js +56 -8
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +15 -0
- package/dist/station-mcp.js +2 -1
- 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 +115 -36
- package/src/discovery.ts +15 -8
- package/src/owner.ts +5 -0
- package/src/server.ts +51 -9
- package/src/station-cli.ts +1 -0
- package/src/station-mcp.ts +2 -1
- package/src/translate-jobs.ts +13 -4
- package/src/translation-passes.ts +155 -3
- package/web/dist/assets/{ar-DsLCtn6q.js → ar-Dw3s9ADu.js} +1 -1
- package/web/dist/assets/{cs-CDebRTvk.js → cs-CzDFyr4i.js} +1 -1
- package/web/dist/assets/{da-CtIZY0F_.js → da-DCTQgBaR.js} +1 -1
- package/web/dist/assets/{de-Ce4XnPH4.js → de-bRuy-6md.js} +1 -1
- package/web/dist/assets/{es-DuTsv2ty.js → es-iHd4cFjq.js} +1 -1
- package/web/dist/assets/{fi-BxzgeUzQ.js → fi-BQgHm0iD.js} +1 -1
- package/web/dist/assets/{fr-1e-Cafh4.js → fr-Bx2XC-ON.js} +1 -1
- package/web/dist/assets/{hi-yKzlszV2.js → hi-Cq8IDrah.js} +1 -1
- package/web/dist/assets/{hu-DEEC9-i5.js → hu-C3mzcLs_.js} +1 -1
- package/web/dist/assets/{id-Da47jrbA.js → id-DddaqO3C.js} +1 -1
- package/web/dist/assets/{it-DyEoFcC_.js → it-C8TvaS5w.js} +1 -1
- package/web/dist/assets/{main-C_3KEwHZ.js → main-BSOJX0Of.js} +2 -2
- package/web/dist/assets/{nl-uTGCd9L3.js → nl-C3LqPPhu.js} +1 -1
- package/web/dist/assets/{ru-BJ7gUalz.js → ru-DzAMilh-.js} +1 -1
- package/web/dist/assets/{sv-C9ZiAm2Q.js → sv-Ca1BMi0N.js} +1 -1
- package/web/dist/assets/{uk-DIcSDuMA.js → uk-D-CADfyO.js} +1 -1
- package/web/dist/assets/{vi-4MI2s3Z9.js → vi-CWeZdHAD.js} +1 -1
- package/web/dist/assets/{zh-DI1PPrRY.js → zh-BvS0BRgy.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
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.
|
|
54
|
-
"@profullstack/x402-gateway": "^0.
|
|
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.
|
|
87
|
+
"@profullstack/x402-gateway": "^0.11.0"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/ai-label.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { execFile } from "node:child_process";
|
|
30
30
|
import { randomBytes } from "node:crypto";
|
|
31
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { dirname, join, resolve, sep } from "node:path";
|
|
33
33
|
import { aiKey } from "./ai-keys.ts";
|
|
34
34
|
import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
|
|
@@ -127,8 +127,10 @@ export interface LabelSettings {
|
|
|
127
127
|
videos: boolean;
|
|
128
128
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
129
129
|
veo: boolean;
|
|
130
|
-
/** How many 8 s Veo shots of the band a single is cut from. */
|
|
130
|
+
/** How many 8 s Veo shots of the band playing (never the singer) a single is cut from. */
|
|
131
131
|
veoShots: number;
|
|
132
|
+
/** How many 8 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
|
|
133
|
+
storyShots: number;
|
|
132
134
|
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
133
135
|
lipsyncShots: number;
|
|
134
136
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
@@ -385,7 +387,8 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
|
|
|
385
387
|
publish: true,
|
|
386
388
|
videos: true,
|
|
387
389
|
veo: false,
|
|
388
|
-
veoShots:
|
|
390
|
+
veoShots: 2,
|
|
391
|
+
storyShots: 4,
|
|
389
392
|
lipsyncShots: 2,
|
|
390
393
|
parallel: 1,
|
|
391
394
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
@@ -626,21 +629,58 @@ export async function writeStoryboard(
|
|
|
626
629
|
return prompts;
|
|
627
630
|
}
|
|
628
631
|
|
|
632
|
+
const VEO_STORY_SYSTEM = [
|
|
633
|
+
"You write the story scenes of a music video for an original song, as prompts for a video model that films 8-second moving shots.",
|
|
634
|
+
"Tell the song's story in cinematic scenes away from any stage: characters, places, action, weather, small details. Keep the same main character(s), setting and color palette across scenes so it reads as one short film; vary the shots (wide, tracking, close detail, aerial) and say how the camera moves.",
|
|
635
|
+
"Describe people by how they look, never by name. Nobody sings, speaks or lip-syncs, and nobody holds a microphone. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
|
|
636
|
+
"Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
|
|
637
|
+
].join(" ");
|
|
638
|
+
|
|
639
|
+
/** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
|
|
640
|
+
export async function writeVeoStory(
|
|
641
|
+
artist: Pick<LabelArtist, "genre" | "sound">,
|
|
642
|
+
members: Array<Pick<Member, "role" | "look">>,
|
|
643
|
+
album: Pick<LabelAlbum, "title" | "concept">,
|
|
644
|
+
song: Pick<LabelSong, "title" | "style" | "lyrics">,
|
|
645
|
+
scenes: number,
|
|
646
|
+
ask: typeof askJson = askJson,
|
|
647
|
+
): Promise<string[]> {
|
|
648
|
+
const cast = members.slice(0, 4).map((one) => `${one.role}: ${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")}`).join("; ");
|
|
649
|
+
const answer = await ask({
|
|
650
|
+
system: VEO_STORY_SYSTEM,
|
|
651
|
+
prompt: [
|
|
652
|
+
`Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
653
|
+
cast ? `The band may appear as characters, described by look: ${cast}` : "",
|
|
654
|
+
`Mood and arrangement: ${song.style}`,
|
|
655
|
+
song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
|
|
656
|
+
`Exactly ${scenes} scenes.`,
|
|
657
|
+
].filter(Boolean).join("\n"),
|
|
658
|
+
maxTokens: 2500,
|
|
659
|
+
timeoutMs: 90_000,
|
|
660
|
+
});
|
|
661
|
+
const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
|
|
662
|
+
const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
|
|
663
|
+
if (prompts.length < 1) throw new Error("the model wrote no story");
|
|
664
|
+
return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
|
|
665
|
+
+ " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
|
|
666
|
+
}
|
|
667
|
+
|
|
629
668
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
630
669
|
export class VeoUnavailable extends Error {}
|
|
631
670
|
|
|
671
|
+
/** nixamp.com briefly away (a deploy: 502-504) or the network: worth asking again. */
|
|
672
|
+
const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
|
|
673
|
+
|
|
632
674
|
// Nobody sings on camera: Veo's singers mouth a melody of their own, never the song's,
|
|
633
675
|
// so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
|
|
634
676
|
// behind, in silhouette or between lines, never a close-up of singing.
|
|
635
677
|
const SHOTS = [
|
|
636
|
-
"Wide shot of the whole band performing on a big stage at night, seen from the back of the crowd, the players small against the lights",
|
|
637
678
|
"Close-up of the guitarist's hands on the fretboard and the strings, shallow focus, stage lights flaring",
|
|
638
|
-
"The camera glides behind the band members as they play hard, seen from behind, looking out over the crowd",
|
|
639
679
|
"Low-angle shot of the drummer hitting the cymbals at the song's climax, sticks blurred, sparks of light",
|
|
640
|
-
"
|
|
641
|
-
"
|
|
642
|
-
"
|
|
643
|
-
"
|
|
680
|
+
"Close-up of the bass player's fingers and the amplifier, then a tilt up to the bassist in silhouette",
|
|
681
|
+
"The guitarist and the bassist seen from behind, playing hard toward a huge crowd, backlit",
|
|
682
|
+
"Slow push-in on the drummer in a quiet moment, moody light, sweat and smoke",
|
|
683
|
+
"Slow aerial push over the crowd toward the stage, fans with hands up, the stage tiny in the distance",
|
|
644
684
|
];
|
|
645
685
|
|
|
646
686
|
// The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
|
|
@@ -662,7 +702,7 @@ export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: P
|
|
|
662
702
|
const cast = members.slice(0, 3).map((one, i) => `the ${one.role} from reference image ${i + 1} (${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
|
|
663
703
|
const rule = kind === "singer"
|
|
664
704
|
? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
|
|
665
|
-
: "
|
|
705
|
+
: "The lead vocalist is not in this shot, and nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
|
|
666
706
|
return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
|
|
667
707
|
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
668
708
|
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
@@ -1103,8 +1143,10 @@ export class AiLabel {
|
|
|
1103
1143
|
if (input["publish"] !== undefined) this.settings.publish = input["publish"] !== false;
|
|
1104
1144
|
if (input["videos"] !== undefined) this.settings.videos = input["videos"] !== false;
|
|
1105
1145
|
if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
|
|
1106
|
-
const shots = whole("veoShots",
|
|
1146
|
+
const shots = whole("veoShots", 0, 8);
|
|
1107
1147
|
if (shots !== undefined) this.settings.veoShots = shots;
|
|
1148
|
+
const story = whole("storyShots", 0, 8);
|
|
1149
|
+
if (story !== undefined) this.settings.storyShots = story;
|
|
1108
1150
|
const close = whole("lipsyncShots", 0, 4);
|
|
1109
1151
|
if (close !== undefined) this.settings.lipsyncShots = close;
|
|
1110
1152
|
const perDay = whole("albumsPerDay", 0, 24);
|
|
@@ -1347,8 +1389,9 @@ export class AiLabel {
|
|
|
1347
1389
|
} catch { /* the asked-for length */ }
|
|
1348
1390
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1349
1391
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1350
|
-
const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
|
|
1351
|
-
|
|
1392
|
+
const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
|
|
1393
|
+
const wanted = this.settings.veoShots + this.settings.storyShots;
|
|
1394
|
+
if (clips.length < Math.max(2, Math.ceil(wanted / 2))) throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
|
|
1352
1395
|
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1353
1396
|
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1354
1397
|
rmSync(shots, { recursive: true, force: true });
|
|
@@ -1378,13 +1421,20 @@ export class AiLabel {
|
|
|
1378
1421
|
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1379
1422
|
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
1423
|
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
|
-
|
|
1382
|
-
|
|
1424
|
+
let bytes: Buffer | null = null;
|
|
1425
|
+
for (let attempt = 0; ; attempt++) {
|
|
1426
|
+
try { bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 }); break; } catch (error) {
|
|
1427
|
+
if (attempt >= 2 || !TRANSIENT.test((error as Error).message)) throw error;
|
|
1428
|
+
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
writeFileSync(`${synced}.part`, bytes!);
|
|
1383
1432
|
renameSync(`${synced}.part`, synced);
|
|
1384
1433
|
pinned.push({ path: synced, at, seconds: 8 });
|
|
1385
1434
|
} catch (error) {
|
|
1435
|
+
// nixamp.com away (a deploy) or the month spent: nothing is cut yet; the shots wait for the next try.
|
|
1436
|
+
if (error instanceof VeoUnavailable || TRANSIENT.test((error as Error).message)) throw new Error(`lip sync ${i + 1} must wait: ${(error as Error).message}`);
|
|
1386
1437
|
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
1438
|
} finally {
|
|
1389
1439
|
rmSync(shot, { force: true });
|
|
1390
1440
|
rmSync(audio, { force: true });
|
|
@@ -1404,41 +1454,64 @@ export class AiLabel {
|
|
|
1404
1454
|
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
1405
1455
|
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
1406
1456
|
*/
|
|
1407
|
-
private async filmShots(artist: LabelArtist, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
|
|
1457
|
+
private async filmShots(artist: LabelArtist, album: LabelAlbum, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
|
|
1408
1458
|
const veo = this.deps.veo;
|
|
1409
1459
|
if (!veo) return { band: [], singer: [] };
|
|
1410
1460
|
mkdirSync(dir, { recursive: true });
|
|
1411
1461
|
mkdirSync(keep, { recursive: true });
|
|
1412
1462
|
const members = this.cast(artist);
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1463
|
+
// The players for the band shots, the voice for the close-ups: a singer's portrait in a band
|
|
1464
|
+
// shot puts the singer at a microphone, singing out of sync.
|
|
1465
|
+
const voice = (member: Member): boolean => /vocal|sing/i.test(member.role);
|
|
1466
|
+
const players = members.filter((member) => !voice(member));
|
|
1467
|
+
const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
|
|
1468
|
+
const portraits = async (list: Member[]): Promise<Buffer[]> => {
|
|
1469
|
+
const out: Buffer[] = [];
|
|
1470
|
+
for (const member of list.slice(0, 3)) {
|
|
1471
|
+
const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
|
|
1472
|
+
const small = join(dir, `ref-${member.slug}.jpg`);
|
|
1473
|
+
try {
|
|
1474
|
+
// 512 px is plenty for a face and keeps the request small.
|
|
1475
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", portrait, "-vf", "scale=512:-2", "-q:v", "4", small], 60_000, "the portrait did not shrink");
|
|
1476
|
+
out.push(readFileSync(small));
|
|
1477
|
+
} catch {
|
|
1478
|
+
// A member without a portrait is described in words only.
|
|
1479
|
+
}
|
|
1423
1480
|
}
|
|
1424
|
-
|
|
1481
|
+
return out;
|
|
1482
|
+
};
|
|
1483
|
+
const [playerRefs, singerRefs] = [await portraits(players), await portraits(singers)];
|
|
1425
1484
|
const band: Array<{ path: string; seconds: number }> = [];
|
|
1485
|
+
const story: Array<{ path: string; seconds: number }> = [];
|
|
1426
1486
|
const singer: Array<{ path: string; seconds: number }> = [];
|
|
1427
|
-
// The
|
|
1487
|
+
// The story is written only while some of its scenes are still to film.
|
|
1488
|
+
const storyFiles = Array.from({ length: this.settings.storyShots }, (_, i) => join(keep, `story-${i}.mp4`));
|
|
1489
|
+
let storyPrompts: string[] = [];
|
|
1490
|
+
if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
|
|
1491
|
+
try {
|
|
1492
|
+
storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
this.log(`no story for "${song.title}" yet: ${(error as Error).message}`);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
|
|
1498
|
+
// the portraits: they would pull the whole band into every scene.
|
|
1428
1499
|
const plan = [
|
|
1429
|
-
...
|
|
1430
|
-
...
|
|
1500
|
+
...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[] })),
|
|
1501
|
+
...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
|
|
1502
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
|
|
1431
1503
|
];
|
|
1432
|
-
for (const [i, { prompt, file, into }] of plan.entries()) {
|
|
1504
|
+
for (const [i, { prompt, file, into, refs }] of plan.entries()) {
|
|
1433
1505
|
try {
|
|
1434
1506
|
if (statSync(file).size > 10_000) { into.push({ path: file, seconds: 8 }); continue; }
|
|
1435
1507
|
} catch { /* not filmed yet */ }
|
|
1508
|
+
if (!prompt) continue;
|
|
1436
1509
|
try {
|
|
1437
1510
|
// nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
|
|
1438
1511
|
let bytes: Buffer | null = null;
|
|
1439
1512
|
for (let attempt = 0; ; attempt++) {
|
|
1440
|
-
try { bytes = await veo({ prompt, seconds: 8, references }); break; } catch (error) {
|
|
1441
|
-
if (attempt >= 2 || error instanceof VeoUnavailable ||
|
|
1513
|
+
try { bytes = await veo({ prompt, seconds: 8, references: refs }); break; } catch (error) {
|
|
1514
|
+
if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test((error as Error).message)) throw error;
|
|
1442
1515
|
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1443
1516
|
}
|
|
1444
1517
|
}
|
|
@@ -1455,8 +1528,14 @@ export class AiLabel {
|
|
|
1455
1528
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
|
|
1456
1529
|
}
|
|
1457
1530
|
}
|
|
1458
|
-
if (band.length || singer.length) this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1459
|
-
|
|
1531
|
+
if (band.length || story.length || singer.length) this.log(`Veo: ${band.length + story.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1532
|
+
// The cut takes them in turn: a story scene, then the band playing, and so on.
|
|
1533
|
+
const mixed: Array<{ path: string; seconds: number }> = [];
|
|
1534
|
+
for (let i = 0; i < Math.max(story.length, band.length); i++) {
|
|
1535
|
+
if (story[i]) mixed.push(story[i]!);
|
|
1536
|
+
if (band[i]) mixed.push(band[i]!);
|
|
1537
|
+
}
|
|
1538
|
+
return { band: mixed, singer };
|
|
1460
1539
|
}
|
|
1461
1540
|
|
|
1462
1541
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
package/src/discovery.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
12
|
|
|
13
13
|
export const RELEASES = "https://github.com/profullstack/nixamp/releases";
|
|
14
|
-
const LATEST_API = "https://api.github.com/repos/profullstack/nixamp/releases/latest";
|
|
15
14
|
|
|
16
15
|
/** Each installer release.yml builds, by the address /download/<target> gives it. */
|
|
17
16
|
export const DESKTOP_TARGETS: ReadonlyArray<{ target: string; label: string; suffix: string }> = [
|
|
@@ -39,19 +38,27 @@ export function desktopAsset(release: Release | null, target: string): string |
|
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
/**
|
|
42
|
-
* The latest published release,
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* The latest published release, from where github.com/.../releases/latest
|
|
42
|
+
* redirects (the tag), asked at most every ten minutes. Not the REST API: its
|
|
43
|
+
* 60 an hour unauthenticated are shared by every site on the box, and ran out.
|
|
44
|
+
* The installers are named by version (release.yml), and a release is only
|
|
45
|
+
* published once every one of them is attached. A failed ask keeps the last
|
|
46
|
+
* answer; with none, the downloads fall back to the releases page.
|
|
45
47
|
*/
|
|
46
48
|
export function latestRelease(fetcher: typeof fetch = fetch, now: () => number = Date.now): () => Promise<Release | null> {
|
|
47
49
|
let kept: { at: number; release: Release | null } = { at: -Infinity, release: null };
|
|
48
50
|
return async () => {
|
|
49
51
|
if (now() - kept.at < 10 * 60_000) return kept.release;
|
|
50
52
|
try {
|
|
51
|
-
const response = await fetcher(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
const response = await fetcher(`${RELEASES}/latest`, { method: "HEAD", redirect: "manual", headers: { "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
|
|
54
|
+
const tag = /\/releases\/tag\/(v\d+\.\d+\.\d+)$/.exec(response.headers.get("location") ?? "")?.[1];
|
|
55
|
+
if (!tag) throw new Error(`no release tag (HTTP ${response.status})`);
|
|
56
|
+
const version = tag.slice(1);
|
|
57
|
+
const assets = DESKTOP_TARGETS.map((one) => {
|
|
58
|
+
const name = `nixamp-${version}${one.suffix}`;
|
|
59
|
+
return { name, browser_download_url: `${RELEASES}/download/${tag}/${name}` };
|
|
60
|
+
});
|
|
61
|
+
kept = { at: now(), release: { tag_name: tag, assets } };
|
|
55
62
|
} catch {
|
|
56
63
|
kept = { at: now(), release: kept.release };
|
|
57
64
|
}
|
package/src/owner.ts
CHANGED
|
@@ -50,6 +50,11 @@ export class Owner {
|
|
|
50
50
|
return this.options.ownerId !== "";
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** The owner's account id, or "" when nobody has claimed this server. */
|
|
54
|
+
get id(): string {
|
|
55
|
+
return this.options.ownerId;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
/** The account a token belongs to, or "" for one nixamp.com does not accept. */
|
|
54
59
|
async accountFor(token: string): Promise<string> {
|
|
55
60
|
if (!token) return "";
|