@pixelvibing/cli 0.57.1 → 0.59.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.
@@ -418,6 +418,66 @@ function apiAddress(environment = process.env) {
418
418
  if (!PLAIN_PATH.test(url.pathname)) throw new ApiAddressError("PIXELVIBING_API_URL's path may hold only letters, digits, and . _ ~ - /");
419
419
  return url.href.replace(/\/+$/, "");
420
420
  }
421
+ const SFX_SECONDS = { min: 0.5, max: 22 };
422
+ const MAX_SPEECH_CHARACTERS = 500;
423
+ const MAX_AUDIO_DESCRIPTION = 600;
424
+ const VOICES = [
425
+ "Aria",
426
+ "Roger",
427
+ "Sarah",
428
+ "Laura",
429
+ "Charlie",
430
+ "George",
431
+ "Callum",
432
+ "River",
433
+ "Liam",
434
+ "Charlotte",
435
+ "Alice",
436
+ "Matilda",
437
+ "Will",
438
+ "Jessica",
439
+ "Eric",
440
+ "Chris",
441
+ "Brian",
442
+ "Daniel",
443
+ "Lily",
444
+ "Bill"
445
+ ];
446
+ const MUSIC_SECONDS = { min: 3, max: 600, unasked: 60 };
447
+ const SECTION_SECONDS = { min: 3, max: 120 };
448
+ const toMs = (seconds) => Math.round(seconds * 1e3) / 1e3;
449
+ function musicSeconds(body) {
450
+ const sections = body.plan?.sections;
451
+ if (Array.isArray(sections))
452
+ return sections.reduce((sum, each) => sum + (typeof each?.seconds === "number" && Number.isFinite(each.seconds) ? toMs(each.seconds) : 0), 0);
453
+ return typeof body.seconds === "number" && Number.isFinite(body.seconds) ? toMs(body.seconds) : MUSIC_SECONDS.unasked;
454
+ }
455
+ function musicMinutes(body) {
456
+ return Math.max(1, Math.ceil(musicSeconds(body) / 60));
457
+ }
458
+ function musicProblem(body) {
459
+ const described = typeof body.description === "string" && body.description.trim() !== "";
460
+ if (body.plan === void 0) {
461
+ if (!described) return "describe the music, or give a composition plan";
462
+ if (body.seconds !== void 0 && !(typeof body.seconds === "number" && body.seconds >= MUSIC_SECONDS.min && body.seconds <= MUSIC_SECONDS.max))
463
+ return `music runs ${MUSIC_SECONDS.min} to ${MUSIC_SECONDS.max} seconds`;
464
+ return null;
465
+ }
466
+ if (body.description !== void 0) return "music takes a description or a composition plan, not both";
467
+ if (body.seconds !== void 0 || body.instrumental !== void 0)
468
+ return "a composition plan's length is its sections', and its lyrics say whether it is sung";
469
+ const sections = body.plan.sections;
470
+ if (!Array.isArray(sections) || sections.length === 0) return "a composition plan has at least one section";
471
+ for (const section2 of sections) {
472
+ const { name, seconds: seconds2 } = typeof section2 === "object" && section2 !== null ? section2 : {};
473
+ if (typeof name !== "string" || name.trim() === "") return "each section has a name";
474
+ if (!(typeof seconds2 === "number" && seconds2 >= SECTION_SECONDS.min && seconds2 <= SECTION_SECONDS.max))
475
+ return `a section runs ${SECTION_SECONDS.min} to ${SECTION_SECONDS.max} seconds, not ${String(seconds2)}`;
476
+ }
477
+ const seconds = musicSeconds(body);
478
+ if (seconds > MUSIC_SECONDS.max) return `a composition plan runs at most ${MUSIC_SECONDS.max} seconds in all, not ${seconds}`;
479
+ return null;
480
+ }
421
481
  class GuideConflict extends Error {
422
482
  constructor(current) {
423
483
  super(`the style guide is now at version ${current}`);
@@ -433,6 +493,12 @@ class TooFewCredits extends Error {
433
493
  this.credits = credits;
434
494
  }
435
495
  }
496
+ function costIn(prices, kind, body) {
497
+ const price = prices.kinds[kind];
498
+ if (price === void 0) return null;
499
+ if ("credits" in price) return price.credits;
500
+ return price.perMinute !== void 0 && body !== void 0 ? price.perMinute * musicMinutes(body) : price.upTo;
501
+ }
436
502
  const id = (value) => encodeURIComponent(value);
437
503
  function createWebApi(fetcher = (input, init2) => fetch(input, init2), base = API_BASE) {
438
504
  const call = createCaller(fetcher, base);
@@ -447,9 +513,10 @@ function createWebApi(fetcher = (input, init2) => fetch(input, init2), base = AP
447
513
  throw error;
448
514
  }
449
515
  };
516
+ const address = new URL(base, globalThis.location?.origin ?? "http://localhost").href.replace(/\/+$/, "");
450
517
  return {
451
518
  /** The API's own address, whole, for the MCP server's next to it (specs/web-app/ R2.14). */
452
- address: new URL(base, globalThis.location?.origin ?? "http://localhost").href.replace(/\/+$/, ""),
519
+ address,
453
520
  /** The project's own token, to the signed-in person alone (specs/accounts-and-sign-in/ R3.12). */
454
521
  projectToken: (projectId) => json2(`/projects/${id(projectId)}/token`),
455
522
  projects: (before = null) => json2(`/projects${before === null ? "" : `?before=${id(before)}`}`),
@@ -524,6 +591,8 @@ function createWebApi(fetcher = (input, init2) => fetch(input, init2), base = AP
524
591
  redo: (jobId) => paying2(async () => (await json2(`/jobs/${id(jobId)}/redo`, { method: "POST" })).id),
525
592
  /** Cancel a job no worker has taken, answering it and the credits that came back; 409 once one has (specs/generation-jobs/ R5). */
526
593
  cancelJob: (jobId) => json2(`/jobs/${id(jobId)}/cancel`, { method: "POST" }),
594
+ /** The address of a project's job changes, as server-sent events (specs/live-generations/ R1.3). */
595
+ jobEventsUrl: (projectId) => `${address}/jobs/events?project=${id(projectId)}`,
527
596
  jobs: (projectId, before = null, kinds = null) => json2(
528
597
  `/jobs?project=${id(projectId)}${before === null ? "" : `&before=${id(before)}`}${kinds === null ? "" : `&kinds=${kinds.map(id).join(",")}`}`
529
598
  ),
@@ -4887,12 +4956,14 @@ function shareWords(credits, limit) {
4887
4956
  function leftWords(usage) {
4888
4957
  return `${100 - usedPercent(usage)}% of your limit left`;
4889
4958
  }
4890
- function costShareWords(prices, kind, usage) {
4959
+ function costShareWords(prices, kind, usage, body) {
4891
4960
  const price = prices.kinds[kind];
4892
- if (price === void 0) return null;
4893
- const most = "credits" in price ? price.credits : price.upTo;
4961
+ const most = costIn(prices, kind, body);
4962
+ if (price === void 0 || most === null) return null;
4894
4963
  const share = `${shareWords(most, usage.limit)} of your limit`;
4895
- return "credits" in price ? share : `up to ${share}, charged for what it uses once done`;
4964
+ if ("credits" in price) return share;
4965
+ if (price.perMinute !== void 0 && body !== void 0) return `${share}, by the minute of its length`;
4966
+ return `up to ${share}, charged for what it uses once done`;
4896
4967
  }
4897
4968
  const LARGEST_FOOTPRINT = {
4898
4969
  cols: Math.min(Math.floor(WINDOW_PX.width / SCALES.at(-1)), Math.floor(MAX_ASSEMBLED_PX / TILE_SIZE)),
@@ -4914,31 +4985,6 @@ function drawCanvas(footprint) {
4914
4985
  if (pxPerTile === void 0) return null;
4915
4986
  return { width: footprint.cols * pxPerTile, height: footprint.rows * pxPerTile, pxPerTile };
4916
4987
  }
4917
- const SFX_SECONDS = { min: 0.5, max: 22 };
4918
- const MAX_SPEECH_CHARACTERS = 500;
4919
- const MAX_AUDIO_DESCRIPTION = 600;
4920
- const VOICES = [
4921
- "Aria",
4922
- "Roger",
4923
- "Sarah",
4924
- "Laura",
4925
- "Charlie",
4926
- "George",
4927
- "Callum",
4928
- "River",
4929
- "Liam",
4930
- "Charlotte",
4931
- "Alice",
4932
- "Matilda",
4933
- "Will",
4934
- "Jessica",
4935
- "Eric",
4936
- "Chris",
4937
- "Brian",
4938
- "Daniel",
4939
- "Lily",
4940
- "Bill"
4941
- ];
4942
4988
  const PATCH_CORNERS = [
4943
4989
  [1, 0, 0, 0, 0],
4944
4990
  [1, 0, 0, 1, 0],
@@ -5774,8 +5820,10 @@ const OWN_OPTIONS = {
5774
5820
  terrain: ["terrain-animation"],
5775
5821
  text: ["speech"],
5776
5822
  voice: ["speech"],
5777
- seconds: ["sfx"],
5823
+ seconds: ["sfx", "music"],
5778
5824
  loop: ["sfx"],
5825
+ instrumental: ["music"],
5826
+ plan: ["music"],
5779
5827
  out: ["sfx", "speech", "music", "vfx"],
5780
5828
  preset: ["vfx"],
5781
5829
  part: ["vfx"],
@@ -5816,8 +5864,18 @@ const MORE_OPTIONS = {
5816
5864
  terrain: { type: "string", value: "<asset id|name>", description: "the terrain set an animated terrain set moves" },
5817
5865
  text: { type: "string", value: "<text>", description: "what speech says" },
5818
5866
  voice: { type: "string", value: "<voice>", description: `the voice speech is said in: ${VOICES.join(", ")}` },
5819
- seconds: { type: "string", value: "<seconds>", description: `a sound effect's length, ${SFX_SECONDS.min} to ${SFX_SECONDS.max}` },
5867
+ seconds: {
5868
+ type: "string",
5869
+ value: "<seconds>",
5870
+ description: `a sound effect's length, ${SFX_SECONDS.min} to ${SFX_SECONDS.max}; music's, ${MUSIC_SECONDS.min} to ${MUSIC_SECONDS.max}, ${MUSIC_SECONDS.unasked} unless given`
5871
+ },
5820
5872
  loop: { type: "boolean", description: "a sound effect that loops seamlessly" },
5873
+ instrumental: { type: "boolean", description: "music with no voice" },
5874
+ plan: {
5875
+ type: "string",
5876
+ value: "<json>",
5877
+ description: `music section by section, instead of --instruction: a JSON file of { styles, avoid, sections: [{ name, seconds (${SECTION_SECONDS.min}-${SECTION_SECONDS.max}), styles, avoid, lyrics }], flexibleLengths }, at most ${MUSIC_SECONDS.max} seconds in all`
5878
+ },
5821
5879
  out: {
5822
5880
  type: "string",
5823
5881
  value: "<path>",
@@ -5992,9 +6050,9 @@ async function moreRequest(context, kind, args, instruction, reference) {
5992
6050
  if (!VOICES.includes(voice)) throw new CliError(`--voice is one of ${VOICES.join(", ")}`);
5993
6051
  return { kind, body: { text: text2, voice }, words: `${words} in ${voice}: ${text2}` };
5994
6052
  }
6053
+ if (kind === "music") return musicRequestOf(context, args, instruction);
5995
6054
  if (instruction === "") throw new CliError(`describe ${words} with --instruction`);
5996
6055
  if (instruction.length > MAX_AUDIO_DESCRIPTION) throw new CliError(`a description is at most ${MAX_AUDIO_DESCRIPTION} characters`);
5997
- if (kind === "music") return { kind, body: { description: instruction }, words: `${words}: ${instruction}` };
5998
6056
  const seconds = stringOf(args, "seconds");
5999
6057
  const length = seconds === void 0 ? void 0 : Number(seconds);
6000
6058
  if (length !== void 0 && !(length >= SFX_SECONDS.min && length <= SFX_SECONDS.max))
@@ -6005,6 +6063,54 @@ async function moreRequest(context, kind, args, instruction, reference) {
6005
6063
  words: `${words}${length === void 0 ? "" : ` of ${length} s`}${flagOf(args, "loop") ? ", looping" : ""}: ${instruction}`
6006
6064
  };
6007
6065
  }
6066
+ const MAX_PLAN_BYTES = 128 * 1024;
6067
+ async function planOf(context, path) {
6068
+ const file = resolve(context.io.cwd, path);
6069
+ let text2;
6070
+ try {
6071
+ const found = await stat(file);
6072
+ if (!found.isFile()) throw new CliError(`the plan ${path} is not a file`);
6073
+ if (found.size > MAX_PLAN_BYTES) throw new CliError(`the plan ${path} is larger than ${MAX_PLAN_BYTES} bytes`);
6074
+ text2 = await readFile(file, "utf8");
6075
+ } catch (error) {
6076
+ if (error instanceof CliError) throw error;
6077
+ throw new CliError(`the plan ${path} cannot be read`);
6078
+ }
6079
+ let plan;
6080
+ try {
6081
+ plan = JSON.parse(text2);
6082
+ } catch {
6083
+ throw new CliError(`the plan ${path} is not JSON`);
6084
+ }
6085
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
6086
+ throw new CliError(`the plan ${path} is not a plan: { "styles": [...], "sections": [{ "name": "intro", "seconds": 20 }] }`);
6087
+ return plan;
6088
+ }
6089
+ async function musicRequestOf(context, args, instruction) {
6090
+ const path = stringOf(args, "plan");
6091
+ const given2 = stringOf(args, "seconds");
6092
+ if (path !== void 0) {
6093
+ if (instruction !== "") throw new CliError("music takes --instruction or --plan, not both");
6094
+ if (given2 !== void 0 || flagOf(args, "instrumental"))
6095
+ throw new CliError("a plan's length is its sections', and its lyrics say whether it is sung: --seconds and --instrumental are not a plan's");
6096
+ const body = { plan: await planOf(context, path) };
6097
+ const problem = musicProblem(body);
6098
+ if (problem !== null) throw new CliError(`the plan ${path}: ${problem}`);
6099
+ const sections = body.plan.sections.map((section2) => String(section2.name)).join(", ");
6100
+ return { kind: "music", body, words: `music of ${musicMinutes(body)} min from ${path}: ${sections}` };
6101
+ }
6102
+ if (instruction === "") throw new CliError("describe the music with --instruction, or give its sections with --plan");
6103
+ if (instruction.length > MAX_AUDIO_DESCRIPTION) throw new CliError(`a description is at most ${MAX_AUDIO_DESCRIPTION} characters`);
6104
+ const seconds = given2 === void 0 ? void 0 : Number(given2);
6105
+ if (seconds !== void 0 && !(seconds >= MUSIC_SECONDS.min && seconds <= MUSIC_SECONDS.max))
6106
+ throw new CliError(`--seconds is ${MUSIC_SECONDS.min} to ${MUSIC_SECONDS.max} for music`);
6107
+ const instrumental = flagOf(args, "instrumental");
6108
+ return {
6109
+ kind: "music",
6110
+ body: { description: instruction, ...seconds === void 0 ? {} : { seconds }, ...instrumental ? { instrumental: true } : {} },
6111
+ words: `music of ${seconds ?? MUSIC_SECONDS.unasked} s${instrumental ? ", instrumental" : ""}: ${instruction}`
6112
+ };
6113
+ }
6008
6114
  async function withPatch(platform, project, request) {
6009
6115
  const { terrain, ...body } = request.body;
6010
6116
  const asset = await platform.web.asset(project, terrain);
@@ -6300,9 +6406,9 @@ async function shapedRequest(context, asked, args) {
6300
6406
  if (stringOf(args, "tiles") !== void 0) throw new CliError("--tiles is a pixel-art concept art's");
6301
6407
  return { kind, body, ...references.length > 0 ? { images: { references } } : {}, words };
6302
6408
  }
6303
- async function confirmed(context, platform, what, prices, kind, yes) {
6409
+ async function confirmed(context, platform, what, prices, kind, yes, body) {
6304
6410
  const usage = usageOf$1(await platform.web.billing());
6305
- const share = costShareWords(prices, kind, usage);
6411
+ const share = costShareWords(prices, kind, usage, body);
6306
6412
  const priced = share === null ? "using what the platform will say of your limit" : `using ${share}`;
6307
6413
  context.io.warn(`Making ${what}, ${priced}; ${leftWords(usage)}.
6308
6414
  `);
@@ -6328,7 +6434,7 @@ async function followed(context, platform, jobId) {
6328
6434
  const generate = {
6329
6435
  name: "generate",
6330
6436
  summary: "Generate an asset, a change of a picture, terrain, a sound or a visual effect on the platform, then pull it, keep it or write it",
6331
- usage: "<object|creature|concept-art|icons|ui|variant|edit|animate|pose|rotate|state|terrain|terrain-animation|sfx|speech|music|vfx> [--instruction <text>] [--source <image id>] [--state <state>] [--into <sprite> --name <name>] [--upper <ground> --lower <ground>] [--terrain <asset>] [--text <text> --voice <voice>] [--preset <preset> | --part <part>…] [--subject <image id>] [--out <path>] [--size <c>x<r>|<w>x<h>|<px>] [--transparent] [--pixel-art] [--tiles <c>x<r>] [--tag <tag>]… [--mask <png>] [--names <list>] [--purpose skill|item] [--sketch <json>] [--reference <png>]… [--no-enrich] [--yes]",
6437
+ usage: "<object|creature|concept-art|icons|ui|variant|edit|animate|pose|rotate|state|terrain|terrain-animation|sfx|speech|music|vfx> [--instruction <text>] [--source <image id>] [--state <state>] [--into <sprite> --name <name>] [--upper <ground> --lower <ground>] [--terrain <asset>] [--text <text> --voice <voice>] [--seconds <s>] [--loop] [--instrumental] [--plan <json>] [--preset <preset> | --part <part>…] [--subject <image id>] [--out <path>] [--size <c>x<r>|<w>x<h>|<px>] [--transparent] [--pixel-art] [--tiles <c>x<r>] [--tag <tag>]… [--mask <png>] [--names <list>] [--purpose skill|item] [--sketch <json>] [--reference <png>]… [--no-enrich] [--yes]",
6332
6438
  options: {
6333
6439
  instruction: {
6334
6440
  type: "string",
@@ -6384,7 +6490,7 @@ const generate = {
6384
6490
  const platform = await signedIn(context);
6385
6491
  const request = asked.kind === "terrain-animation" ? await withPatch(platform, project, asked) : asked;
6386
6492
  const prices = await platform.web.prices();
6387
- const uses = await confirmed(context, platform, request.words, prices, request.kind, flagOf(args, "yes"));
6493
+ const uses = await confirmed(context, platform, request.words, prices, request.kind, flagOf(args, "yes"), request.body);
6388
6494
  const jobId = await paying(platform, () => startGeneration(platform.web, project, request));
6389
6495
  context.io.warn(`Started the job ${jobId}.
6390
6496
  `);
@@ -7374,7 +7480,7 @@ function terminalOf(streams) {
7374
7480
  step: (title, at = "step") => drawStep(streams.stderr, title, at)
7375
7481
  };
7376
7482
  }
7377
- const version = "0.57.1";
7483
+ const version = "0.59.0";
7378
7484
  const manifest = {
7379
7485
  version
7380
7486
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelvibing/cli",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "description": "PixelVibing: pixel art game assets from the command line, and the Skills that teach coding assistants to make them.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://pixelvibing.com",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pixelvibing-sound
3
- description: Generate sounds for this game on PixelVibing — a sound effect such as a coin picked up or a door creaking, a line of speech in a voice, or a piece of music — with pixel-vibing-cli or the PixelVibing MCP server, written as MP3 files. Use when the game needs audio.
3
+ description: Generate sounds for this game on PixelVibing — a sound effect such as a coin picked up or a door creaking, a line of speech in a voice, or a piece of music up to ten minutes long, from a description or section by section — with pixel-vibing-cli or the PixelVibing MCP server, written as MP3 files. Use when the game needs audio.
4
4
  ---
5
5
 
6
6
  # Sound
@@ -14,7 +14,23 @@ Load the `pixelvibing` Skill first: who is signed in, the limit.
14
14
  ```bash
15
15
  pixel-vibing-cli generate sfx --instruction "<what it sounds like>" [--seconds <s>] [--loop] [--out <file>] --yes --json
16
16
  pixel-vibing-cli generate speech --text "<what is said>" --voice <voice> [--out <file>] --yes --json
17
- pixel-vibing-cli generate music --instruction "<the piece>" [--out <file>] --yes --json
17
+ pixel-vibing-cli generate music --instruction "<the piece>" [--seconds <3-600>] [--instrumental] [--out <file>] --yes --json
18
+ pixel-vibing-cli generate music --plan <plan.json> [--out <file>] --yes --json
19
+ ```
20
+
21
+ A plan's file is the track section by section, in order:
22
+
23
+ ```json
24
+ {
25
+ "styles": ["chiptune", "heroic", "140 bpm"],
26
+ "avoid": ["vocals"],
27
+ "sections": [
28
+ { "name": "intro", "seconds": 15, "styles": ["soft pads"] },
29
+ { "name": "theme", "seconds": 90, "lyrics": ["Sail on", "the harbour waits"] },
30
+ { "name": "outro", "seconds": 20, "avoid": ["drums"] }
31
+ ],
32
+ "flexibleLengths": false
33
+ }
18
34
  ```
19
35
 
20
36
  Each writes its sound as an MP3 into `pixelvibing/sounds/`, or to the file `--out` names, once
@@ -25,7 +41,8 @@ its job is done. `pixel-vibing-cli generate --help` lists the voices.
25
41
  ```json
26
42
  { "project": "<id>", "kind": "sfx", "description": "a coin picked up, bright and short", "seconds": 1, "max_percent": 30 }
27
43
  { "project": "<id>", "kind": "speech", "text": "Welcome aboard, traveller.", "voice": "<voice>", "max_percent": 30 }
28
- { "project": "<id>", "kind": "music", "description": "a calm harbour theme on strings", "max_percent": 30 }
44
+ { "project": "<id>", "kind": "music", "description": "a calm harbour theme on strings", "seconds": 180, "instrumental": true, "max_percent": 30 }
45
+ { "project": "<id>", "kind": "music", "plan": { "styles": ["chiptune"], "sections": [{ "name": "intro", "seconds": 15 }, { "name": "loop", "seconds": 90 }] }, "max_percent": 30 }
29
46
  ```
30
47
 
31
48
  with the tool `generate_sound`. `get_job` follows the job and, once done, names its `sounds`;
@@ -39,7 +56,17 @@ with the tool `generate_sound`. `get_job` follows the job and, once done, names
39
56
  - **Speech.** The words exactly as they are said, in a voice. Keep a line short; one line a
40
57
  generation.
41
58
  - **Music.** The mood, the instruments and the tempo: "a calm harbour theme on strings and a
42
- soft accordion".
59
+ soft accordion". It runs 60 seconds unless `--seconds` (`seconds`) says otherwise, from 3 to
60
+ 600. A game's theme, a level's loop or a menu track is asked at its full length: a short one
61
+ ends where it stops. `--instrumental` (`instrumental`) makes one with no voice.
62
+ - **Music section by section.** Give a plan instead of a description when the track has parts
63
+ that must land in order: an intro, a loop, a boss phase, an ending. Each section is 3 to 120
64
+ seconds, with its own styles, styles to avoid, and lyrics for its sung lines (none for an
65
+ instrumental part); the track is at most 600 seconds in all. `flexibleLengths`
66
+ (`flexible_lengths` on the MCP server) lets each section's length move while the track's stays.
67
+ - **What music uses.** Music uses the limit by the minute of its length, rounded up: a
68
+ four-minute theme uses four times a one-minute one. Say so before asking for a long track, and
69
+ keep `max_percent` to what the person agreed.
43
70
  - **Original sound.** Never another game's music or theme, and never a real person's voice:
44
71
  when the person names one, make original sound in its spirit and say so.
45
72
  - **Where it goes.** The game loads the MP3 from where it was written; copy it into the game's