nixamp 0.67.4 → 0.68.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 (30) hide show
  1. package/dist/ai-label.d.ts +18 -1
  2. package/dist/ai-label.js +54 -23
  3. package/dist/paywall.d.ts +13 -0
  4. package/dist/paywall.js +28 -0
  5. package/dist/server.d.ts +4 -0
  6. package/dist/server.js +14 -1
  7. package/package.json +1 -1
  8. package/src/ai-label.ts +58 -23
  9. package/src/paywall.ts +31 -0
  10. package/src/server.ts +17 -0
  11. package/web/dist/assets/{ar-CRV9Quan.js → ar-Cdo2sCMS.js} +1 -1
  12. package/web/dist/assets/{cs-CAoAOSEV.js → cs-DNjom4fp.js} +1 -1
  13. package/web/dist/assets/{da-DUvxzaUE.js → da-B-GZOeCd.js} +1 -1
  14. package/web/dist/assets/{de-BdA0QfUh.js → de-Dw6GVH5L.js} +1 -1
  15. package/web/dist/assets/{es-DfbHSteU.js → es-BdSXQf0A.js} +1 -1
  16. package/web/dist/assets/{fi-65IkDcD0.js → fi-C6tCVBdz.js} +1 -1
  17. package/web/dist/assets/{fr-ZCLeigWh.js → fr-pmo397iH.js} +1 -1
  18. package/web/dist/assets/{hi-CgUjpeV3.js → hi-ypPDgWtr.js} +1 -1
  19. package/web/dist/assets/{hu-Nk3lmNzK.js → hu-DJrEIkXY.js} +1 -1
  20. package/web/dist/assets/{id-CSbL1VEB.js → id-shlSuK1w.js} +1 -1
  21. package/web/dist/assets/{it-Ca1dxbJu.js → it-Dhikshqy.js} +1 -1
  22. package/web/dist/assets/{main-3VBJDnTo.js → main-C0jCSWdV.js} +2 -2
  23. package/web/dist/assets/{nl-BGzV-Nib.js → nl-B3MgE_6D.js} +1 -1
  24. package/web/dist/assets/{ru--JBLHgkK.js → ru-BZ3YZm3i.js} +1 -1
  25. package/web/dist/assets/{sv-xxdDl1G_.js → sv-D23OJdcE.js} +1 -1
  26. package/web/dist/assets/{uk-BK5Gpfe7.js → uk-C8wMdda6.js} +1 -1
  27. package/web/dist/assets/{vi-COp3eRc5.js → vi-DFgw8zgY.js} +1 -1
  28. package/web/dist/assets/{zh-xl2IR6MI.js → zh-DACOTWje.js} +1 -1
  29. package/web/dist/index.html +1 -1
  30. package/web/dist/sw.js +19 -19
@@ -102,6 +102,11 @@ export interface LabelSettings {
102
102
  storyShots: number;
103
103
  /** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
104
104
  lipsyncShots: number;
105
+ /**
106
+ * Film enough for the whole song, nothing shown twice: the counts come from the song's
107
+ * length (shotPlan) and the three above are not used. Setting any of them turns this off.
108
+ */
109
+ coverSong: boolean;
105
110
  /** Public, listed and owned, so the genre stations are on the guide. */
106
111
  publish: boolean;
107
112
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -203,6 +208,16 @@ export declare function renderStoryVideo(ffmpeg: string[], input: {
203
208
  seconds: number;
204
209
  }>;
205
210
  }): Promise<void>;
211
+ /**
212
+ * How many shots a single needs so that its footage covers the whole song with
213
+ * nothing shown twice (8 s a shot, the singer's close-ups whole, the rest cut in
214
+ * 4 s halves): about a quarter the singer, a fifth the band, the rest the story.
215
+ */
216
+ export declare function shotPlan(seconds: number, singing: boolean): {
217
+ story: number;
218
+ band: number;
219
+ singer: number;
220
+ };
206
221
  /**
207
222
  * Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
208
223
  * nearest a quarter and three quarters in (one: a third in), each `length`
@@ -218,7 +233,9 @@ export declare function closeUpTimes(seconds: number, lines: Array<{
218
233
  * is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
219
234
  * the mic in the wide shots).
220
235
  */
221
- export declare const FILMED = 6;
236
+ export declare const FILMED = 7;
237
+ /** The oldest version still shown while its single waits to be filmed again: 6, story and band with a close-up, but footage looped and little singing. */
238
+ export declare const SHOWN = 6;
222
239
  /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
223
240
  export declare const VEO_QUOTA: RegExp;
224
241
  /** The next midnight in California, when Google's daily quotas reset. */
package/dist/ai-label.js CHANGED
@@ -276,6 +276,7 @@ export const DEFAULTS = {
276
276
  veoShots: 2,
277
277
  storyShots: 4,
278
278
  lipsyncShots: 2,
279
+ coverSong: true,
279
280
  parallel: 1,
280
281
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
281
282
  };
@@ -535,13 +536,16 @@ const SHOTS = [
535
536
  "Slow aerial push over the crowd toward the stage, fans with hands up, the stage tiny in the distance",
536
537
  ];
