nixamp 0.69.0 → 0.70.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 (37) hide show
  1. package/dist/ai-label.d.ts +15 -5
  2. package/dist/ai-label.js +84 -34
  3. package/dist/station-cli.js +1 -0
  4. package/dist/veo-studio.d.ts +1 -1
  5. package/dist/veo-studio.js +2 -2
  6. package/package.json +1 -1
  7. package/src/ai-label.ts +86 -35
  8. package/src/station-cli.ts +1 -0
  9. package/src/veo-studio.ts +2 -2
  10. package/web/dist/assets/{ar-BAUYJ7CG.js → ar-DpvJfvBS.js} +1 -1
  11. package/web/dist/assets/{cs-D5Njj-lD.js → cs-Byt29q-N.js} +1 -1
  12. package/web/dist/assets/{da-CQ52TUpt.js → da-BNVqf41w.js} +1 -1
  13. package/web/dist/assets/{de-slj7FWP_.js → de-CkkNpSeK.js} +1 -1
  14. package/web/dist/assets/{es-wy-duArI.js → es-DTbm6erE.js} +1 -1
  15. package/web/dist/assets/{fi-D8tuiwIa.js → fi-CLe542jk.js} +1 -1
  16. package/web/dist/assets/{fr-AlVe-2YY.js → fr-Cw6IcnjA.js} +1 -1
  17. package/web/dist/assets/{hi-BkrehL5x.js → hi-BA_-5ZbE.js} +1 -1
  18. package/web/dist/assets/{hu-_3tyj6FI.js → hu-D4hoNRZH.js} +1 -1
  19. package/web/dist/assets/{id-O2_s9_ZD.js → id-BUGLjAyd.js} +1 -1
  20. package/web/dist/assets/{it-D6kuTye8.js → it-zM-JnjaU.js} +1 -1
  21. package/web/dist/assets/main-p1NGc9XO.js +22 -0
  22. package/web/dist/assets/music-C8ITlUNF.js +1 -0
  23. package/web/dist/assets/{nl-DOdu_ltu.js → nl-ka_E88m2.js} +1 -1
  24. package/web/dist/assets/now-playing-DVQqWQIu.js +1 -0
  25. package/web/dist/assets/{ru-B39HLLYh.js → ru-CNiGclVH.js} +1 -1
  26. package/web/dist/assets/station-tWQPqySd.js +3 -0
  27. package/web/dist/assets/{sv-DTDAE9DG.js → sv-cvAfJ4oL.js} +1 -1
  28. package/web/dist/assets/{uk-DmM6P2_W.js → uk-45wTyXWC.js} +1 -1
  29. package/web/dist/assets/{vi-8EoMeh4v.js → vi-CnN2uyTw.js} +1 -1
  30. package/web/dist/assets/{zh-DkGyIChT.js → zh-BfjjooDK.js} +1 -1
  31. package/web/dist/index.html +2 -1
  32. package/web/dist/music.html +2 -1
  33. package/web/dist/station.html +2 -1
  34. package/web/dist/sw.js +22 -21
  35. package/web/dist/assets/main-Bh0dWH3R.js +0 -22
  36. package/web/dist/assets/music-Bby75I2_.js +0 -1
  37. package/web/dist/assets/station-DtY1LOm5.js +0 -3
@@ -170,6 +170,8 @@ export interface TimedWord {
170
170
  }
171
171
  /** ElevenLabs forced alignment: the song and its words in, each word's time out. */
172
172
  export declare function elevenLabsAligner(key: string, fetcher?: typeof fetch): (audio: string, text: string) => Promise<TimedWord[]>;
