@genex-ai/cli-demo 1.36.7-dev.908 → 1.36.8-dev.910

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/README.md CHANGED
@@ -12,6 +12,7 @@ genex list # list your games — slug, status, page link (find t
12
12
  genex preview # build + push to the hosted draft URL (unlisted)
13
13
  genex publish # build + push, then list the game in the gallery
14
14
  genex cover [file] # show the game's cover, or make one real frame (PNG/JPEG/WebP) the cover
15
+ genex cover --clip <file> --commit <sha> # make a recording of real play (MP4/WebM) the cover clip
15
16
  genex make-remixable # make this game remixable — migrate a private source to a public repo
16
17
  genex model "<prompt>" # generate a 3D model → prints an asset URL
17
18
  genex sfx "<prompt>" # generate a sound fx → prints an asset URL
package/dist/index.js CHANGED
@@ -6344,7 +6344,9 @@ async function fetchOwnerProject(apiUrl, token, slug) {
6344
6344
  return {
6345
6345
  status: project.status ?? "draft",
6346
6346
  coverUrl: project.thumbnailUrl ?? null,
6347
- coverSource: "coverSource" in project ? project.coverSource ?? null : void 0
6347
+ coverSource: "coverSource" in project ? project.coverSource ?? null : void 0,
6348
+ clipUrl: project.coverClipUrl ?? null,
6349
+ clipSource: project.coverClipSource ?? null
6348
6350
  };
6349
6351
  } catch {
6350
6352
  return null;
@@ -6525,6 +6527,134 @@ async function uploadCover(args) {
6525
6527
  const view = { coverUrl: body?.coverUrl ?? null, coverSource: body?.coverSource ?? null };
6526
6528
  return body?.applied === false ? { kind: "outranked", ...view } : { kind: "applied", ...view };
6527
6529
  }
6530
+ var CLIP_MAX_BYTES = 8 * 1024 * 1024;
6531
+ var BUILD_COMMIT = /^[0-9a-f]{7,40}$/;
6532
+ function sniffClip(bytes) {
6533
+ if (bytes.byteLength < 16) return null;
6534
+ const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
6535
+ if (b.toString("ascii", 4, 8) === "ftyp") return "video/mp4";
6536
+ if (b.readUInt32BE(0) === 440786851) return "video/webm";
6537
+ return null;
6538
+ }
6539
+ function normaliseBuildCommit(commit) {
6540
+ const sha = commit?.trim().toLowerCase() ?? "";
6541
+ return BUILD_COMMIT.test(sha) ? sha : null;
6542
+ }
6543
+ var CLIP_CLOSED = "nothing was uploaded";
6544
+ function clipRefusal(res, error, what) {
6545
+ const status2 = res.status;
6546
+ const wait = waitWords(res.headers.get("Retry-After"));
6547
+ if (status2 === 401) return "Genex refused this machine's sign-in \u2014 run `npx genex auth`, then try again.";
6548
+ if (error === "cover_clip_off") return `Cover clips are switched off on this Genex stand \u2014 ${CLIP_CLOSED}.`;
6549
+ if (error === "cover_clip_lane_closed") return `This Genex stand doesn't take cover clips from the CLI yet \u2014 ${CLIP_CLOSED}.`;
6550
+ if (status2 === 403) return "Only the game's owner can set its cover clip, and this machine is signed in as someone else.";
6551
+ if (status2 === 404) return "Genex doesn't know this game under this account \u2014 check `.genex/project.json` (or `npx genex link <slug>`).";
6552
+ if (error === "cover_clip_not_a_game") return "This project is an asset page, not a game \u2014 it takes no cover clip.";
6553
+ if (error === "cover_clip_taken_down") return "This game was taken down \u2014 it takes no cover clip.";
6554
+ if (error === "cover_clip_stale_build") {
6555
+ return "That --commit is not a build this game ships \u2014 record the build on its preview (draft) or live (published) channel and pass that build's commit.";
6556
+ }
6557
+ if (status2 === 413) return `That recording is too big for a cover clip (at most ${CLIP_MAX_BYTES / 1024 / 1024} MB).`;
6558
+ if (error === "cover_clip_rate_limited") {
6559
+ return `Too many cover clip uploads on this account for now (rate limited) \u2014 Genex takes the next one ${wait}.`;
6560
+ }
6561
+ if (error === "cover_clip_capacity") return "Today's cover clips are used up (daily cap) \u2014 Genex takes the next one after midnight UTC.";
6562
+ if (error === "cover_clip_busy") return `Genex is busy encoding another clip for this game \u2014 try again ${wait}.`;
6563
+ if (error === "cover_clip_upload_failed") return "Genex couldn't read the uploaded clip yet \u2014 try again.";
6564
+ if (error === "cover_clip_unavailable" || error === "cover_clip_encode_failed") {
6565
+ return "Genex's clip encoder is unavailable right now \u2014 try again in a few minutes.";
6566
+ }
6567
+ return `Couldn't ${what} (HTTP ${status2}) \u2014 try again.`;
6568
+ }
6569
+ async function uploadClip(args) {
6570
+ const { apiUrl, token, projectId, bytes, label } = args;
6571
+ const buildCommit = normaliseBuildCommit(args.buildCommit);
6572
+ if (!buildCommit) return { kind: "invalid", message: `--commit must be the build's git sha (7 to 40 hex characters).` };
6573
+ const type = sniffClip(bytes);
6574
+ if (!type) return { kind: "invalid", message: `${label} is not an MP4 or WebM video.` };
6575
+ if (bytes.byteLength > CLIP_MAX_BYTES) {
6576
+ return { kind: "invalid", message: `${label} is ${mb(bytes.byteLength)}; a cover clip recording can be at most ${CLIP_MAX_BYTES / 1024 / 1024} MB.` };
6577
+ }
6578
+ const headers = { "Content-Type": "application/json", Authorization: `Bearer ${token}` };
6579
+ const base = `${apiUrl}/api/projects/${encodeURIComponent(projectId)}/cover/clip`;
6580
+ const errorOf = async (res) => await res.json().catch(() => null);
6581
+ let minted;
6582
+ try {
6583
+ minted = await apiFetch(`${base}/upload-url`, { method: "POST", headers, body: JSON.stringify({ bytes: bytes.byteLength, contentType: type }) });
6584
+ } catch {
6585
+ return { kind: "failed", message: "Couldn't reach Genex to upload the cover clip." };
6586
+ }
6587
+ if (printedStructuredError(minted)) return { kind: "failed", message: null };
6588
+ if (!minted.ok) return { kind: "failed", message: clipRefusal(minted, (await errorOf(minted))?.error, "start the cover clip upload") };
6589
+ const grant = await minted.json().catch(() => null);
6590
+ if (!grant?.uploadUrl || !grant.key) {
6591
+ return { kind: "failed", message: "Genex answered the cover clip upload unexpectedly \u2014 nothing was uploaded." };
6592
+ }
6593
+ let put;
6594
+ try {
6595
+ put = await fetch(grant.uploadUrl, {
6596
+ method: "PUT",
6597
+ // Both are part of what the URL was signed for; send them back unchanged.
6598
+ headers: { "Content-Type": type, "Content-Length": String(bytes.byteLength) },
6599
+ body: new Uint8Array(bytes)
6600
+ });
6601
+ } catch {
6602
+ return { kind: "failed", message: "The cover clip upload didn't reach storage (network error) \u2014 try again." };
6603
+ }
6604
+ if (!put.ok) return { kind: "failed", message: `The cover clip upload failed (HTTP ${put.status}) \u2014 try again.` };
6605
+ let done;
6606
+ try {
6607
+ done = await apiFetch(`${base}/commit`, { method: "POST", headers, body: JSON.stringify({ key: grant.key, buildCommit }) });
6608
+ } catch {
6609
+ return { kind: "failed", message: "Couldn't reach Genex to finish the cover clip upload." };
6610
+ }
6611
+ if (printedStructuredError(done)) return { kind: "failed", message: null };
6612
+ const body = await done.json().catch(() => null);
6613
+ if (done.status === 422 && body?.error === "cover_clip_rejected") {
6614
+ return { kind: "rejected", reason: body.reason ?? "rejected", stats: body.stats ?? null };
6615
+ }
6616
+ if (!done.ok) return { kind: "failed", message: clipRefusal(done, body?.error, "set the cover clip") };
6617
+ const view = { clipUrl: body?.clipUrl ?? null, clipSource: body?.clipSource ?? null };
6618
+ return body?.applied === false ? { kind: "outranked", ...view } : { kind: "applied", ...view };
6619
+ }
6620
+ var CLIP_SOURCE_LABEL = {
6621
+ owner: "recorded by the owner",
6622
+ agent: "sent from the build (genex cover --clip)",
6623
+ owner_play: "recorded while the owner played"
6624
+ };
6625
+ function describeClipSource(source) {
6626
+ if (source === null) return "none";
6627
+ return CLIP_SOURCE_LABEL[source] ?? source;
6628
+ }
6629
+ var CLIP_REJECTION = {
6630
+ too_dark: "is too dark to read as the game on a card",
6631
+ flat: "is almost one flat colour \u2014 a loading screen or a fade reads as nothing",
6632
+ static: "barely moves \u2014 a frozen or paused screen is not a clip",
6633
+ flashing: "flashes too fast to show anyone (a strobe can hurt whoever watches it)",
6634
+ too_short: "is too short (record 5\u20136 s)",
6635
+ too_long: "is too long (at most 12 s)"
6636
+ };
6637
+ function reportClipOutcome(log, outcome2, opts) {
6638
+ switch (outcome2.kind) {
6639
+ case "applied":
6640
+ log.success(`Cover clip set \u2014 ${opts.label} is this game's cover clip now.`);
6641
+ if (outcome2.clipUrl) log.dim(` ${outcome2.clipUrl}`);
6642
+ return;
6643
+ case "outranked":
6644
+ log.info("Cover clip unchanged \u2014 the owner chose this game's clip themselves (or removed it), and that choice stands. Nothing to retry.");
6645
+ return;
6646
+ case "rejected":
6647
+ log.error(`Cover clip not used \u2014 ${opts.label} ${CLIP_REJECTION[outcome2.reason] ?? `was refused (${outcome2.reason})`}.`);
6648
+ log.dim(" Record 5\u20136 s of real, moving, lit gameplay and run this again \u2014 never change the game for a clip.");
6649
+ return;
6650
+ case "invalid":
6651
+ log.error(`Cover clip not used \u2014 ${outcome2.message}`);
6652
+ return;
6653
+ case "failed":
6654
+ if (outcome2.message) log.error(outcome2.message);
6655
+ return;
6656
+ }
6657
+ }
6528
6658
  var pct = (n) => `${Math.round(n * 100)}%`;
6529
6659
  function reportCoverOutcome(log, outcome2, opts) {
6530
6660
  const fail5 = (m) => opts.asError ? log.error(m) : log.warn(m);
@@ -8824,6 +8954,11 @@ async function runCover(opts) {
8824
8954
  const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
8825
8955
  const token = await readUserToken(opts.envPath, apiUrl);
8826
8956
  if (!token) return fail2("Not signed in. Run `genex auth` first.", opts, log);
8957
+ const clip = opts.clipPath?.trim();
8958
+ if (clip || opts.commit !== void 0) {
8959
+ await sendClip(apiUrl, token, meta.id, clip, opts, log);
8960
+ return;
8961
+ }
8827
8962
  const file = opts.imagePath?.trim();
8828
8963
  if (!file) {
8829
8964
  await showCover(apiUrl, token, meta.slug, opts, log);
@@ -8860,8 +8995,36 @@ async function rememberNomination(bytes, file, outcome2) {
8860
8995
  const stale = await fs24.readFile(scratch).catch(() => null);
8861
8996
  if (stale) await rememberCoverUpload(stale, scratch, outcome2);
8862
8997
  }
8998
+ async function sendClip(apiUrl, token, projectId, clip, opts, log) {
8999
+ const file = clip ?? "";
9000
+ const invalid = (message) => finishClip({ kind: "invalid", message }, file, opts, log);
9001
+ if (!clip) return invalid("--commit goes with --clip <file>: pass the recording to send.");
9002
+ if (opts.imagePath?.trim()) return invalid("Pass either an image (the cover) or --clip <file> (the cover clip), not both.");
9003
+ if (!opts.commit?.trim()) return invalid("--clip needs --commit <sha>: the git commit of the build the recording shows.");
9004
+ let bytes;
9005
+ try {
9006
+ const stat = await fs24.stat(clip);
9007
+ if (!stat.isFile()) throw new Error("not a file");
9008
+ if (stat.size > CLIP_MAX_BYTES) {
9009
+ return invalid(`${clip} is ${(stat.size / 1024 / 1024).toFixed(1)} MB; a cover clip recording can be at most ${CLIP_MAX_BYTES / 1024 / 1024} MB.`);
9010
+ }
9011
+ bytes = await fs24.readFile(clip);
9012
+ } catch {
9013
+ return invalid(`Can't read ${clip} \u2014 pass the path of an MP4 or WebM recording.`);
9014
+ }
9015
+ log.step(`Uploading ${path24.basename(clip)} as the cover clip\u2026`);
9016
+ const outcome2 = await uploadClip({ apiUrl, token, projectId, bytes, label: clip, buildCommit: opts.commit });
9017
+ finishClip(outcome2, clip, opts, log);
9018
+ }
9019
+ function finishClip(outcome2, file, opts, log) {
9020
+ if (opts.json) process.stdout.write(`${JSON.stringify({ file: file || null, ...outcome2 })}
9021
+ `);
9022
+ else reportClipOutcome(log, outcome2, { label: file });
9023
+ if (outcome2.kind !== "applied" && outcome2.kind !== "outranked") process.exitCode = 1;
9024
+ }
8863
9025
  function fail2(message, opts, log) {
8864
- if (opts.json) process.stdout.write(`${JSON.stringify({ file: opts.imagePath?.trim() || null, kind: "failed", message })}
9026
+ const file = opts.clipPath?.trim() || opts.imagePath?.trim() || null;
9027
+ if (opts.json) process.stdout.write(`${JSON.stringify({ file, kind: "failed", message })}
8865
9028
  `);
8866
9029
  else log.error(message);
8867
9030
  process.exitCode = 1;
@@ -8884,7 +9047,8 @@ async function showCover(apiUrl, token, slug, opts, log) {
8884
9047
  const view = await fetchOwnerProject(apiUrl, token, slug);
8885
9048
  if (!view) return fail2("Couldn't read this game from Genex \u2014 check the sign-in (`genex doctor`) and try again.", opts, log);
8886
9049
  if (opts.json) {
8887
- process.stdout.write(`${JSON.stringify({ coverUrl: view.coverUrl, coverSource: view.coverSource ?? null })}
9050
+ const clip = { clipUrl: view.clipUrl, clipSource: view.clipSource };
9051
+ process.stdout.write(`${JSON.stringify({ coverUrl: view.coverUrl, coverSource: view.coverSource ?? null, ...clip })}
8888
9052
  `);
8889
9053
  return;
8890
9054
  }
@@ -8894,6 +9058,10 @@ async function showCover(apiUrl, token, slug, opts, log) {
8894
9058
  log.plain(` cover: ${c.cyan(view.coverUrl)}`);
8895
9059
  log.plain(` source: ${describeCoverSource(view.coverSource)}`);
8896
9060
  }
9061
+ if (view.clipUrl) {
9062
+ log.plain(` clip: ${c.cyan(view.clipUrl)}`);
9063
+ log.plain(` source: ${describeClipSource(view.clipSource)}`);
9064
+ }
8897
9065
  if (!view.coverUrl || coverIsStandIn(view.coverSource)) {
8898
9066
  log.plain("");
8899
9067
  log.dim(` A cover is ${WHAT_A_COVER_IS}.`);
@@ -27463,6 +27631,9 @@ ${c.bold("Usage")}
27463
27631
  on the dashboard is never replaced from here.
27464
27632
  'genex preview' also uploads a new
27465
27633
  .genex/scratch/cover.png by itself.
27634
+ genex cover --clip <file> --commit <sha>
27635
+ Make a 5-6 s recording of real play (MP4/WebM,
27636
+ \u22648 MB) of build <sha> the game's cover clip.
27466
27637
  genex make-remixable [options] Make THIS game remixable by everyone \u2014 migrates a
27467
27638
  private source onto a public managed genex repo.
27468
27639
  genex domain <sub> [host] Play this game on a domain you own:
@@ -27784,7 +27955,10 @@ ${c.bold("Options for `list`")}
27784
27955
 
27785
27956
  ${c.bold("Options for `cover`")}
27786
27957
  <file> The image to make the cover (positional). Omit it to see
27787
- the current cover and where it came from.
27958
+ the current cover and clip, and where they came from.
27959
+ --clip <file> A recording of real play (MP4 or WebM, \u22648 MB) to make the
27960
+ cover clip instead; the server keeps 5 s of it.
27961
+ --commit <sha> With --clip: the build the recording shows (7-40 hex).
27788
27962
  --json One machine-readable result object.
27789
27963
  --api-url <url> Override the API base URL.
27790
27964
  --env <path> Token env file (default: ~/.genex/env).
@@ -27854,6 +28028,7 @@ ${c.bold("Examples")}
27854
28028
  genex publish --categories games,vfx
27855
28029
  genex cover
27856
28030
  genex cover .genex/scratch/cover.png
28031
+ genex cover --clip .genex/scratch/clip.mp4 --commit 3f9c2ab
27857
28032
  genex make-remixable
27858
28033
  genex domain add play.mygame.com
27859
28034
  genex domain list
@@ -28402,7 +28577,10 @@ function parseArgs(argv) {
28402
28577
  "--tri-band",
28403
28578
  "--holder",
28404
28579
  "--summary",
28405
- "--tags"
28580
+ "--tags",
28581
+ // `genex cover --clip <file> --commit <sha>`: the clip and the build it shows.
28582
+ "--clip",
28583
+ "--commit"
28406
28584
  ]);
28407
28585
  let i = 0;
28408
28586
  while (i < argv.length) {
@@ -29087,6 +29265,12 @@ function applyValueFlag(options, flag, value) {
29087
29265
  case "--src":
29088
29266
  options.src = value;
29089
29267
  break;
29268
+ case "--clip":
29269
+ options.clipPath = value;
29270
+ break;
29271
+ case "--commit":
29272
+ options.commit = value;
29273
+ break;
29090
29274
  }
29091
29275
  }
29092
29276
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "1.36.7-dev.908",
3
+ "version": "1.36.8-dev.910",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -351,6 +351,9 @@ must not be there.
351
351
  `npx genex cover .genex/scratch/cover.png` sends it now: exit 0 for set or
352
352
  outranked, 1 otherwise; `--json` prints one object whose `kind` is
353
353
  `applied`, `outranked`, `rejected`, `invalid` or `failed`.
354
+ A cover clip (5–6 s of real play, MP4 or WebM, at most 8 MB) goes up with
355
+ `npx genex cover --clip <file> --commit <sha of the build it shows>`, with
356
+ the same exit codes and kinds; the owner's own clip always outranks it.
354
357
  4. **No browser that can capture the game?** Do not set one up just for this;
355
358
  tell the owner they can set a cover on the game's page.
356
359