nixamp 0.59.5 → 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 +2 -0
  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 +2 -0
  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-D7L1Lzg5.js → ar-jc2nfbwU.js} +1 -1
  23. package/web/dist/assets/{cs-DR-oumcX.js → cs-CsQm5s-8.js} +1 -1
  24. package/web/dist/assets/{da-B7ofIVod.js → da-DHCYXrSL.js} +1 -1
  25. package/web/dist/assets/{de-CglXPk_n.js → de-IO9rajjn.js} +1 -1
  26. package/web/dist/assets/{es-CYVNiI20.js → es-CIkJTUJE.js} +1 -1
  27. package/web/dist/assets/{fi-Bktga6d_.js → fi-JklvgzxQ.js} +1 -1
  28. package/web/dist/assets/{fr-CNxMDHg5.js → fr-CUk3yUOe.js} +1 -1
  29. package/web/dist/assets/{hi-DEyWHgib.js → hi-CEdvI1Y5.js} +1 -1
  30. package/web/dist/assets/{hu-mcHUOvhY.js → hu-B_oRzY-F.js} +1 -1
  31. package/web/dist/assets/{id-T9wfahs5.js → id-D5Wx0nsW.js} +1 -1
  32. package/web/dist/assets/{it-CASLaiyC.js → it-DP4ncIUW.js} +1 -1
  33. package/web/dist/assets/{main-D9-BuE2r.js → main-D1zLWorp.js} +2 -2
  34. package/web/dist/assets/{nl-DQ-YPUBS.js → nl-BSvol-ht.js} +1 -1
  35. package/web/dist/assets/{ru-CDSDPqFZ.js → ru-35Ln-kD-.js} +1 -1
  36. package/web/dist/assets/{sv-liuu51-p.js → sv-D1DNLvXv.js} +1 -1
  37. package/web/dist/assets/{uk-B3vreqbA.js → uk-B0fTytVz.js} +1 -1
  38. package/web/dist/assets/{vi-B0NyoUEc.js → vi-6o60v8Th.js} +1 -1
  39. package/web/dist/assets/{zh-Bw5OQASg.js → zh-CbbDoNoL.js} +1 -1
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/sw.js +19 -19
@@ -0,0 +1,39 @@
1
+ export declare class VeoBudgetError extends Error {
2
+ }
3
+ export declare class VeoRefused extends Error {
4
+ }
5
+ export interface VeoStudioOptions {
6
+ key: string;
7
+ file: string;
8
+ monthlyUsd?: number;
9
+ usdPerSecond?: number;
10
+ model?: string;
11
+ fetcher?: typeof fetch;
12
+ now?: () => number;
13
+ sleep?: (ms: number) => Promise<void>;
14
+ }
15
+ export declare class VeoStudio {
16
+ private readonly options;
17
+ private spent;
18
+ constructor(options: VeoStudioOptions);
19
+ static fileFor(stateDir: string, port: number): string;
20
+ private month;
21
+ private get cap();
22
+ private get rate();
23
+ private save;
24
+ status(): {
25
+ month: string;
26
+ spentUsd: number;
27
+ capUsd: number;
28
+ leftUsd: number;
29
+ usdPerSecond: number;
30
+ secondsLeft: number;
31
+ };
32
+ /** One shot: 4, 6 or 8 seconds, 16:9, with up to three reference images (JPEG). */
33
+ clip(input: {
34
+ prompt: string;
35
+ seconds: number;
36
+ references: Buffer[];
37
+ }): Promise<Buffer>;
38
+ private make;
39
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Veo clips for the AI label's music videos, made on nixamp.com.
3
+ *
4
+ * A label server asks for a shot (a prompt, its length, up to three member
5
+ * portraits as reference images) and gets back an MP4. Google's Veo 3.1 Fast
6
+ * makes it; nixamp.com keeps the month's spend under a cap in dollars
7
+ * (NIXAMP_VEO_MONTHLY_USD, $50 by default), counting every second at a
8
+ * deliberately high rate (NIXAMP_VEO_USD_PER_SECOND, $0.15) so the cap holds
9
+ * whatever Google's price is today. A shot is reserved before it is asked for
10
+ * and given back when Google makes nothing (a refusal, a filter, a timeout).
11
+ *
12
+ * The key is NIXAMP_VEO_API_KEY, its own name, so nothing else on nixamp.com
13
+ * starts spending Gemini credit because a Gemini key appeared.
14
+ */
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ export class VeoBudgetError extends Error {
18
+ }
19
+ export class VeoRefused extends Error {
20
+ }
21
+ const BASE = "https://generativelanguage.googleapis.com/v1beta";
22
+ export class VeoStudio {
23
+ options;
24
+ spent = {};
25
+ constructor(options) {
26
+ this.options = options;
27
+ try {
28
+ this.spent = JSON.parse(readFileSync(options.file, "utf8")).spent ?? {};
29
+ }
30
+ catch {
31
+ this.spent = {};
32
+ }
33
+ }
34
+ static fileFor(stateDir, port) {
35
+ return join(stateDir, `veo-${port}.json`);
36
+ }
37
+ month() {
38
+ return new Date(this.options.now?.() ?? Date.now()).toISOString().slice(0, 7);
39
+ }
40
+ get cap() { return this.options.monthlyUsd ?? 50; }
41
+ get rate() { return this.options.usdPerSecond ?? 0.15; }
42
+ save() {
43
+ mkdirSync(dirname(this.options.file), { recursive: true });
44
+ const temporary = `${this.options.file}.${process.pid}.tmp`;
45
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, spent: this.spent }, null, 2)}\n`, { mode: 0o600 });
46
+ renameSync(temporary, this.options.file);
47
+ }
48
+ status() {
49
+ const spent = this.spent[this.month()] ?? 0;
50
+ const left = Math.max(0, this.cap - spent);
51
+ return { month: this.month(), spentUsd: Math.round(spent * 100) / 100, capUsd: this.cap, leftUsd: Math.round(left * 100) / 100, usdPerSecond: this.rate, secondsLeft: Math.floor(left / this.rate) };
52
+ }
53
+ /** One shot: 4, 6 or 8 seconds, 16:9, with up to three reference images (JPEG). */
54
+ async clip(input) {
55
+ const seconds = [4, 6, 8].includes(input.seconds) ? input.seconds : 8;
56
+ const cost = seconds * this.rate;
57
+ const month = this.month();
58
+ if ((this.spent[month] ?? 0) + cost > this.cap + 1e-9)
59
+ throw new VeoBudgetError(`this month's $${this.cap} of Veo is spent`);
60
+ // Reserved first, so two at once cannot both squeeze under the cap.
61
+ this.spent[month] = (this.spent[month] ?? 0) + cost;
62
+ this.save();
63
+ try {
64
+ return await this.make(input.prompt, seconds, input.references.slice(0, 3));
65
+ }
66
+ catch (error) {
67
+ this.spent[month] = Math.max(0, (this.spent[month] ?? 0) - cost);
68
+ this.save();
69
+ throw error;
70
+ }
71
+ }
72
+ async make(prompt, seconds, references) {
73
+ const send = this.options.fetcher ?? fetch;
74
+ const headers = { "x-goog-api-key": this.options.key, "content-type": "application/json" };
75
+ const model = this.options.model ?? "veo-3.1-fast-generate-preview";
76
+ const started = await send(`${BASE}/models/${model}:predictLongRunning`, {
77
+ method: "POST",
78
+ headers,
79
+ signal: AbortSignal.timeout(60_000),
80
+ body: JSON.stringify({
81
+ instances: [{
82
+ prompt: prompt.slice(0, 1800),
83
+ ...(references.length ? { referenceImages: references.map((bytes) => ({ image: { bytesBase64Encoded: bytes.toString("base64"), mimeType: "image/jpeg" }, referenceType: "asset" })) } : {}),
84
+ }],
85
+ parameters: { aspectRatio: "16:9", durationSeconds: seconds },
86
+ }),
87
+ });
88
+ const operation = await started.json().catch(() => ({}));
89
+ if (!started.ok || !operation.name)
90
+ throw new VeoRefused(`Veo refused the shot: ${operation.error?.message?.slice(0, 200) ?? `HTTP ${started.status}`}`);
91
+ const sleep = this.options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
92
+ for (let tries = 0; tries < 60; tries++) {
93
+ await sleep(10_000);
94
+ const state = await (await send(`${BASE}/${operation.name}`, { headers, signal: AbortSignal.timeout(30_000) })).json().catch(() => ({}));
95
+ if (!state.done)
96
+ continue;
97
+ if (state.error)
98
+ throw new VeoRefused(`Veo failed: ${state.error.message?.slice(0, 200) ?? "no reason"}`);
99
+ const uri = state.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri;
100
+ if (!uri)
101
+ throw new VeoRefused(`Veo made nothing: ${state.response?.generateVideoResponse?.raiMediaFilteredReasons?.[0]?.slice(0, 200) ?? "no sample"}`);
102
+ const video = await send(uri, { headers: { "x-goog-api-key": this.options.key }, signal: AbortSignal.timeout(120_000) });
103
+ const bytes = Buffer.from(await video.arrayBuffer());
104
+ if (!video.ok || bytes.length < 10_000)
105
+ throw new VeoRefused(`the Veo clip did not download (HTTP ${video.status})`);
106
+ return bytes;
107
+ }
108
+ throw new VeoRefused("Veo took too long (10 minutes)");
109
+ }
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.59.5",
3
+ "version": "0.60.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-label.ts CHANGED
@@ -125,6 +125,10 @@ export interface LabelSettings {
125
125
  songSeconds: number;
126
126
  /** A music video for every song (the cover over itself, blurred, and the song's waveform), and a TV station of them. */
127
127
  videos: boolean;
128
+ /** Each album's single (song 1) gets filmed Veo shots of the band, while the month's Veo budget lasts. */
129
+ veo: boolean;
130
+ /** How many 8 s Veo shots a single gets. */
131
+ veoShots: number;
128
132
  /** Public, listed and owned, so the genre stations are on the guide. */
129
133
  publish: boolean;
130
134
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -378,6 +382,8 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
378
382
  songSeconds: 150,
379
383
  publish: true,
380
384
  videos: true,
385
+ veo: false,
386
+ veoShots: 4,
381
387
  parallel: 1,
382
388
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
383
389
  };
