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.
- package/dist/ai-label.d.ts +50 -4
- package/dist/ai-label.js +221 -36
- package/dist/ai-radio.js +14 -2
- package/dist/ai-text.d.ts +5 -0
- package/dist/ai-text.js +43 -1
- package/dist/server.js +11 -6
- package/dist/station-routes.d.ts +2 -0
- package/dist/station-routes.js +5 -0
- package/dist/stations.d.ts +5 -0
- package/dist/stations.js +4 -0
- package/package.json +1 -1
- package/src/ai-label.ts +218 -32
- package/src/ai-radio.ts +13 -2
- package/src/ai-text.ts +26 -1
- package/src/server.ts +11 -6
- package/src/station-routes.ts +7 -0
- package/src/stations.ts +5 -0
- package/web/dist/assets/{ar-Cmon75WL.js → ar-CHGRWAbA.js} +1 -1
- package/web/dist/assets/{cs-BRaPWZ-l.js → cs-DG2hfiKN.js} +1 -1
- package/web/dist/assets/{da-DzwX-95l.js → da-KT2He0jg.js} +1 -1
- package/web/dist/assets/{de-Dp5yZesS.js → de-DXU2uKKW.js} +1 -1
- package/web/dist/assets/{es-DI_neNna.js → es-Bc1KFlo2.js} +1 -1
- package/web/dist/assets/{fi-RizsR0Qs.js → fi-B_tEGfuT.js} +1 -1
- package/web/dist/assets/{fr-Du-Xj-FG.js → fr-DJYssjeG.js} +1 -1
- package/web/dist/assets/{hi-K3VZUZDS.js → hi-D9gdzu7j.js} +1 -1
- package/web/dist/assets/{hu-s6bvR7Qx.js → hu-CjndMTE_.js} +1 -1
- package/web/dist/assets/{id-BtqFTjdZ.js → id-DYaJe6gl.js} +1 -1
- package/web/dist/assets/{it-DlTM2OMR.js → it-fihgylYf.js} +1 -1
- package/web/dist/assets/{main-DaqdNlZC.js → main-CEkEtWRE.js} +2 -2
- package/web/dist/assets/{nl-KW4bKgIq.js → nl-Bh_6FjCg.js} +1 -1
- package/web/dist/assets/{radio-Ct2xxni3.css → radio-CtAne8M1.css} +1 -1
- package/web/dist/assets/{radio-B8n_n-WD.js → radio-ZiViiR-a.js} +1 -1
- package/web/dist/assets/{ru-CZhm9rUN.js → ru-BO7vJb22.js} +1 -1
- package/web/dist/assets/{station-Be-z7jQ2.css → station-Bqk5iV8L.css} +1 -1
- package/web/dist/assets/{station-CrAZxHcX.js → station-CvUUHCvu.js} +1 -1
- package/web/dist/assets/{sv-Bs6WYhqU.js → sv-zP8tkRkk.js} +1 -1
- package/web/dist/assets/{uk-OcuLYdVU.js → uk-Bqwr1bUL.js} +1 -1
- package/web/dist/assets/{vi-BX_K6R8j.js → vi-B_gIUmc5.js} +1 -1
- package/web/dist/assets/{zh-CEzbVQ57.js → zh-DhCVKyZF.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/radio.html +9 -4
- package/web/dist/station.html +6 -2
- package/web/dist/sw.js +26 -26
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- /package/web/dist/assets/{vod-CrvTO3FN.js → vod-BSo-pl8M.js} +0 -0
- /package/web/dist/assets/{watch-itlvgIf8.js → watch-DBSszFQm.js} +0 -0
package/dist/ai-label.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
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
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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",
|
|
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:
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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(
|
|
6934
|
-
known: trackFor(engine,
|
|
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,
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -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;
|
package/dist/station-routes.js
CHANGED
|
@@ -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 }
|
package/dist/stations.d.ts
CHANGED
|
@@ -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)) {
|