nixamp 0.66.3 → 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.
Files changed (29) hide show
  1. package/dist/ai-label.d.ts +5 -1
  2. package/dist/ai-label.js +97 -33
  3. package/dist/station-cli.js +1 -0
  4. package/dist/station-mcp.d.ts +15 -0
  5. package/dist/station-mcp.js +2 -1
  6. package/package.json +1 -1
  7. package/src/ai-label.ts +101 -32
  8. package/src/station-cli.ts +1 -0
  9. package/src/station-mcp.ts +2 -1
  10. package/web/dist/assets/{ar-BQfRAPxT.js → ar-Dw3s9ADu.js} +1 -1
  11. package/web/dist/assets/{cs-CIlyIUyM.js → cs-CzDFyr4i.js} +1 -1
  12. package/web/dist/assets/{da-B73sEH_l.js → da-DCTQgBaR.js} +1 -1
  13. package/web/dist/assets/{de-CR_qS1kD.js → de-bRuy-6md.js} +1 -1
  14. package/web/dist/assets/{es-CWcxhMQ1.js → es-iHd4cFjq.js} +1 -1
  15. package/web/dist/assets/{fi-sw-rgeRG.js → fi-BQgHm0iD.js} +1 -1
  16. package/web/dist/assets/{fr-DWwCpFG-.js → fr-Bx2XC-ON.js} +1 -1
  17. package/web/dist/assets/{hi-BDa2YD-x.js → hi-Cq8IDrah.js} +1 -1
  18. package/web/dist/assets/{hu-CsYXXp0t.js → hu-C3mzcLs_.js} +1 -1
  19. package/web/dist/assets/{id-CzrnSbAL.js → id-DddaqO3C.js} +1 -1
  20. package/web/dist/assets/{it-DT6eQX_8.js → it-C8TvaS5w.js} +1 -1
  21. package/web/dist/assets/{main-B-WZ8VbE.js → main-BSOJX0Of.js} +2 -2
  22. package/web/dist/assets/{nl-C57z9Sux.js → nl-C3LqPPhu.js} +1 -1
  23. package/web/dist/assets/{ru-CW9If0BU.js → ru-DzAMilh-.js} +1 -1
  24. package/web/dist/assets/{sv-CiW_iXWQ.js → sv-Ca1BMi0N.js} +1 -1
  25. package/web/dist/assets/{uk-D_agLCru.js → uk-D-CADfyO.js} +1 -1
  26. package/web/dist/assets/{vi-C4wrS-qx.js → vi-CWeZdHAD.js} +1 -1
  27. package/web/dist/assets/{zh-TOIwUGfV.js → zh-BvS0BRgy.js} +1 -1
  28. package/web/dist/index.html +1 -1
  29. package/web/dist/sw.js +19 -19
@@ -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: 6,
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,6 +490,34 @@ 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
  }
@@ -498,14 +527,12 @@ const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
498
527
  // so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
499
528
  // behind, in silhouette or between lines, never a close-up of singing.
500
529
  const SHOTS = [
501
- "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",
502
530
  "Close-up of the guitarist's hands on the fretboard and the strings, shallow focus, stage lights flaring",
503
- "The camera glides behind the band members as they play hard, seen from behind, looking out over the crowd",
504
531
  "Low-angle shot of the drummer hitting the cymbals at the song's climax, sticks blurred, sparks of light",
505
- "The lead vocalist from behind, arms raised to the crowd between lines, backlit silhouette, no face to camera",
506
- "Slow aerial push over the crowd toward the stage, fans with hands up, the band tiny in the distance",
507
- "Close-up of the bass player's fingers and the amplifier, then a tilt up to the band in silhouette",
508
- "The band walking out on stage before the show, backlit silhouettes, nobody speaking or singing",
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",
509
536
  ];
510
537
  // The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
511
538
  // must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