@@ -617,6 +623,35 @@ export async function writeStoryboard(
617
623
  return prompts;
618
624
  }
619
625
 
626
+ /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
627
+ export class VeoUnavailable extends Error {}
628
+
629
+ const SHOTS = [
630
+ "Wide shot of the whole band performing on a stage, the crowd in the foreground",
631
+ "Close-up of the lead singer singing the chorus into the microphone",
632
+ "The camera moves across the stage between the band members as they play hard",
633
+ "Low-angle shot of the band at the song's climax, lights flaring behind them",
634
+ "Over-the-shoulder shot from behind the drums, looking out at the band and the crowd",
635
+ "Slow push-in on the band in a quieter moment, moody light",
636
+ "Handheld shot from the crowd, fans with hands up, the band lit on stage",
637
+ "The band walking out on stage before the show, backlit silhouettes turning into faces",
638
+ ];
639
+
640
+ /**
641
+ * The prompts for a single's filmed shots. The people are the reference
642
+ * images, described by role and look only: Veo refuses names with faces
643
+ * ("we can't create videos with real people's names or likenesses").
644
+ */
645
+ export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[] {
646
+ 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("; ");
647
+ return SHOTS.slice(0, count).map((shot) => [
648
+ `Music video for a fictional ${artist.genre} band. ${shot}.`,
649
+ cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
650
+ `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
651
+ "Cinematic, 35mm film look, dynamic camera. No text, no logos.",
652
+ ].filter(Boolean).join(" "));
653
+ }
654
+
620
655
  /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
621
656
  function titleFont(): string {
622
657
  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"]) {
@@ -640,10 +675,21 @@ function runLow(ffmpeg: string[], args: string[], timeout: number, what: string)
640
675
  * the artist and title come up at the start, and the MP3's audio and tags
641
676
  * (title, artist, album, words) are copied in. 720p H.264.
642
677
  */
643
- export function renderStoryVideo(ffmpeg: string[], input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }): Promise<void> {
678
+ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string; clips?: Array<{ path: string; seconds: number }> }): Promise<void> {
644
679
  const fade = 1;
645
- const count = input.images.length;
646
- const each = input.seconds / count + fade;
680
+ const clips = input.clips ?? [];
681
+ // Stills fill what the filmed shots do not; each gets at least 3 s, so on a short song some are left out.
682
+ let images = [...input.images];
683
+ const clipTime = clips.reduce((sum, one) => sum + one.seconds, 0);
684
+ const stillFor = (count: number): number => (input.seconds + (count + clips.length - 1) * fade - clipTime) / Math.max(1, count);
685
+ while (images.length > 0 && stillFor(images.length) < 3) images = images.slice(0, -1);
686
+ const each = images.length ? stillFor(images.length) : 0;
687
+ // A still, then a filmed shot, in turn; whatever is left of either at the end.
688
+ const order: Array<{ kind: "image"; path: string } | { kind: "clip"; path: string; seconds: number }> = [];
689
+ for (let i = 0; i < Math.max(images.length, clips.length); i++) {
690
+ if (images[i]) order.push({ kind: "image", path: images[i]! });
691
+ if (clips[i]) order.push({ kind: "clip", ...clips[i]! });
692
+ }
647
693
  const frames = Math.max(25, Math.round(each * 25));
648
694
  const moves = [
649
695
  `z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
@@ -652,12 +698,25 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
652
698
  `z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
653
699
  ];
654
700
  const parts: string[] = [];
655
- input.images.forEach((_, i) => {
656
- 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}]`);
701
+ const lengths: number[] = [];
702
+ let still = 0;
703
+ order.forEach((item, i) => {
704
+ if (item.kind === "image") {
705
+ 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}]`);
706
+ lengths.push(each);
707
+ } else {
708
+ // A filmed shot plays as it was filmed; the last one holds its final frame if the song runs on.
709
+ const hold = i === order.length - 1 ? `,tpad=stop_mode=clone:stop_duration=${Math.max(0, input.seconds).toFixed(2)}` : "";
710
+ 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}]`);
711
+ lengths.push(item.seconds);
712
+ }
657
713
  });
