nixamp 0.52.1 → 0.53.2

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 (47) hide show
  1. package/dist/ai-label.d.ts +50 -4
  2. package/dist/ai-label.js +221 -36
  3. package/dist/ai-radio.js +14 -2
  4. package/dist/ai-text.d.ts +5 -0
  5. package/dist/ai-text.js +43 -1
  6. package/dist/server.js +11 -6
  7. package/dist/station-routes.d.ts +2 -0
  8. package/dist/station-routes.js +5 -0
  9. package/dist/stations.d.ts +5 -0
  10. package/dist/stations.js +4 -0
  11. package/package.json +1 -1
  12. package/src/ai-label.ts +218 -32
  13. package/src/ai-radio.ts +13 -2
  14. package/src/ai-text.ts +26 -1
  15. package/src/server.ts +11 -6
  16. package/src/station-routes.ts +7 -0
  17. package/src/stations.ts +5 -0
  18. package/web/dist/assets/{ar-Cmon75WL.js → ar-CHGRWAbA.js} +1 -1
  19. package/web/dist/assets/{cs-BRaPWZ-l.js → cs-DG2hfiKN.js} +1 -1
  20. package/web/dist/assets/{da-DzwX-95l.js → da-KT2He0jg.js} +1 -1
  21. package/web/dist/assets/{de-Dp5yZesS.js → de-DXU2uKKW.js} +1 -1
  22. package/web/dist/assets/{es-DI_neNna.js → es-Bc1KFlo2.js} +1 -1
  23. package/web/dist/assets/{fi-RizsR0Qs.js → fi-B_tEGfuT.js} +1 -1
  24. package/web/dist/assets/{fr-Du-Xj-FG.js → fr-DJYssjeG.js} +1 -1
  25. package/web/dist/assets/{hi-K3VZUZDS.js → hi-D9gdzu7j.js} +1 -1
  26. package/web/dist/assets/{hu-s6bvR7Qx.js → hu-CjndMTE_.js} +1 -1
  27. package/web/dist/assets/{id-BtqFTjdZ.js → id-DYaJe6gl.js} +1 -1
  28. package/web/dist/assets/{it-DlTM2OMR.js → it-fihgylYf.js} +1 -1
  29. package/web/dist/assets/{main-DaqdNlZC.js → main-CEkEtWRE.js} +2 -2
  30. package/web/dist/assets/{nl-KW4bKgIq.js → nl-Bh_6FjCg.js} +1 -1
  31. package/web/dist/assets/{radio-Ct2xxni3.css → radio-CtAne8M1.css} +1 -1
  32. package/web/dist/assets/{radio-B8n_n-WD.js → radio-ZiViiR-a.js} +1 -1
  33. package/web/dist/assets/{ru-CZhm9rUN.js → ru-BO7vJb22.js} +1 -1
  34. package/web/dist/assets/{station-Be-z7jQ2.css → station-Bqk5iV8L.css} +1 -1
  35. package/web/dist/assets/{station-CrAZxHcX.js → station-CvUUHCvu.js} +1 -1
  36. package/web/dist/assets/{sv-Bs6WYhqU.js → sv-zP8tkRkk.js} +1 -1
  37. package/web/dist/assets/{uk-OcuLYdVU.js → uk-Bqwr1bUL.js} +1 -1
  38. package/web/dist/assets/{vi-BX_K6R8j.js → vi-B_gIUmc5.js} +1 -1
  39. package/web/dist/assets/{zh-CEzbVQ57.js → zh-DhCVKyZF.js} +1 -1
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/radio.html +9 -4
  42. package/web/dist/station.html +6 -2
  43. package/web/dist/sw.js +26 -26
  44. package/web/dist/vod.html +2 -2
  45. package/web/dist/watch.html +2 -2
  46. /package/web/dist/assets/{vod-CrvTO3FN.js → vod-BSo-pl8M.js} +0 -0
  47. /package/web/dist/assets/{watch-itlvgIf8.js → watch-DBSszFQm.js} +0 -0
