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.
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 +47 -2
  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 +40 -2
  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-T_s9IpDl.js} +1 -1
  19. package/web/dist/assets/{cs-BCAcbLw4.js → cs-u6VmDGRj.js} +1 -1
  20. package/web/dist/assets/{da-B8ZLImmi.js → da-CtKMj9yA.js} +1 -1
  21. package/web/dist/assets/{de-BEfJLxqy.js → de-DUIp6cPq.js} +1 -1
  22. package/web/dist/assets/{es-CvwtfSaX.js → es-CDKeGB_J.js} +1 -1
  23. package/web/dist/assets/{fi-D8zNiMjO.js → fi-Bm5mnEJW.js} +1 -1
  24. package/web/dist/assets/{fr-C2YZ2L4l.js → fr-C-wTfG2I.js} +1 -1
  25. package/web/dist/assets/{hi-K_VSycz2.js → hi-Cj5DBYQn.js} +1 -1
  26. package/web/dist/assets/{hu-Dl_GeLxY.js → hu-l11hhWja.js} +1 -1
  27. package/web/dist/assets/{id-Ba1NrTEb.js → id-Y_NtfdKr.js} +1 -1
  28. package/web/dist/assets/{it-ClsnZdJN.js → it-DPsBnd04.js} +1 -1
  29. package/web/dist/assets/{main-DD5A9Exa.js → main-wXq7DAXz.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-BJnRKhqE.js} +1 -1
  32. package/web/dist/assets/{ru-BvhOKYAB.js → ru-CLRFRTHU.js} +1 -1
  33. package/web/dist/assets/{sv-BItd8vZI.js → sv-BarkkLkN.js} +1 -1
  34. package/web/dist/assets/{uk-BcUnWToF.js → uk-DlOj9CHi.js} +1 -1
  35. package/web/dist/assets/{vi-LFZ3qSPF.js → vi-DqXccGG3.js} +1 -1
  36. package/web/dist/assets/{zh-DfCRV3mt.js → zh-CfnJPLyy.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";
@@ -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, 2 * 1024 * 1024));
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 2 MB" } };
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)
@@ -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.0",
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",
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
- return SHOTS.slice(0, count).map((shot) => [
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
- "Nobody sings, speaks or lip-syncs on camera: no mouths in close-up. Cinematic, 35mm film look, dynamic camera. No text, no logos.",
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
- * The version of a video cut from Veo footage with nobody singing on camera; anything
740
- * older (a slideshow, or 4: footage whose singers were out of sync) is not shown.
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; i++) {
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
- const start = Math.min(half * cut, Math.max(0, clip.seconds - cut));
758
- const length = Math.min(cut, clip.seconds - start, input.seconds - at + 0.5);
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
- await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out });
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 clips: Array<{ path: string; seconds: number }> = [];
1330
- for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1331
- const file = join(keep, `shot-${i}.mp4`);
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) { clips.push({ path: file, seconds: 8 }); continue; }
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
- clips.push({ path: file, seconds: 8 });
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 (clips.length) this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1358
- return clips;
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. */