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
package/dist/ai-label.d.ts
CHANGED
|
@@ -170,6 +170,8 @@ export interface TimedWord {
|
|
|
170
170
|
}
|
|
171
171
|
/** ElevenLabs forced alignment: the song and its words in, each word's time out. */
|
|
172
172
|
export declare function elevenLabsAligner(key: string, fetcher?: typeof fetch): (audio: string, text: string) => Promise<TimedWord[]>;
|
|
173
|
+
/** ElevenLabs voice isolation: a few seconds of the song in, the voice alone out (what a lip sync follows best). */
|
|
174
|
+
export declare function elevenLabsIsolator(key: string, fetcher?: typeof fetch): (audio: Buffer) => Promise<Buffer>;
|
|
173
175
|
/** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
|
|
174
176
|
export declare function sungLines(lyrics: string): string[];
|
|
175
177
|
/**
|
|
@@ -181,8 +183,13 @@ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLin
|
|
|
181
183
|
export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
|
|
182
184
|
cast?: string;
|
|
183
185
|
}, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
|
|
184
|
-
/** A
|
|
185
|
-
export
|
|
186
|
+
/** A story scene for Veo: the prompt and the roles of the band members in it (their portraits go with it). */
|
|
187
|
+
export interface StoryScene {
|
|
188
|
+
prompt: string;
|
|
189
|
+
cast: string[];
|
|
190
|
+
}
|
|
191
|
+
/** A single's story scenes for Veo: a short film from its words starring the band as they look, nobody singing. */
|
|
192
|
+
export declare function writeVeoStory(artist: Pick<LabelArtist, "genre" | "sound">, members: Array<Pick<Member, "role" | "look">>, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<StoryScene[]>;
|
|
186
193
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
187
194
|
export declare class VeoUnavailable extends Error {
|
|
188
195
|
}
|
|
@@ -225,7 +232,7 @@ export declare const VIDEO_USD = 8;
|
|
|
225
232
|
* story. Footage shorter than the song is cut in turn and shown again.
|
|
226
233
|
* $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
|
|
227
234
|
*/
|
|
228
|
-
export declare function shotPlan(
|
|
235
|
+
export declare function shotPlan(seconds: number, singing: boolean, usd?: number): {
|
|
229
236
|
story: number;
|
|
230
237
|
band: number;
|
|
231
238
|
singer: number;
|
|
@@ -249,9 +256,10 @@ export declare function closeUpTimes(seconds: number, lines: Array<{
|
|
|
249
256
|
* The version of a video cut from Veo footage: story scenes and the band playing with
|
|
250
257
|
* nobody singing, the singer only in close-ups lip-synced to the song. Anything older
|
|
251
258
|
* is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
|
|
252
|
-
* the mic in the wide shots).
|
|
259
|
+
* the mic in the wide shots). 6 and 7 (strangers in the story scenes, footage looped)
|
|
260
|
+
* stay up until filmed again.
|
|
253
261
|
*/
|
|
254
|
-
export declare const FILMED =
|
|
262
|
+
export declare const FILMED = 8;
|
|
255
263
|
/** 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. */
|
|
256
264
|
export declare const SHOWN = 6;
|
|
257
265
|
/** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
|
|
@@ -341,6 +349,8 @@ export interface AiLabelDeps {
|
|
|
341
349
|
seconds: number;
|
|
342
350
|
}>;
|
|
343
351
|
}) => Promise<void>;
|
|
352
|
+
/** The voice alone from a few seconds of a song (ElevenLabs by default when its key is set); null for none. */
|
|
353
|
+
isolate?: ((audio: Buffer) => Promise<Buffer>) | null;
|
|
344
354
|
/** 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. */
|
|
345
355
|
lipsync?: ((input: {
|
|
346
356
|
video: Buffer;
|
package/dist/ai-label.js
CHANGED
|
@@ -441,6 +441,20 @@ export function elevenLabsAligner(key, fetcher = fetch) {
|
|
|
441
441
|
.filter((one) => one.text && Number.isFinite(one.start) && Number.isFinite(one.end));
|
|
442
442
|
};
|
|
443
443
|
}
|
|
444
|
+
/** ElevenLabs voice isolation: a few seconds of the song in, the voice alone out (what a lip sync follows best). */
|
|
445
|
+
export function elevenLabsIsolator(key, fetcher = fetch) {
|
|
446
|
+
return async (audio) => {
|
|
447
|
+
const form = new FormData();
|
|
448
|
+
form.append("audio", new Blob([new Uint8Array(audio)], { type: "audio/mpeg" }), "lines.mp3");
|
|
449
|
+
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) });
|
|
450
|
+
if (!response.ok)
|
|
451
|
+
throw new Error(`the voice isolation answered HTTP ${response.status}`);
|
|
452
|
+
const voice = Buffer.from(await response.arrayBuffer());
|
|
453
|
+
if (voice.length < 1000)
|
|
454
|
+
throw new Error("the voice isolation answered with no audio");
|
|
455
|
+
return voice;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
444
458
|
/** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
|
|
445
459
|
export function sungLines(lyrics) {
|
|
446
460
|
return lyrics.split("\n").map((line) => line.trim()).filter((line) => line && !/^\[[^\]]*\]$/.test(line));
|
|
@@ -493,32 +507,41 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
|
|
|
493
507
|
return prompts;
|
|
494
508
|
}
|
|
495
509
|
const VEO_STORY_SYSTEM = [
|
|
496
|
-
"You write the story scenes of a music video for an original song, as prompts for a video model that films
|
|
497
|
-
"Tell the song's story in cinematic scenes away from any stage
|
|
498
|
-
"
|
|
499
|
-
"
|
|
510
|
+
"You write the story scenes of a music video for an original song, as prompts for a video model that films short moving shots.",
|
|
511
|
+
"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.",
|
|
512
|
+
"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.",
|
|
513
|
+
"Describe people by role and look, never by name. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
|
|
514
|
+
"Answer as JSON: {\"scenes\": [{\"prompt\": \"...\", \"cast\": [\"lead vocals\", ...]}, ...]} where cast lists the roles (exactly as given) of the members in that scene, [] for none.",
|
|
500
515
|
].join(" ");
|
|
501
|
-
/** A single's story scenes for Veo: a short film from its words
|
|
516
|
+
/** A single's story scenes for Veo: a short film from its words starring the band as they look, nobody singing. */
|
|
502
517
|
export async function writeVeoStory(artist, members, album, song, scenes, ask = askJson) {
|
|
503
|
-
const cast = members.slice(0,
|
|
518
|
+
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("; ");
|
|
504
519
|
const answer = await ask({
|
|
505
520
|
system: VEO_STORY_SYSTEM,
|
|
506
521
|
prompt: [
|
|
507
522
|
`Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
508
|
-
cast ? `The band
|
|
523
|
+
cast ? `The band (the only main characters), by role and look: ${cast}` : "",
|
|
509
524
|
`Mood and arrangement: ${song.style}`,
|
|
510
525
|
song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
|
|
511
526
|
`Exactly ${scenes} scenes.`,
|
|
512
527
|
].filter(Boolean).join("\n"),
|
|
513
|
-
maxTokens:
|
|
514
|
-
timeoutMs:
|
|
528
|
+
maxTokens: 4000,
|
|
529
|
+
timeoutMs: 120_000,
|
|
515
530
|
});
|
|
531
|
+
const roles = new Set(members.map((one) => one.role.toLowerCase()));
|
|
516
532
|
const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
|
|
517
|
-
const
|
|
518
|
-
|
|
533
|
+
const out = list.map((one) => {
|
|
534
|
+
const item = (typeof one === "object" && one ? one : { prompt: one });
|
|
535
|
+
const who = Array.isArray(item.cast) ? item.cast.map((role) => String(role).toLowerCase().trim()).filter((role) => roles.has(role)) : [];
|
|
536
|
+
return { prompt: clean(item.prompt, 400), cast: [...new Set(who)].slice(0, 3) };
|
|
537
|
+
}).filter((one) => one.prompt.length > 10).slice(0, scenes);
|
|
538
|
+
if (out.length < 1)
|
|
519
539
|
throw new Error("the model wrote no story");
|
|
520
|
-
return
|
|
521
|
-
|
|
540
|
+
return out.map((scene) => ({
|
|
541
|
+
cast: scene.cast,
|
|
542
|
+
prompt: `Music video for a fictional ${artist.genre} song. ${scene.prompt} Mood: ${song.style}`.slice(0, 900)
|
|
543
|
+
+ " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, high detail. No text, no logos.",
|
|
544
|
+
}));
|
|
522
545
|
}
|
|
523
546
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
524
547
|
export class VeoUnavailable extends Error {
|
|
@@ -671,13 +694,17 @@ export const VIDEO_USD = 8;
|
|
|
671
694
|
* story. Footage shorter than the song is cut in turn and shown again.
|
|
672
695
|
* $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
|
|
673
696
|
*/
|
|
674
|
-
export function shotPlan(
|
|
697
|
+
export function shotPlan(seconds, singing, usd = VIDEO_USD) {
|
|
675
698
|
const closeUp = SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND), shot = SHOT_SECONDS * VEO_USD_PER_SECOND;
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
699
|
+
const length = Math.max(30, seconds);
|
|
700
|
+
// About one lip-synced close-up every 30 s of song, as many as leave room for at least two shots.
|
|
701
|
+
let singer = singing ? Math.max(1, Math.min(6, Math.round(length / 30))) : 0;
|
|
702
|
+
while (singer > 1 && usd < singer * closeUp + 2 * shot)
|
|
703
|
+
singer -= 1;
|
|
704
|
+
// Then shots enough to cover the rest of the song once (nothing shown twice), as far as the money goes.
|
|
705
|
+
const cover = Math.ceil(Math.max(0, length - singer * SINGER_SECONDS) / SHOT_SECONDS);
|
|
706
|
+
const shots = Math.max(2, Math.min(cover, Math.floor((usd - singer * closeUp) / shot + 1e-9)));
|
|
707
|
+
const band = Math.max(1, Math.round(shots / 4));
|
|
681
708
|
return { story: Math.max(1, shots - band), band, singer };
|
|
682
709
|
}
|
|
683
710
|
/** What a plan costs on nixamp.com, in dollars. */
|
|
@@ -714,9 +741,10 @@ export function closeUpTimes(seconds, lines, count, length = 8) {
|
|
|
714
741
|
* The version of a video cut from Veo footage: story scenes and the band playing with
|
|
715
742
|
* nobody singing, the singer only in close-ups lip-synced to the song. Anything older
|
|
716
743
|
* is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
|
|
717
|
-
* the mic in the wide shots).
|
|
744
|
+
* the mic in the wide shots). 6 and 7 (strangers in the story scenes, footage looped)
|
|
745
|
+
* stay up until filmed again.
|
|
718
746
|
*/
|
|
719
|
-
export const FILMED =
|
|
747
|
+
export const FILMED = 8;
|
|
720
748
|
/** 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. */
|
|
721
749
|
export const SHOWN = 6;
|
|
722
750
|
/** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
|
|
@@ -1357,10 +1385,15 @@ export class AiLabel {
|
|
|
1357
1385
|
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1358
1386
|
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");
|
|
1359
1387
|
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");
|
|
1388
|
+
// The mouth follows the voice alone better than the whole mix; the video keeps the whole mix.
|
|
1389
|
+
let voice = readFileSync(audio);
|
|
1390
|
+
const isolate = this.deps.isolate !== undefined ? this.deps.isolate : (aiKey("ELEVENLABS_API_KEY") ? elevenLabsIsolator(aiKey("ELEVENLABS_API_KEY")) : null);
|
|
1391
|
+
if (isolate)
|
|
1392
|
+
voice = await isolate(voice).catch((error) => { this.log(`voice isolation for "${song.title}": ${error.message}; the whole mix instead`); return voice; });
|
|
1360
1393
|
let bytes = null;
|
|
1361
1394
|
for (let attempt = 0;; attempt++) {
|
|
1362
1395
|
try {
|
|
1363
|
-
bytes = await lipsync({ video: readFileSync(shot), audio:
|
|
1396
|
+
bytes = await lipsync({ video: readFileSync(shot), audio: voice, seconds: 8 });
|
|
1364
1397
|
break;
|
|
1365
1398
|
}
|
|
1366
1399
|
catch (error) {
|
|
@@ -1410,19 +1443,26 @@ export class AiLabel {
|
|
|
1410
1443
|
const voice = (member) => /vocal|sing/i.test(member.role);
|
|
1411
1444
|
const players = members.filter((member) => !voice(member));
|
|
1412
1445
|
const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
|
|
1446
|
+
const shrunk = new Map();
|
|
1413
1447
|
const portraits = async (list) => {
|
|
1414
1448
|
const out = [];
|
|
1415
1449
|
for (const member of list.slice(0, 3)) {
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1450
|
+
if (!shrunk.has(member.slug)) {
|
|
1451
|
+
const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
|
|
1452
|
+
const small = join(dir, `ref-${member.slug}.jpg`);
|
|
1453
|
+
try {
|
|
1454
|
+
// 768 px keeps a face's detail and the request small.
|
|
1455
|
+
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");
|
|
1456
|
+
shrunk.set(member.slug, readFileSync(small));
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
// A member without a portrait is described in words only.
|
|
1460
|
+
shrunk.set(member.slug, null);
|
|
1461
|
+
}
|
|
1425
1462
|
}
|
|
1463
|
+
const bytes = shrunk.get(member.slug);
|
|
1464
|
+
if (bytes)
|
|
1465
|
+
out.push(bytes);
|
|
1426
1466
|
}
|
|
1427
1467
|
return out;
|
|
1428
1468
|
};
|
|
@@ -1441,12 +1481,22 @@ export class AiLabel {
|
|
|
1441
1481
|
this.log(`no story for "${song.title}" yet: ${error.message}`);
|
|
1442
1482
|
}
|
|
1443
1483
|
}
|
|
1444
|
-
// The singer's close-ups only where they can be lip-synced afterwards.
|
|
1445
|
-
// the portraits
|
|
1484
|
+
// The singer's close-ups only where they can be lip-synced afterwards. Each story scene is filmed
|
|
1485
|
+
// with the portraits of the members in it, and their looks in words, so they are the same people
|
|
1486
|
+
// in every shot; a scene with nobody in it goes without.
|
|
1487
|
+
const byRole = new Map(members.map((member) => [member.role.toLowerCase(), member]));
|
|
1488
|
+
const storyCast = await Promise.all(storyPrompts.map(async (scene) => {
|
|
1489
|
+
const who = scene.cast.map((role) => byRole.get(role)).filter((member) => Boolean(member));
|
|
1490
|
+
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("; ");
|
|
1491
|
+
return {
|
|
1492
|
+
prompt: who.length ? `${scene.prompt} The people are exactly the ones in the reference images, same faces, hair and clothes: ${named}.` : scene.prompt,
|
|
1493
|
+
refs: await portraits(who),
|
|
1494
|
+
};
|
|
1495
|
+
}));
|
|
1446
1496
|
// The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
|
|
1447
1497
|
const plan = [
|
|
1448
1498
|
...(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 })) : []),
|
|
1449
|
-
...storyFiles.map((file, i) => ({ prompt:
|
|
1499
|
+
...storyFiles.map((file, i) => ({ prompt: storyCast[i]?.prompt ?? "", file, into: story, refs: storyCast[i]?.refs ?? [], seconds: SHOT_SECONDS })),
|
|
1450
1500
|
...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs, seconds: SHOT_SECONDS })),
|
|
1451
1501
|
];
|
|
1452
1502
|
for (const [i, { prompt, file, into, refs, seconds }] of plan.entries()) {
|
package/dist/main.js
CHANGED
|
@@ -10,6 +10,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
|
|
|
10
10
|
import { USAGE as STATION_USAGE } from "./station-usage.js";
|
|
11
11
|
import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.js";
|
|
12
12
|
import { MUSIC_USAGE } from "./music-cli.js";
|
|
13
|
+
import { SCHOOL_USAGE } from "./school-cli.js";
|
|
13
14
|
import { createApp, themes } from "@profullstack/hqtui";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { resolve } from "node:path";
|
|
@@ -211,6 +212,7 @@ keeps serving its browser remote. One per user.
|
|
|
211
212
|
nixamp admin who is connected, and re-stream to them
|
|
212
213
|
nixamp station make a folder into a station others can join (see: nixamp station help)
|
|
213
214
|
nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
|
|
215
|
+
nixamp school classes on backtoschool.help: teach a station live, check, prune (nixamp school help)
|
|
214
216
|
|
|
215
217
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
216
218
|
`,
|
|
@@ -378,6 +380,8 @@ export function helpFor(topic) {
|
|
|
378
380
|
return LIBRARY_USAGE;
|
|
379
381
|
if (topic === "music")
|
|
380
382
|
return MUSIC_USAGE;
|
|
383
|
+
if (topic === "school")
|
|
384
|
+
return SCHOOL_USAGE;
|
|
381
385
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
382
386
|
}
|
|
383
387
|
/**
|
|
@@ -540,6 +544,11 @@ export async function main() {
|
|
|
540
544
|
process.exitCode = await music(rest);
|
|
541
545
|
return;
|
|
542
546
|
}
|
|
547
|
+
if (first === "school" || first === "class" || first === "classes") {
|
|
548
|
+
const { school } = await import("./school-cli.js");
|
|
549
|
+
process.exitCode = await school(rest);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
543
552
|
if (first === "station" || first === "stations") {
|
|
544
553
|
const { station } = await import("./station-cli.js");
|
|
545
554
|
process.exitCode = await station(rest);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface McpOptions {
|
|
|
37
37
|
/** How a file is described for its record; the tests hand in a fake. */
|
|
38
38
|
describe?: (path: string) => Promise<Described>;
|
|
39
39
|
say?: (line: string) => void;
|
|
40
|
+
/** Where the class tools go; backtoschool.help (or NIXAMP_SCHOOL_URL) otherwise. */
|
|
41
|
+
school?: string;
|
|
40
42
|
}
|
|
41
43
|
/** A tool answer, in the shape MCP wants: content blocks, and a flag for failure. */
|
|
42
44
|
export interface ToolResult {
|
package/dist/mcp.js
CHANGED
|
@@ -33,6 +33,7 @@ import { personaLines, readPersona, readVoices, writePersona } from "./profile.j
|
|
|
33
33
|
import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
|
|
34
34
|
import { PUBLIC_TOOLS, callPublicTool, isPublicTool } from "./directory-mcp.js";
|
|
35
35
|
import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.js";
|
|
36
|
+
import { SCHOOL_TOOLS, callSchoolTool, isSchoolTool } from "./school-mcp.js";
|
|
36
37
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
37
38
|
const STRING = { type: "string" };
|
|
38
39
|
export const TOOLS = [
|
|
@@ -244,6 +245,7 @@ export const TOOLS = [
|
|
|
244
245
|
...STATION_TOOLS,
|
|
245
246
|
...PUBLIC_TOOLS,
|
|
246
247
|
...LIBRARY_TOOLS,
|
|
248
|
+
...SCHOOL_TOOLS,
|
|
247
249
|
];
|
|
248
250
|
function said(line) {
|
|
249
251
|
return `${line.createdAt} ${line.handle}: ${line.body}`;
|
|
@@ -302,6 +304,14 @@ export async function callTool(name, args, options = {}) {
|
|
|
302
304
|
return failed(error.message);
|
|
303
305
|
}
|
|
304
306
|
}
|
|
307
|
+
// backtoschool.help classes: the same token, sent to the school's host.
|
|
308
|
+
if (isSchoolTool(name)) {
|
|
309
|
+
return callSchoolTool(name, args, {
|
|
310
|
+
...(options.fetcher ? { fetcher: options.fetcher } : {}),
|
|
311
|
+
...(options.session !== undefined ? { token: options.session?.token ?? null } : {}),
|
|
312
|
+
...(options.school ? { site: options.school } : {}),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
305
315
|
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
306
316
|
if (isStationTool(name) || isLibraryTool(name)) {
|
|
307
317
|
const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
|
|
@@ -612,8 +622,8 @@ export async function handleMessage(message, options = {}) {
|
|
|
612
622
|
return reply({
|
|
613
623
|
protocolVersion: PROTOCOL_VERSION,
|
|
614
624
|
capabilities: { tools: { listChanged: false } },
|
|
615
|
-
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
616
|
-
instructions: "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.",
|
|
625
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, classes, watch parties, rooms and transcripts", version: "2" },
|
|
626
|
+
instructions: "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.",
|
|
617
627
|
});
|
|
618
628
|
}
|
|
619
629
|
// Notifications carry no id and are answered with silence, which is what
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const SCHOOL_USAGE = "nixamp school: classes on backtoschool.help (sign in with `nixamp login`)\n\n nixamp school [list] [--status live|scheduled|draft|ended] [--mine]\n nixamp school show SLUG one class, and whether its broadcast plays\n nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]\n make a class of a broadcast and start it now\n nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]\n nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]\n nixamp school start SLUG\n nixamp school end SLUG\n nixamp school delete SLUG\n nixamp school check your live classes, and which ones no longer play\n nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)\n\nLINK is what plays: a station link (`nixamp station show ID`), a nixamp.com share link, a\nPairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.\n --json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)\n";
|
|
2
|
+
export declare function school(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp school`: classes on backtoschool.help from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* A class is a live event on nixamp's accounts, so `nixamp login` is the
|
|
5
|
+
* sign-in here too. `teach` is the short path: a station link in, a class on
|
|
6
|
+
* the air out. `check` and `prune` exist because a class stays live after
|
|
7
|
+
* its broadcast is gone, and the page then shows a player that never plays.
|
|
8
|
+
*/
|
|
9
|
+
import { broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass, } from "./school-client.js";
|
|
10
|
+
export const SCHOOL_USAGE = `nixamp school: classes on backtoschool.help (sign in with \`nixamp login\`)
|
|
11
|
+
|
|
12
|
+
nixamp school [list] [--status live|scheduled|draft|ended] [--mine]
|
|
13
|
+
nixamp school show SLUG one class, and whether its broadcast plays
|
|
14
|
+
nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]
|
|
15
|
+
make a class of a broadcast and start it now
|
|
16
|
+
nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]
|
|
17
|
+
nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]
|
|
18
|
+
nixamp school start SLUG
|
|
19
|
+
nixamp school end SLUG
|
|
20
|
+
nixamp school delete SLUG
|
|
21
|
+
nixamp school check your live classes, and which ones no longer play
|
|
22
|
+
nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)
|
|
23
|
+
|
|
24
|
+
LINK is what plays: a station link (\`nixamp station show ID\`), a nixamp.com share link, a
|
|
25
|
+
Pairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.
|
|
26
|
+
--json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)
|
|
27
|
+
`;
|
|
28
|
+
function flag(argv, name) {
|
|
29
|
+
const at = argv.indexOf(name);
|
|
30
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
31
|
+
}
|
|
32
|
+
/** The first argument after the command that is not a flag or a flag's value. */
|
|
33
|
+
function positional(argv) {
|
|
34
|
+
const valued = new Set(["--title", "--description", "--topic", "--starts", "--link", "--status"]);
|
|
35
|
+
for (let at = 1; at < argv.length; at += 1) {
|
|
36
|
+
const one = argv[at];
|
|
37
|
+
if (valued.has(one)) {
|
|
38
|
+
at += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!one.startsWith("--"))
|
|
42
|
+
return one;
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
function fields(argv) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [name, key] of [["--title", "title"], ["--description", "description"], ["--topic", "topic"], ["--starts", "startsAt"], ["--link", "broadcastUrl"]]) {
|
|
49
|
+
const value = flag(argv, name);
|
|
50
|
+
if (value !== undefined)
|
|
51
|
+
out[key] = value;
|
|
52
|
+
}
|
|
53
|
+
for (const visibility of ["public", "unlisted", "private"])
|
|
54
|
+
if (argv.includes(`--${visibility}`))
|
|
55
|
+
out["visibility"] = visibility;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function print(item) {
|
|
59
|
+
console.log(`${item.status.padEnd(9)} ${item.title}\n ${classUrl(item)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`);
|
|
60
|
+
}
|
|
61
|
+
export async function school(argv) {
|
|
62
|
+
const [sub = "list"] = argv;
|
|
63
|
+
const json = argv.includes("--json");
|
|
64
|
+
const slug = positional(argv);
|
|
65
|
+
const out = (value, human) => { if (json)
|
|
66
|
+
console.log(JSON.stringify(value, null, 2));
|
|
67
|
+
else
|
|
68
|
+
human(); };
|
|
69
|
+
try {
|
|
70
|
+
if (sub === "help" || argv.includes("--help")) {
|
|
71
|
+
console.log(SCHOOL_USAGE);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
if (sub === "list") {
|
|
75
|
+
const status = flag(argv, "--status");
|
|
76
|
+
const items = await listClasses({ ...(status ? { status } : {}), mine: argv.includes("--mine") });
|
|
77
|
+
out(items, () => { if (items.length === 0)
|
|
78
|
+
console.log("No classes match."); for (const one of items)
|
|
79
|
+
print(one); });
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if (sub === "check" || sub === "prune") {
|
|
83
|
+
const checked = await checkClasses();
|
|
84
|
+
const broken = checked.filter((one) => one.health.ok === false);
|
|
85
|
+
const act = sub === "prune" ? (argv.includes("--delete") ? "delete" : argv.includes("--end") ? "end" : "") : "";
|
|
86
|
+
for (const { item } of act ? broken : []) {
|
|
87
|
+
if (act === "delete")
|
|
88
|
+
await deleteClass(item.id);
|
|
89
|
+
else
|
|
90
|
+
await moveClass(item.id, "end");
|
|
91
|
+
}
|
|
92
|
+
out(checked.map(({ item, health }) => ({ slug: item.slug, ok: health.ok, reason: health.reason, ...(act && health.ok === false ? { done: act } : {}) })), () => {
|
|
93
|
+
if (checked.length === 0)
|
|
94
|
+
console.log("You have no live classes.");
|
|
95
|
+
for (const { item, health } of checked)
|
|
96
|
+
console.log(`${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : " ? "} ${item.slug} (${health.reason})`);
|
|
97
|
+
if (sub === "prune" && broken.length > 0)
|
|
98
|
+
console.log(act ? `\n${act === "delete" ? "Deleted" : "Ended"} ${broken.length}.` : `\n${broken.length} no longer play. --end or --delete them.`);
|
|
99
|
+
});
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
if (sub === "teach" || sub === "create") {
|
|
103
|
+
const input = fields(argv);
|
|
104
|
+
if (slug)
|
|
105
|
+
input["broadcastUrl"] = slug;
|
|
106
|
+
if (!input["title"]) {
|
|
107
|
+
console.error(`nixamp school ${sub} needs --title`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
if (sub === "teach" && !input["broadcastUrl"]) {
|
|
111
|
+
console.error("nixamp school teach needs the LINK that plays");
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const item = sub === "teach" ? await teach(input) : await createClass(input);
|
|
115
|
+
out(item, () => { print(item); if (sub === "create")
|
|
116
|
+
console.log(`\nnixamp school start ${item.slug} puts it on the air.`); });
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (!slug) {
|
|
120
|
+
console.error(`nixamp school ${sub} needs a class (its slug or /live/ link)\n\n${SCHOOL_USAGE}`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
if (sub === "show") {
|
|
124
|
+
const item = await getClass(slug);
|
|
125
|
+
const health = await broadcastHealth(item.broadcastUrl);
|
|
126
|
+
out({ ...item, broadcast: health }, () => { print(item); console.log(` broadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`); });
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (sub === "update") {
|
|
130
|
+
const item = await updateClass(slug, fields(argv));
|
|
131
|
+
out(item, () => print(item));
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
if (sub === "start" || sub === "end") {
|
|
135
|
+
const item = await moveClass(slug, sub);
|
|
136
|
+
out(item, () => print(item));
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (sub === "delete" || sub === "rm") {
|
|
140
|
+
await deleteClass(slug);
|
|
141
|
+
out({ ok: true, slug }, () => console.log(`Deleted ${slug}.`));
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
console.error(`nixamp school: no command "${sub}".\n\n${SCHOOL_USAGE}`);
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.error(`nixamp school: ${error.message}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export declare const SCHOOL_SITE = "https://backtoschool.help";
|
|
2
|
+
type Fetcher = typeof fetch;
|
|
3
|
+
export interface SchoolOptions {
|
|
4
|
+
/** backtoschool.help unless NIXAMP_SCHOOL_URL says otherwise; the tests hand in a local server. */
|
|
5
|
+
site?: string;
|
|
6
|
+
/** The session on disk (or NIXAMP_TOKEN) unless given; null means signed out. */
|
|
7
|
+
token?: string | null;
|
|
8
|
+
fetcher?: Fetcher;
|
|
9
|
+
}
|
|
10
|
+
export interface SchoolClass {
|
|
11
|
+
id: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
title: string;
|
|
14
|
+
status: string;
|
|
15
|
+
visibility: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
version: number;
|
|
18
|
+
ownerId: string;
|
|
19
|
+
broadcastUrl?: string | null;
|
|
20
|
+
description?: string;
|
|
21
|
+
topic?: string;
|
|
22
|
+
hostName?: string;
|
|
23
|
+
startsAt?: string | null;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare class SchoolError extends Error {
|
|
27
|
+
readonly status: number;
|
|
28
|
+
constructor(message: string, status?: number);
|
|
29
|
+
}
|
|
30
|
+
export declare function schoolSite(options?: SchoolOptions): string;
|
|
31
|
+
export declare function classUrl(item: Pick<SchoolClass, "slug">, options?: SchoolOptions): string;
|
|
32
|
+
export declare function listClasses(query?: {
|
|
33
|
+
status?: string;
|
|
34
|
+
kind?: string;
|
|
35
|
+
mine?: boolean;
|
|
36
|
+
limit?: number;
|
|
37
|
+
}, options?: SchoolOptions): Promise<SchoolClass[]>;
|
|
38
|
+
/** A class by its slug, id, or the /live/<slug> link. */
|
|
39
|
+
export declare function getClass(slug: string, options?: SchoolOptions): Promise<SchoolClass>;
|
|
40
|
+
export declare function createClass(input: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
|
|
41
|
+
export declare function updateClass(slug: string, patch: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
|
|
42
|
+
/** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
|
|
43
|
+
export declare function moveClass(slug: string, action: "start" | "end" | "doors" | "encore" | "cancel" | "archive", options?: SchoolOptions): Promise<SchoolClass>;
|
|
44
|
+
export declare function deleteClass(slug: string, options?: SchoolOptions): Promise<void>;
|
|
45
|
+
/** Make a class of a broadcast and put it on the air in one go. */
|
|
46
|
+
export declare function teach(input: Record<string, unknown>, options?: SchoolOptions): Promise<SchoolClass>;
|
|
47
|
+
export interface Health {
|
|
48
|
+
ok: boolean | null;
|
|
49
|
+
reason: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Does this broadcast play? true and false are answers; null is "cannot tell
|
|
53
|
+
* from here" (a Pairux room), which a prune must leave alone.
|
|
54
|
+
*/
|
|
55
|
+
export declare function broadcastHealth(link: string | null | undefined, fetcher?: Fetcher, timeoutMs?: number): Promise<Health>;
|
|
56
|
+
/** Your live classes, each with whether its broadcast plays. */
|
|
57
|
+
export declare function checkClasses(options?: SchoolOptions, status?: string): Promise<Array<{
|
|
58
|
+
item: SchoolClass;
|
|
59
|
+
health: Health;
|
|
60
|
+
}>>;
|
|
61
|
+
export {};
|