173
+ /** ElevenLabs voice isolation: a few seconds of the song in, the voice alone out (what a lip sync follows best). */
174
+ export declare function elevenLabsIsolator(key: string, fetcher?: typeof fetch): (audio: Buffer) => Promise<Buffer>;
173
175
  /** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
174
176
  export declare function sungLines(lyrics: string): string[];
175
177
  /**
@@ -181,8 +183,13 @@ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLin
181
183
  export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
182
184
  cast?: string;
183
185
  }, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
184
- /** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
185
- export declare function writeVeoStory(artist: Pick<LabelArtist, "genre" | "sound">, members: Array<Pick<Member, "role" | "look">>, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
186
+ /** A story scene for Veo: the prompt and the roles of the band members in it (their portraits go with it). */
187
+ export interface StoryScene {
188
+ prompt: string;
189
+ cast: string[];
190
+ }
191
+ /** A single's story scenes for Veo: a short film from its words starring the band as they look, nobody singing. */
192
+ export declare function writeVeoStory(artist: Pick<LabelArtist, "genre" | "sound">, members: Array<Pick<Member, "role" | "look">>, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<StoryScene[]>;
186
193
  /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
187
194
  export declare class VeoUnavailable extends Error {
188
195
  }
@@ -225,7 +232,7 @@ export declare const VIDEO_USD = 8;
225
232
  * story. Footage shorter than the song is cut in turn and shown again.
226
233
  * $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
227
234
  */
228
- export declare function shotPlan(_seconds: number, singing: boolean, usd?: number): {
235
+ export declare function shotPlan(seconds: number, singing: boolean, usd?: number): {
229
236
  story: number;
230
237
  band: number;
231
238
  singer: number;
@@ -249,9 +256,10 @@ export declare function closeUpTimes(seconds: number, lines: Array<{
249
256
  * The version of a video cut from Veo footage: story scenes and the band playing with
250
257
  * nobody singing, the singer only in close-ups lip-synced to the song. Anything older
251
258
  * is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
252
- * the mic in the wide shots).
259
+ * the mic in the wide shots). 6 and 7 (strangers in the story scenes, footage looped)
260
+ * stay up until filmed again.
253
261
  */
254
- export declare const FILMED = 7;
262
+ export declare const FILMED = 8;
255
263
  /** 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. */
256
264
  export declare const SHOWN = 6;
257
265
  /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
@@ -341,6 +349,8 @@ export interface AiLabelDeps {
341
349
  seconds: number;
342
350
  }>;
343
351
  }) => Promise<void>;
352
+ /** The voice alone from a few seconds of a song (ElevenLabs by default when its key is set); null for none. */
353
+ isolate?: ((audio: Buffer) => Promise<Buffer>) | null;
344
354
  /** A singer's close-up with the mouth redrawn to these seconds of the song (nixamp.com, fal.ai); VeoUnavailable when the month is spent. */
345
355
  lipsync?: ((input: {
346
356
  video: Buffer;
package/dist/ai-label.js CHANGED
@@ -441,6 +441,20 @@ export function elevenLabsAligner(key, fetcher = fetch) {
441
441
  .filter((one) => one.text && Number.isFinite(one.start) && Number.isFinite(one.end));
442
442
  };
443
443
  }
444
+ /** ElevenLabs voice isolation: a few seconds of the song in, the voice alone out (what a lip sync follows best). */
445
+ export function elevenLabsIsolator(key, fetcher = fetch) {
446
+ return async (audio) => {
447
+ const form = new FormData();
448
+ form.append("audio", new Blob([new Uint8Array(audio)], { type: "audio/mpeg" }), "lines.mp3");
449
+ const response = await fetcher("https://api.elevenlabs.io/v1/audio-isolation", { method: "POST", headers: { "xi-api-key": key }, body: form, signal: AbortSignal.timeout(2 * 60_000) });
450
+ if (!response.ok)
451
+ throw new Error(`the voice isolation answered HTTP ${response.status}`);
452
+ const voice = Buffer.from(await response.arrayBuffer());
453
+ if (voice.length < 1000)
454
+ throw new Error("the voice isolation answered with no audio");
455
+ return voice;
456
+ };
457
+ }
444
458
  /** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
445
459
  export function sungLines(lyrics) {
446
460
  return lyrics.split("\n").map((line) => line.trim()).filter((line) => line && !/^\[[^\]]*\]$/.test(line));
@@ -493,32 +507,41 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
493
507
  return prompts;
494
508
  }
495
509
  const VEO_STORY_SYSTEM = [
496
- "You write the story scenes of a music video for an original song, as prompts for a video model that films 8-second moving shots.",
497
- "Tell the song's story in cinematic scenes away from any stage: characters, places, action, weather, small details. Keep the same main character(s), setting and color palette across scenes so it reads as one short film; vary the shots (wide, tracking, close detail, aerial) and say how the camera moves.",
498
- "Describe people by how they look, never by name. Nobody sings, speaks or lip-syncs, and nobody holds a microphone. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
499
- "Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
510
+ "You write the story scenes of a music video for an original song, as prompts for a video model that films short moving shots.",
511
+ "Tell the song's story in cinematic scenes away from any stage. The main characters are ONLY the band members listed (by role); every scene with a person in it features one to three of them, and each one keeps exactly the look given. Other people may appear only in the background, faces unseen.",
512
+ "Keep the same settings and color palette across scenes so it reads as one short film; vary the shots (wide, tracking, close detail, aerial) and say how the camera moves. Nobody sings, speaks or lip-syncs, and nobody holds a microphone.",
513
+ "Describe people by role and look, never by name. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
514
+ "Answer as JSON: {\"scenes\": [{\"prompt\": \"...\", \"cast\": [\"lead vocals\", ...]}, ...]} where cast lists the roles (exactly as given) of the members in that scene, [] for none.",
500
515
  ].join(" ");
501
- /** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
516
+ /** A single's story scenes for Veo: a short film from its words starring the band as they look, nobody singing. */
502
517
  export async function writeVeoStory(artist, members, album, song, scenes, ask = askJson) {
503
- const cast = members.slice(0, 4).map((one) => `${one.role}: ${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")}`).join("; ");
518
+ const cast = members.slice(0, 5).map((one) => `${one.role}: ${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")}`).join("; ");
504
519
  const answer = await ask({
505
520
  system: VEO_STORY_SYSTEM,
506
521
  prompt: [
507
522
  `Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
508
- cast ? `The band may appear as characters, described by look: ${cast}` : "",
523
+ cast ? `The band (the only main characters), by role and look: ${cast}` : "",
509
524
  `Mood and arrangement: ${song.style}`,
510
525
  song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
511
526
  `Exactly ${scenes} scenes.`,
512
527
  ].filter(Boolean).join("\n"),
513
- maxTokens: 2500,
514
- timeoutMs: 90_000,
528
+ maxTokens: 4000,
529
+ timeoutMs: 120_000,
515
530
  });
531
+ const roles = new Set(members.map((one) => one.role.toLowerCase()));
516
532
  const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
517
- const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
518
- if (prompts.length < 1)
533
+ const out = list.map((one) => {
534
+ const item = (typeof one === "object" && one ? one : { prompt: one });
535
+ const who = Array.isArray(item.cast) ? item.cast.map((role) => String(role).toLowerCase().trim()).filter((role) => roles.has(role)) : [];
536
+ return { prompt: clean(item.prompt, 400), cast: [...new Set(who)].slice(0, 3) };
537
+ }).filter((one) => one.prompt.length > 10).slice(0, scenes);
538
+ if (out.length < 1)
519
539
  throw new Error("the model wrote no story");
520
- return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
521
- + " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
540
+ return out.map((scene) => ({
541
+ cast: scene.cast,
542
+ prompt: `Music video for a fictional ${artist.genre} song. ${scene.prompt} Mood: ${song.style}`.slice(0, 900)
543
+ + " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, high detail. No text, no logos.",
544
+ }));
522
545
  }
523
546
  /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
524
547
  export class VeoUnavailable extends Error {
@@ -671,13 +694,17 @@ export const VIDEO_USD = 8;
671
694
  * story. Footage shorter than the song is cut in turn and shown again.
672
695
  * $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
673
696
  */
674
- export function shotPlan(_seconds, singing, usd = VIDEO_USD) {
697
+ export function shotPlan(seconds, singing, usd = VIDEO_USD) {
675
698
  const closeUp = SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND), shot = SHOT_SECONDS * VEO_USD_PER_SECOND;
676
- // Two close-ups when they leave room for at least two shots; one when the budget is tight.
677
- const singer = !singing ? 0 : usd >= 2 * closeUp + 2 * shot ? 2 : 1;
678
- const left = usd - singer * SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND);
679
- const shots = Math.max(2, Math.floor(left / (SHOT_SECONDS * VEO_USD_PER_SECOND) + 1e-9));
680
- const band = Math.max(1, Math.round(shots / 3));
699
+ const length = Math.max(30, seconds);
700
+ // About one lip-synced close-up every 30 s of song, as many as leave room for at least two shots.
701
+ let singer = singing ? Math.max(1, Math.min(6, Math.round(length / 30))) : 0;
702
+ while (singer > 1 && usd < singer * closeUp + 2 * shot)
703
+ singer -= 1;
704
+ // Then shots enough to cover the rest of the song once (nothing shown twice), as far as the money goes.
705
+ const cover = Math.ceil(Math.max(0, length - singer * SINGER_SECONDS) / SHOT_SECONDS);
706
+ const shots = Math.max(2, Math.min(cover, Math.floor((usd - singer * closeUp) / shot + 1e-9)));
707
+ const band = Math.max(1, Math.round(shots / 4));
681
708
  return { story: Math.max(1, shots - band), band, singer };
682
709
  }
683
710
  /** What a plan costs on nixamp.com, in dollars. */
@@ -714,9 +741,10 @@ export function closeUpTimes(seconds, lines, count, length = 8) {
714
741
  * The version of a video cut from Veo footage: story scenes and the band playing with
715
742
  * nobody singing, the singer only in close-ups lip-synced to the song. Anything older
716
743
  * is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
717
- * the mic in the wide shots).
744
+ * the mic in the wide shots). 6 and 7 (strangers in the story scenes, footage looped)
745
+ * stay up until filmed again.
718
746
  */
719
- export const FILMED = 7;
747
+ export const FILMED = 8;
720
748
  /** 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. */
721
749
  export const SHOWN = 6;
722
750
  /** Google's answer when the project's daily Veo requests are used up (HTTP 429). */
@@ -1357,10 +1385,15 @@ export class AiLabel {
1357
1385
  // The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
1358
1386
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i].path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
1359
1387
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
1388
+ // The mouth follows the voice alone better than the whole mix; the video keeps the whole mix.
1389
+ let voice = readFileSync(audio);
1390
+ const isolate = this.deps.isolate !== undefined ? this.deps.isolate : (aiKey("ELEVENLABS_API_KEY") ? elevenLabsIsolator(aiKey("ELEVENLABS_API_KEY")) : null);
1391
+ if (isolate)
1392
+ voice = await isolate(voice).catch((error) => { this.log(`voice isolation for "${song.title}": ${error.message}; the whole mix instead`); return voice; });
1360
1393
  let bytes = null;
1361
1394
  for (let attempt = 0;; attempt++) {
1362
1395
  try {
1363
- bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 });
1396
+ bytes = await lipsync({ video: readFileSync(shot), audio: voice, seconds: 8 });
1364
1397
  break;
1365
1398
  }
1366
1399
  catch (error) {
@@ -1410,19 +1443,26 @@ export class AiLabel {
1410
1443
  const voice = (member) => /vocal|sing/i.test(member.role);
1411
1444
  const players = members.filter((member) => !voice(member));
1412
1445
  const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
1446
+ const shrunk = new Map();
1413
1447
  const portraits = async (list) => {
1414
1448
  const out = [];
1415
1449
  for (const member of list.slice(0, 3)) {
1416
- const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1417
- const small = join(dir, `ref-${member.slug}.jpg`);
1418
- try {
1419
- // 512 px is plenty for a face and keeps the request small.
1420
- 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");
1421
- out.push(readFileSync(small));
1422
- }
1423
- catch {
1424
- // A member without a portrait is described in words only.
1450
+ if (!shrunk.has(member.slug)) {
1451
+ const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1452
+ const small = join(dir, `ref-${member.slug}.jpg`);
1453
+ try {
1454
+ // 768 px keeps a face's detail and the request small.
1455
+ await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", portrait, "-vf", "scale=768:-2", "-q:v", "3", small], 60_000, "the portrait did not shrink");
1456
+ shrunk.set(member.slug, readFileSync(small));
1457
+ }
1458
+ catch {
1459
+ // A member without a portrait is described in words only.
1460
+ shrunk.set(member.slug, null);
1461
+ }
1425
1462
  }
1463
+ const bytes = shrunk.get(member.slug);
1464
+ if (bytes)
1465
+ out.push(bytes);
1426
1466
  }
1427
1467
  return out;
1428
1468
  };
@@ -1441,12 +1481,22 @@ export class AiLabel {
1441
1481
  this.log(`no story for "${song.title}" yet: ${error.message}`);
1442
1482
  }
1443
1483
  }
1444
- // The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
1445
- // the portraits: they would pull the whole band into every scene.
1484
+ // The singer's close-ups only where they can be lip-synced afterwards. Each story scene is filmed
1485
+ // with the portraits of the members in it, and their looks in words, so they are the same people
1486
+ // in every shot; a scene with nobody in it goes without.
1487
+ const byRole = new Map(members.map((member) => [member.role.toLowerCase(), member]));
1488
+ const storyCast = await Promise.all(storyPrompts.map(async (scene) => {
1489
+ const who = scene.cast.map((role) => byRole.get(role)).filter((member) => Boolean(member));
1490
+ const named = who.map((member, i) => `the ${member.role} from reference image ${i + 1} (${member.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
1491
+ return {
1492
+ prompt: who.length ? `${scene.prompt} The people are exactly the ones in the reference images, same faces, hair and clothes: ${named}.` : scene.prompt,
1493
+ refs: await portraits(who),
1494
+ };
1495
+ }));
1446
1496
  // The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
1447
1497
  const plan = [
1448
1498
  ...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs, seconds: SINGER_SECONDS })) : []),
1449
- ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [], seconds: SHOT_SECONDS })),
1499
+ ...storyFiles.map((file, i) => ({ prompt: storyCast[i]?.prompt ?? "", file, into: story, refs: storyCast[i]?.refs ?? [], seconds: SHOT_SECONDS })),
1450
1500
  ...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs, seconds: SHOT_SECONDS })),
