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.
- package/dist/ai-label.d.ts +37 -0
- package/dist/ai-label.js +127 -10
- package/dist/music-hub.d.ts +6 -0
- package/dist/music-hub.js +15 -1
- package/dist/server.d.ts +5 -0
- package/dist/server.js +64 -2
- package/dist/station-cli.js +2 -0
- package/dist/station-mcp.d.ts +30 -0
- package/dist/station-mcp.js +3 -1
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +1 -0
- package/dist/veo-studio.d.ts +39 -0
- package/dist/veo-studio.js +110 -0
- package/package.json +1 -1
- package/src/ai-label.ts +128 -12
- package/src/music-hub.ts +12 -1
- package/src/server.ts +50 -3
- package/src/station-cli.ts +2 -0
- package/src/station-mcp.ts +3 -1
- package/src/station-usage.ts +1 -0
- package/src/veo-studio.ts +123 -0
- package/web/dist/assets/{ar-D7L1Lzg5.js → ar-jc2nfbwU.js} +1 -1
- package/web/dist/assets/{cs-DR-oumcX.js → cs-CsQm5s-8.js} +1 -1
- package/web/dist/assets/{da-B7ofIVod.js → da-DHCYXrSL.js} +1 -1
- package/web/dist/assets/{de-CglXPk_n.js → de-IO9rajjn.js} +1 -1
- package/web/dist/assets/{es-CYVNiI20.js → es-CIkJTUJE.js} +1 -1
- package/web/dist/assets/{fi-Bktga6d_.js → fi-JklvgzxQ.js} +1 -1
- package/web/dist/assets/{fr-CNxMDHg5.js → fr-CUk3yUOe.js} +1 -1
- package/web/dist/assets/{hi-DEyWHgib.js → hi-CEdvI1Y5.js} +1 -1
- package/web/dist/assets/{hu-mcHUOvhY.js → hu-B_oRzY-F.js} +1 -1
- package/web/dist/assets/{id-T9wfahs5.js → id-D5Wx0nsW.js} +1 -1
- package/web/dist/assets/{it-CASLaiyC.js → it-DP4ncIUW.js} +1 -1
- package/web/dist/assets/{main-D9-BuE2r.js → main-D1zLWorp.js} +2 -2
- package/web/dist/assets/{nl-DQ-YPUBS.js → nl-BSvol-ht.js} +1 -1
- package/web/dist/assets/{ru-CDSDPqFZ.js → ru-35Ln-kD-.js} +1 -1
- package/web/dist/assets/{sv-liuu51-p.js → sv-D1DNLvXv.js} +1 -1
- package/web/dist/assets/{uk-B3vreqbA.js → uk-B0fTytVz.js} +1 -1
- package/web/dist/assets/{vi-B0NyoUEc.js → vi-6o60v8Th.js} +1 -1
- package/web/dist/assets/{zh-Bw5OQASg.js → zh-CbbDoNoL.js} +1 -1
- package/web/dist/index.html +1 -1
- 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
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
|
|
646
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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 } : {}),
|
package/src/station-cli.ts
CHANGED
|
@@ -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,
|
package/src/station-mcp.ts
CHANGED
|
@@ -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
|
package/src/station-usage.ts
CHANGED
|
@@ -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
|
|