nixamp 0.59.5 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/ai-label.d.ts +37 -0
  2. package/dist/ai-label.js +127 -10
  3. package/dist/music-hub.d.ts +8 -0
  4. package/dist/music-hub.js +30 -6
  5. package/dist/music-seo.d.ts +38 -0
  6. package/dist/music-seo.js +160 -0
  7. package/dist/radio-pages.d.ts +4 -2
  8. package/dist/radio-pages.js +11 -4
  9. package/dist/server.d.ts +5 -0
  10. package/dist/server.js +81 -7
  11. package/dist/station-cli.js +2 -0
  12. package/dist/station-mcp.d.ts +30 -0
  13. package/dist/station-mcp.js +3 -1
  14. package/dist/station-usage.d.ts +1 -1
  15. package/dist/station-usage.js +1 -0
  16. package/dist/veo-studio.d.ts +39 -0
  17. package/dist/veo-studio.js +110 -0
  18. package/package.json +1 -1
  19. package/src/ai-label.ts +128 -12
  20. package/src/music-hub.ts +29 -6
  21. package/src/music-seo.ts +191 -0
  22. package/src/radio-pages.ts +12 -4
  23. package/src/server.ts +62 -8
  24. package/src/station-cli.ts +2 -0
  25. package/src/station-mcp.ts +3 -1
  26. package/src/station-usage.ts +1 -0
  27. package/src/veo-studio.ts +123 -0
  28. package/web/dist/assets/{ar-D7L1Lzg5.js → ar-5jTau3by.js} +1 -1
  29. package/web/dist/assets/{cs-DR-oumcX.js → cs-nlVt79qU.js} +1 -1
  30. package/web/dist/assets/{da-B7ofIVod.js → da-C_mRyuJW.js} +1 -1
  31. package/web/dist/assets/{de-CglXPk_n.js → de-Duqxb30z.js} +1 -1
  32. package/web/dist/assets/{es-CYVNiI20.js → es-CAHgck_F.js} +1 -1
  33. package/web/dist/assets/{fi-Bktga6d_.js → fi-BPXwol2s.js} +1 -1
  34. package/web/dist/assets/{fr-CNxMDHg5.js → fr-C9N4WLtT.js} +1 -1
  35. package/web/dist/assets/{hi-DEyWHgib.js → hi-PWlH7Sya.js} +1 -1
  36. package/web/dist/assets/{hu-mcHUOvhY.js → hu-DHmawwq3.js} +1 -1
  37. package/web/dist/assets/{id-T9wfahs5.js → id-DwMuYDOT.js} +1 -1
  38. package/web/dist/assets/{it-CASLaiyC.js → it-DuVsDcAz.js} +1 -1
  39. package/web/dist/assets/{main-D9-BuE2r.js → main-BV2N47tZ.js} +2 -2
  40. package/web/dist/assets/{nl-DQ-YPUBS.js → nl-BcdvPtpE.js} +1 -1
  41. package/web/dist/assets/{ru-CDSDPqFZ.js → ru-CfMl9LlQ.js} +1 -1
  42. package/web/dist/assets/{sv-liuu51-p.js → sv-CDtolH7I.js} +1 -1
  43. package/web/dist/assets/{uk-B3vreqbA.js → uk-DwjjSnVU.js} +1 -1
  44. package/web/dist/assets/{vi-B0NyoUEc.js → vi-CNYK9Tlb.js} +1 -1
  45. package/web/dist/assets/{zh-Bw5OQASg.js → zh-6nGSjON5.js} +1 -1
  46. package/web/dist/index.html +1 -1
  47. package/web/dist/sw.js +19 -19
@@ -88,6 +88,10 @@ export interface LabelSettings {
88
88
  songSeconds: number;
89
89
  /** A music video for every song (the cover over itself, blurred, and the song's waveform), and a TV station of them. */
90
90
  videos: boolean;
91
+ /** Each album's single (song 1) gets filmed Veo shots of the band, while the month's Veo budget lasts. */
92
+ veo: boolean;
93
+ /** How many 8 s Veo shots a single gets. */
94
+ veoShots: number;
91
95
  /** Public, listed and owned, so the genre stations are on the guide. */
92
96
  publish: boolean;
93
97
  /** Songs made at the same time (ElevenLabs takes several requests at once on a paid plan). */
@@ -160,6 +164,15 @@ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLin
160
164
  export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
161
165
  cast?: string;
162
166
  }, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
167
+ /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
168
+ export declare class VeoUnavailable extends Error {
169
+ }
170
+ /**
171
+ * The prompts for a single's filmed shots. The people are the reference
172
+ * images, described by role and look only: Veo refuses names with faces
173
+ * ("we can't create videos with real people's names or likenesses").
174
+ */
175
+ export declare function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: Pick<LabelSong, "style">, members: Array<Pick<Member, "role" | "look">>, count: number): string[];
163
176
  /**
164
177
  * A song's music video from its storyboard: each scene's picture moves (a
165
178
  * slow zoom in, a pan, a zoom out, a pan back, in turn), scenes cross-fade,
@@ -173,6 +186,10 @@ export declare function renderStoryVideo(ffmpeg: string[], input: {
173
186
  artist: string;
174
187
  title: string;
175
188
  out: string;
189
+ clips?: Array<{
190
+ path: string;
191
+ seconds: number;
192
+ }>;
176
193
  }): Promise<void>;
177
194
  /**
178
195
  * A song's music video without a storyboard: its album cover over a blurred
@@ -210,6 +227,12 @@ export interface AiLabelDeps {
210
227
  video?: (audio: string, cover: string, out: string) => Promise<void>;
211
228
  /** Times each sung word in a recording; ElevenLabs forced alignment by default, null without a key. */
