nixamp 0.65.1 → 0.67.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 +5 -1
- package/dist/ai-label.js +115 -37
- package/dist/discovery.d.ts +6 -3
- package/dist/discovery.js +16 -9
- package/dist/owner.d.ts +2 -0
- package/dist/owner.js +4 -0
- package/dist/server.js +56 -8
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +15 -0
- package/dist/station-mcp.js +2 -1
- package/dist/translate-jobs.d.ts +5 -2
- package/dist/translate-jobs.js +11 -4
- package/dist/translation-passes.d.ts +55 -3
- package/dist/translation-passes.js +138 -3
- package/package.json +4 -4
- package/src/ai-label.ts +115 -36
- package/src/discovery.ts +15 -8
- package/src/owner.ts +5 -0
- package/src/server.ts +51 -9
- package/src/station-cli.ts +1 -0
- package/src/station-mcp.ts +2 -1
- package/src/translate-jobs.ts +13 -4
- package/src/translation-passes.ts +155 -3
- package/web/dist/assets/{ar-DsLCtn6q.js → ar-Dw3s9ADu.js} +1 -1
- package/web/dist/assets/{cs-CDebRTvk.js → cs-CzDFyr4i.js} +1 -1
- package/web/dist/assets/{da-CtIZY0F_.js → da-DCTQgBaR.js} +1 -1
- package/web/dist/assets/{de-Ce4XnPH4.js → de-bRuy-6md.js} +1 -1
- package/web/dist/assets/{es-DuTsv2ty.js → es-iHd4cFjq.js} +1 -1
- package/web/dist/assets/{fi-BxzgeUzQ.js → fi-BQgHm0iD.js} +1 -1
- package/web/dist/assets/{fr-1e-Cafh4.js → fr-Bx2XC-ON.js} +1 -1
- package/web/dist/assets/{hi-yKzlszV2.js → hi-Cq8IDrah.js} +1 -1
- package/web/dist/assets/{hu-DEEC9-i5.js → hu-C3mzcLs_.js} +1 -1
- package/web/dist/assets/{id-Da47jrbA.js → id-DddaqO3C.js} +1 -1
- package/web/dist/assets/{it-DyEoFcC_.js → it-C8TvaS5w.js} +1 -1
- package/web/dist/assets/{main-C_3KEwHZ.js → main-BSOJX0Of.js} +2 -2
- package/web/dist/assets/{nl-uTGCd9L3.js → nl-C3LqPPhu.js} +1 -1
- package/web/dist/assets/{ru-BJ7gUalz.js → ru-DzAMilh-.js} +1 -1
- package/web/dist/assets/{sv-C9ZiAm2Q.js → sv-Ca1BMi0N.js} +1 -1
- package/web/dist/assets/{uk-DIcSDuMA.js → uk-D-CADfyO.js} +1 -1
- package/web/dist/assets/{vi-4MI2s3Z9.js → vi-CWeZdHAD.js} +1 -1
- package/web/dist/assets/{zh-DI1PPrRY.js → zh-BvS0BRgy.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
package/dist/ai-label.d.ts
CHANGED
|
@@ -90,8 +90,10 @@ export interface LabelSettings {
|
|
|
90
90
|
videos: boolean;
|
|
91
91
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
92
92
|
veo: boolean;
|
|
93
|
-
/** How many 8 s Veo shots of the band a single is cut from. */
|
|
93
|
+
/** How many 8 s Veo shots of the band playing (never the singer) a single is cut from. */
|
|
94
94
|
veoShots: number;
|
|
95
|
+
/** How many 8 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
|
|
96
|
+
storyShots: number;
|
|
95
97
|
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
96
98
|
lipsyncShots: number;
|
|
97
99
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
@@ -166,6 +168,8 @@ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLin
|
|
|
166
168
|
export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
|
|
167
169
|
cast?: string;
|
|
168
170
|
}, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
|
|
171
|
+
/** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
|
|
172
|
+
export declare function writeVeoStory(artist: Pick<LabelArtist, "genre" | "sound">, members: Array<Pick<Member, "role" | "look">>, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
|
|
169
173
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
170
174
|
export declare class VeoUnavailable extends Error {
|
|
171
175
|
}
|
package/dist/ai-label.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { execFile } from "node:child_process";
|
|
30
30
|
import { randomBytes } from "node:crypto";
|
|
31
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { dirname, join, resolve, sep } from "node:path";
|
|
33
33
|
import { aiKey } from "./ai-keys.js";
|
|
34
34
|
import { composerFromEnv, OutOfCredit } from "./ai-radio.js";
|
|
@@ -273,7 +273,8 @@ export const DEFAULTS = {
|
|
|
273
273
|
publish: true,
|
|
274
274
|
videos: true,
|
|
275
275
|
veo: false,
|
|
276
|
-
veoShots:
|
|
276
|
+
veoShots: 2,
|
|
277
|
+
storyShots: 4,
|
|
277
278
|
lipsyncShots: 2,
|
|
278
279
|
parallel: 1,
|
|
279
280
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
@@ -489,21 +490,49 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
|
|
|
489
490
|
throw new Error("the model wrote no storyboard");
|
|
490
491
|
return prompts;
|
|
491
492
|
}
|
|
493
|
+
const VEO_STORY_SYSTEM = [
|
|
494
|
+
"You write the story scenes of a music video for an original song, as prompts for a video model that films 8-second moving shots.",
|
|
495
|
+
"Tell the song's story in cinematic scenes away from any stage: characters, places, action, weather, small details. Keep the same main character(s), setting and color palette across scenes so it reads as one short film; vary the shots (wide, tracking, close detail, aerial) and say how the camera moves.",
|
|
496
|
+
"Describe people by how they look, never by name. Nobody sings, speaks or lip-syncs, and nobody holds a microphone. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
|
|
497
|
+
"Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
|
|
498
|
+
].join(" ");
|
|
499
|
+
/** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
|
|
500
|
+
export async function writeVeoStory(artist, members, album, song, scenes, ask = askJson) {
|
|
501
|
+
const cast = members.slice(0, 4).map((one) => `${one.role}: ${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")}`).join("; ");
|
|
502
|
+
const answer = await ask({
|
|
503
|
+
system: VEO_STORY_SYSTEM,
|
|
504
|
+
prompt: [
|
|
505
|
+
`Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
506
|
+
cast ? `The band may appear as characters, described by look: ${cast}` : "",
|
|
507
|
+
`Mood and arrangement: ${song.style}`,
|
|
508
|
+
song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
|
|
509
|
+
`Exactly ${scenes} scenes.`,
|
|
510
|
+
].filter(Boolean).join("\n"),
|
|
511
|
+
maxTokens: 2500,
|
|
512
|
+
timeoutMs: 90_000,
|
|
513
|
+
});
|
|
514
|
+
const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
|
|
515
|
+
const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
|
|
516
|
+
if (prompts.length < 1)
|
|
517
|
+
throw new Error("the model wrote no story");
|
|
518
|
+
return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
|
|
519
|
+
+ " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
|
|
520
|
+
}
|
|
492
521
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
493
522
|
export class VeoUnavailable extends Error {
|
|
494
523
|
}
|
|
524
|
+
/** nixamp.com briefly away (a deploy: 502-504) or the network: worth asking again. */
|
|
525
|
+
const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
|
|
495
526
|
// Nobody sings on camera: Veo's singers mouth a melody of their own, never the song's,
|
|
496
527
|
// so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
|
|
497
528
|
// behind, in silhouette or between lines, never a close-up of singing.
|
|
498
529
|
const SHOTS = [
|
|
499
|
-
"Wide shot of the whole band performing on a big stage at night, seen from the back of the crowd, the players small against the lights",
|
|
500
530
|
"Close-up of the guitarist's hands on the fretboard and the strings, shallow focus, stage lights flaring",
|
|
501
|
-
"The camera glides behind the band members as they play hard, seen from behind, looking out over the crowd",
|
|
502
531
|
"Low-angle shot of the drummer hitting the cymbals at the song's climax, sticks blurred, sparks of light",
|
|
503
|
-
"
|
|
504
|
-
"
|
|
505
|
-
"
|
|
506
|
-
"
|
|
532
|
+
"Close-up of the bass player's fingers and the amplifier, then a tilt up to the bassist in silhouette",
|
|
533
|
+
"The guitarist and the bassist seen from behind, playing hard toward a huge crowd, backlit",
|
|
534
|
+
"Slow push-in on the drummer in a quiet moment, moody light, sweat and smoke",
|
|
535
|
+
"Slow aerial push over the crowd toward the stage, fans with hands up, the stage tiny in the distance",
|
|
507
536
|
];
|
|
508
537
|
// The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
|
|
509
538
|
// must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
|
|
@@ -523,7 +552,7 @@ export function veoPrompts(artist, song, members, count, kind = "band") {
|
|
|
523
552
|
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("; ");
|
|
524
553
|
const rule = kind === "singer"
|
|
525
554
|
? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
|
|
526
|
-
: "
|
|
555
|
+
: "The lead vocalist is not in this shot, and nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
|
|
527
556
|
return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
|
|
528
557
|
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
529
558
|
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
@@ -938,9 +967,12 @@ export class AiLabel {
|
|
|
938
967
|
this.settings.videos = input["videos"] !== false;
|
|
939
968
|
if (input["veo"] !== undefined)
|
|
940
969
|
this.settings.veo = input["veo"] === true;
|
|
941
|
-
const shots = whole("veoShots",
|
|
970
|
+
const shots = whole("veoShots", 0, 8);
|
|
942
971
|
if (shots !== undefined)
|
|
943
972
|
this.settings.veoShots = shots;
|
|
973
|
+
const story = whole("storyShots", 0, 8);
|
|
974
|
+
if (story !== undefined)
|
|
975
|
+
this.settings.storyShots = story;
|
|
944
976
|
const close = whole("lipsyncShots", 0, 4);
|
|
945
977
|
if (close !== undefined)
|
|
946
978
|
this.settings.lipsyncShots = close;
|
|
@@ -1206,9 +1238,10 @@ export class AiLabel {
|
|
|
1206
1238
|
catch { /* the asked-for length */ }
|
|
1207
1239
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1208
1240
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1209
|
-
const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
|
|
1210
|
-
|
|
1211
|
-
|
|
1241
|
+
const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
|
|
1242
|
+
const wanted = this.settings.veoShots + this.settings.storyShots;
|
|
1243
|
+
if (clips.length < Math.max(2, Math.ceil(wanted / 2)))
|
|
1244
|
+
throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
|
|
1212
1245
|
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1213
1246
|
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1214
1247
|
rmSync(shots, { recursive: true, force: true });
|
|
@@ -1246,15 +1279,27 @@ export class AiLabel {
|
|
|
1246
1279
|
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1247
1280
|
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i].path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
|
|
1248
1281
|
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
|
|
1249
|
-
|
|
1282
|
+
let bytes = null;
|
|
1283
|
+
for (let attempt = 0;; attempt++) {
|
|
1284
|
+
try {
|
|
1285
|
+
bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
|
|
1286
|
+
break;
|
|
1287
|
+
}
|
|
1288
|
+
catch (error) {
|
|
1289
|
+
if (attempt >= 2 || !TRANSIENT.test(error.message))
|
|
1290
|
+
throw error;
|
|
1291
|
+
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1250
1294
|
writeFileSync(`${synced}.part`, bytes);
|
|
1251
1295
|
renameSync(`${synced}.part`, synced);
|
|
1252
1296
|
pinned.push({ path: synced, at, seconds: 8 });
|
|
1253
1297
|
}
|
|
1254
1298
|
catch (error) {
|
|
1299
|
+
// nixamp.com away (a deploy) or the month spent: nothing is cut yet; the shots wait for the next try.
|
|
1300
|
+
if (error instanceof VeoUnavailable || TRANSIENT.test(error.message))
|
|
1301
|
+
throw new Error(`lip sync ${i + 1} must wait: ${error.message}`);
|
|
1255
1302
|
this.log(`lip sync ${i + 1} for "${song.title}": ${error.message}; the band plays there instead`);
|
|
1256
|
-
if (error instanceof VeoUnavailable)
|
|
1257
|
-
break;
|
|
1258
1303
|
}
|
|
1259
1304
|
finally {
|
|
1260
1305
|
rmSync(shot, { force: true });
|
|
@@ -1274,34 +1319,57 @@ export class AiLabel {
|
|
|
1274
1319
|
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
1275
1320
|
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
1276
1321
|
*/
|
|
1277
|
-
async filmShots(artist, song, keep, dir) {
|
|
1322
|
+
async filmShots(artist, album, song, keep, dir) {
|
|
1278
1323
|
const veo = this.deps.veo;
|
|
1279
1324
|
if (!veo)
|
|
1280
1325
|
return { band: [], singer: [] };
|
|
1281
1326
|
mkdirSync(dir, { recursive: true });
|
|
1282
1327
|
mkdirSync(keep, { recursive: true });
|
|
1283
1328
|
const members = this.cast(artist);
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1329
|
+
// The players for the band shots, the voice for the close-ups: a singer's portrait in a band
|
|
1330
|
+
// shot puts the singer at a microphone, singing out of sync.
|
|
1331
|
+
const voice = (member) => /vocal|sing/i.test(member.role);
|
|
1332
|
+
const players = members.filter((member) => !voice(member));
|
|
1333
|
+
const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
|
|
1334
|
+
const portraits = async (list) => {
|
|
1335
|
+
const out = [];
|
|
1336
|
+
for (const member of list.slice(0, 3)) {
|
|
1337
|
+
const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
|
|
1338
|
+
const small = join(dir, `ref-${member.slug}.jpg`);
|
|
1339
|
+
try {
|
|
1340
|
+
// 512 px is plenty for a face and keeps the request small.
|
|
1341
|
+
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");
|
|
1342
|
+
out.push(readFileSync(small));
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
// A member without a portrait is described in words only.
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return out;
|
|
1349
|
+
};
|
|
1350
|
+
const [playerRefs, singerRefs] = [await portraits(players), await portraits(singers)];
|
|
1351
|
+
const band = [];
|
|
1352
|
+
const story = [];
|
|
1353
|
+
const singer = [];
|
|
1354
|
+
// The story is written only while some of its scenes are still to film.
|
|
1355
|
+
const storyFiles = Array.from({ length: this.settings.storyShots }, (_, i) => join(keep, `story-${i}.mp4`));
|
|
1356
|
+
let storyPrompts = [];
|
|
1357
|
+
if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
|
|
1288
1358
|
try {
|
|
1289
|
-
|
|
1290
|
-
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");
|
|
1291
|
-
references.push(readFileSync(small));
|
|
1359
|
+
storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
|
|
1292
1360
|
}
|
|
1293
|
-
catch {
|
|
1294
|
-
|
|
1361
|
+
catch (error) {
|
|
1362
|
+
this.log(`no story for "${song.title}" yet: ${error.message}`);
|
|
1295
1363
|
}
|
|
1296
1364
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
// The singer's close-ups only where they can be lip-synced afterwards.
|
|
1365
|
+
// The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
|
|
1366
|
+
// the portraits: they would pull the whole band into every scene.
|
|
1300
1367
|
const plan = [
|
|
1301
|
-
...
|
|
1302
|
-
...
|
|
1368
|
+
...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] })),
|
|
1369
|
+
...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
|
|
1370
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
|
|
1303
1371
|
];
|
|
1304
|
-
for (const [i, { prompt, file, into }] of plan.entries()) {
|
|
1372
|
+
for (const [i, { prompt, file, into, refs }] of plan.entries()) {
|
|
1305
1373
|
try {
|
|
1306
1374
|
if (statSync(file).size > 10_000) {
|
|
1307
1375
|
into.push({ path: file, seconds: 8 });
|
|
@@ -1309,16 +1377,18 @@ export class AiLabel {
|
|
|
1309
1377
|
}
|
|
1310
1378
|
}
|
|
1311
1379
|
catch { /* not filmed yet */ }
|
|
1380
|
+
if (!prompt)
|
|
1381
|
+
continue;
|
|
1312
1382
|
try {
|
|
1313
1383
|
// nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
|
|
1314
1384
|
let bytes = null;
|
|
1315
1385
|
for (let attempt = 0;; attempt++) {
|
|
1316
1386
|
try {
|
|
1317
|
-
bytes = await veo({ prompt, seconds: 8, references });
|
|
1387
|
+
bytes = await veo({ prompt, seconds: 8, references: refs });
|
|
1318
1388
|
break;
|
|
1319
1389
|
}
|
|
1320
1390
|
catch (error) {
|
|
1321
|
-
if (attempt >= 2 || error instanceof VeoUnavailable ||
|
|
1391
|
+
if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test(error.message))
|
|
1322
1392
|
throw error;
|
|
1323
1393
|
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1324
1394
|
}
|
|
@@ -1337,9 +1407,17 @@ export class AiLabel {
|
|
|
1337
1407
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
|
|
1338
1408
|
}
|
|
1339
1409
|
}
|
|
1340
|
-
if (band.length || singer.length)
|
|
1341
|
-
this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1342
|
-
|
|
1410
|
+
if (band.length || story.length || singer.length)
|
|
1411
|
+
this.log(`Veo: ${band.length + story.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1412
|
+
// The cut takes them in turn: a story scene, then the band playing, and so on.
|
|
1413
|
+
const mixed = [];
|
|
1414
|
+
for (let i = 0; i < Math.max(story.length, band.length); i++) {
|
|
1415
|
+
if (story[i])
|
|
1416
|
+
mixed.push(story[i]);
|
|
1417
|
+
if (band[i])
|
|
1418
|
+
mixed.push(band[i]);
|
|
1419
|
+
}
|
|
1420
|
+
return { band: mixed, singer };
|
|
1343
1421
|
}
|
|
1344
1422
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
|
1345
1423
|
placeVideos() {
|
package/dist/discovery.d.ts
CHANGED
|
@@ -29,9 +29,12 @@ export interface Release {
|
|
|
29
29
|
/** The installer for `target` among a release's files, or null when that build is missing. */
|
|
30
30
|
export declare function desktopAsset(release: Release | null, target: string): string | null;
|
|
31
31
|
/**
|
|
32
|
-
* The latest published release,
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* The latest published release, from where github.com/.../releases/latest
|
|
33
|
+
* redirects (the tag), asked at most every ten minutes. Not the REST API: its
|
|
34
|
+
* 60 an hour unauthenticated are shared by every site on the box, and ran out.
|
|
35
|
+
* The installers are named by version (release.yml), and a release is only
|
|
36
|
+
* published once every one of them is attached. A failed ask keeps the last
|
|
37
|
+
* answer; with none, the downloads fall back to the releases page.
|
|
35
38
|
*/
|
|
36
39
|
export declare function latestRelease(fetcher?: typeof fetch, now?: () => number): () => Promise<Release | null>;
|
|
37
40
|
export declare function llmsTxt(site: string): string;
|
package/dist/discovery.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export const RELEASES = "https://github.com/profullstack/nixamp/releases";
|
|
2
|
-
const LATEST_API = "https://api.github.com/repos/profullstack/nixamp/releases/latest";
|
|
3
2
|
/** Each installer release.yml builds, by the address /download/<target> gives it. */
|
|
4
3
|
export const DESKTOP_TARGETS = [
|
|
5
4
|
{ target: "mac-arm64", label: "macOS (Apple silicon)", suffix: "-mac-arm64.dmg" },
|
|
@@ -21,9 +20,12 @@ export function desktopAsset(release, target) {
|
|
|
21
20
|
return release.assets.find((asset) => asset.name.startsWith("nixamp-") && asset.name.endsWith(spec.suffix))?.browser_download_url ?? null;
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
|
-
* The latest published release,
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* The latest published release, from where github.com/.../releases/latest
|
|
24
|
+
* redirects (the tag), asked at most every ten minutes. Not the REST API: its
|
|
25
|
+
* 60 an hour unauthenticated are shared by every site on the box, and ran out.
|
|
26
|
+
* The installers are named by version (release.yml), and a release is only
|
|
27
|
+
* published once every one of them is attached. A failed ask keeps the last
|
|
28
|
+
* answer; with none, the downloads fall back to the releases page.
|
|
27
29
|
*/
|
|
28
30
|
export function latestRelease(fetcher = fetch, now = Date.now) {
|
|
29
31
|
let kept = { at: -Infinity, release: null };
|
|
@@ -31,11 +33,16 @@ export function latestRelease(fetcher = fetch, now = Date.now) {
|
|
|
31
33
|
if (now() - kept.at < 10 * 60_000)
|
|
32
34
|
return kept.release;
|
|
33
35
|
try {
|
|
34
|
-
const response = await fetcher(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const response = await fetcher(`${RELEASES}/latest`, { method: "HEAD", redirect: "manual", headers: { "user-agent": "nixamp.com" }, signal: AbortSignal.timeout(8_000) });
|
|
37
|
+
const tag = /\/releases\/tag\/(v\d+\.\d+\.\d+)$/.exec(response.headers.get("location") ?? "")?.[1];
|
|
38
|
+
if (!tag)
|
|
39
|
+
throw new Error(`no release tag (HTTP ${response.status})`);
|
|
40
|
+
const version = tag.slice(1);
|
|
41
|
+
const assets = DESKTOP_TARGETS.map((one) => {
|
|
42
|
+
const name = `nixamp-${version}${one.suffix}`;
|
|
43
|
+
return { name, browser_download_url: `${RELEASES}/download/${tag}/${name}` };
|
|
44
|
+
});
|
|
45
|
+
kept = { at: now(), release: { tag_name: tag, assets } };
|
|
39
46
|
}
|
|
40
47
|
catch {
|
|
41
48
|
kept = { at: now(), release: kept.release };
|
package/dist/owner.d.ts
CHANGED
|
@@ -42,6 +42,8 @@ export declare class Owner {
|
|
|
42
42
|
private readonly cache;
|
|
43
43
|
constructor(options: OwnerOptions);
|
|
44
44
|
get claimed(): boolean;
|
|
45
|
+
/** The owner's account id, or "" when nobody has claimed this server. */
|
|
46
|
+
get id(): string;
|
|
45
47
|
/** The account a token belongs to, or "" for one nixamp.com does not accept. */
|
|
46
48
|
accountFor(token: string): Promise<string>;
|
|
47
49
|
check(hasControlKey: boolean, token: string): Promise<AdminCheck>;
|
package/dist/owner.js
CHANGED
|
@@ -32,6 +32,10 @@ export class Owner {
|
|
|
32
32
|
get claimed() {
|
|
33
33
|
return this.options.ownerId !== "";
|
|
34
34
|
}
|
|
35
|
+
/** The owner's account id, or "" when nobody has claimed this server. */
|
|
36
|
+
get id() {
|
|
37
|
+
return this.options.ownerId;
|
|
38
|
+
}
|
|
35
39
|
/** The account a token belongs to, or "" for one nixamp.com does not accept. */
|
|
36
40
|
async accountFor(token) {
|
|
37
41
|
if (!token)
|
package/dist/server.js
CHANGED
|
@@ -3399,6 +3399,18 @@ export function createHandler(engine, options) {
|
|
|
3399
3399
|
* OpenProfile. Never awaited by the poster: the phone is a side effect
|
|
3400
3400
|
* of the chat, and a slow Telnyx must not slow the box.
|
|
3401
3401
|
*/
|
|
3402
|
+
/**
|
|
3403
|
+
* AI work done for a signed-in account, paid as it goes from their credit:
|
|
3404
|
+
* charged first, refunded if the work fails, a 402 with no credit. Only the
|
|
3405
|
+
* admin (the owner) is never charged; with billing off, only the admin may.
|
|
3406
|
+
*/
|
|
3407
|
+
const payForAi = (by, kind, units, work) => {
|
|
3408
|
+
if (options.translationPasses)
|
|
3409
|
+
return options.translationPasses.spend(by, kind, units, work);
|
|
3410
|
+
if (by !== "" && by === options.owner?.id)
|
|
3411
|
+
return work();
|
|
3412
|
+
throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
|
|
3413
|
+
};
|
|
3402
3414
|
const readOnThePhone = (where, authorId, handle, body) => {
|
|
3403
3415
|
const partyLine = options.partyLine;
|
|
3404
3416
|
const listing = (options.directory?.list() ?? []).find((one) => {
|
|
@@ -3419,7 +3431,11 @@ export function createHandler(engine, options) {
|
|
|
3419
3431
|
const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
|
|
3420
3432
|
const pools = options.voices ? await options.voices.pools() : { provider: "kokoro", female: [], male: [] };
|
|
3421
3433
|
const spoken = spokenVoiceFor({ userId: authorId, voice: persona.voice, profile }, pools);
|
|
3422
|
-
|
|
3434
|
+
const words = spokenLine(handle, body);
|
|
3435
|
+
// Read aloud is AI speech, paid per character from the author's credit (the admin's never is).
|
|
3436
|
+
// No credit: the line stays in the chat and is not read.
|
|
3437
|
+
const say = () => partyLine.say(code, words, spoken.voice, spoken.settings);
|
|
3438
|
+
await payForAi(authorId, "phone_voice", words.length, say);
|
|
3423
3439
|
})().catch(() => undefined);
|
|
3424
3440
|
};
|
|
3425
3441
|
/*
|
|
@@ -3738,13 +3754,15 @@ export function createHandler(engine, options) {
|
|
|
3738
3754
|
json(response, 400, { error: "a room is a server address and a channel" });
|
|
3739
3755
|
return;
|
|
3740
3756
|
}
|
|
3741
|
-
|
|
3757
|
+
// Pay as you go per second of sound, from the WAV's own byte rate (the admin is never charged).
|
|
3758
|
+
const byteRate = bytes.length > 44 ? new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(28, true) : 0;
|
|
3759
|
+
const heard = await payForAi(who.id, "transcription_local", byteRate > 0 ? (bytes.length - 44) / byteRate : 1, () => speech.transcribe(bytes, {
|
|
3742
3760
|
language: languageOf(url.searchParams.get("language")),
|
|
3743
3761
|
// Pieces with their timing, for a whole file being written down.
|
|
3744
3762
|
timestamps: url.searchParams.get("timestamps") === "1",
|
|
3745
3763
|
by: who.id,
|
|
3746
3764
|
...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}),
|
|
3747
|
-
});
|
|
3765
|
+
}));
|
|
3748
3766
|
// The language travels back: as told, or as the ear guessed it, so
|
|
3749
3767
|
// a captioner can say it next time and a transcript can be kept as it.
|
|
3750
3768
|
const said = {
|
|
@@ -3964,7 +3982,9 @@ export function createHandler(engine, options) {
|
|
|
3964
3982
|
return;
|
|
3965
3983
|
}
|
|
3966
3984
|
try {
|
|
3967
|
-
|
|
3985
|
+
// Pay as you go per character sent (the admin is never charged).
|
|
3986
|
+
const characters = texts.reduce((sum, one) => sum + one.length, 0);
|
|
3987
|
+
json(response, 200, await payForAi(who.id, "translation_local", characters, () => translator.translate(texts, from, to, { by: who.id, ...(url.searchParams.get("live") === "1" ? { deadline: Date.now() + 10_000 } : {}) })));
|
|
3968
3988
|
}
|
|
3969
3989
|
catch (error) {
|
|
3970
3990
|
if (error instanceof SpeechError)
|
|
@@ -7526,7 +7546,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7526
7546
|
// Account servers meter paid providers by default, including when checkout
|
|
7527
7547
|
// is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
|
|
7528
7548
|
const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
|
|
7529
|
-
|
|
7549
|
+
// Every AI use is paid from this credit; only the admin (--owner / NIXAMP_OWNER) is never charged.
|
|
7550
|
+
? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
|
|
7530
7551
|
// Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
|
|
7531
7552
|
const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
|
|
7532
7553
|
// Monthly support: each month a new CoinPay payment and its link by email.
|
|
@@ -7608,6 +7629,16 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7608
7629
|
return { status: 403, body: { error: "only the owner of a listed server" } };
|
|
7609
7630
|
if (request.method === "GET")
|
|
7610
7631
|
return { status: 200, body: await veoStudio.status() };
|
|
7632
|
+
// Pay as you go from the caller's AI credit, per second made; the admin is never charged.
|
|
7633
|
+
const payer = accounts ? (await accounts.whoIs(tokenFrom(request.headers)).catch(() => null))?.id ?? "" : "";
|
|
7634
|
+
const paid = (kind, seconds, work) => {
|
|
7635
|
+
if (translationPasses)
|
|
7636
|
+
return translationPasses.spend(payer, kind, seconds, work);
|
|
7637
|
+
if (payer !== "" && payer === options.owner)
|
|
7638
|
+
return work();
|
|
7639
|
+
throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
|
|
7640
|
+
};
|
|
7641
|
+
const unpaid = (error) => error instanceof SpeechError ? { status: error.status, body: { error: error.message, code: "credit" } } : null;
|
|
7611
7642
|
if (request.method !== "POST")
|
|
7612
7643
|
return { status: 405, body: { error: "GET or POST" } };
|
|
7613
7644
|
let input;
|
|
@@ -7626,9 +7657,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7626
7657
|
if (video.length < 10_000 || audio.length < 1_000)
|
|
7627
7658
|
return { status: 400, body: { error: "a shot and its audio" } };
|
|
7628
7659
|
try {
|
|
7629
|
-
|
|
7660
|
+
// Charged as veo-studio.ts makes it: 1 to 10 s.
|
|
7661
|
+
const seconds = Math.max(1, Math.min(10, Number(input.seconds ?? 8) || 8));
|
|
7662
|
+
return { status: 200, body: null, video: await paid("lipsync", seconds, () => veoStudio.lipsync({ video, audio, seconds })) };
|
|
7630
7663
|
}
|
|
7631
7664
|
catch (error) {
|
|
7665
|
+
const owed = unpaid(error);
|
|
7666
|
+
if (owed)
|
|
7667
|
+
return owed;
|
|
7632
7668
|
if (error instanceof VeoBudgetError)
|
|
7633
7669
|
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7634
7670
|
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
@@ -7639,9 +7675,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7639
7675
|
return { status: 400, body: { error: "describe the shot" } };
|
|
7640
7676
|
const references = (Array.isArray(input.references) ? input.references : []).slice(0, 3).map((one) => Buffer.from(String(one), "base64")).filter((one) => one.length > 1000);
|
|
7641
7677
|
try {
|
|
7642
|
-
|
|
7678
|
+
// Charged as veo-studio.ts makes it: 4, 6 or 8 s.
|
|
7679
|
+
const seconds = [4, 6, 8].includes(Number(input.seconds)) ? Number(input.seconds) : 8;
|
|
7680
|
+
return { status: 200, body: null, video: await paid("video", seconds, () => veoStudio.clip({ prompt, seconds, references })) };
|
|
7643
7681
|
}
|
|
7644
7682
|
catch (error) {
|
|
7683
|
+
const owed = unpaid(error);
|
|
7684
|
+
if (owed)
|
|
7685
|
+
return owed;
|
|
7645
7686
|
if (error instanceof VeoBudgetError)
|
|
7646
7687
|
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7647
7688
|
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
@@ -7704,7 +7745,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7704
7745
|
}
|
|
7705
7746
|
// What was heard, kept: where the accounts are, under the media's identity.
|
|
7706
7747
|
const transcripts = pool ? new Transcripts(pool, (message) => console.log(message)) : undefined;
|
|
7707
|
-
|
|
7748
|
+
// Translating a stored transcript is AI: paid per character by whoever asked; only the admin is never charged.
|
|
7749
|
+
const translations = transcripts ? new StoredTranslations(transcripts, translator, (message) => console.log(message), (by, characters, work) => {
|
|
7750
|
+
if (translationPasses)
|
|
7751
|
+
return translationPasses.spend(by, "translation_local", characters, work);
|
|
7752
|
+
if (by !== "" && by === options.owner)
|
|
7753
|
+
return work();
|
|
7754
|
+
throw new SpeechError("AI on nixamp is pay as you go, and billing is off here.", 402);
|
|
7755
|
+
}) : undefined;
|
|
7708
7756
|
// The record of every file nixamp has met, by its bytes: nixamp.com/hash/<id>.
|
|
7709
7757
|
const records = pool ? new Media(pool, (message) => console.log(message)) : undefined;
|
|
7710
7758
|
// nixamp as an OAuth 2.1 authorization server, and the watch parties a
|
package/dist/station-cli.js
CHANGED
|
@@ -541,6 +541,7 @@ async function labelCommand(argv, call, show) {
|
|
|
541
541
|
veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
|
|
542
542
|
veoShots: number("--veo-shots"),
|
|
543
543
|
lipsyncShots: number("--lipsync-shots"),
|
|
544
|
+
storyShots: number("--story-shots"),
|
|
544
545
|
genreOrder: flag(argv, "--genres"),
|
|
545
546
|
enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
|
|
546
547
|
publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export declare const STATION_TOOLS: ({
|
|
|
53
53
|
veo?: undefined;
|
|
54
54
|
veoShots?: undefined;
|
|
55
55
|
lipsyncShots?: undefined;
|
|
56
|
+
storyShots?: undefined;
|
|
56
57
|
genreOrder?: undefined;
|
|
57
58
|
state?: undefined;
|
|
58
59
|
reason?: undefined;
|
|
@@ -132,6 +133,7 @@ export declare const STATION_TOOLS: ({
|
|
|
132
133
|
veo?: undefined;
|
|
133
134
|
veoShots?: undefined;
|
|
134
135
|
lipsyncShots?: undefined;
|
|
136
|
+
storyShots?: undefined;
|
|
135
137
|
genreOrder?: undefined;
|
|
136
138
|
state?: undefined;
|
|
137
139
|
reason?: undefined;
|
|
@@ -234,6 +236,7 @@ export declare const STATION_TOOLS: ({
|
|
|
234
236
|
veo?: undefined;
|
|
235
237
|
veoShots?: undefined;
|
|
236
238
|
lipsyncShots?: undefined;
|
|
239
|
+
storyShots?: undefined;
|
|
237
240
|
genreOrder?: undefined;
|
|
238
241
|
state?: undefined;
|
|
239
242
|
reason?: undefined;
|
|
@@ -333,6 +336,7 @@ export declare const STATION_TOOLS: ({
|
|
|
333
336
|
veo?: undefined;
|
|
334
337
|
veoShots?: undefined;
|
|
335
338
|
lipsyncShots?: undefined;
|
|
339
|
+
storyShots?: undefined;
|
|
336
340
|
genreOrder?: undefined;
|
|
337
341
|
state?: undefined;
|
|
338
342
|
reason?: undefined;
|
|
@@ -414,6 +418,7 @@ export declare const STATION_TOOLS: ({
|
|
|
414
418
|
veo?: undefined;
|
|
415
419
|
veoShots?: undefined;
|
|
416
420
|
lipsyncShots?: undefined;
|
|
421
|
+
storyShots?: undefined;
|
|
417
422
|
genreOrder?: undefined;
|
|
418
423
|
state?: undefined;
|
|
419
424
|
reason?: undefined;
|
|
@@ -502,6 +507,7 @@ export declare const STATION_TOOLS: ({
|
|
|
502
507
|
veo?: undefined;
|
|
503
508
|
veoShots?: undefined;
|
|
504
509
|
lipsyncShots?: undefined;
|
|
510
|
+
storyShots?: undefined;
|
|
505
511
|
genreOrder?: undefined;
|
|
506
512
|
state?: undefined;
|
|
507
513
|
reason?: undefined;
|
|
@@ -590,6 +596,7 @@ export declare const STATION_TOOLS: ({
|
|
|
590
596
|
veo?: undefined;
|
|
591
597
|
veoShots?: undefined;
|
|
592
598
|
lipsyncShots?: undefined;
|
|
599
|
+
storyShots?: undefined;
|
|
593
600
|
genreOrder?: undefined;
|
|
594
601
|
state?: undefined;
|
|
595
602
|
reason?: undefined;
|
|
@@ -695,6 +702,7 @@ export declare const STATION_TOOLS: ({
|
|
|
695
702
|
veo?: undefined;
|
|
696
703
|
veoShots?: undefined;
|
|
697
704
|
lipsyncShots?: undefined;
|
|
705
|
+
storyShots?: undefined;
|
|
698
706
|
genreOrder?: undefined;
|
|
699
707
|
state?: undefined;
|
|
700
708
|
reason?: undefined;
|
|
@@ -829,6 +837,10 @@ export declare const STATION_TOOLS: ({
|
|
|
829
837
|
type: string;
|
|
830
838
|
description: string;
|
|
831
839
|
};
|
|
840
|
+
storyShots: {
|
|
841
|
+
type: string;
|
|
842
|
+
description: string;
|
|
843
|
+
};
|
|
832
844
|
genreOrder: {
|
|
833
845
|
type: string;
|
|
834
846
|
items: {
|
|
@@ -903,6 +915,7 @@ export declare const STATION_TOOLS: ({
|
|
|
903
915
|
veo?: undefined;
|
|
904
916
|
veoShots?: undefined;
|
|
905
917
|
lipsyncShots?: undefined;
|
|
918
|
+
storyShots?: undefined;
|
|
906
919
|
genreOrder?: undefined;
|
|
907
920
|
server: {
|
|
908
921
|
type: "string";
|
|
@@ -1000,6 +1013,7 @@ export declare const STATION_TOOLS: ({
|
|
|
1000
1013
|
veo?: undefined;
|
|
1001
1014
|
veoShots?: undefined;
|
|
1002
1015
|
lipsyncShots?: undefined;
|
|
1016
|
+
storyShots?: undefined;
|
|
1003
1017
|
genreOrder?: undefined;
|
|
1004
1018
|
state?: undefined;
|
|
1005
1019
|
reason?: undefined;
|
|
@@ -1099,6 +1113,7 @@ export declare const STATION_TOOLS: ({
|
|
|
1099
1113
|
veo?: undefined;
|
|
1100
1114
|
veoShots?: undefined;
|
|
1101
1115
|
lipsyncShots?: undefined;
|
|
1116
|
+
storyShots?: undefined;
|
|
1102
1117
|
genreOrder?: undefined;
|
|
1103
1118
|
state?: undefined;
|
|
1104
1119
|
reason?: undefined;
|
package/dist/station-mcp.js
CHANGED
|
@@ -125,6 +125,7 @@ export const STATION_TOOLS = [
|
|
|
125
125
|
veo: { type: "boolean", description: "Each album's single gets filmed Veo shots of the band (nixamp.com's monthly Veo cap applies)." },
|
|
126
126
|
veoShots: { type: "integer", description: "8 s Veo shots of the band per single (1-8); nobody sings in them." },
|
|
127
127
|
lipsyncShots: { type: "integer", description: "Close-ups of the singer per single, lip-synced to the song on nixamp.com (0-4; 0 = the band only)." },
|
|
128
|
+
storyShots: { type: "integer", description: "Cinematic story scenes per single, a short film from the song's words, nobody singing (0-8)." },
|
|
128
129
|
genreOrder: { type: "array", items: STRING, description: "Genres whose artists get albums first, in order." },
|
|
129
130
|
},
|
|
130
131
|
},
|
|
@@ -244,7 +245,7 @@ export async function callStationTool(name, args, options = {}) {
|
|
|
244
245
|
const key = typeof args["key"] === "string" && /^[a-f0-9]{10}$/.test(args["key"]) ? args["key"] : "";
|
|
245
246
|
const answer = action === "status" ? await call("GET", "/api/v1/stations/label")
|
|
246
247
|
: action === "sign" ? await call("POST", "/api/v1/stations/label/artists", pick(["preset", "roster", "name", "genre", "sound", "voice", "bio", "themes", "enabled"]))
|
|
247
|
-
: action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "veo", "veoShots", "lipsyncShots", "enabled", "publish"]))
|
|
248
|
+
: action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "veo", "veoShots", "lipsyncShots", "storyShots", "enabled", "publish"]))
|
|
248
249
|
: action === "run" ? await call("POST", "/api/v1/stations/label/run", {})
|
|
249
250
|
: action === "resume" ? await call("POST", "/api/v1/stations/label", { resume: true })
|
|
250
251
|
: !key ? null
|