537
538
  // The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
538
- // must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
539
- // the mouth is redrawn.
539
+ // must be clear: whatever covers it is smeared into the face when the mouth is redrawn,
540
+ // and Veo puts a microphone in front of it whenever there is one in the scene, however
541
+ // the prompt asks for it held low. So there is none: the singer sings in the story's world.
540
542
  const CLOSE_UPS = [
541
- "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",
542
- "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",
543
- "Medium shot of the lead vocalist singing to the crowd, the microphone held out to the side, face lit and the mouth fully visible",
544
- "Close-up of the lead vocalist singing in profile-three-quarter view, no microphone in frame, the mouth clearly visible",
543
+ "Close-up of the lead vocalist singing straight to camera at night in the rain on a neon-lit street, face and mouth fully lit, no microphone anywhere",
544
+ "Medium close-up of the lead vocalist singing to camera from the driver's seat of a car at dusk, window down, wind in the hair, no microphone",
545
+ "Close-up of the lead vocalist singing on an open road at golden hour, the camera slowly circling, face to camera, no microphone",
546
+ "Close-up of the lead vocalist singing in a dark room lit by one warm lamp, head and shoulders, face to camera, no microphone",
547
+ "Medium close-up of the lead vocalist singing on a rooftop at night, the city lights behind, face to camera, no microphone",
548
+ "Close-up of the lead vocalist singing in the back of a moving train at dawn, soft window light on the face, no microphone",
545
549
  ];
546
550
  /**
547
551
  * The prompts for a single's filmed shots. The people are the reference
@@ -551,9 +555,10 @@ const CLOSE_UPS = [
551
555
  export function veoPrompts(artist, song, members, count, kind = "band") {
552
556
  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("; ");
553
557
  const rule = kind === "singer"
554
- ? "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."
558
+ ? "The vocalist sings clearly to camera, the whole mouth always visible. There is no microphone, mic stand or instrument anywhere in the shot, and nothing in front of the face. Cinematic, 35mm film look. No text, no logos."
555
559
  : "The lead vocalist is not in this shot, and nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
556
- return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
560
+ const list = kind === "singer" ? CLOSE_UPS : SHOTS;
561
+ return Array.from({ length: Math.max(0, count) }, (_, i) => list[i % list.length] + (i >= list.length ? ", from another angle" : "")).map((shot) => [
557
562
  `Music video for a fictional ${artist.genre} band. ${shot}.`,
558
563
  cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
559
564
  `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
@@ -650,6 +655,17 @@ export function renderStoryVideo(ffmpeg, input) {
650
655
  "-movflags", "+faststart+use_metadata_tags", input.out];
651
656
  return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
652
657
  }
658
+ /**
659
+ * How many shots a single needs so that its footage covers the whole song with
660
+ * nothing shown twice (8 s a shot, the singer's close-ups whole, the rest cut in
661
+ * 4 s halves): about a quarter the singer, a fifth the band, the rest the story.
662
+ */
663
+ export function shotPlan(seconds, singing) {
664
+ const total = Math.ceil(Math.max(30, seconds) / 8) + 1;
665
+ const singer = singing ? Math.max(2, Math.round(total / 4)) : 0;
666
+ const band = Math.max(2, Math.round(total / 5));
667
+ return { story: Math.max(2, total - singer - band), band, singer };
668
+ }
653
669
  /**
654
670
  * Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
655
671
  * nearest a quarter and three quarters in (one: a third in), each `length`
@@ -659,13 +675,18 @@ export function renderStoryVideo(ffmpeg, input) {
659
675
  export function closeUpTimes(seconds, lines, count, length = 8) {
660
676
  if (count < 1)
661
677
  return [];
662
- const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => 0.25 + (0.5 * i) / (count - 1));
678
+ // Two: a quarter and three quarters in; more: spread through the song.
679
+ const edge = count <= 2 ? 0.25 : 0.12;
680
+ const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => edge + ((1 - 2 * edge) * i) / (count - 1));
663
681
  const fits = (t) => t >= 6 && t + length <= seconds - 1;
664
682
  const starts = lines.map((line) => Math.round(line.start * 100) / 100).filter(fits);
665
683
  const chosen = [];
666
684
  for (const share of targets) {
667
685
  const want = share * seconds;
668
- const pool = (starts.length ? starts : [Math.round(want)]).filter((t) => fits(t) && chosen.every((one) => Math.abs(one - t) >= length));
686
+ const free = (t) => fits(t) && chosen.every((one) => Math.abs(one - t) >= length);
687
+ // A sung line near the point if one is free, else the point itself.
688
+ const near = starts.filter(free).filter((t) => Math.abs(t - want) <= seconds / (2 * count));
689
+ const pool = near.length ? near : [Math.round(want)].filter(free);
669
690
  if (pool.length)
670
691
  chosen.push(pool.reduce((best, t) => (Math.abs(t - want) < Math.abs(best - want) ? t : best)));
671
692
  }
@@ -677,7 +698,9 @@ export function closeUpTimes(seconds, lines, count, length = 8) {
677
698
  * is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
678
699
  * the mic in the wide shots).
679
700
  */