714
+ const count = order.length;
658
715
  let last = "v0";
716
+ let at = 0;
659
717
  for (let i = 1; i < count; i++) {
660
- parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${(i * (each - fade)).toFixed(2)}[x${i}]`);
718
+ at += lengths[i - 1]! - fade;
719
+ parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${at.toFixed(2)}[x${i}]`);
661
720
  last = `x${i}`;
662
721
  }
663
722
  const font = titleFont();
@@ -666,7 +725,7 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
666
725
  ? `[${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]`
667
726
  : `[${last}]format=yuv420p[v]`);
668
727
  const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
669
- ...input.images.flatMap((image) => ["-loop", "1", "-t", each.toFixed(2), "-i", image]),
728
+ ...order.flatMap((item) => (item.kind === "image" ? ["-loop", "1", "-t", each.toFixed(2), "-i", item.path] : ["-i", item.path])),
670
729
  "-i", input.audio, "-filter_complex", parts.join(";"),
671
730
  "-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
672
731
  "-movflags", "+faststart+use_metadata_tags", input.out];
@@ -758,8 +817,10 @@ export interface AiLabelDeps {
758
817
  video?: (audio: string, cover: string, out: string) => Promise<void>;
759
818
  /** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
760
819
  align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
820
+ /** A filmed shot (Veo, through nixamp.com): the MP4, or a VeoUnavailable when the month's budget is spent. */
821
+ veo?: ((input: { prompt: string; seconds: number; references: Buffer[] }) => Promise<Buffer>) | null;
761
822
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
762
- storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string }) => Promise<void>;
823
+ storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string; clips?: Array<{ path: string; seconds: number }> }) => Promise<void>;
763
824
  /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
