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.
- package/dist/ai-label.d.ts +15 -5
- package/dist/ai-label.js +84 -34
- package/dist/station-cli.js +1 -0
- package/dist/veo-studio.d.ts +1 -1
- package/dist/veo-studio.js +2 -2
- package/package.json +1 -1
- package/src/ai-label.ts +86 -35
- package/src/station-cli.ts +1 -0
- package/src/veo-studio.ts +2 -2
- package/web/dist/assets/{ar-BAUYJ7CG.js → ar-DpvJfvBS.js} +1 -1
- package/web/dist/assets/{cs-D5Njj-lD.js → cs-Byt29q-N.js} +1 -1
- package/web/dist/assets/{da-CQ52TUpt.js → da-BNVqf41w.js} +1 -1
- package/web/dist/assets/{de-slj7FWP_.js → de-CkkNpSeK.js} +1 -1
- package/web/dist/assets/{es-wy-duArI.js → es-DTbm6erE.js} +1 -1
- package/web/dist/assets/{fi-D8tuiwIa.js → fi-CLe542jk.js} +1 -1
- package/web/dist/assets/{fr-AlVe-2YY.js → fr-Cw6IcnjA.js} +1 -1
- package/web/dist/assets/{hi-BkrehL5x.js → hi-BA_-5ZbE.js} +1 -1
- package/web/dist/assets/{hu-_3tyj6FI.js → hu-D4hoNRZH.js} +1 -1
- package/web/dist/assets/{id-O2_s9_ZD.js → id-BUGLjAyd.js} +1 -1
- package/web/dist/assets/{it-D6kuTye8.js → it-zM-JnjaU.js} +1 -1
- package/web/dist/assets/main-p1NGc9XO.js +22 -0
- package/web/dist/assets/music-C8ITlUNF.js +1 -0
- package/web/dist/assets/{nl-DOdu_ltu.js → nl-ka_E88m2.js} +1 -1
- package/web/dist/assets/now-playing-DVQqWQIu.js +1 -0
- package/web/dist/assets/{ru-B39HLLYh.js → ru-CNiGclVH.js} +1 -1
- package/web/dist/assets/station-tWQPqySd.js +3 -0
- package/web/dist/assets/{sv-DTDAE9DG.js → sv-cvAfJ4oL.js} +1 -1
- package/web/dist/assets/{uk-DmM6P2_W.js → uk-45wTyXWC.js} +1 -1
- package/web/dist/assets/{vi-8EoMeh4v.js → vi-CnN2uyTw.js} +1 -1
- package/web/dist/assets/{zh-DkGyIChT.js → zh-BfjjooDK.js} +1 -1
- package/web/dist/index.html +2 -1
- package/web/dist/music.html +2 -1
- package/web/dist/station.html +2 -1
- package/web/dist/sw.js +22 -21
- package/web/dist/assets/main-Bh0dWH3R.js +0 -22
- package/web/dist/assets/music-Bby75I2_.js +0 -1
- package/web/dist/assets/station-DtY1LOm5.js +0 -3
package/dist/ai-label.d.ts
CHANGED
|
@@ -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
|
|
185
|
-
export
|
|
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(
|
|
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 =
|
|
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
|
|
497
|
-
"Tell the song's story in cinematic scenes away from any stage
|
|
498
|
-
"
|
|
499
|
-
"
|
|
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
|
|
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,
|
|
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
|
|
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:
|
|
514
|
-
timeoutMs:
|
|
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
|
|
518
|
-
|
|
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
|
|
521
|
-
|
|
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(
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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.
|
|
1445
|
-
// the portraits
|
|
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:
|
|
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()) {
|
package/dist/station-cli.js
CHANGED
|
@@ -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"),
|
package/dist/veo-studio.d.ts
CHANGED
|
@@ -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: {
|
package/dist/veo-studio.js
CHANGED
|
@@ -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
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
|
|
649
|
-
"Tell the song's story in cinematic scenes away from any stage
|
|
650
|
-
"
|
|
651
|
-
"
|
|
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
|
|
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<
|
|
663
|
-
const cast = members.slice(0,
|
|
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
|
|
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:
|
|
674
|
-
timeoutMs:
|
|
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
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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(
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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:
|
|
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.
|
|
1588
|
-
// the portraits
|
|
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:
|
|
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()) {
|
package/src/station-cli.ts
CHANGED
|
@@ -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-
|
|
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};
|