680
- export const FILMED = 6;
701
+ export const FILMED = 7;
702
+ /** The oldest version still shown while its single waits to be filmed again: 6, story and band with a close-up, but footage looped and little singing. */
703
+ export const SHOWN = 6;
681
704
  /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
682
705
  export const VEO_QUOTA = /exceeded your current quota|RESOURCE_EXHAUSTED/i;
683
706
  /** The next midnight in California, when Google's daily quotas reset. */
@@ -989,6 +1012,11 @@ export class AiLabel {
989
1012
  const close = whole("lipsyncShots", 0, 4);
990
1013
  if (close !== undefined)
991
1014
  this.settings.lipsyncShots = close;
1015
+ // Counts given by hand mean those counts; coverSong says otherwise only when given too.
1016
+ if (shots !== undefined || story !== undefined || close !== undefined)
1017
+ this.settings.coverSong = false;
1018
+ if (input["coverSong"] !== undefined)
1019
+ this.settings.coverSong = input["coverSong"] === true;
992
1020
  const perDay = whole("albumsPerDay", 0, 24);
993
1021
  if (perDay !== undefined)
994
1022
  this.settings.albumsPerDay = perDay;
@@ -1184,7 +1212,7 @@ export class AiLabel {
1184
1212
  // A filmed video whose singer was never filmed (a quota or a refusal took the close-ups) is filmed again while it stays up.
1185
1213
  const singerless = (song) => Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0 && (song.videoVersion ?? 1) >= FILMED && !song.singer;
1186
1214
  const missing = songs.filter(({ song }) => this.wantsVeo(song) && ((song.videoVersion ?? 1) < FILMED || singerless(song)) && this.now() - (this.filmTriedAt.get(song.file) ?? 0) > 60 * 60_000);
1187
- const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= FILMED);
1215
+ const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= SHOWN);
1188
1216
  const old = [];
1189
1217
  let made = 0;
1190
1218
  for (const { album, song } of untagged) {
@@ -1256,12 +1284,15 @@ export class AiLabel {
1256
1284
  catch { /* the asked-for length */ }
1257
1285
  // Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
1258
1286
  const shots = join(_album.folder, `.veo-${song.n}`);
1259
- const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
1260
- const wanted = this.settings.veoShots + this.settings.storyShots;
1287
+ const plan = this.settings.coverSong
1288
+ ? shotPlan(seconds, Boolean(this.deps.lipsync))
1289
+ : { story: this.settings.storyShots, band: this.settings.veoShots, singer: this.settings.lipsyncShots };
1290
+ const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes, plan);
1291
+ const wanted = plan.band + plan.story;
1261
1292
  if (clips.length < Math.max(2, Math.ceil(wanted / 2)))
1262
1293
  throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
1263
1294
  // The singer is the point of the video: with lip sync on, nothing is cut until a close-up is filmed.
1264
- const singing = Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0;
1295
+ const singing = Boolean(this.deps.lipsync) && plan.singer > 0;
1265
1296
  if (singing && singer.length === 0)
1266
1297
  throw new Error("the singer's close-ups are not filmed yet");
1267
1298
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
@@ -1342,7 +1373,7 @@ export class AiLabel {
1342
1373
  * Veo's reference images), on stage, in the song's mood. Stops for the
1343
1374
  * month when the budget is spent; a shot Veo will not make is skipped.
1344
1375
  */
1345
- async filmShots(artist, album, song, keep, dir) {
1376
+ async filmShots(artist, album, song, keep, dir, counts) {
1346
1377
  const veo = this.deps.veo;
1347
1378
  if (!veo)
1348
1379
  return { band: [], singer: [] };
@@ -1375,7 +1406,7 @@ export class AiLabel {
1375
1406
  const story = [];
1376
1407
  const singer = [];
1377
1408
  // The story is written only while some of its scenes are still to film.
1378
- const storyFiles = Array.from({ length: this.settings.storyShots }, (_, i) => join(keep, `story-${i}.mp4`));
1409
+ const storyFiles = Array.from({ length: counts.story }, (_, i) => join(keep, `story-${i}.mp4`));
1379
1410
  let storyPrompts = [];
1380
1411
  if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
1381
1412
  try {
@@ -1389,9 +1420,9 @@ export class AiLabel {
1389
1420
  // the portraits: they would pull the whole band into every scene.
1390
1421
  // The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
1391
1422
  const plan = [
1392
- ...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
1423
+ ...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
1393
1424
  ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] })),
1394
- ...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1425
+ ...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1395
1426
  ];
1396
1427
  for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1397
1428
  try {
@@ -1454,7 +1485,7 @@ export class AiLabel {
1454
1485
  const next = this.placing.then(async () => {
1455
1486
  await this.deps.index.scan(this.source(), this.deps.probe).done;
1456
1487
  const files = this.list()
1457
- .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => ((song.videoVersion ?? 1) >= FILMED ? song.video ?? "" : ""))))
1488
+ .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => ((song.videoVersion ?? 1) >= SHOWN ? song.video ?? "" : ""))))
1458
1489
  .filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
1459
1490
  const existing = this.deps.store.byAuto("ai:label:videos");
1460
1491
  if (files.length === 0) {
@@ -1816,7 +1847,7 @@ export class AiLabel {
1816
1847
  .filter((album) => album.songs.some((song) => song.file))
1817
1848
  .map((album) => ({
1818
1849
  key: album.key, title: album.title, year: album.year, concept: album.concept, cover: Boolean(album.cover), complete: album.doneAt > 0,
1819
- songs: album.songs.filter((song) => song.file).map((song) => ({ n: song.n, title: song.title, seconds: song.seconds, lyrics: song.lyrics, ...(song.video && (song.videoVersion ?? 1) >= FILMED ? { video: true } : {}) })),
1850
+ songs: album.songs.filter((song) => song.file).map((song) => ({ n: song.n, title: song.title, seconds: song.seconds, lyrics: song.lyrics, ...(song.video && (song.videoVersion ?? 1) >= SHOWN ? { video: true } : {}) })),
1820
1851
  }))
1821
1852
  .reverse(),
1822
1853
  }))