764
825
  image?: (prompt: string) => Promise<Buffer | null>;
765
826
  /** Tells the world an album landed (nixamp.com tells the artist's followers); never awaited. */
@@ -789,6 +850,8 @@ export class AiLabel {
789
850
  private videoFailed = new Set<string>();
790
851
  /** Songs whose words were already sent to the aligner since this server started. */
791
852
  private alignTried = new Set<string>();
853
+ /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
854
+ private veoSpentIn = "";
792
855
  /** Old videos already tried as storyboards since this server started. */
793
856
  private upgradeTried = new Set<string>();
794
857
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -937,6 +1000,9 @@ export class AiLabel {
937
1000
  if (input["enabled"] !== undefined) this.settings.enabled = input["enabled"] !== false;
938
1001
  if (input["publish"] !== undefined) this.settings.publish = input["publish"] !== false;
939
1002
  if (input["videos"] !== undefined) this.settings.videos = input["videos"] !== false;
1003
+ if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
1004
+ const shots = whole("veoShots", 1, 8);
1005
+ if (shots !== undefined) this.settings.veoShots = shots;
940
1006
  const perDay = whole("albumsPerDay", 0, 24);
941
1007
  if (perDay !== undefined) this.settings.albumsPerDay = perDay;
942
1008
  if (input["newArtistChance"] !== undefined) {
@@ -1114,7 +1180,7 @@ export class AiLabel {
1114
1180
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
1115
1181
  const missing = songs.filter(({ song }) => !song.video);
1116
1182
  const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
1117
- const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
1183
+ 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));
1118
1184
  let made = 0;
1119
1185
  for (const { album, song } of untagged) {
1120
1186
  if (!this.settings.videos) break;
@@ -1192,15 +1258,65 @@ export class AiLabel {
1192
1258
  this.log(`no storyboard for "${song.title}" (${(error as Error).message}); the cover video instead`);
1193
1259
  }
1194
1260
  }
1195
- if (images.length >= 2) {
1196
- await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out });
1197
- return 2;
1261
+ // An album's single gets filmed shots of the band too, while the month's Veo budget lasts.
1262
+ const clips = this.wantsVeo(song) ? await this.filmShots(artist, song, scenes) : [];
1263
+ if (images.length >= 2 || clips.length > 0) {
1264
+ if (storyOnly && (song.videoVersion ?? 1) >= 2 && clips.length === 0) throw new Error("no filmed shots this time; the video it has stays");
1265
+ 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 } : {}) });
1266
+ return clips.length ? 3 : 2;
1198
1267
  }
