nixamp 0.63.3 → 0.65.1
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 +38 -4
- package/dist/ai-label.js +132 -20
- package/dist/discovery.d.ts +48 -0
- package/dist/discovery.js +181 -0
- package/dist/server.js +64 -11
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +15 -0
- package/dist/station-mcp.js +3 -2
- package/dist/veo-studio.d.ts +16 -0
- package/dist/veo-studio.js +59 -0
- package/package.json +1 -1
- package/src/ai-label.ts +124 -23
- package/src/discovery.ts +210 -0
- package/src/server.ts +56 -11
- package/src/station-cli.ts +1 -0
- package/src/station-mcp.ts +3 -2
- package/src/veo-studio.ts +57 -0
- package/web/dist/assets/{ar-BdJErAsT.js → ar-DsLCtn6q.js} +1 -1
- package/web/dist/assets/{cs-BCAcbLw4.js → cs-CDebRTvk.js} +1 -1
- package/web/dist/assets/{da-B8ZLImmi.js → da-CtIZY0F_.js} +1 -1
- package/web/dist/assets/{de-BEfJLxqy.js → de-Ce4XnPH4.js} +1 -1
- package/web/dist/assets/{es-CvwtfSaX.js → es-DuTsv2ty.js} +1 -1
- package/web/dist/assets/{fi-D8zNiMjO.js → fi-BxzgeUzQ.js} +1 -1
- package/web/dist/assets/{fr-C2YZ2L4l.js → fr-1e-Cafh4.js} +1 -1
- package/web/dist/assets/{hi-K_VSycz2.js → hi-yKzlszV2.js} +1 -1
- package/web/dist/assets/{hu-Dl_GeLxY.js → hu-DEEC9-i5.js} +1 -1
- package/web/dist/assets/{id-Ba1NrTEb.js → id-Da47jrbA.js} +1 -1
- package/web/dist/assets/{it-ClsnZdJN.js → it-DyEoFcC_.js} +1 -1
- package/web/dist/assets/{main-DD5A9Exa.js → main-C_3KEwHZ.js} +2 -2
- package/web/dist/assets/{music-LviR4dJP.js → music-Bby75I2_.js} +1 -1
- package/web/dist/assets/{nl-BLjok-1D.js → nl-uTGCd9L3.js} +1 -1
- package/web/dist/assets/{ru-BvhOKYAB.js → ru-BJ7gUalz.js} +1 -1
- package/web/dist/assets/{sv-BItd8vZI.js → sv-C9ZiAm2Q.js} +1 -1
- package/web/dist/assets/{uk-BcUnWToF.js → uk-DIcSDuMA.js} +1 -1
- package/web/dist/assets/{vi-LFZ3qSPF.js → vi-4MI2s3Z9.js} +1 -1
- package/web/dist/assets/{zh-DfCRV3mt.js → zh-DI1PPrRY.js} +1 -1
- package/web/dist/icons/music/apple-touch-icon.png +0 -0
- package/web/dist/icons/music/icon-192.png +0 -0
- package/web/dist/icons/music/icon-384.png +0 -0
- package/web/dist/icons/music/icon-512.png +0 -0
- package/web/dist/index.html +1 -1
- package/web/dist/music.html +3 -1
- package/web/dist/music.webmanifest +57 -0
- package/web/dist/sw.js +34 -24
package/dist/ai-label.d.ts
CHANGED
|
@@ -90,8 +90,10 @@ export interface LabelSettings {
|
|
|
90
90
|
videos: boolean;
|
|
91
91
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
92
92
|
veo: boolean;
|
|
93
|
-
/** How many 8 s Veo shots a single is cut from. */
|
|
93
|
+
/** How many 8 s Veo shots of the band a single is cut from. */
|
|
94
94
|
veoShots: number;
|
|
95
|
+
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
96
|
+
lipsyncShots: number;
|
|
95
97
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
96
98
|
publish: boolean;
|
|
97
99
|
/** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
|
|
@@ -172,7 +174,7 @@ export declare class VeoUnavailable extends Error {
|
|
|
172
174
|
* images, described by role and look only: Veo refuses names with faces
|
|
173
175
|
* ("we can't create videos with real people's names or likenesses").
|
|
174
176
|
*/
|
|
175
|
-
export declare function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[];
|
|
177
|
+
export declare function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number, kind?: "band" | "singer"): string[];
|
|
176
178
|
/**
|
|
177
179
|
* A song's music video from its storyboard: each scene's picture moves (a
|
|
178
180
|
* slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
|
|
@@ -192,8 +194,18 @@ export declare function renderStoryVideo(ffmpeg: string[], input: {
|
|
|
192
194
|
}>;
|
|
193
195
|
}): Promise<void>;
|
|
194
196
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
+
* Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
|
|
198
|
+
* nearest a quarter and three quarters in (one: a third in), each `length`
|
|
199
|
+
* seconds, after the title card, never overlapping. Without timed words, those
|
|
200
|
+
* points themselves.
|
|
201
|
+
*/
|
|
202
|
+
export declare function closeUpTimes(seconds: number, lines: Array<{
|
|
203
|
+
start: number;
|
|
204
|
+
}>, count: number, length?: number): number[];
|
|
205
|
+
/**
|
|
206
|
+
* The version of a video cut from Veo footage: the band with nobody singing, and the
|
|
207
|
+
* singer only in close-ups lip-synced to the song. Anything older (a slideshow, or 4:
|
|
208
|
+
* footage whose singers were out of sync) is not shown.
|
|
197
209
|
*/
|
|
198
210
|
export declare const FILMED = 5;
|
|
199
211
|
/**
|
|
@@ -212,6 +224,11 @@ export declare function renderFilmedVideo(ffmpeg: string[], input: {
|
|
|
212
224
|
artist: string;
|
|
213
225
|
title: string;
|
|
214
226
|
out: string;
|
|
227
|
+
pinned?: Array<{
|
|
228
|
+
path: string;
|
|
229
|
+
at: number;
|
|
230
|
+
seconds: number;
|
|
231
|
+
}>;
|
|
215
232
|
}): Promise<void>;
|
|
216
233
|
/**
|
|
217
234
|
* A song's music video without a storyboard: its album cover over a blurred
|
|
@@ -268,7 +285,18 @@ export interface AiLabelDeps {
|
|
|
268
285
|
artist: string;
|
|
269
286
|
title: string;
|
|
270
287
|
out: string;
|
|
288
|
+
pinned?: Array<{
|
|
289
|
+
path: string;
|
|
290
|
+
at: number;
|
|
291
|
+
seconds: number;
|
|
292
|
+
}>;
|
|
271
293
|
}) => Promise<void>;
|
|
294
|
+
/** 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. */
|
|
295
|
+
lipsync?: ((input: {
|
|
296
|
+
video: Buffer;
|
|
297
|
+
audio: Buffer;
|
|
298
|
+
seconds: number;
|
|
299
|
+
}) => Promise<Buffer>) | null;
|
|
272
300
|
/** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
|
|
273
301
|
storyVideo?: (input: {
|
|
274
302
|
audio: string;
|
|
@@ -376,6 +404,12 @@ export declare class AiLabel {
|
|
|
376
404
|
* video at all: footage stretched that thin would be a loop, not a video.
|
|
377
405
|
*/
|
|
378
406
|
private filmSong;
|
|
407
|
+
/**
|
|
408
|
+
* The singer's close-ups, each placed on a sung line and its mouth redrawn
|
|
409
|
+
* to those seconds of the song. A close-up that cannot be synced is left
|
|
410
|
+
* out (the band fills its place): an unsynced singer is never shown.
|
|
411
|
+
*/
|
|
412
|
+
private syncCloseUps;
|
|
379
413
|
/** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
|
|
380
414
|
private wantsVeo;
|
|
381
415
|
/**
|
package/dist/ai-label.js
CHANGED
|
@@ -33,7 +33,7 @@ import { dirname, join, resolve, sep } from "node:path";
|
|
|
33
33
|
import { aiKey } from "./ai-keys.js";
|
|
34
34
|
import { composerFromEnv, OutOfCredit } from "./ai-radio.js";
|
|
35
35
|
import { askJson, hasModel } from "./ai-text.js";
|
|
36
|
-
import { formatLrc } from "./lyrics.js";
|
|
36
|
+
import { formatLrc, parseLrc } from "./lyrics.js";
|
|
37
37
|
import { bandMarkdown, castLine, inventMembers, readMembers, writeMembers } from "./label-profiles.js";
|
|
38
38
|
import { StationError } from "./stations.js";
|
|
39
39
|
/**
|
|
@@ -274,6 +274,7 @@ export const DEFAULTS = {
|
|
|
274
274
|
videos: true,
|
|
275
275
|
veo: false,
|
|
276
276
|
veoShots: 6,
|
|
277
|
+
lipsyncShots: 2,
|
|
277
278
|
parallel: 1,
|
|
278
279
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
279
280
|
};
|
|
@@ -504,18 +505,30 @@ const SHOTS = [
|
|
|
504
505
|
"Close-up of the bass player's fingers and the amplifier, then a tilt up to the band in silhouette",
|
|
505
506
|
"The band walking out on stage before the show, backlit silhouettes, nobody speaking or singing",
|
|
506
507
|
];
|
|
508
|
+
// The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
|
|
509
|
+
// must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
|
|
510
|
+
// the mouth is redrawn.
|
|
511
|
+
const CLOSE_UPS = [
|
|
512
|
+
"Medium close-up of the lead vocalist singing straight to camera, the microphone stand lowered to chest height so the whole mouth and chin are clear, nothing in front of the face",
|
|
513
|
+
"Close-up of the lead vocalist singing with feeling, head and shoulders, a handheld microphone held down at the chest, the whole mouth clearly visible",
|
|
514
|
+
"Medium shot of the lead vocalist singing to the crowd, the microphone held out to the side, face lit and the mouth fully visible",
|
|
515
|
+
"Close-up of the lead vocalist singing in profile-three-quarter view, no microphone in frame, the mouth clearly visible",
|
|
516
|
+
];
|
|
507
517
|
/**
|
|
508
518
|
* The prompts for a single's filmed shots. The people are the reference
|
|
509
519
|
* images, described by role and look only: Veo refuses names with faces
|
|
510
520
|
* ("we can't create videos with real people's names or likenesses").
|
|
511
521
|
*/
|
|
512
|
-
export function veoPrompts(artist, song, members, count) {
|
|
522
|
+
export function veoPrompts(artist, song, members, count, kind = "band") {
|
|
513
523
|
const cast = members.slice(0, 3).map((one, i) => `the ${one.role} from reference image ${i + 1} (${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
|
|
514
|
-
|
|
524
|
+
const rule = kind === "singer"
|
|
525
|
+
? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
|
|
526
|
+
: "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
|
|
527
|
+
return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
|
|
515
528
|
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
516
529
|
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
517
530
|
`Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
|
|
518
|
-
|
|
531
|
+
rule,
|
|
519
532
|
].filter(Boolean).join(" "));
|
|
520
533
|
}
|
|
521
534
|
/** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
|
|
@@ -609,8 +622,30 @@ export function renderStoryVideo(ffmpeg, input) {
|
|
|
609
622
|
return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
|
|
610
623
|
}
|
|
611
624
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
625
|
+
* Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
|
|
626
|
+
* nearest a quarter and three quarters in (one: a third in), each `length`
|
|
627
|
+
* seconds, after the title card, never overlapping. Without timed words, those
|
|
628
|
+
* points themselves.
|
|
629
|
+
*/
|
|
630
|
+
export function closeUpTimes(seconds, lines, count, length = 8) {
|
|
631
|
+
if (count < 1)
|
|
632
|
+
return [];
|
|
633
|
+
const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => 0.25 + (0.5 * i) / (count - 1));
|
|
634
|
+
const fits = (t) => t >= 6 && t + length <= seconds - 1;
|
|
635
|
+
const starts = lines.map((line) => Math.round(line.start * 100) / 100).filter(fits);
|
|
636
|
+
const chosen = [];
|
|
637
|
+
for (const share of targets) {
|
|
638
|
+
const want = share * seconds;
|
|
639
|
+
const pool = (starts.length ? starts : [Math.round(want)]).filter((t) => fits(t) && chosen.every((one) => Math.abs(one - t) >= length));
|
|
640
|
+
if (pool.length)
|
|
641
|
+
chosen.push(pool.reduce((best, t) => (Math.abs(t - want) < Math.abs(best - want) ? t : best)));
|
|
642
|
+
}
|
|
643
|
+
return chosen.sort((a, b) => a - b);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* The version of a video cut from Veo footage: the band with nobody singing, and the
|
|
647
|
+
* singer only in close-ups lip-synced to the song. Anything older (a slideshow, or 4:
|
|
648
|
+
* footage whose singers were out of sync) is not shown.
|
|
614
649
|
*/
|
|
615
650
|
export const FILMED = 5;
|
|
616
651
|
/**
|
|
@@ -623,14 +658,31 @@ export function renderFilmedVideo(ffmpeg, input) {
|
|
|
623
658
|
if (input.clips.length === 0)
|
|
624
659
|
return Promise.reject(new Error("no footage to cut"));
|
|
625
660
|
const cut = 4;
|
|
661
|
+
// Lip-synced close-ups play whole at the second of the song they were synced to; the band fills around them.
|
|
662
|
+
const pinned = [...(input.pinned ?? [])].sort((a, b) => a.at - b.at);
|
|
626
663
|
const cuts = [];
|
|
627
|
-
for (let at = 0, i = 0; at < input.seconds;
|
|
664
|
+
for (let at = 0, i = 0; at < input.seconds - 0.05;) {
|
|
665
|
+
const pin = pinned.find((one) => Math.abs(one.at - at) < 0.05);
|
|
666
|
+
if (pin) {
|
|
667
|
+
const length = Math.min(pin.seconds, input.seconds - at + 0.5);
|
|
668
|
+
cuts.push({ path: pin.path, start: 0, length });
|
|
669
|
+
at += length;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const next = pinned.find((one) => one.at > at + 0.05);
|
|
628
673
|
const clip = input.clips[i % input.clips.length];
|
|
629
674
|
const half = Math.floor(i / input.clips.length) % 2;
|
|
630
|
-
|
|
631
|
-
|
|
675
|
+
let length = Math.min(cut, input.seconds - at + 0.5);
|
|
676
|
+
// Up to a close-up: one cut to meet it exactly, never a sliver.
|
|
677
|
+
if (next)
|
|
678
|
+
length = next.at - at < cut * 1.5 ? next.at - at : Math.min(length, next.at - at);
|
|
679
|
+
const start = length > cut ? 0 : Math.min(half * cut, Math.max(0, clip.seconds - cut));
|
|
680
|
+
length = Math.min(length, clip.seconds - start);
|
|
681
|
+
if (length < 0.04)
|
|
682
|
+
break;
|
|
632
683
|
cuts.push({ path: clip.path, start, length });
|
|
633
684
|
at += length;
|
|
685
|
+
i += 1;
|
|
634
686
|
}
|
|
635
687
|
const parts = cuts.map((_, i) => `[${i}:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,fps=25,setsar=1,setpts=PTS-STARTPTS[c${i}]`);
|
|
636
688
|
parts.push(`${cuts.map((_, i) => `[c${i}]`).join("")}concat=n=${cuts.length}:v=1:a=0[cut]`);
|
|
@@ -889,6 +941,9 @@ export class AiLabel {
|
|
|
889
941
|
const shots = whole("veoShots", 1, 8);
|
|
890
942
|
if (shots !== undefined)
|
|
891
943
|
this.settings.veoShots = shots;
|
|
944
|
+
const close = whole("lipsyncShots", 0, 4);
|
|
945
|
+
if (close !== undefined)
|
|
946
|
+
this.settings.lipsyncShots = close;
|
|
892
947
|
const perDay = whole("albumsPerDay", 0, 24);
|
|
893
948
|
if (perDay !== undefined)
|
|
894
949
|
this.settings.albumsPerDay = perDay;
|
|
@@ -1151,13 +1206,65 @@ export class AiLabel {
|
|
|
1151
1206
|
catch { /* the asked-for length */ }
|
|
1152
1207
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1153
1208
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1154
|
-
const clips = await this.filmShots(artist, song, shots, scenes);
|
|
1209
|
+
const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
|
|
1155
1210
|
if (clips.length < Math.max(2, Math.ceil(this.settings.veoShots / 2)))
|
|
1156
1211
|
throw new Error(`only ${clips.length} of ${this.settings.veoShots} shots filmed so far`);
|
|
1157
|
-
|
|
1212
|
+
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1213
|
+
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1158
1214
|
rmSync(shots, { recursive: true, force: true });
|
|
1159
1215
|
return FILMED;
|
|
1160
1216
|
}
|
|
1217
|
+
/**
|
|
1218
|
+
* The singer's close-ups, each placed on a sung line and its mouth redrawn
|
|
1219
|
+
* to those seconds of the song. A close-up that cannot be synced is left
|
|
1220
|
+
* out (the band fills its place): an unsynced singer is never shown.
|
|
1221
|
+
*/
|
|
1222
|
+
async syncCloseUps(song, singer, seconds, keep) {
|
|
1223
|
+
const lipsync = this.deps.lipsync;
|
|
1224
|
+
if (!lipsync || singer.length === 0)
|
|
1225
|
+
return [];
|
|
1226
|
+
let lines = [];
|
|
1227
|
+
try {
|
|
1228
|
+
if (song.lrc)
|
|
1229
|
+
lines = parseLrc(readFileSync(song.lrc, "utf8"));
|
|
1230
|
+
}
|
|
1231
|
+
catch { /* the song's thirds instead */ }
|
|
1232
|
+
const times = closeUpTimes(seconds, lines, singer.length);
|
|
1233
|
+
const pinned = [];
|
|
1234
|
+
for (const [i, at] of times.entries()) {
|
|
1235
|
+
const synced = join(keep, `synced-${i}-${Math.round(at * 100)}.mp4`);
|
|
1236
|
+
try {
|
|
1237
|
+
if (statSync(synced).size > 10_000) {
|
|
1238
|
+
pinned.push({ path: synced, at, seconds: 8 });
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
catch { /* not synced yet */ }
|
|
1243
|
+
const shot = join(keep, `.send-${i}.mp4`);
|
|
1244
|
+
const audio = join(keep, `.send-${i}.mp3`);
|
|
1245
|
+
try {
|
|
1246
|
+
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1247
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i].path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
|
|
1248
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
|
|
1249
|
+
const bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
|
|
1250
|
+
writeFileSync(`${synced}.part`, bytes);
|
|
1251
|
+
renameSync(`${synced}.part`, synced);
|
|
1252
|
+
pinned.push({ path: synced, at, seconds: 8 });
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
this.log(`lip sync ${i + 1} for "${song.title}": ${error.message}; the band plays there instead`);
|
|
1256
|
+
if (error instanceof VeoUnavailable)
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
finally {
|
|
1260
|
+
rmSync(shot, { force: true });
|
|
1261
|
+
rmSync(audio, { force: true });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (pinned.length)
|
|
1265
|
+
this.log(`lip-synced ${pinned.length} close-up(s) of "${song.title}"`);
|
|
1266
|
+
return pinned;
|
|
1267
|
+
}
|
|
1161
1268
|
/** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
|
|
1162
1269
|
wantsVeo(song) {
|
|
1163
1270
|
return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
|
|
@@ -1170,7 +1277,7 @@ export class AiLabel {
|
|
|
1170
1277
|
async filmShots(artist, song, keep, dir) {
|
|
1171
1278
|
const veo = this.deps.veo;
|
|
1172
1279
|
if (!veo)
|
|
1173
|
-
return [];
|
|
1280
|
+
return { band: [], singer: [] };
|
|
1174
1281
|
mkdirSync(dir, { recursive: true });
|
|
1175
1282
|
mkdirSync(keep, { recursive: true });
|
|
1176
1283
|
const members = this.cast(artist);
|
|
@@ -1187,12 +1294,17 @@ export class AiLabel {
|
|
|
1187
1294
|
// A member without a portrait is described in words only.
|
|
1188
1295
|
}
|
|
1189
1296
|
}
|
|
1190
|
-
const
|
|
1191
|
-
|
|
1192
|
-
|
|
1297
|
+
const band = [];
|
|
1298
|
+
const singer = [];
|
|
1299
|
+
// The singer's close-ups only where they can be lip-synced afterwards.
|
|
1300
|
+
const plan = [
|
|
1301
|
+
...veoPrompts(artist, song, members, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band })),
|
|
1302
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, members, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer })) : []),
|
|
1303
|
+
];
|
|
1304
|
+
for (const [i, { prompt, file, into }] of plan.entries()) {
|
|
1193
1305
|
try {
|
|
1194
1306
|
if (statSync(file).size > 10_000) {
|
|
1195
|
-
|
|
1307
|
+
into.push({ path: file, seconds: 8 });
|
|
1196
1308
|
continue;
|
|
1197
1309
|
}
|
|
1198
1310
|
}
|
|
@@ -1214,7 +1326,7 @@ export class AiLabel {
|
|
|
1214
1326
|
const partial = `${file}.part`;
|
|
1215
1327
|
writeFileSync(partial, bytes);
|
|
1216
1328
|
renameSync(partial, file);
|
|
1217
|
-
|
|
1329
|
+
into.push({ path: file, seconds: 8 });
|
|
1218
1330
|
}
|
|
1219
1331
|
catch (error) {
|
|
1220
1332
|
if (error instanceof VeoUnavailable) {
|
|
@@ -1225,9 +1337,9 @@ export class AiLabel {
|
|
|
1225
1337
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
|
|
1226
1338
|
}
|
|
1227
1339
|
}
|
|
1228
|
-
if (
|
|
1229
|
-
this.log(`Veo: ${
|
|
1230
|
-
return
|
|
1340
|
+
if (band.length || singer.length)
|
|
1341
|
+
this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1342
|
+
return { band, singer };
|
|
1231
1343
|
}
|
|
1232
1344
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
|
1233
1345
|
placeVideos() {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an agent, a store or a person finds every way into nixamp from nixamp.com
|
|
3
|
+
* alone: /llms.txt names the MCP servers, the API and the desktop downloads;
|
|
4
|
+
* /openapi.json describes the API; /api/v1/health says it is up; /download is
|
|
5
|
+
* the desktop app, and /download/<target> sends the browser straight to that
|
|
6
|
+
* installer on the latest release.
|
|
7
|
+
*
|
|
8
|
+
* Only nixamp.com answers these (the server where accounts live). A home
|
|
9
|
+
* server is somebody's own machine and has no business advertising itself.
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
export declare const RELEASES = "https://github.com/profullstack/nixamp/releases";
|
|
13
|
+
/** Each installer release.yml builds, by the address /download/<target> gives it. */
|
|
14
|
+
export declare const DESKTOP_TARGETS: ReadonlyArray<{
|
|
15
|
+
target: string;
|
|
16
|
+
label: string;
|
|
17
|
+
suffix: string;
|
|
18
|
+
}>;
|
|
19
|
+
export declare const DISCOVERY_PATHS: Set<string>;
|
|
20
|
+
export declare const isDiscoveryPath: (path: string) => boolean;
|
|
21
|
+
export interface ReleaseAsset {
|
|
22
|
+
name: string;
|
|
23
|
+
browser_download_url: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Release {
|
|
26
|
+
tag_name: string;
|
|
27
|
+
assets: ReleaseAsset[];
|
|
28
|
+
}
|
|
29
|
+
/** The installer for `target` among a release's files, or null when that build is missing. */
|
|
30
|
+
export declare function desktopAsset(release: Release | null, target: string): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* The latest published release, asked of GitHub at most every ten minutes. A
|
|
33
|
+
* failed ask keeps the last answer; with none, the downloads fall back to the
|
|
34
|
+
* releases page, which is never wrong, only one click longer.
|
|
35
|
+
*/
|
|
36
|
+
export declare function latestRelease(fetcher?: typeof fetch, now?: () => number): () => Promise<Release | null>;
|
|
37
|
+
export declare function llmsTxt(site: string): string;
|
|
38
|
+
/** The API as OpenAPI 3.1: what an agent can call on nixamp.com, and which calls want a token. */
|
|
39
|
+
export declare function openapiDocument(site: string, version: string): Record<string, unknown>;
|
|
40
|
+
export declare function downloadPage(site: string, release: Release | null): string;
|
|
41
|
+
export interface DiscoveryContext {
|
|
42
|
+
site: string;
|
|
43
|
+
version: string;
|
|
44
|
+
release: () => Promise<Release | null>;
|
|
45
|
+
json: (response: ServerResponse, code: number, body: unknown) => void;
|
|
46
|
+
}
|
|
47
|
+
/** Answers a discovery path; false when `path` is not one, so the caller carries on. */
|
|
48
|
+
export declare function handleDiscovery(request: IncomingMessage, response: ServerResponse, path: string, ctx: DiscoveryContext): Promise<boolean>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
export const RELEASES = "https://github.com/profullstack/nixamp/releases";
|
|
2
|
+
const LATEST_API = "https://api.github.com/repos/profullstack/nixamp/releases/latest";
|
|
3
|
+
/** Each installer release.yml builds, by the address /download/<target> gives it. */
|
|
4
|
+
export const DESKTOP_TARGETS = [
|
|
5
|
+
{ target: "mac-arm64", label: "macOS (Apple silicon)", suffix: "-mac-arm64.dmg" },
|
|
6
|
+
{ target: "mac-x64", label: "macOS (Intel)", suffix: "-mac-x64.dmg" },
|
|
7
|
+
{ target: "win-x64", label: "Windows (x64)", suffix: "-win-x64.exe" },
|
|
8
|
+
{ target: "win-arm64", label: "Windows (ARM)", suffix: "-win-arm64.exe" },
|
|
9
|
+
{ target: "linux-x64", label: "Linux AppImage (x86_64)", suffix: "-linux-x86_64.AppImage" },
|
|
10
|
+
{ target: "linux-arm64", label: "Linux AppImage (arm64)", suffix: "-linux-arm64.AppImage" },
|
|
11
|
+
{ target: "deb-amd64", label: "Debian/Ubuntu .deb (amd64)", suffix: "-linux-amd64.deb" },
|
|
12
|
+
{ target: "deb-arm64", label: "Debian/Ubuntu .deb (arm64)", suffix: "-linux-arm64.deb" },
|
|
13
|
+
];
|
|
14
|
+
export const DISCOVERY_PATHS = new Set(["/llms.txt", "/openapi.json", "/api/v1/health", "/download"]);
|
|
15
|
+
export const isDiscoveryPath = (path) => DISCOVERY_PATHS.has(path) || /^\/download\/[a-z0-9-]{1,20}$/.test(path);
|
|
16
|
+
/** The installer for `target` among a release's files, or null when that build is missing. */
|
|
17
|
+
export function desktopAsset(release, target) {
|
|
18
|
+
const spec = DESKTOP_TARGETS.find((one) => one.target === target);
|
|
19
|
+
if (!spec || !release)
|
|
20
|
+
return null;
|
|
21
|
+
return release.assets.find((asset) => asset.name.startsWith("nixamp-") && asset.name.endsWith(spec.suffix))?.browser_download_url ?? null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The latest published release, asked of GitHub at most every ten minutes. A
|
|
25
|
+
* failed ask keeps the last answer; with none, the downloads fall back to the
|
|
26
|
+
* releases page, which is never wrong, only one click longer.
|
|
27
|
+
*/
|
|
28
|
+
export function latestRelease(fetcher = fetch, now = Date.now) {
|
|
29
|
+
let kept = { at: -Infinity, release: null };
|
|
30
|
+
return async () => {
|
|
31
|
+
if (now() - kept.at < 10 * 60_000)
|
|
32
|
+
return kept.release;
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetcher(LATEST_API, { headers: { accept: "application/vnd.github+json", "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error(`HTTP ${response.status}`);
|
|
37
|
+
const body = await response.json();
|
|
38
|
+
kept = { at: now(), release: { tag_name: String(body.tag_name ?? ""), assets: Array.isArray(body.assets) ? body.assets : [] } };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
kept = { at: now(), release: kept.release };
|
|
42
|
+
}
|
|
43
|
+
return kept.release;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function llmsTxt(site) {
|
|
47
|
+
const desktop = DESKTOP_TARGETS.map((one) => `- ${one.label}: ${site}/download/${one.target}`).join("\n");
|
|
48
|
+
return `# nixamp
|
|
49
|
+
|
|
50
|
+
> Broadcast live radio, television and film from your own machine. Open source streaming you run yourself: a terminal player, a web app (PWA) and a desktop app on one engine. Listen free, go live in one command, monetize with x402.
|
|
51
|
+
|
|
52
|
+
## Web app (PWA)
|
|
53
|
+
- ${site}/ : the player and remote, installable
|
|
54
|
+
- ${site}/music : AI music, live and on demand, installable on its own
|
|
55
|
+
|
|
56
|
+
## CLI
|
|
57
|
+
- Install: curl -fsSL ${site}/install.sh | sh
|
|
58
|
+
- Windows: irm ${site}/install.ps1 | iex
|
|
59
|
+
- npm: npx -y nixamp
|
|
60
|
+
|
|
61
|
+
## MCP
|
|
62
|
+
- Public, no account (stations, what is on, share a track): ${site}/mcp/public
|
|
63
|
+
- Signed in (watch parties, rooms, transcripts, music, your stations): ${site}/mcp , a nixamp token as the bearer (\`nixamp token create\`) or OAuth
|
|
64
|
+
- Local over stdio: npx -y nixamp mcp
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
- OpenAPI: ${site}/openapi.json
|
|
68
|
+
- Base: ${site}/api/v1
|
|
69
|
+
- GET /api/v1/health
|
|
70
|
+
- GET /api/v1/music : the AI label's catalogue, every genre station, artist and album
|
|
71
|
+
- GET /api/v1/stations : public stations
|
|
72
|
+
- GET /api/v1/guide/rooms : live rooms
|
|
73
|
+
- GET /api/v1/guide/vods : films and shows on demand
|
|
74
|
+
- GET /api/directory : every nixamp server that is broadcasting
|
|
75
|
+
- GET /api/v1/auth/me : who the bearer token is
|
|
76
|
+
- POST /api/v1/auth/device/code : sign a CLI or agent in from a browser
|
|
77
|
+
|
|
78
|
+
## Desktop app
|
|
79
|
+
Downloads: ${site}/download
|
|
80
|
+
${desktop}
|
|
81
|
+
Every release: ${RELEASES}
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
const ok = { description: "OK", content: { "application/json": { schema: { type: "object" } } } };
|
|
85
|
+
const signedIn = { description: "Not signed in", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } };
|
|
86
|
+
const route = (summary, tags, auth = false) => ({
|
|
87
|
+
summary,
|
|
88
|
+
tags,
|
|
89
|
+
...(auth ? { security: [{ bearer: [] }], responses: { 200: ok, 401: signedIn } } : { responses: { 200: ok } }),
|
|
90
|
+
});
|
|
91
|
+
/** The API as OpenAPI 3.1: what an agent can call on nixamp.com, and which calls want a token. */
|
|
92
|
+
export function openapiDocument(site, version) {
|
|
93
|
+
return {
|
|
94
|
+
openapi: "3.1.0",
|
|
95
|
+
info: {
|
|
96
|
+
title: "nixamp",
|
|
97
|
+
version,
|
|
98
|
+
description: "Live radio, television and film from your own machine. Public routes answer anybody; the rest want a nixamp token as the bearer (`nixamp token create`).",
|
|
99
|
+
license: { name: "MIT", identifier: "MIT" },
|
|
100
|
+
},
|
|
101
|
+
servers: [{ url: site }],
|
|
102
|
+
externalDocs: { description: "llms.txt: MCP, CLI, desktop and API in one place", url: `${site}/llms.txt` },
|
|
103
|
+
components: {
|
|
104
|
+
securitySchemes: { bearer: { type: "http", scheme: "bearer", description: "A nixamp token (`nixamp token create`) or an OAuth access token." } },
|
|
105
|
+
schemas: { Error: { type: "object", properties: { error: { type: "string" } }, required: ["error"] } },
|
|
106
|
+
},
|
|
107
|
+
paths: {
|
|
108
|
+
"/api/v1/health": { get: route("Is nixamp.com up, and which version", ["meta"]) },
|
|
109
|
+
"/api/directory": { get: route("Every nixamp server that is broadcasting", ["stations"]) },
|
|
110
|
+
"/api/v1/stations": { get: route("Public stations", ["stations"]) },
|
|
111
|
+
"/api/v1/guide/rooms": { get: route("Live rooms on the guide", ["stations"]) },
|
|
112
|
+
"/api/v1/guide/vods": { get: route("Films and shows on demand", ["stations"]) },
|
|
113
|
+
"/api/v1/opendirs": { get: route("Open directories nixamp has indexed", ["stations"]) },
|
|
114
|
+
"/api/v1/music": { get: route("The AI label's catalogue: genre stations, artists, albums", ["music"]) },
|
|
115
|
+
"/api/v1/auth/providers": { get: route("How you can sign in", ["auth"]) },
|
|
116
|
+
"/api/v1/auth/device/code": { post: route("Start a device sign-in (CLI or agent); a person approves it in a browser", ["auth"]) },
|
|
117
|
+
"/api/v1/auth/device/token": { post: route("Collect the token once the device sign-in is approved", ["auth"]) },
|
|
118
|
+
"/api/v1/auth/me": { get: route("Who the bearer is", ["auth"], true) },
|
|
119
|
+
"/api/v1/auth/tokens": { get: route("Your tokens", ["auth"], true), post: route("Make a token", ["auth"], true) },
|
|
120
|
+
"/api/v1/follows": { get: route("Who you follow", ["social"], true) },
|
|
121
|
+
"/api/v1/favorites": { get: route("Your favourite stations", ["social"], true) },
|
|
122
|
+
"/api/v1/servers": { get: route("Your nixamp servers", ["servers"], true) },
|
|
123
|
+
"/mcp/public": { post: route("MCP (streamable HTTP, JSON-RPC): the public station tools", ["mcp"]) },
|
|
124
|
+
"/mcp": { post: route("MCP (streamable HTTP, JSON-RPC): every tool, as the bearer", ["mcp"], true) },
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const escape = (text) => text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]);
|
|
129
|
+
const STYLE = "body{margin:0;background:#080c09;color:#d8e6da;font:16px/1.5 system-ui,sans-serif}main{max-width:640px;margin:0 auto;padding:32px 16px}h1{font-size:28px;margin:0 0 4px}a{color:#7ee787}ul{padding:0;list-style:none}li{margin:10px 0}li a{display:block;padding:12px 14px;border:1px solid #1f2b22;border-radius:8px;text-decoration:none}li a:hover{border-color:#7ee787}code{background:#111a13;padding:2px 6px;border-radius:4px}.by{color:#8aa08e;margin:0 0 24px}";
|
|
130
|
+
export function downloadPage(site, release) {
|
|
131
|
+
const tag = release?.tag_name ? ` ${escape(release.tag_name)}` : "";
|
|
132
|
+
const items = DESKTOP_TARGETS.map((one) => `<li><a href="/download/${one.target}">${escape(one.label)}</a></li>`).join("");
|
|
133
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
134
|
+
<title>Download the desktop app · nixamp</title><meta name="description" content="nixamp for macOS, Windows and Linux: the player, the radio console and a nixamp server in one app.">
|
|
135
|
+
<link rel="canonical" href="${escape(site)}/download"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
|
|
136
|
+
<body><main><h1>nixamp for your desktop</h1><p class="by">The player, the radio console and a nixamp server in one app${tag}.</p>
|
|
137
|
+
<ul>${items}</ul>
|
|
138
|
+
<p>Or in a terminal: <code>curl -fsSL ${escape(site)}/install.sh | sh</code></p>
|
|
139
|
+
<p><a href="/">Open the web app</a> · <a href="${RELEASES}">Every release</a> · <a href="/llms.txt">For agents</a></p></main></body></html>`;
|
|
140
|
+
}
|
|
141
|
+
function send(response, code, type, body, method, extra = {}) {
|
|
142
|
+
response.writeHead(code, { "content-type": type, "content-length": Buffer.byteLength(body), "cache-control": "public, max-age=300", "access-control-allow-origin": "*", ...extra });
|
|
143
|
+
response.end(method === "HEAD" ? undefined : body);
|
|
144
|
+
}
|
|
145
|
+
/** Answers a discovery path; false when `path` is not one, so the caller carries on. */
|
|
146
|
+
export async function handleDiscovery(request, response, path, ctx) {
|
|
147
|
+
if (!isDiscoveryPath(path))
|
|
148
|
+
return false;
|
|
149
|
+
const method = request.method ?? "GET";
|
|
150
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
151
|
+
response.writeHead(405, { allow: "GET, HEAD" });
|
|
152
|
+
response.end();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
const site = ctx.site.replace(/\/+$/, "");
|
|
156
|
+
if (path === "/api/v1/health") {
|
|
157
|
+
ctx.json(response, 200, { ok: true, name: "nixamp", version: ctx.version });
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if (path === "/llms.txt") {
|
|
161
|
+
send(response, 200, "text/plain; charset=utf-8", llmsTxt(site), method);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (path === "/openapi.json") {
|
|
165
|
+
send(response, 200, "application/json; charset=utf-8", JSON.stringify(openapiDocument(site, ctx.version), null, 2), method);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (path === "/download") {
|
|
169
|
+
send(response, 200, "text/html; charset=utf-8", downloadPage(site, await ctx.release()), method, { "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'` });
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
const target = path.slice("/download/".length);
|
|
173
|
+
if (!DESKTOP_TARGETS.some((one) => one.target === target)) {
|
|
174
|
+
ctx.json(response, 404, { error: `no such download; one of ${DESKTOP_TARGETS.map((one) => one.target).join(", ")}` });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
// Short-lived: the next release moves it.
|
|
178
|
+
response.writeHead(302, { location: desktopAsset(await ctx.release(), target) ?? `${RELEASES}/latest`, "cache-control": "public, max-age=300" });
|
|
179
|
+
response.end();
|
|
180
|
+
return true;
|
|
181
|
+
}
|