@@ -17,6 +17,10 @@ export interface LabelSong {
17
17
  lyricsFile: string;
18
18
  /** Its music video (001. <title>.mp4), "" until made; absent on songs from before videos. */
19
19
  video?: string;
20
+ /** Whether the video carries its song's tags (videos from 0.52.1 and before do not, until retagged). */
21
+ videoTagged?: boolean;
22
+ /** 2 for a storyboard video (scenes that move); absent or 1 for the cover-and-waveform kind, which is made again. */
23
+ videoVersion?: number;
20
24
  /** Who wrote the words and who sang them, e.g. "llama-3.3 + elevenlabs/music_v2_5". */
21
25
  made: string;
22
26
  composedAt: number;
@@ -123,13 +127,30 @@ export declare function writeLyrics(artist: Pick<LabelArtist, "name" | "genre" |
123
127
  * OPENAI_API_KEY its token); null when there is no image model to ask.
124
128
  */
125
129
  export declare function workersAiCover(prompt: string, env?: Record<string, string | undefined>, fetcher?: typeof fetch): Promise<Buffer | null>;
126
- /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
130
+ /** Image prompts for a song's video, one a scene, from its words and mood. */
131
+ export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound">, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
127
132
  /**
128
- * A song's music video: its album cover over a blurred copy of itself, the
129
- * song's waveform along the bottom, 720p H.264 with the MP3's audio copied in.
130
- * Without a cover, a dark plate.
133
+ * A song's music video from its storyboard: each scene's picture moves (a
134
+ * slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
135
+ * the artist and title come up at the start, and the MP3's audio and tags
136
+ * (title, artist, album, words) are copied in. 720p H.264.
137
+ */
138
+ export declare function renderStoryVideo(ffmpeg: string[], input: {
139
+ audio: string;
140
+ images: string[];
141
+ seconds: number;
142
+ artist: string;
143
+ title: string;
144
+ out: string;
145
+ }): Promise<void>;
146
+ /**
147
+ * A song's music video without a storyboard: its album cover over a blurred
148
+ * copy of itself, the song's waveform along the bottom, 720p H.264 with the
149
+ * MP3's audio and tags copied in. Without a cover, a dark plate.
131
150
  */
132
151
  export declare function renderVideo(ffmpeg: string[], audio: string, cover: string, out: string): Promise<void>;
152
+ /** A video made before videos carried tags, given its song's: streams copied, nothing re-encoded. */
153
+ export declare function retagVideo(ffmpeg: string[], video: string, audio: string, out: string): Promise<void>;
133
154
  export interface AiLabelDeps {
134
155
  index: LibraryIndex;
135
156
  store: StationStore;
@@ -148,6 +169,17 @@ export interface AiLabelDeps {
148
169
  tag?: (from: string, to: string, tags: Record<string, string>, cover: string) => Promise<void>;
149
170
  /** Renders a song's music video to `out`; ffmpeg by default (renderVideo). */
150
171
  video?: (audio: string, cover: string, out: string) => Promise<void>;
172
+ /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
173
+ storyVideo?: (input: {
174
+ audio: string;
175
+ images: string[];
176
+ seconds: number;
177
+ artist: string;
178
+ title: string;
179
+ out: string;
180
+ }) => Promise<void>;
181
+ /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
182
+ image?: (prompt: string) => Promise<Buffer | null>;
151
183
  /** The day's pick (artist, genre, new or not); Math.random by default. */
152
184
  random?: () => number;
153
185
  now?: () => number;
@@ -168,6 +200,8 @@ export declare class AiLabel {
168
200
  private filming;
169
201
  /** Songs whose video failed since this server started. */
170
202
  private videoFailed;
203
+ /** Old videos already tried as storyboards since this server started. */
204
+ private upgradeTried;
171
205
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
172
206
  private dailyTriedAt;
173
207
  /** Genre stations are kept in step one change at a time. */
@@ -207,7 +241,17 @@ export declare class AiLabel {
207
241
  */
208
242
  videos(): Promise<number>;
209
243
  private film;
244
+ /**
245
+ * One pass: songs without a video first, then old videos given their
246
+ * song's tags (seconds each), then old cover-and-waveform videos made again
247
+ * as storyboards.
248
+ */
210
249
  private filmPass;
250
+ /**
251
+ * One song's video: a storyboard of scenes when a model can write it and an
252
+ * image model can draw it (version 2), else the cover-and-waveform kind (1).
253
+ */
254
+ private filmSong;
211
255
  /** The label's music videos as one TV station: free to watch, with its breaks. */
212
256
  private placeVideos;
213
257
  stopSchedule(): void;
@@ -262,6 +306,8 @@ export declare class AiLabel {
262
306
  artistName: string;
263
307
  albumTitle: string;
264
308
  } | null;
309
+ /** The song's MP3 when `file` is one of the label's music videos (it holds the tags, cover and words); else `file`. */
310
+ audioFor(file: string): string;
265
311
  /** A released song's MP3, when this grant's scope covers it. */
266
312
  songFile(artistKey: string, albumKey: string, n: number): string;
267
313
  /**
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, writeFileSync } from "node:fs";
31
+ import { 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";
@@ -408,11 +408,97 @@ export async function workersAiCover(prompt, env = process.env, fetcher = fetch)
408
408
  throw new Error("the cover model answered with no picture");
409
409
  return image;
410
410
  }
411
- /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
411
+ const STORYBOARD_SYSTEM = [
412
+ "You storyboard music videos for original songs. Write one image prompt per scene, in order, following the song's story and mood.",
413
+ "Keep the same main character(s), setting and color palette across scenes so it reads as one video; vary the shots (wide, close-up, action, detail).",
414
+ "Each prompt: cinematic, photorealistic or stylized to suit the genre, at most 300 characters. No text, letters, logos, real people or real brands.",
415
+ "Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
416
+ ].join(" ");
417
+ /** Image prompts for a song's video, one a scene, from its words and mood. */
418
+ export async function writeStoryboard(artist, album, song, scenes, ask = askJson) {
419
+ const answer = await ask({
420
+ system: STORYBOARD_SYSTEM,
421
+ prompt: [
422
+ `Song: "${song.title}" by ${artist.name} (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
423
+ `Mood and arrangement: ${song.style}`,
424
+ song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
425
+ `Exactly ${scenes} scenes.`,
426
+ ].filter(Boolean).join("\n"),
427
+ maxTokens: 2500,
428
+ timeoutMs: 90_000,
429
+ });
430
+ const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
431
+ const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
432
+ if (prompts.length < 2)
433
+ throw new Error("the model wrote no storyboard");
434
+ return prompts;
435
+ }
436
+ /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
437
+ function titleFont() {
438
+ 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"]) {
439
+ try {
440
+ if (statSync(file).isFile())
441
+ return file;
442
+ }
443
+ catch { /* next */ }
444
+ }
445
+ return "";
446
+ }
447
+ /** ffmpeg at the lowest priority, so the stations it shares the machine with come first. */
448
+ function runLow(ffmpeg, args, timeout, what) {
449
+ const nice = process.platform === "linux" ? (() => { try {
450
+ return statSync("/usr/bin/nice").isFile();
451
+ }
452
+ catch {
453
+ return false;
454
+ } })() : false;
455
+ const [bin, rest] = nice ? ["/usr/bin/nice", ["-n", "19", ffmpeg[0], ...args]] : [ffmpeg[0], args];
456
+ return new Promise((done, fail) => {
457
+ execFile(bin, rest, { timeout, maxBuffer: 4 * 1024 * 1024 }, (error) => (error ? fail(new Error(`${what}: ${error.message}`)) : done()));
458
+ });
459
+ }
412
460
  /**
413
- * A song's music video: its album cover over a blurred copy of itself, the
414
- * song's waveform along the bottom, 720p H.264 with the MP3's audio copied in.
415
- * Without a cover, a dark plate.
461
+ * A song's music video from its storyboard: each scene's picture moves (a
462
+ * slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
463
+ * the artist and title come up at the start, and the MP3's audio and tags
464
+ * (title, artist, album, words) are copied in. 720p H.264.
465
+ */
466
+ export function renderStoryVideo(ffmpeg, input) {
467
+ const fade = 1;
468
+ const count = input.images.length;
469
+ const each = input.seconds / count + fade;
470
+ const frames = Math.max(25, Math.round(each * 25));
471
+ const moves = [
472
+ `z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
473
+ `z='1.15':x='(iw-iw/zoom)*on/${frames}':y='ih/2-(ih/zoom/2)'`,
474
+ `z='1.15-0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
475
+ `z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
476
+ ];
477
+ const parts = [];
478
+ input.images.forEach((_, i) => {
479
+ parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[i % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
480
+ });
481
+ let last = "v0";
482
+ for (let i = 1; i < count; i++) {
483
+ parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${(i * (each - fade)).toFixed(2)}[x${i}]`);
484
+ last = `x${i}`;
485
+ }
486
+ const font = titleFont();
487
+ const card = `${input.artist} — ${input.title}`.replace(/[\\':%]/g, " ").slice(0, 80);
488
+ parts.push(font
489
+ ? `[${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]`
490
+ : `[${last}]format=yuv420p[v]`);
491
+ const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
492
+ ...input.images.flatMap((image) => ["-loop", "1", "-t", each.toFixed(2), "-i", image]),
493
+ "-i", input.audio, "-filter_complex", parts.join(";"),
494
+ "-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
495
+ "-movflags", "+faststart+use_metadata_tags", input.out];
496
+ return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
497
+ }
498
+ /**
499
+ * A song's music video without a storyboard: its album cover over a blurred
500
+ * copy of itself, the song's waveform along the bottom, 720p H.264 with the
501
+ * MP3's audio and tags copied in. Without a cover, a dark plate.
416
502
  */
417
503
  export function renderVideo(ffmpeg, audio, cover, out) {
418
504
  const picture = cover ? ["-loop", "1", "-framerate", "25", "-i", cover] : ["-f", "lavfi", "-i", "color=c=0x0d1410:s=1280x720:r=25"];
@@ -424,11 +510,19 @@ export function renderVideo(ffmpeg, audio, cover, out) {
424
510
  "[a][viz]overlay=0:H-160,format=yuv420p[v]",
425
511
  ].join(";");
426
512
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", ...picture, "-i", audio, "-filter_complex", graph,
427
- "-map", "[v]", "-map", "1:a", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest", "-movflags", "+faststart", out];
513
+ "-map", "[v]", "-map", "1:a", "-map_metadata", "1", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-r", "25", "-c:a", "copy", "-shortest",
514
+ "-movflags", "+faststart+use_metadata_tags", out];
515
+ return runLow(ffmpeg, args, 20 * 60_000, "the video failed");
516
+ }
517
+ /** A video made before videos carried tags, given its song's: streams copied, nothing re-encoded. */
518
+ export function retagVideo(ffmpeg, video, audio, out) {
519
+ const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", video, "-i", audio,
520
+ "-map", "0:v", "-map", "0:a", "-map_metadata", "1", "-c", "copy", "-movflags", "+faststart+use_metadata_tags", out];
428
521
  return new Promise((done, fail) => {
429
- execFile(ffmpeg[0], args, { timeout: 20 * 60_000 }, (error) => (error ? fail(new Error(`the video failed: ${error.message}`)) : done()));
522
+ execFile(ffmpeg[0], args, { timeout: 5 * 60_000 }, (error) => (error ? fail(new Error(`retagging the video failed: ${error.message}`)) : done()));
430
523
  });
431
524
  }
525
+ /** Writes the tags (and the cover, as the attached picture) into a copy of the file. */
432
526
  function tagFile(ffmpeg, from, to, tags, cover) {
433
527
  const args = [...ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", from];
434
528
  if (cover)
@@ -461,6 +555,8 @@ export class AiLabel {
461
555
  filming = null;
462
556
  /** Songs whose video failed since this server started. */
463
557
  videoFailed = new Set();
558
+ /** Old videos already tried as storyboards since this server started. */
559
+ upgradeTried = new Set();
464
560
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
465
561
  dailyTriedAt = 0;
466
562
  /** Genre stations are kept in step one change at a time. */
@@ -743,40 +839,115 @@ export class AiLabel {
743
839
  total += made;
744
840
  return total;
745
841
  }
842
+ /**
843
+ * One pass: songs without a video first, then old videos given their
844
+ * song's tags (seconds each), then old cover-and-waveform videos made again
845
+ * as storyboards.
846
+ */
746
847
  async filmPass() {
848
+ const songs = this.list().flatMap((artist) => artist.albums
849
+ .filter((album) => album.cover || album.coverTried)
850
+ .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
851
+ const missing = songs.filter(({ song }) => !song.video);
852
+ const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
853
+ const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
747
854
  let made = 0;
748
- {
749
- for (const artist of this.list()) {
750
- for (const album of artist.albums) {
751
- if (!album.cover && !album.coverTried)
752
- continue;
753
- for (const song of album.songs) {
754
- if (!song.file || song.video || this.videoFailed.has(song.file) || !this.settings.videos)
755
- continue;
756
- const out = join(album.folder, `${songBase(song)}.mp4`);
757
- const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
758
- try {
759
- await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, partial);
760
- renameSync(partial, out);
761
- song.video = out;
762
- made += 1;
763
- this.save();
764
- // On the air as soon as it is made, not after the whole pass.
765
- await this.placeVideos().catch(() => undefined);
766
- }
767
- catch (error) {
768
- this.videoFailed.add(song.file);
769
- this.log(`no video for "${song.title}": ${error.message}`);
770
- }
771
- finally {
772
- rmSync(partial, { force: true });
773
- }
774
- }
775
- }
855
+ for (const { album, song } of untagged) {
856
+ if (!this.settings.videos)
857
+ break;
858
+ // Made before videos carried tags: give it its song's, in place.
859
+ const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.retag.mp4`);
860
+ try {
861
+ await retagVideo(this.deps.ffmpeg, song.video, song.file, partial);
862
+ renameSync(partial, song.video);
863
+ song.videoTagged = true;
864
+ made += 1;
865
+ this.save();
866
+ }
867
+ catch (error) {
868
+ this.log(`could not tag the video of "${song.title}": ${error.message}`);
869
+ song.videoTagged = true; // a storyboard remake brings the tags anyway
870
+ }
871
+ finally {
872
+ rmSync(partial, { force: true });
873
+ }
874
+ }
875
+ for (const { artist, album, song } of [...missing, ...old]) {
876
+ if (!this.settings.videos)
877
+ break;
878
+ // An old video is only made again as a storyboard, and tried once a run.
879
+ const upgrade = Boolean(song.video);
880
+ if (upgrade)
881
+ this.upgradeTried.add(song.file);
882
+ const out = join(album.folder, `${songBase(song)}.mp4`);
883
+ const partial = join(album.folder, `.${randomBytes(4).toString("hex")}.video.mp4`);
884
+ const scenes = join(album.folder, `.scenes-${song.n}-${randomBytes(3).toString("hex")}`);
885
+ try {
886
+ song.videoVersion = await this.filmSong(artist, album, song, scenes, partial, upgrade);
887
+ renameSync(partial, out);
888
+ song.video = out;
889
+ song.videoTagged = true;
890
+ made += 1;
891
+ this.save();
892
+ // On the air as soon as it is made, not after the whole pass.
893
+ await this.placeVideos().catch(() => undefined);
894
+ }
895
+ catch (error) {
896
+ if (!upgrade)
897
+ this.videoFailed.add(song.file);
898
+ this.log(`no ${upgrade ? "storyboard " : ""}video for "${song.title}": ${error.message}`);
899
+ }
900
+ finally {
901
+ rmSync(partial, { force: true });
902
+ rmSync(scenes, { recursive: true, force: true });
776
903
  }
777
904
  }
778
905
  return made;
779
906
  }
907
+ /**
908
+ * One song's video: a storyboard of scenes when a model can write it and an
909
+ * image model can draw it (version 2), else the cover-and-waveform kind (1).
910
+ */
911
+ async filmSong(artist, album, song, scenes, out, storyOnly = false) {
912
+ let seconds = song.seconds;
913
+ try {
914
+ const probed = await this.deps.probe(song.file);
915
+ if (probed.duration > 5)
916
+ seconds = probed.duration;
917
+ }
918
+ catch { /* the asked-for length */ }
919
+ const images = [];
920
+ if (this.canAsk()) {
921
+ try {
922
+ const count = Math.max(4, Math.min(16, Math.round(seconds / 9)));
923
+ const prompts = await writeStoryboard(artist, album, song, count, this.deps.ask ?? askJson);
924
+ mkdirSync(scenes, { recursive: true });
925
+ const draw = this.deps.image ?? ((prompt) => workersAiCover(prompt));
926
+ // A few at a time: the image model is quick, the machine is shared.
927
+ for (let i = 0; i < prompts.length; i += 3) {
928
+ const drawn = await Promise.all(prompts.slice(i, i + 3).map((prompt) => draw(`${prompt} Cinematic 16:9 music video still, no text.`).catch(() => null)));
929
+ drawn.forEach((picture, j) => {
930
+ if (!picture)
931
+ return;
932
+ const file = join(scenes, `${String(i + j).padStart(2, "0")}.jpg`);
933
+ writeFileSync(file, picture);
934
+ images.push(file);
935
+ });
936
+ }
937
+ }
938
+ catch (error) {
939
+ this.log(`no storyboard for "${song.title}" (${error.message}); the cover video instead`);
940
+ }
941
+ }
942
+ if (images.length >= 2) {
943
+ await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out });
944
+ return 2;
945
+ }
946
+ if (storyOnly)
947
+ throw new Error("no storyboard could be drawn; the video it has stays");
948
+ await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
949
+ return 1;
950
+ }
780
951
  /** The label's music videos as one TV station: free to watch, with its breaks. */
781
952
  placeVideos() {
782
953
  const next = this.placing.then(async () => {
@@ -874,7 +1045,8 @@ export class AiLabel {
874
1045
  return "composed";
875
1046
  }
876
1047
  // Room for another album: the artist most overdue (in the owner's genre order) gets its next one.
877
- const inProgress = this.list().filter((one) => one.albums.some((album) => !album.doneAt)).length + this.planning.size;
1048
+ // A paused artist's unfinished album waits without holding a slot.
1049
+ const inProgress = this.list().filter((one) => one.enabled && one.albums.some((album) => !album.doneAt)).length + this.planning.size;
878
1050
  if (inProgress >= Math.max(1, this.settings.parallel))
879
1051
  return "idle";
880
1052
  if (!this.canAsk() || this.settings.songSeconds + claimedSeconds > this.budgetLeft())
@@ -1155,6 +1327,19 @@ export class AiLabel {
1155
1327
  }
1156
1328
  return null;
1157
1329
  }
1330
+ /** The song's MP3 when `file` is one of the label's music videos (it holds the tags, cover and words); else `file`. */
1331
+ audioFor(file) {
1332
+ if (!file.endsWith(".mp4"))
1333
+ return file;
1334
+ for (const artist of this.artists.values()) {
1335
+ for (const album of artist.albums) {
1336
+ const song = album.songs.find((one) => one.video === file);
1337
+ if (song)
1338
+ return song.file;
1339
+ }
1340
+ }
1341
+ return file;
1342
+ }
1158
1343
  /** A released song's MP3, when this grant's scope covers it. */
1159
1344
  songFile(artistKey, albumKey, n) {
1160
1345
  return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
package/dist/ai-radio.js CHANGED
@@ -60,7 +60,19 @@ export function elevenLabsComposer(key, fetcher = fetch, model = "music_v2_5") {
60
60
  ...(words ? { lyrics_text: words } : { force_instrumental: !vocals }),
61
61
  }),
62
62
  });
63
- let response = await ask(prompt);
63
+ // Too many at once (several songs are made in parallel): wait and ask again, a few times.
64
+ const patient = async (text) => {
65
+ let answer = await ask(text);
66
+ for (const wait of [15_000, 30_000, 60_000]) {
67
+ if (answer.status !== 429)
68
+ break;
69
+ await answer.body?.cancel().catch(() => undefined);
70
+ await new Promise((done) => setTimeout(done, wait));
71
+ answer = await ask(text);
72
+ }
73
+ return answer;
74
+ };
75
+ let response = await patient(prompt);
64
76
  if (response.status === 400) {
65
77
  // A brief it will not make (an artist's name, a lyric) comes back
66
78
  // with one it will; take it once.
@@ -68,7 +80,7 @@ export function elevenLabsComposer(key, fetcher = fetch, model = "music_v2_5") {
68
80
  const suggestion = body.detail?.data?.prompt_suggestion;
69
81
  if (!suggestion)
70
82
  throw new Error(`elevenlabs refused the brief (${body.detail?.status ?? "HTTP 400"})`);
71
- response = await ask(suggestion);
83
+ response = await patient(suggestion);
72
84
  }
73
85
  if (response.status === 401 || response.status === 402)
74
86
  throw new OutOfCredit(`elevenlabs answered HTTP ${response.status}: the key or the plan's credit`);
package/dist/ai-text.d.ts CHANGED
@@ -18,6 +18,11 @@ export interface Answer {
18
18
  export declare class NoModel extends Error {
19
19
  constructor();
20
20
  }
21
+ /**
22
+ * Raw line breaks and tabs inside JSON strings, escaped: models writing song
23
+ * words often break the lines for real inside the quotes, which JSON refuses.
24
+ */
25
+ export declare function escapeInStrings(text: string): string;
21
26
  /** The first JSON object in a model's answer. */
22
27
  export declare function firstObject(text: string): Record<string, unknown>;
23
28
  /** Whether any model can be asked. */
package/dist/ai-text.js CHANGED
@@ -14,9 +14,51 @@ export class NoModel extends Error {
14
14
  super("no language model key is set (GEMINI_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY)");
15
15
  }
16
16
  }
17
+ /**
18
+ * Raw line breaks and tabs inside JSON strings, escaped: models writing song
19
+ * words often break the lines for real inside the quotes, which JSON refuses.
20
+ */
21
+ export function escapeInStrings(text) {
22
+ let out = "";
23
+ let inString = false;
24
+ let escaped = false;
25
+ for (const char of text) {
26
+ if (inString) {
27
+ if (escaped)
28
+ escaped = false;
29
+ else if (char === "\\")
30
+ escaped = true;
31
+ else if (char === "\"")
32
+ inString = false;
33
+ else if (char === "\n") {
34
+ out += "\\n";
35
+ continue;
36
+ }
37
+ else if (char === "\r")
38
+ continue;
39
+ else if (char === "\t") {
40
+ out += "\\t";
41
+ continue;
42
+ }
43
+ }
44
+ else if (char === "\"")
45
+ inString = true;
46
+ out += char;
47
+ }
48
+ return out;
49
+ }
17
50
  /** The first JSON object in a model's answer. */
18
51
  export function firstObject(text) {
19
- const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
52
+ const fenced = text.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
53
+ const cleaned = (() => {
54
+ try {
55
+ JSON.parse(fenced);
56
+ return fenced;
57
+ }
58
+ catch {
59
+ return escapeInStrings(fenced);
60
+ }
61
+ })();
20
62
  try {
21
63
  const parsed = JSON.parse(cleaned);
22
64
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
package/dist/server.js CHANGED
@@ -2623,6 +2623,7 @@ export function createHandler(engine, options) {
2623
2623
  },
2624
2624
  trackFor: (file) => trackFor(engine, file),
2625
2625
  ...(options.library ? { isVideo: (file) => options.library.index.itemAt(file)?.video === true } : {}),
2626
+ ...(options.library ? { durationOf: (file) => options.library.index.itemAt(file)?.duration ?? 0 } : {}),
2626
2627
  base: `${scheme}://${request.headers.host ?? "localhost"}`,
2627
2628
  publicBase: options.stations.publicBase?.() ?? "",
2628
2629
  json,
@@ -6862,7 +6863,9 @@ export async function serve(argv, version = "0.1.0") {
6862
6863
  stations.holders = holderStore;
6863
6864
  // Covers read once per file (and its size and time), a few hundred kept.
6864
6865
  const covers = new Map();
6865
- stations.cover = async (file) => {
6866
+ stations.cover = async (playing) => {
6867
+ // A label music video's cover is its song's.
6868
+ const file = aiLabel.audioFor(playing);
6866
6869
  let key = "";
6867
6870
  try {
6868
6871
  const info = statSync(file);
@@ -6883,7 +6886,7 @@ export async function serve(argv, version = "0.1.0") {
6883
6886
  };
6884
6887
  // The words of what is on: the file's own tag, else nichedb.dev's.
6885
6888
  const lyricsFinder = new LyricsFinder({ tags: (file) => tagReader.read(file), known: (file) => trackFor(engine, file) });
6886
- stations.lyrics = (_station, file) => lyricsFinder.find(file);
6889
+ stations.lyrics = (_station, file) => lyricsFinder.find(aiLabel.audioFor(file));
6887
6890
  // Our own songs' words, back to nichedb, when a key is set: on any station
6888
6891
  // change, after a library scan, and once at start.
6889
6892
  const lyricsPublisher = new LyricsPublisher({
@@ -6914,9 +6917,11 @@ export async function serve(argv, version = "0.1.0") {
6914
6917
  stamp = "";
6915
6918
  }
6916
6919
  const art = picture ? `/api/v1/stations/${station.id}/art?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` : "";
6917
- const words = await lyricsFinder.find(file).catch(() => null);
6920
+ // A label music video is told about as its song: the MP3 holds the tags and the words.
6921
+ const audio = aiLabel.audioFor(file);
6922
+ const words = await lyricsFinder.find(audio).catch(() => null);
6918
6923
  // A label song is for sale on nixamp.com: the song, its album, everything by the artist.
6919
- const song = aiLabel.songAt(file);
6924
+ const song = aiLabel.songAt(audio);
6920
6925
  const here = stations.publicBase?.() ?? "";
6921
6926
  const shop = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
6922
6927
  const store = song && /^https:\/\//.test(here) ? {
@@ -6930,8 +6935,8 @@ export async function serve(argv, version = "0.1.0") {
6930
6935
  art,
6931
6936
  ...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
6932
6937
  station, file,
6933
- tags: await tagReader.read(file),
6934
- known: trackFor(engine, file),
6938
+ tags: await tagReader.read(audio),
6939
+ known: trackFor(engine, audio),
6935
6940
  holders: holderStore.list(),
6936
6941
  share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
6937
6942
  aiTrack: composed ? { made: composed.made } : null,
@@ -64,6 +64,8 @@ export interface StationContext {
64
64
  } | null;
65
65
  /** What a listener may know about the track on the air (track-facts.ts). */
66
66
  facts?: (station: Station, file: string) => Promise<TrackFacts>;
67
+ /** How long a file is, in seconds (the library's probe); 0 when unknown. For the page's progress bar. */
68
+ durationOf?: (file: string) => number;
67
69
  /** The cover picture inside a file (JPEG/PNG bytes), or null. */
68
70
  cover?: (file: string) => Promise<{
69
71
  bytes: Buffer;
@@ -41,6 +41,10 @@ export function publicView(ctx, station) {
41
41
  // What the channel is carrying wins over the flag: a station made by hand
42
42
  // from films streams video whether or not anyone said so.
43
43
  const video = station.video || ctx.channels.kindOf(channelOf(station.id)) === "video";
44
+ // How far into the song (or film) on the air, by the encoder's clock, and how long it is.
45
+ const position = file ? ctx.channels.info(channelOf(station.id))?.position : undefined;
46
+ const duration = file && ctx.durationOf ? ctx.durationOf(file) : 0;
47
+ const progress = typeof position === "number" && duration > 0 ? { position: Math.round(Math.min(position, duration) * 10) / 10, duration: Math.round(duration * 10) / 10 } : null;
44
48
  return {
45
49
  id: station.id,
46
50
  name: station.name,
@@ -49,6 +53,7 @@ export function publicView(ctx, station) {
49
53
  state,
50
54
  // Tags, or nothing: a bare file name is the owner's, not the public's.
51
55
  nowPlaying: track && (track.title || track.artist) ? track : null,
56
+ progress,
52
57
  kind: video ? "video" : "audio",
53
58
  format: state === "stopped" || state === "offline" ? null : video
54
59
  ? { codec: "h264+aac", bitrateKbps: station.bitrate, sampleRate: 48_000, channels: 2 }
@@ -149,6 +149,11 @@ export interface PublicStation {
149
149
  artist: string;
150
150
  album: string;
151
151
  } | null;
152
+ /** Seconds into what is on and how long it is, when both are known. */
153
+ progress?: {
154
+ position: number;
155
+ duration: number;
156
+ } | null;
152
157
  format: {
153
158
  codec: string;
154
159
  bitrateKbps: number;
package/dist/stations.js CHANGED
@@ -284,6 +284,10 @@ export class StationStore {
284
284
  let listed = access.listed === undefined ? station.listed : access.listed === true;
285
285
  if (visibility !== "public")
286
286
  listed = false;
287
+ // Made private, a station forgets what was declared for it: going public
288
+ // again asks afresh, so a stale (or false) declaration cannot republish it.
289
+ if (visibility === "private" && !access.rights)
290
+ station.rights = null;
287
291
  if (access.rights) {
288
292
  const basis = access.rights.basis;
289
293
  if (typeof basis !== "string" || !RIGHTS_BASES.includes(basis)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.52.1",
3
+ "version": "0.53.2",
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",