nixamp 0.68.2 → 0.70.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 +15 -5
- package/dist/ai-label.js +84 -34
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +12 -2
- package/dist/school-cli.d.ts +2 -0
- package/dist/school-cli.js +151 -0
- package/dist/school-client.d.ts +61 -0
- package/dist/school-client.js +148 -0
- package/dist/school-discovery.d.ts +32 -0
- package/dist/school-discovery.js +204 -0
- package/dist/school-mcp.d.ts +180 -0
- package/dist/school-mcp.js +143 -0
- package/dist/server.js +10 -0
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +6 -6
- package/dist/veo-studio.d.ts +1 -1
- package/dist/veo-studio.js +2 -2
- package/package.json +1 -1
- package/src/ai-label.ts +86 -35
- package/src/main.ts +8 -0
- package/src/mcp.ts +14 -2
- package/src/school-cli.ts +131 -0
- package/src/school-client.ts +180 -0
- package/src/school-discovery.ts +212 -0
- package/src/school-mcp.ts +153 -0
- package/src/server.ts +9 -0
- package/src/station-cli.ts +1 -0
- package/src/veo-studio.ts +2 -2
- package/web/dist/assets/{ar-BUn23r3N.js → ar-CoJztWFs.js} +1 -1
- package/web/dist/assets/{cs-FwQ-DhBJ.js → cs-1Avz2N93.js} +1 -1
- package/web/dist/assets/{da-C8OtQ3BT.js → da-CiNddZp3.js} +1 -1
- package/web/dist/assets/{de-CldRtYgJ.js → de-D8Rljw7l.js} +1 -1
- package/web/dist/assets/{es-DSPjVfW5.js → es-CIxJsfiv.js} +1 -1
- package/web/dist/assets/{fi-XgwcnMwU.js → fi-C5MieJtE.js} +1 -1
- package/web/dist/assets/{fr-BH9o9Stx.js → fr-DBMzgQul.js} +1 -1
- package/web/dist/assets/{hi-DuMMcoiX.js → hi-CUNsIGCW.js} +1 -1
- package/web/dist/assets/{hu-B2Hiutdz.js → hu-CRkS43qM.js} +1 -1
- package/web/dist/assets/{id-BNctMtkh.js → id-DtXzZzr5.js} +1 -1
- package/web/dist/assets/{it-C_ADjBqX.js → it-CleEG3yJ.js} +1 -1
- package/web/dist/assets/{main-BVDC-f0Q.js → main-DZtT4u1U.js} +2 -2
- package/web/dist/assets/{nl-BPTAZ6gI.js → nl-HDKYTlnz.js} +1 -1
- package/web/dist/assets/{ru-CMSzyodc.js → ru-CX6u3qpl.js} +1 -1
- package/web/dist/assets/{sv-BqdBoD8p.js → sv-BRCqB1fE.js} +1 -1
- package/web/dist/assets/{uk-C3lM_pHS.js → uk-BMLA9Xl6.js} +1 -1
- package/web/dist/assets/{vi-CkVfVKSH.js → vi-DzPvzeIq.js} +1 -1
- package/web/dist/assets/{zh-BDiROZo0.js → zh-Cbb7TICl.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backtoschool.help classes as MCP tools: what `nixamp school` does, for an agent.
|
|
3
|
+
*
|
|
4
|
+
* Reading what is on needs nobody; making, starting, ending and deleting a
|
|
5
|
+
* class needs the token the rest of nixamp uses, because a class is a live
|
|
6
|
+
* event on the same accounts. `class_teach` is the one-call path from a
|
|
7
|
+
* station link (station_list / station_create) to a class on the air.
|
|
8
|
+
*/
|
|
9
|
+
import { broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass, } from "./school-client.js";
|
|
10
|
+
const STRING = { type: "string" };
|
|
11
|
+
const SLUG = { ...STRING, description: "The class's slug, id, or its backtoschool.help/live/<slug> link." };
|
|
12
|
+
const BROADCAST = { ...STRING, description: "What plays: a nixamp station link (https://<server>.nixamp.com:4321/station/<id>?t=<token>), a nixamp.com share link, a Pairux room, or an https .m3u8/.mp4/.mp3 URL." };
|
|
13
|
+
const FIELDS = {
|
|
14
|
+
title: STRING,
|
|
15
|
+
description: STRING,
|
|
16
|
+
topic: STRING,
|
|
17
|
+
visibility: { type: "string", enum: ["public", "unlisted", "private"] },
|
|
18
|
+
startsAt: { ...STRING, description: "ISO 8601; leave out to start now (class_teach) or keep it a draft (class_create)." },
|
|
19
|
+
chatEnabled: { type: "boolean" },
|
|
20
|
+
handRaiseEnabled: { type: "boolean" },
|
|
21
|
+
};
|
|
22
|
+
export const SCHOOL_PUBLIC_TOOLS = [
|
|
23
|
+
{
|
|
24
|
+
name: "classes_list",
|
|
25
|
+
description: "Classes on backtoschool.help: live now, scheduled, drafts or ended. Each with its link, status and what it broadcasts. No sign-in needed (mine=true needs one).",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
status: { type: "string", enum: ["draft", "scheduled", "starting", "live", "encore", "ended", "cancelled", "archived"] },
|
|
30
|
+
mine: { type: "boolean", description: "Only the classes this account hosts." },
|
|
31
|
+
limit: { type: "integer" },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "class_get",
|
|
37
|
+
description: "One class in full, and whether its broadcast is playing right now.",
|
|
38
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
export const SCHOOL_TOOLS = [
|
|
42
|
+
...SCHOOL_PUBLIC_TOOLS,
|
|
43
|
+
{
|
|
44
|
+
name: "class_teach",
|
|
45
|
+
description: "Make a class of a broadcast and put it on the air now: the one call from a station link to a live class. Returns the class link.",
|
|
46
|
+
inputSchema: { type: "object", properties: { broadcastUrl: BROADCAST, ...FIELDS }, required: ["title", "broadcastUrl"] },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "class_create",
|
|
50
|
+
description: "Make a class without starting it: a draft, or scheduled when startsAt is given.",
|
|
51
|
+
inputSchema: { type: "object", properties: { broadcastUrl: BROADCAST, ...FIELDS }, required: ["title"] },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "class_update",
|
|
55
|
+
description: "Change a class you host: title, description, topic, broadcast, visibility, time, chat or raised hands.",
|
|
56
|
+
inputSchema: { type: "object", properties: { slug: SLUG, broadcastUrl: BROADCAST, ...FIELDS }, required: ["slug"] },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "class_start",
|
|
60
|
+
description: "Put a class you host on the air.",
|
|
61
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "class_end",
|
|
65
|
+
description: "End a class you host. It stays listed as ended; a repeating class schedules its next session.",
|
|
66
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "class_delete",
|
|
70
|
+
description: "Delete a class you host, for good.",
|
|
71
|
+
inputSchema: { type: "object", properties: { slug: SLUG }, required: ["slug"] },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "classes_check",
|
|
75
|
+
description: "Your live classes, each with whether its broadcast still plays. A class stays live until ended, so this finds the ones showing a dead player; end or delete those.",
|
|
76
|
+
inputSchema: { type: "object", properties: {} },
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
export function isSchoolTool(name) {
|
|
80
|
+
return SCHOOL_TOOLS.some((tool) => tool.name === name);
|
|
81
|
+
}
|
|
82
|
+
export function isSchoolPublicTool(name) {
|
|
83
|
+
return SCHOOL_PUBLIC_TOOLS.some((tool) => tool.name === name);
|
|
84
|
+
}
|
|
85
|
+
const say = (text, isError = false) => ({ content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) });
|
|
86
|
+
function line(item, options) {
|
|
87
|
+
return `${item.status.padEnd(9)} ${item.title}\n ${classUrl(item, options)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`;
|
|
88
|
+
}
|
|
89
|
+
function fieldsOf(args) {
|
|
90
|
+
const keys = ["title", "description", "topic", "visibility", "startsAt", "chatEnabled", "handRaiseEnabled", "broadcastUrl"];
|
|
91
|
+
return Object.fromEntries(keys.filter((key) => args[key] !== undefined).map((key) => [key, args[key]]));
|
|
92
|
+
}
|
|
93
|
+
export async function callSchoolTool(name, args, options = {}) {
|
|
94
|
+
const slug = typeof args["slug"] === "string" ? args["slug"] : "";
|
|
95
|
+
try {
|
|
96
|
+
if (name === "classes_list") {
|
|
97
|
+
const items = await listClasses({
|
|
98
|
+
...(typeof args["status"] === "string" ? { status: args["status"] } : {}),
|
|
99
|
+
...(args["mine"] === true ? { mine: true } : {}),
|
|
100
|
+
...(typeof args["limit"] === "number" ? { limit: args["limit"] } : {}),
|
|
101
|
+
}, options);
|
|
102
|
+
return say(items.length ? items.map((one) => line(one, options)).join("\n\n") : "No classes match.");
|
|
103
|
+
}
|
|
104
|
+
if (name === "classes_check") {
|
|
105
|
+
const checked = await checkClasses(options);
|
|
106
|
+
if (checked.length === 0)
|
|
107
|
+
return say("You have no live classes.");
|
|
108
|
+
return say(checked.map(({ item, health }) => `${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : "unknown"} ${item.slug}: ${health.reason}`).join("\n"));
|
|
109
|
+
}
|
|
110
|
+
if (!slug && name !== "class_teach" && name !== "class_create")
|
|
111
|
+
return say("Which class? Pass its slug (classes_list).", true);
|
|
112
|
+
if (name === "class_get") {
|
|
113
|
+
const item = await getClass(slug, options);
|
|
114
|
+
const health = await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch);
|
|
115
|
+
return say(`${JSON.stringify(item, null, 2)}\n\nbroadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`);
|
|
116
|
+
}
|
|
117
|
+
if (name === "class_teach") {
|
|
118
|
+
const item = await teach(fieldsOf(args), options);
|
|
119
|
+
return say(`On the air: ${classUrl(item, options)}`);
|
|
120
|
+
}
|
|
121
|
+
if (name === "class_create") {
|
|
122
|
+
const item = await createClass(fieldsOf(args), options);
|
|
123
|
+
return say(`Made (${item.status}): ${classUrl(item, options)}\nclass_start ${item.slug} puts it on the air.`);
|
|
124
|
+
}
|
|
125
|
+
if (name === "class_update") {
|
|
126
|
+
const { slug: _slug, ...rest } = args;
|
|
127
|
+
const item = await updateClass(slug, fieldsOf(rest), options);
|
|
128
|
+
return say(line(item, options));
|
|
129
|
+
}
|
|
130
|
+
if (name === "class_start" || name === "class_end") {
|
|
131
|
+
const item = await moveClass(slug, name === "class_start" ? "start" : "end", options);
|
|
132
|
+
return say(line(item, options));
|
|
133
|
+
}
|
|
134
|
+
if (name === "class_delete") {
|
|
135
|
+
await deleteClass(slug, options);
|
|
136
|
+
return say(`Deleted ${slug}.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return say(error.message, true);
|
|
141
|
+
}
|
|
142
|
+
return say(`No such tool: ${name}`, true);
|
|
143
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -30,6 +30,7 @@ import { Ingest, normaliseFormat } from "./ingest.js";
|
|
|
30
30
|
import { BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow, } from "./channels.js";
|
|
31
31
|
import { RtmpListeners } from "./rtmp-in.js";
|
|
32
32
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
|
|
33
|
+
import { handleSchoolDiscovery, isSchoolSite } from "./school-discovery.js";
|
|
33
34
|
import { RESET_INVALID, RESET_MESSAGE, resendPasswordReset } from "./password-reset.js";
|
|
34
35
|
import { passwordResetPage } from "./password-reset-page.js";
|
|
35
36
|
import { anonymousHandle, Handles } from "./handles.js";
|
|
@@ -3113,6 +3114,15 @@ export function createHandler(engine, options) {
|
|
|
3113
3114
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
3114
3115
|
return;
|
|
3115
3116
|
}
|
|
3117
|
+
// backtoschool.help's own llms.txt, OpenAPI and class MCP, ahead of nixamp's (school-discovery.ts).
|
|
3118
|
+
if (options.accounts && webSite && isSchoolSite(webSite.site)) {
|
|
3119
|
+
const accounts = options.accounts;
|
|
3120
|
+
if (await handleSchoolDiscovery(request, response, path, {
|
|
3121
|
+
site: webSite.site, accountSite: options.site ?? "https://nixamp.com", version: options.version,
|
|
3122
|
+
whoIs: (token) => accounts.whoIs(token), token: tokenFrom(request.headers), readBody, json,
|
|
3123
|
+
}))
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3116
3126
|
// Every way into nixamp, for agents and stores: llms.txt, OpenAPI, health, desktop downloads (discovery.ts).
|
|
3117
3127
|
if (options.accounts && options.site && await handleDiscovery(request, response, path, { site: options.site, version: options.version, release, json }))
|
|
3118
3128
|
return;
|
package/dist/station-cli.js
CHANGED
|
@@ -540,6 +540,7 @@ async function labelCommand(argv, call, show) {
|
|
|
540
540
|
resetDay: number("--reset-day"),
|
|
541
541
|
veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
|
|
542
542
|
veoShots: number("--veo-shots"),
|
|
543
|
+
videoUsd: number("--video-usd"),
|
|
543
544
|
lipsyncShots: number("--lipsync-shots"),
|
|
544
545
|
storyShots: number("--story-shots"),
|
|
545
546
|
genreOrder: flag(argv, "--genres"),
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const STATION_TOOLS: ({
|
|
|
2
2
|
name: string;
|
|
3
3
|
description: string;
|
|
4
4
|
inputSchema: {
|
|
5
|
+
required?: undefined;
|
|
5
6
|
type: string;
|
|
6
7
|
properties: {
|
|
7
8
|
server: {
|
|
@@ -72,7 +73,6 @@ export declare const STATION_TOOLS: ({
|
|
|
72
73
|
id?: undefined;
|
|
73
74
|
private?: undefined;
|
|
74
75
|
};
|
|
75
|
-
required?: undefined;
|
|
76
76
|
};
|
|
77
77
|
} | {
|
|
78
78
|
name: string;
|
|
@@ -531,6 +531,7 @@ export declare const STATION_TOOLS: ({
|
|
|
531
531
|
name: string;
|
|
532
532
|
description: string;
|
|
533
533
|
inputSchema: {
|
|
534
|
+
required?: undefined;
|
|
534
535
|
type: string;
|
|
535
536
|
properties: {
|
|
536
537
|
playlist?: undefined;
|
|
@@ -615,12 +616,12 @@ export declare const STATION_TOOLS: ({
|
|
|
615
616
|
id?: undefined;
|
|
616
617
|
private?: undefined;
|
|
617
618
|
};
|
|
618
|
-
required?: undefined;
|
|
619
619
|
};
|
|
620
620
|
} | {
|
|
621
621
|
name: string;
|
|
622
622
|
description: string;
|
|
623
623
|
inputSchema: {
|
|
624
|
+
required?: undefined;
|
|
624
625
|
type: string;
|
|
625
626
|
properties: {
|
|
626
627
|
playlist?: undefined;
|
|
@@ -718,12 +719,12 @@ export declare const STATION_TOOLS: ({
|
|
|
718
719
|
id?: undefined;
|
|
719
720
|
private?: undefined;
|
|
720
721
|
};
|
|
721
|
-
required?: undefined;
|
|
722
722
|
};
|
|
723
723
|
} | {
|
|
724
724
|
name: string;
|
|
725
725
|
description: string;
|
|
726
726
|
inputSchema: {
|
|
727
|
+
required?: undefined;
|
|
727
728
|
type: string;
|
|
728
729
|
properties: {
|
|
729
730
|
playlist?: undefined;
|
|
@@ -862,12 +863,12 @@ export declare const STATION_TOOLS: ({
|
|
|
862
863
|
id?: undefined;
|
|
863
864
|
private?: undefined;
|
|
864
865
|
};
|
|
865
|
-
required?: undefined;
|
|
866
866
|
};
|
|
867
867
|
} | {
|
|
868
868
|
name: string;
|
|
869
869
|
description: string;
|
|
870
870
|
inputSchema: {
|
|
871
|
+
required?: undefined;
|
|
871
872
|
type: string;
|
|
872
873
|
properties: {
|
|
873
874
|
playlist?: undefined;
|
|
@@ -960,12 +961,12 @@ export declare const STATION_TOOLS: ({
|
|
|
960
961
|
license?: undefined;
|
|
961
962
|
private?: undefined;
|
|
962
963
|
};
|
|
963
|
-
required?: undefined;
|
|
964
964
|
};
|
|
965
965
|
} | {
|
|
966
966
|
name: string;
|
|
967
967
|
description: string;
|
|
968
968
|
inputSchema: {
|
|
969
|
+
required?: undefined;
|
|
969
970
|
type: string;
|
|
970
971
|
properties: {
|
|
971
972
|
playlist?: undefined;
|
|
@@ -1060,7 +1061,6 @@ export declare const STATION_TOOLS: ({
|
|
|
1060
1061
|
};
|
|
1061
1062
|
private?: undefined;
|
|
1062
1063
|
};
|
|
1063
|
-
required?: undefined;
|
|
1064
1064
|
};
|
|
1065
1065
|
} | {
|
|
1066
1066
|
name: string;
|
package/dist/veo-studio.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export declare class VeoStudio {
|
|
|
61
61
|
get canLipsync(): boolean;
|
|
62
62
|
/**
|
|
63
63
|
* A singer's close-up with the mouth redrawn to `audio` (the same seconds of
|
|
64
|
-
* the song): Sync's lipsync-2 on fal.ai. Veo's singers mouth a melody of their
|
|
64
|
+
* the song): Sync's lipsync-2-pro on fal.ai. Veo's singers mouth a melody of their
|
|
65
65
|
* own; this makes them sing ours. Paid under the same monthly cap.
|
|
66
66
|
*/
|
|
67
67
|
lipsync(input: {
|
package/dist/veo-studio.js
CHANGED
|
@@ -176,7 +176,7 @@ export class VeoStudio {
|
|
|
176
176
|
get canLipsync() { return Boolean(this.options.falKey); }
|
|
177
177
|
/**
|
|
178
178
|
* A singer's close-up with the mouth redrawn to `audio` (the same seconds of
|
|
179
|
-
* the song): Sync's lipsync-2 on fal.ai. Veo's singers mouth a melody of their
|
|
179
|
+
* the song): Sync's lipsync-2-pro on fal.ai. Veo's singers mouth a melody of their
|
|
180
180
|
* own; this makes them sing ours. Paid under the same monthly cap.
|
|
181
181
|
*/
|
|
182
182
|
async lipsync(input) {
|
|
@@ -197,7 +197,7 @@ export class VeoStudio {
|
|
|
197
197
|
}
|
|
198
198
|
async sync(key, video, audio) {
|
|
199
199
|
const [videoUrl, audioUrl] = await Promise.all([this.falUpload(key, video, "video/mp4", "shot.mp4"), this.falUpload(key, audio, "audio/mpeg", "vocal.mp3")]);
|
|
200
|
-
return await this.falJob(key, "fal-ai/sync-lipsync/v2", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
|
|
200
|
+
return await this.falJob(key, "fal-ai/sync-lipsync/v2/pro", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
|
|
201
201
|
}
|
|
202
202
|
async make(prompt, seconds, references) {
|
|
203
203
|
const send = this.options.fetcher ?? fetch;
|
package/package.json
CHANGED
package/src/ai-label.ts
CHANGED
|
@@ -586,6 +586,19 @@ export function elevenLabsAligner(key: string, fetcher: typeof fetch = fetch): (
|
|
|
586
586
|
};
|
|
587
587
|
}
|
|
588
588
|
|
|
589
|
+
/** ElevenLabs voice isolation: a few seconds of the song in, the voice alone out (what a lip sync follows best). */
|
|
590
|
+
export function elevenLabsIsolator(key: string, fetcher: typeof fetch = fetch): (audio: Buffer) => Promise<Buffer> {
|
|
591
|
+
return async (audio) => {
|
|
592
|
+
const form = new FormData();
|
|
593
|
+
form.append("audio", new Blob([new Uint8Array(audio)], { type: "audio/mpeg" }), "lines.mp3");
|
|
594
|
+
const response = await fetcher("https://api.elevenlabs.io/v1/audio-isolation", { method: "POST", headers: { "xi-api-key": key }, body: form, signal: AbortSignal.timeout(2 * 60_000) });
|
|
595
|
+
if (!response.ok) throw new Error(`the voice isolation answered HTTP ${response.status}`);
|
|
596
|
+
const voice = Buffer.from(await response.arrayBuffer());
|
|
597
|
+
if (voice.length < 1000) throw new Error("the voice isolation answered with no audio");
|
|
598
|
+
return voice;
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
589
602
|
/** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
|
|
590
603
|
export function sungLines(lyrics: string): string[] {
|
|
591
604
|
return lyrics.split("\n").map((line) => line.trim()).filter((line) => line && !/^\[[^\]]*\]$/.test(line));
|
|
@@ -645,13 +658,17 @@ export async function writeStoryboard(
|
|
|
645
658
|
}
|
|
646
659
|
|
|
647
660
|
const VEO_STORY_SYSTEM = [
|
|
648
|
-
"You write the story scenes of a music video for an original song, as prompts for a video model that films
|
|
649
|
-
"Tell the song's story in cinematic scenes away from any stage
|
|
650
|
-
"
|
|
651
|
-
"
|
|
661
|
+
"You write the story scenes of a music video for an original song, as prompts for a video model that films short moving shots.",
|
|
662
|
+
"Tell the song's story in cinematic scenes away from any stage. The main characters are ONLY the band members listed (by role); every scene with a person in it features one to three of them, and each one keeps exactly the look given. Other people may appear only in the background, faces unseen.",
|
|
663
|
+
"Keep the same settings 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. Nobody sings, speaks or lip-syncs, and nobody holds a microphone.",
|
|
664
|
+
"Describe people by role and look, never by name. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
|
|
665
|
+
"Answer as JSON: {\"scenes\": [{\"prompt\": \"...\", \"cast\": [\"lead vocals\", ...]}, ...]} where cast lists the roles (exactly as given) of the members in that scene, [] for none.",
|
|
652
666
|
].join(" ");
|
|
653
667
|
|
|
654
|
-
/** A
|
|
668
|
+
/** A story scene for Veo: the prompt and the roles of the band members in it (their portraits go with it). */
|
|
669
|
+
export interface StoryScene { prompt: string; cast: string[] }
|
|
670
|
+
|
|
671
|
+
/** A single's story scenes for Veo: a short film from its words starring the band as they look, nobody singing. */
|
|
655
672
|
export async function writeVeoStory(
|
|
656
673
|
artist: Pick<LabelArtist, "genre" | "sound">,
|
|
657
674
|
members: Array<Pick<Member, "role" | "look">>,
|
|
@@ -659,25 +676,33 @@ export async function writeVeoStory(
|
|
|
659
676
|
song: Pick<LabelSong, "title" | "style" | "lyrics">,
|
|
660
677
|
scenes: number,
|
|
661
678
|
ask: typeof askJson = askJson,
|
|
662
|
-
): Promise<
|
|
663
|
-
const cast = members.slice(0,
|
|
679
|
+
): Promise<StoryScene[]> {
|
|
680
|
+
const cast = members.slice(0, 5).map((one) => `${one.role}: ${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")}`).join("; ");
|
|
664
681
|
const answer = await ask({
|
|
665
682
|
system: VEO_STORY_SYSTEM,
|
|
666
683
|
prompt: [
|
|
667
684
|
`Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
668
|
-
cast ? `The band
|
|
685
|
+
cast ? `The band (the only main characters), by role and look: ${cast}` : "",
|
|
669
686
|
`Mood and arrangement: ${song.style}`,
|
|
670
687
|
song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
|
|
671
688
|
`Exactly ${scenes} scenes.`,
|
|
672
689
|
].filter(Boolean).join("\n"),
|
|
673
|
-
maxTokens:
|
|
674
|
-
timeoutMs:
|
|
690
|
+
maxTokens: 4000,
|
|
691
|
+
timeoutMs: 120_000,
|
|
675
692
|
});
|
|
693
|
+
const roles = new Set(members.map((one) => one.role.toLowerCase()));
|
|
676
694
|
const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
695
|
+
const out = list.map((one): StoryScene => {
|
|
696
|
+
const item = (typeof one === "object" && one ? one : { prompt: one }) as { prompt?: unknown; cast?: unknown };
|
|
697
|
+
const who = Array.isArray(item.cast) ? item.cast.map((role) => String(role).toLowerCase().trim()).filter((role) => roles.has(role)) : [];
|
|
698
|
+
return { prompt: clean(item.prompt, 400), cast: [...new Set(who)].slice(0, 3) };
|
|
699
|
+
}).filter((one) => one.prompt.length > 10).slice(0, scenes);
|
|
700
|
+
if (out.length < 1) throw new Error("the model wrote no story");
|
|
701
|
+
return out.map((scene) => ({
|
|
702
|
+
cast: scene.cast,
|
|
703
|
+
prompt: `Music video for a fictional ${artist.genre} song. ${scene.prompt} Mood: ${song.style}`.slice(0, 900)
|
|
704
|
+
+ " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, high detail. No text, no logos.",
|
|
705
|
+
}));
|
|
681
706
|
}
|
|
682
707
|
|
|
683
708
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
@@ -826,13 +851,16 @@ export const VIDEO_USD = 8;
|
|
|
826
851
|
* story. Footage shorter than the song is cut in turn and shown again.
|
|
827
852
|
* $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
|
|
828
853
|
*/
|
|
829
|
-
export function shotPlan(
|
|
854
|
+
export function shotPlan(seconds: number, singing: boolean, usd = VIDEO_USD): { story: number; band: number; singer: number } {
|
|
830
855
|
const closeUp = SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND), shot = SHOT_SECONDS * VEO_USD_PER_SECOND;
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
856
|
+
const length = Math.max(30, seconds);
|
|
857
|
+
// About one lip-synced close-up every 30 s of song, as many as leave room for at least two shots.
|
|
858
|
+
let singer = singing ? Math.max(1, Math.min(6, Math.round(length / 30))) : 0;
|
|
859
|
+
while (singer > 1 && usd < singer * closeUp + 2 * shot) singer -= 1;
|
|
860
|
+
// Then shots enough to cover the rest of the song once (nothing shown twice), as far as the money goes.
|
|
861
|
+
const cover = Math.ceil(Math.max(0, length - singer * SINGER_SECONDS) / SHOT_SECONDS);
|
|
862
|
+
const shots = Math.max(2, Math.min(cover, Math.floor((usd - singer * closeUp) / shot + 1e-9)));
|
|
863
|
+
const band = Math.max(1, Math.round(shots / 4));
|
|
836
864
|
return { story: Math.max(1, shots - band), band, singer };
|
|
837
865
|
}
|
|
838
866
|
|
|
@@ -870,9 +898,10 @@ export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, c
|
|
|
870
898
|
* The version of a video cut from Veo footage: story scenes and the band playing with
|
|
871
899
|
* nobody singing, the singer only in close-ups lip-synced to the song. Anything older
|
|
872
900
|
* is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
|
|
873
|
-
* the mic in the wide shots).
|
|
901
|
+
* the mic in the wide shots). 6 and 7 (strangers in the story scenes, footage looped)
|
|
902
|
+
* stay up until filmed again.
|
|
874
903
|
*/
|
|
875
|
-
export const FILMED =
|
|
904
|
+
export const FILMED = 8;
|
|
876
905
|
/** The oldest version still shown while its single waits to be filmed again: 6, story and band with a close-up, but footage looped and little singing. */
|
|
877
906
|
export const SHOWN = 6;
|
|
878
907
|
|
|
@@ -1026,6 +1055,8 @@ export interface AiLabelDeps {
|
|
|
1026
1055
|
retryMs?: number;
|
|
1027
1056
|
/** Cuts a single's filmed shots to its song; ffmpeg by default (renderFilmedVideo). */
|
|
1028
1057
|
filmedVideo?: (input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string; pinned?: Array<{ path: string; at: number; seconds: number }> }) => Promise<void>;
|
|
1058
|
+
/** The voice alone from a few seconds of a song (ElevenLabs by default when its key is set); null for none. */
|
|
1059
|
+
isolate?: ((audio: Buffer) => Promise<Buffer>) | null;
|
|
1029
1060
|
/** A singer's close-up with the mouth redrawn to these seconds of the song (nixamp.com, fal.ai); VeoUnavailable when the month is spent. */
|
|
1030
1061
|
lipsync?: ((input: { video: Buffer; audio: Buffer; seconds: number }) => Promise<Buffer>) | null;
|
|
1031
1062
|
/** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
|
|
@@ -1510,9 +1541,13 @@ export class AiLabel {
|
|
|
1510
1541
|
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1511
1542
|
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");
|
|
1512
1543
|
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");
|
|
1544
|
+
// The mouth follows the voice alone better than the whole mix; the video keeps the whole mix.
|
|
1545
|
+
let voice: Buffer = readFileSync(audio);
|
|
1546
|
+
const isolate = this.deps.isolate !== undefined ? this.deps.isolate : (aiKey("ELEVENLABS_API_KEY") ? elevenLabsIsolator(aiKey("ELEVENLABS_API_KEY")!) : null);
|
|
1547
|
+
if (isolate) voice = await isolate(voice).catch((error: unknown) => { this.log(`voice isolation for "${song.title}": ${(error as Error).message}; the whole mix instead`); return voice; });
|
|
1513
1548
|
let bytes: Buffer | null = null;
|
|
1514
1549
|
for (let attempt = 0; ; attempt++) {
|
|
1515
|
-
try { bytes = await lipsync({ video: readFileSync(shot), audio:
|
|
1550
|
+
try { bytes = await lipsync({ video: readFileSync(shot), audio: voice, seconds: 8 }); break; } catch (error) {
|
|
1516
1551
|
if (attempt >= 2 || !TRANSIENT.test((error as Error).message)) throw error;
|
|
1517
1552
|
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1518
1553
|
}
|
|
@@ -1555,18 +1590,24 @@ export class AiLabel {
|
|
|
1555
1590
|
const voice = (member: Member): boolean => /vocal|sing/i.test(member.role);
|
|
1556
1591
|
const players = members.filter((member) => !voice(member));
|
|
1557
1592
|
const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
|
|
1593
|
+
const shrunk = new Map<string, Buffer | null>();
|
|
1558
1594
|
const portraits = async (list: Member[]): Promise<Buffer[]> => {
|
|
1559
1595
|
const out: Buffer[] = [];
|
|
1560
1596
|
for (const member of list.slice(0, 3)) {
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1597
|
+
if (!shrunk.has(member.slug)) {
|
|
1598
|
+
const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
|
|
1599
|
+
const small = join(dir, `ref-${member.slug}.jpg`);
|
|
1600
|
+
try {
|
|
1601
|
+
// 768 px keeps a face's detail and the request small.
|
|
1602
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", portrait, "-vf", "scale=768:-2", "-q:v", "3", small], 60_000, "the portrait did not shrink");
|
|
1603
|
+
shrunk.set(member.slug, readFileSync(small));
|
|
1604
|
+
} catch {
|
|
1605
|
+
// A member without a portrait is described in words only.
|
|
1606
|
+
shrunk.set(member.slug, null);
|
|
1607
|
+
}
|
|
1569
1608
|
}
|
|
1609
|
+
const bytes = shrunk.get(member.slug);
|
|
1610
|
+
if (bytes) out.push(bytes);
|
|
1570
1611
|
}
|
|
1571
1612
|
return out;
|
|
1572
1613
|
};
|
|
@@ -1576,7 +1617,7 @@ export class AiLabel {
|
|
|
1576
1617
|
const singer: Array<{ path: string; seconds: number }> = [];
|
|
1577
1618
|
// The story is written only while some of its scenes are still to film.
|
|
1578
1619
|
const storyFiles = Array.from({ length: counts.story }, (_, i) => join(keep, `story-${i}.mp4`));
|
|
1579
|
-
let storyPrompts:
|
|
1620
|
+
let storyPrompts: StoryScene[] = [];
|
|
1580
1621
|
if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
|
|
1581
1622
|
try {
|
|
1582
1623
|
storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
|
|
@@ -1584,12 +1625,22 @@ export class AiLabel {
|
|
|
1584
1625
|
this.log(`no story for "${song.title}" yet: ${(error as Error).message}`);
|
|
1585
1626
|
}
|
|
1586
1627
|
}
|
|
1587
|
-
// The singer's close-ups only where they can be lip-synced afterwards.
|
|
1588
|
-
// the portraits
|
|
1628
|
+
// The singer's close-ups only where they can be lip-synced afterwards. Each story scene is filmed
|
|
1629
|
+
// with the portraits of the members in it, and their looks in words, so they are the same people
|
|
1630
|
+
// in every shot; a scene with nobody in it goes without.
|
|
1631
|
+
const byRole = new Map(members.map((member) => [member.role.toLowerCase(), member]));
|
|
1632
|
+
const storyCast = await Promise.all(storyPrompts.map(async (scene) => {
|
|
1633
|
+
const who = scene.cast.map((role) => byRole.get(role)).filter((member): member is Member => Boolean(member));
|
|
1634
|
+
const named = who.map((member, i) => `the ${member.role} from reference image ${i + 1} (${member.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
|
|
1635
|
+
return {
|
|
1636
|
+
prompt: who.length ? `${scene.prompt} The people are exactly the ones in the reference images, same faces, hair and clothes: ${named}.` : scene.prompt,
|
|
1637
|
+
refs: await portraits(who),
|
|
1638
|
+
};
|
|
1639
|
+
}));
|
|
1589
1640
|
// The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
|
|
1590
1641
|
const plan = [
|
|
1591
1642
|
...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs, seconds: SINGER_SECONDS })) : []),
|
|
1592
|
-
...storyFiles.map((file, i) => ({ prompt:
|
|
1643
|
+
...storyFiles.map((file, i) => ({ prompt: storyCast[i]?.prompt ?? "", file, into: story, refs: storyCast[i]?.refs ?? [], seconds: SHOT_SECONDS })),
|
|
1593
1644
|
...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs, seconds: SHOT_SECONDS })),
|
|
1594
1645
|
];
|
|
1595
1646
|
for (const [i, { prompt, file, into, refs, seconds }] of plan.entries()) {
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts"
|
|
|
10
10
|
import { USAGE as STATION_USAGE } from "./station-usage.ts";
|
|
11
11
|
import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
|
|
12
12
|
import { MUSIC_USAGE } from "./music-cli.ts";
|
|
13
|
+
import { SCHOOL_USAGE } from "./school-cli.ts";
|
|
13
14
|
import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { resolve } from "node:path";
|
|
@@ -237,6 +238,7 @@ keeps serving its browser remote. One per user.
|
|
|
237
238
|
nixamp admin who is connected, and re-stream to them
|
|
238
239
|
nixamp station make a folder into a station others can join (see: nixamp station help)
|
|
239
240
|
nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
|
|
241
|
+
nixamp school classes on backtoschool.help: teach a station live, check, prune (nixamp school help)
|
|
240
242
|
|
|
241
243
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
242
244
|
`,
|
|
@@ -402,6 +404,7 @@ export function helpFor(topic: string | undefined): string {
|
|
|
402
404
|
if (topic === "station" || topic === "stations") return STATION_USAGE;
|
|
403
405
|
if (topic === "library") return LIBRARY_USAGE;
|
|
404
406
|
if (topic === "music") return MUSIC_USAGE;
|
|
407
|
+
if (topic === "school") return SCHOOL_USAGE;
|
|
405
408
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
406
409
|
}
|
|
407
410
|
|
|
@@ -566,6 +569,11 @@ export async function main(): Promise<void> {
|
|
|
566
569
|
process.exitCode = await music(rest);
|
|
567
570
|
return;
|
|
568
571
|
}
|
|
572
|
+
if (first === "school" || first === "class" || first === "classes") {
|
|
573
|
+
const { school } = await import("./school-cli.ts");
|
|
574
|
+
process.exitCode = await school(rest);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
569
577
|
if (first === "station" || first === "stations") {
|
|
570
578
|
const { station } = await import("./station-cli.ts");
|
|
571
579
|
process.exitCode = await station(rest);
|
package/src/mcp.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { personaLines, readPersona, readVoices, writePersona } from "./profile.t
|
|
|
33
33
|
import { STATION_TOOLS, callStationTool, isStationTool, type StationToolOptions } from "./station-mcp.ts";
|
|
34
34
|
import { PUBLIC_TOOLS, callPublicTool, isPublicTool, type PublicToolOptions } from "./directory-mcp.ts";
|
|
35
35
|
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.ts";
|
|
36
|
+
import { SCHOOL_TOOLS, callSchoolTool, isSchoolTool } from "./school-mcp.ts";
|
|
36
37
|
|
|
37
38
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
38
39
|
|
|
@@ -271,6 +272,7 @@ export const TOOLS: ToolDefinition[] = [
|
|
|
271
272
|
...STATION_TOOLS,
|
|
272
273
|
...(PUBLIC_TOOLS as unknown as ToolDefinition[]),
|
|
273
274
|
...LIBRARY_TOOLS,
|
|
275
|
+
...SCHOOL_TOOLS,
|
|
274
276
|
];
|
|
275
277
|
|
|
276
278
|
interface TrollboxLine {
|
|
@@ -303,6 +305,8 @@ export interface McpOptions {
|
|
|
303
305
|
/** How a file is described for its record; the tests hand in a fake. */
|
|
304
306
|
describe?: (path: string) => Promise<Described>;
|
|
305
307
|
say?: (line: string) => void;
|
|
308
|
+
/** Where the class tools go; backtoschool.help (or NIXAMP_SCHOOL_URL) otherwise. */
|
|
309
|
+
school?: string;
|
|
306
310
|
}
|
|
307
311
|
|
|
308
312
|
/** A kept transcript, as a tool answers it. */
|
|
@@ -365,6 +369,14 @@ export async function callTool(name: string, args: Record<string, unknown>, opti
|
|
|
365
369
|
return failed((error as Error).message);
|
|
366
370
|
}
|
|
367
371
|
}
|
|
372
|
+
// backtoschool.help classes: the same token, sent to the school's host.
|
|
373
|
+
if (isSchoolTool(name)) {
|
|
374
|
+
return callSchoolTool(name, args, {
|
|
375
|
+
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
|
376
|
+
...(options.session !== undefined ? { token: options.session?.token ?? null } : {}),
|
|
377
|
+
...(options.school ? { site: options.school } : {}),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
368
380
|
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
369
381
|
if (isStationTool(name) || isLibraryTool(name)) {
|
|
370
382
|
const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
|
|
@@ -653,9 +665,9 @@ export async function handleMessage(message: Request, options: McpOptions = {}):
|
|
|
653
665
|
return reply({
|
|
654
666
|
protocolVersion: PROTOCOL_VERSION,
|
|
655
667
|
capabilities: { tools: { listChanged: false } },
|
|
656
|
-
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
668
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, classes, watch parties, rooms and transcripts", version: "2" },
|
|
657
669
|
instructions:
|
|
658
|
-
"Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
670
|
+
"Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Classes on backtoschool.help: classes_list and class_get for anybody; class_teach puts a station link on the air as a class in one call, classes_check finds live classes whose broadcast stopped playing. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
659
671
|
});
|
|
660
672
|
}
|
|
661
673
|
// Notifications carry no id and are answered with silence, which is what
|