nixamp 0.59.4 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/ai-label.d.ts +37 -0
  2. package/dist/ai-label.js +127 -10
  3. package/dist/music-hub.d.ts +6 -0
  4. package/dist/music-hub.js +15 -1
  5. package/dist/server.d.ts +5 -0
  6. package/dist/server.js +64 -2
  7. package/dist/station-cli.js +4 -1
  8. package/dist/station-mcp.d.ts +30 -0
  9. package/dist/station-mcp.js +3 -1
  10. package/dist/station-usage.d.ts +1 -1
  11. package/dist/station-usage.js +1 -0
  12. package/dist/veo-studio.d.ts +39 -0
  13. package/dist/veo-studio.js +110 -0
  14. package/package.json +1 -1
  15. package/src/ai-label.ts +128 -12
  16. package/src/music-hub.ts +12 -1
  17. package/src/server.ts +50 -3
  18. package/src/station-cli.ts +4 -1
  19. package/src/station-mcp.ts +3 -1
  20. package/src/station-usage.ts +1 -0
  21. package/src/veo-studio.ts +123 -0
  22. package/web/dist/assets/{ar-BO4rWQ9P.js → ar-jc2nfbwU.js} +1 -1
  23. package/web/dist/assets/{cs-BbzHmyap.js → cs-CsQm5s-8.js} +1 -1
  24. package/web/dist/assets/{da-BycUJKjY.js → da-DHCYXrSL.js} +1 -1
  25. package/web/dist/assets/{de-CsO18_9e.js → de-IO9rajjn.js} +1 -1
  26. package/web/dist/assets/{es-DU675bF4.js → es-CIkJTUJE.js} +1 -1
  27. package/web/dist/assets/{fi-CblqzoKx.js → fi-JklvgzxQ.js} +1 -1
  28. package/web/dist/assets/{fr-YYXvYy_4.js → fr-CUk3yUOe.js} +1 -1
  29. package/web/dist/assets/{hi-CkCMt25E.js → hi-CEdvI1Y5.js} +1 -1
  30. package/web/dist/assets/{hu-BHSVpBm0.js → hu-B_oRzY-F.js} +1 -1
  31. package/web/dist/assets/{id-BjFgJnnl.js → id-D5Wx0nsW.js} +1 -1
  32. package/web/dist/assets/{it-HsdMsXQn.js → it-DP4ncIUW.js} +1 -1
  33. package/web/dist/assets/{main-DYyAppfI.js → main-D1zLWorp.js} +2 -2
  34. package/web/dist/assets/{nl-D_p5aMmJ.js → nl-BSvol-ht.js} +1 -1
  35. package/web/dist/assets/{ru-BgvTz-66.js → ru-35Ln-kD-.js} +1 -1
  36. package/web/dist/assets/{sv-BwkC3xqh.js → sv-D1DNLvXv.js} +1 -1
  37. package/web/dist/assets/{uk-Bhfl4oKh.js → uk-B0fTytVz.js} +1 -1
  38. package/web/dist/assets/{vi-CCYFXsEm.js → vi-6o60v8Th.js} +1 -1
  39. package/web/dist/assets/{zh-D8lP7hhM.js → zh-CbbDoNoL.js} +1 -1
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/sw.js +19 -19
@@ -88,6 +88,10 @@ export interface LabelSettings {
88
88
  songSeconds: number;
89
89
  /** A music video for every song (the cover over itself, blurred, and the song's waveform), and a TV station of them. */
90
90
  videos: boolean;
91
+ /** Each album's single (song 1) gets filmed Veo shots of the band, while the month's Veo budget lasts. */
92
+ veo: boolean;
93
+ /** How many 8 s Veo shots a single gets. */
94
+ veoShots: number;
91
95
  /** Public, listed and owned, so the genre stations are on the guide. */
92
96
  publish: boolean;
93
97
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -160,6 +164,15 @@ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLin
160
164
  export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
161
165
  cast?: string;
162
166
  }, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