@@ -525,7 +552,7 @@ export function veoPrompts(artist, song, members, count, kind = "band") {
525
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("; ");
526
553
  const rule = kind === "singer"
527
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."
528
- : "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
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.";
529
556
  return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
530
557
  `Music video for a fictional ${artist.genre} band. ${shot}.`,
531
558
  cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
@@ -940,9 +967,12 @@ export class AiLabel {
940
967
  this.settings.videos = input["videos"] !== false;
941
968
  if (input["veo"] !== undefined)
942
969
  this.settings.veo = input["veo"] === true;
943
- const shots = whole("veoShots", 1, 8);
970
+ const shots = whole("veoShots", 0, 8);
944
971
  if (shots !== undefined)
945
972
  this.settings.veoShots = shots;
973
+ const story = whole("storyShots", 0, 8);
974
+ if (story !== undefined)
975
+ this.settings.storyShots = story;
946
976
  const close = whole("lipsyncShots", 0, 4);
947
977
  if (close !== undefined)
948
978
  this.settings.lipsyncShots = close;
@@ -1208,9 +1238,10 @@ export class AiLabel {
1208
1238
  catch { /* the asked-for length */ }
1209
1239
  // Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
1210
1240
  const shots = join(_album.folder, `.veo-${song.n}`);
1211
- const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
1212
- if (clips.length < Math.max(2, Math.ceil(this.settings.veoShots / 2)))
1213
- throw new Error(`only ${clips.length} of ${this.settings.veoShots} shots filmed so far`);
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`);
1214
1245
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1215
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 } : {}) });
1216
1247
  rmSync(shots, { recursive: true, force: true });
@@ -1288,34 +1319,57 @@ export class AiLabel {
1288
1319
  * Veo's reference images), on stage, in the song's mood. Stops for the
1289
1320
  * month when the budget is spent; a shot Veo will not make is skipped.
1290
1321
  */
1291
- async filmShots(artist, song, keep, dir) {
1322
+ async filmShots(artist, album, song, keep, dir) {
1292
1323
  const veo = this.deps.veo;
1293
1324
  if (!veo)
1294
1325
  return { band: [], singer: [] };
1295
1326
  mkdirSync(dir, { recursive: true });
1296
1327
  mkdirSync(keep, { recursive: true });
1297
1328
  const members = this.cast(artist);
1298
- const references = [];
1299
- for (const member of members.slice(0, 3)) {
1300
- const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1301
- const small = join(dir, `ref-${member.slug}.jpg`);
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()) {
1302
1358
  try {
1303
- // 512 px is plenty for a face and keeps the request small.
1304
- 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");
1305
- references.push(readFileSync(small));
1359
+ storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
1306
1360
  }
1307
- catch {
1308
- // A member without a portrait is described in words only.
1361
+ catch (error) {
1362
+ this.log(`no story for "${song.title}" yet: ${error.message}`);
1309
1363
  }
1310
1364
  }
1311
- const band = [];
1312
- const singer = [];
1313
- // 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.
1314
1367
  const plan = [
1315
- ...veoPrompts(artist, song, members, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band })),
1316
- ...(this.deps.lipsync ? veoPrompts(artist, song, members, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer })) : []),
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 })) : []),
1317
1371
  ];
1318
- for (const [i, { prompt, file, into }] of plan.entries()) {
1372
+ for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1319
1373
  try {
1320
1374
  if (statSync(file).size > 10_000) {
1321
1375
  into.push({ path: file, seconds: 8 });
@@ -1323,12 +1377,14 @@ export class AiLabel {
1323
1377
  }
1324
1378
  }
1325
1379
  catch { /* not filmed yet */ }
1380
+ if (!prompt)
1381
+ continue;
1326
1382
  try {
1327
1383
  // nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
1328
1384
  let bytes = null;
1329
1385
  for (let attempt = 0;; attempt++) {
1330
1386
  try {
1331
- bytes = await veo({ prompt, seconds: 8, references });
1387
+ bytes = await veo({ prompt, seconds: 8, references: refs });
1332
1388
  break;
1333
1389
  }
1334
1390
  catch (error) {
@@ -1351,9 +1407,17 @@ export class AiLabel {
1351
1407
  this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
1352
1408
  }
1353
1409
  }
1354
- if (band.length || singer.length)
1355
- this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
1356
- return { band, singer };
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 };
1357
1421
  }
1358
1422
  /** The label's music videos as one TV station: free to watch, with its breaks. */
1359
1423
  placeVideos() {
@@ -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,
@@ -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;
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.66.3",
3
+ "version": "0.67.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
@@ -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.ts";
34
34
  import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
@@ -127,8 +127,10 @@ export interface LabelSettings {
127
127
  videos: boolean;
128
128
  /** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
129
129
  veo: boolean;
130
- /** How many 8 s Veo shots of the band a single is cut from. */
130
+ /** How many 8 s Veo shots of the band playing (never the singer) a single is cut from. */
131
131
  veoShots: number;
132
+ /** How many 8 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
133
+ storyShots: number;
132
134
  /** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
133
135
  lipsyncShots: number;
134
136
  /** Public, listed and owned, so the genre stations are on the guide. */
@@ -385,7 +387,8 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
385
387
  publish: true,
386
388
  videos: true,
387
389
  veo: false,
388
- veoShots: 6,
390
+ veoShots: 2,
391
+ storyShots: 4,
389
392
  lipsyncShots: 2,
390
393
  parallel: 1,
391
394
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
@@ -626,6 +629,42 @@ export async function writeStoryboard(
626
629
  return prompts;
627
630
  }
628
631
 
632
+ const VEO_STORY_SYSTEM = [
633
+ "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.",
634
+ "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.",
635
+ "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.",
636
+ "Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
637
+ ].join(" ");
638
+
639
+ /** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
640
+ export async function writeVeoStory(
641
+ artist: Pick<LabelArtist, "genre" | "sound">,
642
+ members: Array<Pick<Member, "role" | "look">>,
643
+ album: Pick<LabelAlbum, "title" | "concept">,
644
+ song: Pick<LabelSong, "title" | "style" | "lyrics">,
645
+ scenes: number,
646
+ ask: typeof askJson = askJson,
647
+ ): Promise<string[]> {
648
+ 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("; ");
649
+ const answer = await ask({
650
+ system: VEO_STORY_SYSTEM,
651
+ prompt: [
652
+ `Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
653
+ cast ? `The band may appear as characters, described by look: ${cast}` : "",
654
+ `Mood and arrangement: ${song.style}`,
655
+ song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
656
+ `Exactly ${scenes} scenes.`,
657
+ ].filter(Boolean).join("\n"),
658
+ maxTokens: 2500,
659
+ timeoutMs: 90_000,
660
+ });
661
+ const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
662
+ const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
663
+ if (prompts.length < 1) throw new Error("the model wrote no story");
664
+ return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
665
+ + " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
666
+ }
667
+
629
668
  /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
