@genex-ai/cli-demo 0.70.0-dev.182 → 0.71.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/index.js +8 -354
- package/package.json +1 -2
- package/templates/controllers/character/vrm/vrm-loader.ts +11 -74
- package/templates/skills/genex-ai-menu/SKILL.md +1 -7
- package/templates/skills/genex-ai-skybox/SKILL.md +4 -15
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +1 -1
- package/templates/skills/genex-explore/SKILL.md +1 -1
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-threejs-bloom/SKILL.md +1 -4
- package/templates/skills/genex-threejs-bloom/references/bloom.md +1 -1
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +8 -48
- package/templates/skills/genex-threejs-camera-direction/references/camera-rigs.md +0 -62
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +1 -4
- package/templates/skills/genex-threejs-game-feel/SKILL.md +1 -4
- package/templates/skills/genex-threejs-game-ui/SKILL.md +8 -42
- package/templates/skills/genex-threejs-image-pipeline/SKILL.md +0 -5
- package/templates/skills/genex-threejs-image-pipeline/references/image-pipeline.md +1 -1
- package/templates/skills/genex-threejs-lighting-design/SKILL.md +1 -5
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +1 -7
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +3 -6
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +0 -1
- package/templates/skills/genex-threejs-screen-space-ambient-occlusion/references/ambient-occlusion.md +1 -1
- package/templates/skills/genex-threejs-shadow-systems/SKILL.md +0 -6
- package/templates/skills/genex-threejs-shadow-systems/references/shadow-systems.md +1 -1
- package/templates/skills/genex-threejs-skill-router/SKILL.md +5 -26
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +7 -21
- package/templates/skills/genex-threejs-spectral-ocean/references/spectral-ocean.md +1 -1
- package/templates/skills/genex-threejs-touch-controls/SKILL.md +0 -11
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +12 -29
- package/templates/skills/genex-threejs-water-optics/references/water-optics.md +1 -1
- package/templates/skills/genex-updates/SKILL.md +1 -1
- package/templates/controllers/quality/governor.ts +0 -147
- package/templates/controllers/quality/pick-asset.ts +0 -57
- package/templates/controllers/quality/tier.ts +0 -170
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +0 -141
- package/templates/skills/genex-threejs-adaptive-quality/references/adaptive-quality.md +0 -105
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from "fs";
|
|
|
8
8
|
import os from "os";
|
|
9
9
|
import path from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
|
-
var RAW_CHANNEL = "
|
|
11
|
+
var RAW_CHANNEL = "latest";
|
|
12
12
|
var CLI_CHANNEL = RAW_CHANNEL === "dev" ? "dev" : "latest";
|
|
13
13
|
var STANDS = {
|
|
14
14
|
prod: { api: "https://api.genex.games", dashboard: "https://genex.games" },
|
|
@@ -1497,261 +1497,6 @@ import crypto3 from "crypto";
|
|
|
1497
1497
|
import fs9 from "fs/promises";
|
|
1498
1498
|
import os4 from "os";
|
|
1499
1499
|
import path10 from "path";
|
|
1500
|
-
|
|
1501
|
-
// ../../packages/mobile-scan/src/image-dims.ts
|
|
1502
|
-
function u32be(b, o) {
|
|
1503
|
-
return (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
|
|
1504
|
-
}
|
|
1505
|
-
function u32le(b, o) {
|
|
1506
|
-
return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
|
|
1507
|
-
}
|
|
1508
|
-
function u16be(b, o) {
|
|
1509
|
-
return b[o] << 8 | b[o + 1];
|
|
1510
|
-
}
|
|
1511
|
-
function u16le(b, o) {
|
|
1512
|
-
return b[o] | b[o + 1] << 8;
|
|
1513
|
-
}
|
|
1514
|
-
function isPng(b) {
|
|
1515
|
-
return b.length > 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10;
|
|
1516
|
-
}
|
|
1517
|
-
var KTX2_MAGIC = [171, 75, 84, 88, 32, 50, 48, 187, 13, 10, 26, 10];
|
|
1518
|
-
function parseImageDims(bytes) {
|
|
1519
|
-
if (bytes.length < 30) return null;
|
|
1520
|
-
if (isPng(bytes)) {
|
|
1521
|
-
return { width: u32be(bytes, 16), height: u32be(bytes, 20) };
|
|
1522
|
-
}
|
|
1523
|
-
if (bytes[0] === 255 && bytes[1] === 216) {
|
|
1524
|
-
let i = 2;
|
|
1525
|
-
while (i + 9 < bytes.length) {
|
|
1526
|
-
if (bytes[i] !== 255) {
|
|
1527
|
-
i++;
|
|
1528
|
-
continue;
|
|
1529
|
-
}
|
|
1530
|
-
const marker = bytes[i + 1];
|
|
1531
|
-
if (marker === 255) {
|
|
1532
|
-
i++;
|
|
1533
|
-
continue;
|
|
1534
|
-
}
|
|
1535
|
-
if (marker === 216 || marker >= 208 && marker <= 217) {
|
|
1536
|
-
i += 2;
|
|
1537
|
-
continue;
|
|
1538
|
-
}
|
|
1539
|
-
const isSof = marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
|
|
1540
|
-
if (isSof) {
|
|
1541
|
-
return { width: u16be(bytes, i + 7), height: u16be(bytes, i + 5) };
|
|
1542
|
-
}
|
|
1543
|
-
i += 2 + u16be(bytes, i + 2);
|
|
1544
|
-
}
|
|
1545
|
-
return null;
|
|
1546
|
-
}
|
|
1547
|
-
if (bytes[0] === 82 && // RIFF….WEBP
|
|
1548
|
-
bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
|
|
1549
|
-
const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
|
|
1550
|
-
if (fourcc === "VP8X" && bytes.length >= 30) {
|
|
1551
|
-
const w = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
|
|
1552
|
-
const h = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
|
|
1553
|
-
return { width: w, height: h };
|
|
1554
|
-
}
|
|
1555
|
-
if (fourcc === "VP8 " && bytes.length >= 30) {
|
|
1556
|
-
return { width: u16le(bytes, 26) & 16383, height: u16le(bytes, 28) & 16383 };
|
|
1557
|
-
}
|
|
1558
|
-
if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
|
|
1559
|
-
const b0 = bytes[21];
|
|
1560
|
-
const b1 = bytes[22];
|
|
1561
|
-
const b2 = bytes[23];
|
|
1562
|
-
const b3 = bytes[24];
|
|
1563
|
-
const width = 1 + ((b1 & 63) << 8 | b0);
|
|
1564
|
-
const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
|
|
1565
|
-
return { width, height };
|
|
1566
|
-
}
|
|
1567
|
-
return null;
|
|
1568
|
-
}
|
|
1569
|
-
if (KTX2_MAGIC.every((v, idx) => bytes[idx] === v)) {
|
|
1570
|
-
return { width: u32le(bytes, 20), height: u32le(bytes, 24), ktx2: true };
|
|
1571
|
-
}
|
|
1572
|
-
return null;
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
// ../../packages/mobile-scan/src/glb.ts
|
|
1576
|
-
function u32le2(b, o) {
|
|
1577
|
-
return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
|
|
1578
|
-
}
|
|
1579
|
-
function scanGlb(bytes) {
|
|
1580
|
-
if (bytes.length < 20 || u32le2(bytes, 0) !== 1179937895) return null;
|
|
1581
|
-
try {
|
|
1582
|
-
const jsonLen = u32le2(bytes, 12);
|
|
1583
|
-
if (u32le2(bytes, 16) !== 1313821514) return null;
|
|
1584
|
-
const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLen)));
|
|
1585
|
-
let bin = null;
|
|
1586
|
-
const binHeader = 20 + jsonLen;
|
|
1587
|
-
if (binHeader + 8 <= bytes.length && u32le2(bytes, binHeader + 4) === 5130562) {
|
|
1588
|
-
bin = bytes.subarray(binHeader + 8, binHeader + 8 + u32le2(bytes, binHeader));
|
|
1589
|
-
}
|
|
1590
|
-
const images = [];
|
|
1591
|
-
for (const img of json.images ?? []) {
|
|
1592
|
-
if (img.bufferView == null || !bin) continue;
|
|
1593
|
-
const view = json.bufferViews?.[img.bufferView];
|
|
1594
|
-
if (!view?.byteLength) continue;
|
|
1595
|
-
const slice = bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength);
|
|
1596
|
-
const dims = parseImageDims(slice);
|
|
1597
|
-
if (dims) images.push({ ...dims, mimeType: img.mimeType, bytes: view.byteLength });
|
|
1598
|
-
}
|
|
1599
|
-
let triangles = 0;
|
|
1600
|
-
for (const mesh of json.meshes ?? []) {
|
|
1601
|
-
for (const prim of mesh.primitives ?? []) {
|
|
1602
|
-
const accessor = prim.indices != null ? json.accessors?.[prim.indices] : json.accessors?.[prim.attributes?.POSITION ?? -1];
|
|
1603
|
-
if (accessor?.count) triangles += Math.floor(accessor.count / 3);
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
return {
|
|
1607
|
-
triangles,
|
|
1608
|
-
binBytes: bin?.byteLength ?? 0,
|
|
1609
|
-
images,
|
|
1610
|
-
extensions: json.extensionsUsed ?? []
|
|
1611
|
-
};
|
|
1612
|
-
} catch {
|
|
1613
|
-
return null;
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
// ../../packages/mobile-scan/src/scan.ts
|
|
1618
|
-
var GENEX_GENERATION_URL_RE = /https:\/\/assets\.genex\.technology\/generations\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._@-]+)/g;
|
|
1619
|
-
var SKYBOX_ROLE = "skybox-equirect";
|
|
1620
|
-
var MOBILE_RUNG_SUFFIX_RE = /@(\d+)$/;
|
|
1621
|
-
var MOBILE_BUDGET_MB = { broad: 300, modern: 700 };
|
|
1622
|
-
var TEXT_EXT = /\.(js|mjs|cjs|html|css|json|txt)$/i;
|
|
1623
|
-
var IMAGE_EXT = /\.(png|jpe?g|webp|ktx2)$/i;
|
|
1624
|
-
var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
1625
|
-
var AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
|
1626
|
-
var GLB_EXT = /\.(glb|gltf)$/i;
|
|
1627
|
-
function scanBundle(files) {
|
|
1628
|
-
const images = [];
|
|
1629
|
-
const glbs = [];
|
|
1630
|
-
const videos = [];
|
|
1631
|
-
const audio = [];
|
|
1632
|
-
const urls = /* @__PURE__ */ new Set();
|
|
1633
|
-
let text = "";
|
|
1634
|
-
let totalBytes = 0;
|
|
1635
|
-
for (const f of files) {
|
|
1636
|
-
totalBytes += f.bytes.byteLength;
|
|
1637
|
-
if (IMAGE_EXT.test(f.relPath)) {
|
|
1638
|
-
const dims = parseImageDims(f.bytes);
|
|
1639
|
-
if (dims) images.push({ path: f.relPath, bytes: f.bytes.byteLength, ...dims });
|
|
1640
|
-
continue;
|
|
1641
|
-
}
|
|
1642
|
-
if (GLB_EXT.test(f.relPath)) {
|
|
1643
|
-
glbs.push({ path: f.relPath, bytes: f.bytes.byteLength, scan: scanGlb(f.bytes) });
|
|
1644
|
-
continue;
|
|
1645
|
-
}
|
|
1646
|
-
if (VIDEO_EXT.test(f.relPath)) {
|
|
1647
|
-
videos.push({ path: f.relPath, bytes: f.bytes.byteLength });
|
|
1648
|
-
continue;
|
|
1649
|
-
}
|
|
1650
|
-
if (AUDIO_EXT.test(f.relPath)) {
|
|
1651
|
-
audio.push({ path: f.relPath, bytes: f.bytes.byteLength });
|
|
1652
|
-
continue;
|
|
1653
|
-
}
|
|
1654
|
-
if (TEXT_EXT.test(f.relPath)) {
|
|
1655
|
-
text += new TextDecoder().decode(f.bytes.subarray(0, 8 * 1024 * 1024));
|
|
1656
|
-
text += "\n";
|
|
1657
|
-
}
|
|
1658
|
-
}
|
|
1659
|
-
for (const m of text.matchAll(GENEX_GENERATION_URL_RE)) urls.add(m[0]);
|
|
1660
|
-
const dprMatch = text.match(/setPixelRatio\(\s*Math\.min\([^,)]+,\s*([\d.]+)\s*\)/);
|
|
1661
|
-
const markers = {
|
|
1662
|
-
rendererBackend: text.includes("WebGPURenderer") || text.includes("getContext('webgpu')") || text.includes('"webgpu"') ? "webgpu" : "webgl",
|
|
1663
|
-
postStack: /EffectComposer|postprocessing|PostProcessing|RenderPipeline/.test(text),
|
|
1664
|
-
dprCapLiteral: dprMatch?.[1] ? Number(dprMatch[1]) : null,
|
|
1665
|
-
contextLossHandler: text.includes("webglcontextlost") || text.includes("device.lost"),
|
|
1666
|
-
ktx2Loader: text.includes("KTX2Loader") || text.includes(".ktx2"),
|
|
1667
|
-
touchHints: /joystick/i.test(text) || text.includes("touchstart") && text.includes("touch-action"),
|
|
1668
|
-
adaptiveQuality: text.includes("__GENEX_QUALITY__")
|
|
1669
|
-
};
|
|
1670
|
-
return {
|
|
1671
|
-
images,
|
|
1672
|
-
glbs,
|
|
1673
|
-
videos,
|
|
1674
|
-
audio,
|
|
1675
|
-
genexAssetUrls: [...urls],
|
|
1676
|
-
markers,
|
|
1677
|
-
totalBytes,
|
|
1678
|
-
fileCount: files.length
|
|
1679
|
-
};
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
// ../../packages/mobile-scan/src/estimate.ts
|
|
1683
|
-
var MB = 1024 * 1024;
|
|
1684
|
-
var CSS_W = 430;
|
|
1685
|
-
var CSS_H = 932;
|
|
1686
|
-
var WORST_SKYBOX_BYTES = 8192 * 4096 * 4 * 1.33;
|
|
1687
|
-
var WORST_IMAGE_BYTES = 2048 * 2048 * 4 * 1.33;
|
|
1688
|
-
var KTX2_DISCOUNT = 6;
|
|
1689
|
-
function textureBytes(w, h, ktx2) {
|
|
1690
|
-
const raw = w * h * 4 * 1.33;
|
|
1691
|
-
return ktx2 ? raw / KTX2_DISCOUNT : raw;
|
|
1692
|
-
}
|
|
1693
|
-
function estimateVram(scan, externalDims) {
|
|
1694
|
-
let bundled = 0;
|
|
1695
|
-
let maxDim = 0;
|
|
1696
|
-
for (const img of scan.images) {
|
|
1697
|
-
bundled += textureBytes(img.width, img.height, img.ktx2);
|
|
1698
|
-
maxDim = Math.max(maxDim, img.width, img.height);
|
|
1699
|
-
}
|
|
1700
|
-
let glbTex = 0;
|
|
1701
|
-
let geometry = 0;
|
|
1702
|
-
for (const glb of scan.glbs) {
|
|
1703
|
-
if (!glb.scan) {
|
|
1704
|
-
geometry += glb.bytes;
|
|
1705
|
-
continue;
|
|
1706
|
-
}
|
|
1707
|
-
const ktx2 = glb.scan.extensions.some((e) => e.includes("KHR_texture_basisu"));
|
|
1708
|
-
for (const img of glb.scan.images) {
|
|
1709
|
-
glbTex += textureBytes(img.width, img.height, img.ktx2 || ktx2);
|
|
1710
|
-
maxDim = Math.max(maxDim, img.width, img.height);
|
|
1711
|
-
}
|
|
1712
|
-
geometry += Math.max(0, glb.scan.binBytes - glb.scan.images.reduce((s, i) => s + i.bytes, 0));
|
|
1713
|
-
}
|
|
1714
|
-
let external = 0;
|
|
1715
|
-
const unresolved = [];
|
|
1716
|
-
for (const url of scan.genexAssetUrls) {
|
|
1717
|
-
const role = url.split("/").pop() ?? "";
|
|
1718
|
-
if (MOBILE_RUNG_SUFFIX_RE.test(role)) continue;
|
|
1719
|
-
const dims = externalDims.get(url);
|
|
1720
|
-
if (dims) {
|
|
1721
|
-
external += textureBytes(dims.width, dims.height, dims.ktx2);
|
|
1722
|
-
maxDim = Math.max(maxDim, dims.width, dims.height);
|
|
1723
|
-
} else {
|
|
1724
|
-
unresolved.push(url);
|
|
1725
|
-
external += role === SKYBOX_ROLE ? WORST_SKYBOX_BYTES : WORST_IMAGE_BYTES;
|
|
1726
|
-
maxDim = Math.max(maxDim, role === SKYBOX_ROLE ? 8192 : 2048);
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
const dpr = Math.min(scan.markers.dprCapLiteral ?? 2, 3);
|
|
1730
|
-
const surface = CSS_W * CSS_H * dpr * dpr * 4;
|
|
1731
|
-
const framebuffers = surface * (3 + (scan.markers.postStack ? 4 : 0));
|
|
1732
|
-
const shadow = 2048 * 2048 * 4;
|
|
1733
|
-
const total = bundled + glbTex + external + framebuffers + shadow + geometry;
|
|
1734
|
-
return {
|
|
1735
|
-
estVramMb: Math.round(total / MB),
|
|
1736
|
-
breakdown: {
|
|
1737
|
-
bundledTexturesMb: Math.round(bundled / MB),
|
|
1738
|
-
glbTexturesMb: Math.round(glbTex / MB),
|
|
1739
|
-
externalTexturesMb: Math.round(external / MB),
|
|
1740
|
-
framebuffersMb: Math.round(framebuffers / MB),
|
|
1741
|
-
shadowDefaultMb: Math.round(shadow / MB),
|
|
1742
|
-
geometryMb: Math.round(geometry / MB)
|
|
1743
|
-
},
|
|
1744
|
-
unresolvedExternals: unresolved,
|
|
1745
|
-
textureMaxDim: maxDim
|
|
1746
|
-
};
|
|
1747
|
-
}
|
|
1748
|
-
function tierFor(estVramMb) {
|
|
1749
|
-
if (estVramMb < MOBILE_BUDGET_MB.broad) return "mobile-broad";
|
|
1750
|
-
if (estVramMb < MOBILE_BUDGET_MB.modern) return "mobile-modern";
|
|
1751
|
-
return "over-budget";
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
// src/lib/deploy.ts
|
|
1755
1500
|
function run(cmd, args, env) {
|
|
1756
1501
|
return new Promise((resolve) => {
|
|
1757
1502
|
let child;
|
|
@@ -1769,48 +1514,6 @@ function run(cmd, args, env) {
|
|
|
1769
1514
|
child.on("close", (code2) => resolve({ code: code2 ?? -1, out, err }));
|
|
1770
1515
|
});
|
|
1771
1516
|
}
|
|
1772
|
-
function printMobilePreflight(files, log) {
|
|
1773
|
-
try {
|
|
1774
|
-
const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
|
|
1775
|
-
const externalDims = /* @__PURE__ */ new Map();
|
|
1776
|
-
for (const url of scan.genexAssetUrls) {
|
|
1777
|
-
const role = url.split("/").pop() ?? "";
|
|
1778
|
-
if (scan.markers.adaptiveQuality && role === SKYBOX_ROLE) {
|
|
1779
|
-
externalDims.set(url, { width: 2048, height: 1024 });
|
|
1780
|
-
} else {
|
|
1781
|
-
externalDims.set(url, null);
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
const est = estimateVram(scan, externalDims);
|
|
1785
|
-
const tier = tierFor(est.estVramMb);
|
|
1786
|
-
const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
|
|
1787
|
-
if (tier === "mobile-broad") {
|
|
1788
|
-
log.dim(` ${line}`);
|
|
1789
|
-
return;
|
|
1790
|
-
}
|
|
1791
|
-
log.warn(line);
|
|
1792
|
-
for (const url of est.unresolvedExternals) {
|
|
1793
|
-
const role = url.split("/").pop() ?? "";
|
|
1794
|
-
if (role === SKYBOX_ROLE) {
|
|
1795
|
-
log.warn(
|
|
1796
|
-
` Skybox loaded full-size (${url.slice(0, 80)}\u2026): ~178 MB decoded on phones. Load it through pickAsset (genex-threejs-adaptive-quality) so phones get the @2048 rung.`
|
|
1797
|
-
);
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
if (est.textureMaxDim > 2048) {
|
|
1801
|
-
log.warn(` Largest texture is ${est.textureMaxDim}px \u2014 phones want \u22642048 (props \u22641024).`);
|
|
1802
|
-
}
|
|
1803
|
-
if (scan.markers.postStack && scan.markers.dprCapLiteral == null) {
|
|
1804
|
-
log.warn(
|
|
1805
|
-
" Post stack detected with no devicePixelRatio cap \u2014 cap at 1.5 on phones (genex-threejs-adaptive-quality)."
|
|
1806
|
-
);
|
|
1807
|
-
}
|
|
1808
|
-
if (!scan.markers.contextLossHandler) {
|
|
1809
|
-
log.warn(" No context-loss handler found \u2014 one GPU reset shows a dead canvas on phones.");
|
|
1810
|
-
}
|
|
1811
|
-
} catch {
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
1517
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
|
|
1815
1518
|
function isSecretEnvFile(name) {
|
|
1816
1519
|
return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
|
|
@@ -1841,7 +1544,6 @@ async function deployGame(ctx, opts, log) {
|
|
|
1841
1544
|
log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
|
|
1842
1545
|
return false;
|
|
1843
1546
|
}
|
|
1844
|
-
printMobilePreflight(files, log);
|
|
1845
1547
|
const commit = contentCommit(files);
|
|
1846
1548
|
log.step("Requesting an upload token\u2026");
|
|
1847
1549
|
const grant = await getUploadToken(ctx, commit, log);
|
|
@@ -1985,8 +1687,7 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1985
1687
|
commit,
|
|
1986
1688
|
matchmaking: opts.matchmaking ?? null,
|
|
1987
1689
|
...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
|
|
1988
|
-
...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {}
|
|
1989
|
-
...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {}
|
|
1690
|
+
...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {}
|
|
1990
1691
|
})
|
|
1991
1692
|
});
|
|
1992
1693
|
} catch (err) {
|
|
@@ -2332,32 +2033,6 @@ async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
|
2332
2033
|
}
|
|
2333
2034
|
return null;
|
|
2334
2035
|
}
|
|
2335
|
-
var TOUCH_KIT_MARKERS = /controllers\/touch\/|controllers\/character\/touch-joystick|TouchJoystick|VirtualButton|DragZone|RotateOverlay/;
|
|
2336
|
-
async function detectMobileControls(cwd = process.cwd()) {
|
|
2337
|
-
try {
|
|
2338
|
-
const raw = await fs11.readFile(path12.join(cwd, "package.json"), "utf8");
|
|
2339
|
-
const pkg = JSON.parse(raw);
|
|
2340
|
-
if (pkg.genex?.mobileControls === true) return true;
|
|
2341
|
-
} catch {
|
|
2342
|
-
}
|
|
2343
|
-
const srcDir = path12.join(cwd, "src");
|
|
2344
|
-
let entries;
|
|
2345
|
-
try {
|
|
2346
|
-
entries = await fs11.readdir(srcDir, { recursive: true });
|
|
2347
|
-
} catch {
|
|
2348
|
-
return false;
|
|
2349
|
-
}
|
|
2350
|
-
for (const rel of entries) {
|
|
2351
|
-
if (rel.includes("node_modules")) continue;
|
|
2352
|
-
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
2353
|
-
try {
|
|
2354
|
-
const content = await fs11.readFile(path12.join(srcDir, rel), "utf8");
|
|
2355
|
-
if (TOUCH_KIT_MARKERS.test(content)) return true;
|
|
2356
|
-
} catch {
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
return false;
|
|
2360
|
-
}
|
|
2361
2036
|
var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
|
|
2362
2037
|
async function detectGameStateUsage(cwd = process.cwd()) {
|
|
2363
2038
|
const srcDir = path12.join(cwd, "src");
|
|
@@ -2542,7 +2217,6 @@ async function detectFeatures(log, cwd = process.cwd()) {
|
|
|
2542
2217
|
multiplayer: await detectMultiplayer(cwd),
|
|
2543
2218
|
matchmaking: await detectMatchmaking(log, cwd),
|
|
2544
2219
|
gameStateUsed: await detectGameStateUsage(cwd),
|
|
2545
|
-
mobileControls: await detectMobileControls(cwd),
|
|
2546
2220
|
uiPhases: await detectUiPhases(cwd),
|
|
2547
2221
|
surfaces: await detectSurfaceScan(cwd),
|
|
2548
2222
|
generations: await detectGenerationAudit(cwd)
|
|
@@ -2686,8 +2360,7 @@ async function runPublish(opts) {
|
|
|
2686
2360
|
noBuild: opts.noBuild,
|
|
2687
2361
|
matchmaking: detections.matchmaking,
|
|
2688
2362
|
embedSdkVersion: detections.embedSdkVersion,
|
|
2689
|
-
multiplayer: detections.multiplayer
|
|
2690
|
-
mobileControls: detections.mobileControls
|
|
2363
|
+
multiplayer: detections.multiplayer
|
|
2691
2364
|
},
|
|
2692
2365
|
log
|
|
2693
2366
|
);
|
|
@@ -2719,7 +2392,6 @@ async function runPublish(opts) {
|
|
|
2719
2392
|
body.multiplayer = detections.multiplayer;
|
|
2720
2393
|
body.matchmaking = detections.matchmaking ?? null;
|
|
2721
2394
|
body.gameStateUsed = detections.gameStateUsed;
|
|
2722
|
-
body.mobileControls = detections.mobileControls;
|
|
2723
2395
|
res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
|
|
2724
2396
|
method: "POST",
|
|
2725
2397
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
@@ -2773,8 +2445,7 @@ async function runPreview(opts) {
|
|
|
2773
2445
|
noBuild: opts.noBuild,
|
|
2774
2446
|
matchmaking: detections.matchmaking,
|
|
2775
2447
|
embedSdkVersion: detections.embedSdkVersion,
|
|
2776
|
-
multiplayer: detections.multiplayer
|
|
2777
|
-
mobileControls: detections.mobileControls
|
|
2448
|
+
multiplayer: detections.multiplayer
|
|
2778
2449
|
},
|
|
2779
2450
|
log
|
|
2780
2451
|
);
|
|
@@ -13023,8 +12694,7 @@ var CONTROLLER_KINDS = [
|
|
|
13023
12694
|
"car",
|
|
13024
12695
|
"drone",
|
|
13025
12696
|
"touch",
|
|
13026
|
-
"networked-physics"
|
|
13027
|
-
"quality"
|
|
12697
|
+
"networked-physics"
|
|
13028
12698
|
];
|
|
13029
12699
|
var SHARED = [
|
|
13030
12700
|
"shared/math.ts",
|
|
@@ -13117,17 +12787,6 @@ var CONTROLLER_FILE_SETS = {
|
|
|
13117
12787
|
`physics.onBeforeStep(() => { drone.setMovement(keyboard.getDroneMovement()); drone.update(); });`
|
|
13118
12788
|
]
|
|
13119
12789
|
},
|
|
13120
|
-
quality: {
|
|
13121
|
-
code: ["quality/tier.ts", "quality/governor.ts", "quality/pick-asset.ts"],
|
|
13122
|
-
assets: [],
|
|
13123
|
-
skill: "genex-threejs-adaptive-quality",
|
|
13124
|
-
sketch: [
|
|
13125
|
-
`const tier = detectTier(); // phone-low | phone | desktop \u2014 manual Quality setting wins`,
|
|
13126
|
-
`renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap)); // antialias: tier.antialias at creation`,
|
|
13127
|
-
`const gov = new QualityGovernor(tier, { setDprScale: (m) => renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap * m)) }, renderer);`,
|
|
13128
|
-
`// per frame: gov.frame(deltaMs); skybox: loadTextureWithFallback(SKYBOX_URL, tier, (u) => loader.loadAsync(u))`
|
|
13129
|
-
]
|
|
13130
|
-
},
|
|
13131
12790
|
touch: {
|
|
13132
12791
|
code: [...TOUCH_KIT, NOTICE],
|
|
13133
12792
|
assets: [],
|
|
@@ -14019,10 +13678,7 @@ async function runExplore(opts) {
|
|
|
14019
13678
|
const meta = [
|
|
14020
13679
|
p.categories.join(", "),
|
|
14021
13680
|
p.license ?? "",
|
|
14022
|
-
p.sourceAuthor ? `originally by ${p.sourceAuthor}` : ""
|
|
14023
|
-
// Reuse signal for mobile-targeted games: cloning a desktop-only/heavy
|
|
14024
|
-
// system into a phone game imports its memory profile too.
|
|
14025
|
-
p.mobileReadiness && p.mobileReadiness.state !== "unknown" ? `[mobile: ${p.mobileReadiness.state}]` : ""
|
|
13681
|
+
p.sourceAuthor ? `originally by ${p.sourceAuthor}` : ""
|
|
14026
13682
|
].filter(Boolean).join(" \xB7 ");
|
|
14027
13683
|
log.plain(`${c.bold(`${i + 1}. ${p.title}`)} \u2014 ${meta}`);
|
|
14028
13684
|
if (p.description) log.plain(` ${p.description.slice(0, 160)}`);
|
|
@@ -15015,13 +14671,11 @@ ${c.bold("Usage")}
|
|
|
15015
14671
|
enqueued (done/running/queued/failed) \u2014 a single
|
|
15016
14672
|
API refresh, never blocks, never bills.
|
|
15017
14673
|
genex controller <type> [--force] Install a bundled controller
|
|
15018
|
-
(character|car|drone|touch|networked-physics
|
|
14674
|
+
(character|car|drone|touch|networked-physics)
|
|
15019
14675
|
into src/controllers (+ assets into public/assets).
|
|
15020
14676
|
"touch" = the standalone mobile touch kit (joystick,
|
|
15021
14677
|
buttons, drag zone, rotate overlay) for games
|
|
15022
|
-
without a physics controller.
|
|
15023
|
-
adaptive-quality kit (device tier, runtime governor,
|
|
15024
|
-
per-tier asset rungs) every game wires at boot.
|
|
14678
|
+
without a physics controller.
|
|
15025
14679
|
Character defaults to the player's VRM + UAL pack.
|
|
15026
14680
|
genex controller character --character <id>
|
|
15027
14681
|
Install the Meshy-native controller variant.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.0",
|
|
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": {
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@dimforge/rapier3d-compat": "^0.19.3",
|
|
44
44
|
"@genex/meshy-animation-catalog": "workspace:*",
|
|
45
|
-
"@genex/mobile-scan": "workspace:*",
|
|
46
45
|
"@genex-ai/multiplayer": "workspace:*",
|
|
47
46
|
"@pixiv/three-vrm": "^3.5.4",
|
|
48
47
|
"@types/pngjs": "^6.0.5",
|
|
@@ -4,17 +4,9 @@
|
|
|
4
4
|
// orientation, and runs the standard perf cleanup — so the rest of the
|
|
5
5
|
// controller treats every avatar (VRM 0.x or 1.0) identically.
|
|
6
6
|
//
|
|
7
|
-
// Mobile-readiness: everyone in a Genex room renders the SAME avatar file, so
|
|
8
|
-
// this module caches per URL — the file downloads once (loadVrm), and remote
|
|
9
|
-
// players should use loadVrmClone, which shares one set of GPU geometry +
|
|
10
|
-
// textures across N remotes (~1 avatar of VRAM instead of N). Before this,
|
|
11
|
-
// each remote re-downloaded AND re-parsed the VRM into duplicate GPU memory —
|
|
12
|
-
// a real jetsam contributor at high player counts.
|
|
13
|
-
//
|
|
14
7
|
// Needs `npm i @pixiv/three-vrm` (peer of three, which the scaffold already has).
|
|
15
8
|
import * as THREE from "three";
|
|
16
9
|
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
17
|
-
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";
|
|
18
10
|
import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
|
|
19
11
|
import type { VRM } from "@pixiv/three-vrm";
|
|
20
12
|
|
|
@@ -25,83 +17,28 @@ export interface LoadedVrm {
|
|
|
25
17
|
vrm: VRM;
|
|
26
18
|
}
|
|
27
19
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
cached = fetch(url).then((r) => {
|
|
37
|
-
if (!r.ok) throw new Error(`VRM fetch failed: ${r.status} ${url}`);
|
|
38
|
-
return r.arrayBuffer();
|
|
39
|
-
});
|
|
40
|
-
cached.catch(() => bufferCache.delete(url)); // failed fetches retry next call
|
|
41
|
-
bufferCache.set(url, cached);
|
|
42
|
-
}
|
|
43
|
-
return cached;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async function parseVrm(url: string): Promise<LoadedVrm> {
|
|
20
|
+
/**
|
|
21
|
+
* Load a `.vrm` and return its scene + VRM instance, ready to animate.
|
|
22
|
+
*
|
|
23
|
+
* `VRMUtils.rotateVRM0` bakes the VRM 0.x 180° flip into BOTH the model and its
|
|
24
|
+
* humanoid rig, so the avatar faces -Z (three's forward, same as every other
|
|
25
|
+
* model) and the UAL retargeter (vrm-retarget.ts) needs no per-version handling.
|
|
26
|
+
*/
|
|
27
|
+
export async function loadVrm(url: string): Promise<LoadedVrm> {
|
|
47
28
|
const loader = new GLTFLoader();
|
|
48
29
|
loader.register((parser) => new VRMLoaderPlugin(parser));
|
|
49
30
|
|
|
50
|
-
const
|
|
51
|
-
const gltf = await loader.parseAsync(buffer.slice(0), url);
|
|
31
|
+
const gltf = await loader.loadAsync(url);
|
|
52
32
|
const vrm = gltf.userData.vrm as VRM;
|
|
53
33
|
|
|
54
34
|
VRMUtils.rotateVRM0(vrm);
|
|
55
35
|
VRMUtils.removeUnnecessaryVertices(vrm.scene);
|
|
56
36
|
VRMUtils.combineSkeletons(vrm.scene);
|
|
57
37
|
|
|
58
|
-
// Skinned
|
|
59
|
-
// but ONLY skinned meshes need the exemption. Blanket-disabling culling on
|
|
60
|
-
// every node made ALL loaded avatars draw every frame regardless of
|
|
61
|
-
// visibility, a per-remote GPU-time tax on phones.
|
|
38
|
+
// Skinned avatars can pop out at glancing camera angles otherwise.
|
|
62
39
|
vrm.scene.traverse((obj) => {
|
|
63
|
-
|
|
40
|
+
obj.frustumCulled = false;
|
|
64
41
|
});
|
|
65
42
|
|
|
66
43
|
return { scene: vrm.scene, vrm };
|
|
67
44
|
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Load a `.vrm` and return its scene + VRM instance, ready to animate.
|
|
71
|
-
* Full-fidelity (spring bones, humanoid) — the LOCAL player's avatar, or any
|
|
72
|
-
* avatar you retarget clips onto. The file itself is fetched once per URL.
|
|
73
|
-
*
|
|
74
|
-
* `VRMUtils.rotateVRM0` bakes the VRM 0.x 180° flip into BOTH the model and its
|
|
75
|
-
* humanoid rig, so the avatar faces -Z (three's forward, same as every other
|
|
76
|
-
* model) and the UAL retargeter (vrm-retarget.ts) needs no per-version handling.
|
|
77
|
-
*/
|
|
78
|
-
export async function loadVrm(url: string): Promise<LoadedVrm> {
|
|
79
|
-
return parseVrm(url);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Visual-only clone for REMOTE players: parses the URL once (cached), then
|
|
84
|
-
* returns `SkeletonUtils.clone` of the base — its own bones and mixer target,
|
|
85
|
-
* but SHARED geometry, materials, and textures (N remotes ≈ 1 avatar of GPU
|
|
86
|
-
* memory). Trade-off, on purpose: clones carry no VRM instance, so spring
|
|
87
|
-
* bones (hair/cloth sway) don't simulate on remotes — retarget animation clips
|
|
88
|
-
* ONCE on the base (`(await loadVrmClone.base(url)).vrm`, or your local
|
|
89
|
-
* loadVrm result when it's the same file) and play them on each clone's own
|
|
90
|
-
* `THREE.AnimationMixer`; track names match by construction.
|
|
91
|
-
*/
|
|
92
|
-
export async function loadVrmClone(url: string): Promise<{ scene: THREE.Group }> {
|
|
93
|
-
const base = await loadVrmCloneBase(url);
|
|
94
|
-
return { scene: SkeletonUtils.clone(base.scene) as THREE.Group };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The shared parsed base behind loadVrmClone — retarget clips against ITS vrm. */
|
|
98
|
-
export function loadVrmCloneBase(url: string): Promise<LoadedVrm> {
|
|
99
|
-
let cached = baseCache.get(url);
|
|
100
|
-
if (!cached) {
|
|
101
|
-
cached = parseVrm(url);
|
|
102
|
-
cached.catch(() => baseCache.delete(url));
|
|
103
|
-
baseCache.set(url, cached);
|
|
104
|
-
}
|
|
105
|
-
return cached;
|
|
106
|
-
}
|
|
107
|
-
loadVrmClone.base = loadVrmCloneBase;
|
|
@@ -83,13 +83,7 @@ npx genex wait <gen-id>
|
|
|
83
83
|
|
|
84
84
|
Never wire the clip as a bare `<video loop>` — the residual seam shows every
|
|
85
85
|
cycle. Two stacked `<video>` elements with the same src crossfade at the
|
|
86
|
-
cycle end; any seam disappears deterministically, no regeneration lottery
|
|
87
|
-
|
|
88
|
-
**Phone tiers get the poster, not the videos** (`$genex-threejs-adaptive-quality`):
|
|
89
|
-
two preloading 720p decoders while the 3D scene boots is a spike at exactly the
|
|
90
|
-
moment phones get killed for memory. On a phone tier, show the key-art poster
|
|
91
|
-
image (a captured frame of the clip works) and skip `seamlessLoop` entirely —
|
|
92
|
-
or defer ONE non-preloading video until after the first gameplay frame:
|
|
86
|
+
cycle end; any seam disappears deterministically, no regeneration lottery:
|
|
93
87
|
|
|
94
88
|
```ts
|
|
95
89
|
/** Deterministic seamless loop: two stacked <video>s crossfade at cycle end. */
|
|
@@ -35,23 +35,15 @@ in local dev, the published game, and remixes).
|
|
|
35
35
|
|
|
36
36
|
## Load it as background + environment
|
|
37
37
|
|
|
38
|
-
Load the equirect JPG
|
|
39
|
-
|
|
40
|
-
The bare URL is an 8192×4096 original — ~178 MB decoded, over half a phone's
|
|
41
|
-
GPU budget in one texture — so phones must load the downscale rung the platform
|
|
42
|
-
stores next to every skybox (`$genex-threejs-adaptive-quality`):
|
|
38
|
+
Load the equirect JPG, mark it equirectangular, and use it for both the visible
|
|
39
|
+
background and the lighting:
|
|
43
40
|
|
|
44
41
|
```ts
|
|
45
42
|
import * as THREE from "three";
|
|
46
|
-
import { detectTier } from "./controllers/quality/tier.ts";
|
|
47
|
-
import { loadTextureWithFallback } from "./controllers/quality/pick-asset.ts";
|
|
48
43
|
|
|
49
44
|
// the URL `npx genex skybox` printed (R2 sends CORS headers, so cross-origin works):
|
|
50
45
|
const SKYBOX_URL = "https://assets.genex.technology/generations/<id>/skybox-equirect";
|
|
51
|
-
const
|
|
52
|
-
const texture = await loadTextureWithFallback(SKYBOX_URL, tier, (u) =>
|
|
53
|
-
new THREE.TextureLoader().loadAsync(u),
|
|
54
|
-
);
|
|
46
|
+
const texture = await new THREE.TextureLoader().loadAsync(SKYBOX_URL);
|
|
55
47
|
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
56
48
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
57
49
|
|
|
@@ -59,9 +51,6 @@ scene.background = texture; // visible sky
|
|
|
59
51
|
scene.environment = texture; // image-based lighting on PBR materials
|
|
60
52
|
```
|
|
61
53
|
|
|
62
|
-
Desktop gets the original; phones get the `@2048`/`@4096` rung; a missing rung
|
|
63
|
-
falls back to the original automatically — never a broken boot.
|
|
64
|
-
|
|
65
54
|
For sharper reflections/lighting, pre-filter it with `PMREMGenerator`:
|
|
66
55
|
|
|
67
56
|
```ts
|
|
@@ -87,7 +76,7 @@ scene.background = texture; // keep the raw texture for the visible sky
|
|
|
87
76
|
|
|
88
77
|
## Troubleshooting
|
|
89
78
|
|
|
90
|
-
- **"Not authorized"** — run `npx @genex-ai/cli-demo@
|
|
79
|
+
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
91
80
|
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
92
81
|
this skybox generation. Tell the user the facts the CLI printed: their balance, this
|
|
93
82
|
generation's cost, and when their credits refill. Then offer to continue the build
|
|
@@ -225,7 +225,7 @@ first one is the one a screenshot of the whole arena will not show you.
|
|
|
225
225
|
|
|
226
226
|
## Troubleshooting
|
|
227
227
|
|
|
228
|
-
- **"Not authorized"** — run `npx @genex-ai/cli-demo@
|
|
228
|
+
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
229
229
|
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
230
230
|
this texture generation. Tell the user the facts the CLI printed: their balance,
|
|
231
231
|
this generation's cost, and when their credits refill. Then offer to continue the
|
|
@@ -145,7 +145,7 @@ set belongs to `$genex-ai-hud` — both build on `npx genex image`/`video`.
|
|
|
145
145
|
|
|
146
146
|
## Troubleshooting
|
|
147
147
|
|
|
148
|
-
- **"Not authorized"** — run `npx @genex-ai/cli-demo@
|
|
148
|
+
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
149
149
|
- **"Prompt rejected"** — the provider's content-safety filter blocked the prompt.
|
|
150
150
|
This is non-retryable; retrying the same wording fails again. Rewrite the prompt.
|
|
151
151
|
- **Nothing plays / black surface** — the first `video.play()` must run inside a user
|