@@ -1836,7 +1867,7 @@ export class AiLabel {
1836
1867
  if (!song)
1837
1868
  return null;
1838
1869
  if (match[2] === "mp4")
1839
- return song.video && (song.videoVersion ?? 1) >= FILMED ? { path: song.video, type: "video/mp4" } : null;
1870
+ return song.video && (song.videoVersion ?? 1) >= SHOWN ? { path: song.video, type: "video/mp4" } : null;
1840
1871
  return match[2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
1841
1872
  }
1842
1873
  /** The label song at this path, for the now-playing card's Download buttons. */
@@ -1951,7 +1982,7 @@ export class AiLabel {
1951
1982
  entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
1952
1983
  if (song.lrc)
1953
1984
  entries.push({ name: `${folder}/${songBase(song)}.lrc`, path: song.lrc });
1954
- if (song.video && (song.videoVersion ?? 1) >= FILMED)
1985
+ if (song.video && (song.videoVersion ?? 1) >= SHOWN)
1955
1986
  entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
1956
1987
  }
1957
1988
  if (album.cover)
package/dist/paywall.d.ts CHANGED
@@ -58,3 +58,16 @@ export declare function paywallFromEnv(env?: NodeJS.ProcessEnv): PaywallConfig;
58
58
  * a payTo the operator did not set is never invented here.
59
59
  */
60
60
  export declare function applyRemoteConfig(current: PaywallConfig, remote: unknown): PaywallConfig;
61
+ /**
62
+ * The crawl door, nixamp.com only: the fleet's day pass ($1 a day over x402, or
63
+ * any coin / PayPal through CoinPay's hosted checkout) sold on /crawl and charged
64
+ * to AI training crawlers, headless browsers and SEO scrapers everywhere else.
65
+ * People, verified search crawlers (Googlebot, Bingbot from their published
66
+ * ranges) and the AI chat/search fetchers are never charged, nor is anyone
67
+ * presenting a session. Separate from the listener paywall above, which is about
68
+ * how busy a stream is rather than who is asking; it signs its passes with its
69
+ * own secret so a crawl pass is not a listening pass.
70
+ *
71
+ * Built only with a scoped key and a payee, so a self-hosted server never sells.
72
+ */
73
+ export declare function createCrawlGate(env?: NodeJS.ProcessEnv, exempt?: (request: Request) => boolean): import("@profullstack/x402-gateway").Gateway | null;
package/dist/paywall.js CHANGED
@@ -160,3 +160,31 @@ export function applyRemoteConfig(current, remote) {
160
160
  passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : current.passMinutes,
161
161
  };
162
162
  }