630
669
  export class VeoUnavailable extends Error {}
631
670
 
@@ -636,14 +675,12 @@ const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
636
675
  // so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
637
676
  // behind, in silhouette or between lines, never a close-up of singing.
638
677
  const SHOTS = [
639
- "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",
640
678
  "Close-up of the guitarist's hands on the fretboard and the strings, shallow focus, stage lights flaring",
641
- "The camera glides behind the band members as they play hard, seen from behind, looking out over the crowd",
642
679
  "Low-angle shot of the drummer hitting the cymbals at the song's climax, sticks blurred, sparks of light",
643
- "The lead vocalist from behind, arms raised to the crowd between lines, backlit silhouette, no face to camera",
644
- "Slow aerial push over the crowd toward the stage, fans with hands up, the band tiny in the distance",
645
- "Close-up of the bass player's fingers and the amplifier, then a tilt up to the band in silhouette",
646
- "The band walking out on stage before the show, backlit silhouettes, nobody speaking or singing",
680
+ "Close-up of the bass player's fingers and the amplifier, then a tilt up to the bassist in silhouette",
681
+ "The guitarist and the bassist seen from behind, playing hard toward a huge crowd, backlit",
682
+ "Slow push-in on the drummer in a quiet moment, moody light, sweat and smoke",
683
+ "Slow aerial push over the crowd toward the stage, fans with hands up, the stage tiny in the distance",
647
684
  ];