1199
1268
  if (storyOnly) throw new Error("no storyboard could be drawn; the video it has stays");
1200
1269
  await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
1201
1270
  return 1;
1202
1271
  }
1203
1272
 
1273
+ /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1274
+ private wantsVeo(song: Pick<LabelSong, "n">): boolean {
1275
+ return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
1276
+ }
1277
+
1278
+ /**
1279
+ * A single's filmed shots: the band, from their portraits (up to three as
1280
+ * Veo's reference images), on stage, in the song's mood. Stops for the
1281
+ * month when the budget is spent; a shot Veo will not make is skipped.
1282
+ */
1283
+ private async filmShots(artist: LabelArtist, song: LabelSong, dir: string): Promise<Array<{ path: string; seconds: number }>> {
1284
+ const veo = this.deps.veo;
1285
+ if (!veo) return [];
1286
+ mkdirSync(dir, { recursive: true });
1287
+ const members = this.cast(artist);
1288
+ const references: Buffer[] = [];
1289
+ for (const member of members.slice(0, 3)) {
1290
+ const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1291
+ const small = join(dir, `ref-${member.slug}.jpg`);
1292
+ try {
1293
+ // 512 px is plenty for a face and keeps the request small.
1294
+ 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");
1295
+ references.push(readFileSync(small));
1296
+ } catch {
1297
+ // A member without a portrait is described in words only.
1298
+ }
1299
+ }
1300
+ const clips: Array<{ path: string; seconds: number }> = [];
1301
+ for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1302
+ try {
1303
+ const bytes = await veo({ prompt, seconds: 8, references });
1304
+ const file = join(dir, `shot-${i}.mp4`);
1305
+ writeFileSync(file, bytes);
1306
+ clips.push({ path: file, seconds: 8 });
1307
+ } catch (error) {
1308
+ if (error instanceof VeoUnavailable) {
1309
+ this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
1310
+ this.log(`Veo: ${(error as Error).message}; singles get the storyboard until next month`);
1311
+ break;
1312
+ }
1313
+ this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
1314
+ }
1315
+ }
1316
+ if (clips.length) this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1317
+ return clips;
1318
+ }
1319
+
1204
1320
  /** The label's music videos as one TV station: free to watch, with its breaks. */