163
+ /**
164
+ * The crawl door, nixamp.com only: the fleet's day pass ($1 a day over x402, or
165
+ * any coin / PayPal through CoinPay's hosted checkout) sold on /crawl and charged
166
+ * to AI training crawlers, headless browsers and SEO scrapers everywhere else.
167
+ * People, verified search crawlers (Googlebot, Bingbot from their published
168
+ * ranges) and the AI chat/search fetchers are never charged, nor is anyone
169
+ * presenting a session. Separate from the listener paywall above, which is about
170
+ * how busy a stream is rather than who is asking; it signs its passes with its
171
+ * own secret so a crawl pass is not a listening pass.
172
+ *
173
+ * Built only with a scoped key and a payee, so a self-hosted server never sells.
174
+ */
175
+ export function createCrawlGate(env = process.env, exempt = () => false) {
176
+ const apiKey = env["COINPAY_X402_KEY"] ?? "";
177
+ const payTo = env["CRAWL_PAY_TO"] ?? "";
178
+ if (!apiKey || !payTo)
179
+ return null;
180
+ return createGateway({
181
+ siteUrl: (env["SITE_URL"] || "https://nixamp.com").replace(/\/+$/, ""),
182
+ siteName: "nixamp",
183
+ coinpay: { apiKey },
184
+ payTo,
185
+ secret: `${apiKey}:crawl`,
186
+ contact: "mailto:anthony@profullstack.com",
187
+ openPaths: ["/api/health", "/llms.txt", "/.well-known/"],
188
+ exempt,
189
+ });
190
+ }
package/dist/server.d.ts CHANGED
@@ -898,6 +898,10 @@ export interface HandlerOptions {
898
898
  * Response it is refused with.
899
899
  */
900
900
  throttle?: Throttle;
901
+ /** The crawl door from paywall.ts (nixamp.com only): a 402 for training crawlers, the /crawl shop. */
902
+ crawlGate?: {
903
+ handle: (request: Request) => Promise<Response | null>;
904
+ } | null;
901
905
  /**
902
906
  * The VAPID pair, read from the environment when the server starts. Only the
903
907
  * public half leaves: it is what a browser needs before it can subscribe.
package/dist/server.js CHANGED
@@ -132,7 +132,7 @@ import { WATCH_EVERY_MS, remember, watchOnce } from "./media-index.js";
132
132
  import { Outro } from "./outro.js";
133
133
  import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.js";
134
134
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
135
- import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
135
+ import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, createCrawlGate, } from "./paywall.js";
136
136
  import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.js";
137
137
  import { codecsOf, probeAsync, transportInputArgs, videoArgs } from "./audio.js";
138
138
  import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyInPath, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, KEY_QUERY, } from "./share.js";
@@ -1657,6 +1657,14 @@ export function createHandler(engine, options) {
1657
1657
  // Metered before anything is done for the request, so a caller over its
1658
1658
  // allowance costs nothing but this check. The throttle decides; this only
1659
1659
  // carries its refusal back through Node's response.
1660
+ // Training crawlers buy a day pass before anything else is done for them.
1661
+ if (options.crawlGate) {
1662
+ const sold = await options.crawlGate.handle(requestFor(request));
1663
+ if (sold) {
1664
+ await answerWith(response, sold);
1665
+ return;
1666
+ }
1667
+ }
1660
1668
  if (options.throttle) {
1661
1669
  const refused = await options.throttle.handle(requestFor(request));
1662
1670
  if (refused) {
@@ -7882,6 +7890,10 @@ export async function serve(argv, version = "0.1.0") {
7882
7890
  });
7883
7891
  certs = new Certs(pool, zone, issuer, { log: (line) => console.log(` ${line}`) });
7884
7892
  }
7893
+ // nixamp.com only (it has the database): a self-hosted server never sells crawl passes.
7894
+ const crawlGate = pool
7895
+ ? createCrawlGate(process.env, (request) => /(?:^|;\s*)nixamp_session=/.test(request.headers.get("cookie") ?? ""))
7896
+ : null;
7885
7897
  const throttle = pool
7886
7898
  ? createThrottle({
7887
7899
  rules: [
@@ -8399,6 +8411,7 @@ export async function serve(argv, version = "0.1.0") {
8399
8411
  ...(certs ? { certs } : {}),
8400
8412
  dnsZone: zoneName,
8401
8413
  ...(throttle ? { throttle } : {}),
8414
+ ...(crawlGate ? { crawlGate } : {}),
8402
8415
  ...(partyLine ? { partyLine } : {}),
8403
8416
  // Accounts live where the directory lives, and only there: a nixamp on a
8404
8417
  // laptop has nobody to be an account of.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.67.4",
3
+ "version": "0.68.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
@@ -139,6 +139,11 @@ export interface LabelSettings {
139
139
  storyShots: number;
140
140
  /** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
141
141
  lipsyncShots: number;
142
+ /**
143
+ * Film enough for the whole song, nothing shown twice: the counts come from the song's
144
+ * length (shotPlan) and the three above are not used. Setting any of them turns this off.
145
+ */
146
+ coverSong: boolean;
142
147
  /** Public, listed and owned, so the genre stations are on the guide. */
143
148
  publish: boolean;
144
149
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -396,6 +401,7 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
396
401
  veoShots: 2,
397
402
  storyShots: 4,
398
403
  lipsyncShots: 2,
404
+ coverSong: true,
399
405
  parallel: 1,
400
406
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
401
407
  };
@@ -690,13 +696,16 @@ const SHOTS = [
690
696
  ];
691
697
 
692
698
  // The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
693
- // must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
694
- // the mouth is redrawn.
699
+ // must be clear: whatever covers it is smeared into the face when the mouth is redrawn,
700
+ // and Veo puts a microphone in front of it whenever there is one in the scene, however
701
+ // the prompt asks for it held low. So there is none: the singer sings in the story's world.
695
702
  const CLOSE_UPS = [
696
- "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",
697
- "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",
698
- "Medium shot of the lead vocalist singing to the crowd, the microphone held out to the side, face lit and the mouth fully visible",
699
- "Close-up of the lead vocalist singing in profile-three-quarter view, no microphone in frame, the mouth clearly visible",
703
+ "Close-up of the lead vocalist singing straight to camera at night in the rain on a neon-lit street, face and mouth fully lit, no microphone anywhere",
704
+ "Medium close-up of the lead vocalist singing to camera from the driver's seat of a car at dusk, window down, wind in the hair, no microphone",
705
+ "Close-up of the lead vocalist singing on an open road at golden hour, the camera slowly circling, face to camera, no microphone",
706
+ "Close-up of the lead vocalist singing in a dark room lit by one warm lamp, head and shoulders, face to camera, no microphone",
707
+ "Medium close-up of the lead vocalist singing on a rooftop at night, the city lights behind, face to camera, no microphone",
708
+ "Close-up of the lead vocalist singing in the back of a moving train at dawn, soft window light on the face, no microphone",
700
709
  ];
