nixamp 0.63.2 → 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 +41 -4
  2. package/dist/ai-label.js +146 -28
  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 +138 -31
  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-DnYW8sEN.js → ar-T_s9IpDl.js} +1 -1
  19. package/web/dist/assets/{cs-EKiTXqE-.js → cs-u6VmDGRj.js} +1 -1
  20. package/web/dist/assets/{da-QDldl8jA.js → da-CtKMj9yA.js} +1 -1
  21. package/web/dist/assets/{de-BlMOQ5lE.js → de-DUIp6cPq.js} +1 -1
  22. package/web/dist/assets/{es-CuAD79p2.js → es-CDKeGB_J.js} +1 -1
  23. package/web/dist/assets/{fi-BAzr_nfC.js → fi-Bm5mnEJW.js} +1 -1
  24. package/web/dist/assets/{fr-CEHHqyrh.js → fr-C-wTfG2I.js} +1 -1
  25. package/web/dist/assets/{hi-DJi58fKA.js → hi-Cj5DBYQn.js} +1 -1
  26. package/web/dist/assets/{hu-D8wQiWXF.js → hu-l11hhWja.js} +1 -1
  27. package/web/dist/assets/{id-saioMisO.js → id-Y_NtfdKr.js} +1 -1
  28. package/web/dist/assets/{it-D5B7xEhJ.js → it-DPsBnd04.js} +1 -1
  29. package/web/dist/assets/{main-CJOuFN3L.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-s3F3M6st.js → nl-BJnRKhqE.js} +1 -1
  32. package/web/dist/assets/{ru-C_C7s7eG.js → ru-CLRFRTHU.js} +1 -1
  33. package/web/dist/assets/{sv-BgGvqWrJ.js → sv-BarkkLkN.js} +1 -1
  34. package/web/dist/assets/{uk-I6Xlr7Mk.js → uk-DlOj9CHi.js} +1 -1
  35. package/web/dist/assets/{vi-Y6ohDBXv.js → vi-DqXccGG3.js} +1 -1
  36. package/web/dist/assets/{zh-a1cajQz8.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.2",
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
  };
@@ -626,15 +629,28 @@ export async function writeStoryboard(
626
629
  /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
627
630
  export class VeoUnavailable extends Error {}
628
631
 
632
+ // Nobody sings on camera: Veo's singers mouth a melody of their own, never the song's,
633
+ // so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
634
+ // behind, in silhouette or between lines, never a close-up of singing.
629
635
  const SHOTS = [
630
- "Wide shot of the whole band performing on a stage, the crowd in the foreground",
631
- "Close-up of the lead singer singing the chorus into the microphone",
632
- "The camera moves across the stage between the band members as they play hard",
633
- "Low-angle shot of the band at the song's climax, lights flaring behind them",
634
- "Over-the-shoulder shot from behind the drums, looking out at the band and the crowd",
635
- "Slow push-in on the band in a quieter moment, moody light",
636
- "Handheld shot from the crowd, fans with hands up, the band lit on stage",
637
- "The band walking out on stage before the show, backlit silhouettes turning into faces",
636
+ "Wide shot of the whole band performing on a big stage at night, seen from the back of the crowd, the players small against the lights",
637
+ "Close-up of the guitarist's hands on the fretboard and the strings, shallow focus, stage lights flaring",
638
+ "The camera glides behind the band members as they play hard, seen from behind, looking out over the crowd",
639
+ "Low-angle shot of the drummer hitting the cymbals at the song's climax, sticks blurred, sparks of light",
640
+ "The lead vocalist from behind, arms raised to the crowd between lines, backlit silhouette, no face to camera",
641
+ "Slow aerial push over the crowd toward the stage, fans with hands up, the band tiny in the distance",
642
+ "Close-up of the bass player's fingers and the amplifier, then a tilt up to the band in silhouette",
643
+ "The band walking out on stage before the show, backlit silhouettes, nobody speaking or singing",
644
+ ];
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",
638
654
  ];
639
655
 
640
656
  /**
@@ -642,13 +658,16 @@ const SHOTS = [
642
658
  * images, described by role and look only: Veo refuses names with faces
643
659
  * ("we can't create videos with real people's names or likenesses").
644
660
  */
