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.
Files changed (49) hide show
  1. package/dist/ai-label.d.ts +15 -5
  2. package/dist/ai-label.js +84 -34
  3. package/dist/main.js +9 -0
  4. package/dist/mcp.d.ts +2 -0
  5. package/dist/mcp.js +12 -2
  6. package/dist/school-cli.d.ts +2 -0
  7. package/dist/school-cli.js +151 -0
  8. package/dist/school-client.d.ts +61 -0
  9. package/dist/school-client.js +148 -0
  10. package/dist/school-discovery.d.ts +32 -0
  11. package/dist/school-discovery.js +204 -0
  12. package/dist/school-mcp.d.ts +180 -0
  13. package/dist/school-mcp.js +143 -0
  14. package/dist/server.js +10 -0
  15. package/dist/station-cli.js +1 -0
  16. package/dist/station-mcp.d.ts +6 -6
  17. package/dist/veo-studio.d.ts +1 -1
  18. package/dist/veo-studio.js +2 -2
  19. package/package.json +1 -1
  20. package/src/ai-label.ts +86 -35
  21. package/src/main.ts +8 -0
  22. package/src/mcp.ts +14 -2
  23. package/src/school-cli.ts +131 -0
  24. package/src/school-client.ts +180 -0
  25. package/src/school-discovery.ts +212 -0
  26. package/src/school-mcp.ts +153 -0
  27. package/src/server.ts +9 -0
  28. package/src/station-cli.ts +1 -0
  29. package/src/veo-studio.ts +2 -2
  30. package/web/dist/assets/{ar-BUn23r3N.js → ar-CoJztWFs.js} +1 -1
  31. package/web/dist/assets/{cs-FwQ-DhBJ.js → cs-1Avz2N93.js} +1 -1
  32. package/web/dist/assets/{da-C8OtQ3BT.js → da-CiNddZp3.js} +1 -1
  33. package/web/dist/assets/{de-CldRtYgJ.js → de-D8Rljw7l.js} +1 -1
  34. package/web/dist/assets/{es-DSPjVfW5.js → es-CIxJsfiv.js} +1 -1
  35. package/web/dist/assets/{fi-XgwcnMwU.js → fi-C5MieJtE.js} +1 -1
  36. package/web/dist/assets/{fr-BH9o9Stx.js → fr-DBMzgQul.js} +1 -1
  37. package/web/dist/assets/{hi-DuMMcoiX.js → hi-CUNsIGCW.js} +1 -1
  38. package/web/dist/assets/{hu-B2Hiutdz.js → hu-CRkS43qM.js} +1 -1
  39. package/web/dist/assets/{id-BNctMtkh.js → id-DtXzZzr5.js} +1 -1
  40. package/web/dist/assets/{it-C_ADjBqX.js → it-CleEG3yJ.js} +1 -1
  41. package/web/dist/assets/{main-BVDC-f0Q.js → main-DZtT4u1U.js} +2 -2
  42. package/web/dist/assets/{nl-BPTAZ6gI.js → nl-HDKYTlnz.js} +1 -1
  43. package/web/dist/assets/{ru-CMSzyodc.js → ru-CX6u3qpl.js} +1 -1
  44. package/web/dist/assets/{sv-BqdBoD8p.js → sv-BRCqB1fE.js} +1 -1
  45. package/web/dist/assets/{uk-C3lM_pHS.js → uk-BMLA9Xl6.js} +1 -1
  46. package/web/dist/assets/{vi-CkVfVKSH.js → vi-DzPvzeIq.js} +1 -1
  47. package/web/dist/assets/{zh-BDiROZo0.js → zh-Cbb7TICl.js} +1 -1
  48. package/web/dist/index.html +1 -1
  49. 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;
@@ -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"),
@@ -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;
@@ -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: {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.68.2",
3
+ "version": "0.70.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",
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 8-second moving shots.",
649
- "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.",
650
- "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.",
651
- "Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
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 single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
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<string[]> {
663
- 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("; ");
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 may appear as characters, described by look: ${cast}` : "",
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: 2500,
674
- timeoutMs: 90_000,
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 prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
678
- if (prompts.length < 1) throw new Error("the model wrote no story");
679
- return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
680
- + " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
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(_seconds: number, singing: boolean, usd = VIDEO_USD): { story: number; band: number; singer: number } {
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
- // Two close-ups when they leave room for at least two shots; one when the budget is tight.
832
- const singer = !singing ? 0 : usd >= 2 * closeUp + 2 * shot ? 2 : 1;
833
- const left = usd - singer * SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND);
834
- const shots = Math.max(2, Math.floor(left / (SHOT_SECONDS * VEO_USD_PER_SECOND) + 1e-9));
835
- const band = Math.max(1, Math.round(shots / 3));
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 = 7;
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: readFileSync(audio), seconds: 8 }); break; } catch (error) {
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
- const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1562
- const small = join(dir, `ref-${member.slug}.jpg`);
1563
- try {
1564
- // 512 px is plenty for a face and keeps the request small.
1565
- 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");
1566
- out.push(readFileSync(small));
1567
- } catch {
1568
- // A member without a portrait is described in words only.
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: string[] = [];
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. Story scenes go without
1588
- // the portraits: they would pull the whole band into every scene.
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: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[], seconds: SHOT_SECONDS })),
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