1205
1321
  private placeVideos(): Promise<void> {
1206
1322
  const next = this.placing.then(async () => {
package/src/music-hub.ts CHANGED
@@ -149,7 +149,7 @@ export async function musicPageFor(hub: MusicHub, path: string): Promise<string>
149
149
  }
150
150
 
151
151
  export function isMusicPath(path: string): boolean {
152
- return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released";
152
+ return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
153
153
  }
154
154
 
155
155
  export interface MusicRoutesContext {
@@ -160,6 +160,8 @@ export interface MusicRoutesContext {
160
160
  page: () => string | null;
161
161
  /** A server says something of its label landed; null when nobody may say so. */
162
162
  released?: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{ status: number; body: unknown }>;
163
+ /** Veo shots for a label server's videos: the month's spend (GET), a clip (POST); answers bytes or JSON. */
164
+ veo?: (request: IncomingMessage) => Promise<{ status: number; body: unknown; video?: Buffer }>;
163
165
  }
164
166
 
165
167
  export async function handleMusicRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: MusicRoutesContext): Promise<boolean> {
@@ -173,6 +175,15 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
173
175
  ctx.json(response, answer.status, answer.body);
174
176
  return true;
175
177
  }
178
+ if (path === "/api/v1/music/veo") {
179
+ if (!ctx.veo) { ctx.json(response, 404, { error: "Veo is not set up here" }); return true; }
180
+ const answer = await ctx.veo(request);
181
+ if (answer.video) {
182
+ response.writeHead(200, { "content-type": "video/mp4", "content-length": String(answer.video.length), "cache-control": "no-store" });
183
+ response.end(answer.video);
184
+ } else ctx.json(response, answer.status, answer.body);
185
+ return true;
186
+ }
176
187
  if (method !== "GET" && method !== "HEAD") { ctx.json(response, 405, { error: "not like that" }); return true; }
177
188
  if (path === "/api/v1/music") {
178
189
  ctx.json(response, 200, await ctx.hub.catalogue());
package/src/server.ts CHANGED
@@ -80,10 +80,11 @@ import { ListenerStore } from "./library-user.ts";
80
80
  import { Importer } from "./handoff.ts";
81
81
  import { handleHandoffs } from "./handoff-routes.ts";
82
82
  import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
83
- import { AiLabel } from "./ai-label.ts";
83
+ import { AiLabel, VeoUnavailable } from "./ai-label.ts";
84
84
  import { buyLink, handleLabelRoutes, isPublicLabelPath } from "./label-routes.ts";
85
85
  import { GrantVerifier, MusicStore, PRICES } from "./music-store.ts";
86
86
  import { handleMusicRoutes, isMusicPath, MusicHub, musicPageFor, serverId } from "./music-hub.ts";
87
+ import { VeoBudgetError, VeoStudio } from "./veo-studio.ts";
87
88
  import { handleStoreRoutes, isStorePath } from "./store-routes.ts";
88
89
  import { AiRadio } from "./ai-radio.ts";
89
90
  import { ListenerShares } from "./listener-shares.ts";
@@ -1963,7 +1964,7 @@ export interface HandlerOptions {
1963
1964
  /** The AI label's songs for sale, nixamp.com only (music-store.ts). */
1964
1965
  musicStore?: MusicStore;
1965
1966
  /** nixamp.com/music: every server's label in one app (music-hub.ts). */
1966
- music?: { hub: MusicHub; released: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{ status: number; body: unknown }> };
1967
+ music?: { hub: MusicHub; released: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{ status: number; body: unknown }>; veo?: (request: IncomingMessage) => Promise<{ status: number; body: unknown; video?: Buffer }> };
1967
1968
  sharedTranslations?: SharedTranslations;
1968
1969
  /** Translation: texts in another language, by a model here. The same optional library. */
1969
1970
  translator?: Translator;
@@ -3413,6 +3414,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3413
3414
  readBody,
3414
3415
  page: () => { try { return shell ? readFileSync(shell, "utf8") : null; } catch { return null; } },
3415
3416
  released: music.released,
3417
+ ...(music.veo ? { veo: music.veo } : {}),
3416
3418
  })) return;
3417
3419
  }