701
710
 
702
711
  /**
@@ -707,9 +716,10 @@ const CLOSE_UPS = [
707
716
  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[] {
708
717
  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("; ");
709
718
  const rule = kind === "singer"
710
- ? "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."
719
+ ? "The vocalist sings clearly to camera, the whole mouth always visible. There is no microphone, mic stand or instrument anywhere in the shot, and nothing in front of the face. Cinematic, 35mm film look. No text, no logos."
711
720
  : "The lead vocalist is not in this shot, and nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
712
- return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
721
+ const list = kind === "singer" ? CLOSE_UPS : SHOTS;
722
+ return Array.from({ length: Math.max(0, count) }, (_, i) => list[i % list.length]! + (i >= list.length ? ", from another angle" : "")).map((shot) => [
713
723
  `Music video for a fictional ${artist.genre} band. ${shot}.`,
714
724
  cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
715
725
  `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
@@ -797,6 +807,18 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
797
807
  return runLow(ffmpeg, [...ffmpeg.slice(1), ...args], 60 * 60_000, "the video failed");
798
808
  }
799
809
 
810
+ /**
811
+ * How many shots a single needs so that its footage covers the whole song with
812
+ * nothing shown twice (8 s a shot, the singer's close-ups whole, the rest cut in
813
+ * 4 s halves): about a quarter the singer, a fifth the band, the rest the story.
814
+ */
815
+ export function shotPlan(seconds: number, singing: boolean): { story: number; band: number; singer: number } {
816
+ const total = Math.ceil(Math.max(30, seconds) / 8) + 1;
817
+ const singer = singing ? Math.max(2, Math.round(total / 4)) : 0;
818
+ const band = Math.max(2, Math.round(total / 5));
819
+ return { story: Math.max(2, total - singer - band), band, singer };
820
+ }
821
+
800
822
  /**
801
823
  * Where a single's lip-synced close-ups go: the start of a sung line (its .lrc)
802
824
  * nearest a quarter and three quarters in (one: a third in), each `length`
@@ -805,13 +827,18 @@ export function renderStoryVideo(ffmpeg: string[], input: { audio: string; image
805
827
  */
806
828
  export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, count: number, length = 8): number[] {
807
829
  if (count < 1) return [];
808
- const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => 0.25 + (0.5 * i) / (count - 1));
830
+ // Two: a quarter and three quarters in; more: spread through the song.
831
+ const edge = count <= 2 ? 0.25 : 0.12;
832
+ const targets = count === 1 ? [1 / 3] : Array.from({ length: count }, (_, i) => edge + ((1 - 2 * edge) * i) / (count - 1));
809
833
  const fits = (t: number): boolean => t >= 6 && t + length <= seconds - 1;
810
834
  const starts = lines.map((line) => Math.round(line.start * 100) / 100).filter(fits);
811
835
  const chosen: number[] = [];
812
836
  for (const share of targets) {
813
837
  const want = share * seconds;
814
- const pool = (starts.length ? starts : [Math.round(want)]).filter((t) => fits(t) && chosen.every((one) => Math.abs(one - t) >= length));
838
+ const free = (t: number): boolean => fits(t) && chosen.every((one) => Math.abs(one - t) >= length);
839
+ // A sung line near the point if one is free, else the point itself.
840
+ const near = starts.filter(free).filter((t) => Math.abs(t - want) <= seconds / (2 * count));
841
+ const pool = near.length ? near : [Math.round(want)].filter(free);
815
842
  if (pool.length) chosen.push(pool.reduce((best, t) => (Math.abs(t - want) < Math.abs(best - want) ? t : best)));
816
843
  }
817
844
  return chosen.sort((a, b) => a - b);
@@ -823,7 +850,9 @@ export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, c
823
850
  * is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
824
851
  * the mic in the wide shots).
825
852
  */
826
- export const FILMED = 6;
853
+ export const FILMED = 7;
854
+ /** The oldest version still shown while its single waits to be filmed again: 6, story and band with a close-up, but footage looped and little singing. */
855
+ export const SHOWN = 6;
827
856
 
828
857
  /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
829
858
  export const VEO_QUOTA = /exceeded your current quota|RESOURCE_EXHAUSTED/i;
