nixamp 0.52.2 → 0.53.3
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 +219 -35
- package/dist/channels.js +3 -0
- package/dist/playlist-input.d.ts +9 -1
- package/dist/playlist-input.js +10 -4
- package/dist/server.js +12 -6
- package/dist/station-routes.d.ts +2 -0
- package/dist/station-routes.js +5 -0
- package/dist/station-runner.d.ts +7 -0
- package/dist/station-runner.js +16 -2
- package/dist/stations.d.ts +13 -0
- package/dist/stations.js +36 -0
- package/package.json +1 -1
- package/src/ai-label.ts +216 -31
- package/src/channels.ts +2 -0
- package/src/playlist-input.ts +14 -8
- package/src/server.ts +12 -6
- package/src/station-routes.ts +7 -0
- package/src/station-runner.ts +18 -2
- package/src/stations.ts +35 -0
- package/web/dist/assets/{ar-Cu3HNStu.js → ar-DsQNv_tK.js} +1 -1
- package/web/dist/assets/{cs-uqfWO8Tj.js → cs-BWVuDS0X.js} +1 -1
- package/web/dist/assets/{da-D3ZenKKD.js → da-yMLNhkak.js} +1 -1
- package/web/dist/assets/{de-D7hSBOda.js → de-DVSMvq22.js} +1 -1
- package/web/dist/assets/{es-Dr-i4Jfe.js → es-B5xdyPjG.js} +1 -1
- package/web/dist/assets/{fi-DL7cLP_e.js → fi-_hVnUyE3.js} +1 -1
- package/web/dist/assets/{fr-C-_Cil5p.js → fr-ChOWYR7D.js} +1 -1
- package/web/dist/assets/{hi-BG6Nbvu1.js → hi-Dtw7Mrwj.js} +1 -1
- package/web/dist/assets/{hu-CO6uMAnx.js → hu-DSrmbSIn.js} +1 -1
- package/web/dist/assets/{id-D_0dB7Cc.js → id-BF5hzSrf.js} +1 -1
- package/web/dist/assets/{it-KC5QkD3e.js → it-Df8Yov7v.js} +1 -1
- package/web/dist/assets/{main-D8ga1_tc.js → main-CbkdPOhq.js} +2 -2
- package/web/dist/assets/{nl-DJtjRzKr.js → nl-DUHEGAjS.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-By0X_8As.js → ru-BTX4-kNd.js} +1 -1
- package/web/dist/assets/{station-Be-z7jQ2.css → station-Bqk5iV8L.css} +1 -1
- package/web/dist/assets/{station-CrAZxHcX.js → station-DUt85zQk.js} +1 -1
- package/web/dist/assets/{sv-Dt4z_CkV.js → sv-DT2zVu1w.js} +1 -1
- package/web/dist/assets/{uk-B39fMx80.js → uk-V9OBaE62.js} +1 -1
- package/web/dist/assets/{vi-BnlBuEHd.js → vi-DaV25yrA.js} +1 -1
- package/web/dist/assets/{zh-5rQ6L9ym.js → zh-BgJ6hib9.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 () => {
|
|
@@ -1156,6 +1327,19 @@ export class AiLabel {
|
|
|
1156
1327
|
}
|
|
1157
1328
|
return null;
|
|
1158
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
|
+
}
|
|
1159
1343
|
/** A released song's MP3, when this grant's scope covers it. */
|
|
1160
1344
|
songFile(artistKey, albumKey, n) {
|
|
1161
1345
|
return this.artists.get(artistKey)?.albums.find((one) => one.key === albumKey)?.songs.find((one) => one.n === n && one.file)?.file ?? "";
|
package/dist/channels.js
CHANGED
|
@@ -414,6 +414,9 @@ export class Channel {
|
|
|
414
414
|
const seconds = Number(match[1]) / 1e6;
|
|
415
415
|
if (playlistInput) {
|
|
416
416
|
const place = playlistInput.place(seconds);
|
|
417
|
+
// A break (-1) belongs to no list; a track's index belongs to its own.
|
|
418
|
+
if (place.at >= 0 && this.info.playlist !== place.list)
|
|
419
|
+
this.info.playlist = place.list;
|
|
417
420
|
this.info.playlistAt = place.at;
|
|
418
421
|
this.info.position = place.position;
|
|
419
422
|
}
|
package/dist/playlist-input.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface PlaylistInput {
|
|
|
17
17
|
* pass: a station-wide shuffle is one order for everybody listening, drawn
|
|
18
18
|
* again each time round. Unset is the list's own order.
|
|
19
19
|
*/
|
|
20
|
-
order?: (length: number) => number[];
|
|
20
|
+
order?: (length: number, files: readonly string[]) => number[];
|
|
21
21
|
/** Read at every entry boundary, so an edit to the list lands on the next track. */
|
|
22
22
|
current?: () => string[];
|
|
23
23
|
/** Entries already known not to play, passed over without being tried. */
|
|
@@ -77,10 +77,18 @@ export declare class PlaylistInputStream {
|
|
|
77
77
|
file: string;
|
|
78
78
|
start: number;
|
|
79
79
|
position: number;
|
|
80
|
+
list: readonly string[];
|
|
80
81
|
}>;
|
|
82
|
+
/**
|
|
83
|
+
* What is on at this point of the stream. `at` indexes `list`, the list as
|
|
84
|
+
* it was when that entry started: a station re-reads its list at every
|
|
85
|
+
* boundary, so after the library changes the index no longer fits the
|
|
86
|
+
* list the channel was dialled with.
|
|
87
|
+
*/
|
|
81
88
|
place(seconds: number): {
|
|
82
89
|
at: number;
|
|
83
90
|
position: number;
|
|
91
|
+
list: readonly string[];
|
|
84
92
|
};
|
|
85
93
|
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
86
94
|
private stopCurrent;
|
package/dist/playlist-input.js
CHANGED
|
@@ -29,10 +29,16 @@ export class PlaylistInputStream {
|
|
|
29
29
|
get history() {
|
|
30
30
|
return this.entries;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* What is on at this point of the stream. `at` indexes `list`, the list as
|
|
34
|
+
* it was when that entry started: a station re-reads its list at every
|
|
35
|
+
* boundary, so after the library changes the index no longer fits the
|
|
36
|
+
* list the channel was dialled with.
|
|
37
|
+
*/
|
|
32
38
|
place(seconds) {
|
|
33
39
|
const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
|
|
34
|
-
return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
|
|
35
|
-
: { at: this.options.at, position: this.options.position };
|
|
40
|
+
return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position), list: entry.list }
|
|
41
|
+
: { at: this.options.at, position: this.options.position, list: this.options.files };
|
|
36
42
|
}
|
|
37
43
|
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
38
44
|
stopCurrent = null;
|
|
@@ -55,7 +61,7 @@ export class PlaylistInputStream {
|
|
|
55
61
|
let failuresInARow = 0;
|
|
56
62
|
let first = true;
|
|
57
63
|
try {
|
|
58
|
-
const orderFor = (list) => o.order ? o.order(list.length).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
|
|
64
|
+
const orderFor = (list) => o.order ? o.order(list.length, list).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
|
|
59
65
|
let files = o.current?.() ?? o.files;
|
|
60
66
|
if (files.length === 0)
|
|
61
67
|
throw new Error("The playlist is empty");
|
|
@@ -140,7 +146,7 @@ export class PlaylistInputStream {
|
|
|
140
146
|
const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
|
|
141
147
|
const cut = room >= 1 && full - room > 5;
|
|
142
148
|
const duration = cut ? room : full;
|
|
143
|
-
this.entries.push({ at, file, start: offset, position });
|
|
149
|
+
this.entries.push({ at, file, start: offset, position, list: files });
|
|
144
150
|
const hasVideo = Boolean(codecs.video);
|
|
145
151
|
const extra = o.video && !hasVideo
|
|
146
152
|
? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
|
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,
|
|
@@ -6701,6 +6702,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6701
6702
|
breaks: (video) => stationBreaks.next(video ? "video" : "audio"),
|
|
6702
6703
|
onRestart: (id) => stationUsage.restarted(id),
|
|
6703
6704
|
onFailure: (id) => stationUsage.failed(id),
|
|
6705
|
+
artistOf: (file) => trackFor(engine, file)?.artist ?? "",
|
|
6704
6706
|
store: stationStore,
|
|
6705
6707
|
channels,
|
|
6706
6708
|
pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
|
|
@@ -6862,7 +6864,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6862
6864
|
stations.holders = holderStore;
|
|
6863
6865
|
// Covers read once per file (and its size and time), a few hundred kept.
|
|
6864
6866
|
const covers = new Map();
|
|
6865
|
-
stations.cover = async (
|
|
6867
|
+
stations.cover = async (playing) => {
|
|
6868
|
+
// A label music video's cover is its song's.
|
|
6869
|
+
const file = aiLabel.audioFor(playing);
|
|
6866
6870
|
let key = "";
|
|
6867
6871
|
try {
|
|
6868
6872
|
const info = statSync(file);
|
|
@@ -6883,7 +6887,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6883
6887
|
};
|
|
6884
6888
|
// The words of what is on: the file's own tag, else nichedb.dev's.
|
|
6885
6889
|
const lyricsFinder = new LyricsFinder({ tags: (file) => tagReader.read(file), known: (file) => trackFor(engine, file) });
|
|
6886
|
-
stations.lyrics = (_station, file) => lyricsFinder.find(file);
|
|
6890
|
+
stations.lyrics = (_station, file) => lyricsFinder.find(aiLabel.audioFor(file));
|
|
6887
6891
|
// Our own songs' words, back to nichedb, when a key is set: on any station
|
|
6888
6892
|
// change, after a library scan, and once at start.
|
|
6889
6893
|
const lyricsPublisher = new LyricsPublisher({
|
|
@@ -6914,9 +6918,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6914
6918
|
stamp = "";
|
|
6915
6919
|
}
|
|
6916
6920
|
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
|
-
|
|
6921
|
+
// A label music video is told about as its song: the MP3 holds the tags and the words.
|
|
6922
|
+
const audio = aiLabel.audioFor(file);
|
|
6923
|
+
const words = await lyricsFinder.find(audio).catch(() => null);
|
|
6918
6924
|
// A label song is for sale on nixamp.com: the song, its album, everything by the artist.
|
|
6919
|
-
const song = aiLabel.songAt(
|
|
6925
|
+
const song = aiLabel.songAt(audio);
|
|
6920
6926
|
const here = stations.publicBase?.() ?? "";
|
|
6921
6927
|
const shop = (process.env["NIXAMP_SITE"] || "https://nixamp.com").replace(/\/+$/, "");
|
|
6922
6928
|
const store = song && /^https:\/\//.test(here) ? {
|
|
@@ -6930,8 +6936,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6930
6936
|
art,
|
|
6931
6937
|
...(words ? { lyrics: `/api/v1/stations/${station.id}/lyrics?t=${encodeURIComponent(station.listenToken)}&f=${sha("sha1").update(file + stamp).digest("hex").slice(0, 12)}` } : {}),
|
|
6932
6938
|
station, file,
|
|
6933
|
-
tags: await tagReader.read(
|
|
6934
|
-
known: trackFor(engine,
|
|
6939
|
+
tags: await tagReader.read(audio),
|
|
6940
|
+
known: trackFor(engine, audio),
|
|
6935
6941
|
holders: holderStore.list(),
|
|
6936
6942
|
share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
|
|
6937
6943
|
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/station-runner.d.ts
CHANGED
|
@@ -28,7 +28,14 @@ export interface RunnerDeps {
|
|
|
28
28
|
onFailure?: (id: string) => void;
|
|
29
29
|
/** A file for a break on a radio (false) or TV (true) station, or null for none. */
|
|
30
30
|
breaks?: (video: boolean) => Promise<string | null>;
|
|
31
|
+
/** Who a file is by, from the library's tags, for a shuffle that spreads artists out; "" when unknown. */
|
|
32
|
+
artistOf?: (file: string) => string;
|
|
31
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Who a file is by when the tags do not say: "Artist - Album/track" names
|
|
36
|
+
* them in the folder, and "Artist/Album/track" one folder up.
|
|
37
|
+
*/
|
|
38
|
+
export declare function artistFromPath(file: string): string;
|
|
32
39
|
/** Something a listener is connected through, that can be ended. */
|
|
33
40
|
export interface Ending {
|
|
34
41
|
end(): void;
|
package/dist/station-runner.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import { EXCLUDE_AFTER, OFFLINE_RETRY_MS, RECOVERY_DELAYS_MS, StationError, channelOf, insideRoots,
|
|
1
|
+
import { EXCLUDE_AFTER, OFFLINE_RETRY_MS, RECOVERY_DELAYS_MS, StationError, channelOf, insideRoots, spreadByArtist, stationOfChannel, } from "./stations.js";
|
|
2
|
+
/**
|
|
3
|
+
* Who a file is by when the tags do not say: "Artist - Album/track" names
|
|
4
|
+
* them in the folder, and "Artist/Album/track" one folder up.
|
|
5
|
+
*/
|
|
6
|
+
export function artistFromPath(file) {
|
|
7
|
+
const parts = file.split(/[\\/]/);
|
|
8
|
+
const folder = parts[parts.length - 2] ?? "";
|
|
9
|
+
const dash = folder.indexOf(" - ");
|
|
10
|
+
if (dash > 0)
|
|
11
|
+
return folder.slice(0, dash);
|
|
12
|
+
return parts[parts.length - 3] ?? folder;
|
|
13
|
+
}
|
|
2
14
|
/** A run shorter than this that ends badly does not reset the backoff. */
|
|
3
15
|
const HEALTHY_RUN_MS = 60_000;
|
|
4
16
|
export class StationRunner {
|
|
@@ -113,7 +125,9 @@ export class StationRunner {
|
|
|
113
125
|
repeat: station.repeat,
|
|
114
126
|
passOver: true,
|
|
115
127
|
// Read at every pass, so turning shuffle on or off lands then.
|
|
116
|
-
order: (length) => (store.get(id)?.shuffle
|
|
128
|
+
order: (length, files) => (store.get(id)?.shuffle
|
|
129
|
+
? spreadByArtist(files, (file) => this.deps.artistOf?.(file) || artistFromPath(file))
|
|
130
|
+
: [...Array(length).keys()]),
|
|
117
131
|
// Read at every track, so an edit lands on the next one.
|
|
118
132
|
current: () => {
|
|
119
133
|
const now = store.get(id);
|
package/dist/stations.d.ts
CHANGED
|
@@ -128,6 +128,14 @@ export declare class StationStore {
|
|
|
128
128
|
}
|
|
129
129
|
/** A station-wide shuffle: one order for everybody, Fisher-Yates. */
|
|
130
130
|
export declare function shuffled(length: number, random?: () => number): number[];
|
|
131
|
+
/**
|
|
132
|
+
* A radio shuffle: the whole list in a random order, with no artist twice in
|
|
133
|
+
* a row while another is left to play. A plain shuffle of a genre where a few
|
|
134
|
+
* artists have whole albums kept playing one artist in series. Each pick is
|
|
135
|
+
* an artist drawn by how many of their tracks are left, so the spread stays
|
|
136
|
+
* even to the end, then one of that artist's tracks at random.
|
|
137
|
+
*/
|
|
138
|
+
export declare function spreadByArtist(files: readonly string[], artistOf: (file: string) => string, random?: () => number): number[];
|
|
131
139
|
/**
|
|
132
140
|
* Restart after an unexpected end: 2 s, doubling, at most a minute apart.
|
|
133
141
|
* After the last of these the station is offline but still wanted, and is
|
|
@@ -149,6 +157,11 @@ export interface PublicStation {
|
|
|
149
157
|
artist: string;
|
|
150
158
|
album: string;
|
|
151
159
|
} | null;
|
|
160
|
+
/** Seconds into what is on and how long it is, when both are known. */
|
|
161
|
+
progress?: {
|
|
162
|
+
position: number;
|
|
163
|
+
duration: number;
|
|
164
|
+
} | null;
|
|
152
165
|
format: {
|
|
153
166
|
codec: string;
|
|
154
167
|
bitrateKbps: number;
|