645
- 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[] {
646
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("; ");
647
- 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) => [
648
667
  `Music video for a fictional ${artist.genre} band. ${shot}.`,
649
668
  cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
650
669
  `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
651
- "Cinematic, 35mm film look, dynamic camera. No text, no logos.",
670
+ rule,
652
671
  ].filter(Boolean).join(" "));
653
672
  }
654
673
 
@@ -732,8 +751,32 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
732
751
  return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
733
752
  }
734
753
 
735
- /** The version of a video cut from Veo footage only; anything older is a slideshow and is not shown. */
736
- export const FILMED = 4;
754
+ /**
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.
778
+ */
779
+ export const FILMED = 5;
737
780
 
738
781
  /**
739
782
  * A single's music video, cut from its filmed shots alone: 4 s cuts, each
@@ -741,17 +784,32 @@ export const FILMED = 4;
741
784
  * ends, so no picture holds still. The artist and title come up at the start;
742
785
  * the MP3's audio and tags are copied in. 1080p H.264.
743
786
  */
744
- 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> {
745
788
  if (input.clips.length === 0) return Promise.reject(new Error("no footage to cut"));
746
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);
747
792
  const cuts: Array<{ path: string; start: number; length: number }> = [];
748
- 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);
749
802
  const clip = input.clips[i % input.clips.length]!;
750
803
  const half = Math.floor(i / input.clips.length) % 2;
751
- const start = Math.min(half * cut, Math.max(0, clip.seconds - cut));
752
- 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;
753
810
  cuts.push({ path: clip.path, start, length });
754
811
  at += length;
812
+ i += 1;
755
813
  }
756
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}]`);
757
815
  parts.push(`${cuts.map((_, i) => `[c${i}]`).join("")}concat=n=${cuts.length}:v=1:a=0[cut]`);
@@ -858,7 +916,9 @@ export interface AiLabelDeps {
858
916
  /** How long to wait before asking Veo again after nixamp.com or the network failed (60 s). */
859
917
  retryMs?: number;
860
918
  /** Cuts a single's filmed shots to its song; ffmpeg by default (renderFilmedVideo). */
861
- 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;
862
922
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
863
923
  storyVideo?: (input: { audio: string; images: string[]; seconds: number; artist: string; title: string; out: string; clips?: Array<{ path: string; seconds: number }> }) => Promise<void>;
864
924
  /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
@@ -1045,6 +1105,8 @@ export class AiLabel {
1045
1105
  if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
1046
1106
  const shots = whole("veoShots", 1, 8);
1047
1107
  if (shots !== undefined) this.settings.veoShots = shots;
1108
+ const close = whole("lipsyncShots", 0, 4);
1109
+ if (close !== undefined) this.settings.lipsyncShots = close;
1048
1110
  const perDay = whole("albumsPerDay", 0, 24);
1049
1111
  if (perDay !== undefined) this.settings.albumsPerDay = perDay;
1050
1112
  if (input["newArtistChance"] !== undefined) {
@@ -1285,13 +1347,53 @@ export class AiLabel {
1285
1347
  } catch { /* the asked-for length */ }
1286
1348
  // Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
1287
1349
  const shots = join(_album.folder, `.veo-${song.n}`);
1288
- const clips = await this.filmShots(artist, song, shots, scenes);
1350
+ const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
1289
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`);
1290
- 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 } : {}) });
1291
1354
  rmSync(shots, { recursive: true, force: true });
1292
1355
  return FILMED;
1293
1356
  }
1294
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
+
1295
1397
  /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1296
1398
  private wantsVeo(song: Pick<LabelSong, "n">): boolean {
1297
1399
  return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
@@ -1302,9 +1404,9 @@ export class AiLabel {
1302
1404
  * Veo's reference images), on stage, in the song's mood. Stops for the
1303
1405
  * month when the budget is spent; a shot Veo will not make is skipped.
1304
1406
  */
1305
- 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 }> }> {
1306
1408
  const veo = this.deps.veo;
1307
- if (!veo) return [];
1409
+ if (!veo) return { band: [], singer: [] };
1308
1410
  mkdirSync(dir, { recursive: true });
1309
1411
  mkdirSync(keep, { recursive: true });
1310
1412
  const members = this.cast(artist);
@@ -1320,11 +1422,16 @@ export class AiLabel {
1320
1422
  // A member without a portrait is described in words only.
1321
1423
  }
1322
1424
  }
1323
- const clips: Array<{ path: string; seconds: number }> = [];
1324
- for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1325
- 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()) {
1326
1433
  try {
1327
- 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; }
1328
1435
  } catch { /* not filmed yet */ }
1329
1436
  try {
1330
1437
  // nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
@@ -1338,7 +1445,7 @@ export class AiLabel {
1338
1445
  const partial = `${file}.part`;
1339
1446
  writeFileSync(partial, bytes!);
1340
1447
  renameSync(partial, file);
1341
- clips.push({ path: file, seconds: 8 });
1448
+ into.push({ path: file, seconds: 8 });
1342
1449
  } catch (error) {
1343
1450
  if (error instanceof VeoUnavailable) {
1344
1451
  this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
@@ -1348,8 +1455,8 @@ export class AiLabel {
1348
1455
  this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
1349
1456
  }
1350
1457
  }
1351
- if (clips.length) this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1352
- 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 };
1353
1460
  }
1354
1461
 
1355
1462
  /** The label's music videos as one TV station: free to watch, with its breaks. */