648
685
 
649
686
  // The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
@@ -665,7 +702,7 @@ export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: P
665
702
  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("; ");
666
703
  const rule = kind === "singer"
667
704
  ? "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."
668
- : "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
705
+ : "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.";
669
706
  return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
670
707
  `Music video for a fictional ${artist.genre} band. ${shot}.`,
671
708
  cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
@@ -1106,8 +1143,10 @@ export class AiLabel {
1106
1143
  if (input["publish"] !== undefined) this.settings.publish = input["publish"] !== false;
1107
1144
  if (input["videos"] !== undefined) this.settings.videos = input["videos"] !== false;
1108
1145
  if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
1109
- const shots = whole("veoShots", 1, 8);
1146
+ const shots = whole("veoShots", 0, 8);
1110
1147
  if (shots !== undefined) this.settings.veoShots = shots;
1148
+ const story = whole("storyShots", 0, 8);
1149
+ if (story !== undefined) this.settings.storyShots = story;
1111
1150
  const close = whole("lipsyncShots", 0, 4);
1112
1151
  if (close !== undefined) this.settings.lipsyncShots = close;
1113
1152
  const perDay = whole("albumsPerDay", 0, 24);
@@ -1350,8 +1389,9 @@ export class AiLabel {
1350
1389
  } catch { /* the asked-for length */ }
1351
1390
  // Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
1352
1391
  const shots = join(_album.folder, `.veo-${song.n}`);
1353
- const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
1354
- if (clips.length < Math.max(2, Math.ceil(this.settings.veoShots / 2))) throw new Error(`only ${clips.length} of ${this.settings.veoShots} shots filmed so far`);
1392
+ const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
1393
+ const wanted = this.settings.veoShots + this.settings.storyShots;
1394
+ if (clips.length < Math.max(2, Math.ceil(wanted / 2))) throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
1355
1395
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1356
1396
  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 } : {}) });
1357
1397
  rmSync(shots, { recursive: true, force: true });
