@stage5/lumine 0.2.5 → 0.2.6

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
@@ -77,6 +77,32 @@ which can differ from a method's `Twinkle.*` SDK return shape — check
77
77
  `TWINKLE_BUILD_SDK.md` for SDK return shapes. Methods that change data require
78
78
  `--allow-write`.
79
79
 
80
+ ## Assets and AI image generation
81
+
82
+ Binary media never lives in the workspace — assets are uploaded to Twinkle and
83
+ referenced from code by URL. `lumine assets upload <file...>` uploads images or
84
+ audio; `lumine assets list` prints your uploads and refreshes
85
+ `.twinkle/assets.json`.
86
+
87
+ `lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>` creates
88
+ an AI-generated image asset instead of uploading one. `--model` is required
89
+ (gpt-image-2 = best quality, slower, pricier; nano-banana = Gemini, faster,
90
+ cheaper). Generation spends your Twinkle AI Battery, so the CLI shows the
91
+ estimated cost and asks for confirmation first — non-interactive runs must pass
92
+ `--yes` to consent. `--quality low|medium|high` applies to gpt-image-2 only.
93
+
94
+ ## Thumbnails
95
+
96
+ `lumine thumbnail set <file>` uploads a jpg/png/webp (max 8MB) as the build's
97
+ thumbnail. `lumine thumbnail capture` screenshots the running app server-side
98
+ and sets the result (add `--out <file>` to keep a local copy).
99
+ `lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>`
100
+ generates an AI image and sets it as the thumbnail (the image is also kept as a
101
+ normal reusable asset); without a prompt the server composes one from the build
102
+ title and description. Replacing an existing thumbnail asks for confirmation;
103
+ pass `--yes` for non-interactive runs. Thumbnail commands are blocked in
104
+ read-only `pull --main` checkouts.
105
+
80
106
  The CLI checks npm for the latest `@stage5/lumine` version on normal commands.
81
107
  If the installed copy is outdated, it prints an update warning and records the
82
108
  version state in `.twinkle/lumine-project.json` so local agents can tell when
package/lib/api.js CHANGED
@@ -219,17 +219,81 @@ export async function buildApiJson({
219
219
  buildApiToken,
220
220
  endpointPath,
221
221
  body,
222
+ timeoutMs,
222
223
  }) {
223
224
  return requestJson({
224
225
  method: "POST",
225
226
  url: `${options.apiUrl}/build/${buildId}/${endpointPath}`,
226
227
  authToken: auth.token,
227
228
  body: body || {},
228
- timeoutMs: options.timeoutMs,
229
+ timeoutMs: timeoutMs || options.timeoutMs,
229
230
  headers: { "x-build-api-token": buildApiToken },
230
231
  });
231
232
  }
232
233
 