@@ -1169,6 +1198,9 @@ export class AiLabel {
1169
1198
  if (story !== undefined) this.settings.storyShots = story;
1170
1199
  const close = whole("lipsyncShots", 0, 4);
1171
1200
  if (close !== undefined) this.settings.lipsyncShots = close;
1201
+ // Counts given by hand mean those counts; coverSong says otherwise only when given too.
1202
+ if (shots !== undefined || story !== undefined || close !== undefined) this.settings.coverSong = false;
1203
+ if (input["coverSong"] !== undefined) this.settings.coverSong = input["coverSong"] === true;
1172
1204
  const perDay = whole("albumsPerDay", 0, 24);
1173
1205
  if (perDay !== undefined) this.settings.albumsPerDay = perDay;
1174
1206
  if (input["newArtistChance"] !== undefined) {
@@ -1350,7 +1382,7 @@ export class AiLabel {
1350
1382
  // A filmed video whose singer was never filmed (a quota or a refusal took the close-ups) is filmed again while it stays up.
1351
1383
  const singerless = (song: LabelSong): boolean => Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0 && (song.videoVersion ?? 1) >= FILMED && !song.singer;
1352
1384
  const missing = songs.filter(({ song }) => this.wantsVeo(song) && ((song.videoVersion ?? 1) < FILMED || singerless(song)) && this.now() - (this.filmTriedAt.get(song.file) ?? 0) > 60 * 60_000);
1353
- const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= FILMED);
1385
+ const untagged = songs.filter(({ song }) => song.video && !song.videoTagged && (song.videoVersion ?? 1) >= SHOWN);
1354
1386
  const old: typeof songs = [];
1355
1387
  let made = 0;
1356
1388
  for (const { album, song } of untagged) {
@@ -1413,11 +1445,14 @@ export class AiLabel {
1413
1445
  } catch { /* the asked-for length */ }
1414
1446
  // Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
1415
1447
  const shots = join(_album.folder, `.veo-${song.n}`);
1416
- const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
1417
- const wanted = this.settings.veoShots + this.settings.storyShots;
1448
+ const plan = this.settings.coverSong
1449
+ ? shotPlan(seconds, Boolean(this.deps.lipsync))
1450
+ : { story: this.settings.storyShots, band: this.settings.veoShots, singer: this.settings.lipsyncShots };
1451
+ const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes, plan);
1452
+ const wanted = plan.band + plan.story;
1418
1453
  if (clips.length < Math.max(2, Math.ceil(wanted / 2))) throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
1419
1454
  // The singer is the point of the video: with lip sync on, nothing is cut until a close-up is filmed.
1420
- const singing = Boolean(this.deps.lipsync) && this.settings.lipsyncShots > 0;
1455
+ const singing = Boolean(this.deps.lipsync) && plan.singer > 0;
1421
1456
  if (singing && singer.length === 0) throw new Error("the singer's close-ups are not filmed yet");
1422
1457
  const pinned = await this.syncCloseUps(song, singer, seconds, shots);
1423
1458
  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 } : {}) });