1451
1501
  ];
1452
1502
  for (const [i, { prompt, file, into, refs, seconds }] of plan.entries()) {
@@ -540,6 +540,7 @@ async function labelCommand(argv, call, show) {
540
540
  resetDay: number("--reset-day"),
541
541
  veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
542
542
  veoShots: number("--veo-shots"),
543
+ videoUsd: number("--video-usd"),
543
544
  lipsyncShots: number("--lipsync-shots"),
544
545
  storyShots: number("--story-shots"),
545
546
  genreOrder: flag(argv, "--genres"),
@@ -61,7 +61,7 @@ export declare class VeoStudio {
61
61
  get canLipsync(): boolean;
62
62
  /**
63
63
  * A singer's close-up with the mouth redrawn to `audio` (the same seconds of
64
- * the song): Sync's lipsync-2 on fal.ai. Veo's singers mouth a melody of their
64
+ * the song): Sync's lipsync-2-pro on fal.ai. Veo's singers mouth a melody of their
65
65
  * own; this makes them sing ours. Paid under the same monthly cap.
66
66
  */
67
67
  lipsync(input: {
@@ -176,7 +176,7 @@ export class VeoStudio {
176
176
  get canLipsync() { return Boolean(this.options.falKey); }
177
177
  /**
178
178
  * A singer's close-up with the mouth redrawn to `audio` (the same seconds of
179
- * the song): Sync's lipsync-2 on fal.ai. Veo's singers mouth a melody of their
179
+ * the song): Sync's lipsync-2-pro on fal.ai. Veo's singers mouth a melody of their
180
180
  * own; this makes them sing ours. Paid under the same monthly cap.
181
181
  */
182
182
  async lipsync(input) {
@@ -197,7 +197,7 @@ export class VeoStudio {
197
197
  }
198
198
  async sync(key, video, audio) {
199
199
  const [videoUrl, audioUrl] = await Promise.all([this.falUpload(key, video, "video/mp4", "shot.mp4"), this.falUpload(key, audio, "audio/mpeg", "vocal.mp3")]);
200
- return await this.falJob(key, "fal-ai/sync-lipsync/v2", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
200
+ return await this.falJob(key, "fal-ai/sync-lipsync/v2/pro", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
201
201
  }
202
202
  async make(prompt, seconds, references) {
203
203
  const send = this.options.fetcher ?? fetch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.69.0",
3
+ "version": "0.70.1",
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
@@ -586,6 +586,19 @@ export function elevenLabsAligner(key: string, fetcher: typeof fetch = fetch): (
586
586
  };
587
587
  }
588
588
 
589
+ /** ElevenLabs voice isolation: a few seconds of the song in, the voice alone out (what a lip sync follows best). */
590
+ export function elevenLabsIsolator(key: string, fetcher: typeof fetch = fetch): (audio: Buffer) => Promise<Buffer> {
591
+ return async (audio) => {
592
+ const form = new FormData();
593
+ form.append("audio", new Blob([new Uint8Array(audio)], { type: "audio/mpeg" }), "lines.mp3");
594
+ const response = await fetcher("https://api.elevenlabs.io/v1/audio-isolation", { method: "POST", headers: { "xi-api-key": key }, body: form, signal: AbortSignal.timeout(2 * 60_000) });
595
+ if (!response.ok) throw new Error(`the voice isolation answered HTTP ${response.status}`);
596
+ const voice = Buffer.from(await response.arrayBuffer());
597
+ if (voice.length < 1000) throw new Error("the voice isolation answered with no audio");
598
+ return voice;
599
+ };
600
+ }
601
+
589
602
  /** The sung lines of a song's words: section tags ([Chorus]) and blank lines are not sung. */
590
603
  export function sungLines(lyrics: string): string[] {
591
604
  return lyrics.split("\n").map((line) => line.trim()).filter((line) => line && !/^\[[^\]]*\]$/.test(line));
@@ -645,13 +658,17 @@ export async function writeStoryboard(
645
658
  }
646
659
 
647
660
  const VEO_STORY_SYSTEM = [
648
- "You write the story scenes of a music video for an original song, as prompts for a video model that films 8-second moving shots.",
649
- "Tell the song's story in cinematic scenes away from any stage: characters, places, action, weather, small details. Keep the same main character(s), setting and color palette across scenes so it reads as one short film; vary the shots (wide, tracking, close detail, aerial) and say how the camera moves.",
650
- "Describe people by how they look, never by name. Nobody sings, speaks or lip-syncs, and nobody holds a microphone. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
651
- "Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
661
+ "You write the story scenes of a music video for an original song, as prompts for a video model that films short moving shots.",
662
+ "Tell the song's story in cinematic scenes away from any stage. The main characters are ONLY the band members listed (by role); every scene with a person in it features one to three of them, and each one keeps exactly the look given. Other people may appear only in the background, faces unseen.",
663
+ "Keep the same settings and color palette across scenes so it reads as one short film; vary the shots (wide, tracking, close detail, aerial) and say how the camera moves. Nobody sings, speaks or lip-syncs, and nobody holds a microphone.",
664
+ "Describe people by role and look, never by name. Each prompt at most 350 characters. No text, letters, logos, real people or real brands.",
665
+ "Answer as JSON: {\"scenes\": [{\"prompt\": \"...\", \"cast\": [\"lead vocals\", ...]}, ...]} where cast lists the roles (exactly as given) of the members in that scene, [] for none.",
652
666
  ].join(" ");
653
667
 
654
- /** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
668
+ /** A story scene for Veo: the prompt and the roles of the band members in it (their portraits go with it). */
669
+ export interface StoryScene { prompt: string; cast: string[] }
670
+
671
+ /** A single's story scenes for Veo: a short film from its words starring the band as they look, nobody singing. */
655
672
  export async function writeVeoStory(
656
673
  artist: Pick<LabelArtist, "genre" | "sound">,
657
674
  members: Array<Pick<Member, "role" | "look">>,
@@ -659,25 +676,33 @@ export async function writeVeoStory(
659
676
  song: Pick<LabelSong, "title" | "style" | "lyrics">,
660
677
  scenes: number,
661
678
  ask: typeof askJson = askJson,
662
- ): Promise<string[]> {
663
- const cast = members.slice(0, 4).map((one) => `${one.role}: ${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")}`).join("; ");
679
+ ): Promise<StoryScene[]> {
680
+ const cast = members.slice(0, 5).map((one) => `${one.role}: ${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")}`).join("; ");
664
681
  const answer = await ask({
665
682
  system: VEO_STORY_SYSTEM,
666
683
  prompt: [
667
684
  `Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
668
- cast ? `The band may appear as characters, described by look: ${cast}` : "",
685
+ cast ? `The band (the only main characters), by role and look: ${cast}` : "",
669
686
  `Mood and arrangement: ${song.style}`,
670
687
  song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
671
688
  `Exactly ${scenes} scenes.`,
672
689
  ].filter(Boolean).join("\n"),
673
- maxTokens: 2500,
674
- timeoutMs: 90_000,
690
+ maxTokens: 4000,
691
+ timeoutMs: 120_000,
675
692
  });
693
+ const roles = new Set(members.map((one) => one.role.toLowerCase()));
676
694
  const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
677
- const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
678
- if (prompts.length < 1) throw new Error("the model wrote no story");
679
- return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
680
- + " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
695
+ const out = list.map((one): StoryScene => {
696
+ const item = (typeof one === "object" && one ? one : { prompt: one }) as { prompt?: unknown; cast?: unknown };
697
+ const who = Array.isArray(item.cast) ? item.cast.map((role) => String(role).toLowerCase().trim()).filter((role) => roles.has(role)) : [];
698
+ return { prompt: clean(item.prompt, 400), cast: [...new Set(who)].slice(0, 3) };
699
+ }).filter((one) => one.prompt.length > 10).slice(0, scenes);
700
+ if (out.length < 1) throw new Error("the model wrote no story");
701
+ return out.map((scene) => ({
702
+ cast: scene.cast,
703
+ prompt: `Music video for a fictional ${artist.genre} song. ${scene.prompt} Mood: ${song.style}`.slice(0, 900)
704
+ + " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, high detail. No text, no logos.",
705
+ }));
681
706
  }
682
707
 
683
708
  /** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
@@ -826,13 +851,16 @@ export const VIDEO_USD = 8;
826
851
  * story. Footage shorter than the song is cut in turn and shown again.
827
852
  * $8 with a singer: 2 close-ups ($4.00 with their sync) and 6 shots ($3.60).
828
853
  */
829
- export function shotPlan(_seconds: number, singing: boolean, usd = VIDEO_USD): { story: number; band: number; singer: number } {
854
+ export function shotPlan(seconds: number, singing: boolean, usd = VIDEO_USD): { story: number; band: number; singer: number } {
830
855
  const closeUp = SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND), shot = SHOT_SECONDS * VEO_USD_PER_SECOND;
831
- // Two close-ups when they leave room for at least two shots; one when the budget is tight.
832
- const singer = !singing ? 0 : usd >= 2 * closeUp + 2 * shot ? 2 : 1;
833
- const left = usd - singer * SINGER_SECONDS * (VEO_USD_PER_SECOND + LIPSYNC_USD_PER_SECOND);
834
- const shots = Math.max(2, Math.floor(left / (SHOT_SECONDS * VEO_USD_PER_SECOND) + 1e-9));
835
- const band = Math.max(1, Math.round(shots / 3));
856
+ const length = Math.max(30, seconds);
857
+ // About one lip-synced close-up every 30 s of song, as many as leave room for at least two shots.
858
+ let singer = singing ? Math.max(1, Math.min(6, Math.round(length / 30))) : 0;
859
+ while (singer > 1 && usd < singer * closeUp + 2 * shot) singer -= 1;
860
+ // Then shots enough to cover the rest of the song once (nothing shown twice), as far as the money goes.
861
+ const cover = Math.ceil(Math.max(0, length - singer * SINGER_SECONDS) / SHOT_SECONDS);
862
+ const shots = Math.max(2, Math.min(cover, Math.floor((usd - singer * closeUp) / shot + 1e-9)));
863
+ const band = Math.max(1, Math.round(shots / 4));
836
864
  return { story: Math.max(1, shots - band), band, singer };
837
865
  }
838
866
 
@@ -870,9 +898,10 @@ export function closeUpTimes(seconds: number, lines: Array<{ start: number }>, c
870
898
  * The version of a video cut from Veo footage: story scenes and the band playing with
871
899
  * nobody singing, the singer only in close-ups lip-synced to the song. Anything older
872
900
  * is not shown: a slideshow, 4 (singers out of sync) or 5 (all stage, the singer at
873
- * the mic in the wide shots).
901
+ * the mic in the wide shots). 6 and 7 (strangers in the story scenes, footage looped)
902
+ * stay up until filmed again.
874
903
  */
875
- export const FILMED = 7;
904
+ export const FILMED = 8;
876
905
  /** 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. */
877
906
  export const SHOWN = 6;
878
907
 
@@ -1026,6 +1055,8 @@ export interface AiLabelDeps {
1026
1055
  retryMs?: number;
1027
1056
  /** Cuts a single's filmed shots to its song; ffmpeg by default (renderFilmedVideo). */
1028
1057
  filmedVideo?: (input: { audio: string; clips: Array<{ path: string; seconds: number }>; seconds: number; artist: string; title: string; out: string; pinned?: Array<{ path: string; at: number; seconds: number }> }) => Promise<void>;
1058
+ /** The voice alone from a few seconds of a song (ElevenLabs by default when its key is set); null for none. */
1059
+ isolate?: ((audio: Buffer) => Promise<Buffer>) | null;
1029
1060
  /** A singer's close-up with the mouth redrawn to these seconds of the song (nixamp.com, fal.ai); VeoUnavailable when the month is spent. */
1030
1061
  lipsync?: ((input: { video: Buffer; audio: Buffer; seconds: number }) => Promise<Buffer>) | null;
1031
1062
  /** Renders a storyboard music video; ffmpeg by default (renderStoryVideo). */
@@ -1510,9 +1541,13 @@ export class AiLabel {
1510
1541
  // The shot made small enough to send (1080p, no sound) and the song's same 8 seconds.
1511
1542
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", singer[i]!.path, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "21", "-t", "8", shot], 5 * 60_000, "the close-up did not shrink");
1512
1543
  await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-ss", at.toFixed(2), "-t", "8", "-i", song.file, "-c:a", "libmp3lame", "-b:a", "192k", audio], 60_000, "the song's seconds did not cut");
1544
+ // The mouth follows the voice alone better than the whole mix; the video keeps the whole mix.
1545
+ let voice: Buffer = readFileSync(audio);
1546
+ const isolate = this.deps.isolate !== undefined ? this.deps.isolate : (aiKey("ELEVENLABS_API_KEY") ? elevenLabsIsolator(aiKey("ELEVENLABS_API_KEY")!) : null);
1547
+ if (isolate) voice = await isolate(voice).catch((error: unknown) => { this.log(`voice isolation for "${song.title}": ${(error as Error).message}; the whole mix instead`); return voice; });
1513
1548
  let bytes: Buffer | null = null;
1514
1549
  for (let attempt = 0; ; attempt++) {
1515
- try { bytes = await lipsync({ video: readFileSync(shot), audio: readFileSync(audio), seconds: 8 }); break; } catch (error) {
1550
+ try { bytes = await lipsync({ video: readFileSync(shot), audio: voice, seconds: 8 }); break; } catch (error) {
1516
1551
  if (attempt >= 2 || !TRANSIENT.test((error as Error).message)) throw error;
1517
1552
  await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
1518
1553
  }
@@ -1555,18 +1590,24 @@ export class AiLabel {
1555
1590
  const voice = (member: Member): boolean => /vocal|sing/i.test(member.role);
1556
1591
  const players = members.filter((member) => !voice(member));
1557
1592
  const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
1593
+ const shrunk = new Map<string, Buffer | null>();
1558
1594
  const portraits = async (list: Member[]): Promise<Buffer[]> => {
1559
1595
  const out: Buffer[] = [];
1560
1596
  for (const member of list.slice(0, 3)) {
1561
- const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1562
- const small = join(dir, `ref-${member.slug}.jpg`);
1563
- try {
1564
- // 512 px is plenty for a face and keeps the request small.
1565
- 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");
1566
- out.push(readFileSync(small));
1567
- } catch {
1568
- // A member without a portrait is described in words only.
1597
+ if (!shrunk.has(member.slug)) {
1598
+ const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
1599
+ const small = join(dir, `ref-${member.slug}.jpg`);
1600
+ try {
1601
+ // 768 px keeps a face's detail and the request small.
1602
+ await runLow(this.deps.ffmpeg, [...this.deps.ffmpeg.slice(1), "-hide_banner", "-loglevel", "error", "-y", "-i", portrait, "-vf", "scale=768:-2", "-q:v", "3", small], 60_000, "the portrait did not shrink");
1603
+ shrunk.set(member.slug, readFileSync(small));
1604
+ } catch {
1605
+ // A member without a portrait is described in words only.
1606
+ shrunk.set(member.slug, null);
1607
+ }
1569
1608
  }
1609
+ const bytes = shrunk.get(member.slug);
1610
+ if (bytes) out.push(bytes);
1570
1611
  }
1571
1612
  return out;
1572
1613
  };
@@ -1576,7 +1617,7 @@ export class AiLabel {
1576
1617
  const singer: Array<{ path: string; seconds: number }> = [];
1577
1618
  // The story is written only while some of its scenes are still to film.
1578
1619
  const storyFiles = Array.from({ length: counts.story }, (_, i) => join(keep, `story-${i}.mp4`));
1579
- let storyPrompts: string[] = [];
1620
+ let storyPrompts: StoryScene[] = [];
1580
1621
  if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
1581
1622
  try {
1582
1623
  storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
@@ -1584,12 +1625,22 @@ export class AiLabel {
1584
1625
  this.log(`no story for "${song.title}" yet: ${(error as Error).message}`);
1585
1626
  }
1586
1627
  }
1587
- // The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
1588
- // the portraits: they would pull the whole band into every scene.
1628
+ // The singer's close-ups only where they can be lip-synced afterwards. Each story scene is filmed
1629
+ // with the portraits of the members in it, and their looks in words, so they are the same people
1630
+ // in every shot; a scene with nobody in it goes without.
1631
+ const byRole = new Map(members.map((member) => [member.role.toLowerCase(), member]));
1632
+ const storyCast = await Promise.all(storyPrompts.map(async (scene) => {
1633
+ const who = scene.cast.map((role) => byRole.get(role)).filter((member): member is Member => Boolean(member));
1634
+ const named = who.map((member, i) => `the ${member.role} from reference image ${i + 1} (${member.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
1635
+ return {
1636
+ prompt: who.length ? `${scene.prompt} The people are exactly the ones in the reference images, same faces, hair and clothes: ${named}.` : scene.prompt,
1637
+ refs: await portraits(who),
1638
+ };
1639
+ }));
1589
1640
  // The singer first: a day's few Veo requests go to what the video is for, then the story and the band.
1590
1641
  const plan = [
1591
1642
  ...(this.deps.lipsync ? veoPrompts(artist, song, singers, counts.singer, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs, seconds: SINGER_SECONDS })) : []),
1592
- ...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[], seconds: SHOT_SECONDS })),
1643
+ ...storyFiles.map((file, i) => ({ prompt: storyCast[i]?.prompt ?? "", file, into: story, refs: storyCast[i]?.refs ?? [], seconds: SHOT_SECONDS })),
1593
1644
  ...veoPrompts(artist, song, players, counts.band).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs, seconds: SHOT_SECONDS })),
1594
1645
  ];
1595
1646
  for (const [i, { prompt, file, into, refs, seconds }] of plan.entries()) {
@@ -506,6 +506,7 @@ async function labelCommand(argv: string[], call: Call, show: Show): Promise<num
506
506
  resetDay: number("--reset-day"),
507
507
  veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
508
508
  veoShots: number("--veo-shots"),
509
+ videoUsd: number("--video-usd"),
509
510
  lipsyncShots: number("--lipsync-shots"),
510
511
  storyShots: number("--story-shots"),
511
512
  genreOrder: flag(argv, "--genres"),
package/src/veo-studio.ts CHANGED
@@ -193,7 +193,7 @@ export class VeoStudio {
193
193
 
194
194
  /**
195
195
  * A singer's close-up with the mouth redrawn to `audio` (the same seconds of
196
- * the song): Sync's lipsync-2 on fal.ai. Veo's singers mouth a melody of their
196
+ * the song): Sync's lipsync-2-pro on fal.ai. Veo's singers mouth a melody of their
197
197
  * own; this makes them sing ours. Paid under the same monthly cap.
198
198
  */
199
199
  async lipsync(input: { video: Buffer; audio: Buffer; seconds: number }): Promise<Buffer> {
@@ -212,7 +212,7 @@ export class VeoStudio {
212
212
 
213
213
  private async sync(key: string, video: Buffer, audio: Buffer): Promise<Buffer> {
214
214
  const [videoUrl, audioUrl] = await Promise.all([this.falUpload(key, video, "video/mp4", "shot.mp4"), this.falUpload(key, audio, "audio/mpeg", "vocal.mp3")]);
215
- return await this.falJob(key, "fal-ai/sync-lipsync/v2", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
215
+ return await this.falJob(key, "fal-ai/sync-lipsync/v2/pro", { video_url: videoUrl, audio_url: audioUrl }, "the lip sync");
216
216
  }
217
217
 
218
218
  private async make(prompt: string, seconds: number, references: Buffer[]): Promise<Buffer> {
@@ -1 +1 @@
1
- import{t as e}from"./main-Bh0dWH3R.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
1
+ import{t as e}from"./main-p1NGc9XO.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};