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.
Files changed (44) hide show
  1. package/dist/ai-label.d.ts +38 -4
  2. package/dist/ai-label.js +132 -20
  3. package/dist/discovery.d.ts +48 -0
  4. package/dist/discovery.js +181 -0
  5. package/dist/server.js +64 -11
  6. package/dist/station-cli.js +1 -0
  7. package/dist/station-mcp.d.ts +15 -0
  8. package/dist/station-mcp.js +3 -2
  9. package/dist/veo-studio.d.ts +16 -0
  10. package/dist/veo-studio.js +59 -0
  11. package/package.json +1 -1
  12. package/src/ai-label.ts +124 -23
  13. package/src/discovery.ts +210 -0
  14. package/src/server.ts +56 -11
  15. package/src/station-cli.ts +1 -0
  16. package/src/station-mcp.ts +3 -2
  17. package/src/veo-studio.ts +57 -0
  18. package/web/dist/assets/{ar-BdJErAsT.js → ar-DsLCtn6q.js} +1 -1
  19. package/web/dist/assets/{cs-BCAcbLw4.js → cs-CDebRTvk.js} +1 -1
  20. package/web/dist/assets/{da-B8ZLImmi.js → da-CtIZY0F_.js} +1 -1
  21. package/web/dist/assets/{de-BEfJLxqy.js → de-Ce4XnPH4.js} +1 -1
  22. package/web/dist/assets/{es-CvwtfSaX.js → es-DuTsv2ty.js} +1 -1
  23. package/web/dist/assets/{fi-D8zNiMjO.js → fi-BxzgeUzQ.js} +1 -1
  24. package/web/dist/assets/{fr-C2YZ2L4l.js → fr-1e-Cafh4.js} +1 -1
  25. package/web/dist/assets/{hi-K_VSycz2.js → hi-yKzlszV2.js} +1 -1
  26. package/web/dist/assets/{hu-Dl_GeLxY.js → hu-DEEC9-i5.js} +1 -1
  27. package/web/dist/assets/{id-Ba1NrTEb.js → id-Da47jrbA.js} +1 -1
  28. package/web/dist/assets/{it-ClsnZdJN.js → it-DyEoFcC_.js} +1 -1
  29. package/web/dist/assets/{main-DD5A9Exa.js → main-C_3KEwHZ.js} +2 -2
  30. package/web/dist/assets/{music-LviR4dJP.js → music-Bby75I2_.js} +1 -1
  31. package/web/dist/assets/{nl-BLjok-1D.js → nl-uTGCd9L3.js} +1 -1
  32. package/web/dist/assets/{ru-BvhOKYAB.js → ru-BJ7gUalz.js} +1 -1
  33. package/web/dist/assets/{sv-BItd8vZI.js → sv-C9ZiAm2Q.js} +1 -1
  34. package/web/dist/assets/{uk-BcUnWToF.js → uk-DIcSDuMA.js} +1 -1
  35. package/web/dist/assets/{vi-LFZ3qSPF.js → vi-4MI2s3Z9.js} +1 -1
  36. package/web/dist/assets/{zh-DfCRV3mt.js → zh-DI1PPrRY.js} +1 -1
  37. package/web/dist/icons/music/apple-touch-icon.png +0 -0
  38. package/web/dist/icons/music/icon-192.png +0 -0
  39. package/web/dist/icons/music/icon-384.png +0 -0
  40. package/web/dist/icons/music/icon-512.png +0 -0
  41. package/web/dist/index.html +1 -1
  42. package/web/dist/music.html +3 -1
  43. package/web/dist/music.webmanifest +57 -0
  44. 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
- owner: "",
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 === null)
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 === null)
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 can reach the
4817
- // port may drive it, and prints exactly that. Locking administration to
4818
- // nobody would contradict it and leave such a server unadministrable.
4819
- const holdsControl = key === null || scopeOf(keyFrom(request, url), key, null) === "control";
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 === null || scopeOf(keyFrom(request, url), key, null) === "control";
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, 2 * 1024 * 1024));
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 2 MB" } };
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 {
@@ -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,
@@ -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;
@@ -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
@@ -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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.63.3",
3
+ "version": "0.65.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",