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.
- package/dist/ai-label.d.ts +18 -1
- package/dist/ai-label.js +54 -23
- package/dist/paywall.d.ts +13 -0
- package/dist/paywall.js +28 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +14 -1
- package/package.json +1 -1
- package/src/ai-label.ts +58 -23
- package/src/paywall.ts +31 -0
- package/src/server.ts +17 -0
- package/web/dist/assets/{ar-CRV9Quan.js → ar-Cdo2sCMS.js} +1 -1
- package/web/dist/assets/{cs-CAoAOSEV.js → cs-DNjom4fp.js} +1 -1
- package/web/dist/assets/{da-DUvxzaUE.js → da-B-GZOeCd.js} +1 -1
- package/web/dist/assets/{de-BdA0QfUh.js → de-Dw6GVH5L.js} +1 -1
- package/web/dist/assets/{es-DfbHSteU.js → es-BdSXQf0A.js} +1 -1
- package/web/dist/assets/{fi-65IkDcD0.js → fi-C6tCVBdz.js} +1 -1
- package/web/dist/assets/{fr-ZCLeigWh.js → fr-pmo397iH.js} +1 -1
- package/web/dist/assets/{hi-CgUjpeV3.js → hi-ypPDgWtr.js} +1 -1
- package/web/dist/assets/{hu-Nk3lmNzK.js → hu-DJrEIkXY.js} +1 -1
- package/web/dist/assets/{id-CSbL1VEB.js → id-shlSuK1w.js} +1 -1
- package/web/dist/assets/{it-Ca1dxbJu.js → it-Dhikshqy.js} +1 -1
- package/web/dist/assets/{main-3VBJDnTo.js → main-C0jCSWdV.js} +2 -2
- package/web/dist/assets/{nl-BGzV-Nib.js → nl-B3MgE_6D.js} +1 -1
- package/web/dist/assets/{ru--JBLHgkK.js → ru-BZ3YZm3i.js} +1 -1
- package/web/dist/assets/{sv-xxdDl1G_.js → sv-D23OJdcE.js} +1 -1
- package/web/dist/assets/{uk-BK5Gpfe7.js → uk-C8wMdda6.js} +1 -1
- package/web/dist/assets/{vi-COp3eRc5.js → vi-DFgw8zgY.js} +1 -1
- package/web/dist/assets/{zh-xl2IR6MI.js → zh-DACOTWje.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
package/dist/ai-label.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
539
|
-
//
|
|
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
|
-
"
|
|
542
|
-
"
|
|
543
|
-
"
|
|
544
|
-
"Close-up of the lead vocalist singing in
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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) >=
|
|
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
|
|
1260
|
-
|
|
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) &&
|
|
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:
|
|
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,
|
|
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,
|
|
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) >=
|
|
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) >=
|
|
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) >=
|
|
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) >=
|
|
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
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
|
|
694
|
-
//
|
|
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
|
-
"
|
|
697
|
-
"
|
|
698
|
-
"
|
|
699
|
-
"Close-up of the lead vocalist singing in
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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) >=
|
|
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
|
|
1417
|
-
|
|
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) &&
|
|
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:
|
|
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,
|
|
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,
|
|
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) >=
|
|
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) >=
|
|
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) >=
|
|
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) >=
|
|
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
|
+
}
|