@@ -1414,40 +1454,63 @@ export class AiLabel {
1414
1454
  * Veo's reference images), on stage, in the song's mood. Stops for the
1415
1455
  * month when the budget is spent; a shot Veo will not make is skipped.
1416
1456
  */
1417
- private async filmShots(artist: LabelArtist, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
1457
+ private async filmShots(artist: LabelArtist, album: LabelAlbum, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
1418
1458
  const veo = this.deps.veo;
1419
1459
  if (!veo) return { band: [], singer: [] };
1420
1460
  mkdirSync(dir, { recursive: true });
1421
1461
  mkdirSync(keep, { recursive: true });
1422
1462
  const members = this.cast(artist);
1423
- const references: Buffer[] = [];
1424
- for (const member of members.slice(0, 3)) {
1425
- const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1426
- const small = join(dir, `ref-${member.slug}.jpg`);
1427
- try {
1428
- // 512 px is plenty for a face and keeps the request small.
1429
- 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");
1430
- references.push(readFileSync(small));
1431
- } catch {
1432
- // A member without a portrait is described in words only.
1463
+ // The players for the band shots, the voice for the close-ups: a singer's portrait in a band
1464
+ // shot puts the singer at a microphone, singing out of sync.
1465
+ const voice = (member: Member): boolean => /vocal|sing/i.test(member.role);
1466
+ const players = members.filter((member) => !voice(member));
1467
+ const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
1468
+ const portraits = async (list: Member[]): Promise<Buffer[]> => {
1469
+ const out: Buffer[] = [];
1470
+ for (const member of list.slice(0, 3)) {
1471
+ const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1472
+ const small = join(dir, `ref-${member.slug}.jpg`);
1473
+ try {
1474
+ // 512 px is plenty for a face and keeps the request small.
1475
+ 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");
1476
+ out.push(readFileSync(small));
1477
+ } catch {
1478
+ // A member without a portrait is described in words only.
1479
+ }
1433
1480
  }
1434
- }
1481
+ return out;
1482
+ };
1483
+ const [playerRefs, singerRefs] = [await portraits(players), await portraits(singers)];
1435
1484
  const band: Array<{ path: string; seconds: number }> = [];
1485
+ const story: Array<{ path: string; seconds: number }> = [];
1436
1486
  const singer: Array<{ path: string; seconds: number }> = [];
1437
- // The singer's close-ups only where they can be lip-synced afterwards.
1487
+ // The story is written only while some of its scenes are still to film.
1488
+ const storyFiles = Array.from({ length: this.settings.storyShots }, (_, i) => join(keep, `story-${i}.mp4`));
1489
+ let storyPrompts: string[] = [];
1490
+ if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
1491
+ try {
1492
+ storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
1493
+ } catch (error) {
1494
+ this.log(`no story for "${song.title}" yet: ${(error as Error).message}`);
1495
+ }
1496
+ }
1497
+ // The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
1498
+ // the portraits: they would pull the whole band into every scene.
1438
1499
  const plan = [
1439
- ...veoPrompts(artist, song, members, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band })),
1440
- ...(this.deps.lipsync ? veoPrompts(artist, song, members, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer })) : []),
1500
+ ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[] })),
1501
+ ...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1502
+ ...(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 })) : []),
1441
1503
  ];
1442
- for (const [i, { prompt, file, into }] of plan.entries()) {
1504
+ for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1443
1505
  try {
1444
1506
  if (statSync(file).size > 10_000) { into.push({ path: file, seconds: 8 }); continue; }
1445
1507
  } catch { /* not filmed yet */ }
1508
+ if (!prompt) continue;
1446
1509
  try {
1447
1510
  // nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
1448
1511
  let bytes: Buffer | null = null;
1449
1512
  for (let attempt = 0; ; attempt++) {
1450
- try { bytes = await veo({ prompt, seconds: 8, references }); break; } catch (error) {
1513
+ try { bytes = await veo({ prompt, seconds: 8, references: refs }); break; } catch (error) {
1451
1514
  if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test((error as Error).message)) throw error;
1452
1515
  await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
1453
1516
  }
@@ -1465,8 +1528,14 @@ export class AiLabel {
1465
1528
  this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
1466
1529
  }
1467
1530
  }
1468
- if (band.length || singer.length) this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
1469
- return { band, singer };
1531
+ if (band.length || story.length || singer.length) this.log(`Veo: ${band.length + story.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
1532
+ // The cut takes them in turn: a story scene, then the band playing, and so on.
1533
+ const mixed: Array<{ path: string; seconds: number }> = [];
1534
+ for (let i = 0; i < Math.max(story.length, band.length); i++) {
1535
+ if (story[i]) mixed.push(story[i]!);
1536
+ if (band[i]) mixed.push(band[i]!);
1537
+ }
1538
+ return { band: mixed, singer };
1470
1539
  }
1471
1540
 
1472
1541
  /** The label's music videos as one TV station: free to watch, with its breaks. */
@@ -507,6 +507,7 @@ async function labelCommand(argv: string[], call: Call, show: Show): Promise<num
507
507
  veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
508
508
  veoShots: number("--veo-shots"),
509
509
  lipsyncShots: number("--lipsync-shots"),
510
+ storyShots: number("--story-shots"),
510
511
  genreOrder: flag(argv, "--genres"),
511
512
  enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
512
513
  publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,