167
+ /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
168
+ export declare class VeoUnavailable extends Error {
169
+ }
170
+ /**
171
+ * The prompts for a single's filmed shots. The people are the reference
172
+ * images, described by role and look only: Veo refuses names with faces
173
+ * ("we can't create videos with real people's names or likenesses").
174
+ */
175
+ export declare function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[];
163
176
  /**
164
177
  * A song's music video from its storyboard: each scene's picture moves (a
165
178
  * slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
@@ -173,6 +186,10 @@ export declare function renderStoryVideo(ffmpeg: string[], input: {
173
186
  artist: string;
174
187
  title: string;
175
188
  out: string;
189
+ clips?: Array<{
190
+ path: string;
191
+ seconds: number;
192
+ }>;
176
193
  }): Promise<void>;
177
194
  /**
178
195
  * A song's music video without a storyboard: its album cover over a blurred
@@ -210,6 +227,12 @@ export interface AiLabelDeps {
210
227
  video?: (audio: string, cover: string, out: string) => Promise<void>;
211
228
  /** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
212
229
  align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
230
+ /** A filmed shot (Veo, through nixamp.com): the MP4, or a VeoUnavailable when the month's budget is spent. */
231
+ veo?: ((input: {
232
+ prompt: string;
233
+ seconds: number;
234
+ references: Buffer[];
235
+ }) => Promise<Buffer>) | null;
213
236
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
214
237
  storyVideo?: (input: {
215
238
  audio: string;
@@ -218,6 +241,10 @@ export interface AiLabelDeps {
218
241
  artist: string;
219
242
  title: string;
220
243
  out: string;
244
+ clips?: Array<{
245
+ path: string;
246
+ seconds: number;
247
+ }>;
221
248
  }) => Promise<void>;
222
249
  /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
223
250
  image?: (prompt: string) => Promise<Buffer | null>;
@@ -250,6 +277,8 @@ export declare class AiLabel {
250
277
  private videoFailed;
251
278
  /** Songs whose words were already sent to the aligner since this server started. */
252
279
  private alignTried;
280
+ /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
281
+ private veoSpentIn;
253
282
  /** Old videos already tried as storyboards since this server started. */
254
283
  private upgradeTried;
255
284
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -308,6 +337,14 @@ export declare class AiLabel {
308
337
  * image model can draw it (version 2), else the cover-and-waveform kind (1).
309
338
  */
310
339
  private filmSong;
340
+ /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
341
+ private wantsVeo;
342
+ /**
343
+ * A single's filmed shots: the band, from their portraits (up to three as
344
+ * Veo's reference images), on stage, in the song's mood. Stops for the
345
+ * month when the budget is spent; a shot Veo will not make is skipped.
346
+ */
347
+ private filmShots;
311
348
  /** The label's music videos as one TV station: free to watch, with its breaks. */
312
349
  private placeVideos;
313
350
  stopSchedule(): void;
package/dist/ai-label.js CHANGED
@@ -272,6 +272,8 @@ export const DEFAULTS = {
272
272
  songSeconds: 150,
273
273
  publish: true,
274
274
  videos: true,
275
+ veo: false,
276
+ veoShots: 4,
275
277
  parallel: 1,
276
278
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
277
279
  };
@@ -486,6 +488,33 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
486
488
  throw new Error("the model wrote no storyboard");
487
489
  return prompts;
488
490
  }
491
+ /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
492
+ export class VeoUnavailable extends Error {
493
+ }
494
+ const SHOTS = [
495
+ "Wide shot of the whole band performing on a stage, the crowd in the foreground",
496
+ "Close-up of the lead singer singing the chorus into the microphone",
497
+ "The camera moves across the stage between the band members as they play hard",
498
+ "Low-angle shot of the band at the song's climax, lights flaring behind them",
499
+ "Over-the-shoulder shot from behind the drums, looking out at the band and the crowd",
500
+ "Slow push-in on the band in a quieter moment, moody light",
501
+ "Handheld shot from the crowd, fans with hands up, the band lit on stage",
502
+ "The band walking out on stage before the show, backlit silhouettes turning into faces",
503
+ ];
504
+ /**
505
+ * The prompts for a single's filmed shots. The people are the reference
506
+ * images, described by role and look only: Veo refuses names with faces
507
+ * ("we can't create videos with real people's names or likenesses").
508
+ */
509
+ export function veoPrompts(artist, song, members, count) {
510
+ 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("; ");
511
+ return SHOTS.slice(0, count).map((shot) => [
512
+ `Music video for a fictional ${artist.genre} band. ${shot}.`,
513
+ cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
514
+ `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
515
+ "Cinematic, 35mm film look, dynamic camera. No text, no logos.",
516
+ ].filter(Boolean).join(" "));
517
+ }
489
518
  /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
490
519
  function titleFont() {
491
520
  for (const file of ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"]) {
@@ -518,8 +547,22 @@ function runLow(ffmpeg, args, timeout, what) {
518
547
  */
519
548
  export function renderStoryVideo(ffmpeg, input) {
520
549
  const fade = 1;
521
- const count = input.images.length;
522
- const each = input.seconds / count + fade;
550
+ const clips = input.clips ?? [];
551
+ // Stills fill what the filmed shots do not; each gets at least 3 s, so on a short song some are left out.
552
+ let images = [...input.images];
553
+ const clipTime = clips.reduce((sum, one) => sum + one.seconds, 0);
554
+ const stillFor = (count) => (input.seconds + (count + clips.length - 1) * fade - clipTime) / Math.max(1, count);
555
+ while (images.length > 0 && stillFor(images.length) < 3)
556
+ images = images.slice(0, -1);
557
+ const each = images.length ? stillFor(images.length) : 0;
558
+ // A still, then a filmed shot, in turn; whatever is left of either at the end.
559
+ const order = [];
560
+ for (let i = 0; i < Math.max(images.length, clips.length); i++) {
561
+ if (images[i])
562
+ order.push({ kind: "image", path: images[i] });
563
+ if (clips[i])
564
+ order.push({ kind: "clip", ...clips[i] });
565
+ }
523
566
  const frames = Math.max(25, Math.round(each * 25));
524
567
  const moves = [
525
568
  `z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
@@ -528,12 +571,26 @@ export function renderStoryVideo(ffmpeg, input) {
528
571
  `z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
529
572
  ];
530
573
  const parts = [];
531
- input.images.forEach((_, i) => {
532
- parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[i % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
574
+ const lengths = [];
575
+ let still = 0;
576
+ order.forEach((item, i) => {
577
+ if (item.kind === "image") {
578
+ parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[still++ % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
579
+ lengths.push(each);
580
+ }
581
+ else {
582
+ // A filmed shot plays as it was filmed; the last one holds its final frame if the song runs on.
583
+ const hold = i === order.length - 1 ? `,tpad=stop_mode=clone:stop_duration=${Math.max(0, input.seconds).toFixed(2)}` : "";
584
+ parts.push(`[${i}:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,fps=25,setsar=1,trim=duration=${item.seconds},setpts=PTS-STARTPTS${hold}[v${i}]`);
585
+ lengths.push(item.seconds);
586
+ }
533
587
  });
588
+ const count = order.length;
534
589
  let last = "v0";
590
+ let at = 0;
535
591
  for (let i = 1; i < count; i++) {
536
- parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${(i * (each - fade)).toFixed(2)}[x${i}]`);
592
+ at += lengths[i - 1] - fade;
593
+ parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${at.toFixed(2)}[x${i}]`);
537
594
  last = `x${i}`;
538
595
  }
539
596
  const font = titleFont();
@@ -542,7 +599,7 @@ export function renderStoryVideo(ffmpeg, input) {
542
599
  ? `[${last}]drawtext=fontfile=${font}:text='${card}':fontcolor=white:fontsize=44:x=60:y=h-110:enable='between(t,0.5,6)':alpha='if(lt(t,1.5),t-0.5,if(gt(t,5),6-t,1))':shadowcolor=black@0.7:shadowx=2:shadowy=2,format=yuv420p[v]`
543
600
  : `[${last}]format=yuv420p[v]`);
544
601
  const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
545
- ...input.images.flatMap((image) => ["-loop", "1", "-t", each.toFixed(2), "-i", image]),
602
+ ...order.flatMap((item) => (item.kind === "image" ? ["-loop", "1", "-t", each.toFixed(2), "-i", item.path] : ["-i", item.path])),
546
603
  "-i", input.audio, "-filter_complex", parts.join(";"),
547
604
  "-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
548
605
  "-movflags", "+faststart+use_metadata_tags", input.out];
@@ -632,6 +689,8 @@ export class AiLabel {
632
689
  videoFailed = new Set();
633
690
  /** Songs whose words were already sent to the aligner since this server started. */
634
691
  alignTried = new Set();
692
+ /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
693
+ veoSpentIn = "";
635
694
  /** Old videos already tried as storyboards since this server started. */
636
695
  upgradeTried = new Set();
637
696
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -782,6 +841,11 @@ export class AiLabel {
782
841
  this.settings.publish = input["publish"] !== false;
783
842
  if (input["videos"] !== undefined)
784
843
  this.settings.videos = input["videos"] !== false;
844
+ if (input["veo"] !== undefined)
845
+ this.settings.veo = input["veo"] === true;
846
+ const shots = whole("veoShots", 1, 8);
847
+ if (shots !== undefined)
848
+ this.settings.veoShots = shots;
785
849
  const perDay = whole("albumsPerDay", 0, 24);
786
850
  if (perDay !== undefined)
787
851
  this.settings.albumsPerDay = perDay;
@@ -973,7 +1037,7 @@ export class AiLabel {
973
1037
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
974
1038
  const missing = songs.filter(({ song }) => !song.video);
975
1039
  const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
976
- const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
1040
+ const old = songs.filter(({ song }) => song.video && song.videoTagged && ((song.videoVersion ?? 1) < 2 || (this.wantsVeo(song) && (song.videoVersion ?? 1) < 3)) && !this.upgradeTried.has(song.file));
977
1041
  let made = 0;
978
1042
  for (const { album, song } of untagged) {
979
1043
  if (!this.settings.videos)
@@ -1062,15 +1126,68 @@ export class AiLabel {
1062
1126
  this.log(`no storyboard for "${song.title}" (${error.message}); the cover video instead`);
1063
1127
  }
1064
1128
  }
1065
- if (images.length >= 2) {
1066
- await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out });
1067
- return 2;
1129
+ // An album's single gets filmed shots of the band too, while the month's Veo budget lasts.
1130
+ const clips = this.wantsVeo(song) ? await this.filmShots(artist, song, scenes) : [];
1131
+ if (images.length >= 2 || clips.length > 0) {
1132
+ if (storyOnly && (song.videoVersion ?? 1) >= 2 && clips.length === 0)
1133
+ throw new Error("no filmed shots this time; the video it has stays");
1134
+ await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out, ...(clips.length ? { clips } : {}) });
1135
+ return clips.length ? 3 : 2;
1068
1136
  }
1069
1137
  if (storyOnly)
1070
1138
  throw new Error("no storyboard could be drawn; the video it has stays");
1071
1139
  await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
1072
1140
  return 1;
1073
1141
  }
1142
+ /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1143
+ wantsVeo(song) {
1144
+ return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
1145
+ }
1146
+ /**
1147
+ * A single's filmed shots: the band, from their portraits (up to three as
1148
+ * Veo's reference images), on stage, in the song's mood. Stops for the
1149
+ * month when the budget is spent; a shot Veo will not make is skipped.
1150
+ */
1151
+ async filmShots(artist, song, dir) {
1152
+ const veo = this.deps.veo;
1153
+ if (!veo)
1154
+ return [];
1155
+ mkdirSync(dir, { recursive: true });
1156
+ const members = this.cast(artist);
1157
+ const references = [];
1158
+ for (const member of members.slice(0, 3)) {
1159
+ const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1160
+ const small = join(dir, `ref-${member.slug}.jpg`);
1161
+ try {
1162
+ // 512 px is plenty for a face and keeps the request small.
1163
+ await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", portrait, "-vf", "scale=512:-2", "-q:v", "4", small], 60_000, "the portrait did not shrink");
1164
+ references.push(readFileSync(small));
1165
+ }
1166
+ catch {
1167
+ // A member without a portrait is described in words only.
1168
+ }
1169
+ }
1170
+ const clips = [];
1171
+ for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1172
+ try {
1173
+ const bytes = await veo({ prompt, seconds: 8, references });
1174
+ const file = join(dir, `shot-${i}.mp4`);
1175
+ writeFileSync(file, bytes);
1176
+ clips.push({ path: file, seconds: 8 });
1177
+ }
1178
+ catch (error) {
1179
+ if (error instanceof VeoUnavailable) {
1180
+ this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
1181
+ this.log(`Veo: ${error.message}; singles get the storyboard until next month`);
1182
+ break;
1183
+ }
1184
+ this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
1185
+ }
1186
+ }
1187
+ if (clips.length)
1188
+ this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1189
+ return clips;
1190
+ }
1074
1191
  /** The label's music videos as one TV station: free to watch, with its breaks. */
1075
1192
  placeVideos() {
1076
1193
  const next = this.placing.then(async () => {
@@ -92,6 +92,12 @@ export interface MusicRoutesContext {
92
92
  status: number;
93
93
  body: unknown;
94
94
  }>;
95
+ /** Veo shots for a label server's videos: the month's spend (GET), a clip (POST); answers bytes or JSON. */
96
+ veo?: (request: IncomingMessage) => Promise<{
97
+ status: number;
98
+ body: unknown;
99
+ video?: Buffer;
100
+ }>;
95
101
  }
96
102
  export declare function handleMusicRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: MusicRoutesContext): Promise<boolean>;
97
103
  export {};
package/dist/music-hub.js CHANGED
@@ -136,7 +136,7 @@ export async function musicPageFor(hub, path) {
136
136
  return "";
137
137
  }
138
138
  export function isMusicPath(path) {
139
- return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released";
139
+ return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
140
140
  }
141
141
  export async function handleMusicRoutes(request, response, path, ctx) {
142
142
  if (!isMusicPath(path))
@@ -159,6 +159,20 @@ export async function handleMusicRoutes(request, response, path, ctx) {
159
159
  ctx.json(response, answer.status, answer.body);
160
160
  return true;
161
161
  }
162
+ if (path === "/api/v1/music/veo") {
163
+ if (!ctx.veo) {
164
+ ctx.json(response, 404, { error: "Veo is not set up here" });
165
+ return true;
166
+ }
167
+ const answer = await ctx.veo(request);
168
+ if (answer.video) {
169
+ response.writeHead(200, { "content-type": "video/mp4", "content-length": String(answer.video.length), "cache-control": "no-store" });
170
+ response.end(answer.video);
171
+ }
172
+ else
173
+ ctx.json(response, answer.status, answer.body);
174
+ return true;
175
+ }
162
176
  if (method !== "GET" && method !== "HEAD") {
163
177
  ctx.json(response, 405, { error: "not like that" });
164
178
  return true;
package/dist/server.d.ts CHANGED
@@ -841,6 +841,11 @@ export interface HandlerOptions {
841
841
  status: number;
842
842
  body: unknown;
843
843
  }>;
844
+ veo?: (request: IncomingMessage) => Promise<{
845
+ status: number;
846
+ body: unknown;
847
+ video?: Buffer;
848
+ }>;
844
849
  };
845
850
  sharedTranslations?: SharedTranslations;
846
851
  /** Translation: texts in another language, by a model here. The same optional library. */
package/dist/server.js CHANGED
@@ -61,10 +61,11 @@ import { ListenerStore } from "./library-user.js";
61
61
  import { Importer } from "./handoff.js";
62
62
  import { handleHandoffs } from "./handoff-routes.js";
63
63
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
- import { AiLabel } from "./ai-label.js";
64
+ import { AiLabel, VeoUnavailable } from "./ai-label.js";
65
65
  import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.js";
66
66
  import { GrantVerifier, MusicStore, PRICES } from "./music-store.js";
67
67
  import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, serverId } from "./music-hub.js";
68
+ import { VeoBudgetError, VeoStudio } from "./veo-studio.js";
68
69
  import { handleStoreRoutes, isStorePath } from "./store-routes.js";
69
70
  import { AiRadio } from "./ai-radio.js";
70
71
  import { ListenerShares } from "./listener-shares.js";
@@ -2888,6 +2889,7 @@ export function createHandler(engine, options) {
2888
2889
  return null;
2889
2890
  } },
2890
2891
  released: music.released,
2892
+ ...(music.veo ? { veo: music.veo } : {}),
2891
2893
  }))
2892
2894
  return;
2893
2895
  }
@@ -7194,6 +7196,24 @@ export async function serve(argv, version = "0.1.0") {
7194
7196
  root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
7195
7197
  owner: options.owner || "",
7196
7198
  mayStart: mayStartStation,
7199
+ // Filmed shots for singles come from nixamp.com's Veo studio, asked as this server's owner, under its monthly cap.
7200
+ veo: async (shot) => {
7201
+ const session = readSession();
7202
+ const here = stations.publicBase?.() ?? "";
7203
+ if (!session?.token || !/^https:\/\//.test(here))
7204
+ throw new VeoUnavailable("this server is not signed in to nixamp.com");
7205
+ const answer = await fetch(`${session.site.replace(/\/+$/, "")}/api/v1/music/veo`, {
7206
+ method: "POST",
7207
+ headers: { "content-type": "application/json", authorization: `Bearer ${session.token}`, "x-nixamp-server": here },
7208
+ body: JSON.stringify({ prompt: shot.prompt, seconds: shot.seconds, references: shot.references.map((one) => one.toString("base64")) }),
7209
+ signal: AbortSignal.timeout(12 * 60_000),
7210
+ });
7211
+ if (answer.status === 402 || answer.status === 404)
7212
+ throw new VeoUnavailable((await answer.json().catch(() => ({}))).error ?? `HTTP ${answer.status}`);
7213
+ if (!answer.ok)
7214
+ throw new Error((await answer.json().catch(() => ({}))).error ?? `nixamp.com answered ${answer.status}`);
7215
+ return Buffer.from(await answer.arrayBuffer());
7216
+ },
7197
7217
  // A release is told to nixamp.com as this server's owner; nixamp.com tells the artist's followers.
7198
7218
  announce: (release) => {
7199
7219
  const session = readSession();
@@ -7479,6 +7499,48 @@ export async function serve(argv, version = "0.1.0") {
7479
7499
  * followers are told. Only the account that owns a listed server may say
7480
7500
  * so, only for an artist that server's catalogue has, once per release.
7481
7501
  */
7502
+ // Veo shots for label music videos, under a monthly dollar cap (veo-studio.ts). nixamp.com only.
7503
+ const veoStudio = options.directory && process.env["NIXAMP_VEO_API_KEY"] ? new VeoStudio({
7504
+ key: process.env["NIXAMP_VEO_API_KEY"],
7505
+ file: VeoStudio.fileFor(stateDir(), options.port),
7506
+ monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
7507
+ usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
7508
+ }) : undefined;
7509
+ /** The signed-in account owns a server listed in the directory at this base. */
7510
+ const ownsListed = async (request, base) => {
7511
+ const me = accounts ? await accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
7512
+ return Boolean(me && base && directory?.list().some((one) => one.ownerId === me.id && peerBase(one.url) === base));
7513
+ };
7514
+ const musicVeo = async (request) => {
7515
+ if (!veoStudio)
7516
+ return { status: 404, body: { error: "Veo is not set up here" } };
7517
+ const base = peerBase(String(request.headers["x-nixamp-server"] ?? ""));
7518
+ if (!(await ownsListed(request, base)))
7519
+ return { status: 403, body: { error: "only the owner of a listed server" } };
7520
+ if (request.method === "GET")
7521
+ return { status: 200, body: veoStudio.status() };
7522
+ if (request.method !== "POST")
7523
+ return { status: 405, body: { error: "GET or POST" } };
7524
+ let input;
7525
+ try {
7526
+ input = JSON.parse(await readBody(request, 2 * 1024 * 1024));
7527
+ }
7528
+ catch {
7529
+ return { status: 400, body: { error: "a JSON body under 2 MB" } };
7530
+ }
7531
+ const prompt = String(input.prompt ?? "").trim();
7532
+ if (prompt.length < 20)
7533
+ return { status: 400, body: { error: "describe the shot" } };
7534
+ const references = (Array.isArray(input.references) ? input.references : []).slice(0, 3).map((one) => Buffer.from(String(one), "base64")).filter((one) => one.length > 1000);
7535
+ try {
7536
+ return { status: 200, body: null, video: await veoStudio.clip({ prompt, seconds: Number(input.seconds ?? 8), references }) };
7537
+ }
7538
+ catch (error) {
7539
+ if (error instanceof VeoBudgetError)
7540
+ return { status: 402, body: { error: error.message, code: "budget", ...veoStudio.status() } };
7541
+ return { status: 422, body: { error: error.message, code: "refused" } };
7542
+ }
7543
+ };
7482
7544
  const announced = new Set();
7483
7545
  const musicReleased = async (request, body) => {
7484
7546
  const me = accounts ? await accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
@@ -8062,7 +8124,7 @@ export async function serve(argv, version = "0.1.0") {
8062
8124
  ...(supportPayments ? { support: supportPayments } : {}),
8063
8125
  ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
8064
8126
  ...(musicStore ? { musicStore } : {}),
8065
- ...(musicHub ? { music: { hub: musicHub, released: musicReleased } } : {}),
8127
+ ...(musicHub ? { music: { hub: musicHub, released: musicReleased, veo: musicVeo } } : {}),
8066
8128
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
8067
8129
  ...(sharedTranslations ? { sharedTranslations } : {}),
8068
8130
  ...(translator ? { translator } : {}),
@@ -449,7 +449,8 @@ async function labelCommand(argv, call, show) {
449
449
  return [
450
450
  `${String(one["name"])} [${String(one["key"])}] ${String(one["genre"])}${one["enabled"] ? "" : " (off)"}`,
451
451
  ...albums.map((album) => ` ${String(album["year"])} ${String(album["title"])} ${String(album["composed"])}/${String(album["songs"])} songs${album["doneAt"] ? "" : " (recording)"}`),
452
- ` next album ${when(Number(one["nextAlbumAt"] ?? -1))}`,
452
+ // On the daily schedule an enabled artist has no date of its own: it is in the daily draw.
453
+ ` next album ${Number(one["nextAlbumAt"] ?? -1) === -1 && one["enabled"] ? "in the daily draw (one album a day across the label)" : when(Number(one["nextAlbumAt"] ?? -1))}`,
453
454
  ...(one["lastError"] ? [` problem ${String(one["lastError"])}`] : []),
454
455
  ].join("\n");
455
456
  };
@@ -537,6 +538,8 @@ async function labelCommand(argv, call, show) {
537
538
  videos: has(argv, "--no-videos") ? false : has(argv, "--videos") ? true : undefined,
538
539
  newArtistChance: number("--new-artists"),
539
540
  resetDay: number("--reset-day"),
541
+ veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
542
+ veoShots: number("--veo-shots"),
540
543
  genreOrder: flag(argv, "--genres"),
541
544
  enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
542
545
  publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
@@ -50,6 +50,8 @@ export declare const STATION_TOOLS: ({
50
50
  albumsPerDay?: undefined;
51
51
  newArtistChance?: undefined;
52
52
  resetDay?: undefined;
53
+ veo?: undefined;
54
+ veoShots?: undefined;
53
55
  genreOrder?: undefined;
54
56
  state?: undefined;
55
57
  reason?: undefined;
@@ -126,6 +128,8 @@ export declare const STATION_TOOLS: ({
126
128
  albumsPerDay?: undefined;
127
129
  newArtistChance?: undefined;
128
130
  resetDay?: undefined;
131
+ veo?: undefined;
132
+ veoShots?: undefined;
129
133
  genreOrder?: undefined;
130
134
  state?: undefined;
131
135
  reason?: undefined;
@@ -225,6 +229,8 @@ export declare const STATION_TOOLS: ({
225
229
  albumsPerDay?: undefined;
226
230
  newArtistChance?: undefined;
227
231
  resetDay?: undefined;
232
+ veo?: undefined;
233
+ veoShots?: undefined;
228
234
  genreOrder?: undefined;
229
235
  state?: undefined;
230
236
  reason?: undefined;
@@ -321,6 +327,8 @@ export declare const STATION_TOOLS: ({
321
327
  albumsPerDay?: undefined;
322
328
  newArtistChance?: undefined;
323
329
  resetDay?: undefined;
330
+ veo?: undefined;
331
+ veoShots?: undefined;
324
332
  genreOrder?: undefined;
325
333
  state?: undefined;
326
334
  reason?: undefined;
@@ -399,6 +407,8 @@ export declare const STATION_TOOLS: ({
399
407
  albumsPerDay?: undefined;
400
408
  newArtistChance?: undefined;
401
409
  resetDay?: undefined;
410
+ veo?: undefined;
411
+ veoShots?: undefined;
402
412
  genreOrder?: undefined;
403
413
  state?: undefined;
404
414
  reason?: undefined;
@@ -484,6 +494,8 @@ export declare const STATION_TOOLS: ({
484
494
  albumsPerDay?: undefined;
485
495
  newArtistChance?: undefined;
486
496
  resetDay?: undefined;
497
+ veo?: undefined;
498
+ veoShots?: undefined;
487
499
  genreOrder?: undefined;
488
500
  state?: undefined;
489
501
  reason?: undefined;
@@ -569,6 +581,8 @@ export declare const STATION_TOOLS: ({
569
581
  albumsPerDay?: undefined;
570
582
  newArtistChance?: undefined;
571
583
  resetDay?: undefined;
584
+ veo?: undefined;
585
+ veoShots?: undefined;
572
586
  genreOrder?: undefined;
573
587
  state?: undefined;
574
588
  reason?: undefined;
@@ -671,6 +685,8 @@ export declare const STATION_TOOLS: ({
671
685
  albumsPerDay?: undefined;
672
686
  newArtistChance?: undefined;
673
687
  resetDay?: undefined;
688
+ veo?: undefined;
689
+ veoShots?: undefined;
674
690
  genreOrder?: undefined;
675
691
  state?: undefined;
676
692
  reason?: undefined;
@@ -793,6 +809,14 @@ export declare const STATION_TOOLS: ({
793
809
  type: string;
794
810
  description: string;
795
811
  };
812
+ veo: {
813
+ type: string;
814
+ description: string;
815
+ };
816
+ veoShots: {
817
+ type: string;
818
+ description: string;
819
+ };
796
820
  genreOrder: {
797
821
  type: string;
798
822
  items: {
@@ -864,6 +888,8 @@ export declare const STATION_TOOLS: ({
864
888
  albumsPerDay?: undefined;
865
889
  newArtistChance?: undefined;
866
890
  resetDay?: undefined;
891
+ veo?: undefined;
892
+ veoShots?: undefined;
867
893
  genreOrder?: undefined;
868
894
  server: {
869
895
  type: "string";
@@ -958,6 +984,8 @@ export declare const STATION_TOOLS: ({
958
984
  albumsPerDay?: undefined;
959
985
  newArtistChance?: undefined;
960
986
  resetDay?: undefined;
987
+ veo?: undefined;
988
+ veoShots?: undefined;
961
989
  genreOrder?: undefined;
962
990
  state?: undefined;
963
991
  reason?: undefined;
@@ -1054,6 +1082,8 @@ export declare const STATION_TOOLS: ({
1054
1082
  albumsPerDay?: undefined;
1055
1083
  newArtistChance?: undefined;
1056
1084
  resetDay?: undefined;
1085
+ veo?: undefined;
1086
+ veoShots?: undefined;
1057
1087
  genreOrder?: undefined;
1058
1088
  state?: undefined;
1059
1089
  reason?: undefined;
@@ -122,6 +122,8 @@ export const STATION_TOOLS = [
122
122
  albumsPerDay: { type: "integer", description: "After the first sweep: new albums a day, by a random artist or a newly invented one." },
123
123
  newArtistChance: { type: "number", description: "0-1: how often the day's album is by a new artist." },
124
124
  resetDay: { type: "integer", description: "Day of the month (1-28) the budget renews: the music provider's billing day." },
125
+ veo: { type: "boolean", description: "Each album's single gets filmed Veo shots of the band (nixamp.com's monthly Veo cap applies)." },
126
+ veoShots: { type: "integer", description: "8 s Veo shots per single (1-8)." },
125
127
  genreOrder: { type: "array", items: STRING, description: "Genres whose artists get albums first, in order." },
126
128
  },
127
129
  },
@@ -241,7 +243,7 @@ export async function callStationTool(name, args, options = {}) {
241
243
  const key = typeof args["key"] === "string" && /^[a-f0-9]{10}$/.test(args["key"]) ? args["key"] : "";
242
244
  const answer = action === "status" ? await call("GET", "/api/v1/stations/label")
243
245
  : action === "sign" ? await call("POST", "/api/v1/stations/label/artists", pick(["preset", "roster", "name", "genre", "sound", "voice", "bio", "themes", "enabled"]))
244
- : action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "enabled", "publish"]))
246
+ : action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "veo", "veoShots", "enabled", "publish"]))
245
247
  : action === "run" ? await call("POST", "/api/v1/stations/label/run", {})
246
248
  : action === "resume" ? await call("POST", "/api/v1/stations/label", { resume: true })
247
249
  : !key ? null
@@ -1,2 +1,2 @@
1
1
  /** `nixamp station` usage, apart so `nixamp help station` does not load the client. */
2
- export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in),\n NIXAMP_MUSIC_MODEL (music_v1|music_v2|music_v2_5), from stdin\n\n AI label (AI artists who release albums on their own; a station per genre; the catalogue at /label):\n nixamp station label artists, albums, this month's seconds, what it is waiting for\n nixamp station label sign --roster the roster: three artists in each of Rock, Hip-Hop, Synthwave, Country,\n Metal and Pop (nixamp station label lists their keys)\n nixamp station label sign (--preset KEY | --name NAME --genre GENRE --sound TEXT [--voice TEXT] [--bio TEXT] [--themes TEXT])\n nixamp station label album KEY plan the artist's next album now and record it\n nixamp station label set KEY [--sound TEXT] [--voice TEXT] [--bio TEXT] [--themes TEXT] [--off|--on]\n nixamp station label drop KEY [--files]\n nixamp station label config [--holder \"Profullstack, Inc.\"] [--budget SECONDS] [--every-days 30] [--seed-albums 1]\n [--songs 8] [--seconds 150] [--on|--off] [--public|--unlisted]\n [--parallel 4] [--genres \"Punk,Blues,Country\"] songs made at once; genres first\n [--per-day 1] [--new-artists 0.25] after the first sweep: albums a day, share by new artists\n [--videos|--no-videos] a music video per song and a TV station of them\n [--reset-day 21] the day the monthly budget renews (the provider's billing day)\n nixamp station label run record whatever is due now\n nixamp station label resume after the provider ran out of credit\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
2
+ export declare const USAGE = "nixamp station \u2014 make a collection into a station\n\n nixamp station list\n nixamp station create --name NAME (--folder PATH | --playlist ID | --items 0,1,2) [--shuffle] [--no-repeat] [--bitrate 128] [--breaks 600 (the default; 0 for none)]\n nixamp station show ID\n nixamp station status ID\n nixamp station start ID\n nixamp station stop ID\n nixamp station skip ID\n nixamp station breaks ID SECONDS a spot between entries every SECONDS (120..86400; --off for none)\n nixamp station retry ID try every left-out file again\n nixamp station publish ID --basis owned|permission|licensed|public-domain [--note TEXT] [--unlisted] [--no-list]\n nixamp station unpublish ID [--private]\n nixamp station link ID [--rotate]\n nixamp station delete ID\n nixamp station guide-host NAME [--remove]\n NAME's front page is the public station guide (e.g. a Moshpit name)\n nixamp station guide-ads [--site ID] [--slot ID] [--format banner_300x250|text_link] [--off]\n crawlproof analytics (project ID) and an ad unit (slot ID) on the guide\n nixamp station guide-peer URL [--remove]\n the guide also lists the public, listed stations of the server at URL\n nixamp station auto [--no-video] [--min 10] [--films 3] [--tv 2] [--breaks 600] [--no-start]\n a station per genre, kept in step with the library (--off stops it)\n\n AI radio (music composed for a station, a few tracks a day):\n nixamp station ai what is composing, this month's seconds, the stations\n nixamp station ai create (--preset lofi|synthwave|ambient|chiptune|punk|jazz | --name NAME --style TEXT)\n [--vocals] [--seconds 120] [--per-day 2] [--unlisted] public on the guide unless --unlisted\n nixamp station ai generate KEY compose one now\n nixamp station ai set KEY [--style TEXT] [--per-day N] [--seconds N] [--off|--on]\n nixamp station ai remove KEY [--files]\n nixamp station ai budget SECONDS seconds of music a month across AI stations (0: no cap)\n nixamp station ai resume after the provider ran out of credit\n nixamp station ai key NAME ELEVENLABS_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY | ANTHROPIC_API_KEY,\n NIXAMP_OPENAI_URL | NIXAMP_OPENAI_MODEL (an OpenAI-compatible stand-in),\n NIXAMP_MUSIC_MODEL (music_v1|music_v2|music_v2_5), from stdin\n\n AI label (AI artists who release albums on their own; a station per genre; the catalogue at /label):\n nixamp station label artists, albums, this month's seconds, what it is waiting for\n nixamp station label sign --roster the roster: three artists in each of Rock, Hip-Hop, Synthwave, Country,\n Metal and Pop (nixamp station label lists their keys)\n nixamp station label sign (--preset KEY | --name NAME --genre GENRE --sound TEXT [--voice TEXT] [--bio TEXT] [--themes TEXT])\n nixamp station label album KEY plan the artist's next album now and record it\n nixamp station label set KEY [--sound TEXT] [--voice TEXT] [--bio TEXT] [--themes TEXT] [--off|--on]\n nixamp station label drop KEY [--files]\n nixamp station label config [--holder \"Profullstack, Inc.\"] [--budget SECONDS] [--every-days 30] [--seed-albums 1]\n [--songs 8] [--seconds 150] [--on|--off] [--public|--unlisted]\n [--parallel 4] [--genres \"Punk,Blues,Country\"] songs made at once; genres first\n [--per-day 1] [--new-artists 0.25] after the first sweep: albums a day, share by new artists\n [--videos|--no-videos] a music video per song and a TV station of them\n [--reset-day 21] the day the monthly budget renews (the provider's billing day)\n [--veo|--no-veo] [--veo-shots 4] filmed Veo shots of the band in each album's single (nixamp.com caps the month)\n nixamp station label run record whatever is due now\n nixamp station label resume after the provider ran out of credit\n\n Listener shares (anybody shares an MP3 link or file from the guide; AI files and checks it):\n nixamp station shares the setting and what is waiting\n nixamp station shares on [--owner-moderation] [--unlisted] [--max-mb 60] [--per-hour 6]\n nixamp station shares off\n nixamp station shares list [--state held|approved|reported|rejected]\n nixamp station shares approve|reject|delete SHARE\n nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For\n\n Lyrics (shown on the station page from each file's own tag, else nichedb.dev; ours published back):\n nixamp station lyrics the key, what was published, which stations may publish\n nixamp station lyrics publish ID publish a station's words now (public, listed, basis owned only)\n nixamp station lyrics key nichedb's key (NIXAMP_LYRICS_KEY), from stdin (--clear forgets it)\n\n Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):\n nixamp station holder list\n nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]\n nixamp station holder remove ID\n\n --json machine-readable output\n --url, --key another server, as with `nixamp admin`\n\nA new station is private and stopped. Starting it does not publish it.";
@@ -50,6 +50,7 @@ export const USAGE = `nixamp station — make a collection into a station
50
50
  [--per-day 1] [--new-artists 0.25] after the first sweep: albums a day, share by new artists
51
51
  [--videos|--no-videos] a music video per song and a TV station of them
52
52
  [--reset-day 21] the day the monthly budget renews (the provider's billing day)
53
+ [--veo|--no-veo] [--veo-shots 4] filmed Veo shots of the band in each album's single (nixamp.com caps the month)
53
54
  nixamp station label run record whatever is due now
54
55
  nixamp station label resume after the provider ran out of credit
55
56