nixamp 0.63.3 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +38 -4
- package/dist/ai-label.js +132 -20
- package/dist/discovery.d.ts +48 -0
- package/dist/discovery.js +181 -0
- package/dist/server.js +47 -2
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +15 -0
- package/dist/station-mcp.js +3 -2
- package/dist/veo-studio.d.ts +16 -0
- package/dist/veo-studio.js +59 -0
- package/package.json +1 -1
- package/src/ai-label.ts +124 -23
- package/src/discovery.ts +210 -0
- package/src/server.ts +40 -2
- package/src/station-cli.ts +1 -0
- package/src/station-mcp.ts +3 -2
- package/src/veo-studio.ts +57 -0
- package/web/dist/assets/{ar-BdJErAsT.js → ar-T_s9IpDl.js} +1 -1
- package/web/dist/assets/{cs-BCAcbLw4.js → cs-u6VmDGRj.js} +1 -1
- package/web/dist/assets/{da-B8ZLImmi.js → da-CtKMj9yA.js} +1 -1
- package/web/dist/assets/{de-BEfJLxqy.js → de-DUIp6cPq.js} +1 -1
- package/web/dist/assets/{es-CvwtfSaX.js → es-CDKeGB_J.js} +1 -1
- package/web/dist/assets/{fi-D8zNiMjO.js → fi-Bm5mnEJW.js} +1 -1
- package/web/dist/assets/{fr-C2YZ2L4l.js → fr-C-wTfG2I.js} +1 -1
- package/web/dist/assets/{hi-K_VSycz2.js → hi-Cj5DBYQn.js} +1 -1
- package/web/dist/assets/{hu-Dl_GeLxY.js → hu-l11hhWja.js} +1 -1
- package/web/dist/assets/{id-Ba1NrTEb.js → id-Y_NtfdKr.js} +1 -1
- package/web/dist/assets/{it-ClsnZdJN.js → it-DPsBnd04.js} +1 -1
- package/web/dist/assets/{main-DD5A9Exa.js → main-wXq7DAXz.js} +2 -2
- package/web/dist/assets/{music-LviR4dJP.js → music-Bby75I2_.js} +1 -1
- package/web/dist/assets/{nl-BLjok-1D.js → nl-BJnRKhqE.js} +1 -1
- package/web/dist/assets/{ru-BvhOKYAB.js → ru-CLRFRTHU.js} +1 -1
- package/web/dist/assets/{sv-BItd8vZI.js → sv-BarkkLkN.js} +1 -1
- package/web/dist/assets/{uk-BcUnWToF.js → uk-DlOj9CHi.js} +1 -1
- package/web/dist/assets/{vi-LFZ3qSPF.js → vi-DqXccGG3.js} +1 -1
- package/web/dist/assets/{zh-DfCRV3mt.js → zh-CfnJPLyy.js} +1 -1
- package/web/dist/icons/music/apple-touch-icon.png +0 -0
- package/web/dist/icons/music/icon-192.png +0 -0
- package/web/dist/icons/music/icon-384.png +0 -0
- package/web/dist/icons/music/icon-512.png +0 -0
- package/web/dist/index.html +1 -1
- package/web/dist/music.html +3 -1
- package/web/dist/music.webmanifest +57 -0
- package/web/dist/sw.js +34 -24
package/dist/server.js
CHANGED
|
@@ -124,6 +124,7 @@ import { StoredTranslations } from "./translate-jobs.js";
|
|
|
124
124
|
import { Transcripts, fileFingerprint, formatOf, idFrom, languageCode, linesFrom, mediaOfLive, mediaOfUrl, toSrt, toText, toVtt, transcriptIdOf, wire, } from "./transcripts.js";
|
|
125
125
|
import { handleMessage as mcpMessage } from "./mcp.js";
|
|
126
126
|
import { PUBLIC_TOOLS, callPublicTool, isPublicTool } from "./directory-mcp.js";
|
|
127
|
+
import { handleDiscovery, isDiscoveryPath, latestRelease } from "./discovery.js";
|
|
127
128
|
import { Media, factsFromRequest, holderFrom, mediaId, openFileListing, openFileOf } from "./media.js";
|
|
128
129
|
import { mediaPage } from "./media-page.js";
|
|
129
130
|
import { describeFile, refreshChanged } from "./media-local.js";
|
|
@@ -1478,6 +1479,8 @@ async function readBytes(request, limit) {
|
|
|
1478
1479
|
export function createHandler(engine, options) {
|
|
1479
1480
|
// One per server, so the counters survive between requests and die with it.
|
|
1480
1481
|
const guard = new Guard();
|
|
1482
|
+
// nixamp.com's /download asks GitHub which release is latest; one answer per ten minutes.
|
|
1483
|
+
const release = latestRelease();
|
|
1481
1484
|
const tracker = options.connections ?? new Connections();
|
|
1482
1485
|
const started = Date.now();
|
|
1483
1486
|
/** Count a request in, count its bytes, and close it out exactly once. */
|
|
@@ -2333,6 +2336,8 @@ export function createHandler(engine, options) {
|
|
|
2333
2336
|
!(/^\/radio\/(artist|album)\//.test(path) && Boolean(options.music)) &&
|
|
2334
2337
|
// What is on, as MCP, for anybody.
|
|
2335
2338
|
path !== "/mcp/public" &&
|
|
2339
|
+
// llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
|
|
2340
|
+
!(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
|
|
2336
2341
|
// Supporting a copyright holder is for anybody listening.
|
|
2337
2342
|
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2338
2343
|
// A station's short link, which sends the browser to wherever it plays.
|
|
@@ -3037,6 +3042,9 @@ export function createHandler(engine, options) {
|
|
|
3037
3042
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
3038
3043
|
return;
|
|
3039
3044
|
}
|
|
3045
|
+
// Every way into nixamp, for agents and stores: llms.txt, OpenAPI, health, desktop downloads (discovery.ts).
|
|
3046
|
+
if (options.accounts && options.site && await handleDiscovery(request, response, path, { site: options.site, version: options.version, release, json }))
|
|
3047
|
+
return;
|
|
3040
3048
|
if (path === "/reset-password" && options.accounts?.passwordResets) {
|
|
3041
3049
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
3042
3050
|
json(response, 405, { error: "GET only" });
|
|
@@ -7246,6 +7254,24 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7246
7254
|
throw new Error((await answer.json().catch(() => ({}))).error ?? `nixamp.com answered ${answer.status}`);
|
|
7247
7255
|
return Buffer.from(await answer.arrayBuffer());
|
|
7248
7256
|
},
|
|
7257
|
+
// A singer's close-up redrawn to the song's own seconds, by nixamp.com (fal.ai), under the same cap.
|
|
7258
|
+
lipsync: async (input) => {
|
|
7259
|
+
const session = readSession();
|
|
7260
|
+
const here = stations.publicBase?.() ?? "";
|
|
7261
|
+
if (!session?.token || !/^https:\/\//.test(here))
|
|
7262
|
+
throw new VeoUnavailable("this server is not signed in to nixamp.com");
|
|
7263
|
+
const answer = await fetch(`${session.site.replace(/\/+$/, "")}/api/v1/music/veo`, {
|
|
7264
|
+
method: "POST",
|
|
7265
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session.token}`, "x-nixamp-server": here },
|
|
7266
|
+
body: JSON.stringify({ lipsync: true, seconds: input.seconds, video: input.video.toString("base64"), audio: input.audio.toString("base64") }),
|
|
7267
|
+
signal: AbortSignal.timeout(15 * 60_000),
|
|
7268
|
+
});
|
|
7269
|
+
if (answer.status === 402)
|
|
7270
|
+
throw new VeoUnavailable((await answer.json().catch(() => ({}))).error ?? `HTTP ${answer.status}`);
|
|
7271
|
+
if (!answer.ok)
|
|
7272
|
+
throw new Error((await answer.json().catch(() => ({}))).error ?? `nixamp.com answered ${answer.status}`);
|
|
7273
|
+
return Buffer.from(await answer.arrayBuffer());
|
|
7274
|
+
},
|
|
7249
7275
|
// A release is told to nixamp.com as this server's owner; nixamp.com tells the artist's followers.
|
|
7250
7276
|
announce: (release) => {
|
|
7251
7277
|
const session = readSession();
|
|
@@ -7555,6 +7581,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7555
7581
|
ledger: pool ? pgLedger(pool) : fileLedger(VeoStudio.fileFor(stateDir(), options.port)),
|
|
7556
7582
|
monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
|
|
7557
7583
|
usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
|
|
7584
|
+
...(process.env["FAL_KEY"] ? { falKey: process.env["FAL_KEY"] } : {}),
|
|
7585
|
+
lipsyncUsdPerSecond: Number(process.env["NIXAMP_LIPSYNC_USD_PER_SECOND"] ?? 0.1),
|
|
7558
7586
|
}) : undefined;
|
|
7559
7587
|
/** The signed-in account owns a server listed in the directory at this base. */
|
|
7560
7588
|
const ownsListed = async (request, base) => {
|
|
@@ -7578,11 +7606,28 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7578
7606
|
if (request.method !== "POST")
|
|
7579
7607
|
return { status: 405, body: { error: "GET or POST" } };
|
|
7580
7608
|
let input;
|
|
7609
|
+
// A lip sync carries an 8 s 1080p shot and its seconds of the song: up to 48 MB as base64.
|
|
7581
7610
|
try {
|
|
7582
|
-
input = JSON.parse(await readBody(request,
|
|
7611
|
+
input = JSON.parse(await readBody(request, 48 * 1024 * 1024));
|
|
7583
7612
|
}
|
|
7584
7613
|
catch {
|
|
7585
|
-
return { status: 400, body: { error: "a JSON body under
|
|
7614
|
+
return { status: 400, body: { error: "a JSON body under 48 MB" } };
|
|
7615
|
+
}
|
|
7616
|
+
if (input.lipsync === true) {
|
|
7617
|
+
if (!veoStudio.canLipsync)
|
|
7618
|
+
return { status: 404, body: { error: "lip sync is not set up here" } };
|
|
7619
|
+
const video = Buffer.from(String(input.video ?? ""), "base64");
|
|
7620
|
+
const audio = Buffer.from(String(input.audio ?? ""), "base64");
|
|
7621
|
+
if (video.length < 10_000 || audio.length < 1_000)
|
|
7622
|
+
return { status: 400, body: { error: "a shot and its audio" } };
|
|
7623
|
+
try {
|
|
7624
|
+
return { status: 200, body: null, video: await veoStudio.lipsync({ video, audio, seconds: Number(input.seconds ?? 8) }) };
|
|
7625
|
+
}
|
|
7626
|
+
catch (error) {
|
|
7627
|
+
if (error instanceof VeoBudgetError)
|
|
7628
|
+
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7629
|
+
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
7630
|
+
}
|
|
7586
7631
|
}
|
|
7587
7632
|
const prompt = String(input.prompt ?? "").trim();
|
|
7588
7633
|
if (prompt.length < 20)
|
package/dist/station-cli.js
CHANGED
|
@@ -540,6 +540,7 @@ async function labelCommand(argv, call, show) {
|
|
|
540
540
|
resetDay: number("--reset-day"),
|
|
541
541
|
veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
|
|
542
542
|
veoShots: number("--veo-shots"),
|
|
543
|
+
lipsyncShots: number("--lipsync-shots"),
|
|
543
544
|
genreOrder: flag(argv, "--genres"),
|
|
544
545
|
enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
|
|
545
546
|
publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -52,6 +52,7 @@ export declare const STATION_TOOLS: ({
|
|
|
52
52
|
resetDay?: undefined;
|
|
53
53
|
veo?: undefined;
|
|
54
54
|
veoShots?: undefined;
|
|
55
|
+
lipsyncShots?: undefined;
|
|
55
56
|
genreOrder?: undefined;
|
|
56
57
|
state?: undefined;
|
|
57
58
|
reason?: undefined;
|
|
@@ -130,6 +131,7 @@ export declare const STATION_TOOLS: ({
|
|
|
130
131
|
resetDay?: undefined;
|
|
131
132
|
veo?: undefined;
|
|
132
133
|
veoShots?: undefined;
|
|
134
|
+
lipsyncShots?: undefined;
|
|
133
135
|
genreOrder?: undefined;
|
|
134
136
|
state?: undefined;
|
|
135
137
|
reason?: undefined;
|
|
@@ -231,6 +233,7 @@ export declare const STATION_TOOLS: ({
|
|
|
231
233
|
resetDay?: undefined;
|
|
232
234
|
veo?: undefined;
|
|
233
235
|
veoShots?: undefined;
|
|
236
|
+
lipsyncShots?: undefined;
|
|
234
237
|
genreOrder?: undefined;
|
|
235
238
|
state?: undefined;
|
|
236
239
|
reason?: undefined;
|
|
@@ -329,6 +332,7 @@ export declare const STATION_TOOLS: ({
|
|
|
329
332
|
resetDay?: undefined;
|
|
330
333
|
veo?: undefined;
|
|
331
334
|
veoShots?: undefined;
|
|
335
|
+
lipsyncShots?: undefined;
|
|
332
336
|
genreOrder?: undefined;
|
|
333
337
|
state?: undefined;
|
|
334
338
|
reason?: undefined;
|
|
@@ -409,6 +413,7 @@ export declare const STATION_TOOLS: ({
|
|
|
409
413
|
resetDay?: undefined;
|
|
410
414
|
veo?: undefined;
|
|
411
415
|
veoShots?: undefined;
|
|
416
|
+
lipsyncShots?: undefined;
|
|
412
417
|
genreOrder?: undefined;
|
|
413
418
|
state?: undefined;
|
|
414
419
|
reason?: undefined;
|
|
@@ -496,6 +501,7 @@ export declare const STATION_TOOLS: ({
|
|
|
496
501
|
resetDay?: undefined;
|
|
497
502
|
veo?: undefined;
|
|
498
503
|
veoShots?: undefined;
|
|
504
|
+
lipsyncShots?: undefined;
|
|
499
505
|
genreOrder?: undefined;
|
|
500
506
|
state?: undefined;
|
|
501
507
|
reason?: undefined;
|
|
@@ -583,6 +589,7 @@ export declare const STATION_TOOLS: ({
|
|
|
583
589
|
resetDay?: undefined;
|
|
584
590
|
veo?: undefined;
|
|
585
591
|
veoShots?: undefined;
|
|
592
|
+
lipsyncShots?: undefined;
|
|
586
593
|
genreOrder?: undefined;
|
|
587
594
|
state?: undefined;
|
|
588
595
|
reason?: undefined;
|
|
@@ -687,6 +694,7 @@ export declare const STATION_TOOLS: ({
|
|
|
687
694
|
resetDay?: undefined;
|
|
688
695
|
veo?: undefined;
|
|
689
696
|
veoShots?: undefined;
|
|
697
|
+
lipsyncShots?: undefined;
|
|
690
698
|
genreOrder?: undefined;
|
|
691
699
|
state?: undefined;
|
|
692
700
|
reason?: undefined;
|
|
@@ -817,6 +825,10 @@ export declare const STATION_TOOLS: ({
|
|
|
817
825
|
type: string;
|
|
818
826
|
description: string;
|
|
819
827
|
};
|
|
828
|
+
lipsyncShots: {
|
|
829
|
+
type: string;
|
|
830
|
+
description: string;
|
|
831
|
+
};
|
|
820
832
|
genreOrder: {
|
|
821
833
|
type: string;
|
|
822
834
|
items: {
|
|
@@ -890,6 +902,7 @@ export declare const STATION_TOOLS: ({
|
|
|
890
902
|
resetDay?: undefined;
|
|
891
903
|
veo?: undefined;
|
|
892
904
|
veoShots?: undefined;
|
|
905
|
+
lipsyncShots?: undefined;
|
|
893
906
|
genreOrder?: undefined;
|
|
894
907
|
server: {
|
|
895
908
|
type: "string";
|
|
@@ -986,6 +999,7 @@ export declare const STATION_TOOLS: ({
|
|
|
986
999
|
resetDay?: undefined;
|
|
987
1000
|
veo?: undefined;
|
|
988
1001
|
veoShots?: undefined;
|
|
1002
|
+
lipsyncShots?: undefined;
|
|
989
1003
|
genreOrder?: undefined;
|
|
990
1004
|
state?: undefined;
|
|
991
1005
|
reason?: undefined;
|
|
@@ -1084,6 +1098,7 @@ export declare const STATION_TOOLS: ({
|
|
|
1084
1098
|
resetDay?: undefined;
|
|
1085
1099
|
veo?: undefined;
|
|
1086
1100
|
veoShots?: undefined;
|
|
1101
|
+
lipsyncShots?: undefined;
|
|
1087
1102
|
genreOrder?: undefined;
|
|
1088
1103
|
state?: undefined;
|
|
1089
1104
|
reason?: undefined;
|
package/dist/station-mcp.js
CHANGED
|
@@ -123,7 +123,8 @@ export const STATION_TOOLS = [
|
|
|
123
123
|
newArtistChance: { type: "number", description: "0-1: how often the day's album is by a new artist." },
|
|
124
124
|
resetDay: { type: "integer", description: "Day of the month (1-28) the budget renews: the music provider's billing day." },
|
|
125
125
|
veo: { type: "boolean", description: "Each album's single gets filmed Veo shots of the band (nixamp.com's monthly Veo cap applies)." },
|
|
126
|
-
veoShots: { type: "integer", description: "8 s Veo shots per single (1-8)." },
|
|
126
|
+
veoShots: { type: "integer", description: "8 s Veo shots of the band per single (1-8); nobody sings in them." },
|
|
127
|
+
lipsyncShots: { type: "integer", description: "Close-ups of the singer per single, lip-synced to the song on nixamp.com (0-4; 0 = the band only)." },
|
|
127
128
|
genreOrder: { type: "array", items: STRING, description: "Genres whose artists get albums first, in order." },
|
|
128
129
|
},
|
|
129
130
|
},
|
|
@@ -243,7 +244,7 @@ export async function callStationTool(name, args, options = {}) {
|
|
|
243
244
|
const key = typeof args["key"] === "string" && /^[a-f0-9]{10}$/.test(args["key"]) ? args["key"] : "";
|
|
244
245
|
const answer = action === "status" ? await call("GET", "/api/v1/stations/label")
|
|
245
246
|
: action === "sign" ? await call("POST", "/api/v1/stations/label/artists", pick(["preset", "roster", "name", "genre", "sound", "voice", "bio", "themes", "enabled"]))
|
|
246
|
-
: action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "veo", "veoShots", "enabled", "publish"]))
|
|
247
|
+
: action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "veo", "veoShots", "lipsyncShots", "enabled", "publish"]))
|
|
247
248
|
: action === "run" ? await call("POST", "/api/v1/stations/label/run", {})
|
|
248
249
|
: action === "resume" ? await call("POST", "/api/v1/stations/label", { resume: true })
|
|
249
250
|
: !key ? null
|
package/dist/veo-studio.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface VeoStudioOptions {
|
|
|
22
22
|
ledger: VeoLedger;
|
|
23
23
|
monthlyUsd?: number;
|
|
24
24
|
usdPerSecond?: number;
|
|
25
|
+
/** fal.ai's key (FAL_KEY): a singer's close-up redrawn to the song's own vocal (Sync lipsync-2). Without it, no lip sync. */
|
|
26
|
+
falKey?: string;
|
|
27
|
+
/** What a second of lip sync is counted at under the same monthly cap; deliberately high ($0.10). */
|
|
28
|
+
lipsyncUsdPerSecond?: number;
|
|
25
29
|
model?: string;
|
|
26
30
|
fetcher?: typeof fetch;
|
|
27
31
|
now?: () => number;
|
|
@@ -48,5 +52,17 @@ export declare class VeoStudio {
|
|
|
48
52
|
seconds: number;
|
|
49
53
|
references: Buffer[];
|
|
50
54
|
}): Promise<Buffer>;
|
|
55
|
+
get canLipsync(): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* A singer's close-up with the mouth redrawn to `audio` (the same seconds of
|
|
58
|
+
* the song): Sync's lipsync-2 on fal.ai. Veo's singers mouth a melody of their
|
|
59
|
+
* own; this makes them sing ours. Paid under the same monthly cap.
|
|
60
|
+
*/
|
|
61
|
+
lipsync(input: {
|
|
62
|
+
video: Buffer;
|
|
63
|
+
audio: Buffer;
|
|
64
|
+
seconds: number;
|
|
65
|
+
}): Promise<Buffer>;
|
|
66
|
+
private sync;
|
|
51
67
|
private make;
|
|
52
68
|
}
|
package/dist/veo-studio.js
CHANGED
|
@@ -108,6 +108,65 @@ export class VeoStudio {
|
|
|
108
108
|
throw error;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
get canLipsync() { return Boolean(this.options.falKey); }
|
|
112
|
+
/**
|
|
113
|
+
* A singer's close-up with the mouth redrawn to `audio` (the same seconds of
|
|
114
|
+
* the song): Sync's lipsync-2 on fal.ai. Veo's singers mouth a melody of their
|
|
115
|
+
* own; this makes them sing ours. Paid under the same monthly cap.
|
|
116
|
+
*/
|
|
117
|
+
async lipsync(input) {
|
|
118
|
+
const key = this.options.falKey;
|
|
119
|
+
if (!key)
|
|
120
|
+
throw new VeoRefused("lip sync is not set up here");
|
|
121
|
+
const cost = Math.max(1, Math.min(10, input.seconds)) * (this.options.lipsyncUsdPerSecond ?? 0.1);
|
|
122
|
+
const month = this.month();
|
|
123
|
+
if (!(await this.options.ledger.reserve(month, cost, this.cap)))
|
|
124
|
+
throw new VeoBudgetError(`this month's $${this.cap} of video is spent`);
|
|
125
|
+
try {
|
|
126
|
+
return await this.sync(key, input.video, input.audio);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
await this.options.ledger.refund(month, cost).catch(() => undefined);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async sync(key, video, audio) {
|
|
134
|
+
const send = this.options.fetcher ?? fetch;
|
|
135
|
+
const auth = { authorization: `Key ${key}`, "content-type": "application/json" };
|
|
136
|
+
// fal.ai takes inputs by URL only (a data: URI is "invalid"): its own storage first.
|
|
137
|
+
const upload = async (bytes, type, name) => {
|
|
138
|
+
const init = await send("https://rest.alpha.fal.ai/storage/upload/initiate?storage_type=fal-cdn-v3", { method: "POST", headers: auth, body: JSON.stringify({ content_type: type, file_name: name }), signal: AbortSignal.timeout(30_000) });
|
|
139
|
+
const slot = await init.json().catch(() => ({}));
|
|
140
|
+
if (!slot.upload_url || !slot.file_url)
|
|
141
|
+
throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${init.status})`);
|
|
142
|
+
const put = await send(slot.upload_url, { method: "PUT", headers: { "content-type": type }, body: new Uint8Array(bytes), signal: AbortSignal.timeout(120_000) });
|
|
143
|
+
if (!put.ok)
|
|
144
|
+
throw new VeoRefused(`fal.ai storage refused the upload (HTTP ${put.status})`);
|
|
145
|
+
return slot.file_url;
|
|
146
|
+
};
|
|
147
|
+
const [videoUrl, audioUrl] = await Promise.all([upload(video, "video/mp4", "shot.mp4"), upload(audio, "audio/mpeg", "vocal.mp3")]);
|
|
148
|
+
const started = await send("https://queue.fal.run/fal-ai/sync-lipsync/v2", { method: "POST", headers: auth, body: JSON.stringify({ video_url: videoUrl, audio_url: audioUrl }), signal: AbortSignal.timeout(60_000) });
|
|
149
|
+
const job = await started.json().catch(() => ({}));
|
|
150
|
+
if (!started.ok || !job.status_url || !job.response_url)
|
|
151
|
+
throw new VeoRefused(`fal.ai refused the lip sync: HTTP ${started.status}`);
|
|
152
|
+
const sleep = this.options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
153
|
+
for (let tries = 0; tries < 120; tries++) {
|
|
154
|
+
await sleep(5_000);
|
|
155
|
+
const state = await (await send(job.status_url, { headers: auth, signal: AbortSignal.timeout(30_000) })).json().catch(() => ({}));
|
|
156
|
+
if (state.status !== "COMPLETED")
|
|
157
|
+
continue;
|
|
158
|
+
const answer = await send(job.response_url, { headers: auth, signal: AbortSignal.timeout(30_000) });
|
|
159
|
+
const result = await answer.json().catch(() => ({}));
|
|
160
|
+
if (!answer.ok || !result.video?.url)
|
|
161
|
+
throw new VeoRefused(`the lip sync failed: ${result.detail?.[0]?.msg ?? `HTTP ${answer.status}`}`);
|
|
162
|
+
const out = await send(result.video.url, { signal: AbortSignal.timeout(120_000) });
|
|
163
|
+
const bytes = Buffer.from(await out.arrayBuffer());
|
|
164
|
+
if (!out.ok || bytes.length < 10_000)
|
|
165
|
+
throw new VeoRefused(`the lip-synced clip did not download (HTTP ${out.status})`);
|
|
166
|
+
return bytes;
|
|
167
|
+
}
|
|
168
|
+
throw new VeoRefused("the lip sync took too long (10 minutes)");
|
|
169
|
+
}
|
|
111
170
|
async make(prompt, seconds, references) {
|
|
112
171
|
const send = this.options.fetcher ?? fetch;
|
|
113
172
|
const headers = { "x-goog-api-key": this.options.key, "content-type": "application/json" };
|
package/package.json
CHANGED
package/src/ai-label.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { dirname, join, resolve, sep } from "node:path";
|
|
|
33
33
|
import { aiKey } from "./ai-keys.ts";
|
|
34
34
|
import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
|
|
35
35
|
import { askJson, hasModel } from "./ai-text.ts";
|
|
36
|
-
import { formatLrc, type SyncedLine } from "./lyrics.ts";
|
|
36
|
+
import { formatLrc, parseLrc, type SyncedLine } from "./lyrics.ts";
|
|
37
37
|
import { bandMarkdown, castLine, inventMembers, readMembers, writeMembers, type Member } from "./label-profiles.ts";
|
|
38
38
|
import type { LibraryIndex, Probed } from "./library-index.ts";
|
|
39
39
|
import type { StationRunner } from "./station-runner.ts";
|
|
@@ -127,8 +127,10 @@ export interface LabelSettings {
|
|
|
127
127
|
videos: boolean;
|
|
128
128
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
129
129
|
veo: boolean;
|
|
130
|
-
/** How many 8 s Veo shots a single is cut from. */
|
|
130
|
+
/** How many 8 s Veo shots of the band a single is cut from. */
|
|
131
131
|
veoShots: number;
|
|
132
|
+
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
133
|
+
lipsyncShots: number;
|
|
132
134
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
133
135
|
publish: boolean;
|
|
134
136
|
/** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
|
|
@@ -384,6 +386,7 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
|
|
|
384
386
|
videos: true,
|
|
385
387
|
veo: false,
|
|
386
388
|
veoShots: 6,
|
|
389
|
+
lipsyncShots: 2,
|
|
387
390
|
parallel: 1,
|
|
388
391
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
389
392
|
};
|
|
@@ -640,18 +643,31 @@ const SHOTS = [
|
|
|
640
643
|
"The band walking out on stage before the show, backlit silhouettes, nobody speaking or singing",
|
|
641
644
|
];
|
|
642
645
|
|
|
646
|
+
// The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
|
|
647
|
+
// must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
|
|
648
|
+
// the mouth is redrawn.
|
|
649
|
+
const CLOSE_UPS = [
|
|
650
|
+
"Medium close-up of the lead vocalist singing straight to camera, the microphone stand lowered to chest height so the whole mouth and chin are clear, nothing in front of the face",
|
|
651
|
+
"Close-up of the lead vocalist singing with feeling, head and shoulders, a handheld microphone held down at the chest, the whole mouth clearly visible",
|
|
652
|
+
"Medium shot of the lead vocalist singing to the crowd, the microphone held out to the side, face lit and the mouth fully visible",
|
|
653
|
+
"Close-up of the lead vocalist singing in profile-three-quarter view, no microphone in frame, the mouth clearly visible",
|
|
654
|
+
];
|
|
655
|
+
|
|
643
656
|
/**
|
|
644
657
|
* The prompts for a single's filmed shots. The people are the reference
|
|
645
658
|
* images, described by role and look only: Veo refuses names with faces
|
|
646
659
|
* ("we can't create videos with real people's names or likenesses").
|
|
647
660
|
*/
|
|
648
|
-
export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[] {
|
|
661
|
+
export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number, kind: "band" | "singer" = "band"): string[] {
|
|
649
662
|
const cast = members.slice(0, 3).map((one, i) => `the ${one.role} from reference image ${i + 1} (${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
|
|
650
|
-
|
|
663
|
+
const rule = kind === "singer"
|
|
664
|
+
? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
|
|
665
|
+
: "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
|
|
666
|
+
return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
|
|
651
667
|
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
652
668
|
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
653
669
|
`Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
|
|
654
|
-
|
|
670
|
+
rule,
|
|
655
671
|
].filter(Boolean).join(" "));
|
|
656
672
|
}
|
|
657
673
|
|
|
@@ -736,8 +752,29 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
|
|
|
736
752
|
}
|
|
737
753
|
|
|
738
754
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
755
|
+
* Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
|
|
756
|
+
* nearest a quarter and three quarters in (one: a third in), each `length`
|
|
757
|
+
* seconds, after the title card, never overlapping. Without timed words, those
|
|
758
|
+
* points themselves.
|
|
759
|
+
*/
|
|
760
|
+
export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, count: number, length = 8): number[] {
|
|
761
|
+
if (count < 1) return [];
|
|
762
|
+
const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => 0.25 + (0.5 * i) / (count - 1));
|
|
763
|
+
const fits = (t: number): boolean => t >= 6 && t + length <= seconds - 1;
|
|
764
|
+
const starts = lines.map((line) => Math.round(line.start * 100) / 100).filter(fits);
|
|
765
|
+
const chosen: number[] = [];
|
|
766
|
+
for (const share of targets) {
|
|
767
|
+
const want = share * seconds;
|
|
768
|
+
const pool = (starts.length ? starts : [Math.round(want)]).filter((t) => fits(t) && chosen.every((one) => Math.abs(one - t) >= length));
|
|
769
|
+
if (pool.length) chosen.push(pool.reduce((best, t) => (Math.abs(t - want) < Math.abs(best - want) ? t : best)));
|
|
770
|
+
}
|
|
771
|
+
return chosen.sort((a, b) => a - b);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The version of a video cut from Veo footage: the band with nobody singing, and the
|
|
776
|
+
* singer only in close-ups lip-synced to the song. Anything older (a slideshow, or 4:
|
|
777
|
+
* footage whose singers were out of sync) is not shown.
|
|
741
778
|
*/
|
|
742
779
|
export const FILMED = 5;
|
|
743
780
|
|
|
@@ -747,17 +784,32 @@ export const FILMED = 5;
|
|
|
747
784
|
* ends, so no picture holds still. The artist and title come up at the start;
|
|
748
785
|
* the MP3's audio and tags are copied in. 1080p H.264.
|
|
749
786
|
*/
|
|
750
|
-
export function renderFilmedVideo(ffmpeg: string[], input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string }): Promise<void> {
|
|
787
|
+
export function renderFilmedVideo(ffmpeg: string[], input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string; pinned?: Array<{ path: string; at: number; seconds: number }> }): Promise<void> {
|
|
751
788
|
if (input.clips.length === 0) return Promise.reject(new Error("no footage to cut"));
|
|
752
789
|
const cut = 4;
|
|
790
|
+
// Lip-synced close-ups play whole at the second of the song they were synced to; the band fills around them.
|
|
791
|
+
const pinned = [...(input.pinned ?? [])].sort((a, b) => a.at - b.at);
|
|
753
792
|
const cuts: Array<{ path: string; start: number; length: number }> = [];
|
|
754
|
-
for (let at = 0, i = 0; at < input.seconds;
|
|
793
|
+
for (let at = 0, i = 0; at < input.seconds - 0.05;) {
|
|
794
|
+
const pin = pinned.find((one) => Math.abs(one.at - at) < 0.05);
|
|
795
|
+
if (pin) {
|
|
796
|
+
const length = Math.min(pin.seconds, input.seconds - at + 0.5);
|
|
797
|
+
cuts.push({ path: pin.path, start: 0, length });
|
|
798
|
+
at += length;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const next = pinned.find((one) => one.at > at + 0.05);
|
|
755
802
|
const clip = input.clips[i % input.clips.length]!;
|
|
756
803
|
const half = Math.floor(i / input.clips.length) % 2;
|
|
757
|
-
|
|
758
|
-
|
|
804
|
+
let length = Math.min(cut, input.seconds - at + 0.5);
|
|
805
|
+
// Up to a close-up: one cut to meet it exactly, never a sliver.
|
|
806
|
+
if (next) length = next.at - at < cut * 1.5 ? next.at - at : Math.min(length, next.at - at);
|
|
807
|
+
const start = length > cut ? 0 : Math.min(half * cut, Math.max(0, clip.seconds - cut));
|
|
808
|
+
length = Math.min(length, clip.seconds - start);
|
|
809
|
+
if (length < 0.04) break;
|
|
759
810
|
cuts.push({ path: clip.path, start, length });
|
|
760
811
|
at += length;
|
|
812
|
+
i += 1;
|
|
761
813
|
}
|
|
762
814
|
const parts = cuts.map((_, i) => `[${i}:v]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,fps=25,setsar=1,setpts=PTS-STARTPTS[c${i}]`);
|
|
763
815
|
parts.push(`${cuts.map((_, i) => `[c${i}]`).join("")}concat=n=${cuts.length}:v=1:a=0[cut]`);
|
|
@@ -864,7 +916,9 @@ export interface AiLabelDeps {
|
|
|
864
916
|
/** How long to wait before asking Veo again after nixamp.com or the network failed (60 s). */
|
|
865
917
|
retryMs?: number;
|
|
866
918
|
/** Cuts a single's filmed shots to its song; ffmpeg by default (renderFilmedVideo). */
|
|
867
|
-
filmedVideo?: (input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string }) => Promise<void>;
|
|
919
|
+
filmedVideo?: (input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string; pinned?: Array<{ path: string; at: number; seconds: number }> }) => Promise<void>;
|
|
920
|
+
/** A singer's close-up with the mouth redrawn to these seconds of the song (nixamp.com, fal.ai); VeoUnavailable when the month is spent. */
|
|
921
|
+
lipsync?: ((input: { video: Buffer; audio: Buffer; seconds: number }) => Promise<Buffer>) | null;
|
|
868
922
|
/** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
|
|
869
923
|
storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string; clips?: Array<{ path: string; seconds: number }> }) => Promise<void>;
|
|
870
924
|
/** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
|
|
@@ -1051,6 +1105,8 @@ export class AiLabel {
|
|
|
1051
1105
|
if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
|
|
1052
1106
|
const shots = whole("veoShots", 1, 8);
|
|
1053
1107
|
if (shots !== undefined) this.settings.veoShots = shots;
|
|
1108
|
+
const close = whole("lipsyncShots", 0, 4);
|
|
1109
|
+
if (close !== undefined) this.settings.lipsyncShots = close;
|
|
1054
1110
|
const perDay = whole("albumsPerDay", 0, 24);
|
|
1055
1111
|
if (perDay !== undefined) this.settings.albumsPerDay = perDay;
|
|
1056
1112
|
if (input["newArtistChance"] !== undefined) {
|
|
@@ -1291,13 +1347,53 @@ export class AiLabel {
|
|
|
1291
1347
|
} catch { /* the asked-for length */ }
|
|
1292
1348
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1293
1349
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1294
|
-
const clips = await this.filmShots(artist, song, shots, scenes);
|
|
1350
|
+
const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
|
|
1295
1351
|
if (clips.length < Math.max(2, Math.ceil(this.settings.veoShots / 2))) throw new Error(`only ${clips.length} of ${this.settings.veoShots} shots filmed so far`);
|
|
1296
|
-
|
|
1352
|
+
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1353
|
+
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1297
1354
|
rmSync(shots, { recursive: true, force: true });
|
|
1298
1355
|
return FILMED;
|
|
1299
1356
|
}
|
|
1300
1357
|
|
|
1358
|
+
/**
|
|
1359
|
+
* The singer's close-ups, each placed on a sung line and its mouth redrawn
|
|
1360
|
+
* to those seconds of the song. A close-up that cannot be synced is left
|
|
1361
|
+
* out (the band fills its place): an unsynced singer is never shown.
|
|
1362
|
+
*/
|
|
1363
|
+
private async syncCloseUps(song: LabelSong, singer: Array<{ path: string; seconds: number }>, seconds: number, keep: string): Promise<Array<{ path: string; at: number; seconds: number }>> {
|
|
1364
|
+
const lipsync = this.deps.lipsync;
|
|
1365
|
+
if (!lipsync || singer.length === 0) return [];
|
|
1366
|
+
let lines: Array<{ start: number }> = [];
|
|
1367
|
+
try { if (song.lrc) lines = parseLrc(readFileSync(song.lrc, "utf8")); } catch { /* the song's thirds instead */ }
|
|
1368
|
+
const times = closeUpTimes(seconds, lines, singer.length);
|
|
1369
|
+
const pinned: Array<{ path: string; at: number; seconds: number }> = [];
|
|
1370
|
+
for (const [i, at] of times.entries()) {
|
|
1371
|
+
const synced = join(keep, `synced-${i}-${Math.round(at * 100)}.mp4`);
|
|
1372
|
+
try {
|
|
1373
|
+
if (statSync(synced).size > 10_000) { pinned.push({ path: synced, at, seconds: 8 }); continue; }
|
|
1374
|
+
} catch { /* not synced yet */ }
|
|
1375
|
+
const shot = join(keep, `.send-${i}.mp4`);
|
|
1376
|
+
const audio = join(keep, `.send-${i}.mp3`);
|
|
1377
|
+
try {
|
|
1378
|
+
// The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
|
|
1379
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i]!.path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
|
|
1380
|
+
await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
|
|
1381
|
+
const bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
|
|
1382
|
+
writeFileSync(`${synced}.part`, bytes);
|
|
1383
|
+
renameSync(`${synced}.part`, synced);
|
|
1384
|
+
pinned.push({ path: synced, at, seconds: 8 });
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
this.log(`lip sync ${i + 1} for "${song.title}": ${(error as Error).message}; the band plays there instead`);
|
|
1387
|
+
if (error instanceof VeoUnavailable) break;
|
|
1388
|
+
} finally {
|
|
1389
|
+
rmSync(shot, { force: true });
|
|
1390
|
+
rmSync(audio, { force: true });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (pinned.length) this.log(`lip-synced ${pinned.length} close-up(s) of "${song.title}"`);
|
|
1394
|
+
return pinned;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1301
1397
|
/** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
|
|
1302
1398
|
private wantsVeo(song: Pick<LabelSong, "n">): boolean {
|
|
1303
1399
|
return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
|
|
@@ -1308,9 +1404,9 @@ export class AiLabel {
|
|
|
1308
1404
|
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
1309
1405
|
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
1310
1406
|
*/
|
|
1311
|
-
private async filmShots(artist: LabelArtist, song: LabelSong, keep: string, dir: string): Promise<Array<{ path: string; seconds: number }
|
|
1407
|
+
private async filmShots(artist: LabelArtist, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
|
|
1312
1408
|
const veo = this.deps.veo;
|
|
1313
|
-
if (!veo) return [];
|
|
1409
|
+
if (!veo) return { band: [], singer: [] };
|
|
1314
1410
|
mkdirSync(dir, { recursive: true });
|
|
1315
1411
|
mkdirSync(keep, { recursive: true });
|
|
1316
1412
|
const members = this.cast(artist);
|
|
@@ -1326,11 +1422,16 @@ export class AiLabel {
|
|
|
1326
1422
|
// A member without a portrait is described in words only.
|
|
1327
1423
|
}
|
|
1328
1424
|
}
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
|
|
1425
|
+
const band: Array<{ path: string; seconds: number }> = [];
|
|
1426
|
+
const singer: Array<{ path: string; seconds: number }> = [];
|
|
1427
|
+
// The singer's close-ups only where they can be lip-synced afterwards.
|
|
1428
|
+
const plan = [
|
|
1429
|
+
...veoPrompts(artist, song, members, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band })),
|
|
1430
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, members, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer })) : []),
|
|
1431
|
+
];
|
|
1432
|
+
for (const [i, { prompt, file, into }] of plan.entries()) {
|
|
1332
1433
|
try {
|
|
1333
|
-
if (statSync(file).size > 10_000) {
|
|
1434
|
+
if (statSync(file).size > 10_000) { into.push({ path: file, seconds: 8 }); continue; }
|
|
1334
1435
|
} catch { /* not filmed yet */ }
|
|
1335
1436
|
try {
|
|
1336
1437
|
// nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
|
|
@@ -1344,7 +1445,7 @@ export class AiLabel {
|
|
|
1344
1445
|
const partial = `${file}.part`;
|
|
1345
1446
|
writeFileSync(partial, bytes!);
|
|
1346
1447
|
renameSync(partial, file);
|
|
1347
|
-
|
|
1448
|
+
into.push({ path: file, seconds: 8 });
|
|
1348
1449
|
} catch (error) {
|
|
1349
1450
|
if (error instanceof VeoUnavailable) {
|
|
1350
1451
|
this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
|
|
@@ -1354,8 +1455,8 @@ export class AiLabel {
|
|
|
1354
1455
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
|
|
1355
1456
|
}
|
|
1356
1457
|
}
|
|
1357
|
-
if (
|
|
1358
|
-
return
|
|
1458
|
+
if (band.length || singer.length) this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1459
|
+
return { band, singer };
|
|
1359
1460
|
}
|
|
1360
1461
|
|
|
1361
1462
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|