234
+ export async function requestThumbnailSignedUpload({
235
+ options,
236
+ auth,
237
+ buildId,
238
+ fileSize,
239
+ contentType,
240
+ }) {
241
+ return requestJson({
242
+ method: "POST",
243
+ url: `${options.apiUrl}/build/${buildId}/thumbnail`,
244
+ authToken: auth.token,
245
+ body: { fileSize, contentType },
246
+ timeoutMs: options.timeoutMs,
247
+ });
248
+ }
249
+
250
+ export async function updateBuildThumbnailUrl({
251
+ options,
252
+ auth,
253
+ buildId,
254
+ thumbnailUrl,
255
+ }) {
256
+ return requestJson({
257
+ method: "PUT",
258
+ url: `${options.apiUrl}/build/${buildId}`,
259
+ authToken: auth.token,
260
+ body: { thumbnailUrl },
261
+ timeoutMs: options.timeoutMs,
262
+ });
263
+ }
264
+
265
+ export async function captureBuildThumbnailPreview({
266
+ options,
267
+ auth,
268
+ buildId,
269
+ previewPath,
270
+ timeoutMs,
271
+ }) {
272
+ return requestJson({
273
+ method: "POST",
274
+ url: `${options.apiUrl}/build/${buildId}/thumbnail/capture-preview`,
275
+ authToken: auth.token,
276
+ body: { previewPath },
277
+ timeoutMs: timeoutMs || options.timeoutMs,
278
+ });
279
+ }
280
+
281
+ export async function generateBuildThumbnailRequest({
282
+ options,
283
+ auth,
284
+ buildId,
285
+ body,
286
+ timeoutMs,
287
+ }) {
288
+ return requestJson({
289
+ method: "POST",
290
+ url: `${options.apiUrl}/build/${buildId}/thumbnail/generate`,
291
+ authToken: auth.token,
292
+ body: body || {},
293
+ timeoutMs: timeoutMs || options.timeoutMs,
294
+ });
295
+ }
296
+
233
297
  export async function fetchAllRuntimeAssets({
234
298
  options,
235
299
  auth,
package/lib/assets.js CHANGED
@@ -4,9 +4,12 @@ import readline from "readline/promises";
4
4
  import { stdin as input, stdout as output } from "process";
5
5
 
6
6
  import {
7
+ ASSET_GENERATE_TIMEOUT_MS,
7
8
  ASSET_MIME_BY_EXTENSION,
8
9
  ASSET_UPLOAD_CHUNK_BYTES,
9
10
  ASSETS_METADATA_FILE,
11
+ GENERATE_MODEL_ALIASES,
12
+ GENERATE_QUALITIES,
10
13
  PROJECT_METADATA_DIR,
11
14
  RUNTIME_ASSET_REFERENCE_PATTERN,
12
15
  } from "./constants.js";
@@ -48,9 +51,169 @@ export async function assetsCommand(options) {
48
51
  await assetsPrune(options);
49
52
  return;
50
53
  }
54
+ if (subcommand === "generate") {
55
+ await assetsGenerate(options);
56
+ return;
57
+ }
51
58
  throw new Error(
52
- "Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets delete <assetId> | lumine assets prune [--yes]",
59
+ 'Usage: lumine assets [list] | lumine assets upload <file...> | lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana> | lumine assets delete <assetId> | lumine assets prune [--yes]',
60
+ );
61
+ }
62
+
63
+ export function resolveGenerateModel(options) {
64
+ const rawModel = String(options.model || "").trim();
65
+ const model = GENERATE_MODEL_ALIASES[rawModel];
66
+ if (!model) {
67
+ throw new Error(
68
+ "--model is required: gpt-image-2 (best quality, slower, pricier) or nano-banana (Gemini — faster, cheaper). No default model is applied.",
69
+ );
70
+ }
71
+ const rawQuality = String(options.quality || "").trim();
72
+ if (rawQuality && !GENERATE_QUALITIES.has(rawQuality)) {
73
+ throw new Error("--quality must be low, medium, or high.");
74
+ }
75
+ if (rawQuality && model !== "gpt-image-2") {
76
+ throw new Error(
77
+ "--quality only applies to gpt-image-2; nano-banana has a single quality tier.",
78
+ );
79
+ }
80
+ return { model, quality: rawQuality || null };
81
+ }
82
+
83
+ export function formatBatteryPercent(energyUnits, fullBatteryUnits) {
84
+ const total = Number(fullBatteryUnits) || 1_000_000;
85
+ const percent = ((Number(energyUnits) || 0) / total) * 100;
86
+ if (percent <= 0) return "~0%";
87
+ if (percent < 1) return "<1%";
88
+ return `~${Math.round(percent)}%`;
89
+ }
90
+
91
+ export function describeGenerateHttpError(error) {
92
+ const code = String(error?.data?.code || "").trim();
93
+ if (error?.status === 429 && code === "ai_energy_depleted") {
94
+ return "Your AI battery is empty. Recharge on Twinkle or try again tomorrow.";
95
+ }
96
+ if (error?.status === 409) {
97
+ return "Another image generation is already running for your account — wait for it to finish, then retry.";
98
+ }
99
+ if (code === "build_runtime_file_storage_limit_exceeded") {
100
+ return `${error?.data?.error || "Storage quota exceeded."} Free space with \`lumine assets prune\`.`;
101
+ }
102
+ if (error?.status === 404) {
103
+ return "Twinkle API does not support asset generation yet (deploy the latest API first).";
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export async function assetsGenerate(options) {
109
+ const prompt = String(options.positional[1] || "").trim();
110
+ if (!prompt) {
111
+ throw new Error(
112
+ 'Usage: lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana> [--quality low|medium|high] [--name <fileName>] [--yes]',
113
+ );
114
+ }
115
+ const { model, quality } = resolveGenerateModel(options);
116
+ const fileName = String(options.assetName || "").trim();
117
+ const buildId = await resolveSdkBuildId(options);
118
+ const auth = await ensureAuth(options);
119
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
120
+ const { token } = await mintBuildApiToken({
121
+ options,
122
+ auth,
123
+ buildId,
124
+ scopes: ["files:read", "files:write"],
125
+ });
126
+
127
+ const requestBody = {
128
+ prompt,
129
+ model,
130
+ ...(quality ? { quality } : {}),
131
+ ...(fileName ? { fileName } : {}),
132
+ };
133
+
134
+ if (!options.assumeYes) {
135
+ let estimate = null;
136
+ try {
137
+ const estimateResult = await buildApiJson({
138
+ options,
139
+ auth,
140
+ buildId,
141
+ buildApiToken: token,
142
+ endpointPath: "api/assets/generate",
143
+ body: { ...requestBody, estimateOnly: true },
144
+ });
145
+ estimate = estimateResult?.estimate || null;
146
+ } catch (error) {
147
+ const friendly = describeGenerateHttpError(error);
148
+ if (friendly) throw new Error(friendly);
149
+ throw error;
150
+ }
151
+ const selectedOption = (estimate?.options || []).find(
152
+ (option) => option.model === model,
153
+ );
154
+ const costLine = selectedOption
155
+ ? ` Estimated battery cost: ${formatBatteryPercent(selectedOption.energyUnits, estimate?.fullBatteryUnits)} of a full AI battery (~$${Number(selectedOption.estimatedUsd || 0).toFixed(2)})`
156
+ : " Estimated battery cost: unavailable";
157
+ const remainingLine = estimate
158
+ ? ` Battery remaining now: ${formatBatteryPercent(estimate.energyRemaining, estimate.fullBatteryUnits)}`
159
+ : "";
160
+ console.log(
161
+ `Generate 1 image with ${model}${quality ? ` (quality ${quality})` : ""}?`,
162
+ );
163
+ console.log(costLine);
164
+ if (remainingLine) console.log(remainingLine);
165
+ const confirmed = await confirmPrompt("Proceed? [y/N] ");
166
+ if (confirmed === null) {
167
+ console.log("Not a TTY — re-run with --yes to generate.");
168
+ return;
169
+ }
170
+ if (!confirmed) {
171
+ console.log("Aborted. Nothing generated.");
172
+ return;
173
+ }
174
+ }
175
+
176
+ console.log("Generating image... this can take a minute.");
177
+ let result;
178
+ try {
179
+ result = await buildApiJson({
180
+ options,
181
+ auth,
182
+ buildId,
183
+ buildApiToken: token,
184
+ endpointPath: "api/assets/generate",
185
+ body: requestBody,
186
+ timeoutMs: ASSET_GENERATE_TIMEOUT_MS,
187
+ });
188
+ } catch (error) {
189
+ const friendly = describeGenerateHttpError(error);
190
+ if (friendly) throw new Error(friendly);
191
+ throw error;
192
+ }
193
+ const asset = result?.asset;
194
+ if (!asset?.url) {
195
+ throw new Error("Generation finished but no asset was returned.");
196
+ }
197
+ console.log(
198
+ `Generated ${asset.originalFileName || asset.fileName} (${formatBytes(asset.sizeBytes)}) as asset #${asset.id}`,
53
199
  );
200
+ console.log(` ${asset.url}`);
201
+ const remaining = result?.aiUsagePolicy?.energyRemaining;
202
+ if (Number.isFinite(Number(remaining))) {
203
+ console.log(
204
+ `Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.fullBatteryUnits)}`,
205
+ );
206
+ }
207
+ console.log(
208
+ "Reference this URL from your project files (e.g. a src/assets.js module).",
209
+ );
210
+ await refreshWorkspaceAssetsManifest({
211
+ options,
212
+ auth,
213
+ buildId,
214
+ buildApiToken: token,
215
+ });
216
+ return asset;
54
217
  }
55
218
 
56
219
  export async function assetsList(options) {
package/lib/commands.js CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  saveProjectFiles,
39
39
  } from "./api.js";
40
40
  import { assetsCommand, writeAssetsManifest } from "./assets.js";
41
+ import { thumbnailCommand } from "./thumbnail.js";
41
42
  import {
42
43
  assertAuthScope,
43
44
  ensureAuth,
@@ -171,6 +172,10 @@ export async function main() {
171
172
  await assetsCommand(options);
172
173
  return;
173
174
  }
175
+ if (options.command === "thumbnail") {
176
+ await thumbnailCommand(options);
177
+ return;
178
+ }
174
179
 
175
180
  printHelp();
176
181
  }
@@ -1233,6 +1238,10 @@ export function parseArgs(args) {
1233
1238
  .map((scope) => scope.trim())
1234
1239
  .filter(Boolean),
1235
1240
  buildIdFlag: raw.build ? String(raw.build) : "",
1241
+ model: raw.model ? String(raw.model) : "",
1242
+ quality: raw.quality ? String(raw.quality) : "",
1243
+ assetName: raw.name ? String(raw.name) : "",
1244
+ out: raw.out ? String(raw.out) : "",
1236
1245
  target: raw.url || raw.target || positional[0] || "",
1237
1246
  title:
1238
1247
  String(
@@ -1437,8 +1446,12 @@ export function printHelp() {
1437
1446
  lumine sdk call <namespace.method> [jsonArgs]
1438
1447
  lumine assets [list]
1439
1448
  lumine assets upload <file...>
1449
+ lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>
1440
1450
  lumine assets delete <assetId>
1441
1451
  lumine assets prune [--yes]
1452
+ lumine thumbnail set <file>
1453
+ lumine thumbnail capture [--out <file>]
1454
+ lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>
1442
1455
 
1443
1456
  Examples:
1444
1457
  npx @stage5/lumine@latest
@@ -1462,6 +1475,10 @@ Examples:
1462
1475
  npx @stage5/lumine@latest sdk call aiStories.list '{"difficulty": 1}' --repeat 5
1463
1476
  npx @stage5/lumine@latest assets upload art/hero.png sounds/theme.mp3
1464
1477
  npx @stage5/lumine@latest assets list --build 917
1478
+ npx @stage5/lumine@latest assets generate "pixel-art forest background" --model nano-banana
1479
+ npx @stage5/lumine@latest thumbnail set art/cover.png
1480
+ npx @stage5/lumine@latest thumbnail capture --out capture.png
1481
+ npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
1465
1482
 
1466
1483
  Options:
1467
1484
  --api-url <url> Twinkle API origin
@@ -1481,11 +1498,15 @@ Options:
1481
1498
  --limit <number> Number of projects to show
1482
1499
  --no-update-check Skip the npm latest-version check
1483
1500
  --no-open Print the approval URL without opening a browser
1484
- --build <id> Build id for sdk/assets calls outside a workspace
1501
+ --build <id> Build id for sdk/assets/thumbnail calls outside a workspace
1485
1502
  --repeat <n> Repeat an sdk call (1-20) and print latency stats
1486
1503
  --allow-write Permit sdk methods that mutate app data
1487
1504
  --path <api/...> Call an sdk endpoint not in the curated list
1488
1505
  --scopes <a,b> Override requested build API token scopes
1489
- --yes Skip the confirmation prompt (assets prune)
1506
+ --model <model> Image model for generate: gpt-image-2 or nano-banana (required, no default)
1507
+ --quality <q> gpt-image-2 quality: low, medium, high (default high)
1508
+ --name <fileName> File name hint for a generated asset
1509
+ --out <path> With thumbnail capture: also save the capture locally
1510
+ --yes Skip confirmation prompts (assets prune/generate, thumbnail)
1490
1511
  `);
1491
1512
  }
package/lib/constants.js CHANGED
@@ -10,6 +10,25 @@ export const DEFAULT_AUTH_FILE = path.join(
10
10
  "lumine-cli-auth.json",
11
11
  );
12
12
  export const DEFAULT_TIMEOUT_MS = 20000;
13
+ // Image generation runs a multi-minute provider call; thumbnail capture runs a
14
+ // headless browser server-side. Both need per-call timeouts far above the
15
+ // 20s default.
16
+ export const ASSET_GENERATE_TIMEOUT_MS = 6 * 60 * 1000;
17
+ export const THUMBNAIL_CAPTURE_TIMEOUT_MS = 90 * 1000;
18
+ export const GENERATE_MODEL_ALIASES = {
19
+ "gpt-image-2": "gpt-image-2",
20
+ "nano-banana": "gemini-3-pro-image-preview",
21
+ "gemini-3-pro-image-preview": "gemini-3-pro-image-preview",
22
+ };
23
+ export const GENERATE_QUALITIES = new Set(["low", "medium", "high"]);
24
+ // Server accepts only these thumbnail content types (8MB max).
25
+ export const THUMBNAIL_CONTENT_TYPE_BY_EXTENSION = {
26
+ ".jpg": "image/jpeg",
27
+ ".jpeg": "image/jpeg",
28
+ ".png": "image/png",
29
+ ".webp": "image/webp",
30
+ };
31
+ export const THUMBNAIL_MAX_FILE_SIZE_BYTES = 8 * 1024 * 1024;
13
32
  export const UPDATE_CHECK_TIMEOUT_MS = 1500;
14
33
  export const DEFAULT_PROJECT_LIMIT = 50;
15
34
  export const PROJECT_METADATA_DIR = ".twinkle";
@@ -152,6 +171,26 @@ lumine save --summary "Describe the change"
152
171
  replaces. Update code references, then delete or prune the old asset.
153
172
  - Prefer one small assets module (e.g. src/assets.js) mapping names to asset
154
173
  URLs over scattering URLs through the code.
174
+ - \`lumine assets generate "<prompt>" --model <gpt-image-2|nano-banana>\` creates
175
+ an AI-generated image asset. --model is REQUIRED (no default): gpt-image-2 is
176
+ best quality but slower and pricier; nano-banana (Gemini) is faster and
177
+ cheaper. It spends the logged-in user's AI Battery, so it asks y/N with the
178
+ estimated cost first; non-interactive runs must pass --yes to consent.
179
+ Optional: --quality low|medium|high (gpt-image-2 only, default high),
180
+ --name <fileName>. The asset lands in .twinkle/${ASSETS_METADATA_FILE} like an upload.
181
+
182
+ ## Thumbnail
183
+
184
+ - \`lumine thumbnail set <file>\` uploads a jpg/png/webp (max 8MB) as the
185
+ build's thumbnail. Thumbnails display ~16:9; the image is uploaded uncropped.
186
+ - \`lumine thumbnail capture\` screenshots the running app server-side and sets
187
+ the result as the thumbnail (add --out <file> to also save it locally).
188
+ - \`lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana>\`
189
+ generates an AI image and sets it as the thumbnail (also stored as a normal
190
+ reusable asset). Same battery-cost consent rules as \`assets generate\`.
191
+ - Replacing an existing thumbnail asks y/N; pass --yes for non-interactive runs.
192
+ - Never run thumbnail commands from a read-only \`pull --main\` checkout; use
193
+ your contribution-branch workspace or an owned build.
155
194
 
156
195
  ## Project File Limits
157
196
 
@@ -252,5 +291,6 @@ export const COMMANDS = new Set([
252
291
  "launch",
253
292
  "sdk",
254
293
  "assets",
294
+ "thumbnail",
255
295
  "help",
256
296
  ]);
@@ -0,0 +1,330 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+
4
+ import {
5
+ ASSET_GENERATE_TIMEOUT_MS,
6
+ THUMBNAIL_CAPTURE_TIMEOUT_MS,
7
+ THUMBNAIL_CONTENT_TYPE_BY_EXTENSION,
8
+ THUMBNAIL_MAX_FILE_SIZE_BYTES,
9
+ } from "./constants.js";
10
+ import {
11
+ captureBuildThumbnailPreview,
12
+ generateBuildThumbnailRequest,
13
+ loadBuildFiles,
14
+ mintBuildApiToken,
15
+ requestThumbnailSignedUpload,
16
+ updateBuildThumbnailUrl,
17
+ } from "./api.js";
18
+ import { assertAuthScope, ensureAuth } from "./auth.js";
19
+ import { resolveSdkBuildId } from "./sdk.js";
20
+ import {
21
+ confirmPrompt,
22
+ describeGenerateHttpError,
23
+ formatBatteryPercent,
24
+ putAssetPartWithRetry,
25
+ resolveGenerateModel,
26
+ } from "./assets.js";
27
+ import { formatBytes } from "./util.js";
28
+ import { findLocalProjectMetadata } from "./workspace.js";
29
+
30
+ export async function thumbnailCommand(options) {
31
+ const subcommand = String(options.positional[0] || "");
32
+ if (subcommand === "set") {
33
+ await thumbnailSet(options);
34
+ return;
35
+ }
36
+ if (subcommand === "capture") {
37
+ await thumbnailCapture(options);
38
+ return;
39
+ }
40
+ if (subcommand === "generate") {
41
+ await thumbnailGenerate(options);
42
+ return;
43
+ }
44
+ throw new Error(
45
+ 'Usage: lumine thumbnail set <file> | lumine thumbnail capture [--out <file>] | lumine thumbnail generate "<prompt>" --model <gpt-image-2|nano-banana>',
46
+ );
47
+ }
48
+
49
+ async function refuseMainCheckout(options) {
50
+ const local = await findLocalProjectMetadata(
51
+ options.dir ? path.resolve(options.dir) : process.cwd(),
52
+ );
53
+ if (local?.metadata?.mainCheckout === true && !options.buildIdFlag) {
54
+ throw new Error(
55
+ "This is a read-only main checkout. Set the thumbnail from your contribution-branch workspace (or pass --build for a build you own).",
56
+ );
57
+ }
58
+ }
59
+
60
+ async function resolveThumbnailContext(options) {
61
+ await refuseMainCheckout(options);
62
+ const buildId = await resolveSdkBuildId(options);
63
+ const auth = await ensureAuth(options);
64
+ await assertAuthScope({ options, auth, scope: "build:write" });
65
+ const metadata = await loadBuildFiles({
66
+ options,
67
+ auth,
68
+ buildId,
69
+ includeContent: false,
70
+ });
71
+ const build = metadata?.build || {};
72
+ if (build.canWrite === false) {
73
+ throw new Error(
74
+ `You cannot write to Build #${buildId}. Thumbnails can only be set on builds/branches you can save.`,
75
+ );
76
+ }
77
+ return { buildId, auth, build };
78
+ }
79
+
80
+ async function confirmThumbnailOverwrite({ options, build, buildId }) {
81
+ if (options.assumeYes) return true;
82
+ if (!String(build?.thumbnailUrl || "").trim()) return true;
83
+ const confirmed = await confirmPrompt(
84
+ `Build #${buildId} already has a thumbnail. Replace it? [y/N] `,
85
+ );
86
+ if (confirmed === null) {
87
+ console.log("Not a TTY — re-run with --yes to replace the thumbnail.");
88
+ return false;
89
+ }
90
+ if (!confirmed) {
91
+ console.log("Aborted. Thumbnail unchanged.");
92
+ return false;
93
+ }
94
+ return true;
95
+ }
96
+
97
+ export async function commitThumbnailImage({
98
+ options,
99
+ auth,
100
+ buildId,
101
+ buffer,
102
+ contentType,
103
+ }) {
104
+ const { signedRequest, thumbnailUrl } = await requestThumbnailSignedUpload({
105
+ options,
106
+ auth,
107
+ buildId,
108
+ fileSize: buffer.length,
109
+ contentType,
110
+ });
111
+ if (!signedRequest || !thumbnailUrl) {
112
+ throw new Error("The server did not return a thumbnail upload URL.");
113
+ }
114
+ await putAssetPartWithRetry({
115
+ url: signedRequest,
116
+ chunk: buffer,
117
+ mimeType: contentType,
118
+ partLabel: "thumbnail",
119
+ });
120
+ const result = await updateBuildThumbnailUrl({
121
+ options,
122
+ auth,
123
+ buildId,
124
+ thumbnailUrl,
125
+ });
126
+ if (result?.success !== true) {
127
+ throw new Error(result?.error || "Failed to save the thumbnail.");
128
+ }
129
+ return { thumbnailUrl, build: result.build || null };
130
+ }
131
+
132
+ export async function thumbnailSet(options) {
133
+ const filePath = String(options.positional[1] || "").trim();
134
+ if (!filePath) {
135
+ throw new Error("Usage: lumine thumbnail set <file> (jpg, png, or webp)");
136
+ }
137
+ const absolutePath = path.resolve(filePath);
138
+ const extension = path.extname(absolutePath).toLowerCase();
139
+ const contentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[extension];
140
+ if (!contentType) {
141
+ throw new Error(
142
+ `Unsupported thumbnail type for ${path.basename(absolutePath)}. Thumbnails support ${Object.keys(THUMBNAIL_CONTENT_TYPE_BY_EXTENSION).join(", ")}.`,
143
+ );
144
+ }
145
+ let buffer;
146
+ try {
147
+ buffer = await fs.readFile(absolutePath);
148
+ } catch (error) {
149
+ if (error.code === "ENOENT") {
150
+ throw new Error(`Thumbnail file does not exist: ${filePath}`);
151
+ }
152
+ throw error;
153
+ }
154
+ if (!buffer.length) {
155
+ throw new Error(`Thumbnail file is empty: ${filePath}`);
156
+ }
157
+ if (buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
158
+ throw new Error(
159
+ `Thumbnail file is too big (${formatBytes(buffer.length)}; max ${formatBytes(THUMBNAIL_MAX_FILE_SIZE_BYTES)}).`,
160
+ );
161
+ }
162
+
163
+ const { buildId, auth, build } = await resolveThumbnailContext(options);
164
+ const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
165
+ if (!proceed) return;
166
+
167
+ const { thumbnailUrl } = await commitThumbnailImage({
168
+ options,
169
+ auth,
170
+ buildId,
171
+ buffer,
172
+ contentType,
173
+ });
174
+ console.log(`Thumbnail set for Build #${buildId}:`);
175
+ console.log(` ${thumbnailUrl}`);
176
+ console.log("Tip: thumbnails display ~16:9; the image was uploaded uncropped.");
177
+ }
178
+
179
+ function decodeImageDataUrl(dataUrl) {
180
+ const match = String(dataUrl || "").match(
181
+ /^data:(image\/[a-z+.-]+);base64,(.+)$/i,
182
+ );
183
+ if (!match) return null;
184
+ return {
185
+ contentType: match[1].toLowerCase(),
186
+ buffer: Buffer.from(match[2], "base64"),
187
+ };
188
+ }
189
+
190
+ export async function thumbnailCapture(options) {
191
+ const { buildId, auth, build } = await resolveThumbnailContext(options);
192
+ await assertAuthScope({ options, auth, scope: "build:sdk" });
193
+ const proceed = await confirmThumbnailOverwrite({ options, build, buildId });
194
+ if (!proceed) return;
195
+
196
+ const { token: previewToken } = await mintBuildApiToken({
197
+ options,
198
+ auth,
199
+ buildId,
200
+ scopes: ["preview:read"],
201
+ });
202
+ const previewPath = `/build/preview/build/${buildId}/current?rev=${Number(build.updatedAt || 0)}&buildApiToken=${encodeURIComponent(previewToken)}`;
203
+
204
+ console.log("Capturing the app preview server-side... this can take ~30s.");
205
+ const captureResult = await captureBuildThumbnailPreview({
206
+ options,
207
+ auth,
208
+ buildId,
209
+ previewPath,
210
+ timeoutMs: THUMBNAIL_CAPTURE_TIMEOUT_MS,
211
+ });
212
+ const decoded = decodeImageDataUrl(captureResult?.imageUrl);
213
+ if (!decoded) {
214
+ throw new Error(
215
+ captureResult?.error || "Preview capture returned no image.",
216
+ );
217
+ }
218
+ if (decoded.buffer.length > THUMBNAIL_MAX_FILE_SIZE_BYTES) {
219
+ throw new Error("Captured image exceeds the 8MB thumbnail limit.");
220
+ }
221
+ if (options.out) {
222
+ const outPath = path.resolve(String(options.out));
223
+ await fs.writeFile(outPath, decoded.buffer);
224
+ console.log(`Saved capture to ${outPath}`);
225
+ }
226
+ const normalizedContentType = THUMBNAIL_CONTENT_TYPE_BY_EXTENSION[
227
+ `.${decoded.contentType.split("/")[1] || "png"}`
228
+ ]
229
+ ? decoded.contentType
230
+ : "image/png";
231
+ const { thumbnailUrl } = await commitThumbnailImage({
232
+ options,
233
+ auth,
234
+ buildId,
235
+ buffer: decoded.buffer,
236
+ contentType: normalizedContentType,
237
+ });
238
+ console.log(`Thumbnail captured and set for Build #${buildId}:`);
239
+ console.log(` ${thumbnailUrl}`);
240
+ }
241
+
242
+ export async function thumbnailGenerate(options) {
243
+ const prompt = String(options.positional[1] || "").trim();
244
+ const { model, quality } = resolveGenerateModel(options);
245
+ const { buildId, auth, build } = await resolveThumbnailContext(options);
246
+
247
+ const requestBody = {
248
+ model,
249
+ ...(quality ? { quality } : {}),
250
+ ...(prompt ? { prompt } : {}),
251
+ };
252
+
253
+ if (!options.assumeYes) {
254
+ let estimate = null;
255
+ try {
256
+ const estimateResult = await generateBuildThumbnailRequest({
257
+ options,
258
+ auth,
259
+ buildId,
260
+ body: { ...requestBody, estimateOnly: true },
261
+ });
262
+ estimate = estimateResult?.estimate || null;
263
+ } catch (error) {
264
+ const friendly = describeGenerateHttpError(error);
265
+ if (friendly) throw new Error(friendly);
266
+ throw error;
267
+ }
268
+ const selectedOption = (estimate?.options || []).find(
269
+ (option) => option.model === model,
270
+ );
271
+ console.log(
272
+ `Generate a thumbnail with ${model}${quality ? ` (quality ${quality})` : ""}${prompt ? "" : " (prompt auto-composed from the build title/description)"}?`,
273
+ );
274
+ if (String(build?.thumbnailUrl || "").trim()) {
275
+ console.log(" This will REPLACE the existing thumbnail.");
276
+ }
277
+ if (selectedOption) {
278
+ console.log(
279
+ ` Estimated battery cost: ${formatBatteryPercent(selectedOption.energyUnits, estimate?.fullBatteryUnits)} of a full AI battery (~$${Number(selectedOption.estimatedUsd || 0).toFixed(2)})`,
280
+ );
281
+ }
282
+ if (estimate) {
283
+ console.log(
284
+ ` Battery remaining now: ${formatBatteryPercent(estimate.energyRemaining, estimate.fullBatteryUnits)}`,
285
+ );
286
+ }
287
+ const confirmed = await confirmPrompt("Proceed? [y/N] ");
288
+ if (confirmed === null) {
289
+ console.log("Not a TTY — re-run with --yes to generate.");
290
+ return;
291
+ }
292
+ if (!confirmed) {
293
+ console.log("Aborted. Nothing generated.");
294
+ return;
295
+ }
296
+ }
297
+
298
+ console.log("Generating the thumbnail... this can take a minute.");
299
+ let result;
300
+ try {
301
+ result = await generateBuildThumbnailRequest({
302
+ options,
303
+ auth,
304
+ buildId,
305
+ body: requestBody,
306
+ timeoutMs: ASSET_GENERATE_TIMEOUT_MS,
307
+ });
308
+ } catch (error) {
309
+ const friendly = describeGenerateHttpError(error);
310
+ if (friendly) throw new Error(friendly);
311
+ throw error;
312
+ }
313
+ if (result?.success !== true || !result?.thumbnailUrl) {
314
+ throw new Error(result?.error || "Thumbnail generation failed.");
315
+ }
316
+ console.log(`Thumbnail generated and set for Build #${buildId}:`);
317
+ console.log(` ${result.thumbnailUrl}`);
318
+ if (result?.asset?.url) {
319
+ console.log(
320
+ `The generated image is also stored as reusable asset #${result.asset.id}:`,
321
+ );
322
+ console.log(` ${result.asset.url}`);
323
+ }
324
+ const remaining = result?.aiUsagePolicy?.energyRemaining;
325
+ if (Number.isFinite(Number(remaining))) {
326
+ console.log(
327
+ `Battery remaining: ${formatBatteryPercent(Number(remaining), result?.aiUsagePolicy?.fullBatteryUnits)}`,
328
+ );
329
+ }
330
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {