@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 +26 -0
- package/lib/api.js +65 -1
- package/lib/assets.js +164 -1
- package/lib/commands.js +23 -2
- package/lib/constants.js +40 -0
- package/lib/thumbnail.js +330 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
--
|
|
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
|
]);
|
package/lib/thumbnail.js
ADDED
|
@@ -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
|
+
}
|