nixamp 0.66.3 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +5 -1
- package/dist/ai-label.js +97 -33
- package/dist/station-cli.js +1 -0
- package/dist/station-mcp.d.ts +15 -0
- package/dist/station-mcp.js +2 -1
- package/package.json +1 -1
- package/src/ai-label.ts +101 -32
- package/src/station-cli.ts +1 -0
- package/src/station-mcp.ts +2 -1
- package/web/dist/assets/{ar-BQfRAPxT.js → ar-Dw3s9ADu.js} +1 -1
- package/web/dist/assets/{cs-CIlyIUyM.js → cs-CzDFyr4i.js} +1 -1
- package/web/dist/assets/{da-B73sEH_l.js → da-DCTQgBaR.js} +1 -1
- package/web/dist/assets/{de-CR_qS1kD.js → de-bRuy-6md.js} +1 -1
- package/web/dist/assets/{es-CWcxhMQ1.js → es-iHd4cFjq.js} +1 -1
- package/web/dist/assets/{fi-sw-rgeRG.js → fi-BQgHm0iD.js} +1 -1
- package/web/dist/assets/{fr-DWwCpFG-.js → fr-Bx2XC-ON.js} +1 -1
- package/web/dist/assets/{hi-BDa2YD-x.js → hi-Cq8IDrah.js} +1 -1
- package/web/dist/assets/{hu-CsYXXp0t.js → hu-C3mzcLs_.js} +1 -1
- package/web/dist/assets/{id-CzrnSbAL.js → id-DddaqO3C.js} +1 -1
- package/web/dist/assets/{it-DT6eQX_8.js → it-C8TvaS5w.js} +1 -1
- package/web/dist/assets/{main-B-WZ8VbE.js → main-BSOJX0Of.js} +2 -2
- package/web/dist/assets/{nl-C57z9Sux.js → nl-C3LqPPhu.js} +1 -1
- package/web/dist/assets/{ru-CW9If0BU.js → ru-DzAMilh-.js} +1 -1
- package/web/dist/assets/{sv-CiW_iXWQ.js → sv-Ca1BMi0N.js} +1 -1
- package/web/dist/assets/{uk-D_agLCru.js → uk-D-CADfyO.js} +1 -1
- package/web/dist/assets/{vi-C4wrS-qx.js → vi-CWeZdHAD.js} +1 -1
- package/web/dist/assets/{zh-TOIwUGfV.js → zh-BvS0BRgy.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
package/dist/ai-label.d.ts
CHANGED
|
@@ -90,8 +90,10 @@ export interface LabelSettings {
|
|
|
90
90
|
videos: boolean;
|
|
91
91
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
92
92
|
veo: boolean;
|
|
93
|
-
/** How many 8 s Veo shots of the band a single is cut from. */
|
|
93
|
+
/** How many 8 s Veo shots of the band playing (never the singer) a single is cut from. */
|
|
94
94
|
veoShots: number;
|
|
95
|
+
/** How many 8 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
|
|
96
|
+
storyShots: number;
|
|
95
97
|
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
96
98
|
lipsyncShots: number;
|
|
97
99
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
@@ -166,6 +168,8 @@ export declare function syncLines(lyrics: string, timed: TimedWord[]): SyncedLin
|
|
|
166
168
|
export declare function writeStoryboard(artist: Pick<LabelArtist, "name" | "genre" | "sound"> & {
|
|
167
169
|
cast?: string;
|
|
168
170
|
}, album: Pick<LabelAlbum, "title" | "concept">, song: Pick<LabelSong, "title" | "style" | "lyrics">, scenes: number, ask?: typeof askJson): Promise<string[]>;
|
|
171
|
+
/** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
|
|
172
|
+
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[]>;
|
|
169
173
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
170
174
|
export declare class VeoUnavailable extends Error {
|
|
171
175
|
}
|
package/dist/ai-label.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { execFile } from "node:child_process";
|
|
30
30
|
import { randomBytes } from "node:crypto";
|
|
31
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { dirname, join, resolve, sep } from "node:path";
|
|
33
33
|
import { aiKey } from "./ai-keys.js";
|
|
34
34
|
import { composerFromEnv, OutOfCredit } from "./ai-radio.js";
|
|
@@ -273,7 +273,8 @@ export const DEFAULTS = {
|
|
|
273
273
|
publish: true,
|
|
274
274
|
videos: true,
|
|
275
275
|
veo: false,
|
|
276
|
-
veoShots:
|
|
276
|
+
veoShots: 2,
|
|
277
|
+
storyShots: 4,
|
|
277
278
|
lipsyncShots: 2,
|
|
278
279
|
parallel: 1,
|
|
279
280
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
@@ -489,6 +490,34 @@ export async function writeStoryboard(artist, album, song, scenes, ask = askJson
|
|
|
489
490
|
throw new Error("the model wrote no storyboard");
|
|
490
491
|
return prompts;
|
|
491
492
|
}
|
|
493
|
+
const VEO_STORY_SYSTEM = [
|
|
494
|
+
"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.",
|
|
495
|
+
"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.",
|
|
496
|
+
"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.",
|
|
497
|
+
"Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
|
|
498
|
+
].join(" ");
|
|
499
|
+
/** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
|
|
500
|
+
export async function writeVeoStory(artist, members, album, song, scenes, ask = askJson) {
|
|
501
|
+
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("; ");
|
|
502
|
+
const answer = await ask({
|
|
503
|
+
system: VEO_STORY_SYSTEM,
|
|
504
|
+
prompt: [
|
|
505
|
+
`Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
506
|
+
cast ? `The band may appear as characters, described by look: ${cast}` : "",
|
|
507
|
+
`Mood and arrangement: ${song.style}`,
|
|
508
|
+
song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
|
|
509
|
+
`Exactly ${scenes} scenes.`,
|
|
510
|
+
].filter(Boolean).join("\n"),
|
|
511
|
+
maxTokens: 2500,
|
|
512
|
+
timeoutMs: 90_000,
|
|
513
|
+
});
|
|
514
|
+
const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
|
|
515
|
+
const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
|
|
516
|
+
if (prompts.length < 1)
|
|
517
|
+
throw new Error("the model wrote no story");
|
|
518
|
+
return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
|
|
519
|
+
+ " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
|
|
520
|
+
}
|
|
492
521
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
493
522
|
export class VeoUnavailable extends Error {
|
|
494
523
|
}
|
|
@@ -498,14 +527,12 @@ const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
|
|
|
498
527
|
// so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
|
|
499
528
|
// behind, in silhouette or between lines, never a close-up of singing.
|
|
500
529
|
const SHOTS = [
|
|
501
|
-
"Wide shot of the whole band performing on a big stage at night, seen from the back of the crowd, the players small against the lights",
|
|
502
530
|
"Close-up of the guitarist's hands on the fretboard and the strings, shallow focus, stage lights flaring",
|
|
503
|
-
"The camera glides behind the band members as they play hard, seen from behind, looking out over the crowd",
|
|
504
531
|
"Low-angle shot of the drummer hitting the cymbals at the song's climax, sticks blurred, sparks of light",
|
|
505
|
-
"
|
|
506
|
-
"
|
|
507
|
-
"
|
|
508
|
-
"
|
|
532
|
+
"Close-up of the bass player's fingers and the amplifier, then a tilt up to the bassist in silhouette",
|
|
533
|
+
"The guitarist and the bassist seen from behind, playing hard toward a huge crowd, backlit",
|
|
534
|
+
"Slow push-in on the drummer in a quiet moment, moody light, sweat and smoke",
|
|
535
|
+
"Slow aerial push over the crowd toward the stage, fans with hands up, the stage tiny in the distance",
|
|
509
536
|
];
|
|
510
537
|
// The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
|
|
511
538
|
// must be clear: whatever covers it (a microphone, a hand) is smeared into the face when
|
|
@@ -525,7 +552,7 @@ export function veoPrompts(artist, song, members, count, kind = "band") {
|
|
|
525
552
|
const cast = members.slice(0, 3).map((one, i) => `the ${one.role} from reference image ${i + 1} (${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
|
|
526
553
|
const rule = kind === "singer"
|
|
527
554
|
? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
|
|
528
|
-
: "
|
|
555
|
+
: "The lead vocalist is not in this shot, and nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
|
|
529
556
|
return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
|
|
530
557
|
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
531
558
|
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
@@ -940,9 +967,12 @@ export class AiLabel {
|
|
|
940
967
|
this.settings.videos = input["videos"] !== false;
|
|
941
968
|
if (input["veo"] !== undefined)
|
|
942
969
|
this.settings.veo = input["veo"] === true;
|
|
943
|
-
const shots = whole("veoShots",
|
|
970
|
+
const shots = whole("veoShots", 0, 8);
|
|
944
971
|
if (shots !== undefined)
|
|
945
972
|
this.settings.veoShots = shots;
|
|
973
|
+
const story = whole("storyShots", 0, 8);
|
|
974
|
+
if (story !== undefined)
|
|
975
|
+
this.settings.storyShots = story;
|
|
946
976
|
const close = whole("lipsyncShots", 0, 4);
|
|
947
977
|
if (close !== undefined)
|
|
948
978
|
this.settings.lipsyncShots = close;
|
|
@@ -1208,9 +1238,10 @@ export class AiLabel {
|
|
|
1208
1238
|
catch { /* the asked-for length */ }
|
|
1209
1239
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1210
1240
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1211
|
-
const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
|
|
1212
|
-
|
|
1213
|
-
|
|
1241
|
+
const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
|
|
1242
|
+
const wanted = this.settings.veoShots + this.settings.storyShots;
|
|
1243
|
+
if (clips.length < Math.max(2, Math.ceil(wanted / 2)))
|
|
1244
|
+
throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
|
|
1214
1245
|
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1215
1246
|
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1216
1247
|
rmSync(shots, { recursive: true, force: true });
|
|
@@ -1288,34 +1319,57 @@ export class AiLabel {
|
|
|
1288
1319
|
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
1289
1320
|
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
1290
1321
|
*/
|
|
1291
|
-
async filmShots(artist, song, keep, dir) {
|
|
1322
|
+
async filmShots(artist, album, song, keep, dir) {
|
|
1292
1323
|
const veo = this.deps.veo;
|
|
1293
1324
|
if (!veo)
|
|
1294
1325
|
return { band: [], singer: [] };
|
|
1295
1326
|
mkdirSync(dir, { recursive: true });
|
|
1296
1327
|
mkdirSync(keep, { recursive: true });
|
|
1297
1328
|
const members = this.cast(artist);
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1329
|
+
// The players for the band shots, the voice for the close-ups: a singer's portrait in a band
|
|
1330
|
+
// shot puts the singer at a microphone, singing out of sync.
|
|
1331
|
+
const voice = (member) => /vocal|sing/i.test(member.role);
|
|
1332
|
+
const players = members.filter((member) => !voice(member));
|
|
1333
|
+
const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
|
|
1334
|
+
const portraits = async (list) => {
|
|
1335
|
+
const out = [];
|
|
1336
|
+
for (const member of list.slice(0, 3)) {
|
|
1337
|
+
const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
|
|
1338
|
+
const small = join(dir, `ref-${member.slug}.jpg`);
|
|
1339
|
+
try {
|
|
1340
|
+
// 512 px is plenty for a face and keeps the request small.
|
|
1341
|
+
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");
|
|
1342
|
+
out.push(readFileSync(small));
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
// A member without a portrait is described in words only.
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return out;
|
|
1349
|
+
};
|
|
1350
|
+
const [playerRefs, singerRefs] = [await portraits(players), await portraits(singers)];
|
|
1351
|
+
const band = [];
|
|
1352
|
+
const story = [];
|
|
1353
|
+
const singer = [];
|
|
1354
|
+
// The story is written only while some of its scenes are still to film.
|
|
1355
|
+
const storyFiles = Array.from({ length: this.settings.storyShots }, (_, i) => join(keep, `story-${i}.mp4`));
|
|
1356
|
+
let storyPrompts = [];
|
|
1357
|
+
if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
|
|
1302
1358
|
try {
|
|
1303
|
-
|
|
1304
|
-
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");
|
|
1305
|
-
references.push(readFileSync(small));
|
|
1359
|
+
storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
|
|
1306
1360
|
}
|
|
1307
|
-
catch {
|
|
1308
|
-
|
|
1361
|
+
catch (error) {
|
|
1362
|
+
this.log(`no story for "${song.title}" yet: ${error.message}`);
|
|
1309
1363
|
}
|
|
1310
1364
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
// The singer's close-ups only where they can be lip-synced afterwards.
|
|
1365
|
+
// The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
|
|
1366
|
+
// the portraits: they would pull the whole band into every scene.
|
|
1314
1367
|
const plan = [
|
|
1315
|
-
...
|
|
1316
|
-
...
|
|
1368
|
+
...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] })),
|
|
1369
|
+
...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
|
|
1370
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
|
|
1317
1371
|
];
|
|
1318
|
-
for (const [i, { prompt, file, into }] of plan.entries()) {
|
|
1372
|
+
for (const [i, { prompt, file, into, refs }] of plan.entries()) {
|
|
1319
1373
|
try {
|
|
1320
1374
|
if (statSync(file).size > 10_000) {
|
|
1321
1375
|
into.push({ path: file, seconds: 8 });
|
|
@@ -1323,12 +1377,14 @@ export class AiLabel {
|
|
|
1323
1377
|
}
|
|
1324
1378
|
}
|
|
1325
1379
|
catch { /* not filmed yet */ }
|
|
1380
|
+
if (!prompt)
|
|
1381
|
+
continue;
|
|
1326
1382
|
try {
|
|
1327
1383
|
// nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
|
|
1328
1384
|
let bytes = null;
|
|
1329
1385
|
for (let attempt = 0;; attempt++) {
|
|
1330
1386
|
try {
|
|
1331
|
-
bytes = await veo({ prompt, seconds: 8, references });
|
|
1387
|
+
bytes = await veo({ prompt, seconds: 8, references: refs });
|
|
1332
1388
|
break;
|
|
1333
1389
|
}
|
|
1334
1390
|
catch (error) {
|
|
@@ -1351,9 +1407,17 @@ export class AiLabel {
|
|
|
1351
1407
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${error.message}`);
|
|
1352
1408
|
}
|
|
1353
1409
|
}
|
|
1354
|
-
if (band.length || singer.length)
|
|
1355
|
-
this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1356
|
-
|
|
1410
|
+
if (band.length || story.length || singer.length)
|
|
1411
|
+
this.log(`Veo: ${band.length + story.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1412
|
+
// The cut takes them in turn: a story scene, then the band playing, and so on.
|
|
1413
|
+
const mixed = [];
|
|
1414
|
+
for (let i = 0; i < Math.max(story.length, band.length); i++) {
|
|
1415
|
+
if (story[i])
|
|
1416
|
+
mixed.push(story[i]);
|
|
1417
|
+
if (band[i])
|
|
1418
|
+
mixed.push(band[i]);
|
|
1419
|
+
}
|
|
1420
|
+
return { band: mixed, singer };
|
|
1357
1421
|
}
|
|
1358
1422
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
|
1359
1423
|
placeVideos() {
|
package/dist/station-cli.js
CHANGED
|
@@ -541,6 +541,7 @@ async function labelCommand(argv, call, show) {
|
|
|
541
541
|
veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
|
|
542
542
|
veoShots: number("--veo-shots"),
|
|
543
543
|
lipsyncShots: number("--lipsync-shots"),
|
|
544
|
+
storyShots: number("--story-shots"),
|
|
544
545
|
genreOrder: flag(argv, "--genres"),
|
|
545
546
|
enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
|
|
546
547
|
publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
|
package/dist/station-mcp.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export declare const STATION_TOOLS: ({
|
|
|
53
53
|
veo?: undefined;
|
|
54
54
|
veoShots?: undefined;
|
|
55
55
|
lipsyncShots?: undefined;
|
|
56
|
+
storyShots?: undefined;
|
|
56
57
|
genreOrder?: undefined;
|
|
57
58
|
state?: undefined;
|
|
58
59
|
reason?: undefined;
|
|
@@ -132,6 +133,7 @@ export declare const STATION_TOOLS: ({
|
|
|
132
133
|
veo?: undefined;
|
|
133
134
|
veoShots?: undefined;
|
|
134
135
|
lipsyncShots?: undefined;
|
|
136
|
+
storyShots?: undefined;
|
|
135
137
|
genreOrder?: undefined;
|
|
136
138
|
state?: undefined;
|
|
137
139
|
reason?: undefined;
|
|
@@ -234,6 +236,7 @@ export declare const STATION_TOOLS: ({
|
|
|
234
236
|
veo?: undefined;
|
|
235
237
|
veoShots?: undefined;
|
|
236
238
|
lipsyncShots?: undefined;
|
|
239
|
+
storyShots?: undefined;
|
|
237
240
|
genreOrder?: undefined;
|
|
238
241
|
state?: undefined;
|
|
239
242
|
reason?: undefined;
|
|
@@ -333,6 +336,7 @@ export declare const STATION_TOOLS: ({
|
|
|
333
336
|
veo?: undefined;
|
|
334
337
|
veoShots?: undefined;
|
|
335
338
|
lipsyncShots?: undefined;
|
|
339
|
+
storyShots?: undefined;
|
|
336
340
|
genreOrder?: undefined;
|
|
337
341
|
state?: undefined;
|
|
338
342
|
reason?: undefined;
|
|
@@ -414,6 +418,7 @@ export declare const STATION_TOOLS: ({
|
|
|
414
418
|
veo?: undefined;
|
|
415
419
|
veoShots?: undefined;
|
|
416
420
|
lipsyncShots?: undefined;
|
|
421
|
+
storyShots?: undefined;
|
|
417
422
|
genreOrder?: undefined;
|
|
418
423
|
state?: undefined;
|
|
419
424
|
reason?: undefined;
|
|
@@ -502,6 +507,7 @@ export declare const STATION_TOOLS: ({
|
|
|
502
507
|
veo?: undefined;
|
|
503
508
|
veoShots?: undefined;
|
|
504
509
|
lipsyncShots?: undefined;
|
|
510
|
+
storyShots?: undefined;
|
|
505
511
|
genreOrder?: undefined;
|
|
506
512
|
state?: undefined;
|
|
507
513
|
reason?: undefined;
|
|
@@ -590,6 +596,7 @@ export declare const STATION_TOOLS: ({
|
|
|
590
596
|
veo?: undefined;
|
|
591
597
|
veoShots?: undefined;
|
|
592
598
|
lipsyncShots?: undefined;
|
|
599
|
+
storyShots?: undefined;
|
|
593
600
|
genreOrder?: undefined;
|
|
594
601
|
state?: undefined;
|
|
595
602
|
reason?: undefined;
|
|
@@ -695,6 +702,7 @@ export declare const STATION_TOOLS: ({
|
|
|
695
702
|
veo?: undefined;
|
|
696
703
|
veoShots?: undefined;
|
|
697
704
|
lipsyncShots?: undefined;
|
|
705
|
+
storyShots?: undefined;
|
|
698
706
|
genreOrder?: undefined;
|
|
699
707
|
state?: undefined;
|
|
700
708
|
reason?: undefined;
|
|
@@ -829,6 +837,10 @@ export declare const STATION_TOOLS: ({
|
|
|
829
837
|
type: string;
|
|
830
838
|
description: string;
|
|
831
839
|
};
|
|
840
|
+
storyShots: {
|
|
841
|
+
type: string;
|
|
842
|
+
description: string;
|
|
843
|
+
};
|
|
832
844
|
genreOrder: {
|
|
833
845
|
type: string;
|
|
834
846
|
items: {
|
|
@@ -903,6 +915,7 @@ export declare const STATION_TOOLS: ({
|
|
|
903
915
|
veo?: undefined;
|
|
904
916
|
veoShots?: undefined;
|
|
905
917
|
lipsyncShots?: undefined;
|
|
918
|
+
storyShots?: undefined;
|
|
906
919
|
genreOrder?: undefined;
|
|
907
920
|
server: {
|
|
908
921
|
type: "string";
|
|
@@ -1000,6 +1013,7 @@ export declare const STATION_TOOLS: ({
|
|
|
1000
1013
|
veo?: undefined;
|
|
1001
1014
|
veoShots?: undefined;
|
|
1002
1015
|
lipsyncShots?: undefined;
|
|
1016
|
+
storyShots?: undefined;
|
|
1003
1017
|
genreOrder?: undefined;
|
|
1004
1018
|
state?: undefined;
|
|
1005
1019
|
reason?: undefined;
|
|
@@ -1099,6 +1113,7 @@ export declare const STATION_TOOLS: ({
|
|
|
1099
1113
|
veo?: undefined;
|
|
1100
1114
|
veoShots?: undefined;
|
|
1101
1115
|
lipsyncShots?: undefined;
|
|
1116
|
+
storyShots?: undefined;
|
|
1102
1117
|
genreOrder?: undefined;
|
|
1103
1118
|
state?: undefined;
|
|
1104
1119
|
reason?: undefined;
|
package/dist/station-mcp.js
CHANGED
|
@@ -125,6 +125,7 @@ export const STATION_TOOLS = [
|
|
|
125
125
|
veo: { type: "boolean", description: "Each album's single gets filmed Veo shots of the band (nixamp.com's monthly Veo cap applies)." },
|
|
126
126
|
veoShots: { type: "integer", description: "8 s Veo shots of the band per single (1-8); nobody sings in them." },
|
|
127
127
|
lipsyncShots: { type: "integer", description: "Close-ups of the singer per single, lip-synced to the song on nixamp.com (0-4; 0 = the band only)." },
|
|
128
|
+
storyShots: { type: "integer", description: "Cinematic story scenes per single, a short film from the song's words, nobody singing (0-8)." },
|
|
128
129
|
genreOrder: { type: "array", items: STRING, description: "Genres whose artists get albums first, in order." },
|
|
129
130
|
},
|
|
130
131
|
},
|
|
@@ -244,7 +245,7 @@ export async function callStationTool(name, args, options = {}) {
|
|
|
244
245
|
const key = typeof args["key"] === "string" && /^[a-f0-9]{10}$/.test(args["key"]) ? args["key"] : "";
|
|
245
246
|
const answer = action === "status" ? await call("GET", "/api/v1/stations/label")
|
|
246
247
|
: action === "sign" ? await call("POST", "/api/v1/stations/label/artists", pick(["preset", "roster", "name", "genre", "sound", "voice", "bio", "themes", "enabled"]))
|
|
247
|
-
: action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "veo", "veoShots", "lipsyncShots", "enabled", "publish"]))
|
|
248
|
+
: action === "configure" ? await call("POST", "/api/v1/stations/label", pick(["holder", "monthlySeconds", "everyDays", "seedAlbums", "songsPerAlbum", "songSeconds", "parallel", "genreOrder", "albumsPerDay", "newArtistChance", "resetDay", "veo", "veoShots", "lipsyncShots", "storyShots", "enabled", "publish"]))
|
|
248
249
|
: action === "run" ? await call("POST", "/api/v1/stations/label/run", {})
|
|
249
250
|
: action === "resume" ? await call("POST", "/api/v1/stations/label", { resume: true })
|
|
250
251
|
: !key ? null
|
package/package.json
CHANGED
package/src/ai-label.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { execFile } from "node:child_process";
|
|
30
30
|
import { randomBytes } from "node:crypto";
|
|
31
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
32
32
|
import { dirname, join, resolve, sep } from "node:path";
|
|
33
33
|
import { aiKey } from "./ai-keys.ts";
|
|
34
34
|
import { composerFromEnv, OutOfCredit, type Composer } from "./ai-radio.ts";
|
|
@@ -127,8 +127,10 @@ export interface LabelSettings {
|
|
|
127
127
|
videos: boolean;
|
|
128
128
|
/** Each album's single (song 1) is filmed with Veo, while the month's Veo budget lasts. */
|
|
129
129
|
veo: boolean;
|
|
130
|
-
/** How many 8 s Veo shots of the band a single is cut from. */
|
|
130
|
+
/** How many 8 s Veo shots of the band playing (never the singer) a single is cut from. */
|
|
131
131
|
veoShots: number;
|
|
132
|
+
/** How many 8 s Veo story scenes (a short film from the song's words, nobody singing) a single gets. */
|
|
133
|
+
storyShots: number;
|
|
132
134
|
/** How many close-ups of the singer a single gets, lip-synced to the song (0: none, the band only). */
|
|
133
135
|
lipsyncShots: number;
|
|
134
136
|
/** Public, listed and owned, so the genre stations are on the guide. */
|
|
@@ -385,7 +387,8 @@ export const DEFAULTS: Omit<LabelSettings, "pausedReason"> = {
|
|
|
385
387
|
publish: true,
|
|
386
388
|
videos: true,
|
|
387
389
|
veo: false,
|
|
388
|
-
veoShots:
|
|
390
|
+
veoShots: 2,
|
|
391
|
+
storyShots: 4,
|
|
389
392
|
lipsyncShots: 2,
|
|
390
393
|
parallel: 1,
|
|
391
394
|
genreOrder: ["Punk", "Blues", "Country", "Jazz", "Classic Rock", "Metal", "Hip-Hop", "Pop", "R&B"],
|
|
@@ -626,6 +629,42 @@ export async function writeStoryboard(
|
|
|
626
629
|
return prompts;
|
|
627
630
|
}
|
|
628
631
|
|
|
632
|
+
const VEO_STORY_SYSTEM = [
|
|
633
|
+
"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.",
|
|
634
|
+
"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.",
|
|
635
|
+
"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.",
|
|
636
|
+
"Answer as JSON: {\"scenes\": [\"prompt\", ...]}.",
|
|
637
|
+
].join(" ");
|
|
638
|
+
|
|
639
|
+
/** A single's story scenes for Veo: a short film from its words, nobody singing, people by look not name. */
|
|
640
|
+
export async function writeVeoStory(
|
|
641
|
+
artist: Pick<LabelArtist, "genre" | "sound">,
|
|
642
|
+
members: Array<Pick<Member, "role" | "look">>,
|
|
643
|
+
album: Pick<LabelAlbum, "title" | "concept">,
|
|
644
|
+
song: Pick<LabelSong, "title" | "style" | "lyrics">,
|
|
645
|
+
scenes: number,
|
|
646
|
+
ask: typeof askJson = askJson,
|
|
647
|
+
): Promise<string[]> {
|
|
648
|
+
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("; ");
|
|
649
|
+
const answer = await ask({
|
|
650
|
+
system: VEO_STORY_SYSTEM,
|
|
651
|
+
prompt: [
|
|
652
|
+
`Song: "${song.title}" (${artist.genre}; ${artist.sound}), from "${album.title}"${album.concept ? `: ${album.concept}` : ""}`,
|
|
653
|
+
cast ? `The band may appear as characters, described by look: ${cast}` : "",
|
|
654
|
+
`Mood and arrangement: ${song.style}`,
|
|
655
|
+
song.lyrics ? `Lyrics:\n${song.lyrics.slice(0, 1800)}` : "",
|
|
656
|
+
`Exactly ${scenes} scenes.`,
|
|
657
|
+
].filter(Boolean).join("\n"),
|
|
658
|
+
maxTokens: 2500,
|
|
659
|
+
timeoutMs: 90_000,
|
|
660
|
+
});
|
|
661
|
+
const list = Array.isArray(answer.value["scenes"]) ? answer.value["scenes"] : [];
|
|
662
|
+
const prompts = list.map((one) => clean(one, 400)).filter((one) => one.length > 10).slice(0, scenes);
|
|
663
|
+
if (prompts.length < 1) throw new Error("the model wrote no story");
|
|
664
|
+
return prompts.map((scene) => `Music video for a fictional ${artist.genre} song. ${scene} Mood: ${song.style}`.slice(0, 900)
|
|
665
|
+
+ " Nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look. No text, no logos.");
|
|
666
|
+
}
|
|
667
|
+
|
|
629
668
|
/** Thrown by a Veo dependency when this month's budget is spent: no more shots until next month. */
|
|
630
669
|
export class VeoUnavailable extends Error {}
|
|
631
670
|
|
|
@@ -636,14 +675,12 @@ const TRANSIENT = /\b50[234]\b|fetch failed|ECONN|timed? ?out/i;
|
|
|
636
675
|
// so a mouth in shot is out of sync. The band plays; the vocalist is far off, from
|
|
637
676
|
// behind, in silhouette or between lines, never a close-up of singing.
|
|
638
677
|
const SHOTS = [
|
|
639
|
-
"Wide shot of the whole band performing on a big stage at night, seen from the back of the crowd, the players small against the lights",
|
|
640
678
|
"Close-up of the guitarist's hands on the fretboard and the strings, shallow focus, stage lights flaring",
|
|
641
|
-
"The camera glides behind the band members as they play hard, seen from behind, looking out over the crowd",
|
|
642
679
|
"Low-angle shot of the drummer hitting the cymbals at the song's climax, sticks blurred, sparks of light",
|
|
643
|
-
"
|
|
644
|
-
"
|
|
645
|
-
"
|
|
646
|
-
"
|
|
680
|
+
"Close-up of the bass player's fingers and the amplifier, then a tilt up to the bassist in silhouette",
|
|
681
|
+
"The guitarist and the bassist seen from behind, playing hard toward a huge crowd, backlit",
|
|
682
|
+
"Slow push-in on the drummer in a quiet moment, moody light, sweat and smoke",
|
|
683
|
+
"Slow aerial push over the crowd toward the stage, fans with hands up, the stage tiny in the distance",
|
|
647
684
|
];
|
|
648
685
|
|
|
649
686
|
// The singer's close-ups, which are lip-synced to the song afterwards (fal.ai). The mouth
|
|
@@ -665,7 +702,7 @@ export function veoPrompts(artist: Pick<LabelArtist, "genre" | "sound">, song: P
|
|
|
665
702
|
const cast = members.slice(0, 3).map((one, i) => `the ${one.role} from reference image ${i + 1} (${one.look.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "")})`).join("; ");
|
|
666
703
|
const rule = kind === "singer"
|
|
667
704
|
? "The vocalist sings clearly, the whole mouth always visible, never covered by a microphone or a hand. Cinematic, 35mm film look. No text, no logos."
|
|
668
|
-
: "
|
|
705
|
+
: "The lead vocalist is not in this shot, and nobody sings, speaks or lip-syncs on camera. Cinematic, 35mm film look, dynamic camera. No text, no logos.";
|
|
669
706
|
return (kind === "singer" ? CLOSE_UPS : SHOTS).slice(0, count).map((shot) => [
|
|
670
707
|
`Music video for a fictional ${artist.genre} band. ${shot}.`,
|
|
671
708
|
cast ? `The performers are exactly the people in the reference images: ${cast}.` : "",
|
|
@@ -1106,8 +1143,10 @@ export class AiLabel {
|
|
|
1106
1143
|
if (input["publish"] !== undefined) this.settings.publish = input["publish"] !== false;
|
|
1107
1144
|
if (input["videos"] !== undefined) this.settings.videos = input["videos"] !== false;
|
|
1108
1145
|
if (input["veo"] !== undefined) this.settings.veo = input["veo"] === true;
|
|
1109
|
-
const shots = whole("veoShots",
|
|
1146
|
+
const shots = whole("veoShots", 0, 8);
|
|
1110
1147
|
if (shots !== undefined) this.settings.veoShots = shots;
|
|
1148
|
+
const story = whole("storyShots", 0, 8);
|
|
1149
|
+
if (story !== undefined) this.settings.storyShots = story;
|
|
1111
1150
|
const close = whole("lipsyncShots", 0, 4);
|
|
1112
1151
|
if (close !== undefined) this.settings.lipsyncShots = close;
|
|
1113
1152
|
const perDay = whole("albumsPerDay", 0, 24);
|
|
@@ -1350,8 +1389,9 @@ export class AiLabel {
|
|
|
1350
1389
|
} catch { /* the asked-for length */ }
|
|
1351
1390
|
// Paid-for shots live beside the album until the video is cut, so nothing filmed is filmed twice.
|
|
1352
1391
|
const shots = join(_album.folder, `.veo-${song.n}`);
|
|
1353
|
-
const { band: clips, singer } = await this.filmShots(artist, song, shots, scenes);
|
|
1354
|
-
|
|
1392
|
+
const { band: clips, singer } = await this.filmShots(artist, _album, song, shots, scenes);
|
|
1393
|
+
const wanted = this.settings.veoShots + this.settings.storyShots;
|
|
1394
|
+
if (clips.length < Math.max(2, Math.ceil(wanted / 2))) throw new Error(`only ${clips.length} of ${wanted} shots filmed so far`);
|
|
1355
1395
|
const pinned = await this.syncCloseUps(song, singer, seconds, shots);
|
|
1356
1396
|
await (this.deps.filmedVideo ?? ((input) => renderFilmedVideo(this.deps.ffmpeg, input)))({ audio: song.file, clips, seconds, artist: artist.name, title: song.title, out, ...(pinned.length ? { pinned } : {}) });
|
|
1357
1397
|
rmSync(shots, { recursive: true, force: true });
|
|
@@ -1414,40 +1454,63 @@ export class AiLabel {
|
|
|
1414
1454
|
* Veo's reference images), on stage, in the song's mood. Stops for the
|
|
1415
1455
|
* month when the budget is spent; a shot Veo will not make is skipped.
|
|
1416
1456
|
*/
|
|
1417
|
-
private async filmShots(artist: LabelArtist, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
|
|
1457
|
+
private async filmShots(artist: LabelArtist, album: LabelAlbum, song: LabelSong, keep: string, dir: string): Promise<{ band: Array<{ path: string; seconds: number }>; singer: Array<{ path: string; seconds: number }> }> {
|
|
1418
1458
|
const veo = this.deps.veo;
|
|
1419
1459
|
if (!veo) return { band: [], singer: [] };
|
|
1420
1460
|
mkdirSync(dir, { recursive: true });
|
|
1421
1461
|
mkdirSync(keep, { recursive: true });
|
|
1422
1462
|
const members = this.cast(artist);
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1463
|
+
// The players for the band shots, the voice for the close-ups: a singer's portrait in a band
|
|
1464
|
+
// shot puts the singer at a microphone, singing out of sync.
|
|
1465
|
+
const voice = (member: Member): boolean => /vocal|sing/i.test(member.role);
|
|
1466
|
+
const players = members.filter((member) => !voice(member));
|
|
1467
|
+
const singers = members.filter(voice).length ? members.filter(voice) : members.slice(0, 1);
|
|
1468
|
+
const portraits = async (list: Member[]): Promise<Buffer[]> => {
|
|
1469
|
+
const out: Buffer[] = [];
|
|
1470
|
+
for (const member of list.slice(0, 3)) {
|
|
1471
|
+
const portrait = join(this.folderOf(artist), "members", `${member.slug}.jpg`);
|
|
1472
|
+
const small = join(dir, `ref-${member.slug}.jpg`);
|
|
1473
|
+
try {
|
|
1474
|
+
// 512 px is plenty for a face and keeps the request small.
|
|
1475
|
+
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");
|
|
1476
|
+
out.push(readFileSync(small));
|
|
1477
|
+
} catch {
|
|
1478
|
+
// A member without a portrait is described in words only.
|
|
1479
|
+
}
|
|
1433
1480
|
}
|
|
1434
|
-
|
|
1481
|
+
return out;
|
|
1482
|
+
};
|
|
1483
|
+
const [playerRefs, singerRefs] = [await portraits(players), await portraits(singers)];
|
|
1435
1484
|
const band: Array<{ path: string; seconds: number }> = [];
|
|
1485
|
+
const story: Array<{ path: string; seconds: number }> = [];
|
|
1436
1486
|
const singer: Array<{ path: string; seconds: number }> = [];
|
|
1437
|
-
// The
|
|
1487
|
+
// The story is written only while some of its scenes are still to film.
|
|
1488
|
+
const storyFiles = Array.from({ length: this.settings.storyShots }, (_, i) => join(keep, `story-${i}.mp4`));
|
|
1489
|
+
let storyPrompts: string[] = [];
|
|
1490
|
+
if (storyFiles.some((file) => !existsSync(file)) && this.canAsk()) {
|
|
1491
|
+
try {
|
|
1492
|
+
storyPrompts = await writeVeoStory(artist, members, album, song, storyFiles.length, this.deps.ask ?? askJson);
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
this.log(`no story for "${song.title}" yet: ${(error as Error).message}`);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// The singer's close-ups only where they can be lip-synced afterwards. Story scenes go without
|
|
1498
|
+
// the portraits: they would pull the whole band into every scene.
|
|
1438
1499
|
const plan = [
|
|
1439
|
-
...
|
|
1440
|
-
...
|
|
1500
|
+
...storyFiles.map((file, i) => ({ prompt: storyPrompts[i] ?? "", file, into: story, refs: [] as Buffer[] })),
|
|
1501
|
+
...veoPrompts(artist, song, players, this.settings.veoShots).map((prompt, i) => ({ prompt, file: join(keep, `shot-${i}.mp4`), into: band, refs: playerRefs })),
|
|
1502
|
+
...(this.deps.lipsync ? veoPrompts(artist, song, singers, this.settings.lipsyncShots, "singer").map((prompt, i) => ({ prompt, file: join(keep, `singer-${i}.mp4`), into: singer, refs: singerRefs })) : []),
|
|
1441
1503
|
];
|
|
1442
|
-
for (const [i, { prompt, file, into }] of plan.entries()) {
|
|
1504
|
+
for (const [i, { prompt, file, into, refs }] of plan.entries()) {
|
|
1443
1505
|
try {
|
|
1444
1506
|
if (statSync(file).size > 10_000) { into.push({ path: file, seconds: 8 }); continue; }
|
|
1445
1507
|
} catch { /* not filmed yet */ }
|
|
1508
|
+
if (!prompt) continue;
|
|
1446
1509
|
try {
|
|
1447
1510
|
// nixamp.com briefly away (a deploy: 502/503) or the network: twice more, a minute apart.
|
|
1448
1511
|
let bytes: Buffer | null = null;
|
|
1449
1512
|
for (let attempt = 0; ; attempt++) {
|
|
1450
|
-
try { bytes = await veo({ prompt, seconds: 8, references }); break; } catch (error) {
|
|
1513
|
+
try { bytes = await veo({ prompt, seconds: 8, references: refs }); break; } catch (error) {
|
|
1451
1514
|
if (attempt >= 2 || error instanceof VeoUnavailable || !TRANSIENT.test((error as Error).message)) throw error;
|
|
1452
1515
|
await new Promise((done) => setTimeout(done, this.deps.retryMs ?? 60_000));
|
|
1453
1516
|
}
|
|
@@ -1465,8 +1528,14 @@ export class AiLabel {
|
|
|
1465
1528
|
this.log(`Veo shot ${i + 1} for "${song.title}": ${(error as Error).message}`);
|
|
1466
1529
|
}
|
|
1467
1530
|
}
|
|
1468
|
-
if (band.length || singer.length) this.log(`Veo: ${band.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1469
|
-
|
|
1531
|
+
if (band.length || story.length || singer.length) this.log(`Veo: ${band.length + story.length + singer.length} filmed shots for "${song.title}" (${artist.name})`);
|
|
1532
|
+
// The cut takes them in turn: a story scene, then the band playing, and so on.
|
|
1533
|
+
const mixed: Array<{ path: string; seconds: number }> = [];
|
|
1534
|
+
for (let i = 0; i < Math.max(story.length, band.length); i++) {
|
|
1535
|
+
if (story[i]) mixed.push(story[i]!);
|
|
1536
|
+
if (band[i]) mixed.push(band[i]!);
|
|
1537
|
+
}
|
|
1538
|
+
return { band: mixed, singer };
|
|
1470
1539
|
}
|
|
1471
1540
|
|
|
1472
1541
|
/** The label's music videos as one TV station: free to watch, with its breaks. */
|
package/src/station-cli.ts
CHANGED
|
@@ -507,6 +507,7 @@ async function labelCommand(argv: string[], call: Call, show: Show): Promise<num
|
|
|
507
507
|
veo: has(argv, "--veo") ? true : has(argv, "--no-veo") ? false : undefined,
|
|
508
508
|
veoShots: number("--veo-shots"),
|
|
509
509
|
lipsyncShots: number("--lipsync-shots"),
|
|
510
|
+
storyShots: number("--story-shots"),
|
|
510
511
|
genreOrder: flag(argv, "--genres"),
|
|
511
512
|
enabled: has(argv, "--off") ? false : has(argv, "--on") ? true : undefined,
|
|
512
513
|
publish: has(argv, "--unlisted") ? false : has(argv, "--public") ? true : undefined,
|