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.
Files changed (44) hide show
  1. package/dist/ai-label.d.ts +38 -4
  2. package/dist/ai-label.js +132 -20
  3. package/dist/discovery.d.ts +48 -0
  4. package/dist/discovery.js +181 -0
  5. package/dist/server.js +64 -11
  6. package/dist/station-cli.js +1 -0
  7. package/dist/station-mcp.d.ts +15 -0
  8. package/dist/station-mcp.js +3 -2
  9. package/dist/veo-studio.d.ts +16 -0
  10. package/dist/veo-studio.js +59 -0
  11. package/package.json +1 -1
  12. package/src/ai-label.ts +124 -23
  13. package/src/discovery.ts +210 -0
  14. package/src/server.ts +56 -11
  15. package/src/station-cli.ts +1 -0
  16. package/src/station-mcp.ts +3 -2
  17. package/src/veo-studio.ts +57 -0
  18. package/web/dist/assets/{ar-BdJErAsT.js → ar-DsLCtn6q.js} +1 -1
  19. package/web/dist/assets/{cs-BCAcbLw4.js → cs-CDebRTvk.js} +1 -1
  20. package/web/dist/assets/{da-B8ZLImmi.js → da-CtIZY0F_.js} +1 -1
  21. package/web/dist/assets/{de-BEfJLxqy.js → de-Ce4XnPH4.js} +1 -1
  22. package/web/dist/assets/{es-CvwtfSaX.js → es-DuTsv2ty.js} +1 -1
  23. package/web/dist/assets/{fi-D8zNiMjO.js → fi-BxzgeUzQ.js} +1 -1
  24. package/web/dist/assets/{fr-C2YZ2L4l.js → fr-1e-Cafh4.js} +1 -1
  25. package/web/dist/assets/{hi-K_VSycz2.js → hi-yKzlszV2.js} +1 -1
  26. package/web/dist/assets/{hu-Dl_GeLxY.js → hu-DEEC9-i5.js} +1 -1
  27. package/web/dist/assets/{id-Ba1NrTEb.js → id-Da47jrbA.js} +1 -1
  28. package/web/dist/assets/{it-ClsnZdJN.js → it-DyEoFcC_.js} +1 -1
  29. package/web/dist/assets/{main-DD5A9Exa.js → main-C_3KEwHZ.js} +2 -2
  30. package/web/dist/assets/{music-LviR4dJP.js → music-Bby75I2_.js} +1 -1
  31. package/web/dist/assets/{nl-BLjok-1D.js → nl-uTGCd9L3.js} +1 -1
  32. package/web/dist/assets/{ru-BvhOKYAB.js → ru-BJ7gUalz.js} +1 -1
  33. package/web/dist/assets/{sv-BItd8vZI.js → sv-C9ZiAm2Q.js} +1 -1
  34. package/web/dist/assets/{uk-BcUnWToF.js → uk-DIcSDuMA.js} +1 -1
  35. package/web/dist/assets/{vi-LFZ3qSPF.js → vi-4MI2s3Z9.js} +1 -1
  36. package/web/dist/assets/{zh-DfCRV3mt.js → zh-DI1PPrRY.js} +1 -1
  37. package/web/dist/icons/music/apple-touch-icon.png +0 -0
  38. package/web/dist/icons/music/icon-192.png +0 -0
  39. package/web/dist/icons/music/icon-384.png +0 -0
  40. package/web/dist/icons/music/icon-512.png +0 -0
  41. package/web/dist/index.html +1 -1
  42. package/web/dist/music.html +3 -1
  43. package/web/dist/music.webmanifest +57 -0
  44. 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
- return SHOTS.slice(0, count).map((shot) => [
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
- "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.",
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
- * The version of a video cut from Veo footage with nobody singing on camera; anything
740
- * older (a slideshow, or 4: footage whose singers were out of sync) is not shown.
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; i++) {
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
- const start = Math.min(half * cut, Math.max(0, clip.seconds - cut));
758
- const length = Math.min(cut, clip.seconds - start, input.seconds - at + 0.5);
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
- await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out });
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 clips: Array<{ path: string; seconds: number }> = [];
1330
- for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1331
- const file = join(keep, `shot-${i}.mp4`);
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) { clips.push({ path: file, seconds: 8 }); continue; }
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
- clips.push({ path: file, seconds: 8 });
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 (clips.length) this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1358
- return clips;
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. */
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
- owner: "",
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 === null) return true;
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 === null) return { allowed: true, account: "" };
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 can reach the
5235
- // port may drive it, and prints exactly that. Locking administration to
5236
- // nobody would contradict it and leave such a server unadministrable.
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 === null || scopeOf(keyFrom(request, url), key, null) === "control";
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 === null || scopeOf(keyFrom(request, url), key, null) === "control";
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
- try { input = JSON.parse(await readBody(request, 2 * 1024 * 1024)) as typeof input; } catch { return { status: 400, body: { error: "a JSON body under 2 MB" } }; }
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.");
@@ -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,