212
229
  align?: ((audio: string, text: string) => Promise<TimedWord[]>) | null;
230
+ /** A filmed shot (Veo, through nixamp.com): the MP4, or a VeoUnavailable when the month's budget is spent. */
231
+ veo?: ((input: {
232
+ prompt: string;
233
+ seconds: number;
234
+ references: Buffer[];
235
+ }) => Promise<Buffer>) | null;
213
236
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
214
237
  storyVideo?: (input: {
215
238
  audio: string;
@@ -218,6 +241,10 @@ export interface AiLabelDeps {
218
241
  artist: string;
219
242
  title: string;
220
243
  out: string;
244
+ clips?: Array<{
245
+ path: string;
246
+ seconds: number;
247
+ }>;
221
248
  }) => Promise<void>;
222
249
  /** A scene's picture for this prompt, or null when nothing can draw one; Workers AI by default. */
223
250
  image?: (prompt: string) => Promise<Buffer | null>;
@@ -250,6 +277,8 @@ export declare class AiLabel {
250
277
  private videoFailed;
251
278
  /** Songs whose words were already sent to the aligner since this server started. */
252
279
  private alignTried;
280
+ /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
281
+ private veoSpentIn;
253
282
  /** Old videos already tried as storyboards since this server started. */
254
283
  private upgradeTried;
255
284
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -308,6 +337,14 @@ export declare class AiLabel {
308
337
  * image model can draw it (version 2), else the cover-and-waveform kind (1).
309
338
  */
310
339
  private filmSong;
340
+ /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
341
+ private wantsVeo;
342
+ /**
343
+ * A single's filmed shots: the band, from their portraits (up to three as
344
+ * Veo's reference images), on stage, in the song's mood. Stops for the
345
+ * month when the budget is spent; a shot Veo will not make is skipped.
346
+ */
347
+ private filmShots;
311
348
  /** The label's music videos as one TV station: free to watch, with its breaks. */
312
349
  private placeVideos;
313
350
  stopSchedule(): void;
package/dist/ai-label.js CHANGED
@@ -272,6 +272,8 @@ export const DEFAULTS = {
272
272
  songSeconds: 150,
273
273
  publish: true,
274
274
  videos: true,
275
+ veo: false,
276
+ veoShots: 4,
275
277
  parallel: 1,
276
278
  genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
277
279
  };
@@ -486,6 +488,33 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
486
488
  throw new Error("the model wrote no storyboard");
487
489
  return prompts;
488
490
  }
491
+ /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
492
+ export class VeoUnavailable extends Error {
493
+ }
494
+ const SHOTS = [
495
+ "Wide shot of the whole band performing on a stage, the crowd in the foreground",
496
+ "Close-up of the lead singer singing the chorus into the microphone",
497
+ "The camera moves across the stage between the band members as they play hard",
498
+ "Low-angle shot of the band at the song's climax, lights flaring behind them",
499
+ "Over-the-shoulder shot from behind the drums, looking out at the band and the crowd",
500
+ "Slow push-in on the band in a quieter moment, moody light",
501
+ "Handheld shot from the crowd, fans with hands up, the band lit on stage",
502
+ "The band walking out on stage before the show, backlit silhouettes turning into faces",
503
+ ];
504
+ /**
505
+ * The prompts for a single's filmed shots. The people are the reference
506
+ * images, described by role and look only: Veo refuses names with faces
507
+ * ("we can't create videos with real people's names or likenesses").
508
+ */
509
+ export function veoPrompts(artist, song, members, count) {
510
+ 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("; ");
511
+ return SHOTS.slice(0, count).map((shot) => [
512
+ `Music video for a fictional ${artist.genre} band. ${shot}.`,
513
+ cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
514
+ `Mood and sound: ${song.style} ${artist.sound}.`.slice(0, 400),
515
+ "Cinematic, 35mm film look, dynamic camera. No text, no logos.",
516
+ ].filter(Boolean).join(" "));
517
+ }
489
518
  /** The DejaVu bold face most Linux boxes have, for the title card; "" when there is none. */
490
519
  function titleFont() {
491
520
  for (const file of ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"]) {
@@ -518,8 +547,22 @@ function runLow(ffmpeg, args, timeout, what) {
518
547
  */
519
548
  export function renderStoryVideo(ffmpeg, input) {
520
549
  const fade = 1;
521
- const count = input.images.length;
522
- const each = input.seconds / count + fade;
550
+ const clips = input.clips ?? [];
551
+ // Stills fill what the filmed shots do not; each gets at least 3 s, so on a short song some are left out.
552
+ let images = [...input.images];
553
+ const clipTime = clips.reduce((sum, one) => sum + one.seconds, 0);
554
+ const stillFor = (count) => (input.seconds + (count + clips.length - 1) * fade - clipTime) / Math.max(1, count);
555
+ while (images.length > 0 && stillFor(images.length) < 3)
556
+ images = images.slice(0, -1);
557
+ const each = images.length ? stillFor(images.length) : 0;
558
+ // A still, then a filmed shot, in turn; whatever is left of either at the end.
559
+ const order = [];
560
+ for (let i = 0; i < Math.max(images.length, clips.length); i++) {
561
+ if (images[i])
562
+ order.push({ kind: "image", path: images[i] });
563
+ if (clips[i])
564
+ order.push({ kind: "clip", ...clips[i] });
565
+ }
523
566
  const frames = Math.max(25, Math.round(each * 25));
524
567
  const moves = [
525
568
  `z='1+0.15*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`,
@@ -528,12 +571,26 @@ export function renderStoryVideo(ffmpeg, input) {
528
571
  `z='1.15':x='(iw-iw/zoom)*(1-on/${frames})':y='(ih-ih/zoom)*on/${frames}'`,
529
572
  ];
530
573
  const parts = [];
531
- input.images.forEach((_, i) => {
532
- parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[i % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
574
+ const lengths = [];
575
+ let still = 0;
576
+ order.forEach((item, i) => {
577
+ if (item.kind === "image") {
578
+ parts.push(`[${i}:v]scale=1600:900:force_original_aspect_ratio=increase,crop=1600:900,zoompan=${moves[still++ % moves.length]}:d=1:s=1280x720:fps=25,setsar=1[v${i}]`);
579
+ lengths.push(each);
580
+ }
581
+ else {
582
+ // A filmed shot plays as it was filmed; the last one holds its final frame if the song runs on.
583
+ const hold = i === order.length - 1 ? `,tpad=stop_mode=clone:stop_duration=${Math.max(0, input.seconds).toFixed(2)}` : "";
584
+ parts.push(`[${i}:v]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,fps=25,setsar=1,trim=duration=${item.seconds},setpts=PTS-STARTPTS${hold}[v${i}]`);
585
+ lengths.push(item.seconds);
586
+ }
533
587
  });
588
+ const count = order.length;
534
589
  let last = "v0";
590
+ let at = 0;
535
591
  for (let i = 1; i < count; i++) {
536
- parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${(i * (each - fade)).toFixed(2)}[x${i}]`);
592
+ at += lengths[i - 1] - fade;
593
+ parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${fade}:offset=${at.toFixed(2)}[x${i}]`);
537
594
  last = `x${i}`;
538
595
  }
539
596
  const font = titleFont();
@@ -542,7 +599,7 @@ export function renderStoryVideo(ffmpeg, input) {
542
599
  ? `[${last}]drawtext=fontfile=${font}:text='${card}':fontcolor=white:fontsize=44:x=60:y=h-110:enable='between(t,0.5,6)':alpha='if(lt(t,1.5),t-0.5,if(gt(t,5),6-t,1))':shadowcolor=black@0.7:shadowx=2:shadowy=2,format=yuv420p[v]`
543
600
  : `[${last}]format=yuv420p[v]`);
544
601
  const args = ["-hide_banner", "-loglevel", "error", "-y", "-threads", "2",
545
- ...input.images.flatMap((image) => ["-loop", "1", "-t", each.toFixed(2), "-i", image]),
602
+ ...order.flatMap((item) => (item.kind === "image" ? ["-loop", "1", "-t", each.toFixed(2), "-i", item.path] : ["-i", item.path])),
546
603
  "-i", input.audio, "-filter_complex", parts.join(";"),
547
604
  "-map", "[v]", "-map", `${count}:a`, "-map_metadata", String(count), "-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-r", "25", "-c:a", "copy", "-shortest",
548
605
  "-movflags", "+faststart+use_metadata_tags", input.out];
@@ -632,6 +689,8 @@ export class AiLabel {
632
689
  videoFailed = new Set();
633
690
  /** Songs whose words were already sent to the aligner since this server started. */
634
691
  alignTried = new Set();
692
+ /** The month ("YYYY-MM") Veo said its budget was spent; no more shots until the next. */
693
+ veoSpentIn = "";
635
694
  /** Old videos already tried as storyboards since this server started. */
636
695
  upgradeTried = new Set();
637
696
  /** The day's album is tried at most every 30 minutes, so a failing model is not hammered. */
@@ -782,6 +841,11 @@ export class AiLabel {
782
841
  this.settings.publish = input["publish"] !== false;
783
842
  if (input["videos"] !== undefined)
784
843
  this.settings.videos = input["videos"] !== false;
844
+ if (input["veo"] !== undefined)
845
+ this.settings.veo = input["veo"] === true;
846
+ const shots = whole("veoShots", 1, 8);
847
+ if (shots !== undefined)
848
+ this.settings.veoShots = shots;
785
849
  const perDay = whole("albumsPerDay", 0, 24);
786
850
  if (perDay !== undefined)
787
851
  this.settings.albumsPerDay = perDay;
@@ -973,7 +1037,7 @@ export class AiLabel {
973
1037
  .flatMap((album) => album.songs.filter((song) => song.file && !this.videoFailed.has(song.file)).map((song) => ({ artist, album, song }))));
974
1038
  const missing = songs.filter(({ song }) => !song.video);
975
1039
  const untagged = songs.filter(({ song }) => song.video && !song.videoTagged);
976
- const old = songs.filter(({ song }) => song.video && song.videoTagged && (song.videoVersion ?? 1) < 2 && !this.upgradeTried.has(song.file));
1040
+ const old = songs.filter(({ song }) => song.video && song.videoTagged && ((song.videoVersion ?? 1) < 2 || (this.wantsVeo(song) && (song.videoVersion ?? 1) < 3)) && !this.upgradeTried.has(song.file));
977
1041
  let made = 0;
978
1042
  for (const { album, song } of untagged) {
979
1043
  if (!this.settings.videos)
@@ -1062,15 +1126,68 @@ export class AiLabel {
1062
1126
  this.log(`no storyboard for "${song.title}" (${error.message}); the cover video instead`);
1063
1127
  }
1064
1128
  }
1065
- if (images.length >= 2) {
1066
- await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out });
1067
- return 2;
1129
+ // An album's single gets filmed shots of the band too, while the month's Veo budget lasts.
1130
+ const clips = this.wantsVeo(song) ? await this.filmShots(artist, song, scenes) : [];
1131
+ if (images.length >= 2 || clips.length > 0) {
1132
+ if (storyOnly && (song.videoVersion ?? 1) >= 2 && clips.length === 0)
1133
+ throw new Error("no filmed shots this time; the video it has stays");
1134
+ await (this.deps.storyVideo ?? ((input) => renderStoryVideo(this.deps.ffmpeg, input)))({ audio: song.file, images, seconds, artist: artist.name, title: song.title, out, ...(clips.length ? { clips } : {}) });
1135
+ return clips.length ? 3 : 2;
1068
1136
  }
1069
1137
  if (storyOnly)
1070
1138
  throw new Error("no storyboard could be drawn; the video it has stays");
1071
1139
  await (this.deps.video ?? ((audio, cover, to) => renderVideo(this.deps.ffmpeg, audio, cover, to)))(song.file, album.cover, out);
1072
1140
  return 1;
1073
1141
  }
1142
+ /** Whether this song gets filmed Veo shots: an album's single, Veo on, and the month's budget not spent. */
1143
+ wantsVeo(song) {
1144
+ return this.settings.veo && song.n === 1 && Boolean(this.deps.veo) && this.veoSpentIn !== new Date(this.now()).toISOString().slice(0, 7);
1145
+ }
1146
+ /**
1147
+ * A single's filmed shots: the band, from their portraits (up to three as
1148
+ * Veo's reference images), on stage, in the song's mood. Stops for the
1149
+ * month when the budget is spent; a shot Veo will not make is skipped.
1150
+ */
1151
+ async filmShots(artist, song, dir) {
1152
+ const veo = this.deps.veo;
1153
+ if (!veo)
1154
+ return [];
1155
+ mkdirSync(dir, { recursive: true });
1156
+ const members = this.cast(artist);
1157
+ const references = [];
1158
+ for (const member of members.slice(0, 3)) {
1159
+ const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1160
+ const small = join(dir, `ref-${member.slug}.jpg`);
1161
+ try {
1162
+ // 512 px is plenty for a face and keeps the request small.
1163
+ await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", portrait, "-vf", "scale=512:-2", "-q:v", "4", small], 60_000, "the portrait did not shrink");
1164
+ references.push(readFileSync(small));
1165
+ }
1166
+ catch {
1167
+ // A member without a portrait is described in words only.
1168
+ }
1169
+ }
1170
+ const clips = [];
1171
+ for (const [i, prompt] of veoPrompts(artist, song, members, this.settings.veoShots).entries()) {
1172
+ try {
1173
+ const bytes = await veo({ prompt, seconds: 8, references });
1174
+ const file = join(dir, `shot-${i}.mp4`);
1175
+ writeFileSync(file, bytes);
1176
+ clips.push({ path: file, seconds: 8 });
1177
+ }
1178
+ catch (error) {
1179
+ if (error instanceof VeoUnavailable) {
1180
+ this.veoSpentIn = new Date(this.now()).toISOString().slice(0, 7);
1181
+ this.log(`Veo: ${error.message}; singles get the storyboard until next month`);
1182
+ break;
1183
+ }
1184
+ this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
1185
+ }
1186
+ }
1187
+ if (clips.length)
1188
+ this.log(`Veo: ${clips.length} filmed shots for "${song.title}" (${artist.name})`);
1189
+ return clips;
1190
+ }
1074
1191
  /** The label's music videos as one TV station: free to watch, with its breaks. */
1075
1192
  placeVideos() {
1076
1193
  const next = this.placing.then(async () => {
@@ -87,11 +87,19 @@ export interface MusicRoutesContext {
87
87
  readBody: (request: IncomingMessage) => Promise<string>;
88
88
  /** The page shell (web/music.html), or null when the web build is absent. */
89
89
  page: () => string | null;
90
+ /** This site's public origin, for canonical and og: addresses (https://nixamp.com). */
91
+ site?: string;
90
92
  /** A server says something of its label landed; null when nobody may say so. */
91
93
  released?: (request: IncomingMessage, body: Record<string, unknown>) => Promise<{
92
94
  status: number;
93
95
  body: unknown;
94
96
  }>;
97
+ /** Veo shots for a label server's videos: the month's spend (GET), a clip (POST); answers bytes or JSON. */
98
+ veo?: (request: IncomingMessage) => Promise<{
99
+ status: number;
100
+ body: unknown;
101
+ video?: Buffer;
102
+ }>;
95
103
  }
96
104
  export declare function handleMusicRoutes(request: IncomingMessage, response: ServerResponse, path: string, ctx: MusicRoutesContext): Promise<boolean>;
97
105
  export {};
package/dist/music-hub.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * POST /api/v1/music/released a server says an album or song landed: its followers hear
13
13
  */
14
14
  import { createHash } from "node:crypto";
15
+ import { musicPage, renderMusicPage } from "./music-seo.js";
15
16
  /** A server's opaque id: the first 10 hex of the SHA-256 of its https origin. Both sides can work it out. */
16
17
  export function serverId(base) {
17
18
  return createHash("sha256").update(base.replace(/\/+$/, "")).digest("hex").slice(0, 10);
@@ -136,7 +137,7 @@ export async function musicPageFor(hub, path) {
136
137
  return "";
137
138
  }
138
139
  export function isMusicPath(path) {
139
- return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released";
140
+ return path === "/music" || path.startsWith("/music/") || path === "/api/v1/music" || FILE.test(path) || path === "/api/v1/music/released" || path === "/api/v1/music/veo";
140
141
  }
141
142
  export async function handleMusicRoutes(request, response, path, ctx) {
142
143
  if (!isMusicPath(path))
@@ -159,6 +160,20 @@ export async function handleMusicRoutes(request, response, path, ctx) {
159
160
  ctx.json(response, answer.status, answer.body);
160
161
  return true;
161
162
  }
163
+ if (path === "/api/v1/music/veo") {
164
+ if (!ctx.veo) {
165
+ ctx.json(response, 404, { error: "Veo is not set up here" });
166
+ return true;
167
+ }
168
+ const answer = await ctx.veo(request);
169
+ if (answer.video) {
170
+ response.writeHead(200, { "content-type": "video/mp4", "content-length": String(answer.video.length), "cache-control": "no-store" });
171
+ response.end(answer.video);
172
+ }
173
+ else
174
+ ctx.json(response, answer.status, answer.body);
175
+ return true;
176
+ }
162
177
  if (method !== "GET" && method !== "HEAD") {
163
178
  ctx.json(response, 405, { error: "not like that" });
164
179
  return true;
@@ -207,13 +222,22 @@ export async function handleMusicRoutes(request, response, path, ctx) {
207
222
  }
208
223
  return true;
209
224
  }
210
- // /music and everything under it is the app; it reads /api/v1/music.
211
- const page = ctx.page();
212
- if (!page) {
225
+ // /music and everything under it is the app; it reads /api/v1/music. Its head and a
226
+ // plain summary are written here, per page (music-seo.ts), for crawlers and link
227
+ // previews; a catalogue that is slow or gone leaves the shell as it is.
228
+ const shell = ctx.page();
229
+ if (!shell) {
213
230
  ctx.json(response, 404, { error: "the music app is not built here" });
214
231
  return true;
215
232
  }
216
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
217
- response.end(method === "HEAD" ? undefined : page);
233
+ const site = (ctx.site ?? "https://nixamp.com").replace(/\/+$/, "");
234
+ const catalogue = await Promise.race([
235
+ ctx.hub.catalogue().catch(() => null),
236
+ new Promise((done) => setTimeout(() => done(null), 2_000).unref?.()),
237
+ ]);
238
+ const view = catalogue ? musicPage(path, catalogue, site) : null;
239
+ const html = view ? renderMusicPage(shell, view, `${site}${path.replace(/\/+$/, "") || "/music"}`) : shell;
240
+ response.writeHead(view?.status ?? 200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" });
241
+ response.end(method === "HEAD" ? undefined : html);
218
242
  return true;
219
243
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * nixamp.com/music for crawlers and link previews: the page shell with its
3
+ * head and a plain summary written on the server, per page. The app reads
4
+ * /api/v1/music and replaces the summary when its script runs, so a person
5
+ * sees the app and a crawler, a chat app or a social card sees the title,
6
+ * the artist, the album and its artwork without running anything.
7
+ *
8
+ * /music the label: genres, artists, stations
9
+ * /music/genre/<slug> a genre's artists
10
+ * /music/artist/<server>/<artist> an artist: bio, members, albums
11
+ * /music/album/<server>/<artist>/<album> an album: cover, songs
12
+ * /music/me the listener's own page: never indexed
13
+ */
14
+ import type { HubCatalogue } from "./music-hub.ts";
15
+ interface Head {
16
+ title: string;
17
+ description: string;
18
+ type: string;
19
+ image?: {
20
+ url: string;
21
+ alt: string;
22
+ };
23
+ noindex?: boolean;
24
+ /** og:music:* and similar, as [property, content]. */
25
+ extra?: Array<[string, string]>;
26
+ ld?: Record<string, unknown>;
27
+ }
28
+ /** What the server writes for a /music address: its head, and a summary for the page body. */
29
+ export interface MusicPage {
30
+ head: Head;
31
+ body: string;
32
+ status: number;
33
+ }
34
+ /** The head and summary of a /music address, from the catalogue. `site` is https://nixamp.com. */
35
+ export declare function musicPage(path: string, catalogue: HubCatalogue, site: string): MusicPage;
36
+ /** The shell with a page's head and summary written in. */
37
+ export declare function renderMusicPage(shell: string, page: MusicPage, url: string): string;
38
+ export {};
@@ -0,0 +1,160 @@
1
+ const slug = (text) => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2
+ const escape = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3
+ const clip = (text, max) => {
4
+ const one = text.replace(/\s+/g, " ").trim();
5
+ return one.length <= max ? one : `${one.slice(0, max - 1).replace(/\s+\S*$/, "")}…`;
6
+ };
7
+ const clock = (seconds) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, "0")}`;
8
+ /** The head and summary of a /music address, from the catalogue. `site` is https://nixamp.com. */
9
+ export function musicPage(path, catalogue, site) {
10
+ const at = (rest) => `${site}${rest}`;
11
+ const file = (server, rest) => at(`/api/v1/music/file/${server}/${rest}`);
12
+ const artists = catalogue.genres.flatMap((genre) => genre.artists);
13
+ const holder = catalogue.holders[0] ?? "Profullstack, Inc.";
14
+ const parts = path.replace(/\/+$/, "").split("/").slice(2);
15
+ const pictureOf = (artist) => {
16
+ const album = [...artist.albums].reverse().find((one) => one.cover);
17
+ if (album)
18
+ return { url: file(artist.server, `${artist.key}/${album.key}/cover.jpg`), alt: `${album.title} by ${artist.name}, the cover` };
19
+ const member = artist.members?.[0];
20
+ return member ? { url: file(artist.server, `${artist.key}/members/${member.slug}.jpg`), alt: `${member.name} of ${artist.name}` } : undefined;
21
+ };
22
+ const artistLink = (artist) => `/music/artist/${artist.server}/${artist.key}`;
23
+ const missing = (what) => ({
24
+ status: 404,
25
+ head: { title: `No such ${what} · nixamp music`, description: `Nothing by that name is on nixamp music.`, type: "website", noindex: true },
26
+ body: `<h1>No such ${escape(what)}</h1><p><a href="/music">Every artist on nixamp music</a></p>`,
27
+ });
28
+ if (parts[0] === "me") {
29
+ return {
30
+ status: 200,
31
+ head: { title: "My music · nixamp", description: "The songs and albums you bought on nixamp, and the artists you follow.", type: "website", noindex: true },
32
+ body: "<h1>My music</h1><p>Sign in to see what you bought and who you follow.</p>",
33
+ };
34
+ }
35
+ if (parts[0] === "genre" && parts[1]) {
36
+ const genre = catalogue.genres.find((one) => slug(one.genre) === parts[1]);
37
+ if (!genre)
38
+ return missing("genre");
39
+ const names = genre.artists.map((one) => one.name);
40
+ const lead = genre.artists[0];
41
+ return {
42
+ status: 200,
43
+ head: {
44
+ title: `${genre.genre} · nixamp music`,
45
+ description: clip(`${genre.genre} by AI artists on nixamp: ${names.join(", ")}. Listen free on the live station; follow the artists; buy the songs.`, 200),
46
+ type: "website",
47
+ ...(lead && pictureOf(lead) ? { image: pictureOf(lead) } : {}),
48
+ },
49
+ body: `<h1>${escape(genre.genre)}</h1><ul>${genre.artists.map((one) => `<li><a href="${artistLink(one)}">${escape(one.name)}</a>${one.bio ? `: ${escape(clip(one.bio, 160))}` : ""}</li>`).join("")}</ul>`,
50
+ };
51
+ }
52
+ if (parts[0] === "artist" && parts[1] && parts[2]) {
53
+ const artist = artists.find((one) => one.server === parts[1] && one.key === parts[2]);
54
+ if (!artist)
55
+ return missing("artist");
56
+ const image = pictureOf(artist);
57
+ const albums = artist.albums.filter((one) => one.songs.length > 0);
58
+ const members = artist.members ?? [];
59
+ return {
60
+ status: 200,
61
+ head: {
62
+ title: `${artist.name} · ${artist.genre} · nixamp music`,
63
+ description: clip(artist.bio || `${artist.name}, ${artist.genre} on nixamp: ${albums.length} album${albums.length === 1 ? "" : "s"}.`, 200),
64
+ type: "profile",
65
+ ...(image ? { image } : {}),
66
+ ld: {
67
+ "@context": "https://schema.org", "@type": "MusicGroup", name: artist.name, genre: artist.genre, url: at(artistLink(artist)),
68
+ ...(artist.bio ? { description: artist.bio } : {}),
69
+ ...(image ? { image: image.url } : {}),
70
+ ...(members.length ? { member: members.map((one) => ({ "@type": "Person", name: one.name, ...(one.role ? { roleName: one.role } : {}) })) } : {}),
71
+ album: albums.map((one) => ({ "@type": "MusicAlbum", name: one.title, url: at(`/music/album/${artist.server}/${artist.key}/${one.key}`), ...(one.year ? { datePublished: String(one.year) } : {}) })),
72
+ recordLabel: { "@type": "Organization", name: holder },
73
+ },
74
+ },
75
+ body: `<p><a href="/music">Music</a> / <a href="/music/genre/${slug(artist.genre)}">${escape(artist.genre)}</a></p><h1>${escape(artist.name)}</h1>`
76
+ + (artist.bio ? `<p>${escape(artist.bio)}</p>` : "")
77
+ + (members.length ? `<h2>Members</h2><ul>${members.map((one) => `<li>${escape(one.name)}${one.role ? `, ${escape(one.role)}` : ""}</li>`).join("")}</ul>` : "")
78
+ + (albums.length ? `<h2>Albums</h2><ul>${albums.map((one) => `<li><a href="/music/album/${artist.server}/${artist.key}/${one.key}">${escape(one.title)}</a>${one.year ? ` (${one.year})` : ""}</li>`).join("")}</ul>` : ""),
79
+ };
80
+ }
81
+ if (parts[0] === "album" && parts[1] && parts[2] && parts[3]) {
82
+ const artist = artists.find((one) => one.server === parts[1] && one.key === parts[2]);
83
+ const album = artist?.albums.find((one) => one.key === parts[3]);
84
+ if (!artist || !album)
85
+ return missing("album");
86
+ const image = album.cover ? { url: file(artist.server, `${artist.key}/${album.key}/cover.jpg`), alt: `${album.title} by ${artist.name}, the cover` } : pictureOf(artist);
87
+ const url = at(`/music/album/${artist.server}/${artist.key}/${album.key}`);
88
+ return {
89
+ status: 200,
90
+ head: {
91
+ title: `${album.title} by ${artist.name} · nixamp music`,
92
+ description: clip(album.concept || `${album.title}, ${album.songs.length} songs by ${artist.name} (${artist.genre})${album.year ? `, ${album.year}` : ""}. Listen free on nixamp.`, 200),
93
+ type: "music.album",
94
+ ...(image ? { image } : {}),
95
+ extra: [
96
+ ["music:musician", at(artistLink(artist))],
97
+ ...(album.year ? [["music:release_date", String(album.year)]] : []),
98
+ ...album.songs.slice(0, 30).map((song) => ["music:song", `${url}#${song.n}`]),
99
+ ],
100
+ ld: {
101
+ "@context": "https://schema.org", "@type": "MusicAlbum", name: album.title, url,
102
+ byArtist: { "@type": "MusicGroup", name: artist.name, url: at(artistLink(artist)) },
103
+ genre: artist.genre, numTracks: album.songs.length,
104
+ ...(album.year ? { datePublished: String(album.year) } : {}),
105
+ ...(image ? { image: image.url } : {}),
106
+ ...(album.concept ? { description: album.concept } : {}),
107
+ recordLabel: { "@type": "Organization", name: holder },
108
+ track: album.songs.map((song) => ({ "@type": "MusicRecording", name: song.title, position: song.n, ...(song.seconds ? { duration: `PT${Math.floor(song.seconds / 60)}M${Math.round(song.seconds % 60)}S` } : {}) })),
109
+ },
110
+ },
111
+ body: `<p><a href="/music">Music</a> / <a href="${artistLink(artist)}">${escape(artist.name)}</a></p><h1>${escape(album.title)}</h1>`
112
+ + `<p>by <a href="${artistLink(artist)}">${escape(artist.name)}</a>${album.year ? ` · ${album.year}` : ""} · ${album.songs.length} songs</p>`
113
+ + (album.concept ? `<p>${escape(album.concept)}</p>` : "")
114
+ + `<ol>${album.songs.map((song) => `<li>${escape(song.title)}${song.seconds ? ` <span>${clock(song.seconds)}</span>` : ""}</li>`).join("")}</ol>`,
115
+ };
116
+ }
117
+ if (parts.length > 0)
118
+ return missing("page");
119
+ const lead = artists.find((one) => pictureOf(one));
120
+ return {
121
+ status: 200,
122
+ head: {
123
+ title: "AI music, live and on demand · nixamp",
124
+ description: clip(`Original bands and artists, written and sung by AI and released by ${holder}: ${catalogue.genres.map((one) => one.genre).join(", ")}. Listen free; follow the artists; buy the songs.`, 200),
125
+ type: "website",
126
+ ...(lead ? { image: pictureOf(lead) } : {}),
127
+ },
128
+ body: `<h1>AI music, live and on demand</h1>${catalogue.genres.map((genre) => `<h2><a href="/music/genre/${slug(genre.genre)}">${escape(genre.genre)}</a></h2><ul>${genre.artists.map((one) => `<li><a href="${artistLink(one)}">${escape(one.name)}</a></li>`).join("")}</ul>`).join("")}`,
129
+ };
130
+ }
131
+ /** The shell with a page's head and summary written in. */
132
+ export function renderMusicPage(shell, page, url) {
133
+ const { head } = page;
134
+ const meta = [
135
+ `<title>${escape(head.title)}</title>`,
136
+ `<meta name="description" content="${escape(head.description)}" />`,
137
+ `<link rel="canonical" href="${escape(url)}" />`,
138
+ ...(head.noindex ? [`<meta name="robots" content="noindex" />`] : []),
139
+ `<meta property="og:site_name" content="nixamp" />`,
140
+ `<meta property="og:title" content="${escape(head.title.replace(/ · nixamp( music)?$/, ""))}" />`,
141
+ `<meta property="og:description" content="${escape(head.description)}" />`,
142
+ `<meta property="og:type" content="${escape(head.type)}" />`,
143
+ `<meta property="og:url" content="${escape(url)}" />`,
144
+ ...(head.image ? [
145
+ `<meta property="og:image" content="${escape(head.image.url)}" />`,
146
+ `<meta property="og:image:alt" content="${escape(head.image.alt)}" />`,
147
+ `<meta name="twitter:card" content="summary_large_image" />`,
148
+ `<meta name="twitter:image" content="${escape(head.image.url)}" />`,
149
+ ] : [`<meta name="twitter:card" content="summary" />`]),
150
+ `<meta name="twitter:title" content="${escape(head.title)}" />`,
151
+ `<meta name="twitter:description" content="${escape(head.description)}" />`,
152
+ ...(head.extra ?? []).map(([property, content]) => `<meta property="${escape(property)}" content="${escape(content)}" />`),
153
+ ...(head.ld ? [`<script type="application/ld+json">${JSON.stringify(head.ld).replace(/</g, "\\u003c")}</script>`] : []),
154
+ ];
155
+ return shell
156
+ .replace(/<title>[^<]*<\/title>/, "")
157
+ .replace(/<meta name="description"[^>]*>/, "")
158
+ .replace("</head>", ` ${meta.join("\n ")}\n </head>`)
159
+ .replace(/(<main id="view"[^>]*>)(<\/main>)/, `$1${page.body}$2`);
160
+ }
@@ -188,9 +188,11 @@ export interface StationFace {
188
188
  description?: string;
189
189
  video?: boolean;
190
190
  }
191
- export declare function stationHead(station: StationFace, slug: string, base: string, path?: string): string;
191
+ export declare function stationHead(station: StationFace, slug: string, base: string, path?: string, image?: string): string;
192
+ /** A station's picture for link previews: the cover of an album it plays, from the catalogue. */
193
+ export declare function stationImage(catalogue: RadioCatalogue, slug: string, base: string): string;
192
194
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
193
- export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string;
195
+ export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string, image?: string): string;
194
196
  /** A station another server lists on this guide (a guide peer), as /radio reaches it. */
195
197
  export interface PeerRadio {
196
198
  origin: string;
@@ -388,7 +388,7 @@ export function renderAlbum(catalogue, album, ctx) {
388
388
  export function renderMissing(what, base, nav) {
389
389
  return document({ title: `Not here · live.streaming`, description: "Nothing by that name is on the air here.", url: `${base}/radio` }, nav, `<h1>No ${escape(what)} by that name</h1><p class="lead">Nothing by that name is on the air here now. <a href="/radio">See every station</a>.</p>`);
390
390
  }
391
- export function stationHead(station, slug, base, path = `/radio/${slug}`) {
391
+ export function stationHead(station, slug, base, path = `/radio/${slug}`, image = "") {
392
392
  const url = `${base}${path}`;
393
393
  const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
394
394
  return `<title>${escape(station.name)} · live.streaming</title>
@@ -399,13 +399,20 @@ export function stationHead(station, slug, base, path = `/radio/${slug}`) {
399
399
  <meta property="og:url" content="${escape(url)}" />
400
400
  <meta property="og:type" content="${station.video ? "video.tv_show" : "music.radio_station"}" />
401
401
  <meta property="og:site_name" content="live.streaming" />
402
- <meta name="twitter:card" content="summary" />
402
+ ${image ? `<meta property="og:image" content="${escape(image)}" />
403
+ <meta name="twitter:card" content="summary_large_image" />
404
+ <meta name="twitter:image" content="${escape(image)}" />` : `<meta name="twitter:card" content="summary" />`}
403
405
  <style>${NAV_STYLE}</style>`;
404
406
  }
407
+ /** A station's picture for link previews: the cover of an album it plays, from the catalogue. */
408
+ export function stationImage(catalogue, slug, base) {
409
+ const album = [...catalogue.albums.values()].find((one) => one.stations.includes(slug) && (one.coverFile || one.label?.cover));
410
+ return album ? `${base}/radio/album/${album.slug}/cover.jpg` : "";
411
+ }
405
412
  /** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
406
- export function asRadioPage(html, station, slug, base, nav, path) {
413
+ export function asRadioPage(html, station, slug, base, nav, path, image = "") {
407
414
  return html
408
- .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
415
+ .replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path, image))
409
416
  .replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
410
417
  .replace(/<body>/, `<body>\n${nav}`);
411
418
  }
package/dist/server.d.ts CHANGED
@@ -841,6 +841,11 @@ export interface HandlerOptions {
841
841
  status: number;
842
842
  body: unknown;
843
843
  }>;
844
+ veo?: (request: IncomingMessage) => Promise<{
845
+ status: number;
846
+ body: unknown;
847
+ video?: Buffer;
848
+ }>;
844
849
  };
845
850
  sharedTranslations?: SharedTranslations;
846
851
  /** Translation: texts in another language, by a model here. The same optional library. */