@@ -1482,7 +1517,7 @@ export class AiLabel {
1482
1517
  * Veo's reference images), on stage, in the song's mood. Stops for the
1483
1518
  * month when the budget is spent; a shot Veo will not make is skipped.
1484
1519
  */
1485
- private async filmShots(artist: LabelArtist, album: LabelAlbum, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
1520
+ private async filmShots(artist: LabelArtist, album: LabelAlbum, song: LabelSong, keep: string, dir: string, counts: { story: number; band: number; singer: number }): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
1486
1521
  const veo = this.deps.veo;
1487
1522
  if (!veo) return { band: [], singer: [] };
1488
1523
  mkdirSync(dir, { recursive: true });
@@ -1513,7 +1548,7 @@ export class AiLabel {
1513
1548
  const story: Array<{ path: string; seconds: number }> = [];
1514
1549
  const singer: Array<{ path: string; seconds: number }> = [];
1515
1550
  // The story is written only while some of its scenes are still to film.
1516
- const storyFiles = Array.from({ length: this.settings.storyShots }, (_, i) => join(keep, `story-${i}.mp4`));
1551
+ const storyFiles = Array.from({ length: counts.story }, (_, i) => join(keep, `story-${i}.mp4`));
1517
1552
  let storyPrompts: string[] = [];
1518
1553
  if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
1519
1554
  try {
@@ -1526,9 +1561,9 @@ export class AiLabel {
1526
1561
  // the portraits: they would pull the whole band into every scene.
1527
1562
  // The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
1528
1563
  const plan = [
1529
- ...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
1564
+ ...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
1530
1565
  ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[] })),
1531
- ...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1566
+ ...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
1532
1567
  ];
1533
1568
  for (const [i, { prompt, file, into, refs }] of plan.entries()) {
1534
1569
  try {
@@ -1578,7 +1613,7 @@ export class AiLabel {
1578
1613
  const next = this.placing.then(async () => {
1579
1614
  await this.deps.index.scan(this.source(), this.deps.probe).done;
1580
1615
  const files = this.list()
1581
- .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => ((song.videoVersion ?? 1) >= FILMED ? song.video ?? "" : ""))))
1616
+ .flatMap((artist) => artist.albums.flatMap((album) => album.songs.map((song) => ((song.videoVersion ?? 1) >= SHOWN ? song.video ?? "" : ""))))
1582
1617
  .filter((file) => file && this.deps.index.itemAt(file)?.state === "ready");
1583
1618
  const existing = this.deps.store.byAuto("ai:label:videos");
1584
1619
  if (files.length === 0) {
@@ -1922,7 +1957,7 @@ export class AiLabel {
1922
1957
  .filter((album) => album.songs.some((song) => song.file))
1923
1958
  .map((album) => ({
1924
1959
  key: album.key, title: album.title, year: album.year, concept: album.concept, cover: Boolean(album.cover), complete: album.doneAt > 0,
1925
- songs: album.songs.filter((song) => song.file).map((song) => ({ n: song.n, title: song.title, seconds: song.seconds, lyrics: song.lyrics, ...(song.video && (song.videoVersion ?? 1) >= FILMED ? { video: true } : {}) })),
1960
+ songs: album.songs.filter((song) => song.file).map((song) => ({ n: song.n, title: song.title, seconds: song.seconds, lyrics: song.lyrics, ...(song.video && (song.videoVersion ?? 1) >= SHOWN ? { video: true } : {}) })),
1926
1961
  }))
1927
1962
  .reverse(),
1928
1963
  }))
@@ -1939,7 +1974,7 @@ export class AiLabel {
1939
1974
  const match = /^(\d{1,3})\.(mp3|txt|mp4)$/.exec(what);
1940
1975
  const song = match ? album.songs.find((one) => one.n === Number(match[1]) && one.file) : undefined;
1941
1976
  if (!song) return null;
1942
- if (match![2] === "mp4") return song.video && (song.videoVersion ?? 1) >= FILMED ? { path: song.video, type: "video/mp4" } : null;
1977
+ if (match![2] === "mp4") return song.video && (song.videoVersion ?? 1) >= SHOWN ? { path: song.video, type: "video/mp4" } : null;
1943
1978
  return match![2] === "mp3" ? { path: song.file, type: "audio/mpeg" } : { path: song.lyricsFile, type: "text/plain; charset=utf-8" };
1944
1979
  }
1945
1980
 
@@ -2051,7 +2086,7 @@ export class AiLabel {
2051
2086
  entries.push({ name: `${folder}/${songBase(song)}.mp3`, path: song.file });
2052
2087
  entries.push({ name: `${folder}/${songBase(song)}.lyrics.txt`, bytes: Buffer.from(`${song.lyrics}\n`) });
2053
2088
  if (song.lrc) entries.push({ name: `${folder}/${songBase(song)}.lrc`, path: song.lrc });
2054
- if (song.video && (song.videoVersion ?? 1) >= FILMED) entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
2089
+ if (song.video && (song.videoVersion ?? 1) >= SHOWN) entries.push({ name: `${folder}/${songBase(song)}.mp4`, path: song.video });
2055
2090
  }
2056
2091
  if (album.cover) entries.push({ name: `${folder}/cover.jpg`, path: album.cover });
2057
2092
  entries.push({ name: `${folder}/album.json`, bytes: Buffer.from(`${JSON.stringify({
package/src/paywall.ts CHANGED
@@ -196,3 +196,34 @@ export function applyRemoteConfig(current: PaywallConfig, remote: unknown): Payw
196
196
  passMinutes: Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : current.passMinutes,
197
197
  };
198
198
  }
199
+
200
+ /**
201
+ * The crawl door, nixamp.com only: the fleet's day pass ($1 a day over x402, or
202
+ * any coin / PayPal through CoinPay's hosted checkout) sold on /crawl and charged
203
+ * to AI training crawlers, headless browsers and SEO scrapers everywhere else.
204
+ * People, verified search crawlers (Googlebot, Bingbot from their published
205
+ * ranges) and the AI chat/search fetchers are never charged, nor is anyone
206
+ * presenting a session. Separate from the listener paywall above, which is about
207
+ * how busy a stream is rather than who is asking; it signs its passes with its
208
+ * own secret so a crawl pass is not a listening pass.
209
+ *
210
+ * Built only with a scoped key and a payee, so a self-hosted server never sells.
211
+ */
212
+ export function createCrawlGate(
213
+ env: NodeJS.ProcessEnv = process.env,
214
+ exempt: (request: Request) => boolean = () => false,
215
+ ) {
216
+ const apiKey = env["COINPAY_X402_KEY"] ?? "";
217
+ const payTo = env["CRAWL_PAY_TO"] ?? "";
218
+ if (!apiKey || !payTo) return null;
219
+ return createGateway({
220
+ siteUrl: (env["SITE_URL"] || "https://nixamp.com").replace(/\/+$/, ""),
221
+ siteName: "nixamp",
222
+ coinpay: { apiKey },
223
+ payTo,
224
+ secret: `${apiKey}:crawl`,
225
+ contact: "mailto:anthony@profullstack.com",
226
+ openPaths: ["/api/health", "/llms.txt", "/.well-known/"],
227
+ exempt,
228
+ });
229
+ }