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/src/ai-label.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { dirname, join, resolve, sep } from "node:path";
|
|
|
33
33
|
import { aiKey } from "./ai-keys.ts";
|
|
34
34
|
import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
|
|
35
35
|
import { askJson, hasModel } from "./ai-text.ts";
|
|
36
|
-
import { formatLrc, type SyncedLine } from "./lyrics.ts";
|
|
36
|
+
import { formatLrc, parseLrc, type SyncedLine } from "./lyrics.ts";
|
|
37
37
|
import { bandMarkdown, castLine, inventMembers, readMembers, writeMembers, type Member } from "./label-profiles.ts";
|
|
38
38
|
import type { LibraryIndex, Probed } from "./library-index.ts";
|
|
39
39
|
import type { StationRunner } from "./station-runner.ts";
|
|
@@ -127,8 +127,10 @@ export interface LabelSettings {
|
|
|
127
127
|
videos: boolean;
|
|
128
128
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
129
129
|
veo: boolean;
|
|
130
|
-
/** How many 8 s Veo shots a single is cut from. */
|
|
130
|
+
/** How many 8 s Veo shots of the band a single is cut from. */
|
|
131
131
|
veoShots: number;
|
|
132
|
+
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
133
|
+
lipsyncShots: number;
|
|
132
134
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
133
135
|
publish: boolean;
|
|
134
136
|
/** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
|
|
@@ -384,6 +386,7 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
|
|
|
384
386
|
videos: true,
|
|
385
387
|
veo: false,
|
|
386
388
|
veoShots: 6,
|
|
389
|
+
lipsyncShots: 2,
|
|
387
390
|
parallel: 1,
|
|
388
391
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
389
392
|
};
|
|
@@ -640,18 +643,31 @@ const SHOTS = [
|
|
|
640
643
|
"The band walking out on stage before the show, backlit silhouettes, nobody speaking or singing",
|
|
641
644
|
];
|
|
642
645
|
|
|
646
|
+
// The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
|
|
647
|
+
// must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
|
|
648
|
+
// the mouth is redrawn.
|
|
649
|
+
const CLOSE_UPS = [
|
|
650
|
+
"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",
|
|
651
|
+
"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",
|
|
652
|
+
"Medium shot of the lead vocalist singing to the crowd, the microphone held out to the side, face lit and the mouth fully visible",
|
|
653
|
+
"Close-up of the lead vocalist singing in profile-three-quarter view, no microphone in frame, the mouth clearly visible",
|
|
654
|
+
];
|
|
655
|
+
|
|
643
656
|
/**
|
|
644
657
|
* The prompts for a single's filmed shots. The people are the reference
|
|
645
658
|
* images, described by role and look only: Veo refuses names with faces
|
|
646
659
|
* ("we can't create videos with real people's names or likenesses").
|
|
647
660
|
*/
|
|
648
|
-
export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[] {
|
|
661
|
+
export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number, kind: "band" | "singer" = "band"): string[] {
|
|
649
662
|
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("; ");
|
|
650
|
-
|
|
663
|
+
const rule = kind === "singer"
|
|
664
|
+
? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
|
|
665
|
+
: "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
|
|
666
|
+
return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
|
|
651
667
|
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
652
668
|
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
653
669
|
`Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
|
|
654
|
-
|
|
670
|
+
rule,
|
|
655
671
|
].filter(Boolean).join(" "));
|
|
656
672
|
}
|
|
657
673
|
|
|
@@ -736,8 +752,29 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
|
|
|
736
752
|
}
|
|
737
753
|
|
|
738
754
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
755
|
+
* Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
|
|
756
|
+
* nearest a quarter and three quarters in (one: a third in), each `length`
|
|
757
|
+
* seconds, after the title card, never overlapping. Without timed words, those
|
|
758
|
+
* points themselves.
|
|
759
|
+
*/
|
|
760
|
+
export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, count: number, length = 8): number[] {
|
|
761
|
+
if (count < 1) return [];
|
|
762
|
+
const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => 0.25 + (0.5 * i) / (count - 1));
|
|
763
|
+
const fits = (t: number): boolean => t >= 6 && t + length <= seconds - 1;
|
|
764
|
+
const starts = lines.map((line) => Math.round(line.start * 100) / 100).filter(fits);
|
|
765
|
+
const chosen: number[] = [];
|
|
766
|
+
for (const share of targets) {
|
|
767
|
+
const want = share * seconds;
|
|
768
|
+
const pool = (starts.length ? starts : [Math.round(want)]).filter((t) => fits(t) && chosen.every((one) => Math.abs(one - t) >= length));
|
|
769
|
+
if (pool.length) chosen.push(pool.reduce((best, t) => (Math.abs(t - want) < Math.abs(best - want) ? t : best)));
|
|
770
|
+
}
|
|
771
|
+
return chosen.sort((a, b) => a - b);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The version of a video cut from Veo footage: the band with nobody singing, and the
|
|
776
|
+
* singer only in close-ups lip-synced to the song. Anything older (a slideshow, or 4:
|
|
777
|
+
* footage whose singers were out of sync) is not shown.
|
|
741
778
|
*/
|
|
742
779
|
export const FILMED = 5;
|
|
743
780
|
|
|
@@ -747,17 +784,32 @@ export const FILMED = 5;
|
|
|
747
784
|
* ends, so no picture holds still. The artist and title come up at the start;
|
|
748
785
|
* the MP3's audio and tags are copied in. 1080p H.264.
|
|
749
786
|
*/
|
|
750
|
-
export function renderFilmedVideo(ffmpeg: string[], input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string }): Promise<void> {
|
|
787
|
+
export function renderFilmedVideo(ffmpeg: string[], 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> {
|
|
751
788
|
if (input.clips.length === 0) return Promise.reject(new Error("no footage to cut"));
|
|
752
789
|
const cut = 4;
|
|
790
|
+
// Lip-synced close-ups play whole at the second of the song they were synced to; the band fills around them.
|
|
791
|
+
const pinned = [...(input.pinned ?? [])].sort((a, b) => a.at - b.at);
|
|
753
792
|
const cuts: Array<{ path: string; start: number; length: number }> = [];
|
|
754
|
-
for (let at = 0, i = 0; at < input.seconds;
|
|
793
|
+
for (let at = 0, i = 0; at < input.seconds - 0.05;) {
|
|
794
|
+
const pin = pinned.find((one) => Math.abs(one.at - at) < 0.05);
|
|
795
|
+
if (pin) {
|
|
796
|
+
const length = Math.min(pin.seconds, input.seconds - at + 0.5);
|
|
797
|
+
cuts.push({ path: pin.path, start: 0, length });
|
|
798
|
+
at += length;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const next = pinned.find((one) => one.at > at + 0.05);
|
|
755
802
|
const clip = input.clips[i % input.clips.length]!;
|
|
756
803
|
const half = Math.floor(i / input.clips.length) % 2;
|
|
757
|
-
|
|
758
|
-
|
|
804
|
+
let length = Math.min(cut, input.seconds - at + 0.5);
|
|
805
|
+
// Up to a close-up: one cut to meet it exactly, never a sliver.
|
|
806
|
+
if (next) length = next.at - at < cut * 1.5 ? next.at - at : Math.min(length, next.at - at);
|
|
807
|
+
const start = length > cut ? 0 : Math.min(half * cut, Math.max(0, clip.seconds - cut));
|
|
808
|
+
length = Math.min(length, clip.seconds - start);
|
|
809
|
+
if (length < 0.04) break;
|
|
759
810
|
cuts.push({ path: clip.path, start, length });
|
|
760
811
|
at += length;
|
|
812
|
+
i += 1;
|
|
761
813
|
}
|
|
762
814
|
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}]`);
|
|
763
815
|
parts.push(`${cuts.map((_, i) => `[c${i}]`).join("")}concat=n=${cuts.length}:v=1:a=0[cut]`);
|
|
@@ -864,7 +916,9 @@ export interface AiLabelDeps {
|
|
|
864
916
|
/** How long to wait before asking Veo again after nixamp.com or the network failed (60 s). */
|
|
865
917
|
retryMs?: number;
|
|
866
918
|
/** Cuts a single's filmed shots to its song; ffmpeg by default (renderFilmedVideo). */
|
|
867
|
-
filmedVideo?: (input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string }) => Promise<void>;
|
|
919
|
+
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>;
|
|
920
|
+
/** 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. */
|
|
921
|
+
lipsync?: ((input: { video: Buffer; audio: Buffer; seconds: number }) => Promise<Buffer>) | null;
|
|
868
922
|
/** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
|
|
869
923
|
storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string; clips?: Array<{ path: string; seconds: number }> }) => Promise<void>;
|
|
870
924
|
/** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
|
|
@@ -1051,6 +1105,8 @@ export class AiLabel {
|
|
|
1051
1105
|
if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
|
|
1052
1106
|
const shots = whole("veoShots", 1, 8);
|
|
1053
1107
|
if (shots !== undefined) this.settings.veoShots = shots;
|
|
1108
|
+
const close = whole("lipsyncShots", 0, 4);
|
|
1109
|
+
if (close !== undefined) this.settings.lipsyncShots = close;
|
|
1054
1110
|
const perDay = whole("albumsPerDay", 0, 24);
|
|
1055
1111
|
if (perDay !== undefined) this.settings.albumsPerDay = perDay;
|
|
1056
1112
|
if (input["newArtistChance"] !== undefined) {
|
|
@@ -1291,13 +1347,53 @@ export class AiLabel {
|
|
|
1291
1347
|
} catch { /* the asked-for length */ }
|
|
1292
1348
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1293
1349
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1294
|
-
const clips = await this.filmShots(artist, song, shots, scenes);
|
|
1350
|
+
const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
|
|
1295
1351
|
if (clips.length < Math.max(2, Math.ceil(this.settings.veoShots / 2))) throw new Error(`only ${clips.length} of ${this.settings.veoShots} shots filmed so far`);
|
|
1296
|
-
|
|
1352
|
+
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1353
|
+
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 } : {}) });
|
|
1297
1354
|
rmSync(shots, { recursive: true, force: true });
|
|
1298
1355
|
return FILMED;
|
|
1299
1356
|
}
|
|
1300
1357
|
|
|
1358
|
+
/**
|
|
1359
|
+
* The singer's close-ups, each placed on a sung line and its mouth redrawn
|
|
1360
|
+
* to those seconds of the song. A close-up that cannot be synced is left
|
|
1361
|
+
* out (the band fills its place): an unsynced singer is never shown.
|
|
1362
|
+
*/
|
|
1363
|
+
private async syncCloseUps(song: LabelSong, singer: Array<{ path: string; seconds: number }>, seconds: number, keep: string): Promise<Array<{ path: string; at: number; seconds: number }>> {
|
|
1364
|
+
const lipsync = this.deps.lipsync;
|
|
1365
|
+
if (!lipsync || singer.length === 0) return [];
|
|
1366
|
+
let lines: Array<{ start: number }> = [];
|
|
1367
|
+
try { if (song.lrc) lines = parseLrc(readFileSync(song.lrc, "utf8")); } catch { /* the song's thirds instead */ }
|
|
1368
|
+
const times = closeUpTimes(seconds, lines, singer.length);
|
|
1369
|
+
const pinned: Array<{ path: string; at: number; seconds: number }> = [];
|
|
1370
|
+
for (const [i, at] of times.entries()) {
|
|
1371
|
+
const synced = join(keep, `synced-${i}-${Math.round(at * 100)}.mp4`);
|
|
1372
|
+
try {
|
|
1373
|
+
if (statSync(synced).size > 10_000) { pinned.push({ path: synced, at, seconds: 8 }); continue; }
|
|
1374
|
+
} catch { /* not synced yet */ }
|
|
1375
|
+
const shot = join(keep, `.send-${i}.mp4`);
|
|
1376
|
+
const audio = join(keep, `.send-${i}.mp3`);
|
|
1377
|
+
try {
|
|
1378
|
+
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1379
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i]!.path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
|
|
1380
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
|
|
1381
|
+
const bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
|
|
1382
|
+
writeFileSync(`${synced}.part`, bytes);
|
|
1383
|
+
renameSync(`${synced}.part`, synced);
|
|
1384
|
+
pinned.push({ path: synced, at, seconds: 8 });
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
this.log(`lip sync ${i + 1} for "${song.title}": ${(error as Error).message}; the band plays there instead`);
|
|
1387
|
+
if (error instanceof VeoUnavailable) break;
|
|
1388
|
+
} finally {
|
|
1389
|
+
rmSync(shot, { force: true });
|
|
1390
|
+
rmSync(audio, { force: true });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (pinned.length) this.log(`lip-synced ${pinned.length} close-up(s) of "${song.title}"`);
|
|
1394
|
+
return pinned;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1301
1397
|
/** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
|
|
1302
1398
|
private wantsVeo(song: Pick<LabelSong, "n">): boolean {
|
|
1303
1399
|
return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
|
|
@@ -1308,9 +1404,9 @@ export class AiLabel {
|
|
|
1308
1404
|
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
1309
1405
|
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
1310
1406
|
*/
|
|
1311
|
-
private async filmShots(artist: LabelArtist, song: LabelSong, keep: string, dir: string): Promise<Array<{ path: string; seconds: number }
|
|
1407
|
+
private async filmShots(artist: LabelArtist, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
|
|
1312
1408
|
const veo = this.deps.veo;
|
|
1313
|
-
if (!veo) return [];
|
|
1409
|
+
if (!veo) return { band: [], singer: [] };
|
|
1314
1410
|
mkdirSync(dir, { recursive: true });
|
|
1315
1411
|
mkdirSync(keep, { recursive: true });
|
|
1316
1412
|
const members = this.cast(artist);
|
|
@@ -1326,11 +1422,16 @@ export class AiLabel {
|
|
|
1326
1422
|
// A member without a portrait is described in words only.
|
|
1327
1423
|
}
|
|
1328
1424
|
}
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
|
|
1425
|
+
const band: Array<{ path: string; seconds: number }> = [];
|
|
1426
|
+
const singer: Array<{ path: string; seconds: number }> = [];
|
|
1427
|
+
// The singer's close-ups only where they can be lip-synced afterwards.
|
|
1428
|
+
const plan = [
|
|
1429
|
+
...veoPrompts(artist, song, members, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band })),
|
|
1430
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, members, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer })) : []),
|
|
1431
|
+
];
|
|
1432
|
+
for (const [i, { prompt, file, into }] of plan.entries()) {
|
|
1332
1433
|
try {
|
|
1333
|
-
if (statSync(file).size > 10_000) {
|
|
1434
|
+
if (statSync(file).size > 10_000) { into.push({ path: file, seconds: 8 }); continue; }
|
|
1334
1435
|
} catch { /* not filmed yet */ }
|
|
1335
1436
|
try {
|
|
1336
1437
|
// nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
|
|
@@ -1344,7 +1445,7 @@ export class AiLabel {
|
|
|
1344
1445
|
const partial = `${file}.part`;
|
|
1345
1446
|
writeFileSync(partial, bytes!);
|
|
1346
1447
|
renameSync(partial, file);
|
|
1347
|
-
|
|
1448
|
+
into.push({ path: file, seconds: 8 });
|
|
1348
1449
|
} catch (error) {
|
|
1349
1450
|
if (error instanceof VeoUnavailable) {
|
|
1350
1451
|
this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
|
|
@@ -1354,8 +1455,8 @@ export class AiLabel {
|
|
|
1354
1455
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
|
|
1355
1456
|
}
|
|
1356
1457
|
}
|
|
1357
|
-
if (
|
|
1358
|
-
return
|
|
1458
|
+
if (band.length || singer.length) this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1459
|
+
return { band, singer };
|
|
1359
1460
|
}
|
|
1360
1461
|
|
|
1361
1462
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
|
|
13
|
+
export const RELEASES = "https://github.com/profullstack/nixamp/releases";
|
|
14
|
+
const LATEST_API = "https://api.github.com/repos/profullstack/nixamp/releases/latest";
|
|
15
|
+
|
|
16
|
+
/** Each installer release.yml builds, by the address /download/<target> gives it. */
|
|
17
|
+
export const DESKTOP_TARGETS: ReadonlyArray<{ target: string; label: string; suffix: string }> = [
|
|
18
|
+
{ target: "mac-arm64", label: "macOS (Apple silicon)", suffix: "-mac-arm64.dmg" },
|
|
19
|
+
{ target: "mac-x64", label: "macOS (Intel)", suffix: "-mac-x64.dmg" },
|
|
20
|
+
{ target: "win-x64", label: "Windows (x64)", suffix: "-win-x64.exe" },
|
|
21
|
+
{ target: "win-arm64", label: "Windows (ARM)", suffix: "-win-arm64.exe" },
|
|
22
|
+
{ target: "linux-x64", label: "Linux AppImage (x86_64)", suffix: "-linux-x86_64.AppImage" },
|
|
23
|
+
{ target: "linux-arm64", label: "Linux AppImage (arm64)", suffix: "-linux-arm64.AppImage" },
|
|
24
|
+
{ target: "deb-amd64", label: "Debian/Ubuntu .deb (amd64)", suffix: "-linux-amd64.deb" },
|
|
25
|
+
{ target: "deb-arm64", label: "Debian/Ubuntu .deb (arm64)", suffix: "-linux-arm64.deb" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const DISCOVERY_PATHS = new Set(["/llms.txt", "/openapi.json", "/api/v1/health", "/download"]);
|
|
29
|
+
export const isDiscoveryPath = (path: string): boolean => DISCOVERY_PATHS.has(path) || /^\/download\/[a-z0-9-]{1,20}$/.test(path);
|
|
30
|
+
|
|
31
|
+
export interface ReleaseAsset { name: string; browser_download_url: string }
|
|
32
|
+
export interface Release { tag_name: string; assets: ReleaseAsset[] }
|
|
33
|
+
|
|
34
|
+
/** The installer for `target` among a release's files, or null when that build is missing. */
|
|
35
|
+
export function desktopAsset(release: Release | null, target: string): string | null {
|
|
36
|
+
const spec = DESKTOP_TARGETS.find((one) => one.target === target);
|
|
37
|
+
if (!spec || !release) return null;
|
|
38
|
+
return release.assets.find((asset) => asset.name.startsWith("nixamp-") && asset.name.endsWith(spec.suffix))?.browser_download_url ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The latest published release, asked of GitHub at most every ten minutes. A
|
|
43
|
+
* failed ask keeps the last answer; with none, the downloads fall back to the
|
|
44
|
+
* releases page, which is never wrong, only one click longer.
|
|
45
|
+
*/
|
|
46
|
+
export function latestRelease(fetcher: typeof fetch = fetch, now: () => number = Date.now): () => Promise<Release | null> {
|
|
47
|
+
let kept: { at: number; release: Release | null } = { at: -Infinity, release: null };
|
|
48
|
+
return async () => {
|
|
49
|
+
if (now() - kept.at < 10 * 60_000) return kept.release;
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetcher(LATEST_API, { headers: { accept: "application/vnd.github+json", "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
|
|
52
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
53
|
+
const body = await response.json() as Release;
|
|
54
|
+
kept = { at: now(), release: { tag_name: String(body.tag_name ?? ""), assets: Array.isArray(body.assets) ? body.assets : [] } };
|
|
55
|
+
} catch {
|
|
56
|
+
kept = { at: now(), release: kept.release };
|
|
57
|
+
}
|
|
58
|
+
return kept.release;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function llmsTxt(site: string): string {
|
|
63
|
+
const desktop = DESKTOP_TARGETS.map((one) => `- ${one.label}: ${site}/download/${one.target}`).join("\n");
|
|
64
|
+
return `# nixamp
|
|
65
|
+
|
|
66
|
+
> 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.
|
|
67
|
+
|
|
68
|
+
## Web app (PWA)
|
|
69
|
+
- ${site}/ : the player and remote, installable
|
|
70
|
+
- ${site}/music : AI music, live and on demand, installable on its own
|
|
71
|
+
|
|
72
|
+
## CLI
|
|
73
|
+
- Install: curl -fsSL ${site}/install.sh | sh
|
|
74
|
+
- Windows: irm ${site}/install.ps1 | iex
|
|
75
|
+
- npm: npx -y nixamp
|
|
76
|
+
|
|
77
|
+
## MCP
|
|
78
|
+
- Public, no account (stations, what is on, share a track): ${site}/mcp/public
|
|
79
|
+
- Signed in (watch parties, rooms, transcripts, music, your stations): ${site}/mcp , a nixamp token as the bearer (\`nixamp token create\`) or OAuth
|
|
80
|
+
- Local over stdio: npx -y nixamp mcp
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
- OpenAPI: ${site}/openapi.json
|
|
84
|
+
- Base: ${site}/api/v1
|
|
85
|
+
- GET /api/v1/health
|
|
86
|
+
- GET /api/v1/music : the AI label's catalogue, every genre station, artist and album
|
|
87
|
+
- GET /api/v1/stations : public stations
|
|
88
|
+
- GET /api/v1/guide/rooms : live rooms
|
|
89
|
+
- GET /api/v1/guide/vods : films and shows on demand
|
|
90
|
+
- GET /api/directory : every nixamp server that is broadcasting
|
|
91
|
+
- GET /api/v1/auth/me : who the bearer token is
|
|
92
|
+
- POST /api/v1/auth/device/code : sign a CLI or agent in from a browser
|
|
93
|
+
|
|
94
|
+
## Desktop app
|
|
95
|
+
Downloads: ${site}/download
|
|
96
|
+
${desktop}
|
|
97
|
+
Every release: ${RELEASES}
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ok = { description: "OK", content: { "application/json": { schema: { type: "object" } } } };
|
|
102
|
+
const signedIn = { description: "Not signed in", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } };
|
|
103
|
+
const route = (summary: string, tags: string[], auth = false) => ({
|
|
104
|
+
summary,
|
|
105
|
+
tags,
|
|
106
|
+
...(auth ? { security: [{ bearer: [] }], responses: { 200: ok, 401: signedIn } } : { responses: { 200: ok } }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** The API as OpenAPI 3.1: what an agent can call on nixamp.com, and which calls want a token. */
|
|
110
|
+
export function openapiDocument(site: string, version: string): Record<string, unknown> {
|
|
111
|
+
return {
|
|
112
|
+
openapi: "3.1.0",
|
|
113
|
+
info: {
|
|
114
|
+
title: "nixamp",
|
|
115
|
+
version,
|
|
116
|
+
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`).",
|
|
117
|
+
license: { name: "MIT", identifier: "MIT" },
|
|
118
|
+
},
|
|
119
|
+
servers: [{ url: site }],
|
|
120
|
+
externalDocs: { description: "llms.txt: MCP, CLI, desktop and API in one place", url: `${site}/llms.txt` },
|
|
121
|
+
components: {
|
|
122
|
+
securitySchemes: { bearer: { type: "http", scheme: "bearer", description: "A nixamp token (`nixamp token create`) or an OAuth access token." } },
|
|
123
|
+
schemas: { Error: { type: "object", properties: { error: { type: "string" } }, required: ["error"] } },
|
|
124
|
+
},
|
|
125
|
+
paths: {
|
|
126
|
+
"/api/v1/health": { get: route("Is nixamp.com up, and which version", ["meta"]) },
|
|
127
|
+
"/api/directory": { get: route("Every nixamp server that is broadcasting", ["stations"]) },
|
|
128
|
+
"/api/v1/stations": { get: route("Public stations", ["stations"]) },
|
|
129
|
+
"/api/v1/guide/rooms": { get: route("Live rooms on the guide", ["stations"]) },
|
|
130
|
+
"/api/v1/guide/vods": { get: route("Films and shows on demand", ["stations"]) },
|
|
131
|
+
"/api/v1/opendirs": { get: route("Open directories nixamp has indexed", ["stations"]) },
|
|
132
|
+
"/api/v1/music": { get: route("The AI label's catalogue: genre stations, artists, albums", ["music"]) },
|
|
133
|
+
"/api/v1/auth/providers": { get: route("How you can sign in", ["auth"]) },
|
|
134
|
+
"/api/v1/auth/device/code": { post: route("Start a device sign-in (CLI or agent); a person approves it in a browser", ["auth"]) },
|
|
135
|
+
"/api/v1/auth/device/token": { post: route("Collect the token once the device sign-in is approved", ["auth"]) },
|
|
136
|
+
"/api/v1/auth/me": { get: route("Who the bearer is", ["auth"], true) },
|
|
137
|
+
"/api/v1/auth/tokens": { get: route("Your tokens", ["auth"], true), post: route("Make a token", ["auth"], true) },
|
|
138
|
+
"/api/v1/follows": { get: route("Who you follow", ["social"], true) },
|
|
139
|
+
"/api/v1/favorites": { get: route("Your favourite stations", ["social"], true) },
|
|
140
|
+
"/api/v1/servers": { get: route("Your nixamp servers", ["servers"], true) },
|
|
141
|
+
"/mcp/public": { post: route("MCP (streamable HTTP, JSON-RPC): the public station tools", ["mcp"]) },
|
|
142
|
+
"/mcp": { post: route("MCP (streamable HTTP, JSON-RPC): every tool, as the bearer", ["mcp"], true) },
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const escape = (text: string): string => text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]!);
|
|
148
|
+
|
|
149
|
+
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}";
|
|
150
|
+
|
|
151
|
+
export function downloadPage(site: string, release: Release | null): string {
|
|
152
|
+
const tag = release?.tag_name ? ` ${escape(release.tag_name)}` : "";
|
|
153
|
+
const items = DESKTOP_TARGETS.map((one) => `<li><a href="/download/${one.target}">${escape(one.label)}</a></li>`).join("");
|
|
154
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
155
|
+
<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.">
|
|
156
|
+
<link rel="canonical" href="${escape(site)}/download"><link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
|
|
157
|
+
<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>
|
|
158
|
+
<ul>${items}</ul>
|
|
159
|
+
<p>Or in a terminal: <code>curl -fsSL ${escape(site)}/install.sh | sh</code></p>
|
|
160
|
+
<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>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface DiscoveryContext {
|
|
164
|
+
site: string;
|
|
165
|
+
version: string;
|
|
166
|
+
release: () => Promise<Release | null>;
|
|
167
|
+
json: (response: ServerResponse, code: number, body: unknown) => void;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function send(response: ServerResponse, code: number, type: string, body: string, method: string, extra: Record<string, string> = {}): void {
|
|
171
|
+
response.writeHead(code, { "content-type": type, "content-length": Buffer.byteLength(body), "cache-control": "public, max-age=300", "access-control-allow-origin": "*", ...extra });
|
|
172
|
+
response.end(method === "HEAD" ? undefined : body);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Answers a discovery path; false when `path` is not one, so the caller carries on. */
|
|
176
|
+
export async function handleDiscovery(request: IncomingMessage, response: ServerResponse, path: string, ctx: DiscoveryContext): Promise<boolean> {
|
|
177
|
+
if (!isDiscoveryPath(path)) return false;
|
|
178
|
+
const method = request.method ?? "GET";
|
|
179
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
180
|
+
response.writeHead(405, { allow: "GET, HEAD" });
|
|
181
|
+
response.end();
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
const site = ctx.site.replace(/\/+$/, "");
|
|
185
|
+
if (path === "/api/v1/health") {
|
|
186
|
+
ctx.json(response, 200, { ok: true, name: "nixamp", version: ctx.version });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (path === "/llms.txt") {
|
|
190
|
+
send(response, 200, "text/plain; charset=utf-8", llmsTxt(site), method);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
if (path === "/openapi.json") {
|
|
194
|
+
send(response, 200, "application/json; charset=utf-8", JSON.stringify(openapiDocument(site, ctx.version), null, 2), method);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (path === "/download") {
|
|
198
|
+
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'` });
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
const target = path.slice("/download/".length);
|
|
202
|
+
if (!DESKTOP_TARGETS.some((one) => one.target === target)) {
|
|
203
|
+
ctx.json(response, 404, { error: `no such download; one of ${DESKTOP_TARGETS.map((one) => one.target).join(", ")}` });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
// Short-lived: the next release moves it.
|
|
207
|
+
response.writeHead(302, { location: desktopAsset(await ctx.release(), target) ?? `${RELEASES}/latest`, "cache-control": "public, max-age=300" });
|
|
208
|
+
response.end();
|
|
209
|
+
return true;
|
|
210
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -152,6 +152,7 @@ import {
|
|
|
152
152
|
} from "./transcripts.ts";
|
|
153
153
|
import { handleMessage as mcpMessage } from "./mcp.ts";
|
|
154
154
|
import { PUBLIC_TOOLS, callPublicTool, isPublicTool } from "./directory-mcp.ts";
|
|
155
|
+
import { handleDiscovery, isDiscoveryPath, latestRelease } from "./discovery.ts";
|
|
155
156
|
import { Media, factsFromRequest, holderFrom, mediaId, openFileListing, openFileOf, type MediaRecord } from "./media.ts";
|
|
156
157
|
import { mediaPage, type PageTranscript } from "./media-page.ts";
|
|
157
158
|
import { describeFile, refreshChanged } from "./media-local.ts";
|
|
@@ -350,7 +351,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
350
351
|
tlsCert: process.env["NIXAMP_TLS_CERT"] ?? "",
|
|
351
352
|
tlsKey: process.env["NIXAMP_TLS_KEY"] ?? "",
|
|
352
353
|
x402: false,
|
|
353
|
-
|
|
354
|
+
// The account that administers this server, for one with no key but accounts (nixamp.com).
|
|
355
|
+
owner: process.env["NIXAMP_OWNER"] ?? "",
|
|
354
356
|
ingest: false,
|
|
355
357
|
rtmpIn: 0,
|
|
356
358
|
rtmpStreams: 3,
|
|
@@ -2030,6 +2032,12 @@ export interface HandlerOptions {
|
|
|
2030
2032
|
export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
2031
2033
|
// One per server, so the counters survive between requests and die with it.
|
|
2032
2034
|
const guard = new Guard();
|
|
2035
|
+
// --no-key opens a server to whoever reaches it, unless it has an owner: then
|
|
2036
|
+
// anyone may listen and only the owner (or the control key) administers.
|
|
2037
|
+
// nixamp.com is that server; without this every visitor was its admin.
|
|
2038
|
+
const openToAll = (key: string | null): boolean => key === null && !options.owner?.claimed;
|
|
2039
|
+
// nixamp.com's /download asks GitHub which release is latest; one answer per ten minutes.
|
|
2040
|
+
const release = latestRelease();
|
|
2033
2041
|
|
|
2034
2042
|
const tracker = options.connections ?? new Connections();
|
|
2035
2043
|
const started = Date.now();
|
|
@@ -2187,7 +2195,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2187
2195
|
cors: CORS,
|
|
2188
2196
|
watch: (incoming, outgoing, track) => watch(incoming, outgoing, "stream", track),
|
|
2189
2197
|
controls: async () => {
|
|
2190
|
-
if (key
|
|
2198
|
+
if (openToAll(key)) return true;
|
|
2191
2199
|
if (scopeOf(keyFrom(request, url), key, null) === "control") return true;
|
|
2192
2200
|
if (options.owner) return (await options.owner.check(false, tokenFrom(request.headers))).allowed;
|
|
2193
2201
|
return false;
|
|
@@ -2873,6 +2881,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2873
2881
|
!(/^\/radio\/(artist|album)\//.test(path) && Boolean(options.music)) &&
|
|
2874
2882
|
// What is on, as MCP, for anybody.
|
|
2875
2883
|
path !== "/mcp/public" &&
|
|
2884
|
+
// llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
|
|
2885
|
+
!(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
|
|
2876
2886
|
// Supporting a copyright holder is for anybody listening.
|
|
2877
2887
|
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2878
2888
|
// A station's short link, which sends the browser to wherever it plays.
|
|
@@ -2935,7 +2945,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2935
2945
|
// or the owner's nixamp.com session. Asked by the routes that do not go
|
|
2936
2946
|
// through the admin gate further down.
|
|
2937
2947
|
const controlOf = async (): Promise<{ allowed: boolean; account: string }> => {
|
|
2938
|
-
if (key
|
|
2948
|
+
if (openToAll(key)) return { allowed: true, account: "" };
|
|
2939
2949
|
if (scopeOf(keyFrom(request, url), key, listenKey) === "control") return { allowed: true, account: "" };
|
|
2940
2950
|
if (options.owner) {
|
|
2941
2951
|
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
@@ -3556,6 +3566,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3556
3566
|
return;
|
|
3557
3567
|
}
|
|
3558
3568
|
|
|
3569
|
+
// Every way into nixamp, for agents and stores: llms.txt, OpenAPI, health, desktop downloads (discovery.ts).
|
|
3570
|
+
if (options.accounts && options.site && await handleDiscovery(request, response, path, { site: options.site, version: options.version, release, json })) return;
|
|
3571
|
+
|
|
3559
3572
|
if (path === "/reset-password" && options.accounts?.passwordResets) {
|
|
3560
3573
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
3561
3574
|
json(response, 405, { error: "GET only" }); return;
|
|
@@ -5231,11 +5244,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
5231
5244
|
eventChannelRequest,
|
|
5232
5245
|
);
|
|
5233
5246
|
if (options.owner && needsAdmin(path, request.method ?? "GET") && !eventMediaOperation) {
|
|
5234
|
-
// A server started with --no-key has said that anyone who
|
|
5235
|
-
// port may drive it, and prints exactly that.
|
|
5236
|
-
//
|
|
5247
|
+
// A server started with --no-key and no owner has said that anyone who
|
|
5248
|
+
// can reach the port may drive it, and prints exactly that. With an owner
|
|
5249
|
+
// (nixamp.com), --no-key only means anyone may listen: the owner drives.
|
|
5237
5250
|
const holdsControl =
|
|
5238
|
-
key
|
|
5251
|
+
openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
5239
5252
|
// A session in the query as well as in a header or a cookie: from
|
|
5240
5253
|
// nixamp.com's page a server is another origin, and the cookie stays
|
|
5241
5254
|
// home, so the session travels the way the key does.
|
|
@@ -5393,7 +5406,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
5393
5406
|
if (path === "/api/catalogs" && request.method === "GET") {
|
|
5394
5407
|
// Where a list is read from is the administrator's business, not a
|
|
5395
5408
|
// listener's: it can carry a provider's credentials in the URL.
|
|
5396
|
-
const holdsControl = key
|
|
5409
|
+
const holdsControl = openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
5397
5410
|
const admin = options.owner
|
|
5398
5411
|
? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
|
|
5399
5412
|
: holdsControl;
|
|
@@ -7728,6 +7741,21 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7728
7741
|
if (!answer.ok) throw new Error(((await answer.json().catch(() => ({}))) as { error?: string }).error ?? `nixamp.com answered ${answer.status}`);
|
|
7729
7742
|
return Buffer.from(await answer.arrayBuffer());
|
|
7730
7743
|
},
|
|
7744
|
+
// A singer's close-up redrawn to the song's own seconds, by nixamp.com (fal.ai), under the same cap.
|
|
7745
|
+
lipsync: async (input) => {
|
|
7746
|
+
const session = readSession();
|
|
7747
|
+
const here = stations.publicBase?.() ?? "";
|
|
7748
|
+
if (!session?.token || !/^https:\/\//.test(here)) throw new VeoUnavailable("this server is not signed in to nixamp.com");
|
|
7749
|
+
const answer = await fetch(`${session.site.replace(/\/+$/, "")}/api/v1/music/veo`, {
|
|
7750
|
+
method: "POST",
|
|
7751
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session.token}`, "x-nixamp-server": here },
|
|
7752
|
+
body: JSON.stringify({ lipsync: true, seconds: input.seconds, video: input.video.toString("base64"), audio: input.audio.toString("base64") }),
|
|
7753
|
+
signal: AbortSignal.timeout(15 * 60_000),
|
|
7754
|
+
});
|
|
7755
|
+
if (answer.status === 402) throw new VeoUnavailable(((await answer.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${answer.status}`);
|
|
7756
|
+
if (!answer.ok) throw new Error(((await answer.json().catch(() => ({}))) as { error?: string }).error ?? `nixamp.com answered ${answer.status}`);
|
|
7757
|
+
return Buffer.from(await answer.arrayBuffer());
|
|
7758
|
+
},
|
|
7731
7759
|
// A release is told to nixamp.com as this server's owner; nixamp.com tells the artist's followers.
|
|
7732
7760
|
announce: (release) => {
|
|
7733
7761
|
const session = readSession();
|
|
@@ -8029,6 +8057,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8029
8057
|
ledger: pool ? pgLedger(pool as unknown as import("./veo-studio.ts").Queryable) : fileLedger(VeoStudio.fileFor(stateDir(), options.port)),
|
|
8030
8058
|
monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
|
|
8031
8059
|
usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
|
|
8060
|
+
...(process.env["FAL_KEY"] ? { falKey: process.env["FAL_KEY"] } : {}),
|
|
8061
|
+
lipsyncUsdPerSecond: Number(process.env["NIXAMP_LIPSYNC_USD_PER_SECOND"] ?? 0.1),
|
|
8032
8062
|
}) : undefined;
|
|
8033
8063
|
/** The signed-in account owns a server listed in the directory at this base. */
|
|
8034
8064
|
const ownsListed = async (request: IncomingMessage, base: string): Promise<boolean> => {
|
|
@@ -8045,8 +8075,21 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8045
8075
|
if (!(await ownsListed(request, base))) return { status: 403, body: { error: "only the owner of a listed server" } };
|
|
8046
8076
|
if (request.method === "GET") return { status: 200, body: await veoStudio.status() };
|
|
8047
8077
|
if (request.method !== "POST") return { status: 405, body: { error: "GET or POST" } };
|
|
8048
|
-
let input: { prompt?: unknown; seconds?: unknown; references?: unknown };
|
|
8049
|
-
|
|
8078
|
+
let input: { prompt?: unknown; seconds?: unknown; references?: unknown; lipsync?: unknown; video?: unknown; audio?: unknown };
|
|
8079
|
+
// A lip sync carries an 8 s 1080p shot and its seconds of the song: up to 48 MB as base64.
|
|
8080
|
+
try { input = JSON.parse(await readBody(request, 48 * 1024 * 1024)) as typeof input; } catch { return { status: 400, body: { error: "a JSON body under 48 MB" } }; }
|
|
8081
|
+
if (input.lipsync === true) {
|
|
8082
|
+
if (!veoStudio.canLipsync) return { status: 404, body: { error: "lip sync is not set up here" } };
|
|
8083
|
+
const video = Buffer.from(String(input.video ?? ""), "base64");
|
|
8084
|
+
const audio = Buffer.from(String(input.audio ?? ""), "base64");
|
|
8085
|
+
if (video.length < 10_000 || audio.length < 1_000) return { status: 400, body: { error: "a shot and its audio" } };
|
|
8086
|
+
try {
|
|
8087
|
+
return { status: 200, body: null, video: await veoStudio.lipsync({ video, audio, seconds: Number(input.seconds ?? 8) }) };
|
|
8088
|
+
} catch (error) {
|
|
8089
|
+
if (error instanceof VeoBudgetError) return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
8090
|
+
return { status: 422, body: { error: (error as Error).message, code: "refused" } };
|
|
8091
|
+
}
|
|
8092
|
+
}
|
|
8050
8093
|
const prompt = String(input.prompt ?? "").trim();
|
|
8051
8094
|
if (prompt.length < 20) return { status: 400, body: { error: "describe the shot" } };
|
|
8052
8095
|
const references = (Array.isArray(input.references) ? input.references : []).slice(0, 3).map((one) => Buffer.from(String(one), "base64")).filter((one) => one.length > 1000);
|
|
@@ -8884,7 +8927,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8884
8927
|
}
|
|
8885
8928
|
|
|
8886
8929
|
console.log("");
|
|
8887
|
-
if (key === null) {
|
|
8930
|
+
if (key === null && owner.claimed) {
|
|
8931
|
+
console.log(" No key: anyone who can reach this port can hear it; only its owner can drive it.");
|
|
8932
|
+
} else if (key === null) {
|
|
8888
8933
|
console.log(" No key: anyone who can reach this port can drive it and hear it.");
|
|
8889
8934
|
} else {
|
|
8890
8935
|
console.log(" Open that link once on a phone or a laptop and it stays signed in.");
|
package/src/station-cli.ts
CHANGED
|
@@ -506,6 +506,7 @@ async function labelCommand(argv: string[], call: Call, show: Show): Promise<num
|
|
|
506
506
|
resetDay: number("--reset-day"),
|
|
507
507
|
veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
|
|
508
508
|
veoShots: number("--veo-shots"),
|
|
509
|
+
lipsyncShots: number("--lipsync-shots"),
|
|
509
510
|
genreOrder: flag(argv, "--genres"),
|
|
510
511
|
enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
|
|
511
512
|
publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
|