3418
3420
 
@@ -7685,6 +7687,21 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7685
7687
  root: process.env["NIXAMP_LABEL_ROOT"] || (labelHome ? join(labelHome.root, "ai") : join(stateDir(), "ai-label")),
7686
7688
  owner: options.owner || "",
7687
7689
  mayStart: mayStartStation,
7690
+ // Filmed shots for singles come from nixamp.com's Veo studio, asked as this server's owner, under its monthly cap.
7691
+ veo: async (shot) => {
7692
+ const session = readSession();
7693
+ const here = stations.publicBase?.() ?? "";
7694
+ if (!session?.token || !/^https:\/\//.test(here)) throw new VeoUnavailable("this server is not signed in to nixamp.com");
7695
+ const answer = await fetch(`${session.site.replace(/\/+$/, "")}/api/v1/music/veo`, {
7696
+ method: "POST",
7697
+ headers: { "content-type": "application/json", authorization: `Bearer ${session.token}`, "x-nixamp-server": here },
7698
+ body: JSON.stringify({ prompt: shot.prompt, seconds: shot.seconds, references: shot.references.map((one) => one.toString("base64")) }),
7699
+ signal: AbortSignal.timeout(12 * 60_000),
7700
+ });
7701
+ if (answer.status === 402 || answer.status === 404) throw new VeoUnavailable(((await answer.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${answer.status}`);
7702
+ if (!answer.ok) throw new Error(((await answer.json().catch(() => ({}))) as { error?: string }).error ?? `nixamp.com answered ${answer.status}`);
7703
+ return Buffer.from(await answer.arrayBuffer());
7704
+ },
7688
7705
  // A release is told to nixamp.com as this server's owner; nixamp.com tells the artist's followers.
7689
7706
  announce: (release) => {
7690
7707
  const session = readSession();
@@ -7961,6 +7978,36 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7961
7978
  * followers are told. Only the account that owns a listed server may say
7962
7979
  * so, only for an artist that server's catalogue has, once per release.
7963
7980
  */
7981
+ // Veo shots for label music videos, under a monthly dollar cap (veo-studio.ts). nixamp.com only.
7982
+ const veoStudio = options.directory && process.env["NIXAMP_VEO_API_KEY"] ? new VeoStudio({
7983
+ key: process.env["NIXAMP_VEO_API_KEY"],
7984
+ file: VeoStudio.fileFor(stateDir(), options.port),
7985
+ monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
7986
+ usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
7987
+ }) : undefined;
7988
+ /** The signed-in account owns a server listed in the directory at this base. */
7989
+ const ownsListed = async (request: IncomingMessage, base: string): Promise<boolean> => {
7990
+ const me = accounts ? await accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
7991
+ return Boolean(me && base && directory?.list().some((one) => one.ownerId === me.id && peerBase(one.url) === base));
7992
+ };
7993
+ const musicVeo = async (request: IncomingMessage): Promise<{ status: number; body: unknown; video?: Buffer }> => {
7994
+ if (!veoStudio) return { status: 404, body: { error: "Veo is not set up here" } };
7995
+ const base = peerBase(String(request.headers["x-nixamp-server"] ?? ""));
7996
+ if (!(await ownsListed(request, base))) return { status: 403, body: { error: "only the owner of a listed server" } };
7997
+ if (request.method === "GET") return { status: 200, body: veoStudio.status() };
7998
+ if (request.method !== "POST") return { status: 405, body: { error: "GET or POST" } };
7999
+ let input: { prompt?: unknown; seconds?: unknown; references?: unknown };
8000
+ 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" } }; }
8001
+ const prompt = String(input.prompt ?? "").trim();
8002
+ if (prompt.length < 20) return { status: 400, body: { error: "describe the shot" } };
8003
+ const references = (Array.isArray(input.references) ? input.references : []).slice(0, 3).map((one) => Buffer.from(String(one), "base64")).filter((one) => one.length > 1000);
8004
+ try {
8005
+ return { status: 200, body: null, video: await veoStudio.clip({ prompt, seconds: Number(input.seconds ?? 8), references }) };
8006
+ } catch (error) {
8007
+ if (error instanceof VeoBudgetError) return { status: 402, body: { error: error.message, code: "budget", ...veoStudio.status() } };
8008
+ return { status: 422, body: { error: (error as Error).message, code: "refused" } };
8009
+ }
8010
+ };
7964
8011
  const announced = new Set<string>();
7965
8012
  const musicReleased = async (request: IncomingMessage, body: Record<string, unknown>): Promise<{ status: number; body: unknown }> => {
7966
8013
  const me = accounts ? await accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
@@ -8544,7 +8591,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
8544
8591
  ...(supportPayments ? { support: supportPayments } : {}),
8545
8592
  ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
8546
8593
  ...(musicStore ? { musicStore } : {}),
8547
- ...(musicHub ? { music: { hub: musicHub, released: musicReleased } } : {}),
8594
+ ...(musicHub ? { music: { hub: musicHub, released: musicReleased, veo: musicVeo } } : {}),
8548
8595
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
8549
8596
  ...(sharedTranslations ? { sharedTranslations } : {}),
8550
8597
  ...(translator ? { translator } : {}),
@@ -504,6 +504,8 @@ async function labelCommand(argv: string[], call: Call, show: Show): Promise<num
504
504
  videos: has(argv, "--no-videos") ? false : has(argv, "--videos") ? true : undefined,
505
505
  newArtistChance: number("--new-artists"),
506
506
  resetDay: number("--reset-day"),
507
+ veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
508
+ veoShots: number("--veo-shots"),
507
509
  genreOrder: flag(argv, "--genres"),
508
510
  enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
509
511
  publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
@@ -124,6 +124,8 @@ export const STATION_TOOLS = [
124
124
  albumsPerDay: { type: "integer", description: "After the first sweep: new albums a day, by a random artist or a newly invented one." },
125
125
  newArtistChance: { type: "number", description: "0-1: how often the day's album is by a new artist." },
126
126
  resetDay: { type: "integer", description: "Day of the month (1-28) the budget renews: the music provider's billing day." },
127
+ veo: { type: "boolean", description: "Each album's single gets filmed Veo shots of the band (nixamp.com's monthly Veo cap applies)." },
128
+ veoShots: { type: "integer", description: "8 s Veo shots per single (1-8)." },
127
129
  genreOrder: { type: "array", items: STRING, description: "Genres whose artists get albums first, in order." },
128
130
  },
129
131
  },
@@ -254,7 +256,7 @@ export async function callStationTool(name: string, args: Record<string, unknown
254
256
  const key = typeof args["key"] === "string" && /^[a-f0-9]{10}$/.test(args["key"]) ? args["key"] : "";
255
257
  const answer = action === "status" ? await call("GET", "/api/v1/stations/label")
256
258
  : action === "sign" ? await call("POST", "/api/v1/stations/label/artists", pick(["preset", "roster", "name", "genre", "sound", "voice", "bio", "themes", "enabled"]))
257
- : action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "enabled", "publish"]))
259
+ : 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"]))
258
260
  : action === "run" ? await call("POST", "/api/v1/stations/label/run", {})
259
261
  : action === "resume" ? await call("POST", "/api/v1/stations/label", { resume: true })
260
262
  : !key ? null
@@ -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