nixamp 0.63.3 → 0.65.1
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 +64 -11
- 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 +56 -11
- 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-DsLCtn6q.js} +1 -1
- package/web/dist/assets/{cs-BCAcbLw4.js → cs-CDebRTvk.js} +1 -1
- package/web/dist/assets/{da-B8ZLImmi.js → da-CtIZY0F_.js} +1 -1
- package/web/dist/assets/{de-BEfJLxqy.js → de-Ce4XnPH4.js} +1 -1
- package/web/dist/assets/{es-CvwtfSaX.js → es-DuTsv2ty.js} +1 -1
- package/web/dist/assets/{fi-D8zNiMjO.js → fi-BxzgeUzQ.js} +1 -1
- package/web/dist/assets/{fr-C2YZ2L4l.js → fr-1e-Cafh4.js} +1 -1
- package/web/dist/assets/{hi-K_VSycz2.js → hi-yKzlszV2.js} +1 -1
- package/web/dist/assets/{hu-Dl_GeLxY.js → hu-DEEC9-i5.js} +1 -1
- package/web/dist/assets/{id-Ba1NrTEb.js → id-Da47jrbA.js} +1 -1
- package/web/dist/assets/{it-ClsnZdJN.js → it-DyEoFcC_.js} +1 -1
- package/web/dist/assets/{main-DD5A9Exa.js → main-C_3KEwHZ.js} +2 -2
- package/web/dist/assets/{music-LviR4dJP.js → music-Bby75I2_.js} +1 -1
- package/web/dist/assets/{nl-BLjok-1D.js → nl-uTGCd9L3.js} +1 -1
- package/web/dist/assets/{ru-BvhOKYAB.js → ru-BJ7gUalz.js} +1 -1
- package/web/dist/assets/{sv-BItd8vZI.js → sv-C9ZiAm2Q.js} +1 -1
- package/web/dist/assets/{uk-BcUnWToF.js → uk-DIcSDuMA.js} +1 -1
- package/web/dist/assets/{vi-LFZ3qSPF.js → vi-4MI2s3Z9.js} +1 -1
- package/web/dist/assets/{zh-DfCRV3mt.js → zh-DI1PPrRY.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";
|
|
@@ -196,7 +197,8 @@ export function parseServeArgs(argv) {
|
|
|
196
197
|
tlsCert: process.env["NIXAMP_TLS_CERT"] ?? "",
|
|
197
198
|
tlsKey: process.env["NIXAMP_TLS_KEY"] ?? "",
|
|
198
199
|
x402: false,
|
|
199
|
-
|
|
200
|
+
// The account that administers this server, for one with no key but accounts (nixamp.com).
|
|
201
|
+
owner: process.env["NIXAMP_OWNER"] ?? "",
|
|
200
202
|
ingest: false,
|
|
201
203
|
rtmpIn: 0,
|
|
202
204
|
rtmpStreams: 3,
|
|
@@ -1478,6 +1480,12 @@ async function readBytes(request, limit) {
|
|
|
1478
1480
|
export function createHandler(engine, options) {
|
|
1479
1481
|
// One per server, so the counters survive between requests and die with it.
|
|
1480
1482
|
const guard = new Guard();
|
|
1483
|
+
// --no-key opens a server to whoever reaches it, unless it has an owner: then
|
|
1484
|
+
// anyone may listen and only the owner (or the control key) administers.
|
|
1485
|
+
// nixamp.com is that server; without this every visitor was its admin.
|
|
1486
|
+
const openToAll = (key) => key === null && !options.owner?.claimed;
|
|
1487
|
+
// nixamp.com's /download asks GitHub which release is latest; one answer per ten minutes.
|
|
1488
|
+
const release = latestRelease();
|
|
1481
1489
|
const tracker = options.connections ?? new Connections();
|
|
1482
1490
|
const started = Date.now();
|
|
1483
1491
|
/** Count a request in, count its bytes, and close it out exactly once. */
|
|
@@ -1631,7 +1639,7 @@ export function createHandler(engine, options) {
|
|
|
1631
1639
|
cors: CORS,
|
|
1632
1640
|
watch: (incoming, outgoing, track) => watch(incoming, outgoing, "stream", track),
|
|
1633
1641
|
controls: async () => {
|
|
1634
|
-
if (key
|
|
1642
|
+
if (openToAll(key))
|
|
1635
1643
|
return true;
|
|
1636
1644
|
if (scopeOf(keyFrom(request, url), key, null) === "control")
|
|
1637
1645
|
return true;
|
|
@@ -2333,6 +2341,8 @@ export function createHandler(engine, options) {
|
|
|
2333
2341
|
!(/^\/radio\/(artist|album)\//.test(path) && Boolean(options.music)) &&
|
|
2334
2342
|
// What is on, as MCP, for anybody.
|
|
2335
2343
|
path !== "/mcp/public" &&
|
|
2344
|
+
// llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
|
|
2345
|
+
!(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
|
|
2336
2346
|
// Supporting a copyright holder is for anybody listening.
|
|
2337
2347
|
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2338
2348
|
// A station's short link, which sends the browser to wherever it plays.
|
|
@@ -2394,7 +2404,7 @@ export function createHandler(engine, options) {
|
|
|
2394
2404
|
// or the owner's nixamp.com session. Asked by the routes that do not go
|
|
2395
2405
|
// through the admin gate further down.
|
|
2396
2406
|
const controlOf = async () => {
|
|
2397
|
-
if (key
|
|
2407
|
+
if (openToAll(key))
|
|
2398
2408
|
return { allowed: true, account: "" };
|
|
2399
2409
|
if (scopeOf(keyFrom(request, url), key, listenKey) === "control")
|
|
2400
2410
|
return { allowed: true, account: "" };
|
|
@@ -3037,6 +3047,9 @@ export function createHandler(engine, options) {
|
|
|
3037
3047
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
3038
3048
|
return;
|
|
3039
3049
|
}
|
|
3050
|
+
// Every way into nixamp, for agents and stores: llms.txt, OpenAPI, health, desktop downloads (discovery.ts).
|
|
3051
|
+
if (options.accounts && options.site && await handleDiscovery(request, response, path, { site: options.site, version: options.version, release, json }))
|
|
3052
|
+
return;
|
|
3040
3053
|
if (path === "/reset-password" && options.accounts?.passwordResets) {
|
|
3041
3054
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
3042
3055
|
json(response, 405, { error: "GET only" });
|
|
@@ -4813,10 +4826,10 @@ export function createHandler(engine, options) {
|
|
|
4813
4826
|
(request.method === "DELETE" && channelAction === undefined)) &&
|
|
4814
4827
|
eventChannelRequest);
|
|
4815
4828
|
if (options.owner && needsAdmin(path, request.method ?? "GET") && !eventMediaOperation) {
|
|
4816
|
-
// A server started with --no-key has said that anyone who
|
|
4817
|
-
// port may drive it, and prints exactly that.
|
|
4818
|
-
//
|
|
4819
|
-
const holdsControl = key
|
|
4829
|
+
// A server started with --no-key and no owner has said that anyone who
|
|
4830
|
+
// can reach the port may drive it, and prints exactly that. With an owner
|
|
4831
|
+
// (nixamp.com), --no-key only means anyone may listen: the owner drives.
|
|
4832
|
+
const holdsControl = openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
4820
4833
|
// A session in the query as well as in a header or a cookie: from
|
|
4821
4834
|
// nixamp.com's page a server is another origin, and the cookie stays
|
|
4822
4835
|
// home, so the session travels the way the key does.
|
|
@@ -4972,7 +4985,7 @@ export function createHandler(engine, options) {
|
|
|
4972
4985
|
if (path === "/api/catalogs" && request.method === "GET") {
|
|
4973
4986
|
// Where a list is read from is the administrator's business, not a
|
|
4974
4987
|
// listener's: it can carry a provider's credentials in the URL.
|
|
4975
|
-
const holdsControl = key
|
|
4988
|
+
const holdsControl = openToAll(key) || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
4976
4989
|
const admin = options.owner
|
|
4977
4990
|
? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
|
|
4978
4991
|
: holdsControl;
|
|
@@ -7246,6 +7259,24 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7246
7259
|
throw new Error((await answer.json().catch(() => ({}))).error ?? `nixamp.com answered ${answer.status}`);
|
|
7247
7260
|
return Buffer.from(await answer.arrayBuffer());
|
|
7248
7261
|
},
|
|
7262
|
+
// A singer's close-up redrawn to the song's own seconds, by nixamp.com (fal.ai), under the same cap.
|
|
7263
|
+
lipsync: async (input) => {
|
|
7264
|
+
const session = readSession();
|
|
7265
|
+
const here = stations.publicBase?.() ?? "";
|
|
7266
|
+
if (!session?.token || !/^https:\/\//.test(here))
|
|
7267
|
+
throw new VeoUnavailable("this server is not signed in to nixamp.com");
|
|
7268
|
+
const answer = await fetch(`${session.site.replace(/\/+$/, "")}/api/v1/music/veo`, {
|
|
7269
|
+
method: "POST",
|
|
7270
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session.token}`, "x-nixamp-server": here },
|
|
7271
|
+
body: JSON.stringify({ lipsync: true, seconds: input.seconds, video: input.video.toString("base64"), audio: input.audio.toString("base64") }),
|
|
7272
|
+
signal: AbortSignal.timeout(15 * 60_000),
|
|
7273
|
+
});
|
|
7274
|
+
if (answer.status === 402)
|
|
7275
|
+
throw new VeoUnavailable((await answer.json().catch(() => ({}))).error ?? `HTTP ${answer.status}`);
|
|
7276
|
+
if (!answer.ok)
|
|
7277
|
+
throw new Error((await answer.json().catch(() => ({}))).error ?? `nixamp.com answered ${answer.status}`);
|
|
7278
|
+
return Buffer.from(await answer.arrayBuffer());
|
|
7279
|
+
},
|
|
7249
7280
|
// A release is told to nixamp.com as this server's owner; nixamp.com tells the artist's followers.
|
|
7250
7281
|
announce: (release) => {
|
|
7251
7282
|
const session = readSession();
|
|
@@ -7555,6 +7586,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7555
7586
|
ledger: pool ? pgLedger(pool) : fileLedger(VeoStudio.fileFor(stateDir(), options.port)),
|
|
7556
7587
|
monthlyUsd: Number(process.env["NIXAMP_VEO_MONTHLY_USD"] ?? 50),
|
|
7557
7588
|
usdPerSecond: Number(process.env["NIXAMP_VEO_USD_PER_SECOND"] ?? 0.15),
|
|
7589
|
+
...(process.env["FAL_KEY"] ? { falKey: process.env["FAL_KEY"] } : {}),
|
|
7590
|
+
lipsyncUsdPerSecond: Number(process.env["NIXAMP_LIPSYNC_USD_PER_SECOND"] ?? 0.1),
|
|
7558
7591
|
}) : undefined;
|
|
7559
7592
|
/** The signed-in account owns a server listed in the directory at this base. */
|
|
7560
7593
|
const ownsListed = async (request, base) => {
|
|
@@ -7578,11 +7611,28 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7578
7611
|
if (request.method !== "POST")
|
|
7579
7612
|
return { status: 405, body: { error: "GET or POST" } };
|
|
7580
7613
|
let input;
|
|
7614
|
+
// A lip sync carries an 8 s 1080p shot and its seconds of the song: up to 48 MB as base64.
|
|
7581
7615
|
try {
|
|
7582
|
-
input = JSON.parse(await readBody(request,
|
|
7616
|
+
input = JSON.parse(await readBody(request, 48 * 1024 * 1024));
|
|
7583
7617
|
}
|
|
7584
7618
|
catch {
|
|
7585
|
-
return { status: 400, body: { error: "a JSON body under
|
|
7619
|
+
return { status: 400, body: { error: "a JSON body under 48 MB" } };
|
|
7620
|
+
}
|
|
7621
|
+
if (input.lipsync === true) {
|
|
7622
|
+
if (!veoStudio.canLipsync)
|
|
7623
|
+
return { status: 404, body: { error: "lip sync is not set up here" } };
|
|
7624
|
+
const video = Buffer.from(String(input.video ?? ""), "base64");
|
|
7625
|
+
const audio = Buffer.from(String(input.audio ?? ""), "base64");
|
|
7626
|
+
if (video.length < 10_000 || audio.length < 1_000)
|
|
7627
|
+
return { status: 400, body: { error: "a shot and its audio" } };
|
|
7628
|
+
try {
|
|
7629
|
+
return { status: 200, body: null, video: await veoStudio.lipsync({ video, audio, seconds: Number(input.seconds ?? 8) }) };
|
|
7630
|
+
}
|
|
7631
|
+
catch (error) {
|
|
7632
|
+
if (error instanceof VeoBudgetError)
|
|
7633
|
+
return { status: 402, body: { error: error.message, code: "budget", ...(await veoStudio.status()) } };
|
|
7634
|
+
return { status: 422, body: { error: error.message, code: "refused" } };
|
|
7635
|
+
}
|
|
7586
7636
|
}
|
|
7587
7637
|
const prompt = String(input.prompt ?? "").trim();
|
|
7588
7638
|
if (prompt.length < 20)
|
|
@@ -8399,7 +8449,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
8399
8449
|
console.log(` Nothing outside reaches it until ${port} is forwarded here.`);
|
|
8400
8450
|
}
|
|
8401
8451
|
console.log("");
|
|
8402
|
-
if (key === null) {
|
|
8452
|
+
if (key === null && owner.claimed) {
|
|
8453
|
+
console.log(" No key: anyone who can reach this port can hear it; only its owner can drive it.");
|
|
8454
|
+
}
|
|
8455
|
+
else if (key === null) {
|
|
8403
8456
|
console.log(" No key: anyone who can reach this port can drive it and hear it.");
|
|
8404
8457
|
}
|
|
8405
8458
|
else {
|
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" };
|