pixelkiln 0.20.2 → 0.22.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/README.md +15 -14
- package/dist/cli.d.ts +6 -2
- package/dist/cli.js +3763 -562
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +3349 -316
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +864 -148
- package/dist/index.d.ts +864 -148
- package/dist/index.js +3335 -317
- package/dist/index.js.map +1 -1
- package/docs/CLI.md +130 -6
- package/docs/GETTING_STARTED.md +9 -0
- package/docs/LIBRARY.md +58 -0
- package/package.json +1 -1
- package/skills/pixelkiln/SKILL.md +5 -0
package/dist/index.js
CHANGED
|
@@ -509,8 +509,8 @@ function validateResponse(schema, raw, operation) {
|
|
|
509
509
|
throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
|
|
510
510
|
}
|
|
511
511
|
var PixelLabError = class extends Error {
|
|
512
|
-
constructor(
|
|
513
|
-
super(
|
|
512
|
+
constructor(message6, status, body) {
|
|
513
|
+
super(message6);
|
|
514
514
|
this.status = status;
|
|
515
515
|
this.body = body;
|
|
516
516
|
this.name = "PixelLabError";
|
|
@@ -535,11 +535,11 @@ var PixelLabClient = class {
|
|
|
535
535
|
* common than the failure mode of retrying (a duplicate object), and a
|
|
536
536
|
* duplicate is visible and free to delete whereas a silent gap is neither.
|
|
537
537
|
*/
|
|
538
|
-
async request(
|
|
538
|
+
async request(path25, init, attempt = 0) {
|
|
539
539
|
const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
|
|
540
540
|
let res;
|
|
541
541
|
try {
|
|
542
|
-
res = await fetch(`${BASE}${
|
|
542
|
+
res = await fetch(`${BASE}${path25}`, {
|
|
543
543
|
...init,
|
|
544
544
|
signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
|
|
545
545
|
headers: {
|
|
@@ -551,24 +551,24 @@ var PixelLabClient = class {
|
|
|
551
551
|
} catch (err) {
|
|
552
552
|
if (attempt < MAX_RETRIES) {
|
|
553
553
|
await sleep(backoffMs(attempt));
|
|
554
|
-
return this.request(
|
|
554
|
+
return this.request(path25, init, attempt + 1);
|
|
555
555
|
}
|
|
556
556
|
throw err;
|
|
557
557
|
}
|
|
558
558
|
if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
|
|
559
559
|
const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
|
|
560
560
|
await sleep(waitMs);
|
|
561
|
-
return this.request(
|
|
561
|
+
return this.request(path25, init, attempt + 1);
|
|
562
562
|
}
|
|
563
563
|
const text = await res.text();
|
|
564
564
|
if (!res.ok) {
|
|
565
|
-
throw new PixelLabError(`${init?.method ?? "GET"} ${
|
|
565
|
+
throw new PixelLabError(`${init?.method ?? "GET"} ${path25} \u2192 ${res.status}`, res.status, text);
|
|
566
566
|
}
|
|
567
567
|
if (!text) return {};
|
|
568
568
|
try {
|
|
569
569
|
return JSON.parse(text);
|
|
570
570
|
} catch {
|
|
571
|
-
throw new Error(`${init?.method ?? "GET"} ${
|
|
571
|
+
throw new Error(`${init?.method ?? "GET"} ${path25} returned invalid JSON`);
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
async balance() {
|
|
@@ -1615,9 +1615,9 @@ var RetroDiffusionClient = class {
|
|
|
1615
1615
|
}
|
|
1616
1616
|
return balance;
|
|
1617
1617
|
}
|
|
1618
|
-
async call(
|
|
1618
|
+
async call(path25, init = {}) {
|
|
1619
1619
|
if (!this.token) throw new Error("RD_API_KEY is not set");
|
|
1620
|
-
const response = await this.request(`${this.baseUrl}${
|
|
1620
|
+
const response = await this.request(`${this.baseUrl}${path25}`, {
|
|
1621
1621
|
...init,
|
|
1622
1622
|
headers: {
|
|
1623
1623
|
"Content-Type": "application/json",
|
|
@@ -1919,8 +1919,8 @@ function retroError(value) {
|
|
|
1919
1919
|
if (details.length) return details.join("; ");
|
|
1920
1920
|
}
|
|
1921
1921
|
if (record.detail && typeof record.detail === "object") {
|
|
1922
|
-
const
|
|
1923
|
-
if (typeof
|
|
1922
|
+
const message6 = record.detail.message;
|
|
1923
|
+
if (typeof message6 === "string") return message6;
|
|
1924
1924
|
}
|
|
1925
1925
|
return JSON.stringify(value);
|
|
1926
1926
|
}
|
|
@@ -2376,13 +2376,13 @@ function validProgress(progress) {
|
|
|
2376
2376
|
}
|
|
2377
2377
|
return Math.round(progress * 100);
|
|
2378
2378
|
}
|
|
2379
|
-
function finiteNonnegative(value,
|
|
2380
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(
|
|
2379
|
+
function finiteNonnegative(value, message6) {
|
|
2380
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(message6);
|
|
2381
2381
|
return value;
|
|
2382
2382
|
}
|
|
2383
|
-
function optionalFiniteNonnegative(value,
|
|
2383
|
+
function optionalFiniteNonnegative(value, message6) {
|
|
2384
2384
|
if (value == null) return 0;
|
|
2385
|
-
return finiteNonnegative(value,
|
|
2385
|
+
return finiteNonnegative(value, message6);
|
|
2386
2386
|
}
|
|
2387
2387
|
function numericRecord(value) {
|
|
2388
2388
|
if (value == null) return {};
|
|
@@ -2420,10 +2420,10 @@ import { createReadStream } from "fs";
|
|
|
2420
2420
|
function sha256(data) {
|
|
2421
2421
|
return createHash("sha256").update(data).digest("hex");
|
|
2422
2422
|
}
|
|
2423
|
-
async function sha256File(
|
|
2423
|
+
async function sha256File(path25) {
|
|
2424
2424
|
return new Promise((resolve, reject) => {
|
|
2425
2425
|
const hash = createHash("sha256");
|
|
2426
|
-
const stream = createReadStream(
|
|
2426
|
+
const stream = createReadStream(path25);
|
|
2427
2427
|
stream.on("data", (chunk2) => hash.update(chunk2));
|
|
2428
2428
|
stream.on("error", reject);
|
|
2429
2429
|
stream.on("end", () => resolve(hash.digest("hex")));
|
|
@@ -3312,9 +3312,9 @@ function normalizeBaseUrl(value) {
|
|
|
3312
3312
|
}
|
|
3313
3313
|
function historyError(status) {
|
|
3314
3314
|
const messages = Array.isArray(status.messages) ? status.messages : [];
|
|
3315
|
-
for (const
|
|
3316
|
-
if (!Array.isArray(
|
|
3317
|
-
const detail =
|
|
3315
|
+
for (const message6 of messages) {
|
|
3316
|
+
if (!Array.isArray(message6) || message6[0] !== "execution_error" || !isObject(message6[1])) continue;
|
|
3317
|
+
const detail = message6[1].exception_message;
|
|
3318
3318
|
if (typeof detail === "string" && detail.trim()) {
|
|
3319
3319
|
return `ComfyUI execution failed: ${detail.trim().slice(0, 300)}`;
|
|
3320
3320
|
}
|
|
@@ -6746,10 +6746,10 @@ function isSha256Hash(value) {
|
|
|
6746
6746
|
function parseCache(value) {
|
|
6747
6747
|
return HashCacheSchema.parse(value);
|
|
6748
6748
|
}
|
|
6749
|
-
async function loadCache(
|
|
6750
|
-
if (!existsSync10(
|
|
6749
|
+
async function loadCache(path25) {
|
|
6750
|
+
if (!existsSync10(path25)) return { version: 1, hashes: {} };
|
|
6751
6751
|
try {
|
|
6752
|
-
const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(
|
|
6752
|
+
const parsed = HashCacheSchema.safeParse(JSON.parse(await readFile9(path25, "utf8")));
|
|
6753
6753
|
if (!parsed.success) return { version: 1, hashes: {} };
|
|
6754
6754
|
return {
|
|
6755
6755
|
version: 1,
|
|
@@ -6761,18 +6761,18 @@ async function loadCache(path23) {
|
|
|
6761
6761
|
return { version: 1, hashes: {} };
|
|
6762
6762
|
}
|
|
6763
6763
|
}
|
|
6764
|
-
async function saveCache(
|
|
6764
|
+
async function saveCache(path25, cache) {
|
|
6765
6765
|
const sorted = {};
|
|
6766
6766
|
for (const key of Object.keys(cache.hashes).sort()) {
|
|
6767
6767
|
const hash = cache.hashes[key];
|
|
6768
6768
|
if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
|
|
6769
6769
|
sorted[key] = hash;
|
|
6770
6770
|
}
|
|
6771
|
-
await mkdir3(pathModule.dirname(pathModule.resolve(
|
|
6772
|
-
const tmp = `${
|
|
6771
|
+
await mkdir3(pathModule.dirname(pathModule.resolve(path25)), { recursive: true });
|
|
6772
|
+
const tmp = `${path25}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
6773
6773
|
try {
|
|
6774
6774
|
await writeFile3(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
|
|
6775
|
-
await rename3(tmp,
|
|
6775
|
+
await rename3(tmp, path25);
|
|
6776
6776
|
} finally {
|
|
6777
6777
|
await rm4(tmp, { force: true });
|
|
6778
6778
|
}
|
|
@@ -7603,7 +7603,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
7603
7603
|
` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
|
|
7604
7604
|
);
|
|
7605
7605
|
} catch (err) {
|
|
7606
|
-
const
|
|
7606
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
7607
7607
|
const entry = lock.entries[key];
|
|
7608
7608
|
if (entry.jobId && entry.submissionComplete === true) {
|
|
7609
7609
|
upsert(lock, key, { status: "processing", error: null });
|
|
@@ -7615,10 +7615,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
7615
7615
|
failed++;
|
|
7616
7616
|
upsert(lock, key, {
|
|
7617
7617
|
status: "failed",
|
|
7618
|
-
error:
|
|
7618
|
+
error: message6,
|
|
7619
7619
|
cost: entry.jobId ? estimate.amount : 0
|
|
7620
7620
|
});
|
|
7621
|
-
log(` FAILED ${key}: ${
|
|
7621
|
+
log(` FAILED ${key}: ${message6}`);
|
|
7622
7622
|
}
|
|
7623
7623
|
}
|
|
7624
7624
|
await saveLock(lockPath, lock);
|
|
@@ -7847,14 +7847,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
|
|
|
7847
7847
|
});
|
|
7848
7848
|
result.downloaded++;
|
|
7849
7849
|
} catch (err) {
|
|
7850
|
-
const
|
|
7850
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
7851
7851
|
upsert(lock, key, {
|
|
7852
7852
|
status: "download-failed",
|
|
7853
7853
|
outputs: mergeOutputs(entry.outputs, outputs),
|
|
7854
|
-
error: `download failed: ${
|
|
7854
|
+
error: `download failed: ${message6}`
|
|
7855
7855
|
});
|
|
7856
7856
|
result.failed++;
|
|
7857
|
-
log(` FAILED ${key}: ${
|
|
7857
|
+
log(` FAILED ${key}: ${message6}`);
|
|
7858
7858
|
}
|
|
7859
7859
|
await saveLock(lockPath, lock);
|
|
7860
7860
|
}
|
|
@@ -7924,7 +7924,7 @@ import { access, stat as stat2 } from "fs/promises";
|
|
|
7924
7924
|
import path17 from "path";
|
|
7925
7925
|
async function doctor(loaded, specs, lock, lockPath, opts = {}) {
|
|
7926
7926
|
const checks = [];
|
|
7927
|
-
const add = (id, level,
|
|
7927
|
+
const add = (id, level, message6) => checks.push({ id, level, message: message6 });
|
|
7928
7928
|
add(
|
|
7929
7929
|
"manifest",
|
|
7930
7930
|
"ok",
|
|
@@ -8228,9 +8228,107 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
|
|
|
8228
8228
|
return count;
|
|
8229
8229
|
}
|
|
8230
8230
|
|
|
8231
|
+
// src/pick/server.ts
|
|
8232
|
+
import { existsSync as existsSync16 } from "fs";
|
|
8233
|
+
import { readFile as readFile15 } from "fs/promises";
|
|
8234
|
+
|
|
8235
|
+
// src/pipeline/init.ts
|
|
8236
|
+
import { readdir as readdir3, readFile as readFile13, writeFile as writeFile6 } from "fs/promises";
|
|
8237
|
+
import { existsSync as existsSync15 } from "fs";
|
|
8238
|
+
import path19 from "path";
|
|
8239
|
+
function pngSize(buf) {
|
|
8240
|
+
if (buf.length < 24) return null;
|
|
8241
|
+
const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
|
|
8242
|
+
if (!isPng) return null;
|
|
8243
|
+
if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
|
|
8244
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
8245
|
+
}
|
|
8246
|
+
function slugify(value) {
|
|
8247
|
+
return value.replace(/\.png$/i, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
|
|
8248
|
+
}
|
|
8249
|
+
async function walk(dir, exclude) {
|
|
8250
|
+
const out = [];
|
|
8251
|
+
for (const entry of await readdir3(dir, { withFileTypes: true })) {
|
|
8252
|
+
const full = path19.join(dir, entry.name);
|
|
8253
|
+
if (exclude.some((x) => full.includes(x))) continue;
|
|
8254
|
+
if (entry.isDirectory()) out.push(...await walk(full, exclude));
|
|
8255
|
+
else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
|
|
8256
|
+
}
|
|
8257
|
+
return out;
|
|
8258
|
+
}
|
|
8259
|
+
async function scanAssets(root, opts = {}) {
|
|
8260
|
+
const exclude = opts.exclude ?? [];
|
|
8261
|
+
const files = await walk(root, exclude);
|
|
8262
|
+
const assets = [];
|
|
8263
|
+
const skipped = [];
|
|
8264
|
+
const seen = /* @__PURE__ */ new Map();
|
|
8265
|
+
for (const file of files.sort()) {
|
|
8266
|
+
const size = pngSize(await readFile13(file));
|
|
8267
|
+
if (!size) {
|
|
8268
|
+
skipped.push(`${file} (not a readable PNG)`);
|
|
8269
|
+
continue;
|
|
8270
|
+
}
|
|
8271
|
+
const rel = path19.relative(root, file);
|
|
8272
|
+
const category = path19.dirname(rel) === "." ? "" : path19.dirname(rel);
|
|
8273
|
+
let id = slugify(path19.basename(rel));
|
|
8274
|
+
if (seen.has(id)) {
|
|
8275
|
+
const n = seen.get(id) + 1;
|
|
8276
|
+
seen.set(id, n);
|
|
8277
|
+
id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
|
|
8278
|
+
} else {
|
|
8279
|
+
seen.set(id, 1);
|
|
8280
|
+
}
|
|
8281
|
+
assets.push({ id, category, file: rel, width: size.width, height: size.height });
|
|
8282
|
+
}
|
|
8283
|
+
return { assets, skipped };
|
|
8284
|
+
}
|
|
8285
|
+
function buildManifest(name, styleId, generator, outDir, scanned) {
|
|
8286
|
+
const squares = scanned.filter((a) => a.width === a.height);
|
|
8287
|
+
const commonSize = squares.length ? mode(squares.map((a) => a.width)) : 64;
|
|
8288
|
+
const assets = {};
|
|
8289
|
+
for (const asset of scanned) {
|
|
8290
|
+
assets[asset.id] = {
|
|
8291
|
+
prompt: "",
|
|
8292
|
+
promptByStyle: {},
|
|
8293
|
+
category: asset.category || void 0,
|
|
8294
|
+
file: asset.file,
|
|
8295
|
+
tags: [],
|
|
8296
|
+
styles: [],
|
|
8297
|
+
providerInputs: {},
|
|
8298
|
+
...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
|
|
8299
|
+
};
|
|
8300
|
+
}
|
|
8301
|
+
return {
|
|
8302
|
+
name,
|
|
8303
|
+
provider: "pixellab",
|
|
8304
|
+
styles: {
|
|
8305
|
+
[styleId]: {
|
|
8306
|
+
generator,
|
|
8307
|
+
size: commonSize,
|
|
8308
|
+
// Matches the schema default: a scaffolded style is for sprites, and
|
|
8309
|
+
// a sprite wants its background stripped.
|
|
8310
|
+
noBackground: true,
|
|
8311
|
+
promptPrefix: "",
|
|
8312
|
+
promptSuffix: "",
|
|
8313
|
+
styleImages: [],
|
|
8314
|
+
palette: [],
|
|
8315
|
+
outDir,
|
|
8316
|
+
tags: [name],
|
|
8317
|
+
providerOptions: {}
|
|
8318
|
+
}
|
|
8319
|
+
},
|
|
8320
|
+
assets
|
|
8321
|
+
};
|
|
8322
|
+
}
|
|
8323
|
+
function mode(values) {
|
|
8324
|
+
const counts = /* @__PURE__ */ new Map();
|
|
8325
|
+
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
8326
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
|
|
8327
|
+
}
|
|
8328
|
+
|
|
8231
8329
|
// src/pick/sheet.ts
|
|
8232
8330
|
var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8233
|
-
function renderSheet(groups) {
|
|
8331
|
+
function renderSheet(groups, options = {}) {
|
|
8234
8332
|
const safe = groups.map((g) => ({
|
|
8235
8333
|
...g,
|
|
8236
8334
|
styleId: escapeHtml(g.styleId),
|
|
@@ -8244,6 +8342,11 @@ function renderSheet(groups) {
|
|
|
8244
8342
|
} : {}
|
|
8245
8343
|
}));
|
|
8246
8344
|
const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
|
|
8345
|
+
const apply = JSON.stringify({
|
|
8346
|
+
url: options.applyUrl ?? "/apply",
|
|
8347
|
+
headers: options.applyHeaders ?? {},
|
|
8348
|
+
embedded: options.embedded === true
|
|
8349
|
+
}).replace(/<\//g, "<\\/");
|
|
8247
8350
|
return `<!doctype html>
|
|
8248
8351
|
<html lang="en">
|
|
8249
8352
|
<head>
|
|
@@ -8286,7 +8389,7 @@ function renderSheet(groups) {
|
|
|
8286
8389
|
align-items:start; }
|
|
8287
8390
|
.review.no-context { display:block; }
|
|
8288
8391
|
.context { border:1px solid var(--line); padding:8px; background:var(--panel-deep);
|
|
8289
|
-
min-width:0; }
|
|
8392
|
+
min-width:0; display:grid; gap:12px; align-content:start; }
|
|
8290
8393
|
.context-label { display:flex; justify-content:space-between; gap:8px; margin-bottom:7px;
|
|
8291
8394
|
color:var(--dim); font-size:10.5px; font-variant-numeric:tabular-nums; }
|
|
8292
8395
|
.context img { display:block; width:100%; height:auto; max-height:420px; object-fit:contain;
|
|
@@ -8355,6 +8458,7 @@ function renderSheet(groups) {
|
|
|
8355
8458
|
</footer>
|
|
8356
8459
|
<script>
|
|
8357
8460
|
const GROUPS = ${data};
|
|
8461
|
+
const APPLY = ${apply};
|
|
8358
8462
|
const picks = new Map();
|
|
8359
8463
|
const root = document.getElementById('root');
|
|
8360
8464
|
document.getElementById('total').textContent = GROUPS.length;
|
|
@@ -8375,24 +8479,40 @@ GROUPS.forEach((g, gi) => {
|
|
|
8375
8479
|
'<div class="ghead"><span class="style">' + g.styleId + '</span>' +
|
|
8376
8480
|
'<span class="aid">' + g.assetId + '</span>' +
|
|
8377
8481
|
'<span class="prompt">' + g.prompt + '</span></div>' +
|
|
8378
|
-
'<div class="review' + (g.revision ? '' : ' no-context') + '">' +
|
|
8482
|
+
'<div class="review' + (g.revision || g.current ? '' : ' no-context') + '">' +
|
|
8379
8483
|
'<div class="frames"></div></div>';
|
|
8380
8484
|
const review = el.querySelector('.review');
|
|
8381
8485
|
const frames = el.querySelector('.frames');
|
|
8382
|
-
|
|
8383
|
-
const
|
|
8384
|
-
context.className = 'context';
|
|
8486
|
+
const contextPanel = (identityText, dimensionsText, url, alt) => {
|
|
8487
|
+
const block = document.createElement('div');
|
|
8385
8488
|
const label = document.createElement('div');
|
|
8386
8489
|
label.className = 'context-label';
|
|
8387
8490
|
const identity = document.createElement('span');
|
|
8388
|
-
identity.textContent =
|
|
8491
|
+
identity.textContent = identityText;
|
|
8389
8492
|
const dimensions = document.createElement('span');
|
|
8390
|
-
dimensions.textContent =
|
|
8493
|
+
dimensions.textContent = dimensionsText;
|
|
8391
8494
|
label.append(identity, dimensions);
|
|
8392
|
-
const
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8495
|
+
const img = document.createElement('img');
|
|
8496
|
+
img.src = url;
|
|
8497
|
+
img.alt = alt;
|
|
8498
|
+
block.append(label, img);
|
|
8499
|
+
return block;
|
|
8500
|
+
};
|
|
8501
|
+
if (g.revision || g.current) {
|
|
8502
|
+
const context = document.createElement('aside');
|
|
8503
|
+
context.className = 'context';
|
|
8504
|
+
if (g.revision) {
|
|
8505
|
+
context.append(contextPanel(
|
|
8506
|
+
g.revision.mode.toUpperCase() + ' \xB7 SOURCE ' + g.revision.sourceAssetId,
|
|
8507
|
+
g.revision.width + '\xD7' + g.revision.height,
|
|
8508
|
+
g.revision.sourceUrl, 'Revision source ' + g.revision.sourceAssetId));
|
|
8509
|
+
}
|
|
8510
|
+
if (g.current) {
|
|
8511
|
+
context.append(contextPanel(
|
|
8512
|
+
'CURRENT ART \xB7 replaced if you choose',
|
|
8513
|
+
g.current.width + '\xD7' + g.current.height,
|
|
8514
|
+
g.current.url, 'Current ' + g.assetId));
|
|
8515
|
+
}
|
|
8396
8516
|
review.insertBefore(context, frames);
|
|
8397
8517
|
}
|
|
8398
8518
|
if (g.mode === 'frame-set') {
|
|
@@ -8576,13 +8696,17 @@ document.getElementById('submit').onclick = async () => {
|
|
|
8576
8696
|
status.textContent = 'applying\u2026';
|
|
8577
8697
|
const selections = [...picks.entries()].map(([gi, index]) => ({ key: GROUPS[gi].key, index }));
|
|
8578
8698
|
try {
|
|
8579
|
-
const res = await fetch(
|
|
8580
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
8699
|
+
const res = await fetch(APPLY.url, {
|
|
8700
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', ...APPLY.headers },
|
|
8581
8701
|
body: JSON.stringify({ selections }),
|
|
8582
8702
|
});
|
|
8583
8703
|
if (!res.ok) throw new Error(await res.text());
|
|
8584
|
-
|
|
8704
|
+
const result = await res.json();
|
|
8705
|
+
status.textContent = APPLY.embedded ? 'applied' : 'done \u2014 you can close this tab';
|
|
8585
8706
|
document.body.style.opacity = '.6';
|
|
8707
|
+
if (APPLY.embedded && window.parent !== window) {
|
|
8708
|
+
window.parent.postMessage({ type: 'pixelkiln:review-applied', result }, location.origin);
|
|
8709
|
+
}
|
|
8586
8710
|
} catch (err) {
|
|
8587
8711
|
status.textContent = 'failed: ' + err.message;
|
|
8588
8712
|
btn.disabled = false;
|
|
@@ -8596,7 +8720,7 @@ document.getElementById('submit').onclick = async () => {
|
|
|
8596
8720
|
// src/pick/review-server.ts
|
|
8597
8721
|
import { createServer } from "http";
|
|
8598
8722
|
import { spawn } from "child_process";
|
|
8599
|
-
import { readFile as
|
|
8723
|
+
import { readFile as readFile14 } from "fs/promises";
|
|
8600
8724
|
function serveReviewPage(opts) {
|
|
8601
8725
|
const log = opts.onProgress ?? (() => {
|
|
8602
8726
|
});
|
|
@@ -8605,7 +8729,7 @@ function serveReviewPage(opts) {
|
|
|
8605
8729
|
if (req.method === "GET" && req.url && opts.assets?.has(req.url)) {
|
|
8606
8730
|
try {
|
|
8607
8731
|
const asset = opts.assets.get(req.url);
|
|
8608
|
-
const bytes = await
|
|
8732
|
+
const bytes = await readFile14(asset.path);
|
|
8609
8733
|
res.writeHead(200, {
|
|
8610
8734
|
"Content-Type": asset.contentType,
|
|
8611
8735
|
"Cache-Control": "no-store",
|
|
@@ -8654,10 +8778,10 @@ function serveReviewPage(opts) {
|
|
|
8654
8778
|
res.end(JSON.stringify(result));
|
|
8655
8779
|
server.close(() => resolve(result));
|
|
8656
8780
|
} catch (err) {
|
|
8657
|
-
const
|
|
8658
|
-
log(` apply failed: ${
|
|
8781
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
8782
|
+
log(` apply failed: ${message6}`);
|
|
8659
8783
|
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
8660
|
-
res.end(
|
|
8784
|
+
res.end(message6);
|
|
8661
8785
|
}
|
|
8662
8786
|
return;
|
|
8663
8787
|
}
|
|
@@ -8677,7 +8801,23 @@ function serveReviewPage(opts) {
|
|
|
8677
8801
|
}
|
|
8678
8802
|
|
|
8679
8803
|
// src/pick/server.ts
|
|
8680
|
-
async function
|
|
8804
|
+
async function currentArt(entry, spec) {
|
|
8805
|
+
if (!spec || !entry.supersededOutputs?.length) return null;
|
|
8806
|
+
const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
|
|
8807
|
+
if (!previous) return null;
|
|
8808
|
+
const file = resolveOutputPath(previous.path, spec.root);
|
|
8809
|
+
if (!existsSync16(file)) return null;
|
|
8810
|
+
const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
|
|
8811
|
+
if (!contentType) return null;
|
|
8812
|
+
let width = entry.width;
|
|
8813
|
+
let height = entry.height;
|
|
8814
|
+
if (contentType === "image/png") {
|
|
8815
|
+
const size = pngSize(await readFile15(file));
|
|
8816
|
+
if (size) ({ width, height } = size);
|
|
8817
|
+
}
|
|
8818
|
+
return { path: file, contentType, width, height };
|
|
8819
|
+
}
|
|
8820
|
+
async function prepareReview(provider, lock, opts = {}) {
|
|
8681
8821
|
const log = opts.onProgress ?? (() => {
|
|
8682
8822
|
});
|
|
8683
8823
|
const selectedKeys = opts.keys ? new Set(opts.keys) : null;
|
|
@@ -8685,6 +8825,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
8685
8825
|
(opts.specs ?? []).map((spec) => [lockKey(spec.styleId, spec.assetId), spec])
|
|
8686
8826
|
);
|
|
8687
8827
|
const reviewAssets = /* @__PURE__ */ new Map();
|
|
8828
|
+
const prefix = opts.routePrefix ?? "";
|
|
8688
8829
|
const groups = [];
|
|
8689
8830
|
for (const [key, entry] of Object.entries(lock.entries)) {
|
|
8690
8831
|
if (entry.provider !== provider.id || selectedKeys && !selectedKeys.has(key)) continue;
|
|
@@ -8694,13 +8835,18 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
8694
8835
|
const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
|
|
8695
8836
|
if (state.status !== "review" && state.status !== "review-set" || (state.status === "review" ? !state.candidateUrls.length : !state.frameUrls.length)) continue;
|
|
8696
8837
|
const frameUrls = state.status === "review" ? state.candidateUrls : state.frameUrls;
|
|
8697
|
-
const sourceRoute = spec?.revision ?
|
|
8838
|
+
const sourceRoute = spec?.revision ? `${prefix}/revision-source/${encodeURIComponent(String(groups.length))}` : null;
|
|
8698
8839
|
if (sourceRoute && spec?.revision) {
|
|
8699
8840
|
reviewAssets.set(sourceRoute, {
|
|
8700
8841
|
path: spec.revision.sourceFile,
|
|
8701
8842
|
contentType: spec.revision.sourceFormat === "jpeg" ? "image/jpeg" : "image/png"
|
|
8702
8843
|
});
|
|
8703
8844
|
}
|
|
8845
|
+
const current = await currentArt(entry, spec);
|
|
8846
|
+
const currentRoute = current ? `${prefix}/current-art/${encodeURIComponent(String(groups.length))}` : null;
|
|
8847
|
+
if (current && currentRoute) {
|
|
8848
|
+
reviewAssets.set(currentRoute, { path: current.path, contentType: current.contentType });
|
|
8849
|
+
}
|
|
8704
8850
|
groups.push({
|
|
8705
8851
|
key,
|
|
8706
8852
|
assetId: entry.assetId,
|
|
@@ -8723,33 +8869,20 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
8723
8869
|
width: spec.revision.sourceWidth,
|
|
8724
8870
|
height: spec.revision.sourceHeight
|
|
8725
8871
|
}
|
|
8726
|
-
} : {}
|
|
8872
|
+
} : {},
|
|
8873
|
+
...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {}
|
|
8727
8874
|
});
|
|
8728
8875
|
} catch (err) {
|
|
8729
8876
|
log(` could not load candidates for ${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8730
8877
|
}
|
|
8731
8878
|
}
|
|
8732
|
-
if (groups.length === 0) return
|
|
8733
|
-
const html = renderSheet(groups);
|
|
8879
|
+
if (groups.length === 0) return null;
|
|
8734
8880
|
const byKey = new Map(groups.map((g) => [g.key, g]));
|
|
8735
|
-
return
|
|
8736
|
-
|
|
8737
|
-
port: opts.port,
|
|
8738
|
-
open: opts.open,
|
|
8739
|
-
onProgress: log,
|
|
8881
|
+
return {
|
|
8882
|
+
groups,
|
|
8740
8883
|
assets: reviewAssets,
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
if (opts.onReady) {
|
|
8744
|
-
opts.onReady(info);
|
|
8745
|
-
} else {
|
|
8746
|
-
log(`
|
|
8747
|
-
${groups.length} asset(s) awaiting selection: ${url}`);
|
|
8748
|
-
log(` (leave this running; it exits once you apply)
|
|
8749
|
-
`);
|
|
8750
|
-
}
|
|
8751
|
-
},
|
|
8752
|
-
handleApply: async (body) => {
|
|
8884
|
+
html: (options) => renderSheet(groups, options),
|
|
8885
|
+
apply: async (body, lockPath) => {
|
|
8753
8886
|
const { selections } = body;
|
|
8754
8887
|
let selected = 0;
|
|
8755
8888
|
for (const { key, index } of selections) {
|
|
@@ -8809,117 +8942,3120 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
8809
8942
|
await saveLock(lockPath, lock);
|
|
8810
8943
|
return { selected, skipped: groups.length - selected };
|
|
8811
8944
|
}
|
|
8945
|
+
};
|
|
8946
|
+
}
|
|
8947
|
+
async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
8948
|
+
const log = opts.onProgress ?? (() => {
|
|
8949
|
+
});
|
|
8950
|
+
const session = await prepareReview(provider, lock, {
|
|
8951
|
+
onProgress: log,
|
|
8952
|
+
keys: opts.keys,
|
|
8953
|
+
specs: opts.specs
|
|
8954
|
+
});
|
|
8955
|
+
if (!session) return { selected: 0, skipped: 0 };
|
|
8956
|
+
const groups = session.groups;
|
|
8957
|
+
return serveReviewPage({
|
|
8958
|
+
html: session.html(),
|
|
8959
|
+
port: opts.port,
|
|
8960
|
+
open: opts.open,
|
|
8961
|
+
onProgress: log,
|
|
8962
|
+
assets: session.assets,
|
|
8963
|
+
onReady: (url) => {
|
|
8964
|
+
const info = { url, keys: groups.map((group) => group.key) };
|
|
8965
|
+
if (opts.onReady) {
|
|
8966
|
+
opts.onReady(info);
|
|
8967
|
+
} else {
|
|
8968
|
+
log(`
|
|
8969
|
+
${groups.length} asset(s) awaiting selection: ${url}`);
|
|
8970
|
+
log(` (leave this running; it exits once you apply)
|
|
8971
|
+
`);
|
|
8972
|
+
}
|
|
8973
|
+
},
|
|
8974
|
+
handleApply: (body) => session.apply(body, lockPath)
|
|
8812
8975
|
});
|
|
8813
8976
|
}
|
|
8814
8977
|
|
|
8815
|
-
// src/
|
|
8816
|
-
import {
|
|
8817
|
-
import { existsSync as
|
|
8818
|
-
import
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
}
|
|
8826
|
-
|
|
8827
|
-
|
|
8978
|
+
// src/gallery/snapshot.ts
|
|
8979
|
+
import { createHash as createHash4 } from "crypto";
|
|
8980
|
+
import { existsSync as existsSync18 } from "fs";
|
|
8981
|
+
import { readFile as readFile18, stat as stat3 } from "fs/promises";
|
|
8982
|
+
import path22 from "path";
|
|
8983
|
+
|
|
8984
|
+
// src/workspace.ts
|
|
8985
|
+
import { mkdir as mkdir5, readFile as readFile16, rename as rename5, rm as rm7, writeFile as writeFile7 } from "fs/promises";
|
|
8986
|
+
import { existsSync as existsSync17 } from "fs";
|
|
8987
|
+
import path20 from "path";
|
|
8988
|
+
import { z as z6 } from "zod";
|
|
8989
|
+
var WorkspaceProjectSchema = z6.object({
|
|
8990
|
+
id: z6.string().min(1),
|
|
8991
|
+
/** Manifest path, relative to the catalog file's own directory. */
|
|
8992
|
+
manifest: z6.string().min(1),
|
|
8993
|
+
/** Lockfile path, relative to the catalog file's own directory. */
|
|
8994
|
+
lock: z6.string().min(1),
|
|
8995
|
+
provider: z6.string().min(1).default("pixellab"),
|
|
8996
|
+
/** Free-form label for a shared account, e.g. distinguishing sandboxes. */
|
|
8997
|
+
account: z6.string().optional()
|
|
8998
|
+
}).strict();
|
|
8999
|
+
var WorkspaceSchema = z6.object({
|
|
9000
|
+
version: z6.literal(1),
|
|
9001
|
+
projects: z6.array(WorkspaceProjectSchema).default([])
|
|
9002
|
+
}).strict();
|
|
9003
|
+
function parseWorkspace(raw) {
|
|
9004
|
+
const parsed = WorkspaceSchema.safeParse(raw);
|
|
9005
|
+
if (parsed.success) return parsed.data;
|
|
9006
|
+
throw new Error(
|
|
9007
|
+
`Workspace catalog is not valid v1:
|
|
9008
|
+
${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
|
|
9009
|
+
);
|
|
8828
9010
|
}
|
|
8829
|
-
async function
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
9011
|
+
async function loadWorkspace(workspacePath) {
|
|
9012
|
+
if (!existsSync17(workspacePath)) return { version: 1, projects: [] };
|
|
9013
|
+
let raw;
|
|
9014
|
+
try {
|
|
9015
|
+
raw = JSON.parse(await readFile16(workspacePath, "utf8"));
|
|
9016
|
+
} catch (err) {
|
|
9017
|
+
throw new Error(
|
|
9018
|
+
`Workspace catalog at ${workspacePath} is malformed:
|
|
9019
|
+
${err instanceof Error ? err.message : String(err)}`
|
|
9020
|
+
);
|
|
8836
9021
|
}
|
|
8837
|
-
return
|
|
9022
|
+
return parseWorkspace(raw);
|
|
8838
9023
|
}
|
|
8839
|
-
async function
|
|
8840
|
-
const
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
}
|
|
8851
|
-
const rel = path19.relative(root, file);
|
|
8852
|
-
const category = path19.dirname(rel) === "." ? "" : path19.dirname(rel);
|
|
8853
|
-
let id = slugify(path19.basename(rel));
|
|
8854
|
-
if (seen.has(id)) {
|
|
8855
|
-
const n = seen.get(id) + 1;
|
|
8856
|
-
seen.set(id, n);
|
|
8857
|
-
id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
|
|
8858
|
-
} else {
|
|
8859
|
-
seen.set(id, 1);
|
|
8860
|
-
}
|
|
8861
|
-
assets.push({ id, category, file: rel, width: size.width, height: size.height });
|
|
9024
|
+
async function saveWorkspace(workspacePath, ws) {
|
|
9025
|
+
const sorted = {
|
|
9026
|
+
version: 1,
|
|
9027
|
+
projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
|
|
9028
|
+
};
|
|
9029
|
+
await mkdir5(path20.dirname(path20.resolve(workspacePath)), { recursive: true });
|
|
9030
|
+
const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
9031
|
+
try {
|
|
9032
|
+
await writeFile7(tmp, JSON.stringify(sorted, null, 2) + "\n");
|
|
9033
|
+
await rename5(tmp, workspacePath);
|
|
9034
|
+
} finally {
|
|
9035
|
+
await rm7(tmp, { force: true });
|
|
8862
9036
|
}
|
|
8863
|
-
return { assets, skipped };
|
|
8864
9037
|
}
|
|
8865
|
-
function
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
9038
|
+
function toPortablePath(dir, absolute) {
|
|
9039
|
+
return path20.relative(dir, absolute).split(path20.sep).join("/");
|
|
9040
|
+
}
|
|
9041
|
+
function resolveProject(dir, project) {
|
|
9042
|
+
return {
|
|
9043
|
+
manifestPath: path20.resolve(dir, project.manifest.split("/").join(path20.sep)),
|
|
9044
|
+
lockPath: path20.resolve(dir, project.lock.split("/").join(path20.sep))
|
|
9045
|
+
};
|
|
9046
|
+
}
|
|
9047
|
+
function validateWorkspace(ws, dir) {
|
|
9048
|
+
const diagnostics = [];
|
|
9049
|
+
const idCounts = /* @__PURE__ */ new Map();
|
|
9050
|
+
const lockOwners = /* @__PURE__ */ new Map();
|
|
9051
|
+
const manifestOwners = /* @__PURE__ */ new Map();
|
|
9052
|
+
for (const project of ws.projects) {
|
|
9053
|
+
idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
|
|
9054
|
+
const { manifestPath, lockPath } = resolveProject(dir, project);
|
|
9055
|
+
lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
|
|
9056
|
+
manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
|
|
9057
|
+
if (path20.isAbsolute(project.manifest) || path20.isAbsolute(project.lock)) {
|
|
9058
|
+
diagnostics.push({
|
|
9059
|
+
id: "absolute-path",
|
|
9060
|
+
level: "warning",
|
|
9061
|
+
message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
|
|
9062
|
+
});
|
|
9063
|
+
}
|
|
9064
|
+
if (!existsSync17(manifestPath)) {
|
|
9065
|
+
diagnostics.push({
|
|
9066
|
+
id: "missing-manifest",
|
|
9067
|
+
level: "error",
|
|
9068
|
+
message: `project "${project.id}" manifest not found: ${manifestPath}`
|
|
9069
|
+
});
|
|
9070
|
+
}
|
|
9071
|
+
if (!existsSync17(lockPath)) {
|
|
9072
|
+
diagnostics.push({
|
|
9073
|
+
id: "missing-lock",
|
|
9074
|
+
level: "error",
|
|
9075
|
+
message: `project "${project.id}" lockfile not found: ${lockPath}`
|
|
9076
|
+
});
|
|
9077
|
+
}
|
|
9078
|
+
}
|
|
9079
|
+
for (const [id, count] of idCounts) {
|
|
9080
|
+
if (count > 1) {
|
|
9081
|
+
diagnostics.push({
|
|
9082
|
+
id: "duplicate-id",
|
|
9083
|
+
level: "error",
|
|
9084
|
+
message: `project id "${id}" is registered ${count} times`
|
|
9085
|
+
});
|
|
9086
|
+
}
|
|
9087
|
+
}
|
|
9088
|
+
for (const [lockPath, ids] of lockOwners) {
|
|
9089
|
+
if (ids.length > 1) {
|
|
9090
|
+
diagnostics.push({
|
|
9091
|
+
id: "duplicate-lock",
|
|
9092
|
+
level: "error",
|
|
9093
|
+
message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
|
|
9094
|
+
});
|
|
9095
|
+
}
|
|
9096
|
+
}
|
|
9097
|
+
for (const [manifestPath, ids] of manifestOwners) {
|
|
9098
|
+
if (ids.length > 1) {
|
|
9099
|
+
diagnostics.push({
|
|
9100
|
+
id: "duplicate-manifest",
|
|
9101
|
+
level: "warning",
|
|
9102
|
+
message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
|
|
9103
|
+
});
|
|
9104
|
+
}
|
|
9105
|
+
}
|
|
9106
|
+
const providers = new Set(ws.projects.map((p) => p.provider));
|
|
9107
|
+
if (providers.size > 1) {
|
|
9108
|
+
diagnostics.push({
|
|
9109
|
+
id: "mixed-provider",
|
|
9110
|
+
level: "warning",
|
|
9111
|
+
message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
|
|
9112
|
+
});
|
|
9113
|
+
}
|
|
9114
|
+
return diagnostics;
|
|
9115
|
+
}
|
|
9116
|
+
|
|
9117
|
+
// src/gallery/edit.ts
|
|
9118
|
+
import { readFile as readFile17, rename as rename6, rm as rm8, writeFile as writeFile8 } from "fs/promises";
|
|
9119
|
+
import path21 from "path";
|
|
9120
|
+
import { z as z7 } from "zod";
|
|
9121
|
+
var HexSha = z7.string().regex(/^[0-9a-f]{64}$/);
|
|
9122
|
+
var AssetPatchSchema = z7.object({
|
|
9123
|
+
prompt: z7.string().optional(),
|
|
9124
|
+
/** Per-style override for the style being viewed; null removes it. */
|
|
9125
|
+
promptForStyle: z7.object({ styleId: z7.string().min(1), prompt: z7.string().nullable() }).optional(),
|
|
9126
|
+
category: z7.string().nullable().optional(),
|
|
9127
|
+
width: z7.number().int().nullable().optional(),
|
|
9128
|
+
height: z7.number().int().nullable().optional(),
|
|
9129
|
+
size: z7.number().int().nullable().optional(),
|
|
9130
|
+
tags: z7.array(z7.string()).optional()
|
|
9131
|
+
}).strict();
|
|
9132
|
+
var NewAssetSchema = z7.object({
|
|
9133
|
+
prompt: z7.string(),
|
|
9134
|
+
category: z7.string().optional(),
|
|
9135
|
+
width: z7.number().int().optional(),
|
|
9136
|
+
height: z7.number().int().optional(),
|
|
9137
|
+
size: z7.number().int().optional(),
|
|
9138
|
+
tags: z7.array(z7.string()).optional(),
|
|
9139
|
+
/** Restrict to these styles; empty or omitted means every style. */
|
|
9140
|
+
styles: z7.array(z7.string().min(1)).optional()
|
|
9141
|
+
}).strict();
|
|
9142
|
+
var CANDIDATE_OPTION = {
|
|
9143
|
+
retrodiffusion: "numImages",
|
|
9144
|
+
comfyui: "numImages",
|
|
9145
|
+
scenario: "numOutputs"
|
|
9146
|
+
};
|
|
9147
|
+
var ManifestEditSchema = z7.discriminatedUnion("action", [
|
|
9148
|
+
z7.object({
|
|
9149
|
+
action: z7.literal("patch-asset"),
|
|
9150
|
+
/** Workspace project id; omitted for a single-project gallery. */
|
|
9151
|
+
project: z7.string().min(1).optional(),
|
|
9152
|
+
assetId: z7.string().min(1),
|
|
9153
|
+
/** Manifest bytes the page last saw; a mismatch refuses the write. */
|
|
9154
|
+
expectedSha256: HexSha,
|
|
9155
|
+
patch: AssetPatchSchema
|
|
9156
|
+
}).strict(),
|
|
9157
|
+
z7.object({
|
|
9158
|
+
action: z7.literal("add-asset"),
|
|
9159
|
+
project: z7.string().min(1).optional(),
|
|
9160
|
+
assetId: z7.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
|
|
9161
|
+
expectedSha256: HexSha,
|
|
9162
|
+
asset: NewAssetSchema
|
|
9163
|
+
}).strict(),
|
|
9164
|
+
z7.object({
|
|
9165
|
+
action: z7.literal("patch-style"),
|
|
9166
|
+
project: z7.string().min(1).optional(),
|
|
9167
|
+
styleId: z7.string().min(1),
|
|
9168
|
+
expectedSha256: HexSha,
|
|
9169
|
+
patch: z7.object({
|
|
9170
|
+
/** Candidates per generation, written to the provider's own option. */
|
|
9171
|
+
candidates: z7.number().int().min(1).max(64).optional(),
|
|
9172
|
+
/** Empty clears the style's own value (inherit, or the default). */
|
|
9173
|
+
promptPrefix: z7.string().optional(),
|
|
9174
|
+
promptSuffix: z7.string().optional(),
|
|
9175
|
+
/** `#rrggbb` values; null or empty clears the style's own palette. */
|
|
9176
|
+
palette: z7.array(z7.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional()
|
|
9177
|
+
}).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
|
|
9178
|
+
}).strict()
|
|
9179
|
+
]);
|
|
9180
|
+
var ManifestDriftError = class extends Error {
|
|
9181
|
+
status = 409;
|
|
9182
|
+
constructor(manifestPath) {
|
|
9183
|
+
super(`${manifestPath} changed on disk since this page loaded; refresh and try again`);
|
|
9184
|
+
this.name = "ManifestDriftError";
|
|
9185
|
+
}
|
|
9186
|
+
};
|
|
9187
|
+
var ManifestEditError = class extends Error {
|
|
9188
|
+
status = 400;
|
|
9189
|
+
constructor(message6) {
|
|
9190
|
+
super(message6);
|
|
9191
|
+
this.name = "ManifestEditError";
|
|
9192
|
+
}
|
|
9193
|
+
};
|
|
9194
|
+
function serializeLike(original, value) {
|
|
9195
|
+
const indentMatch = /\n([ \t]+)"/.exec(original);
|
|
9196
|
+
const indent = indentMatch ? indentMatch[1].startsWith(" ") ? " " : indentMatch[1].length : 2;
|
|
9197
|
+
return JSON.stringify(value, null, indent) + (original.endsWith("\n") ? "\n" : "");
|
|
9198
|
+
}
|
|
9199
|
+
function styleProvider(raw, styleId, seen = /* @__PURE__ */ new Set()) {
|
|
9200
|
+
const style = raw.styles?.[styleId];
|
|
9201
|
+
if (!style || seen.has(styleId)) return raw.provider ?? "pixellab";
|
|
9202
|
+
if (style.provider) return style.provider;
|
|
9203
|
+
seen.add(styleId);
|
|
9204
|
+
return style.extends ? styleProvider(raw, style.extends, seen) : raw.provider ?? "pixellab";
|
|
9205
|
+
}
|
|
9206
|
+
function setOrDelete(target, key, value) {
|
|
9207
|
+
if (value === null || value === void 0) delete target[key];
|
|
9208
|
+
else target[key] = value;
|
|
9209
|
+
}
|
|
9210
|
+
function applyEdit(raw, edit) {
|
|
9211
|
+
raw.assets ??= {};
|
|
9212
|
+
if (edit.action === "patch-style") {
|
|
9213
|
+
const style = raw.styles && Object.hasOwn(raw.styles, edit.styleId) ? raw.styles[edit.styleId] : void 0;
|
|
9214
|
+
if (!style) throw new ManifestEditError(`style "${edit.styleId}" is not declared by the manifest`);
|
|
9215
|
+
const { patch: patch2 } = edit;
|
|
9216
|
+
if (patch2.candidates !== void 0) {
|
|
9217
|
+
const provider = styleProvider(raw, edit.styleId);
|
|
9218
|
+
const option = CANDIDATE_OPTION[provider];
|
|
9219
|
+
if (!option) {
|
|
9220
|
+
throw new ManifestEditError(
|
|
9221
|
+
provider === "pixellab" ? "PixelLab's candidate count follows the generator and size: map and pixflux return one image; a 1dir style returns 4\u201364 for its size" : `provider "${provider}" has no candidate-count option`
|
|
9222
|
+
);
|
|
9223
|
+
}
|
|
9224
|
+
const options = { ...style.providerOptions ?? {} };
|
|
9225
|
+
options[provider] = { ...options[provider] ?? {}, [option]: patch2.candidates };
|
|
9226
|
+
style.providerOptions = options;
|
|
9227
|
+
}
|
|
9228
|
+
if (patch2.promptPrefix !== void 0) setOrDelete(style, "promptPrefix", patch2.promptPrefix || null);
|
|
9229
|
+
if (patch2.promptSuffix !== void 0) setOrDelete(style, "promptSuffix", patch2.promptSuffix || null);
|
|
9230
|
+
if (patch2.palette !== void 0) {
|
|
9231
|
+
const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
|
|
9232
|
+
setOrDelete(style, "palette", colors.length ? colors : null);
|
|
9233
|
+
}
|
|
9234
|
+
return;
|
|
9235
|
+
}
|
|
9236
|
+
if (edit.action === "add-asset") {
|
|
9237
|
+
if (Object.hasOwn(raw.assets, edit.assetId)) {
|
|
9238
|
+
throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
|
|
9239
|
+
}
|
|
9240
|
+
const { styles, tags, ...rest } = edit.asset;
|
|
9241
|
+
const asset2 = { ...rest };
|
|
9242
|
+
if (tags?.length) asset2.tags = tags;
|
|
9243
|
+
if (styles?.length) {
|
|
9244
|
+
for (const styleId of styles) {
|
|
9245
|
+
if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
|
|
9246
|
+
throw new ManifestEditError(`unknown style "${styleId}"`);
|
|
9247
|
+
}
|
|
9248
|
+
}
|
|
9249
|
+
asset2.styles = styles;
|
|
9250
|
+
}
|
|
9251
|
+
raw.assets[edit.assetId] = asset2;
|
|
9252
|
+
return;
|
|
9253
|
+
}
|
|
9254
|
+
const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
|
|
9255
|
+
if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
|
|
9256
|
+
const { patch } = edit;
|
|
9257
|
+
if (patch.prompt !== void 0) asset.prompt = patch.prompt;
|
|
9258
|
+
if (patch.promptForStyle) {
|
|
9259
|
+
const { styleId, prompt } = patch.promptForStyle;
|
|
9260
|
+
if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
|
|
9261
|
+
throw new ManifestEditError(`unknown style "${styleId}"`);
|
|
9262
|
+
}
|
|
9263
|
+
const byStyle = { ...asset.promptByStyle ?? {} };
|
|
9264
|
+
if (prompt === null) delete byStyle[styleId];
|
|
9265
|
+
else byStyle[styleId] = prompt;
|
|
9266
|
+
if (Object.keys(byStyle).length) asset.promptByStyle = byStyle;
|
|
9267
|
+
else delete asset.promptByStyle;
|
|
9268
|
+
}
|
|
9269
|
+
if ("category" in patch) setOrDelete(asset, "category", patch.category);
|
|
9270
|
+
if ("width" in patch) setOrDelete(asset, "width", patch.width);
|
|
9271
|
+
if ("height" in patch) setOrDelete(asset, "height", patch.height);
|
|
9272
|
+
if ("size" in patch) setOrDelete(asset, "size", patch.size);
|
|
9273
|
+
if (patch.tags !== void 0) {
|
|
9274
|
+
if (patch.tags.length) asset.tags = patch.tags;
|
|
9275
|
+
else delete asset.tags;
|
|
9276
|
+
}
|
|
9277
|
+
}
|
|
9278
|
+
async function applyManifestEdit(manifestPath, edit) {
|
|
9279
|
+
const absolute = path21.resolve(manifestPath);
|
|
9280
|
+
const original = await readFile17(absolute, "utf8");
|
|
9281
|
+
const before = sha256(original);
|
|
9282
|
+
if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
|
|
9283
|
+
let raw;
|
|
9284
|
+
try {
|
|
9285
|
+
raw = JSON.parse(original);
|
|
9286
|
+
} catch (error) {
|
|
9287
|
+
throw new ManifestEditError(
|
|
9288
|
+
`manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
9289
|
+
);
|
|
9290
|
+
}
|
|
9291
|
+
applyEdit(raw, edit);
|
|
9292
|
+
const next = serializeLike(original, raw);
|
|
9293
|
+
if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
|
|
9294
|
+
const tmp = path21.join(
|
|
9295
|
+
path21.dirname(absolute),
|
|
9296
|
+
`.${path21.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
|
|
9297
|
+
);
|
|
9298
|
+
try {
|
|
9299
|
+
await writeFile8(tmp, next);
|
|
9300
|
+
try {
|
|
9301
|
+
const loaded = await loadManifest(tmp);
|
|
9302
|
+
await resolveSpecs(loaded);
|
|
9303
|
+
} catch (error) {
|
|
9304
|
+
const message6 = error instanceof Error ? error.message : String(error);
|
|
9305
|
+
throw new ManifestEditError(message6.replace(tmp, absolute));
|
|
9306
|
+
}
|
|
9307
|
+
await rename6(tmp, absolute);
|
|
9308
|
+
} finally {
|
|
9309
|
+
await rm8(tmp, { force: true });
|
|
9310
|
+
}
|
|
9311
|
+
return { manifestPath: absolute, sha256: sha256(next), changed: true };
|
|
9312
|
+
}
|
|
9313
|
+
function createGalleryEditHandler(opts) {
|
|
9314
|
+
const log = opts.onProgress ?? (() => {
|
|
9315
|
+
});
|
|
9316
|
+
return async (body) => {
|
|
9317
|
+
const parsed = ManifestEditSchema.safeParse(body);
|
|
9318
|
+
if (!parsed.success) {
|
|
9319
|
+
throw new ManifestEditError(
|
|
9320
|
+
"invalid edit: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
|
|
9321
|
+
);
|
|
9322
|
+
}
|
|
9323
|
+
let manifestPath;
|
|
9324
|
+
try {
|
|
9325
|
+
manifestPath = await opts.manifestFor(parsed.data.project);
|
|
9326
|
+
} catch (error) {
|
|
9327
|
+
throw new ManifestEditError(error instanceof Error ? error.message : String(error));
|
|
9328
|
+
}
|
|
9329
|
+
const result = await applyManifestEdit(manifestPath, parsed.data);
|
|
9330
|
+
if (result.changed) {
|
|
9331
|
+
const relative = path21.relative(process.cwd(), result.manifestPath);
|
|
9332
|
+
const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
|
|
9333
|
+
const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.assetId;
|
|
9334
|
+
log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
|
|
9335
|
+
}
|
|
9336
|
+
return opts.reload();
|
|
9337
|
+
};
|
|
9338
|
+
}
|
|
9339
|
+
|
|
9340
|
+
// src/gallery/snapshot.ts
|
|
9341
|
+
function galleryMediaId(absolutePath) {
|
|
9342
|
+
return createHash4("sha256").update(path22.resolve(absolutePath)).digest("hex").slice(0, 24);
|
|
9343
|
+
}
|
|
9344
|
+
function galleryMediaRoute(id) {
|
|
9345
|
+
return `/media/${id}`;
|
|
9346
|
+
}
|
|
9347
|
+
function isDeclared(loaded, styleId, assetId) {
|
|
9348
|
+
const asset = loaded.manifest.assets[assetId];
|
|
9349
|
+
if (!asset || !loaded.manifest.styles[styleId]) return false;
|
|
9350
|
+
return asset.styles.length === 0 || asset.styles.includes(styleId);
|
|
9351
|
+
}
|
|
9352
|
+
function matchesFilter(filter, styleId, assetId) {
|
|
9353
|
+
if (filter?.styles?.length && !filter.styles.includes(styleId)) return false;
|
|
9354
|
+
if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
|
|
9355
|
+
return true;
|
|
9356
|
+
}
|
|
9357
|
+
function metadataFps(entry) {
|
|
9358
|
+
if (!entry) return null;
|
|
9359
|
+
const namespace = entry.providerMetadata?.[entry.provider];
|
|
9360
|
+
const frameSet = namespace?.frameSet;
|
|
9361
|
+
if (frameSet && typeof frameSet === "object" && "fps" in frameSet) {
|
|
9362
|
+
const fps = frameSet.fps;
|
|
9363
|
+
if (typeof fps === "number" && Number.isFinite(fps) && fps > 0) return fps;
|
|
9364
|
+
}
|
|
9365
|
+
return null;
|
|
9366
|
+
}
|
|
9367
|
+
async function fileBytes(absolutePath) {
|
|
9368
|
+
try {
|
|
9369
|
+
const info = await stat3(absolutePath);
|
|
9370
|
+
return info.isFile() ? info.size : null;
|
|
9371
|
+
} catch {
|
|
9372
|
+
return null;
|
|
9373
|
+
}
|
|
9374
|
+
}
|
|
9375
|
+
async function describeOutput(media, root, absolutePath, recorded = {}) {
|
|
9376
|
+
const bytes = await fileBytes(absolutePath);
|
|
9377
|
+
const exists = bytes !== null;
|
|
9378
|
+
const mediaType = recorded.mediaType ?? mediaTypeFromExtension(absolutePath);
|
|
9379
|
+
let url = null;
|
|
9380
|
+
if (exists && mediaType) {
|
|
9381
|
+
const id = galleryMediaId(absolutePath);
|
|
9382
|
+
media.set(id, { path: absolutePath, contentType: mediaType });
|
|
9383
|
+
url = `${galleryMediaRoute(id)}?v=${(recorded.sha256 ?? String(bytes)).slice(0, 16)}`;
|
|
9384
|
+
}
|
|
9385
|
+
return {
|
|
9386
|
+
path: portableOutputPath(absolutePath, root),
|
|
9387
|
+
absolutePath,
|
|
9388
|
+
sha256: recorded.sha256 ?? null,
|
|
9389
|
+
role: recorded.role ?? null,
|
|
9390
|
+
mediaType,
|
|
9391
|
+
exists,
|
|
9392
|
+
bytes,
|
|
9393
|
+
url
|
|
9394
|
+
};
|
|
9395
|
+
}
|
|
9396
|
+
async function describeQuality(media, root, inspection) {
|
|
9397
|
+
const outputPaths = inspection.outputs ?? [inspection.output];
|
|
9398
|
+
const outputs = await Promise.all(
|
|
9399
|
+
outputPaths.map((file) => describeOutput(media, root, path22.resolve(root, file)))
|
|
9400
|
+
);
|
|
9401
|
+
const recordPath = path22.resolve(root, inspection.record);
|
|
9402
|
+
const recordExists = existsSync18(recordPath);
|
|
9403
|
+
let options = null;
|
|
9404
|
+
let check = null;
|
|
9405
|
+
if (recordExists) {
|
|
9406
|
+
try {
|
|
9407
|
+
const result = await checkQualityRecord(recordPath);
|
|
9408
|
+
options = result.options;
|
|
9409
|
+
check = { safe: result.safe, current: result.current, reasons: result.reasons };
|
|
9410
|
+
} catch (error) {
|
|
9411
|
+
check = {
|
|
9412
|
+
safe: false,
|
|
9413
|
+
current: false,
|
|
9414
|
+
reasons: [error instanceof Error ? error.message : String(error)]
|
|
9415
|
+
};
|
|
9416
|
+
}
|
|
9417
|
+
}
|
|
9418
|
+
return {
|
|
9419
|
+
state: inspection.state,
|
|
9420
|
+
reason: inspection.reason,
|
|
9421
|
+
output: portableOutputPath(path22.resolve(root, inspection.output), root),
|
|
9422
|
+
record: portableOutputPath(recordPath, root),
|
|
9423
|
+
recordExists,
|
|
9424
|
+
outputs,
|
|
9425
|
+
palette: options?.palette.colors ?? null,
|
|
9426
|
+
review: options ? options.review.status === "approved" ? {
|
|
9427
|
+
status: "approved",
|
|
9428
|
+
reviewer: options.review.reviewer,
|
|
9429
|
+
approvedAt: options.review.approvedAt,
|
|
9430
|
+
note: options.review.note ?? null
|
|
9431
|
+
} : { status: "pending" } : null,
|
|
9432
|
+
nativeGrid: options ? {
|
|
9433
|
+
confidence: options.nativeGrid.confidence,
|
|
9434
|
+
consensus: options.nativeGrid.consensus,
|
|
9435
|
+
stepX: options.nativeGrid.stepX,
|
|
9436
|
+
stepY: options.nativeGrid.stepY,
|
|
9437
|
+
sourceWidth: options.nativeGrid.sourceWidth,
|
|
9438
|
+
sourceHeight: options.nativeGrid.sourceHeight,
|
|
9439
|
+
nativeWidth: options.nativeGrid.nativeWidth,
|
|
9440
|
+
nativeHeight: options.nativeGrid.nativeHeight
|
|
9441
|
+
} : null,
|
|
9442
|
+
audit: options ? {
|
|
9443
|
+
safe: options.audit.safe,
|
|
9444
|
+
colorCount: options.audit.colorCount,
|
|
9445
|
+
transparency: options.audit.transparency,
|
|
9446
|
+
reasons: options.audit.reasons
|
|
9447
|
+
} : null,
|
|
9448
|
+
frameSet: options?.frameSet ? { fps: options.frameSet.fps, count: options.frameSet.count, roles: options.frameSet.roles } : null,
|
|
9449
|
+
check
|
|
9450
|
+
};
|
|
9451
|
+
}
|
|
9452
|
+
async function buildGallerySnapshot(opts) {
|
|
9453
|
+
const { loaded, specs, lock } = opts;
|
|
9454
|
+
const root = loaded.root;
|
|
9455
|
+
const media = /* @__PURE__ */ new Map();
|
|
9456
|
+
const plan = await buildPlan(specs, lock);
|
|
9457
|
+
const items = [];
|
|
9458
|
+
const manifestText = await readFile18(loaded.path, "utf8");
|
|
9459
|
+
const manifestSha256 = sha256(manifestText);
|
|
9460
|
+
let rawStyles = {};
|
|
9461
|
+
try {
|
|
9462
|
+
const raw = JSON.parse(manifestText);
|
|
9463
|
+
if (raw && typeof raw === "object" && raw.styles && typeof raw.styles === "object") rawStyles = raw.styles;
|
|
9464
|
+
} catch {
|
|
9465
|
+
}
|
|
9466
|
+
for (const planItem of plan.items) {
|
|
9467
|
+
const { spec, key } = planItem;
|
|
9468
|
+
const entry = lock.entries[key];
|
|
9469
|
+
const asset = loaded.manifest.assets[spec.assetId] ?? null;
|
|
9470
|
+
let outputs;
|
|
9471
|
+
if (entry && entry.outputs.length) {
|
|
9472
|
+
outputs = await Promise.all(
|
|
9473
|
+
entry.outputs.map(
|
|
9474
|
+
(output, index) => describeOutput(media, root, currentEntryOutputPath(entry, spec, index), output)
|
|
9475
|
+
)
|
|
9476
|
+
);
|
|
9477
|
+
} else if (spec.source) {
|
|
9478
|
+
outputs = [await describeOutput(media, root, path22.resolve(root, spec.source))];
|
|
9479
|
+
} else if (!entry && existsSync18(spec.outFile)) {
|
|
9480
|
+
outputs = [await describeOutput(media, root, spec.outFile)];
|
|
9481
|
+
} else {
|
|
9482
|
+
outputs = [];
|
|
9483
|
+
}
|
|
9484
|
+
const quality = planItem.quality ? await describeQuality(media, root, planItem.quality) : null;
|
|
9485
|
+
items.push({
|
|
9486
|
+
id: key,
|
|
9487
|
+
project: null,
|
|
9488
|
+
key,
|
|
9489
|
+
styleId: spec.styleId,
|
|
9490
|
+
assetId: spec.assetId,
|
|
9491
|
+
declared: true,
|
|
9492
|
+
state: planItem.state,
|
|
9493
|
+
reason: planItem.reason,
|
|
9494
|
+
status: entry?.status ?? null,
|
|
9495
|
+
provider: entry?.provider ?? spec.provider,
|
|
9496
|
+
generator: entry?.generator ?? spec.generator,
|
|
9497
|
+
prompt: entry?.prompt ?? spec.prompt,
|
|
9498
|
+
currentPrompt: entry && entry.prompt !== spec.prompt ? spec.prompt : null,
|
|
9499
|
+
width: entry?.width ?? spec.width,
|
|
9500
|
+
height: entry?.height ?? spec.height,
|
|
9501
|
+
tileFeature: entry?.tileFeature ?? spec.tileFeature ?? null,
|
|
9502
|
+
fps: quality?.frameSet?.fps ?? metadataFps(entry) ?? spec.quality?.fps ?? null,
|
|
9503
|
+
cost: entry?.cost ?? 0,
|
|
9504
|
+
costUnit: entry?.costUnit ?? spec.costUnit,
|
|
9505
|
+
estimatedCost: spec.cost,
|
|
9506
|
+
candidates: spec.candidates,
|
|
9507
|
+
submittedAt: entry?.submittedAt ?? null,
|
|
9508
|
+
downloadedAt: entry?.downloadedAt ?? null,
|
|
9509
|
+
jobId: entry?.jobId ?? null,
|
|
9510
|
+
objectId: entry?.objectId ?? null,
|
|
9511
|
+
reviewObjectId: entry?.reviewObjectId ?? null,
|
|
9512
|
+
candidateIndex: entry?.candidateIndex ?? null,
|
|
9513
|
+
error: entry?.error ?? null,
|
|
9514
|
+
recordedSpecHash: entry?.specHash ?? null,
|
|
9515
|
+
currentSpecHash: spec.specHash,
|
|
9516
|
+
revision: entry?.revision ?? null,
|
|
9517
|
+
revisionParentKey: spec.revision ? lockKey(spec.styleId, spec.revision.sourceAssetId) : entry?.revision ? lockKey(spec.styleId, entry.revision.sourceAssetId) : null,
|
|
9518
|
+
outputs,
|
|
9519
|
+
quality,
|
|
9520
|
+
providerMetadata: entry?.providerMetadata ?? {},
|
|
9521
|
+
asset,
|
|
9522
|
+
source: spec.source ?? null,
|
|
9523
|
+
tags: spec.tags,
|
|
9524
|
+
category: asset?.category ?? null
|
|
9525
|
+
});
|
|
9526
|
+
}
|
|
9527
|
+
const shown = new Set(items.map((item) => item.key));
|
|
9528
|
+
for (const [key, entry] of Object.entries(lock.entries)) {
|
|
9529
|
+
if (shown.has(key)) continue;
|
|
9530
|
+
if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
|
|
9531
|
+
if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
|
|
9532
|
+
const outputs = await Promise.all(
|
|
9533
|
+
entry.outputs.map(
|
|
9534
|
+
(output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
|
|
9535
|
+
)
|
|
9536
|
+
);
|
|
9537
|
+
items.push({
|
|
9538
|
+
id: key,
|
|
9539
|
+
project: null,
|
|
9540
|
+
key,
|
|
9541
|
+
styleId: entry.styleId,
|
|
9542
|
+
assetId: entry.assetId,
|
|
9543
|
+
declared: false,
|
|
9544
|
+
state: "undeclared",
|
|
9545
|
+
reason: "not declared by the current manifest; `pixelkiln prune` removes the entry",
|
|
9546
|
+
status: entry.status,
|
|
9547
|
+
provider: entry.provider,
|
|
9548
|
+
generator: entry.generator,
|
|
9549
|
+
prompt: entry.prompt,
|
|
9550
|
+
currentPrompt: null,
|
|
9551
|
+
width: entry.width,
|
|
9552
|
+
height: entry.height,
|
|
9553
|
+
tileFeature: entry.tileFeature,
|
|
9554
|
+
fps: metadataFps(entry),
|
|
9555
|
+
cost: entry.cost,
|
|
9556
|
+
costUnit: entry.costUnit,
|
|
9557
|
+
estimatedCost: null,
|
|
9558
|
+
candidates: null,
|
|
9559
|
+
submittedAt: entry.submittedAt,
|
|
9560
|
+
downloadedAt: entry.downloadedAt,
|
|
9561
|
+
jobId: entry.jobId,
|
|
9562
|
+
objectId: entry.objectId,
|
|
9563
|
+
reviewObjectId: entry.reviewObjectId,
|
|
9564
|
+
candidateIndex: entry.candidateIndex,
|
|
9565
|
+
error: entry.error,
|
|
9566
|
+
recordedSpecHash: entry.specHash,
|
|
9567
|
+
currentSpecHash: null,
|
|
9568
|
+
revision: entry.revision,
|
|
9569
|
+
revisionParentKey: entry.revision ? lockKey(entry.styleId, entry.revision.sourceAssetId) : null,
|
|
9570
|
+
outputs,
|
|
9571
|
+
quality: null,
|
|
9572
|
+
providerMetadata: entry.providerMetadata ?? {},
|
|
9573
|
+
asset: null,
|
|
9574
|
+
source: null,
|
|
9575
|
+
tags: [],
|
|
9576
|
+
category: null
|
|
9577
|
+
});
|
|
9578
|
+
}
|
|
9579
|
+
items.sort((a, b) => a.key.localeCompare(b.key));
|
|
9580
|
+
const byState = {};
|
|
9581
|
+
for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
|
|
9582
|
+
const byStatus = {};
|
|
9583
|
+
for (const entry of Object.values(lock.entries)) {
|
|
9584
|
+
byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1;
|
|
9585
|
+
}
|
|
9586
|
+
const styleIds = /* @__PURE__ */ new Set([
|
|
9587
|
+
...Object.keys(loaded.manifest.styles),
|
|
9588
|
+
...items.map((item) => item.styleId)
|
|
9589
|
+
]);
|
|
9590
|
+
const styles = [...styleIds].filter((id) => !opts.filter?.styles?.length || opts.filter.styles.includes(id) || items.some((item) => item.styleId === id)).sort().map((id) => {
|
|
9591
|
+
const style = loaded.manifest.styles[id];
|
|
9592
|
+
const styleItems = items.filter((item) => item.styleId === id);
|
|
9593
|
+
const spend = {};
|
|
9594
|
+
for (const item of styleItems) {
|
|
9595
|
+
if (item.cost) spend[item.costUnit] = (spend[item.costUnit] ?? 0) + item.cost;
|
|
9596
|
+
}
|
|
9597
|
+
const counts = new Set(styleItems.filter((item) => item.declared && item.candidates !== null).map((item) => item.candidates));
|
|
9598
|
+
const provider = style ? style.provider ?? loaded.manifest.provider : styleItems[0]?.provider ?? loaded.manifest.provider;
|
|
9599
|
+
const actionableItems = styleItems.filter((item) => item.declared && (item.state === "missing" || item.state === "stale" || item.state === "failed"));
|
|
9600
|
+
const declaredItems = styleItems.filter((item) => item.declared);
|
|
9601
|
+
const rawStyle = Object.hasOwn(rawStyles, id) ? rawStyles[id] : void 0;
|
|
9602
|
+
return {
|
|
9603
|
+
id,
|
|
9604
|
+
project: null,
|
|
9605
|
+
// A declared style inherits the manifest default; only a style the
|
|
9606
|
+
// manifest no longer has falls back to what its entries recorded.
|
|
9607
|
+
provider,
|
|
9608
|
+
generator: style?.generator ?? styleItems[0]?.generator ?? "map",
|
|
9609
|
+
outDir: style?.outDir ?? "",
|
|
9610
|
+
promptPrefix: style?.promptPrefix ?? "",
|
|
9611
|
+
promptSuffix: style?.promptSuffix ?? "",
|
|
9612
|
+
palette: style?.palette ?? [],
|
|
9613
|
+
quality: Boolean(style?.quality),
|
|
9614
|
+
tags: style?.tags ?? [],
|
|
9615
|
+
extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
|
|
9616
|
+
ownFields: rawStyle ? Object.keys(rawStyle).filter((key) => key !== "extends") : [],
|
|
9617
|
+
items: styleItems.length,
|
|
9618
|
+
spendByUnit: spend,
|
|
9619
|
+
regenerate: {
|
|
9620
|
+
assets: declaredItems.length,
|
|
9621
|
+
cost: declaredItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
|
|
9622
|
+
costUnit: declaredItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
|
|
9623
|
+
},
|
|
9624
|
+
candidates: counts.size === 1 ? [...counts][0] : null,
|
|
9625
|
+
candidatesEditable: Boolean(style) && Object.hasOwn(CANDIDATE_OPTION, provider),
|
|
9626
|
+
actionable: {
|
|
9627
|
+
keys: actionableItems.map((item) => item.key),
|
|
9628
|
+
cost: actionableItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
|
|
9629
|
+
costUnit: actionableItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
|
|
9630
|
+
}
|
|
9631
|
+
};
|
|
9632
|
+
}).filter((style) => style.items > 0 || !opts.filter?.styles?.length);
|
|
9633
|
+
const snapshot = {
|
|
9634
|
+
version: 1,
|
|
9635
|
+
generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
9636
|
+
project: {
|
|
9637
|
+
id: loaded.manifest.name,
|
|
9638
|
+
name: loaded.manifest.name,
|
|
9639
|
+
manifest: loaded.path,
|
|
9640
|
+
lock: path22.resolve(opts.lockPath),
|
|
9641
|
+
root,
|
|
9642
|
+
provider: loaded.manifest.provider,
|
|
9643
|
+
account: null,
|
|
9644
|
+
manifestSha256,
|
|
9645
|
+
entries: Object.keys(lock.entries).length,
|
|
9646
|
+
items: items.length,
|
|
9647
|
+
spendByUnit: spendByUnit(lock),
|
|
9648
|
+
error: null
|
|
9649
|
+
},
|
|
9650
|
+
workspace: null,
|
|
9651
|
+
filter: { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] },
|
|
9652
|
+
totals: {
|
|
9653
|
+
entries: Object.keys(lock.entries).length,
|
|
9654
|
+
items: items.length,
|
|
9655
|
+
byState,
|
|
9656
|
+
byStatus,
|
|
9657
|
+
spendByUnit: spendByUnit(lock)
|
|
9658
|
+
},
|
|
9659
|
+
styles,
|
|
9660
|
+
items
|
|
9661
|
+
};
|
|
9662
|
+
return { snapshot, media };
|
|
9663
|
+
}
|
|
9664
|
+
async function buildWorkspaceGallerySnapshot(opts) {
|
|
9665
|
+
const dir = path22.dirname(path22.resolve(opts.workspacePath));
|
|
9666
|
+
const media = /* @__PURE__ */ new Map();
|
|
9667
|
+
const projects = [];
|
|
9668
|
+
const items = [];
|
|
9669
|
+
const styles = [];
|
|
9670
|
+
const filter = { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] };
|
|
9671
|
+
for (const project of opts.workspace.projects) {
|
|
9672
|
+
const { manifestPath, lockPath } = resolveProject(dir, project);
|
|
9673
|
+
try {
|
|
9674
|
+
const loaded = await loadManifest(manifestPath);
|
|
9675
|
+
const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
|
|
9676
|
+
const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
|
|
9677
|
+
const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
|
|
9678
|
+
const lock = await loadLock(lockPath);
|
|
9679
|
+
let projectItems = [];
|
|
9680
|
+
let projectStyles = [];
|
|
9681
|
+
let manifestSha256 = null;
|
|
9682
|
+
if (!excluded) {
|
|
9683
|
+
const specs = await resolveSpecs(loaded, { styles: styleFilter, assets: assetFilter });
|
|
9684
|
+
normalizeLockOutputPaths(lock, specs);
|
|
9685
|
+
const build = await buildGallerySnapshot({
|
|
9686
|
+
loaded,
|
|
9687
|
+
specs,
|
|
9688
|
+
lock,
|
|
9689
|
+
lockPath,
|
|
9690
|
+
filter: { styles: styleFilter, assets: assetFilter },
|
|
9691
|
+
now: opts.now
|
|
9692
|
+
});
|
|
9693
|
+
for (const [id, file] of build.media) media.set(id, file);
|
|
9694
|
+
manifestSha256 = build.snapshot.project?.manifestSha256 ?? null;
|
|
9695
|
+
projectItems = build.snapshot.items.map((item) => ({
|
|
9696
|
+
...item,
|
|
9697
|
+
id: `${project.id}:${item.key}`,
|
|
9698
|
+
project: project.id
|
|
9699
|
+
}));
|
|
9700
|
+
projectStyles = build.snapshot.styles.map((style) => ({ ...style, project: project.id }));
|
|
9701
|
+
}
|
|
9702
|
+
items.push(...projectItems);
|
|
9703
|
+
styles.push(...projectStyles);
|
|
9704
|
+
projects.push({
|
|
9705
|
+
id: project.id,
|
|
9706
|
+
name: loaded.manifest.name,
|
|
9707
|
+
manifest: manifestPath,
|
|
9708
|
+
lock: path22.resolve(lockPath),
|
|
9709
|
+
root: loaded.root,
|
|
9710
|
+
provider: loaded.manifest.provider,
|
|
9711
|
+
account: project.account ?? null,
|
|
9712
|
+
manifestSha256: manifestSha256 ?? sha256(await readFile18(manifestPath, "utf8")),
|
|
9713
|
+
entries: Object.keys(lock.entries).length,
|
|
9714
|
+
items: projectItems.length,
|
|
9715
|
+
spendByUnit: spendByUnit(lock),
|
|
9716
|
+
error: null
|
|
9717
|
+
});
|
|
9718
|
+
} catch (err) {
|
|
9719
|
+
projects.push({
|
|
9720
|
+
id: project.id,
|
|
9721
|
+
name: project.id,
|
|
9722
|
+
manifest: manifestPath,
|
|
9723
|
+
lock: path22.resolve(lockPath),
|
|
9724
|
+
root: path22.dirname(manifestPath),
|
|
9725
|
+
provider: project.provider,
|
|
9726
|
+
account: project.account ?? null,
|
|
9727
|
+
manifestSha256: null,
|
|
9728
|
+
entries: 0,
|
|
9729
|
+
items: 0,
|
|
9730
|
+
spendByUnit: {},
|
|
9731
|
+
error: err instanceof Error ? err.message : String(err)
|
|
9732
|
+
});
|
|
9733
|
+
}
|
|
9734
|
+
}
|
|
9735
|
+
const byState = {};
|
|
9736
|
+
for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
|
|
9737
|
+
const byStatus = {};
|
|
9738
|
+
for (const item of items) if (item.status) byStatus[item.status] = (byStatus[item.status] ?? 0) + 1;
|
|
9739
|
+
const spend = {};
|
|
9740
|
+
for (const project of projects) {
|
|
9741
|
+
for (const [unit, amount] of Object.entries(project.spendByUnit)) {
|
|
9742
|
+
spend[unit] = (spend[unit] ?? 0) + amount;
|
|
9743
|
+
}
|
|
9744
|
+
}
|
|
9745
|
+
return {
|
|
9746
|
+
snapshot: {
|
|
9747
|
+
version: 1,
|
|
9748
|
+
generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
9749
|
+
project: null,
|
|
9750
|
+
workspace: { path: path22.resolve(opts.workspacePath), dir, projects },
|
|
9751
|
+
filter,
|
|
9752
|
+
totals: {
|
|
9753
|
+
entries: projects.reduce((sum, project) => sum + project.entries, 0),
|
|
9754
|
+
items: items.length,
|
|
9755
|
+
byState,
|
|
9756
|
+
byStatus,
|
|
9757
|
+
spendByUnit: spend
|
|
9758
|
+
},
|
|
9759
|
+
styles,
|
|
9760
|
+
items
|
|
9761
|
+
},
|
|
9762
|
+
media
|
|
9763
|
+
};
|
|
9764
|
+
}
|
|
9765
|
+
|
|
9766
|
+
// src/gallery/page.ts
|
|
9767
|
+
function renderGallery(snapshot, opts = {}) {
|
|
9768
|
+
const data = JSON.stringify(snapshot).replace(/<\//g, "<\\/").replace(/<!--/g, "<\\u0021--");
|
|
9769
|
+
const session = JSON.stringify(opts.session ?? null);
|
|
9770
|
+
const editable = JSON.stringify(Boolean(opts.editable && opts.session));
|
|
9771
|
+
const generation = JSON.stringify(Boolean(opts.generation && opts.session));
|
|
9772
|
+
const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
|
|
9773
|
+
return `<!doctype html>
|
|
9774
|
+
<html lang="en">
|
|
9775
|
+
<head>
|
|
9776
|
+
<meta charset="utf-8">
|
|
9777
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
9778
|
+
<title>${escapeHtml2(title)}</title>
|
|
9779
|
+
<link rel="icon" href="data:,">
|
|
9780
|
+
<style>
|
|
9781
|
+
:root {
|
|
9782
|
+
--bg: #17150f; --panel: #201d17; --panel-deep: #100f0c;
|
|
9783
|
+
--line: rgba(243,234,214,.16); --line-strong: rgba(243,234,214,.3);
|
|
9784
|
+
--text: #f3ead6; --dim: #9b9384; --accent: #ff6b35; --accent-soft: #ff9c5f;
|
|
9785
|
+
--ok: #b9f27c; --warn: #ff9c5f; --bad: #ff7a6b; --cool: #8fc7ff;
|
|
9786
|
+
--content: 1520px; --drawer: 540px;
|
|
9787
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
9788
|
+
--checker:
|
|
9789
|
+
linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
|
|
9790
|
+
linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
|
|
9791
|
+
}
|
|
9792
|
+
* { box-sizing: border-box; }
|
|
9793
|
+
html { color-scheme: dark; }
|
|
9794
|
+
body { margin:0; background:var(--bg); color:var(--text);
|
|
9795
|
+
font:14px/1.5 ui-sans-serif,-apple-system,"SF Pro Text",Inter,system-ui,sans-serif; }
|
|
9796
|
+
button, input, select { font:inherit; color:inherit; }
|
|
9797
|
+
button { border-radius:0; padding:6px 11px; border:1px solid var(--line);
|
|
9798
|
+
background:transparent; cursor:pointer; }
|
|
9799
|
+
button:hover { border-color:var(--line-strong); }
|
|
9800
|
+
button.primary { background:var(--accent); border-color:var(--accent); color:var(--bg); font-weight:600; }
|
|
9801
|
+
button:disabled { opacity:.45; cursor:not-allowed; }
|
|
9802
|
+
:focus-visible { outline:2px solid var(--accent-soft); outline-offset:2px; }
|
|
9803
|
+
a { color:var(--accent-soft); }
|
|
9804
|
+
code, .mono { font-family:var(--mono); }
|
|
9805
|
+
|
|
9806
|
+
header { position:sticky; top:0; z-index:10; background:var(--panel);
|
|
9807
|
+
border-bottom:1px solid var(--line-strong); }
|
|
9808
|
+
.bar { width:min(100%,var(--content)); margin:0 auto; padding:13px 22px 0;
|
|
9809
|
+
display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
|
|
9810
|
+
h1 { font:700 14px/1 var(--mono); margin:0; letter-spacing:-.02em; white-space:nowrap; }
|
|
9811
|
+
h1::before { content:'\u25C6'; color:var(--accent); margin-right:9px; font-size:11px; }
|
|
9812
|
+
.project { color:var(--dim); font-size:12.5px; min-width:0; overflow:hidden;
|
|
9813
|
+
text-overflow:ellipsis; white-space:nowrap; }
|
|
9814
|
+
.project b { color:var(--text); font-weight:600; }
|
|
9815
|
+
.grow { flex:1 1 auto; }
|
|
9816
|
+
.search { display:flex; align-items:center; gap:8px; border:1px solid var(--line);
|
|
9817
|
+
background:var(--panel-deep); padding:0 10px; min-width:min(320px, 100%); }
|
|
9818
|
+
.search input { flex:1; border:0; background:transparent; padding:7px 0; min-width:0; }
|
|
9819
|
+
.search input:focus { outline:none; }
|
|
9820
|
+
.search kbd { color:var(--dim); border:1px solid var(--line); border-bottom-width:2px;
|
|
9821
|
+
padding:0 5px; font-size:11px; font-family:var(--mono); }
|
|
9822
|
+
select { background:var(--panel-deep); border:1px solid var(--line); padding:6px 8px; border-radius:0; }
|
|
9823
|
+
.totals { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 0;
|
|
9824
|
+
display:flex; gap:14px; flex-wrap:wrap; align-items:baseline;
|
|
9825
|
+
color:var(--dim); font-size:12.5px; font-variant-numeric:tabular-nums; }
|
|
9826
|
+
.totals b { color:var(--text); font-weight:600; }
|
|
9827
|
+
.chips { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 13px;
|
|
9828
|
+
display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
|
|
9829
|
+
.chips .sep { width:1px; height:18px; background:var(--line); margin:0 4px; }
|
|
9830
|
+
.chip { padding:3px 9px; font-size:12px; border:1px solid var(--line); color:var(--dim);
|
|
9831
|
+
display:inline-flex; align-items:center; gap:6px; font-variant-numeric:tabular-nums; }
|
|
9832
|
+
.chip.on { color:var(--text); border-color:var(--line-strong); background:var(--panel-deep); }
|
|
9833
|
+
.chip .n { color:var(--dim); }
|
|
9834
|
+
.dot { width:7px; height:7px; display:inline-block; background:var(--dim); flex:none; }
|
|
9835
|
+
.dot.ok { background:var(--ok); } .dot.warn { background:var(--warn); }
|
|
9836
|
+
.dot.bad { background:var(--bad); } .dot.cool { background:var(--cool); }
|
|
9837
|
+
.state-ok { color:var(--ok); } .state-warn { color:var(--warn); }
|
|
9838
|
+
.state-bad { color:var(--bad); } .state-cool { color:var(--cool); } .state-dim { color:var(--dim); }
|
|
9839
|
+
|
|
9840
|
+
main { width:min(100%,var(--content)); margin:0 auto; border-inline:1px solid var(--line);
|
|
9841
|
+
min-height:60vh; }
|
|
9842
|
+
.style { border-bottom:1px solid var(--line); padding:20px 22px 24px; }
|
|
9843
|
+
.project { border-bottom:1px solid var(--line-strong); background:var(--panel); padding:14px 22px; }
|
|
9844
|
+
.phead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; }
|
|
9845
|
+
.phead h2 { margin:0; font:700 15px/1.2 var(--mono); }
|
|
9846
|
+
.phead h2::before { content:'\u25C6'; color:var(--accent); margin-right:8px; font-size:10px; }
|
|
9847
|
+
.phead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap; }
|
|
9848
|
+
.sid.project { color:var(--dim); border-color:var(--line-strong); }
|
|
9849
|
+
.shead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; margin-bottom:14px; }
|
|
9850
|
+
.shead h2 { margin:0; font:650 15px/1.2 var(--mono); }
|
|
9851
|
+
.shead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap;
|
|
9852
|
+
align-items:center; }
|
|
9853
|
+
.swatches { display:inline-flex; gap:2px; vertical-align:middle; }
|
|
9854
|
+
.swatches i { width:12px; height:12px; display:block; border:1px solid var(--line); }
|
|
9855
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(176px, 1fr)); gap:10px; }
|
|
9856
|
+
.card { border:1px solid var(--line); background:var(--panel); text-align:left; padding:0;
|
|
9857
|
+
display:flex; flex-direction:column; cursor:pointer; min-width:0;
|
|
9858
|
+
content-visibility:auto; contain-intrinsic-size:auto 176px auto 196px; }
|
|
9859
|
+
.card:hover { border-color:var(--line-strong); }
|
|
9860
|
+
.card.active { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); }
|
|
9861
|
+
.card.ghost { border-style:dashed; background:transparent; }
|
|
9862
|
+
.cell { height:132px; display:grid; place-items:center; background:var(--panel-deep);
|
|
9863
|
+
background-image:var(--checker); background-size:12px 12px; background-position:0 0,6px 6px;
|
|
9864
|
+
overflow:hidden; position:relative; padding:8px; }
|
|
9865
|
+
.card.ghost .cell { background-image:none; color:var(--dim); font-size:12px; }
|
|
9866
|
+
.cell img { image-rendering:pixelated; display:block; max-width:100%; max-height:100%; }
|
|
9867
|
+
.cell img.fit { width:auto; height:auto; object-fit:contain; }
|
|
9868
|
+
.cell .multi { display:grid; grid-template-columns:1fr 1fr; gap:4px; width:100%; height:100%; }
|
|
9869
|
+
.cell .multi div { display:grid; place-items:center; overflow:hidden; }
|
|
9870
|
+
.badge { position:absolute; right:6px; bottom:6px; background:var(--panel); color:var(--dim);
|
|
9871
|
+
border:1px solid var(--line); font:11px/1 var(--mono); padding:3px 6px; }
|
|
9872
|
+
.cbody { padding:8px 10px 10px; display:flex; flex-direction:column; gap:3px; min-width:0; }
|
|
9873
|
+
.aid { font:600 13px/1.3 var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
9874
|
+
.cmeta { display:flex; gap:8px; align-items:center; color:var(--dim); font-size:11.5px;
|
|
9875
|
+
font-variant-numeric:tabular-nums; white-space:nowrap; overflow:hidden; }
|
|
9876
|
+
.cmeta .st { display:inline-flex; align-items:center; gap:5px; }
|
|
9877
|
+
.empty { padding:64px 22px; color:var(--dim); text-align:center; }
|
|
9878
|
+
.empty p { margin:0 0 12px; }
|
|
9879
|
+
footer { width:min(100%,var(--content)); margin:0 auto; padding:22px 22px 60px; color:var(--dim);
|
|
9880
|
+
font-size:12.5px; }
|
|
9881
|
+
kbd { border:1px solid var(--line); border-bottom-width:2px; padding:1px 5px; font-size:11px;
|
|
9882
|
+
font-family:var(--mono); }
|
|
9883
|
+
|
|
9884
|
+
.scrim { position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:19; }
|
|
9885
|
+
.drawer { position:fixed; top:0; right:0; bottom:0; width:min(var(--drawer), 100vw); z-index:20;
|
|
9886
|
+
background:var(--panel); border-left:1px solid var(--line-strong); overflow-y:auto;
|
|
9887
|
+
display:flex; flex-direction:column; }
|
|
9888
|
+
.dhead { position:sticky; top:0; background:var(--panel); border-bottom:1px solid var(--line);
|
|
9889
|
+
padding:14px 18px; display:flex; align-items:center; gap:10px; z-index:1; }
|
|
9890
|
+
.dhead .title { flex:1; min-width:0; }
|
|
9891
|
+
.dhead .title .sid { color:var(--accent-soft); font:650 10.5px/1 var(--mono); border:1px solid var(--accent);
|
|
9892
|
+
padding:2px 6px; display:inline-block; margin-bottom:6px; }
|
|
9893
|
+
.dhead .title .aid { font-size:16px; white-space:normal; word-break:break-all; }
|
|
9894
|
+
.dhead button { padding:5px 9px; }
|
|
9895
|
+
.dbody { padding:0 18px 40px; }
|
|
9896
|
+
.preview { margin:16px 0 10px; background:var(--panel-deep); background-image:var(--checker);
|
|
9897
|
+
background-size:12px 12px; background-position:0 0,6px 6px; border:1px solid var(--line);
|
|
9898
|
+
min-height:200px; max-height:60vh; overflow:auto; display:grid; place-items:center; padding:12px; }
|
|
9899
|
+
.preview img { image-rendering:pixelated; display:block; }
|
|
9900
|
+
.preview .none { color:var(--dim); font-size:12.5px; text-align:center; padding:40px 12px; }
|
|
9901
|
+
.zoom { display:flex; gap:6px; align-items:center; flex-wrap:wrap; font-size:12px; color:var(--dim); }
|
|
9902
|
+
.zoom button { padding:3px 8px; font-size:12px; }
|
|
9903
|
+
.zoom button.on { border-color:var(--accent); color:var(--text); }
|
|
9904
|
+
.zoom .grow { text-align:right; font-variant-numeric:tabular-nums; }
|
|
9905
|
+
.members { display:flex; gap:6px; overflow-x:auto; padding:10px 0 4px; }
|
|
9906
|
+
.members button { flex:none; padding:4px; display:flex; flex-direction:column; align-items:center; gap:3px;
|
|
9907
|
+
background:var(--panel-deep); background-image:var(--checker); background-size:8px 8px;
|
|
9908
|
+
background-position:0 0,4px 4px; }
|
|
9909
|
+
.members button.on { border-color:var(--accent); }
|
|
9910
|
+
.members img { image-rendering:pixelated; display:block; width:40px; height:40px; object-fit:contain; }
|
|
9911
|
+
.members span { font:10.5px/1 var(--mono); color:var(--dim); background:var(--panel); padding:2px 4px; }
|
|
9912
|
+
.status { margin:14px 0 0; padding:10px 12px; border:1px solid var(--line); display:flex; gap:10px;
|
|
9913
|
+
align-items:baseline; font-size:13px; }
|
|
9914
|
+
.status .st { font-weight:600; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
|
|
9915
|
+
.status .why { color:var(--dim); }
|
|
9916
|
+
section.meta { border-top:1px solid var(--line); padding:14px 0 6px; margin-top:14px; }
|
|
9917
|
+
section.meta h3 { margin:0 0 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
|
|
9918
|
+
dl { margin:0; display:grid; grid-template-columns:minmax(96px, 30%) 1fr; gap:6px 12px; font-size:12.5px; }
|
|
9919
|
+
dt { color:var(--dim); }
|
|
9920
|
+
dd { margin:0; min-width:0; word-break:break-word; }
|
|
9921
|
+
dd.mono { font-family:var(--mono); font-size:12px; }
|
|
9922
|
+
dd .copy { margin-left:6px; padding:0 5px; font:11px/1.4 var(--mono); color:var(--dim); border-color:transparent; }
|
|
9923
|
+
dd .copy:hover { border-color:var(--line); color:var(--text); }
|
|
9924
|
+
.prompt { white-space:pre-wrap; }
|
|
9925
|
+
.out { border:1px solid var(--line); padding:8px 10px; margin-bottom:8px; }
|
|
9926
|
+
.out dl { grid-template-columns:minmax(72px, 24%) 1fr; }
|
|
9927
|
+
.out .role { font:600 12px/1 var(--mono); margin-bottom:6px; display:flex; gap:8px; align-items:center; }
|
|
9928
|
+
.miss { color:var(--bad); }
|
|
9929
|
+
.pal { display:flex; flex-wrap:wrap; gap:3px; }
|
|
9930
|
+
.pal i { width:16px; height:16px; display:block; border:1px solid var(--line); }
|
|
9931
|
+
details { border:1px solid var(--line); padding:0; margin-top:8px; }
|
|
9932
|
+
details summary { cursor:pointer; padding:7px 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
|
|
9933
|
+
details pre { margin:0; padding:10px; border-top:1px solid var(--line); background:var(--panel-deep);
|
|
9934
|
+
font:11.5px/1.45 var(--mono); overflow:auto; max-height:320px; }
|
|
9935
|
+
.linkish { background:none; border:0; padding:0; color:var(--accent-soft); cursor:pointer;
|
|
9936
|
+
font-family:var(--mono); font-size:12px; text-decoration:underline; }
|
|
9937
|
+
.nav { display:flex; gap:6px; }
|
|
9938
|
+
#note { color:var(--warn); font-size:12.5px; }
|
|
9939
|
+
.editing { color:var(--accent-soft); border:1px solid var(--accent); padding:2px 7px; font:650 10.5px/1.4 var(--mono); }
|
|
9940
|
+
form.edit { border:1px solid var(--accent); padding:12px 14px 14px; margin-top:14px; display:grid; gap:10px; }
|
|
9941
|
+
form.edit h3 { margin:0; font:650 12.5px/1 var(--mono); color:var(--accent-soft); }
|
|
9942
|
+
form.edit .row { display:grid; grid-template-columns:repeat(auto-fit, minmax(110px, 1fr)); gap:10px; }
|
|
9943
|
+
.field { display:grid; gap:4px; font-size:12px; color:var(--dim); min-width:0; }
|
|
9944
|
+
.field span { font-family:var(--mono); font-size:11px; }
|
|
9945
|
+
.field small { font-size:11px; color:var(--dim); }
|
|
9946
|
+
.field input, .field textarea, .field select { background:var(--panel-deep); border:1px solid var(--line);
|
|
9947
|
+
color:var(--text); padding:6px 8px; border-radius:0; font:13px/1.45 inherit; width:100%; min-width:0; }
|
|
9948
|
+
.field textarea { resize:vertical; min-height:64px; font-family:inherit; }
|
|
9949
|
+
.field input:focus, .field textarea:focus, .field select:focus { outline:none; border-color:var(--accent); }
|
|
9950
|
+
.field.check { display:flex; align-items:center; gap:8px; }
|
|
9951
|
+
.field.check input { width:auto; }
|
|
9952
|
+
form.edit .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
9953
|
+
form.edit .msg { font-size:12.5px; }
|
|
9954
|
+
form.edit .msg.bad { color:var(--bad); }
|
|
9955
|
+
.notice { margin:14px 0 0; padding:10px 12px; border:1px solid var(--ok); color:var(--ok); font-size:13px; }
|
|
9956
|
+
.shead .tools { margin-left:auto; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
|
9957
|
+
.shead .tools button { padding:3px 9px; font-size:12px; }
|
|
9958
|
+
.shead .cand { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--dim); }
|
|
9959
|
+
.shead .cand input { width:56px; background:var(--panel-deep); border:1px solid var(--line); color:var(--text);
|
|
9960
|
+
padding:2px 6px; font:12px/1.4 inherit; border-radius:0; }
|
|
9961
|
+
form.edit.style-form { margin:0 0 14px; }
|
|
9962
|
+
form.edit .blast { padding:8px 10px; border:1px solid var(--line); font-size:12.5px; color:var(--dim); }
|
|
9963
|
+
form.edit .blast.hot { border-color:var(--warn); color:var(--text); }
|
|
9964
|
+
form.edit .blast b { color:var(--warn); font-weight:600; }
|
|
9965
|
+
form.edit .pal { margin-top:4px; }
|
|
9966
|
+
.style .notice { margin:0 0 14px; }
|
|
9967
|
+
#jobs { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; display:grid; gap:6px; }
|
|
9968
|
+
#jobs:empty { display:none; }
|
|
9969
|
+
.job { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
|
|
9970
|
+
grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
|
|
9971
|
+
.job .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
|
|
9972
|
+
.job .last { color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
|
9973
|
+
.job .acts { display:flex; gap:6px; }
|
|
9974
|
+
.job .acts button { padding:3px 9px; font-size:12px; }
|
|
9975
|
+
.job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
|
|
9976
|
+
font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
|
|
9977
|
+
.budget { color:var(--dim); }
|
|
9978
|
+
.budget b { color:var(--text); }
|
|
9979
|
+
.dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
|
|
9980
|
+
.dialog form { background:var(--panel); border:1px solid var(--accent); width:min(560px, calc(100vw - 32px));
|
|
9981
|
+
max-height:calc(100vh - 32px); overflow:auto; padding:16px 18px 18px; display:grid; gap:12px; }
|
|
9982
|
+
.dialog h3 { margin:0; font:650 14px/1.2 var(--mono); }
|
|
9983
|
+
.dialog table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
|
9984
|
+
.dialog td, .dialog th { text-align:left; padding:4px 6px; border-bottom:1px solid var(--line); }
|
|
9985
|
+
.dialog th { color:var(--dim); font-weight:500; }
|
|
9986
|
+
.dialog td.n, .dialog th.n { text-align:right; font-variant-numeric:tabular-nums; }
|
|
9987
|
+
.dialog .sum { display:grid; gap:4px; font-size:13px; }
|
|
9988
|
+
.dialog .sum b { font-weight:600; }
|
|
9989
|
+
.dialog .warn { color:var(--warn); font-size:12.5px; }
|
|
9990
|
+
.dialog .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
9991
|
+
.dialog .msg { font-size:12.5px; color:var(--bad); }
|
|
9992
|
+
.sheet-scrim { position:fixed; inset:0; z-index:24; background:rgba(0,0,0,.5); }
|
|
9993
|
+
.sheet { position:fixed; top:0; right:0; bottom:0; z-index:25; background:var(--bg);
|
|
9994
|
+
border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
|
|
9995
|
+
.sheet.review-host { width:min(1180px, 94vw); }
|
|
9996
|
+
.sheet.compare-host { width:min(1500px, 96vw); }
|
|
9997
|
+
.sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
|
|
9998
|
+
background:var(--panel); font-size:13px; flex-wrap:wrap; }
|
|
9999
|
+
.sheet .rbar span { color:var(--dim); }
|
|
10000
|
+
.sheet .rbar .zoom { margin-left:auto; }
|
|
10001
|
+
.sheet iframe { border:0; width:100%; height:100%; background:var(--bg); }
|
|
10002
|
+
@media (prefers-reduced-motion: no-preference) { .sheet { animation: slide .18s ease-out; } }
|
|
10003
|
+
.compare-body { overflow:auto; padding:16px; }
|
|
10004
|
+
.compare-body table { border-collapse:separate; border-spacing:0; min-width:100%; font-size:12.5px; }
|
|
10005
|
+
.compare-body th, .compare-body td { text-align:left; vertical-align:top; padding:6px 10px; border-bottom:1px solid var(--line);
|
|
10006
|
+
min-width:180px; max-width:420px; }
|
|
10007
|
+
.compare-body th:first-child, .compare-body td:first-child { min-width:96px; max-width:120px; color:var(--dim);
|
|
10008
|
+
position:sticky; left:0; background:var(--bg); z-index:1; }
|
|
10009
|
+
.compare-body thead th { position:sticky; top:0; background:var(--bg); z-index:2; border-bottom:1px solid var(--line-strong); }
|
|
10010
|
+
.compare-body .chead { display:grid; gap:4px; }
|
|
10011
|
+
.compare-body .chead .aid { font-size:14px; }
|
|
10012
|
+
.compare-body .chead .badges { display:flex; gap:6px; }
|
|
10013
|
+
.compare-body .chead .badges .sid { display:inline-block; }
|
|
10014
|
+
.compare-body .cimg { background:var(--panel-deep); background-image:var(--checker); background-size:12px 12px;
|
|
10015
|
+
background-position:0 0,6px 6px; border:1px solid var(--line); display:grid; place-items:center; padding:10px;
|
|
10016
|
+
min-height:120px; max-height:56vh; overflow:auto; }
|
|
10017
|
+
.compare-body .cimg img { image-rendering:pixelated; display:block; }
|
|
10018
|
+
.compare-body .cimg .none { color:var(--dim); font-size:12px; }
|
|
10019
|
+
.compare-body td.diff { background:color-mix(in srgb, var(--warn) 9%, transparent); }
|
|
10020
|
+
.compare-body td.mono { font-family:var(--mono); font-size:12px; word-break:break-all; }
|
|
10021
|
+
.compare-body td .prompt { white-space:pre-wrap; }
|
|
10022
|
+
.compare-body td .rm { float:right; padding:1px 6px; font-size:11px; color:var(--dim); border-color:transparent; }
|
|
10023
|
+
.card.compared { outline:2px dashed var(--accent-soft); outline-offset:-2px; }
|
|
10024
|
+
.card .slot { position:absolute; left:6px; top:6px; background:var(--accent); color:var(--bg); font:700 11px/1 var(--mono);
|
|
10025
|
+
padding:3px 6px; }
|
|
10026
|
+
.tray { position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:18; background:var(--panel);
|
|
10027
|
+
border:1px solid var(--accent); padding:8px 10px; display:flex; gap:8px; align-items:center; max-width:calc(100vw - 32px);
|
|
10028
|
+
box-shadow:0 10px 30px rgba(0,0,0,.4); }
|
|
10029
|
+
.tray .chipset { display:flex; gap:6px; overflow-x:auto; }
|
|
10030
|
+
.tray .pick { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); padding:3px 4px 3px 6px;
|
|
10031
|
+
font:12px/1.2 var(--mono); white-space:nowrap; }
|
|
10032
|
+
.tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
|
|
10033
|
+
.tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
|
|
10034
|
+
.tray > button { padding:5px 11px; font-size:12.5px; }
|
|
10035
|
+
@media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
|
|
10036
|
+
.drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
|
10037
|
+
.drawer .gen button.primary { padding:7px 14px; }
|
|
10038
|
+
@media (max-width: 720px) {
|
|
10039
|
+
.bar, .totals, .chips, .style, footer { padding-inline:14px; }
|
|
10040
|
+
.grid { grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)); }
|
|
10041
|
+
.cell { height:112px; }
|
|
10042
|
+
.drawer { width:100vw; }
|
|
10043
|
+
}
|
|
10044
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
10045
|
+
.drawer { animation: slide .16s ease-out; }
|
|
10046
|
+
@keyframes slide { from { transform: translateX(16px); opacity:.6; } to { transform:none; opacity:1; } }
|
|
10047
|
+
}
|
|
10048
|
+
</style>
|
|
10049
|
+
</head>
|
|
10050
|
+
<body>
|
|
10051
|
+
<header>
|
|
10052
|
+
<div class="bar">
|
|
10053
|
+
<h1>pixelkiln</h1>
|
|
10054
|
+
<span class="project" id="project"></span>
|
|
10055
|
+
<span class="grow"></span>
|
|
10056
|
+
<label class="search"><input id="q" type="search" placeholder="Search assets, prompts, ids, hashes" autocomplete="off"><kbd>/</kbd></label>
|
|
10057
|
+
<select id="sort" aria-label="Sort">
|
|
10058
|
+
<option value="key">Sort: style / asset</option>
|
|
10059
|
+
<option value="newest">Sort: newest first</option>
|
|
10060
|
+
<option value="oldest">Sort: oldest first</option>
|
|
10061
|
+
<option value="cost">Sort: cost</option>
|
|
10062
|
+
<option value="size">Sort: size</option>
|
|
10063
|
+
</select>
|
|
10064
|
+
<select id="group" aria-label="Group">
|
|
10065
|
+
<option value="style">Group: by style</option>
|
|
10066
|
+
<option value="none">Group: none</option>
|
|
10067
|
+
</select>
|
|
10068
|
+
<span id="editing" class="editing" hidden title="This gallery can write the manifest. It never contacts a provider.">editing</span>
|
|
10069
|
+
<button id="refresh" type="button" title="Re-read the manifest, lockfile, and disk">Refresh</button>
|
|
10070
|
+
<label class="chip" title="Refresh every 5 seconds while this tab is visible"><input id="auto" type="checkbox"> auto</label>
|
|
10071
|
+
</div>
|
|
10072
|
+
<div class="totals" id="totals"></div>
|
|
10073
|
+
<div class="chips" id="chips"></div>
|
|
10074
|
+
<div id="jobs"></div>
|
|
10075
|
+
</header>
|
|
10076
|
+
<main id="root"></main>
|
|
10077
|
+
<footer>
|
|
10078
|
+
Click a sprite for its full record; <kbd>shift</kbd>-click adds it to a side-by-side comparison. <kbd>\u2190</kbd>/<kbd>\u2192</kbd> step through the visible set while a record
|
|
10079
|
+
is open, <kbd>Esc</kbd> closes it, and <kbd>/</kbd> jumps to search. This page reads the manifest, lockfile,
|
|
10080
|
+
and disk only \u2014 it never contacts a provider<span id="foot-edit"> and never writes anything</span><span id="foot-editing" hidden>.
|
|
10081
|
+
Editing is on: saving rewrites the manifest and nothing else</span><span id="foot-gen" hidden>.
|
|
10082
|
+
Generation is on under the session budget shown above; every run is the same submit, poll, and fetch as <code>pixelkiln gen</code></span>.
|
|
10083
|
+
<span id="note"></span>
|
|
10084
|
+
</footer>
|
|
10085
|
+
<div id="drawer-host"></div>
|
|
10086
|
+
<div id="dialog-host"></div>
|
|
10087
|
+
<script>
|
|
10088
|
+
const INITIAL = ${data};
|
|
10089
|
+
const SESSION = ${session};
|
|
10090
|
+
const EDITABLE = ${editable};
|
|
10091
|
+
const GENERATION = ${generation};
|
|
10092
|
+
let snap = INITIAL;
|
|
10093
|
+
const STATE_TONE = {
|
|
10094
|
+
ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
|
|
10095
|
+
failed: 'bad', 'in-flight': 'cool', recoverable: 'cool', missing: 'dim', undeclared: 'dim',
|
|
10096
|
+
};
|
|
10097
|
+
const STATE_ORDER = ['ok','stale','orphaned','untracked','in-flight','recoverable','blocked','failed','missing','undeclared'];
|
|
10098
|
+
const ui = {
|
|
10099
|
+
q: '', states: new Set(), providers: new Set(), generators: new Set(), projects: new Set(),
|
|
10100
|
+
sort: 'key', group: 'style', open: null, member: 0, zoom: 'auto', playing: null,
|
|
10101
|
+
/** Cards rendered per pass; a project with thousands of assets opts into the rest. */
|
|
10102
|
+
limit: 600,
|
|
10103
|
+
/** Item id whose edit form is open, or 'new:<project>:<style>' for an add form. */
|
|
10104
|
+
editing: null,
|
|
10105
|
+
/** One-shot confirmation shown in the drawer after a save. */
|
|
10106
|
+
notice: null,
|
|
10107
|
+
/** Record ids picked for side-by-side comparison, in pick order (max 4). */
|
|
10108
|
+
compare: [],
|
|
10109
|
+
/** Zoom for the comparison panel. */
|
|
10110
|
+
compareZoom: 'auto',
|
|
10111
|
+
/** Jobs expanded to show their log. */
|
|
10112
|
+
logs: new Set(),
|
|
10113
|
+
/** Job ids already seen finished, so a completion refreshes exactly once. */
|
|
10114
|
+
settled: new Set(),
|
|
10115
|
+
};
|
|
10116
|
+
/** Generation status from /api/jobs: jobs, session budget, spend so far. */
|
|
10117
|
+
let GEN = { jobs: [], budget: { byProvider: {} }, spent: {}, units: {} };
|
|
10118
|
+
const $ = (id) => document.getElementById(id);
|
|
10119
|
+
const el = (tag, cls, text) => {
|
|
10120
|
+
const n = document.createElement(tag);
|
|
10121
|
+
if (cls) n.className = cls;
|
|
10122
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
10123
|
+
return n;
|
|
10124
|
+
};
|
|
10125
|
+
const fmtCost = (unit, amount) => {
|
|
10126
|
+
if (unit === 'free') return 'free';
|
|
10127
|
+
if (unit === 'usd') return '$' + Number(amount).toFixed(2);
|
|
10128
|
+
if (unit === 'generations') return amount + ' generation' + (amount === 1 ? '' : 's');
|
|
10129
|
+
return amount + ' ' + unit;
|
|
10130
|
+
};
|
|
10131
|
+
const fmtSpend = (spend) => Object.entries(spend).filter(([, n]) => n).sort()
|
|
10132
|
+
.map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + ') || 'no spend recorded';
|
|
10133
|
+
const fmtBytes = (n) => n == null ? '\u2014' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB';
|
|
10134
|
+
const fmtWhen = (iso) => {
|
|
10135
|
+
if (!iso) return null;
|
|
10136
|
+
const d = new Date(iso);
|
|
10137
|
+
return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
10138
|
+
};
|
|
10139
|
+
const isFrameSet = (item) => item.generator === 'frames' ||
|
|
10140
|
+
(item.outputs.length > 1 && item.outputs.every((o) => o.role && /^frame/i.test(o.role)));
|
|
10141
|
+
const shortPath = (p) => {
|
|
10142
|
+
if (p.length <= 64) return p;
|
|
10143
|
+
const parts = p.split('/');
|
|
10144
|
+
return parts.length > 4 ? '\u2026/' + parts.slice(-3).join('/') : p;
|
|
10145
|
+
};
|
|
10146
|
+
const displayScale = (w, h, boxW, boxH) => {
|
|
10147
|
+
const s = Math.min(boxW / w, boxH / h);
|
|
10148
|
+
return s >= 1 ? Math.floor(s) : s;
|
|
10149
|
+
};
|
|
10150
|
+
|
|
10151
|
+
const projectOf = (item) => snap.workspace
|
|
10152
|
+
? snap.workspace.projects.find((pr) => pr.id === item.project)
|
|
10153
|
+
: snap.project;
|
|
10154
|
+
|
|
10155
|
+
// The only write this page ever makes: one manifest edit, quoting the
|
|
10156
|
+
// manifest hash it was rendered from so a concurrent hand edit is refused
|
|
10157
|
+
// rather than overwritten. The server answers with the rebuilt gallery.
|
|
10158
|
+
async function postEdit(body) {
|
|
10159
|
+
const res = await fetch('/api/edit', {
|
|
10160
|
+
method: 'POST',
|
|
10161
|
+
headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
|
|
10162
|
+
body: JSON.stringify(body),
|
|
10163
|
+
});
|
|
10164
|
+
if (!res.ok) {
|
|
10165
|
+
const err = new Error(await res.text());
|
|
10166
|
+
err.status = res.status;
|
|
10167
|
+
throw err;
|
|
10168
|
+
}
|
|
10169
|
+
return res.json();
|
|
10170
|
+
}
|
|
10171
|
+
const field = (label, input, hint) => {
|
|
10172
|
+
const w = el('label', 'field');
|
|
10173
|
+
w.append(el('span', null, label), input);
|
|
10174
|
+
if (hint) w.append(el('small', null, hint));
|
|
10175
|
+
return w;
|
|
10176
|
+
};
|
|
10177
|
+
const numberInput = (value, placeholder) => {
|
|
10178
|
+
const i = el('input'); i.type = 'number'; i.min = '16'; i.step = '1';
|
|
10179
|
+
i.value = value == null ? '' : String(value); i.placeholder = placeholder || '';
|
|
10180
|
+
return i;
|
|
10181
|
+
};
|
|
10182
|
+
const numberOrNull = (input) => input.value.trim() === '' ? null : Number(input.value);
|
|
10183
|
+
const splitTags = (value) => value.split(',').map((t) => t.trim()).filter(Boolean);
|
|
10184
|
+
|
|
10185
|
+
// ---- generation (only when the server holds a session budget) -------------
|
|
10186
|
+
|
|
10187
|
+
const ACTIVE_PHASES = new Set(['queued', 'submitting', 'polling', 'fetching', 'review']);
|
|
10188
|
+
const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetching: 'cool', review: 'warn', done: 'ok', failed: 'bad' };
|
|
10189
|
+
const remainingBudget = (provider) => {
|
|
10190
|
+
const keyed = GEN.budget.byProvider[provider];
|
|
10191
|
+
const ceiling = keyed !== undefined ? keyed : GEN.budget.amount;
|
|
10192
|
+
return ceiling === undefined ? null : Math.max(0, ceiling - (GEN.spent[provider] || 0));
|
|
10193
|
+
};
|
|
10194
|
+
async function postGenerate(body) {
|
|
10195
|
+
const res = await fetch('/api/generate', {
|
|
10196
|
+
method: 'POST',
|
|
10197
|
+
headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
|
|
10198
|
+
body: JSON.stringify(body),
|
|
10199
|
+
});
|
|
10200
|
+
if (!res.ok) { const err = new Error(await res.text()); err.status = res.status; throw err; }
|
|
10201
|
+
return res.json();
|
|
10202
|
+
}
|
|
10203
|
+
let jobsTimer = null;
|
|
10204
|
+
async function pollJobs() {
|
|
10205
|
+
if (!GENERATION) return;
|
|
10206
|
+
try {
|
|
10207
|
+
const res = await fetch('/api/jobs', { cache: 'no-store' });
|
|
10208
|
+
if (!res.ok) throw new Error(await res.text());
|
|
10209
|
+
GEN = await res.json();
|
|
10210
|
+
} catch (err) {
|
|
10211
|
+
$('note').textContent = ' Job status unavailable: ' + err.message;
|
|
10212
|
+
return;
|
|
10213
|
+
}
|
|
10214
|
+
renderJobs();
|
|
10215
|
+
renderHeader();
|
|
10216
|
+
let changed = false;
|
|
10217
|
+
for (const job of GEN.jobs) {
|
|
10218
|
+
if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) { ui.settled.add(job.id); changed = true; }
|
|
10219
|
+
if (job.phase === 'review' && !ui.settled.has(job.id + ':review')) { ui.settled.add(job.id + ':review'); changed = true; }
|
|
10220
|
+
}
|
|
10221
|
+
if (changed) refresh();
|
|
10222
|
+
const active = GEN.jobs.some((job) => ACTIVE_PHASES.has(job.phase) && job.phase !== 'review');
|
|
10223
|
+
clearTimeout(jobsTimer);
|
|
10224
|
+
if (active) jobsTimer = setTimeout(pollJobs, 2000);
|
|
10225
|
+
}
|
|
10226
|
+
function renderJobs() {
|
|
10227
|
+
const host = $('jobs');
|
|
10228
|
+
host.textContent = '';
|
|
10229
|
+
if (!GENERATION) return;
|
|
10230
|
+
for (const job of GEN.jobs) {
|
|
10231
|
+
if (ui.settled.has(job.id + ':dismissed')) continue;
|
|
10232
|
+
const row = el('div', 'job');
|
|
10233
|
+
const ph = el('span', 'ph');
|
|
10234
|
+
ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
|
|
10235
|
+
const what = (job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
|
|
10236
|
+
(job.project ? ' in ' + job.project : '');
|
|
10237
|
+
const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
|
|
10238
|
+
last.title = job.keys.join('\\n');
|
|
10239
|
+
const acts = el('div', 'acts');
|
|
10240
|
+
if (job.phase === 'review' && job.review.length) {
|
|
10241
|
+
const b = el('button', 'primary', 'Review ' + job.review.length);
|
|
10242
|
+
b.type = 'button'; b.onclick = () => openReview(job.id);
|
|
10243
|
+
acts.append(b);
|
|
10244
|
+
}
|
|
10245
|
+
const logBtn = el('button', null, ui.logs.has(job.id) ? 'Hide log' : 'Log');
|
|
10246
|
+
logBtn.type = 'button';
|
|
10247
|
+
logBtn.onclick = () => { ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id); renderJobs(); };
|
|
10248
|
+
acts.append(logBtn);
|
|
10249
|
+
if (!ACTIVE_PHASES.has(job.phase)) {
|
|
10250
|
+
const d = el('button', null, 'Dismiss');
|
|
10251
|
+
d.type = 'button'; d.onclick = () => { ui.settled.add(job.id + ':dismissed'); renderJobs(); };
|
|
10252
|
+
acts.append(d);
|
|
10253
|
+
}
|
|
10254
|
+
row.append(ph, last, acts);
|
|
10255
|
+
if (ui.logs.has(job.id)) row.append(el('pre', null, job.messages.join('\\n')));
|
|
10256
|
+
host.append(row);
|
|
10257
|
+
}
|
|
10258
|
+
}
|
|
10259
|
+
function budgetLine() {
|
|
10260
|
+
const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
|
|
10261
|
+
const parts = [];
|
|
10262
|
+
for (const provider of [...providers].sort()) {
|
|
10263
|
+
const left = remainingBudget(provider);
|
|
10264
|
+
const unit = GEN.units[provider] || 'generations';
|
|
10265
|
+
if (left !== null) parts.push(provider + ': ' + fmtCost(unit, Math.round(left * 100) / 100) + ' left');
|
|
10266
|
+
}
|
|
10267
|
+
// An unkeyed ceiling belongs to whichever single provider first spends it.
|
|
10268
|
+
if (GEN.budget.amount !== undefined && !Object.keys(GEN.spent).length) parts.push(GEN.budget.amount + ' available');
|
|
10269
|
+
return parts.length ? 'session budget \u2014 ' + parts.join(' \xB7 ') : '';
|
|
10270
|
+
}
|
|
10271
|
+
|
|
10272
|
+
// The confirm step: what will be sent, what it is estimated to cost, and what
|
|
10273
|
+
// this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
|
|
10274
|
+
function generateDialog(items, { project = null, force = false, resume = false } = {}) {
|
|
10275
|
+
const host = $('dialog-host');
|
|
10276
|
+
host.textContent = '';
|
|
10277
|
+
const wrap = el('div', 'dialog');
|
|
10278
|
+
const form = el('form');
|
|
10279
|
+
form.append(el('h3', null, resume ? 'Resume ' + items.length + (items.length === 1 ? ' asset' : ' assets')
|
|
10280
|
+
: (force ? 'Regenerate ' : 'Generate ') + items.length + (items.length === 1 ? ' asset' : ' assets')));
|
|
10281
|
+
const table = el('table');
|
|
10282
|
+
const thead = el('tr'); thead.append(el('th', null, 'asset'), el('th', null, 'state'), el('th', 'n', 'candidates'), el('th', 'n', 'estimate'));
|
|
10283
|
+
table.append(thead);
|
|
10284
|
+
const byProvider = new Map();
|
|
10285
|
+
for (const item of items) {
|
|
10286
|
+
const tr = el('tr');
|
|
10287
|
+
tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
|
|
10288
|
+
el('td', 'n', resume ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
|
|
10289
|
+
table.append(tr);
|
|
10290
|
+
if (!resume) {
|
|
10291
|
+
const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
|
|
10292
|
+
g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
|
|
10293
|
+
}
|
|
10294
|
+
}
|
|
10295
|
+
form.append(table);
|
|
10296
|
+
const sum = el('div', 'sum');
|
|
10297
|
+
if (resume) {
|
|
10298
|
+
sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
|
|
10299
|
+
} else {
|
|
10300
|
+
for (const [provider, g] of byProvider) {
|
|
10301
|
+
const left = remainingBudget(provider);
|
|
10302
|
+
const line = el('div');
|
|
10303
|
+
line.append(el('b', null, provider + ': ' + fmtCost(g.unit, Math.round(g.cost * 100) / 100)));
|
|
10304
|
+
line.append(document.createTextNode(left === null ? ' \u2014 no session budget for this provider'
|
|
10305
|
+
: ' \xB7 ' + fmtCost(g.unit, Math.round(left * 100) / 100) + ' of the session budget left'));
|
|
10306
|
+
if (left !== null && g.cost > left) line.className = 'warn';
|
|
10307
|
+
sum.append(line);
|
|
10308
|
+
}
|
|
10309
|
+
if (force) sum.append(el('div', 'warn', 'Regenerating replaces the current art. The previous file is replaced when the new result is fetched; the provider objects it came from are not deleted.'));
|
|
10310
|
+
sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
|
|
10311
|
+
}
|
|
10312
|
+
form.append(sum);
|
|
10313
|
+
const actions = el('div', 'actions');
|
|
10314
|
+
const go = el('button', 'primary', resume ? 'Resume' : 'Generate');
|
|
10315
|
+
go.type = 'submit';
|
|
10316
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
|
|
10317
|
+
const msg = el('span', 'msg');
|
|
10318
|
+
actions.append(go, cancel, msg);
|
|
10319
|
+
form.append(actions);
|
|
10320
|
+
form.onsubmit = async (e) => {
|
|
10321
|
+
e.preventDefault();
|
|
10322
|
+
go.disabled = true; msg.textContent = '';
|
|
10323
|
+
try {
|
|
10324
|
+
const body = { keys: items.map((i) => i.key) };
|
|
10325
|
+
if (project) body.project = project;
|
|
10326
|
+
if (force) body.force = true;
|
|
10327
|
+
if (resume) body.resume = true;
|
|
10328
|
+
const job = await postGenerate(body);
|
|
10329
|
+
host.textContent = '';
|
|
10330
|
+
GEN.jobs.unshift(job);
|
|
10331
|
+
renderJobs();
|
|
10332
|
+
pollJobs();
|
|
10333
|
+
} catch (err) {
|
|
10334
|
+
go.disabled = false;
|
|
10335
|
+
msg.textContent = err.message;
|
|
10336
|
+
}
|
|
10337
|
+
};
|
|
10338
|
+
wrap.append(form);
|
|
10339
|
+
host.append(wrap);
|
|
10340
|
+
setTimeout(() => go.focus(), 0);
|
|
10341
|
+
}
|
|
10342
|
+
|
|
10343
|
+
function openReview(jobId) {
|
|
10344
|
+
const host = $('dialog-host');
|
|
10345
|
+
host.textContent = '';
|
|
10346
|
+
const scrim = el('div', 'sheet-scrim');
|
|
10347
|
+
scrim.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
|
|
10348
|
+
const panel = el('div', 'sheet review-host');
|
|
10349
|
+
const bar = el('div', 'rbar');
|
|
10350
|
+
const close = el('button', null, 'Back to gallery'); close.type = 'button';
|
|
10351
|
+
close.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
|
|
10352
|
+
bar.append(close, el('span', null, 'Choose from the candidates below. Apply selections writes the lockfile and downloads what you chose; unchosen rows stay in review.'));
|
|
10353
|
+
const frame = el('iframe');
|
|
10354
|
+
frame.src = '/review/' + encodeURIComponent(jobId);
|
|
10355
|
+
frame.title = 'Candidate review';
|
|
10356
|
+
panel.append(bar, frame);
|
|
10357
|
+
host.append(scrim, panel);
|
|
10358
|
+
}
|
|
10359
|
+
window.addEventListener('message', (e) => {
|
|
10360
|
+
if (e.origin !== location.origin || !e.data || e.data.type !== 'pixelkiln:review-applied') return;
|
|
10361
|
+
setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
|
|
10362
|
+
});
|
|
10363
|
+
|
|
10364
|
+
function visibleItems() {
|
|
10365
|
+
const q = ui.q.trim().toLowerCase();
|
|
10366
|
+
let items = snap.items.filter((item) => {
|
|
10367
|
+
if (ui.states.size && !ui.states.has(item.state)) return false;
|
|
10368
|
+
if (ui.providers.size && !ui.providers.has(item.provider)) return false;
|
|
10369
|
+
if (ui.generators.size && !ui.generators.has(item.generator)) return false;
|
|
10370
|
+
if (ui.projects.size && !ui.projects.has(item.project)) return false;
|
|
10371
|
+
if (!q) return true;
|
|
10372
|
+
const hay = [item.id, item.prompt, item.currentPrompt, item.jobId, item.objectId,
|
|
10373
|
+
item.recordedSpecHash, item.currentSpecHash, item.category, ...(item.tags || []),
|
|
10374
|
+
...item.outputs.flatMap((o) => [o.path, o.sha256])].filter(Boolean).join('\\n').toLowerCase();
|
|
10375
|
+
return hay.includes(q);
|
|
10376
|
+
});
|
|
10377
|
+
const when = (item) => item.downloadedAt || item.submittedAt || '';
|
|
10378
|
+
const sorters = {
|
|
10379
|
+
key: (a, b) => a.id.localeCompare(b.id),
|
|
10380
|
+
newest: (a, b) => when(b).localeCompare(when(a)) || a.id.localeCompare(b.id),
|
|
10381
|
+
oldest: (a, b) => when(a).localeCompare(when(b)) || a.id.localeCompare(b.id),
|
|
10382
|
+
cost: (a, b) => (b.cost - a.cost) || a.id.localeCompare(b.id),
|
|
10383
|
+
size: (a, b) => (b.width * b.height - a.width * a.height) || a.id.localeCompare(b.id),
|
|
10384
|
+
};
|
|
10385
|
+
items.sort(sorters[ui.sort] || sorters.key);
|
|
10386
|
+
return items;
|
|
10387
|
+
}
|
|
10388
|
+
|
|
10389
|
+
function renderHeader() {
|
|
10390
|
+
$('editing').hidden = !EDITABLE;
|
|
10391
|
+
$('foot-edit').hidden = EDITABLE;
|
|
10392
|
+
$('foot-editing').hidden = !EDITABLE;
|
|
10393
|
+
$('foot-gen').hidden = !GENERATION;
|
|
10394
|
+
const p = $('project');
|
|
10395
|
+
p.textContent = '';
|
|
10396
|
+
if (snap.workspace) {
|
|
10397
|
+
p.append(el('b', null, 'workspace'), ' ', document.createTextNode(shortPath(snap.workspace.path)));
|
|
10398
|
+
p.title = snap.workspace.path;
|
|
10399
|
+
} else {
|
|
10400
|
+
p.append(el('b', null, snap.project.name), ' ', document.createTextNode(shortPath(snap.project.manifest)));
|
|
10401
|
+
p.title = snap.project.manifest + '\\nlockfile: ' + snap.project.lock;
|
|
10402
|
+
}
|
|
10403
|
+
|
|
10404
|
+
const t = $('totals');
|
|
10405
|
+
t.textContent = '';
|
|
10406
|
+
if (snap.workspace) {
|
|
10407
|
+
const broken = snap.workspace.projects.filter((pr) => pr.error).length;
|
|
10408
|
+
const w = el('span', null, '');
|
|
10409
|
+
w.append(el('b', null, snap.workspace.projects.length), document.createTextNode(' project' +
|
|
10410
|
+
(snap.workspace.projects.length === 1 ? '' : 's') + (broken ? ' (' + broken + ' unreadable)' : '')));
|
|
10411
|
+
t.append(w);
|
|
10412
|
+
}
|
|
10413
|
+
const n = snap.totals.entries;
|
|
10414
|
+
t.append(el('span', null, ''));
|
|
10415
|
+
t.lastChild.append(el('b', null, n), document.createTextNode(' lock ' + (n === 1 ? 'entry' : 'entries')));
|
|
10416
|
+
const declared = snap.items.filter((i) => i.declared).length;
|
|
10417
|
+
t.append(el('span', null, declared + ' declared by ' + (snap.workspace ? 'a manifest' : 'the manifest')));
|
|
10418
|
+
const recorded = fmtSpend(snap.totals.spendByUnit);
|
|
10419
|
+
t.append(el('span', null, recorded === 'no spend recorded' ? recorded : recorded + ' recorded'));
|
|
10420
|
+
if (snap.filter.styles.length || snap.filter.assets.length) {
|
|
10421
|
+
t.append(el('span', 'state-warn', 'filtered: ' +
|
|
10422
|
+
[snap.filter.styles.length ? '--style ' + snap.filter.styles.join(',') : '',
|
|
10423
|
+
snap.filter.assets.length ? '--only ' + snap.filter.assets.join(',') : ''].filter(Boolean).join(' ')));
|
|
10424
|
+
}
|
|
10425
|
+
t.append(el('span', null, 'snapshot ' + fmtWhen(snap.generatedAt)));
|
|
10426
|
+
if (GENERATION) {
|
|
10427
|
+
const line = budgetLine();
|
|
10428
|
+
if (line) t.append(el('span', 'budget', line));
|
|
10429
|
+
}
|
|
10430
|
+
|
|
10431
|
+
const chips = $('chips');
|
|
10432
|
+
chips.textContent = '';
|
|
10433
|
+
const counts = (pick) => {
|
|
10434
|
+
const m = new Map();
|
|
10435
|
+
for (const item of snap.items) { const k = pick(item); m.set(k, (m.get(k) || 0) + 1); }
|
|
10436
|
+
return m;
|
|
10437
|
+
};
|
|
10438
|
+
const chip = (label, count, set, key, tone) => {
|
|
10439
|
+
const b = el('button', 'chip' + (set.has(key) ? ' on' : ''));
|
|
10440
|
+
b.type = 'button';
|
|
10441
|
+
b.setAttribute('aria-pressed', String(set.has(key)));
|
|
10442
|
+
if (tone) b.append(el('i', 'dot ' + tone));
|
|
10443
|
+
b.append(document.createTextNode(label), el('span', 'n', count));
|
|
10444
|
+
b.onclick = () => { set.has(key) ? set.delete(key) : set.add(key); render(); };
|
|
10445
|
+
return b;
|
|
10446
|
+
};
|
|
10447
|
+
if (snap.workspace) {
|
|
10448
|
+
const byProject = counts((i) => i.project);
|
|
10449
|
+
for (const pr of snap.workspace.projects) {
|
|
10450
|
+
if (byProject.has(pr.id) || ui.projects.has(pr.id)) chips.append(chip(pr.id, byProject.get(pr.id) || 0, ui.projects, pr.id));
|
|
10451
|
+
}
|
|
10452
|
+
chips.append(el('span', 'sep'));
|
|
10453
|
+
}
|
|
10454
|
+
const byState = counts((i) => i.state);
|
|
10455
|
+
for (const s of STATE_ORDER) if (byState.has(s)) chips.append(chip(s, byState.get(s), ui.states, s, STATE_TONE[s]));
|
|
10456
|
+
const byProvider = counts((i) => i.provider);
|
|
10457
|
+
if (byProvider.size > 1 || ui.providers.size) {
|
|
10458
|
+
chips.append(el('span', 'sep'));
|
|
10459
|
+
for (const [k, v] of [...byProvider].sort()) chips.append(chip(k, v, ui.providers, k));
|
|
10460
|
+
}
|
|
10461
|
+
const byGen = counts((i) => i.generator);
|
|
10462
|
+
if (byGen.size > 1 || ui.generators.size) {
|
|
10463
|
+
chips.append(el('span', 'sep'));
|
|
10464
|
+
for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
|
|
10465
|
+
}
|
|
10466
|
+
if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
|
|
10467
|
+
const clear = el('button', 'chip', 'clear filters');
|
|
10468
|
+
clear.type = 'button';
|
|
10469
|
+
clear.onclick = () => clearFilters();
|
|
10470
|
+
chips.append(el('span', 'sep'), clear);
|
|
10471
|
+
}
|
|
10472
|
+
}
|
|
10473
|
+
|
|
10474
|
+
function clearFilters() {
|
|
10475
|
+
ui.states.clear(); ui.providers.clear(); ui.generators.clear(); ui.projects.clear();
|
|
10476
|
+
ui.q = ''; $('q').value = '';
|
|
10477
|
+
render();
|
|
10478
|
+
}
|
|
10479
|
+
|
|
10480
|
+
function thumb(item) {
|
|
10481
|
+
const cell = el('div', 'cell');
|
|
10482
|
+
const shown = item.outputs.filter((o) => o.url);
|
|
10483
|
+
if (!shown.length) {
|
|
10484
|
+
cell.classList.add('none');
|
|
10485
|
+
cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
|
|
10486
|
+
: item.state === 'in-flight' ? (item.status === 'review' ? 'awaiting review' : 'awaiting provider')
|
|
10487
|
+
: item.state === 'recoverable' ? 'ready to fetch'
|
|
10488
|
+
: item.state === 'failed' ? 'generation failed'
|
|
10489
|
+
: item.state === 'blocked' ? 'waiting on its parent'
|
|
10490
|
+
: item.outputs.length ? 'file missing on disk' : 'no output recorded'));
|
|
10491
|
+
return cell;
|
|
10492
|
+
}
|
|
10493
|
+
const w = item.width, h = item.height;
|
|
10494
|
+
if (shown.length > 1 && !isFrameSet(item)) {
|
|
10495
|
+
const multi = el('div', 'multi');
|
|
10496
|
+
for (const o of shown.slice(0, 4)) {
|
|
10497
|
+
const box = el('div');
|
|
10498
|
+
const img = el('img');
|
|
10499
|
+
img.src = o.url; img.alt = o.role || item.assetId; img.loading = 'lazy';
|
|
10500
|
+
const s = displayScale(w, h, 66, 52);
|
|
10501
|
+
if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
|
|
10502
|
+
box.append(img); multi.append(box);
|
|
10503
|
+
}
|
|
10504
|
+
cell.append(multi, el('span', 'badge', shown.length + ' outputs'));
|
|
10505
|
+
return cell;
|
|
10506
|
+
}
|
|
10507
|
+
const img = el('img');
|
|
10508
|
+
img.src = shown[0].url; img.alt = item.assetId; img.loading = 'lazy';
|
|
10509
|
+
const s = displayScale(w, h, 156, 116);
|
|
10510
|
+
if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
|
|
10511
|
+
cell.append(img);
|
|
10512
|
+
if (isFrameSet(item)) cell.append(el('span', 'badge', '\u25B8 ' + shown.length + ' frames'));
|
|
10513
|
+
else if (s >= 1 && s !== 1) cell.append(el('span', 'badge', s + '\xD7'));
|
|
10514
|
+
return cell;
|
|
10515
|
+
}
|
|
10516
|
+
|
|
10517
|
+
function card(item) {
|
|
10518
|
+
const slot = ui.compare.indexOf(item.id);
|
|
10519
|
+
const c = el('button', 'card' + (item.outputs.some((o) => o.url) ? '' : ' ghost') + (ui.open === item.id ? ' active' : '') + (slot >= 0 ? ' compared' : ''));
|
|
10520
|
+
c.type = 'button';
|
|
10521
|
+
c.dataset.key = item.id;
|
|
10522
|
+
c.setAttribute('aria-label', item.id + ', ' + item.state + (slot >= 0 ? ', in comparison' : ''));
|
|
10523
|
+
const body = el('div', 'cbody');
|
|
10524
|
+
body.append(el('div', 'aid', item.assetId));
|
|
10525
|
+
const meta = el('div', 'cmeta');
|
|
10526
|
+
const st = el('span', 'st');
|
|
10527
|
+
st.append(el('i', 'dot ' + STATE_TONE[item.state]), document.createTextNode(item.state));
|
|
10528
|
+
meta.append(st, el('span', null, item.width + '\xD7' + item.height));
|
|
10529
|
+
if (item.cost) meta.append(el('span', null, fmtCost(item.costUnit, item.cost)));
|
|
10530
|
+
body.append(meta);
|
|
10531
|
+
const cell = thumb(item);
|
|
10532
|
+
if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
|
|
10533
|
+
c.append(cell, body);
|
|
10534
|
+
c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
|
|
10535
|
+
return c;
|
|
10536
|
+
}
|
|
10537
|
+
|
|
10538
|
+
function renderMain(items) {
|
|
10539
|
+
const root = $('root');
|
|
10540
|
+
root.textContent = '';
|
|
10541
|
+
if (!snap.items.length) {
|
|
10542
|
+
const e = el('div', 'empty');
|
|
10543
|
+
e.append(el('p', null, 'Nothing has been generated for this project yet.'));
|
|
10544
|
+
e.append(el('p', null, 'pixelkiln plan shows what the manifest would make; pixelkiln gen makes it.'));
|
|
10545
|
+
root.append(e);
|
|
10546
|
+
return;
|
|
10547
|
+
}
|
|
10548
|
+
if (!items.length) {
|
|
10549
|
+
const e = el('div', 'empty');
|
|
10550
|
+
e.append(el('p', null, 'No generations match these filters.'));
|
|
10551
|
+
const b = el('button', null, 'Clear filters');
|
|
10552
|
+
b.type = 'button';
|
|
10553
|
+
b.onclick = clearFilters;
|
|
10554
|
+
e.append(b);
|
|
10555
|
+
root.append(e);
|
|
10556
|
+
return;
|
|
10557
|
+
}
|
|
10558
|
+
// Building every card for a very large project on each keystroke is the one
|
|
10559
|
+
// thing that makes this page feel slow, so a render is capped and the rest
|
|
10560
|
+
// is one click away. Filters usually narrow well below the cap anyway.
|
|
10561
|
+
const hidden = Math.max(0, items.length - ui.limit);
|
|
10562
|
+
const shown = hidden ? items.slice(0, ui.limit) : items;
|
|
10563
|
+
// A workspace always breaks on project: lock keys repeat across projects,
|
|
10564
|
+
// so a flat wall of "anvil" cards would be ambiguous. Style grouping stays
|
|
10565
|
+
// optional inside each project, exactly as it is for one project.
|
|
10566
|
+
const unfiltered = !ui.projects.size && !ui.q && !ui.states.size && !ui.providers.size && !ui.generators.size;
|
|
10567
|
+
const projects = snap.workspace
|
|
10568
|
+
? snap.workspace.projects.filter((pr) => ui.projects.size
|
|
10569
|
+
? ui.projects.has(pr.id)
|
|
10570
|
+
: unfiltered || shown.some((i) => i.project === pr.id))
|
|
10571
|
+
: [null];
|
|
10572
|
+
const sections = [];
|
|
10573
|
+
for (const pr of projects) {
|
|
10574
|
+
const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
|
|
10575
|
+
const styleSections = ui.group === 'style'
|
|
10576
|
+
? snap.styles.filter((s) => !pr || s.project === pr.id)
|
|
10577
|
+
.map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length)
|
|
10578
|
+
: (inProject.length ? [{ style: null, items: inProject }] : []);
|
|
10579
|
+
if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
|
|
10580
|
+
sections.push(...styleSections);
|
|
10581
|
+
}
|
|
10582
|
+
for (const sec of sections) {
|
|
10583
|
+
if (sec.project) {
|
|
10584
|
+
root.append(projectHeader(sec.project, sec.count));
|
|
10585
|
+
continue;
|
|
10586
|
+
}
|
|
10587
|
+
const wrap = el('section', 'style');
|
|
10588
|
+
if (sec.style) {
|
|
10589
|
+
const s = sec.style;
|
|
10590
|
+
const head = el('div', 'shead');
|
|
10591
|
+
head.append(el('h2', null, s.id));
|
|
10592
|
+
const meta = el('div', 'meta');
|
|
10593
|
+
meta.append(el('span', null, s.provider), el('span', null, s.generator));
|
|
10594
|
+
if (s.outDir) meta.append(el('span', 'mono', s.outDir));
|
|
10595
|
+
if (s.palette.length) {
|
|
10596
|
+
const sw = el('span', 'swatches');
|
|
10597
|
+
for (const c of s.palette) { const i = el('i'); i.style.background = c; i.title = c; sw.append(i); }
|
|
10598
|
+
meta.append(sw);
|
|
10599
|
+
}
|
|
10600
|
+
if (s.quality) meta.append(el('span', null, 'quality profile'));
|
|
10601
|
+
meta.append(el('span', null, sec.items.length + ' of ' + s.items + (s.items === 1 ? ' asset' : ' assets')));
|
|
10602
|
+
if (Object.keys(s.spendByUnit).length) meta.append(el('span', null, fmtSpend(s.spendByUnit)));
|
|
10603
|
+
head.append(meta);
|
|
10604
|
+
const addKey = 'new:' + (s.project || '') + ':' + s.id;
|
|
10605
|
+
const tools = el('div', 'tools');
|
|
10606
|
+
if (s.outDir && s.candidates !== null) tools.append(candidatesControl(s));
|
|
10607
|
+
if (GENERATION && s.actionable.keys.length) {
|
|
10608
|
+
const g = el('button', 'primary', 'Generate ' + s.actionable.keys.length + ' \xB7 ' + fmtCost(s.actionable.costUnit, Math.round(s.actionable.cost * 100) / 100));
|
|
10609
|
+
g.type = 'button';
|
|
10610
|
+
g.onclick = () => generateDialog(snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
|
|
10611
|
+
tools.append(g);
|
|
10612
|
+
}
|
|
10613
|
+
const styleKey = 'style:' + (s.project || '') + ':' + s.id;
|
|
10614
|
+
if (EDITABLE && s.outDir) {
|
|
10615
|
+
const es = el('button', null, ui.editing === styleKey ? 'Cancel' : 'Edit style');
|
|
10616
|
+
es.type = 'button';
|
|
10617
|
+
es.onclick = () => { ui.editing = ui.editing === styleKey ? null : styleKey; ui.notice = null; render(); };
|
|
10618
|
+
tools.append(es);
|
|
10619
|
+
const add = el('button', 'add', ui.editing === addKey ? 'Cancel' : '+ Add asset');
|
|
10620
|
+
add.type = 'button';
|
|
10621
|
+
add.onclick = () => { ui.editing = ui.editing === addKey ? null : addKey; render(); };
|
|
10622
|
+
tools.append(add);
|
|
10623
|
+
}
|
|
10624
|
+
if (tools.childNodes.length) head.append(tools);
|
|
10625
|
+
wrap.append(head);
|
|
10626
|
+
if (ui.notice && ui.notice.id === styleKey) wrap.append(el('div', 'notice', ui.notice.text));
|
|
10627
|
+
if (ui.editing === styleKey) wrap.append(styleForm(s));
|
|
10628
|
+
if (ui.editing === addKey) wrap.append(addAssetForm(s));
|
|
10629
|
+
}
|
|
10630
|
+
const grid = el('div', 'grid');
|
|
10631
|
+
for (const item of sec.items) grid.append(card(item));
|
|
10632
|
+
wrap.append(grid);
|
|
10633
|
+
root.append(wrap);
|
|
10634
|
+
}
|
|
10635
|
+
if (hidden) {
|
|
10636
|
+
const more = el('div', 'empty');
|
|
10637
|
+
more.append(el('p', null, hidden + ' more ' + (hidden === 1 ? 'generation is' : 'generations are') + ' not shown.'));
|
|
10638
|
+
const b = el('button', null, 'Show all ' + items.length);
|
|
10639
|
+
b.type = 'button';
|
|
10640
|
+
b.onclick = () => { ui.limit = Infinity; render(); };
|
|
10641
|
+
more.append(b);
|
|
10642
|
+
root.append(more);
|
|
10643
|
+
}
|
|
10644
|
+
}
|
|
10645
|
+
|
|
10646
|
+
function projectHeader(pr, count) {
|
|
10647
|
+
const head = el('section', 'project');
|
|
10648
|
+
const line = el('div', 'phead');
|
|
10649
|
+
line.append(el('h2', null, pr.id));
|
|
10650
|
+
const meta = el('div', 'meta');
|
|
10651
|
+
if (pr.name !== pr.id) meta.append(el('span', null, pr.name));
|
|
10652
|
+
meta.append(el('span', 'mono', shortPath(pr.manifest)));
|
|
10653
|
+
if (pr.account) meta.append(el('span', null, pr.account));
|
|
10654
|
+
if (pr.error) {
|
|
10655
|
+
meta.append(el('span', 'state-bad', 'unreadable: ' + pr.error));
|
|
10656
|
+
} else {
|
|
10657
|
+
meta.append(el('span', null, count + ' of ' + pr.items + ' shown \xB7 ' + pr.entries + ' lock ' + (pr.entries === 1 ? 'entry' : 'entries')));
|
|
10658
|
+
if (Object.keys(pr.spendByUnit).length) meta.append(el('span', null, fmtSpend(pr.spendByUnit)));
|
|
10659
|
+
}
|
|
10660
|
+
line.append(meta);
|
|
10661
|
+
head.append(line);
|
|
10662
|
+
return head;
|
|
10663
|
+
}
|
|
10664
|
+
|
|
10665
|
+
// How many images one generation returns for this style. A provider option
|
|
10666
|
+
// where the adapter has one; a fact of the generator where it does not.
|
|
10667
|
+
function candidatesControl(style) {
|
|
10668
|
+
const wrap = el('span', 'cand');
|
|
10669
|
+
const editKey = 'cand:' + (style.project || '') + ':' + style.id;
|
|
10670
|
+
const declared = snap.items.filter((i) => i.project === style.project && i.styleId === style.id && i.declared).length;
|
|
10671
|
+
if (!(EDITABLE && style.candidatesEditable) || ui.editing !== editKey) {
|
|
10672
|
+
wrap.append(el('span', null, style.candidates + (style.candidates === 1 ? ' candidate' : ' candidates') + ' per generation'));
|
|
10673
|
+
if (EDITABLE && style.candidatesEditable) {
|
|
10674
|
+
const b = el('button', null, 'change'); b.type = 'button';
|
|
10675
|
+
b.onclick = () => { ui.editing = editKey; render(); };
|
|
10676
|
+
wrap.append(b);
|
|
10677
|
+
} else if (style.provider === 'pixellab') {
|
|
10678
|
+
wrap.title = 'PixelLab: map and pixflux return one image per generation; a 1dir style returns 4\u201364 for its size.';
|
|
10679
|
+
}
|
|
10680
|
+
return wrap;
|
|
10681
|
+
}
|
|
10682
|
+
const input = el('input'); input.type = 'number'; input.min = '1'; input.max = '64'; input.value = String(style.candidates);
|
|
10683
|
+
const save = el('button', 'primary', 'Save'); save.type = 'button';
|
|
10684
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
|
|
10685
|
+
const note = el('span', null, 'affects ' + declared + (declared === 1 ? ' asset' : ' assets') + ' \u2014 they become stale');
|
|
10686
|
+
save.onclick = async () => {
|
|
10687
|
+
save.disabled = true;
|
|
10688
|
+
try {
|
|
10689
|
+
const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
|
|
10690
|
+
const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch: { candidates: Number(input.value) } };
|
|
10691
|
+
if (style.project) body.project = style.project;
|
|
10692
|
+
snap = await postEdit(body);
|
|
10693
|
+
ui.editing = null;
|
|
10694
|
+
render();
|
|
10695
|
+
} catch (err) {
|
|
10696
|
+
save.disabled = false;
|
|
10697
|
+
note.textContent = err.message;
|
|
10698
|
+
note.className = 'state-bad';
|
|
10699
|
+
}
|
|
10700
|
+
};
|
|
10701
|
+
wrap.append(input, save, cancel, note);
|
|
10702
|
+
setTimeout(() => input.focus(), 0);
|
|
10703
|
+
return wrap;
|
|
10704
|
+
}
|
|
10705
|
+
|
|
10706
|
+
function generateActions(item) {
|
|
10707
|
+
const row = el('div', 'gen');
|
|
10708
|
+
if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
|
|
10709
|
+
const cost = item.estimatedCost === null ? '' : ' \xB7 ' + fmtCost(item.costUnit, item.estimatedCost);
|
|
10710
|
+
const add = (label, cls, opts) => {
|
|
10711
|
+
const b = el('button', cls, label); b.type = 'button';
|
|
10712
|
+
b.onclick = () => generateDialog([item], { project: item.project, ...opts });
|
|
10713
|
+
row.append(b);
|
|
10714
|
+
};
|
|
10715
|
+
if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
|
|
10716
|
+
else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
|
|
10717
|
+
if (item.state === 'in-flight' || item.state === 'recoverable') {
|
|
10718
|
+
// A job already parked this asset in review: go straight to its sheet.
|
|
10719
|
+
const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
|
|
10720
|
+
if (holder) {
|
|
10721
|
+
const b = el('button', 'primary', 'Review candidates'); b.type = 'button';
|
|
10722
|
+
b.onclick = () => openReview(holder.id);
|
|
10723
|
+
row.append(b);
|
|
10724
|
+
} else {
|
|
10725
|
+
add(item.status === 'review' ? 'Resume review' : 'Resume \xB7 no cost', 'primary', { resume: true });
|
|
10726
|
+
}
|
|
10727
|
+
}
|
|
10728
|
+
return row;
|
|
10729
|
+
}
|
|
10730
|
+
|
|
10731
|
+
// ---- compare ---------------------------------------------------------------
|
|
10732
|
+
|
|
10733
|
+
function toggleCompare(id) {
|
|
10734
|
+
const at = ui.compare.indexOf(id);
|
|
10735
|
+
if (at >= 0) ui.compare.splice(at, 1);
|
|
10736
|
+
else if (ui.compare.length >= 4) { $('note').textContent = ' Compare holds four records; remove one first.'; return; }
|
|
10737
|
+
else ui.compare.push(id);
|
|
10738
|
+
$('note').textContent = '';
|
|
10739
|
+
render();
|
|
10740
|
+
}
|
|
10741
|
+
function renderTray() {
|
|
10742
|
+
let tray = $('tray');
|
|
10743
|
+
if (!ui.compare.length) { if (tray) tray.remove(); return; }
|
|
10744
|
+
if (!tray) { tray = el('div', 'tray'); tray.id = 'tray'; document.body.append(tray); }
|
|
10745
|
+
tray.textContent = '';
|
|
10746
|
+
const chips = el('div', 'chipset');
|
|
10747
|
+
for (const id of ui.compare) {
|
|
10748
|
+
const item = snap.items.find((i) => i.id === id);
|
|
10749
|
+
if (!item) continue;
|
|
10750
|
+
const pick = el('span', 'pick');
|
|
10751
|
+
const shown = item.outputs.find((o) => o.url);
|
|
10752
|
+
if (shown) { const t = el('img'); t.src = shown.url; t.alt = ''; pick.append(t); }
|
|
10753
|
+
pick.append(document.createTextNode((item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId));
|
|
10754
|
+
const x = el('button', null, '\xD7'); x.type = 'button'; x.title = 'Remove from comparison';
|
|
10755
|
+
x.onclick = () => toggleCompare(id);
|
|
10756
|
+
pick.append(x);
|
|
10757
|
+
chips.append(pick);
|
|
10758
|
+
}
|
|
10759
|
+
const go = el('button', 'primary', 'Compare ' + ui.compare.length); go.type = 'button';
|
|
10760
|
+
go.disabled = ui.compare.length < 2;
|
|
10761
|
+
go.onclick = () => openCompare();
|
|
10762
|
+
const clear = el('button', null, 'Clear'); clear.type = 'button';
|
|
10763
|
+
clear.onclick = () => { ui.compare = []; render(); };
|
|
10764
|
+
tray.append(chips, go, clear);
|
|
10765
|
+
}
|
|
10766
|
+
function openCompare() {
|
|
10767
|
+
const items = ui.compare.map((id) => snap.items.find((i) => i.id === id)).filter(Boolean);
|
|
10768
|
+
if (items.length < 2) return;
|
|
10769
|
+
const host = $('dialog-host');
|
|
10770
|
+
host.textContent = '';
|
|
10771
|
+
const scrim = el('div', 'sheet-scrim'); scrim.onclick = () => { host.textContent = ''; };
|
|
10772
|
+
const panel = el('div', 'sheet compare-host');
|
|
10773
|
+
const bar = el('div', 'rbar');
|
|
10774
|
+
const close = el('button', null, 'Back to gallery'); close.type = 'button'; close.onclick = () => { host.textContent = ''; };
|
|
10775
|
+
bar.append(close, el('span', null, 'Fields that differ are tinted. Shift-click cards in the gallery to change the set.'));
|
|
10776
|
+
const zoomBar = el('div', 'zoom');
|
|
10777
|
+
zoomBar.append(el('span', null, 'zoom'));
|
|
10778
|
+
const columnWidth = Math.max(160, Math.floor((Math.min(window.innerWidth * 0.96, 1500) - 140) / items.length) - 24);
|
|
10779
|
+
const largest = Math.max(...items.map((i) => Math.max(i.width, i.height)));
|
|
10780
|
+
const auto = displayScale(largest, largest, columnWidth, Math.min(window.innerHeight * 0.5, 520));
|
|
10781
|
+
for (const z of ['fit', 1, 2, 4, 8]) {
|
|
10782
|
+
const b = el('button', ui.compareZoom === String(z) || (ui.compareZoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
|
|
10783
|
+
b.type = 'button'; b.onclick = () => { ui.compareZoom = String(z); openCompare(); };
|
|
10784
|
+
zoomBar.append(b);
|
|
10785
|
+
}
|
|
10786
|
+
bar.append(zoomBar);
|
|
10787
|
+
const zoom = ui.compareZoom === 'auto' ? auto : ui.compareZoom === 'fit' ? Math.min(auto, 1) : Number(ui.compareZoom);
|
|
10788
|
+
|
|
10789
|
+
const body = el('div', 'compare-body');
|
|
10790
|
+
const table = el('table');
|
|
10791
|
+
const thead = el('thead'); const hr = el('tr'); hr.append(el('th', null, ''));
|
|
10792
|
+
for (const item of items) {
|
|
10793
|
+
const th = el('th');
|
|
10794
|
+
const head = el('div', 'chead');
|
|
10795
|
+
const badges = el('div', 'badges');
|
|
10796
|
+
if (item.project) badges.append(el('span', 'sid project', item.project));
|
|
10797
|
+
badges.append(el('span', 'sid', item.styleId));
|
|
10798
|
+
const aid = el('div', 'aid', item.assetId);
|
|
10799
|
+
const openBtn = el('button', 'linkish', 'open record'); openBtn.type = 'button';
|
|
10800
|
+
openBtn.onclick = () => { host.textContent = ''; openItem(item.id); };
|
|
10801
|
+
head.append(badges, aid, openBtn);
|
|
10802
|
+
th.append(head);
|
|
10803
|
+
hr.append(th);
|
|
10804
|
+
}
|
|
10805
|
+
thead.append(hr); table.append(thead);
|
|
10806
|
+
const tbody = el('tbody');
|
|
10807
|
+
|
|
10808
|
+
// Images first, at one shared zoom, so like is compared with like.
|
|
10809
|
+
const imgRow = el('tr'); imgRow.append(el('td', null, 'art'));
|
|
10810
|
+
for (const item of items) {
|
|
10811
|
+
const td = el('td');
|
|
10812
|
+
const box = el('div', 'cimg');
|
|
10813
|
+
const shown = item.outputs.find((o) => o.url);
|
|
10814
|
+
if (shown) {
|
|
10815
|
+
const img = el('img'); img.src = shown.url; img.alt = item.assetId;
|
|
10816
|
+
if (zoom >= 1) { img.width = Math.round(item.width * zoom); img.height = Math.round(item.height * zoom); }
|
|
10817
|
+
else { img.style.maxWidth = '100%'; img.style.height = 'auto'; }
|
|
10818
|
+
box.append(img);
|
|
10819
|
+
} else box.append(el('span', 'none', 'no art on disk'));
|
|
10820
|
+
td.append(box);
|
|
10821
|
+
if (item.outputs.filter((o) => o.url).length > 1) td.append(el('div', 'state-dim', item.outputs.length + ' outputs; first shown'));
|
|
10822
|
+
imgRow.append(td);
|
|
10823
|
+
}
|
|
10824
|
+
tbody.append(imgRow);
|
|
10825
|
+
|
|
10826
|
+
const rows = [
|
|
10827
|
+
['state', (i) => i.state + (i.reason ? ' \u2014 ' + i.reason : ''), (i) => i.state],
|
|
10828
|
+
['provider', (i) => i.provider],
|
|
10829
|
+
['generator', (i) => i.generator + (i.tileFeature ? ' \xB7 ' + i.tileFeature : '')],
|
|
10830
|
+
['candidates', (i) => i.candidates ?? '\u2014'],
|
|
10831
|
+
['size', (i) => i.width + ' \xD7 ' + i.height],
|
|
10832
|
+
['cost', (i) => i.status ? fmtCost(i.costUnit, i.cost) : (i.estimatedCost !== null ? 'est. ' + fmtCost(i.costUnit, i.estimatedCost) : '\u2014')],
|
|
10833
|
+
['prompt', (i) => i.prompt, null, 'prompt'],
|
|
10834
|
+
['submitted', (i) => fmtWhen(i.submittedAt) || '\u2014'],
|
|
10835
|
+
['downloaded', (i) => fmtWhen(i.downloadedAt) || '\u2014'],
|
|
10836
|
+
['quality', (i) => i.quality ? i.quality.state + (i.quality.review && i.quality.review.status === 'approved' ? ' by ' + i.quality.review.reviewer : '') : '\u2014'],
|
|
10837
|
+
['spec hash', (i) => i.recordedSpecHash ? i.recordedSpecHash.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
|
|
10838
|
+
['sha256', (i) => i.outputs[0] && i.outputs[0].sha256 ? i.outputs[0].sha256.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
|
|
10839
|
+
['tags', (i) => (i.tags || []).filter((t) => !/^(pixelkiln|asset|style):/.test(t)).join(', ') || '\u2014'],
|
|
10840
|
+
];
|
|
10841
|
+
for (const [label, show, keyOf, cls] of rows) {
|
|
10842
|
+
const tr = el('tr'); tr.append(el('td', null, label));
|
|
10843
|
+
const keys = items.map((i) => String((keyOf || show)(i)));
|
|
10844
|
+
const differs = new Set(keys).size > 1;
|
|
10845
|
+
for (const item of items) {
|
|
10846
|
+
const td = el('td', (differs ? 'diff' : '') + (cls === 'mono' ? ' mono' : ''));
|
|
10847
|
+
if (cls === 'prompt') td.append(el('div', 'prompt', show(item))); else td.textContent = String(show(item));
|
|
10848
|
+
tr.append(td);
|
|
10849
|
+
}
|
|
10850
|
+
tbody.append(tr);
|
|
10851
|
+
}
|
|
10852
|
+
table.append(tbody);
|
|
10853
|
+
body.append(table);
|
|
10854
|
+
panel.append(bar, body);
|
|
10855
|
+
host.append(scrim, panel);
|
|
10856
|
+
close.focus({ preventScroll: true });
|
|
10857
|
+
}
|
|
10858
|
+
|
|
10859
|
+
// ---- editing (only when the server minted a session) ---------------------
|
|
10860
|
+
|
|
10861
|
+
// Styles a parent edit reaches: every descendant (via extends) that does not
|
|
10862
|
+
// declare the field itself. Child-wins means an omitted field is inherited,
|
|
10863
|
+
// so the count follows the manifest's own rule rather than guessing.
|
|
10864
|
+
function inheritorsOf(style, field) {
|
|
10865
|
+
const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
|
|
10866
|
+
const out = [];
|
|
10867
|
+
for (const other of byId.values()) {
|
|
10868
|
+
if (other.id === style.id) continue;
|
|
10869
|
+
let cur = other, blocked = false, hops = 0;
|
|
10870
|
+
while (cur && cur.id !== style.id && hops++ < 32) {
|
|
10871
|
+
if (cur.ownFields.includes(field)) { blocked = true; break; }
|
|
10872
|
+
cur = cur.extends ? byId.get(cur.extends) : null;
|
|
10873
|
+
}
|
|
10874
|
+
if (cur && cur.id === style.id && !blocked) out.push(other.id);
|
|
10875
|
+
}
|
|
10876
|
+
return out;
|
|
10877
|
+
}
|
|
10878
|
+
function blastRadius(style, fields) {
|
|
10879
|
+
const styleIds = new Set([style.id]);
|
|
10880
|
+
for (const field of fields) for (const id of inheritorsOf(style, field)) styleIds.add(id);
|
|
10881
|
+
const affected = snap.items.filter((i) => i.project === style.project && i.declared && styleIds.has(i.styleId));
|
|
10882
|
+
const units = new Map();
|
|
10883
|
+
for (const i of affected) if (i.estimatedCost !== null) units.set(i.costUnit, (units.get(i.costUnit) || 0) + i.estimatedCost);
|
|
10884
|
+
return {
|
|
10885
|
+
styles: [...styleIds],
|
|
10886
|
+
assets: affected.length,
|
|
10887
|
+
cost: [...units].map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + '),
|
|
10888
|
+
};
|
|
10889
|
+
}
|
|
10890
|
+
const parsePalette = (value) => value.split(/[s,]+/).map((c) => c.trim()).filter(Boolean)
|
|
10891
|
+
.map((c) => '#' + c.replace(/^#/, '').toLowerCase());
|
|
10892
|
+
const paletteValid = (colors) => colors.every((c) => /^#[0-9a-f]{6}$/.test(c));
|
|
10893
|
+
|
|
10894
|
+
function styleForm(style) {
|
|
10895
|
+
const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
|
|
10896
|
+
const own = (field) => style.ownFields.includes(field);
|
|
10897
|
+
const provenance = (field) => own(field) ? 'set on this style'
|
|
10898
|
+
: style.extends ? 'inherited from ' + style.extends + ' \u2014 saving sets a value on this style itself; ' + style.extends + ' is unchanged'
|
|
10899
|
+
: 'default \u2014 saving sets a value on this style itself';
|
|
10900
|
+
const form = el('form', 'edit style-form');
|
|
10901
|
+
form.append(el('h3', null, 'Edit style ' + style.id));
|
|
10902
|
+
const prefix = el('input'); prefix.type = 'text'; prefix.value = style.promptPrefix; prefix.placeholder = 'none';
|
|
10903
|
+
const suffix = el('input'); suffix.type = 'text'; suffix.value = style.promptSuffix; suffix.placeholder = 'none';
|
|
10904
|
+
const palette = el('input'); palette.type = 'text'; palette.value = style.palette.join(', '); palette.placeholder = '#rrggbb, #rrggbb \u2014 leave empty for no forced palette';
|
|
10905
|
+
const swatches = el('div', 'pal');
|
|
10906
|
+
const drawSwatches = () => {
|
|
10907
|
+
swatches.textContent = '';
|
|
10908
|
+
for (const c of parsePalette(palette.value)) { const i = el('i'); i.style.background = c; i.title = c; swatches.append(i); }
|
|
10909
|
+
};
|
|
10910
|
+
drawSwatches();
|
|
10911
|
+
form.append(
|
|
10912
|
+
field('prompt prefix', prefix, 'Prepended to every asset prompt in this style. ' + provenance('promptPrefix')),
|
|
10913
|
+
field('prompt suffix', suffix, 'Appended to every asset prompt in this style. ' + provenance('promptSuffix')),
|
|
10914
|
+
field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
|
|
10915
|
+
swatches,
|
|
10916
|
+
);
|
|
10917
|
+
const blast = el('div', 'blast');
|
|
10918
|
+
const changed = () => {
|
|
10919
|
+
const out = [];
|
|
10920
|
+
if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
|
|
10921
|
+
if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
|
|
10922
|
+
if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
|
|
10923
|
+
return out;
|
|
10924
|
+
};
|
|
10925
|
+
const updateBlast = () => {
|
|
10926
|
+
const fields = changed();
|
|
10927
|
+
drawSwatches();
|
|
10928
|
+
if (!fields.length) { blast.className = 'blast'; blast.textContent = 'No changes yet. A style change alters the request for every asset that uses it.'; return; }
|
|
10929
|
+
const r = blastRadius(style, fields);
|
|
10930
|
+
blast.className = 'blast hot';
|
|
10931
|
+
blast.textContent = '';
|
|
10932
|
+
const others = r.styles.filter((id) => id !== style.id);
|
|
10933
|
+
blast.append(el('b', null, 'Affects ' + r.assets + (r.assets === 1 ? ' asset' : ' assets')));
|
|
10934
|
+
blast.append(document.createTextNode(others.length
|
|
10935
|
+
? ' in ' + style.id + ' and ' + others.length + (others.length === 1 ? ' style that inherits it (' : ' styles that inherit it (') + others.join(', ') + ').'
|
|
10936
|
+
: ' in ' + style.id + '.'));
|
|
10937
|
+
blast.append(document.createTextNode(' Generated ones become stale; regenerating all of them is about ' + (r.cost || 'nothing') + '. Nothing is spent until you generate.'));
|
|
10938
|
+
};
|
|
10939
|
+
for (const input of [prefix, suffix, palette]) input.addEventListener('input', updateBlast);
|
|
10940
|
+
updateBlast();
|
|
10941
|
+
form.append(blast);
|
|
10942
|
+
const actions = el('div', 'actions');
|
|
10943
|
+
const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
|
|
10944
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
|
|
10945
|
+
const msg = el('span', 'msg');
|
|
10946
|
+
actions.append(save, cancel, msg);
|
|
10947
|
+
form.append(actions);
|
|
10948
|
+
form.onsubmit = async (e) => {
|
|
10949
|
+
e.preventDefault();
|
|
10950
|
+
const fields = changed();
|
|
10951
|
+
if (!fields.length) { ui.editing = null; render(); return; }
|
|
10952
|
+
const colors = parsePalette(palette.value);
|
|
10953
|
+
if (fields.includes('palette') && !paletteValid(colors)) { msg.className = 'msg bad'; msg.textContent = 'palette must be six-digit hex colours'; return; }
|
|
10954
|
+
const patch = {};
|
|
10955
|
+
if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
|
|
10956
|
+
if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
|
|
10957
|
+
if (fields.includes('palette')) patch.palette = colors;
|
|
10958
|
+
save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
|
|
10959
|
+
const r = blastRadius(style, fields);
|
|
10960
|
+
try {
|
|
10961
|
+
const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch };
|
|
10962
|
+
if (style.project) body.project = style.project;
|
|
10963
|
+
snap = await postEdit(body);
|
|
10964
|
+
ui.editing = null;
|
|
10965
|
+
ui.notice = { id: 'style:' + (style.project || '') + ':' + style.id,
|
|
10966
|
+
text: 'Saved. The request for ' + r.assets + (r.assets === 1 ? ' asset' : ' assets') + ' changed' + (r.cost ? ' \u2014 about ' + r.cost + ' to regenerate.' : '.') + ' Nothing is spent until you generate.' };
|
|
10967
|
+
render();
|
|
10968
|
+
} catch (err) {
|
|
10969
|
+
save.disabled = false;
|
|
10970
|
+
msg.className = 'msg bad';
|
|
10971
|
+
msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
|
|
10972
|
+
}
|
|
10973
|
+
};
|
|
10974
|
+
setTimeout(() => prefix.focus(), 0);
|
|
10975
|
+
return form;
|
|
10976
|
+
}
|
|
10977
|
+
|
|
10978
|
+
function addAssetForm(style) {
|
|
10979
|
+
const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
|
|
10980
|
+
const siblings = snap.styles.filter((x) => x.project === style.project);
|
|
10981
|
+
const form = el('form', 'edit');
|
|
10982
|
+
form.append(el('h3', null, 'New asset in ' + style.id));
|
|
10983
|
+
const id = el('input'); id.type = 'text'; id.placeholder = 'asset-id'; id.required = true; id.autocomplete = 'off';
|
|
10984
|
+
id.pattern = '[^/\\\\]+';
|
|
10985
|
+
const prompt = el('textarea'); prompt.placeholder = 'What to generate. The style adds its prefix and suffix.'; prompt.required = true;
|
|
10986
|
+
const width = numberInput(null, 'style default'), height = numberInput(null, 'style default');
|
|
10987
|
+
const category = el('input'); category.type = 'text'; category.placeholder = 'optional subfolder';
|
|
10988
|
+
const only = el('input'); only.type = 'checkbox'; only.checked = siblings.length > 1;
|
|
10989
|
+
const onlyField = el('label', 'field check'); onlyField.append(only, el('span', null, 'only in ' + style.id));
|
|
10990
|
+
const row1 = el('div', 'row'); row1.append(field('id', id), field('category', category));
|
|
10991
|
+
const row2 = el('div', 'row'); row2.append(field('width', width), field('height', height));
|
|
10992
|
+
form.append(row1, field('prompt', prompt), row2);
|
|
10993
|
+
if (siblings.length > 1) form.append(onlyField);
|
|
10994
|
+
const actions = el('div', 'actions');
|
|
10995
|
+
const save = el('button', 'primary', 'Add asset'); save.type = 'submit';
|
|
10996
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
|
|
10997
|
+
const msg = el('span', 'msg');
|
|
10998
|
+
actions.append(save, cancel, msg);
|
|
10999
|
+
form.append(actions);
|
|
11000
|
+
form.onsubmit = async (e) => {
|
|
11001
|
+
e.preventDefault();
|
|
11002
|
+
save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
|
|
11003
|
+
const asset = { prompt: prompt.value };
|
|
11004
|
+
if (numberOrNull(width) !== null) asset.width = numberOrNull(width);
|
|
11005
|
+
if (numberOrNull(height) !== null) asset.height = numberOrNull(height);
|
|
11006
|
+
if (category.value.trim()) asset.category = category.value.trim();
|
|
11007
|
+
if (siblings.length > 1 && only.checked) asset.styles = [style.id];
|
|
11008
|
+
try {
|
|
11009
|
+
const body = { action: 'add-asset', assetId: id.value.trim(), expectedSha256: pr.manifestSha256, asset };
|
|
11010
|
+
if (style.project) body.project = style.project;
|
|
11011
|
+
snap = await postEdit(body);
|
|
11012
|
+
const newId = (style.project ? style.project + ':' : '') + style.id + '/' + id.value.trim();
|
|
11013
|
+
ui.editing = null;
|
|
11014
|
+
ui.notice = { id: newId, text: 'Added to the manifest. Nothing is generated until you run pixelkiln gen.' };
|
|
11015
|
+
render();
|
|
11016
|
+
if (snap.items.some((i) => i.id === newId)) openItem(newId);
|
|
11017
|
+
} catch (err) {
|
|
11018
|
+
save.disabled = false;
|
|
11019
|
+
msg.className = 'msg bad';
|
|
11020
|
+
msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
|
|
11021
|
+
}
|
|
11022
|
+
};
|
|
11023
|
+
setTimeout(() => id.focus(), 0);
|
|
11024
|
+
return form;
|
|
11025
|
+
}
|
|
11026
|
+
|
|
11027
|
+
function editForm(item) {
|
|
11028
|
+
const pr = projectOf(item);
|
|
11029
|
+
const a = item.asset;
|
|
11030
|
+
const styled = !!(a.promptByStyle && Object.hasOwn(a.promptByStyle, item.styleId));
|
|
11031
|
+
const form = el('form', 'edit');
|
|
11032
|
+
form.append(el('h3', null, 'Edit intent'));
|
|
11033
|
+
const scope = el('select');
|
|
11034
|
+
scope.append(new Option('Prompt for every style', 'all'), new Option('Prompt only for ' + item.styleId, 'style'));
|
|
11035
|
+
scope.value = styled ? 'style' : 'all';
|
|
11036
|
+
const prompt = el('textarea');
|
|
11037
|
+
prompt.value = styled ? a.promptByStyle[item.styleId] : a.prompt;
|
|
11038
|
+
scope.onchange = () => {
|
|
11039
|
+
prompt.value = scope.value === 'style' ? (a.promptByStyle?.[item.styleId] ?? a.prompt) : a.prompt;
|
|
11040
|
+
};
|
|
11041
|
+
const width = numberInput(a.width, 'style default'), height = numberInput(a.height, 'style default'), size = numberInput(a.size, 'style default');
|
|
11042
|
+
const category = el('input'); category.type = 'text'; category.value = a.category || '';
|
|
11043
|
+
const tags = el('input'); tags.type = 'text'; tags.value = (a.tags || []).join(', '); tags.placeholder = 'comma-separated';
|
|
11044
|
+
form.append(field('prompt', prompt, 'The style adds its prefix and suffix; the sent prompt is shown below.'), field('applies to', scope));
|
|
11045
|
+
const row = el('div', 'row'); row.append(field('width', width), field('height', height), field('size', size));
|
|
11046
|
+
form.append(row);
|
|
11047
|
+
const row2 = el('div', 'row');
|
|
11048
|
+
row2.append(field('category', category, 'Output subfolder. The existing file stays put; restore or gen writes the new path.'), field('tags', tags));
|
|
11049
|
+
form.append(row2);
|
|
11050
|
+
const actions = el('div', 'actions');
|
|
11051
|
+
const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
|
|
11052
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; renderDrawer(); };
|
|
11053
|
+
const msg = el('span', 'msg');
|
|
11054
|
+
actions.append(save, cancel, msg);
|
|
11055
|
+
form.append(actions);
|
|
11056
|
+
form.onsubmit = async (e) => {
|
|
11057
|
+
e.preventDefault();
|
|
11058
|
+
const patch = {};
|
|
11059
|
+
if (scope.value === 'style') {
|
|
11060
|
+
if (!styled || prompt.value !== a.promptByStyle[item.styleId]) patch.promptForStyle = { styleId: item.styleId, prompt: prompt.value };
|
|
11061
|
+
} else {
|
|
11062
|
+
if (prompt.value !== a.prompt) patch.prompt = prompt.value;
|
|
11063
|
+
if (styled) patch.promptForStyle = { styleId: item.styleId, prompt: null };
|
|
11064
|
+
}
|
|
11065
|
+
for (const [key, input] of [['width', width], ['height', height], ['size', size]]) {
|
|
11066
|
+
const next = numberOrNull(input);
|
|
11067
|
+
if (next !== (a[key] ?? null)) patch[key] = next;
|
|
11068
|
+
}
|
|
11069
|
+
const nextCategory = category.value.trim() || null;
|
|
11070
|
+
if (nextCategory !== (a.category || null)) patch.category = nextCategory;
|
|
11071
|
+
const nextTags = splitTags(tags.value);
|
|
11072
|
+
if (nextTags.join('\\u0000') !== (a.tags || []).join('\\u0000')) patch.tags = nextTags;
|
|
11073
|
+
if (!Object.keys(patch).length) { ui.editing = null; renderDrawer(); return; }
|
|
11074
|
+
save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
|
|
11075
|
+
try {
|
|
11076
|
+
const body = { action: 'patch-asset', assetId: item.assetId, expectedSha256: pr.manifestSha256, patch };
|
|
11077
|
+
if (item.project) body.project = item.project;
|
|
11078
|
+
snap = await postEdit(body);
|
|
11079
|
+
const after = snap.items.find((i) => i.id === item.id);
|
|
11080
|
+
ui.editing = null;
|
|
11081
|
+
ui.notice = {
|
|
11082
|
+
id: item.id,
|
|
11083
|
+
text: after
|
|
11084
|
+
? 'Saved. plan now reports ' + after.state +
|
|
11085
|
+
(after.estimatedCost !== null && (after.state === 'stale' || after.state === 'missing')
|
|
11086
|
+
? ' \u2014 ' + fmtCost(after.costUnit, after.estimatedCost) + ' to generate. Nothing is spent until you run pixelkiln gen.'
|
|
11087
|
+
: '.')
|
|
11088
|
+
: 'Saved.',
|
|
11089
|
+
};
|
|
11090
|
+
render();
|
|
11091
|
+
} catch (err) {
|
|
11092
|
+
save.disabled = false;
|
|
11093
|
+
msg.className = 'msg bad';
|
|
11094
|
+
msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
|
|
11095
|
+
}
|
|
11096
|
+
};
|
|
11097
|
+
setTimeout(() => prompt.focus(), 0);
|
|
11098
|
+
return form;
|
|
11099
|
+
}
|
|
11100
|
+
|
|
11101
|
+
// ---- detail drawer -------------------------------------------------------
|
|
11102
|
+
|
|
11103
|
+
function row(dl, label, value, opts = {}) {
|
|
11104
|
+
if (value === null || value === undefined || value === '') return;
|
|
11105
|
+
dl.append(el('dt', null, label));
|
|
11106
|
+
const dd = el('dd', opts.mono ? 'mono' : null);
|
|
11107
|
+
if (value instanceof Node) dd.append(value); else dd.textContent = String(value);
|
|
11108
|
+
if (opts.copy) {
|
|
11109
|
+
const b = el('button', 'copy', 'copy');
|
|
11110
|
+
b.type = 'button';
|
|
11111
|
+
b.onclick = async () => {
|
|
11112
|
+
try { await navigator.clipboard.writeText(String(opts.copy)); b.textContent = 'copied'; }
|
|
11113
|
+
catch { b.textContent = 'select to copy'; }
|
|
11114
|
+
setTimeout(() => (b.textContent = 'copy'), 1200);
|
|
11115
|
+
};
|
|
11116
|
+
dd.append(b);
|
|
11117
|
+
}
|
|
11118
|
+
dl.append(dd);
|
|
11119
|
+
}
|
|
11120
|
+
function section(title) {
|
|
11121
|
+
const s = el('section', 'meta');
|
|
11122
|
+
s.append(el('h3', null, title));
|
|
11123
|
+
const dl = el('dl');
|
|
11124
|
+
s.append(dl);
|
|
11125
|
+
return { s, dl };
|
|
11126
|
+
}
|
|
11127
|
+
function stateNode(state, reason) {
|
|
11128
|
+
const box = el('div', 'status');
|
|
11129
|
+
const st = el('span', 'st state-' + STATE_TONE[state]);
|
|
11130
|
+
st.append(el('i', 'dot ' + STATE_TONE[state]), document.createTextNode(state));
|
|
11131
|
+
box.append(st, el('span', 'why', reason));
|
|
11132
|
+
return box;
|
|
11133
|
+
}
|
|
11134
|
+
function sibling(item, key) {
|
|
11135
|
+
return snap.items.find((i) => i.key === key && i.project === item.project);
|
|
11136
|
+
}
|
|
11137
|
+
function keyLink(item, key, label) {
|
|
11138
|
+
const b = el('button', 'linkish', label || key);
|
|
11139
|
+
b.type = 'button';
|
|
11140
|
+
const target = sibling(item, key);
|
|
11141
|
+
if (!target) { b.disabled = true; b.title = 'not in this snapshot'; }
|
|
11142
|
+
b.onclick = () => openItem(target.id);
|
|
11143
|
+
return b;
|
|
11144
|
+
}
|
|
11145
|
+
function jsonDetails(title, value) {
|
|
11146
|
+
const d = el('details');
|
|
11147
|
+
d.append(el('summary', null, title), el('pre', null, JSON.stringify(value, null, 2)));
|
|
11148
|
+
return d;
|
|
11149
|
+
}
|
|
11150
|
+
|
|
11151
|
+
function stopPlayback() {
|
|
11152
|
+
if (ui.playing) { clearInterval(ui.playing); ui.playing = null; }
|
|
11153
|
+
}
|
|
11154
|
+
|
|
11155
|
+
function renderPreview(item, host) {
|
|
11156
|
+
host.textContent = '';
|
|
11157
|
+
stopPlayback();
|
|
11158
|
+
const shown = item.outputs.filter((o) => o.url);
|
|
11159
|
+
const preview = el('div', 'preview');
|
|
11160
|
+
if (!shown.length) {
|
|
11161
|
+
preview.append(el('div', 'none', item.outputs.length ? 'The recorded file is not on disk. pixelkiln restore re-downloads it without spending.' : 'No output to show.'));
|
|
11162
|
+
host.append(preview);
|
|
11163
|
+
return;
|
|
11164
|
+
}
|
|
11165
|
+
const member = shown[Math.min(ui.member, shown.length - 1)] || shown[0];
|
|
11166
|
+
const frameSet = isFrameSet(item);
|
|
11167
|
+
const w = item.width, h = item.height;
|
|
11168
|
+
const box = Math.min(window.innerWidth, 540) - 62;
|
|
11169
|
+
const auto = displayScale(w, h, box, Math.min(window.innerHeight * 0.55, 560));
|
|
11170
|
+
const zoom = ui.zoom === 'auto' ? auto : ui.zoom === 'fit' ? Math.min(auto, 1) : Number(ui.zoom);
|
|
11171
|
+
const img = el('img');
|
|
11172
|
+
img.src = member.url; img.alt = member.role || item.assetId;
|
|
11173
|
+
if (zoom >= 1) { img.width = Math.round(w * zoom); img.height = Math.round(h * zoom); }
|
|
11174
|
+
else { img.style.maxWidth = '100%'; img.style.maxHeight = '55vh'; img.style.width = 'auto'; img.style.height = 'auto'; }
|
|
11175
|
+
preview.append(img);
|
|
11176
|
+
|
|
11177
|
+
const zoomBar = el('div', 'zoom');
|
|
11178
|
+
zoomBar.append(el('span', null, 'zoom'));
|
|
11179
|
+
for (const z of ['fit', 1, 2, 4, 8]) {
|
|
11180
|
+
const b = el('button', ui.zoom === String(z) || (ui.zoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
|
|
11181
|
+
b.type = 'button';
|
|
11182
|
+
b.onclick = () => { ui.zoom = String(z); renderPreview(item, host); };
|
|
11183
|
+
zoomBar.append(b);
|
|
11184
|
+
}
|
|
11185
|
+
const label = el('span', 'grow', w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : ' fitted'));
|
|
11186
|
+
zoomBar.append(label);
|
|
11187
|
+
if (frameSet && shown.length > 1) {
|
|
11188
|
+
const fps = item.fps || 12;
|
|
11189
|
+
const play = el('button', null, 'Play ' + fps + ' fps');
|
|
11190
|
+
play.type = 'button';
|
|
11191
|
+
let idx = shown.indexOf(member);
|
|
11192
|
+
play.onclick = () => {
|
|
11193
|
+
if (ui.playing) { stopPlayback(); play.textContent = 'Play ' + fps + ' fps'; return; }
|
|
11194
|
+
play.textContent = 'Pause';
|
|
11195
|
+
ui.playing = setInterval(() => {
|
|
11196
|
+
if (document.hidden) return;
|
|
11197
|
+
idx = (idx + 1) % shown.length;
|
|
11198
|
+
img.src = shown[idx].url;
|
|
11199
|
+
label.textContent = (shown[idx].role || 'frame ' + (idx + 1)) + ' \xB7 ' + w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : '');
|
|
11200
|
+
for (const [i, b] of [...host.querySelectorAll('.members button')].entries()) b.classList.toggle('on', i === idx);
|
|
11201
|
+
}, Math.max(16, Math.round(1000 / fps)));
|
|
11202
|
+
};
|
|
11203
|
+
zoomBar.append(play);
|
|
11204
|
+
}
|
|
11205
|
+
host.append(preview, zoomBar);
|
|
11206
|
+
|
|
11207
|
+
if (shown.length > 1) {
|
|
11208
|
+
const strip = el('div', 'members');
|
|
11209
|
+
shown.forEach((o, i) => {
|
|
11210
|
+
const b = el('button', o === member ? 'on' : null);
|
|
11211
|
+
b.type = 'button';
|
|
11212
|
+
const t = el('img'); t.src = o.url; t.alt = o.role || 'output ' + (i + 1); t.loading = 'lazy';
|
|
11213
|
+
b.append(t, el('span', null, o.role || String(i + 1)));
|
|
11214
|
+
b.onclick = () => { ui.member = i; renderPreview(item, host); };
|
|
11215
|
+
strip.append(b);
|
|
11216
|
+
});
|
|
11217
|
+
host.append(strip);
|
|
11218
|
+
}
|
|
11219
|
+
}
|
|
11220
|
+
|
|
11221
|
+
function renderDrawer() {
|
|
11222
|
+
const host = $('drawer-host');
|
|
11223
|
+
host.textContent = '';
|
|
11224
|
+
stopPlayback();
|
|
11225
|
+
const item = ui.open && snap.items.find((i) => i.id === ui.open);
|
|
11226
|
+
if (!item) { ui.open = null; return; }
|
|
11227
|
+
const items = visibleItems();
|
|
11228
|
+
const pos = items.findIndex((i) => i.id === item.id);
|
|
11229
|
+
|
|
11230
|
+
const scrim = el('div', 'scrim');
|
|
11231
|
+
scrim.onclick = () => closeItem();
|
|
11232
|
+
const drawer = el('aside', 'drawer');
|
|
11233
|
+
drawer.setAttribute('role', 'dialog');
|
|
11234
|
+
drawer.setAttribute('aria-label', item.id);
|
|
11235
|
+
|
|
11236
|
+
const head = el('div', 'dhead');
|
|
11237
|
+
const title = el('div', 'title');
|
|
11238
|
+
if (item.project) title.append(el('span', 'sid project', item.project), ' ');
|
|
11239
|
+
title.append(el('span', 'sid', item.styleId), el('div', 'aid', item.assetId));
|
|
11240
|
+
const nav = el('div', 'nav');
|
|
11241
|
+
const prev = el('button', null, '\u2190'); prev.type = 'button'; prev.title = 'Previous (\u2190)';
|
|
11242
|
+
prev.disabled = pos <= 0; prev.onclick = () => step(-1);
|
|
11243
|
+
const next = el('button', null, '\u2192'); next.type = 'button'; next.title = 'Next (\u2192)';
|
|
11244
|
+
next.disabled = pos < 0 || pos >= items.length - 1; next.onclick = () => step(1);
|
|
11245
|
+
const close = el('button', null, 'Close'); close.type = 'button'; close.onclick = () => closeItem();
|
|
11246
|
+
const cmp = el('button', null, ui.compare.includes(item.id) ? 'Remove from compare' : 'Compare +');
|
|
11247
|
+
cmp.type = 'button'; cmp.onclick = () => toggleCompare(item.id);
|
|
11248
|
+
nav.append(cmp);
|
|
11249
|
+
const canEdit = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256;
|
|
11250
|
+
if (canEdit) {
|
|
11251
|
+
const edit = el('button', null, ui.editing === item.id ? 'Cancel edit' : 'Edit');
|
|
11252
|
+
edit.type = 'button';
|
|
11253
|
+
edit.onclick = () => { ui.editing = ui.editing === item.id ? null : item.id; ui.notice = null; renderDrawer(); };
|
|
11254
|
+
nav.append(edit);
|
|
11255
|
+
}
|
|
11256
|
+
nav.append(prev, next, close);
|
|
11257
|
+
head.append(title, nav);
|
|
11258
|
+
|
|
11259
|
+
const body = el('div', 'dbody');
|
|
11260
|
+
const previewHost = el('div');
|
|
11261
|
+
renderPreview(item, previewHost);
|
|
11262
|
+
body.append(previewHost);
|
|
11263
|
+
if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
|
|
11264
|
+
body.append(stateNode(item.state, item.reason));
|
|
11265
|
+
if (GENERATION) body.append(generateActions(item));
|
|
11266
|
+
if (canEdit && ui.editing === item.id) body.append(editForm(item));
|
|
11267
|
+
// Plan already quotes the error as the reason for a failed entry; only a
|
|
11268
|
+
// stale or superseded failure needs its own line.
|
|
11269
|
+
if (item.error && !item.reason.includes(item.error)) body.append(stateNode('failed', item.error));
|
|
11270
|
+
|
|
11271
|
+
// Generation \u2014 what was sent and what it cost.
|
|
11272
|
+
{
|
|
11273
|
+
const { s, dl } = section('Generation');
|
|
11274
|
+
row(dl, 'provider', item.provider);
|
|
11275
|
+
row(dl, 'generator', item.generator + (item.tileFeature ? ' \xB7 ' + item.tileFeature : ''));
|
|
11276
|
+
row(dl, 'lock status', item.status || 'none \u2014 nothing submitted');
|
|
11277
|
+
const pr = el('div', 'prompt', item.prompt); row(dl, item.status ? 'prompt sent' : 'prompt', pr);
|
|
11278
|
+
if (item.currentPrompt) row(dl, 'prompt now', el('div', 'prompt state-warn', item.currentPrompt));
|
|
11279
|
+
row(dl, 'size', item.width + ' \xD7 ' + item.height + ' px');
|
|
11280
|
+
if (item.status) row(dl, 'cost', fmtCost(item.costUnit, item.cost));
|
|
11281
|
+
if (item.estimatedCost !== null && (item.estimatedCost !== item.cost || !item.status)) {
|
|
11282
|
+
row(dl, 'estimate now', fmtCost(item.costUnit, item.estimatedCost) +
|
|
11283
|
+
(item.candidates && item.candidates > 1 ? ' for ' + item.candidates + ' candidates' : ''));
|
|
11284
|
+
}
|
|
11285
|
+
if (item.candidateIndex !== null) row(dl, 'candidate', '#' + (item.candidateIndex + 1) + ' chosen in review');
|
|
11286
|
+
row(dl, 'submitted', fmtWhen(item.submittedAt));
|
|
11287
|
+
row(dl, 'downloaded', fmtWhen(item.downloadedAt));
|
|
11288
|
+
row(dl, 'job id', item.jobId, { mono: true, copy: item.jobId });
|
|
11289
|
+
row(dl, 'object id', item.objectId, { mono: true, copy: item.objectId });
|
|
11290
|
+
row(dl, 'review object', item.reviewObjectId, { mono: true, copy: item.reviewObjectId });
|
|
11291
|
+
body.append(s);
|
|
11292
|
+
}
|
|
11293
|
+
|
|
11294
|
+
// Outputs \u2014 every file with its hash and whether it is really there.
|
|
11295
|
+
{
|
|
11296
|
+
const s = el('section', 'meta');
|
|
11297
|
+
s.append(el('h3', null, item.outputs.length === 1 ? 'Output' : 'Outputs (' + item.outputs.length + ')'));
|
|
11298
|
+
if (!item.outputs.length) s.append(el('div', 'state-dim', item.source ? 'placed from ' + item.source : 'none recorded'));
|
|
11299
|
+
for (const o of item.outputs) {
|
|
11300
|
+
const box = el('div', 'out');
|
|
11301
|
+
const role = el('div', 'role');
|
|
11302
|
+
role.append(document.createTextNode(o.role || (item.outputs.length > 1 ? 'primary' : 'file')));
|
|
11303
|
+
if (!o.exists) role.append(el('span', 'miss', 'missing on disk'));
|
|
11304
|
+
if (o.url) { const a = el('a', null, 'open'); a.href = o.url.split('?')[0]; a.target = '_blank'; a.rel = 'noopener'; role.append(a); }
|
|
11305
|
+
box.append(role);
|
|
11306
|
+
const dl = el('dl');
|
|
11307
|
+
row(dl, 'path', o.path, { mono: true, copy: o.absolutePath });
|
|
11308
|
+
row(dl, 'sha256', o.sha256 ? o.sha256.slice(0, 16) + '\u2026' : (o.exists ? 'not recorded' : null), { mono: true, copy: o.sha256 || undefined });
|
|
11309
|
+
row(dl, 'bytes', o.exists ? fmtBytes(o.bytes) : null);
|
|
11310
|
+
row(dl, 'type', o.mediaType);
|
|
11311
|
+
box.append(dl);
|
|
11312
|
+
s.append(box);
|
|
11313
|
+
}
|
|
11314
|
+
body.append(s);
|
|
11315
|
+
}
|
|
11316
|
+
|
|
11317
|
+
if (item.revision || item.revisionParentKey) {
|
|
11318
|
+
const { s, dl } = section('Lineage');
|
|
11319
|
+
row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
|
|
11320
|
+
if (item.revisionParentKey) row(dl, 'parent', keyLink(item, item.revisionParentKey));
|
|
11321
|
+
if (item.revision) {
|
|
11322
|
+
row(dl, 'parent sha256', item.revision.sourceSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.sourceSha256 });
|
|
11323
|
+
if (item.revision.maskSha256) row(dl, 'mask sha256', item.revision.maskSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.maskSha256 });
|
|
11324
|
+
if (item.revision.strength !== undefined) row(dl, 'strength', item.revision.strength);
|
|
11325
|
+
}
|
|
11326
|
+
body.append(s);
|
|
11327
|
+
}
|
|
11328
|
+
const children = snap.items.filter((i) => i.revisionParentKey === item.key && i.project === item.project);
|
|
11329
|
+
if (children.length) {
|
|
11330
|
+
const { s, dl } = section('Revisions from this asset');
|
|
11331
|
+
for (const c of children) row(dl, c.revision ? c.revision.mode : 'child', keyLink(item, c.key));
|
|
11332
|
+
body.append(s);
|
|
11333
|
+
}
|
|
11334
|
+
|
|
11335
|
+
if (item.quality) {
|
|
11336
|
+
const q = item.quality;
|
|
11337
|
+
const { s, dl } = section('Quality');
|
|
11338
|
+
const tone = q.state === 'approved' ? 'ok' : q.state === 'blocked' ? 'bad' : 'warn';
|
|
11339
|
+
const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone + ' '), document.createTextNode(' ' + q.state));
|
|
11340
|
+
row(dl, 'state', st);
|
|
11341
|
+
row(dl, 'why', q.reason);
|
|
11342
|
+
if (q.review) {
|
|
11343
|
+
row(dl, 'review', q.review.status === 'approved'
|
|
11344
|
+
? 'approved by ' + q.review.reviewer + ' \xB7 ' + fmtWhen(q.review.approvedAt)
|
|
11345
|
+
: 'pending human 1\xD7 review');
|
|
11346
|
+
if (q.review.status === 'approved' && q.review.note) row(dl, 'note', q.review.note);
|
|
11347
|
+
}
|
|
11348
|
+
if (q.palette) {
|
|
11349
|
+
const pal = el('div', 'pal');
|
|
11350
|
+
for (const c of q.palette) { const i = el('i'); i.style.background = c; i.title = c; pal.append(i); }
|
|
11351
|
+
row(dl, 'palette', pal);
|
|
11352
|
+
row(dl, 'colors', q.palette.length + ' declared' + (q.audit ? ', ' + q.audit.colorCount + ' used' : ''));
|
|
11353
|
+
}
|
|
11354
|
+
if (q.nativeGrid) {
|
|
11355
|
+
row(dl, 'native grid', q.nativeGrid.sourceWidth + '\xD7' + q.nativeGrid.sourceHeight + ' \u2192 ' +
|
|
11356
|
+
q.nativeGrid.nativeWidth + '\xD7' + q.nativeGrid.nativeHeight + ' (step ' + q.nativeGrid.stepX + '\xD7' + q.nativeGrid.stepY +
|
|
11357
|
+
', ' + q.nativeGrid.confidence + ' confidence)');
|
|
11358
|
+
}
|
|
11359
|
+
if (q.audit) row(dl, 'audit', (q.audit.safe ? 'passed' : 'failed') + ' \xB7 ' + Math.round(q.audit.transparency * 100) + '% transparent' +
|
|
11360
|
+
(q.audit.reasons.length ? ' \xB7 ' + q.audit.reasons.join('; ') : ''));
|
|
11361
|
+
if (q.frameSet) row(dl, 'frame set', q.frameSet.count + ' frames at ' + q.frameSet.fps + ' fps');
|
|
11362
|
+
row(dl, 'output', q.output, { mono: true });
|
|
11363
|
+
row(dl, 'record', q.record + (q.recordExists ? '' : ' (not written yet)'), { mono: true });
|
|
11364
|
+
if (q.check && !q.check.safe) row(dl, 'check', el('div', 'state-warn', q.check.reasons.join('\\n')));
|
|
11365
|
+
body.append(s);
|
|
11366
|
+
if (q.outputs.some((o) => o.url)) {
|
|
11367
|
+
const strip = el('div', 'members');
|
|
11368
|
+
for (const o of q.outputs.filter((o) => o.url)) {
|
|
11369
|
+
const b = el('button'); b.type = 'button';
|
|
11370
|
+
const t = el('img'); t.src = o.url; t.alt = 'refined ' + (o.role || item.assetId);
|
|
11371
|
+
b.append(t, el('span', null, 'refined')); b.onclick = () => window.open(o.url.split('?')[0], '_blank');
|
|
11372
|
+
strip.append(b);
|
|
11373
|
+
}
|
|
11374
|
+
s.append(strip);
|
|
11375
|
+
}
|
|
11376
|
+
}
|
|
11377
|
+
|
|
11378
|
+
{
|
|
11379
|
+
const { s, dl } = section('Identity');
|
|
11380
|
+
if (item.project) row(dl, 'project', item.project);
|
|
11381
|
+
row(dl, 'lock key', item.key, { mono: true, copy: item.key });
|
|
11382
|
+
if (item.recordedSpecHash) {
|
|
11383
|
+
row(dl, 'spec hash', item.recordedSpecHash.slice(0, 16) + '\u2026', { mono: true, copy: item.recordedSpecHash });
|
|
11384
|
+
}
|
|
11385
|
+
if (item.currentSpecHash && item.currentSpecHash !== item.recordedSpecHash) {
|
|
11386
|
+
row(dl, item.recordedSpecHash ? 'spec hash now' : 'spec hash',
|
|
11387
|
+
el('span', item.recordedSpecHash ? 'state-warn' : null, item.currentSpecHash.slice(0, 16) + '\u2026'), { mono: true, copy: item.currentSpecHash });
|
|
11388
|
+
}
|
|
11389
|
+
row(dl, 'declared', item.declared ? 'yes' : 'no \u2014 the manifest no longer has this asset in this style');
|
|
11390
|
+
row(dl, 'category', item.category);
|
|
11391
|
+
row(dl, 'tags', item.tags && item.tags.length ? item.tags.join(', ') : null);
|
|
11392
|
+
row(dl, 'source art', item.source, { mono: true });
|
|
11393
|
+
body.append(s);
|
|
11394
|
+
}
|
|
11395
|
+
|
|
11396
|
+
if (item.asset) {
|
|
11397
|
+
// Show the asset as the author would write it: schema defaults that are
|
|
11398
|
+
// empty add nothing but noise to a record.
|
|
11399
|
+
const declared = Object.fromEntries(Object.entries(item.asset).filter(([, v]) =>
|
|
11400
|
+
!(Array.isArray(v) && !v.length) && !(v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length)));
|
|
11401
|
+
body.append(jsonDetails('Manifest asset', declared));
|
|
11402
|
+
}
|
|
11403
|
+
if (item.providerMetadata && Object.keys(item.providerMetadata).length) {
|
|
11404
|
+
body.append(jsonDetails('Provider metadata', item.providerMetadata));
|
|
11405
|
+
}
|
|
11406
|
+
|
|
11407
|
+
drawer.append(head, body);
|
|
11408
|
+
host.append(scrim, drawer);
|
|
11409
|
+
close.focus({ preventScroll: true });
|
|
11410
|
+
}
|
|
11411
|
+
|
|
11412
|
+
// View state lives in the URL \u2014 filters and sort in the query, the open
|
|
11413
|
+
// record in the hash \u2014 so a link to "every failed generation" or to one
|
|
11414
|
+
// specific record can be pasted into a review thread and land there.
|
|
11415
|
+
function readUrlState() {
|
|
11416
|
+
const params = new URLSearchParams(location.search);
|
|
11417
|
+
const list = (name) => (params.get(name) || '').split(',').map((v) => v.trim()).filter(Boolean);
|
|
11418
|
+
ui.q = params.get('q') || '';
|
|
11419
|
+
ui.states = new Set(list('state'));
|
|
11420
|
+
ui.providers = new Set(list('provider'));
|
|
11421
|
+
ui.generators = new Set(list('generator'));
|
|
11422
|
+
ui.projects = new Set(list('project'));
|
|
11423
|
+
ui.compare = list('compare').slice(0, 4);
|
|
11424
|
+
if (['key', 'newest', 'oldest', 'cost', 'size'].includes(params.get('sort'))) ui.sort = params.get('sort');
|
|
11425
|
+
if (['style', 'none'].includes(params.get('group'))) ui.group = params.get('group');
|
|
11426
|
+
}
|
|
11427
|
+
function writeUrlState() {
|
|
11428
|
+
const params = new URLSearchParams();
|
|
11429
|
+
if (ui.q) params.set('q', ui.q);
|
|
11430
|
+
if (ui.states.size) params.set('state', [...ui.states].join(','));
|
|
11431
|
+
if (ui.providers.size) params.set('provider', [...ui.providers].join(','));
|
|
11432
|
+
if (ui.generators.size) params.set('generator', [...ui.generators].join(','));
|
|
11433
|
+
if (ui.projects.size) params.set('project', [...ui.projects].join(','));
|
|
11434
|
+
if (ui.compare.length) params.set('compare', ui.compare.join(','));
|
|
11435
|
+
if (ui.sort !== 'key') params.set('sort', ui.sort);
|
|
11436
|
+
if (ui.group !== 'style') params.set('group', ui.group);
|
|
11437
|
+
const query = params.toString();
|
|
11438
|
+
const next = location.pathname + (query ? '?' + query : '') + (ui.open ? '#' + encodeURIComponent(ui.open) : '');
|
|
11439
|
+
if (next !== location.pathname + location.search + location.hash) history.replaceState(null, '', next);
|
|
11440
|
+
}
|
|
11441
|
+
function syncHash() { writeUrlState(); }
|
|
11442
|
+
function keyFromHash() {
|
|
11443
|
+
try { return location.hash.length > 1 ? decodeURIComponent(location.hash.slice(1)) : null; }
|
|
11444
|
+
catch { return null; }
|
|
11445
|
+
}
|
|
11446
|
+
// Opening a record must not rebuild the grid: at a few thousand cards that
|
|
11447
|
+
// is a visible pause, and nothing in the grid changes except one highlight.
|
|
11448
|
+
function markActive(key) {
|
|
11449
|
+
document.querySelector('.card.active')?.classList.remove('active');
|
|
11450
|
+
const card = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
|
|
11451
|
+
if (card) card.classList.add('active');
|
|
11452
|
+
return card;
|
|
11453
|
+
}
|
|
11454
|
+
function openItem(key) {
|
|
11455
|
+
if (ui.open !== key) { ui.editing = null; if (ui.notice && ui.notice.id !== key) ui.notice = null; }
|
|
11456
|
+
ui.open = key; ui.member = 0; ui.zoom = 'auto';
|
|
11457
|
+
syncHash();
|
|
11458
|
+
const card = markActive(key);
|
|
11459
|
+
renderDrawer();
|
|
11460
|
+
if (card) card.scrollIntoView({ block: 'nearest' });
|
|
11461
|
+
}
|
|
11462
|
+
function closeItem() {
|
|
11463
|
+
const key = ui.open;
|
|
11464
|
+
ui.open = null;
|
|
11465
|
+
ui.editing = null;
|
|
11466
|
+
ui.notice = null;
|
|
11467
|
+
syncHash();
|
|
11468
|
+
const card = markActive(null);
|
|
11469
|
+
renderDrawer();
|
|
11470
|
+
const focus = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
|
|
11471
|
+
if (focus) focus.focus({ preventScroll: true });
|
|
11472
|
+
}
|
|
11473
|
+
window.addEventListener('hashchange', () => {
|
|
11474
|
+
const key = keyFromHash();
|
|
11475
|
+
if (key && key !== ui.open && snap.items.some((i) => i.id === key)) openItem(key);
|
|
11476
|
+
else if (!key && ui.open) closeItem();
|
|
11477
|
+
});
|
|
11478
|
+
function step(delta) {
|
|
11479
|
+
const items = visibleItems();
|
|
11480
|
+
const pos = items.findIndex((i) => i.id === ui.open);
|
|
11481
|
+
const next = items[pos + delta];
|
|
11482
|
+
if (next) openItem(next.id);
|
|
11483
|
+
}
|
|
11484
|
+
|
|
11485
|
+
function render() {
|
|
11486
|
+
writeUrlState();
|
|
11487
|
+
renderHeader();
|
|
11488
|
+
renderMain(visibleItems());
|
|
11489
|
+
renderDrawer();
|
|
11490
|
+
renderTray();
|
|
11491
|
+
}
|
|
11492
|
+
|
|
11493
|
+
// ---- refresh --------------------------------------------------------------
|
|
11494
|
+
|
|
11495
|
+
let refreshing = false;
|
|
11496
|
+
async function refresh() {
|
|
11497
|
+
if (refreshing) return;
|
|
11498
|
+
refreshing = true;
|
|
11499
|
+
const b = $('refresh');
|
|
11500
|
+
b.disabled = true;
|
|
11501
|
+
try {
|
|
11502
|
+
const res = await fetch('/api/gallery.json', { cache: 'no-store' });
|
|
11503
|
+
if (!res.ok) throw new Error(await res.text());
|
|
11504
|
+
snap = await res.json();
|
|
11505
|
+
$('note').textContent = '';
|
|
11506
|
+
render();
|
|
11507
|
+
} catch (err) {
|
|
11508
|
+
$('note').textContent = ' Refresh failed: ' + err.message + ' \u2014 is pixelkiln gallery still running?';
|
|
11509
|
+
} finally {
|
|
11510
|
+
refreshing = false;
|
|
11511
|
+
b.disabled = false;
|
|
11512
|
+
}
|
|
11513
|
+
}
|
|
11514
|
+
$('refresh').onclick = refresh;
|
|
11515
|
+
let autoTimer = null;
|
|
11516
|
+
$('auto').onchange = (e) => {
|
|
11517
|
+
if (autoTimer) clearInterval(autoTimer);
|
|
11518
|
+
autoTimer = e.target.checked ? setInterval(() => { if (!document.hidden) refresh(); }, 5000) : null;
|
|
11519
|
+
};
|
|
11520
|
+
|
|
11521
|
+
// ---- input ----------------------------------------------------------------
|
|
11522
|
+
|
|
11523
|
+
let searchTimer = null;
|
|
11524
|
+
$('q').addEventListener('input', (e) => {
|
|
11525
|
+
ui.q = e.target.value;
|
|
11526
|
+
clearTimeout(searchTimer);
|
|
11527
|
+
searchTimer = setTimeout(render, 90);
|
|
11528
|
+
});
|
|
11529
|
+
$('sort').onchange = (e) => { ui.sort = e.target.value; render(); };
|
|
11530
|
+
$('group').onchange = (e) => { ui.group = e.target.value; render(); };
|
|
11531
|
+
document.addEventListener('keydown', (e) => {
|
|
11532
|
+
const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || '');
|
|
11533
|
+
if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
|
|
11534
|
+
if (e.key === 'Escape') {
|
|
11535
|
+
if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
|
|
11536
|
+
if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
|
|
11537
|
+
if (ui.open) { e.preventDefault(); closeItem(); }
|
|
11538
|
+
return;
|
|
11539
|
+
}
|
|
11540
|
+
if (ui.open && !typing) {
|
|
11541
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
|
|
11542
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
|
|
11543
|
+
}
|
|
11544
|
+
});
|
|
11545
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) stopPlayback(); });
|
|
11546
|
+
|
|
11547
|
+
readUrlState();
|
|
11548
|
+
$('q').value = ui.q;
|
|
11549
|
+
$('sort').value = ui.sort;
|
|
11550
|
+
$('group').value = ui.group;
|
|
11551
|
+
const initialKey = keyFromHash();
|
|
11552
|
+
if (initialKey && snap.items.some((i) => i.id === initialKey)) ui.open = initialKey;
|
|
11553
|
+
ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
|
|
11554
|
+
render();
|
|
11555
|
+
if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
|
|
11556
|
+
if (GENERATION) pollJobs();
|
|
11557
|
+
if (ui.compare.length >= 2 && !ui.open) openCompare();
|
|
11558
|
+
</script>
|
|
11559
|
+
</body>
|
|
11560
|
+
</html>`;
|
|
11561
|
+
}
|
|
11562
|
+
var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
11563
|
+
|
|
11564
|
+
// src/gallery/server.ts
|
|
11565
|
+
import { spawn as spawn2 } from "child_process";
|
|
11566
|
+
import { randomBytes } from "crypto";
|
|
11567
|
+
import { createServer as createServer2 } from "http";
|
|
11568
|
+
import { readFile as readFile19 } from "fs/promises";
|
|
11569
|
+
var MAX_EDIT_BYTES = 64 * 1024;
|
|
11570
|
+
async function readJsonBody(req) {
|
|
11571
|
+
const chunks = [];
|
|
11572
|
+
let bytes = 0;
|
|
11573
|
+
for await (const chunk2 of req) {
|
|
11574
|
+
bytes += chunk2.length;
|
|
11575
|
+
if (bytes > MAX_EDIT_BYTES) throw Object.assign(new Error("edit request is too large"), { status: 413 });
|
|
11576
|
+
chunks.push(chunk2);
|
|
11577
|
+
}
|
|
11578
|
+
try {
|
|
11579
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
11580
|
+
} catch {
|
|
11581
|
+
throw Object.assign(new Error("edit request is not valid JSON"), { status: 400 });
|
|
11582
|
+
}
|
|
11583
|
+
}
|
|
11584
|
+
async function serveGallery(opts) {
|
|
11585
|
+
const log = opts.onProgress ?? (() => {
|
|
11586
|
+
});
|
|
11587
|
+
const session = opts.edit || opts.generate ? randomBytes(16).toString("hex") : null;
|
|
11588
|
+
let media = /* @__PURE__ */ new Map();
|
|
11589
|
+
let loading = null;
|
|
11590
|
+
const reviewAssets = /* @__PURE__ */ new Map();
|
|
11591
|
+
const load = () => {
|
|
11592
|
+
if (!loading) {
|
|
11593
|
+
loading = opts.load().then((build) => {
|
|
11594
|
+
media = build.media;
|
|
11595
|
+
return build;
|
|
11596
|
+
}).finally(() => {
|
|
11597
|
+
loading = null;
|
|
11598
|
+
});
|
|
11599
|
+
}
|
|
11600
|
+
return loading;
|
|
11601
|
+
};
|
|
11602
|
+
const server = createServer2(async (req, res) => {
|
|
11603
|
+
const url2 = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
11604
|
+
const fail = (status, message6) => {
|
|
11605
|
+
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
|
|
11606
|
+
res.end(message6);
|
|
11607
|
+
};
|
|
11608
|
+
const json = (status, value) => {
|
|
11609
|
+
res.writeHead(status, {
|
|
11610
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
11611
|
+
"Cache-Control": "no-store",
|
|
11612
|
+
"X-Content-Type-Options": "nosniff"
|
|
11613
|
+
});
|
|
11614
|
+
res.end(JSON.stringify(value));
|
|
11615
|
+
};
|
|
11616
|
+
const guardedBody = async (what) => {
|
|
11617
|
+
const expectedOrigin = req.headers.host ? `http://${req.headers.host}` : null;
|
|
11618
|
+
if (!req.headers.origin || req.headers.origin !== expectedOrigin) {
|
|
11619
|
+
fail(403, `cross-origin ${what} are not allowed`);
|
|
11620
|
+
return void 0;
|
|
11621
|
+
}
|
|
11622
|
+
if (req.headers["x-pixelkiln-session"] !== session) {
|
|
11623
|
+
fail(403, "missing or stale gallery session");
|
|
11624
|
+
return void 0;
|
|
11625
|
+
}
|
|
11626
|
+
if (!req.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
|
|
11627
|
+
fail(415, "application/json is required");
|
|
11628
|
+
return void 0;
|
|
11629
|
+
}
|
|
11630
|
+
return readJsonBody(req);
|
|
11631
|
+
};
|
|
11632
|
+
const reportError = (what, err) => {
|
|
11633
|
+
const status = typeof err?.status === "number" ? err.status : 500;
|
|
11634
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
11635
|
+
if (status >= 500) log(` gallery ${what} failed: ${message6}`);
|
|
11636
|
+
fail(status, message6);
|
|
11637
|
+
};
|
|
11638
|
+
if (req.method === "POST" && url2.pathname === "/api/edit") {
|
|
11639
|
+
if (!opts.edit || !session) return fail(405, "the gallery is read-only; start it with --edit to change the manifest");
|
|
11640
|
+
try {
|
|
11641
|
+
const body = await guardedBody("edits");
|
|
11642
|
+
if (body === void 0) return;
|
|
11643
|
+
const build = await opts.edit(body);
|
|
11644
|
+
media = build.media;
|
|
11645
|
+
json(200, build.snapshot);
|
|
11646
|
+
} catch (err) {
|
|
11647
|
+
reportError("edit", err);
|
|
11648
|
+
}
|
|
11649
|
+
return;
|
|
11650
|
+
}
|
|
11651
|
+
if (req.method === "POST" && url2.pathname === "/api/generate") {
|
|
11652
|
+
if (!opts.generate || !session) return fail(405, "generation is off; start the gallery with --budget to enable it");
|
|
11653
|
+
try {
|
|
11654
|
+
const body = await guardedBody("generation requests");
|
|
11655
|
+
if (body === void 0) return;
|
|
11656
|
+
json(202, await opts.generate.start(body));
|
|
11657
|
+
} catch (err) {
|
|
11658
|
+
reportError("generate", err);
|
|
11659
|
+
}
|
|
11660
|
+
return;
|
|
11661
|
+
}
|
|
11662
|
+
const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
|
|
11663
|
+
if (reviewMatch && opts.generate) {
|
|
11664
|
+
const [, jobId, rest = ""] = reviewMatch;
|
|
11665
|
+
if (req.method === "POST" && rest === "/apply") {
|
|
11666
|
+
try {
|
|
11667
|
+
const body = await guardedBody("review submissions");
|
|
11668
|
+
if (body === void 0) return;
|
|
11669
|
+
const result = await opts.generate.applyReview(jobId, body);
|
|
11670
|
+
reviewAssets.delete(jobId);
|
|
11671
|
+
json(200, result);
|
|
11672
|
+
} catch (err) {
|
|
11673
|
+
reportError("review", err);
|
|
11674
|
+
}
|
|
11675
|
+
return;
|
|
11676
|
+
}
|
|
11677
|
+
if (req.method === "GET" && rest === "") {
|
|
11678
|
+
try {
|
|
11679
|
+
const routePrefix = `/review/${jobId}`;
|
|
11680
|
+
const review = await opts.generate.review(jobId, {
|
|
11681
|
+
routePrefix,
|
|
11682
|
+
applyUrl: `${routePrefix}/apply`,
|
|
11683
|
+
applyHeaders: { "X-Pixelkiln-Session": session },
|
|
11684
|
+
embedded: true
|
|
11685
|
+
});
|
|
11686
|
+
if (!review) return fail(404, "nothing is waiting for review on this job");
|
|
11687
|
+
reviewAssets.set(jobId, review.assets);
|
|
11688
|
+
res.writeHead(200, {
|
|
11689
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
11690
|
+
"Cache-Control": "no-store",
|
|
11691
|
+
"X-Content-Type-Options": "nosniff"
|
|
11692
|
+
});
|
|
11693
|
+
res.end(review.html({
|
|
11694
|
+
applyUrl: `${routePrefix}/apply`,
|
|
11695
|
+
applyHeaders: { "X-Pixelkiln-Session": session },
|
|
11696
|
+
embedded: true
|
|
11697
|
+
}));
|
|
11698
|
+
} catch (err) {
|
|
11699
|
+
reportError("review", err);
|
|
11700
|
+
}
|
|
11701
|
+
return;
|
|
11702
|
+
}
|
|
11703
|
+
if (req.method === "GET") {
|
|
11704
|
+
const asset = reviewAssets.get(jobId)?.get(url2.pathname);
|
|
11705
|
+
if (!asset) return fail(404, "no such review asset");
|
|
11706
|
+
try {
|
|
11707
|
+
const bytes = await readFile19(asset.path);
|
|
11708
|
+
res.writeHead(200, {
|
|
11709
|
+
"Content-Type": asset.contentType,
|
|
11710
|
+
"Cache-Control": "no-store",
|
|
11711
|
+
"X-Content-Type-Options": "nosniff"
|
|
11712
|
+
});
|
|
11713
|
+
res.end(bytes);
|
|
11714
|
+
} catch {
|
|
11715
|
+
fail(404, "review asset is unavailable");
|
|
11716
|
+
}
|
|
11717
|
+
return;
|
|
11718
|
+
}
|
|
11719
|
+
}
|
|
11720
|
+
if (req.method !== "GET" && req.method !== "HEAD") return fail(405, "the gallery is read-only");
|
|
11721
|
+
if (url2.pathname === "/api/jobs") {
|
|
11722
|
+
if (!opts.generate) return fail(404, "generation is off");
|
|
11723
|
+
return json(200, opts.generate.status());
|
|
11724
|
+
}
|
|
11725
|
+
try {
|
|
11726
|
+
if (url2.pathname === "/") {
|
|
11727
|
+
const { snapshot } = await load();
|
|
11728
|
+
res.writeHead(200, {
|
|
11729
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
11730
|
+
"Cache-Control": "no-store",
|
|
11731
|
+
"X-Content-Type-Options": "nosniff"
|
|
11732
|
+
});
|
|
11733
|
+
res.end(renderGallery(snapshot, {
|
|
11734
|
+
...session ? { session } : {},
|
|
11735
|
+
editable: Boolean(opts.edit),
|
|
11736
|
+
generation: Boolean(opts.generate)
|
|
11737
|
+
}));
|
|
11738
|
+
return;
|
|
11739
|
+
}
|
|
11740
|
+
if (url2.pathname === "/api/gallery.json") {
|
|
11741
|
+
const { snapshot } = await load();
|
|
11742
|
+
res.writeHead(200, {
|
|
11743
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
11744
|
+
"Cache-Control": "no-store",
|
|
11745
|
+
"X-Content-Type-Options": "nosniff"
|
|
11746
|
+
});
|
|
11747
|
+
res.end(JSON.stringify(snapshot));
|
|
11748
|
+
return;
|
|
11749
|
+
}
|
|
11750
|
+
const mediaMatch = /^\/media\/([0-9a-f]{24})$/.exec(url2.pathname);
|
|
11751
|
+
if (mediaMatch) {
|
|
11752
|
+
const asset = media.get(mediaMatch[1]);
|
|
11753
|
+
if (!asset) return fail(404, "no such gallery media");
|
|
11754
|
+
let bytes;
|
|
11755
|
+
try {
|
|
11756
|
+
bytes = await readFile19(asset.path);
|
|
11757
|
+
} catch {
|
|
11758
|
+
return fail(404, "gallery media is no longer on disk; refresh the page");
|
|
11759
|
+
}
|
|
11760
|
+
res.writeHead(200, {
|
|
11761
|
+
"Content-Type": asset.contentType,
|
|
11762
|
+
"Content-Length": bytes.length,
|
|
11763
|
+
// The page cache-busts with the recorded hash, so a regenerated file
|
|
11764
|
+
// is never shown stale; `no-cache` keeps a hard reload honest too.
|
|
11765
|
+
"Cache-Control": "no-cache",
|
|
11766
|
+
"X-Content-Type-Options": "nosniff"
|
|
11767
|
+
});
|
|
11768
|
+
res.end(req.method === "HEAD" ? void 0 : bytes);
|
|
11769
|
+
return;
|
|
11770
|
+
}
|
|
11771
|
+
fail(404, "not found");
|
|
11772
|
+
} catch (err) {
|
|
11773
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
11774
|
+
log(` gallery request failed: ${message6}`);
|
|
11775
|
+
fail(500, message6);
|
|
11776
|
+
}
|
|
11777
|
+
});
|
|
11778
|
+
const url = await new Promise((resolve, reject) => {
|
|
11779
|
+
server.once("error", reject);
|
|
11780
|
+
server.listen(opts.port ?? 0, "127.0.0.1", () => {
|
|
11781
|
+
const address = server.address();
|
|
11782
|
+
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
11783
|
+
resolve(`http://127.0.0.1:${port}/`);
|
|
11784
|
+
});
|
|
11785
|
+
});
|
|
11786
|
+
opts.onReady?.(url);
|
|
11787
|
+
if (opts.open !== false && process.platform === "darwin") {
|
|
11788
|
+
spawn2("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
11789
|
+
}
|
|
8881
11790
|
return {
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
8889
|
-
|
|
8890
|
-
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
11791
|
+
url,
|
|
11792
|
+
session,
|
|
11793
|
+
close: () => new Promise((resolve, reject) => {
|
|
11794
|
+
server.closeAllConnections?.();
|
|
11795
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
11796
|
+
})
|
|
11797
|
+
};
|
|
11798
|
+
}
|
|
11799
|
+
|
|
11800
|
+
// src/gallery/generate.ts
|
|
11801
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
11802
|
+
import { z as z8 } from "zod";
|
|
11803
|
+
var GenerateRequestSchema = z8.object({
|
|
11804
|
+
project: z8.string().min(1).optional(),
|
|
11805
|
+
keys: z8.array(z8.string().min(1)).min(1).max(500),
|
|
11806
|
+
/** Regenerate work that is up to date, as `gen --force` does. */
|
|
11807
|
+
force: z8.boolean().optional(),
|
|
11808
|
+
/** Advance in-flight, review, or selected work without submitting anything. */
|
|
11809
|
+
resume: z8.boolean().optional()
|
|
11810
|
+
}).strict();
|
|
11811
|
+
var GenerateRequestError = class extends Error {
|
|
11812
|
+
constructor(message6, status = 400) {
|
|
11813
|
+
super(message6);
|
|
11814
|
+
this.status = status;
|
|
11815
|
+
this.name = "GenerateRequestError";
|
|
11816
|
+
}
|
|
11817
|
+
status;
|
|
11818
|
+
};
|
|
11819
|
+
function message5(error) {
|
|
11820
|
+
return error instanceof Error ? error.message : String(error);
|
|
11821
|
+
}
|
|
11822
|
+
function remainingFor(group, groupCount, budget, spent) {
|
|
11823
|
+
const keyed = budget.byProvider[group.provider];
|
|
11824
|
+
if (keyed === void 0 && groupCount > 1 && budget.amount !== void 0) {
|
|
11825
|
+
throw new GenerateRequestError(
|
|
11826
|
+
"This run spans several providers; start the gallery with a keyed budget for each, for example --budget pixellab=40 --budget retrodiffusion=1.25."
|
|
11827
|
+
);
|
|
11828
|
+
}
|
|
11829
|
+
const ceiling = keyed ?? (groupCount === 1 ? budget.amount : void 0);
|
|
11830
|
+
if (ceiling === void 0) {
|
|
11831
|
+
throw new GenerateRequestError(
|
|
11832
|
+
`No session budget for ${group.provider}; start the gallery with --budget ${group.provider}=<amount>.`
|
|
11833
|
+
);
|
|
11834
|
+
}
|
|
11835
|
+
return Math.max(0, ceiling - (spent[group.provider] ?? 0));
|
|
11836
|
+
}
|
|
11837
|
+
function createGenerateHandlers(opts) {
|
|
11838
|
+
const log = opts.onProgress ?? (() => {
|
|
11839
|
+
});
|
|
11840
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
11841
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
11842
|
+
const spent = {};
|
|
11843
|
+
const units = {};
|
|
11844
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
11845
|
+
const reviews = /* @__PURE__ */ new Map();
|
|
11846
|
+
const busyKeys = () => {
|
|
11847
|
+
const busy = /* @__PURE__ */ new Map();
|
|
11848
|
+
for (const job of jobs.values()) {
|
|
11849
|
+
if (job.phase === "done" || job.phase === "failed") continue;
|
|
11850
|
+
for (const key of job.keys) busy.set(`${job.project ?? ""}:${key}`, job.id);
|
|
11851
|
+
}
|
|
11852
|
+
return busy;
|
|
11853
|
+
};
|
|
11854
|
+
const say = (job, line) => {
|
|
11855
|
+
job.messages.push(line);
|
|
11856
|
+
if (job.messages.length > 400) job.messages.splice(0, job.messages.length - 400);
|
|
11857
|
+
log(` [${job.id.slice(0, 6)}] ${line.trim()}`);
|
|
11858
|
+
};
|
|
11859
|
+
const groupByRecordedProvider = (specs, lock) => {
|
|
11860
|
+
const groups = /* @__PURE__ */ new Map();
|
|
11861
|
+
for (const spec of specs) {
|
|
11862
|
+
const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
|
|
11863
|
+
const providerId = entry?.provider ?? spec.provider;
|
|
11864
|
+
groups.set(providerId, [...groups.get(providerId) ?? [], spec]);
|
|
11865
|
+
}
|
|
11866
|
+
return groups;
|
|
11867
|
+
};
|
|
11868
|
+
async function settle(job, ctx, specs) {
|
|
11869
|
+
job.phase = "polling";
|
|
11870
|
+
for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
|
|
11871
|
+
const provider = opts.providerFor(job.project ?? void 0, providerId);
|
|
11872
|
+
const res = await poll(provider, ctx.lock, ctx.lockPath, {
|
|
11873
|
+
onProgress: (line) => say(job, line),
|
|
11874
|
+
specs: providerSpecs,
|
|
11875
|
+
intervalMs: opts.pollIntervalMs
|
|
11876
|
+
});
|
|
11877
|
+
say(job, `${providerId}: ${res.completed} completed, ${res.review} awaiting review, ${res.failed} failed, ${res.stillRunning} still running`);
|
|
11878
|
+
}
|
|
11879
|
+
job.review = specs.map((spec) => lockKey(spec.styleId, spec.assetId)).filter((key) => ctx.lock.entries[key]?.status === "review");
|
|
11880
|
+
job.phase = "fetching";
|
|
11881
|
+
for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
|
|
11882
|
+
const provider = opts.providerFor(job.project ?? void 0, providerId);
|
|
11883
|
+
const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
|
|
11884
|
+
onProgress: (line) => say(job, line)
|
|
11885
|
+
});
|
|
11886
|
+
job.counts.downloaded += res.downloaded;
|
|
11887
|
+
if (res.downloaded || res.failed) {
|
|
11888
|
+
say(job, `${providerId}: downloaded ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
|
|
11889
|
+
}
|
|
11890
|
+
}
|
|
11891
|
+
if (job.review.length) {
|
|
11892
|
+
job.phase = "review";
|
|
11893
|
+
say(job, `${job.review.length} asset(s) awaiting selection`);
|
|
11894
|
+
} else {
|
|
11895
|
+
job.phase = "done";
|
|
11896
|
+
job.finishedAt = now().toISOString();
|
|
11897
|
+
}
|
|
11898
|
+
}
|
|
11899
|
+
async function run(job, ctx, specs, groups) {
|
|
11900
|
+
try {
|
|
11901
|
+
if (job.mode === "generate") {
|
|
11902
|
+
job.phase = "submitting";
|
|
11903
|
+
for (const group of groups) {
|
|
11904
|
+
const provider = opts.providerFor(job.project ?? void 0, group.provider);
|
|
11905
|
+
const ceiling = remainingFor(group, groups.length, opts.budget, spent);
|
|
11906
|
+
if (provider.balance) {
|
|
11907
|
+
const balance = await provider.balance();
|
|
11908
|
+
say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
|
|
11909
|
+
if (balance.unit !== group.costUnit) {
|
|
11910
|
+
throw new Error(
|
|
11911
|
+
`Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
|
|
11912
|
+
);
|
|
11913
|
+
}
|
|
11914
|
+
if (balance.unit !== "free" && group.cost > balance.remaining) {
|
|
11915
|
+
throw new Error(
|
|
11916
|
+
`${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
|
|
11917
|
+
);
|
|
11918
|
+
}
|
|
11919
|
+
}
|
|
11920
|
+
const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
|
|
11921
|
+
budget: ceiling,
|
|
11922
|
+
onProgress: (line) => say(job, line),
|
|
11923
|
+
spacingMs: opts.submitSpacingMs
|
|
11924
|
+
});
|
|
11925
|
+
spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
|
|
11926
|
+
units[group.provider] = res.unit;
|
|
11927
|
+
job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
|
|
11928
|
+
job.counts.submitted += res.submitted;
|
|
11929
|
+
job.counts.failed += res.failed;
|
|
11930
|
+
say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
|
|
11931
|
+
}
|
|
11932
|
+
}
|
|
11933
|
+
await settle(job, ctx, specs);
|
|
11934
|
+
} catch (error) {
|
|
11935
|
+
job.phase = "failed";
|
|
11936
|
+
job.error = message5(error);
|
|
11937
|
+
job.finishedAt = now().toISOString();
|
|
11938
|
+
say(job, `failed: ${job.error}`);
|
|
11939
|
+
}
|
|
11940
|
+
}
|
|
11941
|
+
return {
|
|
11942
|
+
async start(body) {
|
|
11943
|
+
const parsed = GenerateRequestSchema.safeParse(body);
|
|
11944
|
+
if (!parsed.success) {
|
|
11945
|
+
throw new GenerateRequestError(
|
|
11946
|
+
"invalid request: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
|
|
11947
|
+
);
|
|
8898
11948
|
}
|
|
11949
|
+
const request = parsed.data;
|
|
11950
|
+
const ctx = await opts.loadProject(request.project);
|
|
11951
|
+
const byKey = new Map(ctx.specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
|
|
11952
|
+
const specs = [];
|
|
11953
|
+
for (const key of request.keys) {
|
|
11954
|
+
const spec = byKey.get(key);
|
|
11955
|
+
if (!spec) throw new GenerateRequestError(`"${key}" is not declared by the manifest`);
|
|
11956
|
+
specs.push(spec);
|
|
11957
|
+
}
|
|
11958
|
+
const busy = busyKeys();
|
|
11959
|
+
for (const key of request.keys) {
|
|
11960
|
+
const holder = busy.get(`${request.project ?? ""}:${key}`);
|
|
11961
|
+
if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
|
|
11962
|
+
}
|
|
11963
|
+
let groups = [];
|
|
11964
|
+
if (!request.resume) {
|
|
11965
|
+
const plan = await buildPlan(specs, ctx.lock, { force: request.force });
|
|
11966
|
+
if (!plan.actionable.length) {
|
|
11967
|
+
const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
|
|
11968
|
+
throw new GenerateRequestError(`nothing to generate; ${reasons.join("; ")}`);
|
|
11969
|
+
}
|
|
11970
|
+
groups = plan.groups;
|
|
11971
|
+
for (const group of groups) {
|
|
11972
|
+
const remaining = remainingFor(group, groups.length, opts.budget, spent);
|
|
11973
|
+
if (group.cost > remaining) {
|
|
11974
|
+
throw new GenerateRequestError(
|
|
11975
|
+
`${group.provider} would spend ${formatCost(group.costUnit, group.cost)} but only ${formatCost(group.costUnit, remaining)} of this session's budget remain.`
|
|
11976
|
+
);
|
|
11977
|
+
}
|
|
11978
|
+
}
|
|
11979
|
+
}
|
|
11980
|
+
const job = {
|
|
11981
|
+
id: randomBytes2(8).toString("hex"),
|
|
11982
|
+
project: request.project ?? null,
|
|
11983
|
+
keys: request.keys,
|
|
11984
|
+
mode: request.resume ? "resume" : "generate",
|
|
11985
|
+
phase: "queued",
|
|
11986
|
+
startedAt: now().toISOString(),
|
|
11987
|
+
finishedAt: null,
|
|
11988
|
+
messages: [],
|
|
11989
|
+
error: null,
|
|
11990
|
+
spent: {},
|
|
11991
|
+
review: [],
|
|
11992
|
+
counts: { submitted: 0, failed: 0, downloaded: 0 }
|
|
11993
|
+
};
|
|
11994
|
+
jobs.set(job.id, job);
|
|
11995
|
+
contexts.set(job.id, ctx);
|
|
11996
|
+
say(job, request.resume ? `resuming ${request.keys.length} asset(s) at no cost` : `generating ${groups.reduce((n, g) => n + g.actionable.length, 0)} asset(s): ` + groups.map((g) => `${g.provider} ${formatCost(g.costUnit, g.cost)}`).join("; "));
|
|
11997
|
+
void run(job, ctx, specs, groups);
|
|
11998
|
+
return job;
|
|
8899
11999
|
},
|
|
8900
|
-
|
|
12000
|
+
status() {
|
|
12001
|
+
return {
|
|
12002
|
+
jobs: [...jobs.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt)),
|
|
12003
|
+
budget: opts.budget,
|
|
12004
|
+
spent,
|
|
12005
|
+
units
|
|
12006
|
+
};
|
|
12007
|
+
},
|
|
12008
|
+
async review(jobId, options) {
|
|
12009
|
+
const job = jobs.get(jobId);
|
|
12010
|
+
const ctx = contexts.get(jobId);
|
|
12011
|
+
if (!job || !ctx || job.phase !== "review" || !job.review.length) return null;
|
|
12012
|
+
const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
|
|
12013
|
+
const providers = [...groupByRecordedProvider(specs, ctx.lock).keys()];
|
|
12014
|
+
const providerId = providers[0];
|
|
12015
|
+
if (!providerId) return null;
|
|
12016
|
+
const session = await prepareReview(opts.providerFor(job.project ?? void 0, providerId), ctx.lock, {
|
|
12017
|
+
keys: job.review,
|
|
12018
|
+
specs,
|
|
12019
|
+
onProgress: (line) => say(job, line),
|
|
12020
|
+
routePrefix: options.routePrefix
|
|
12021
|
+
});
|
|
12022
|
+
if (session) reviews.set(jobId, session);
|
|
12023
|
+
return session;
|
|
12024
|
+
},
|
|
12025
|
+
async applyReview(jobId, body) {
|
|
12026
|
+
const job = jobs.get(jobId);
|
|
12027
|
+
const ctx = contexts.get(jobId);
|
|
12028
|
+
const session = reviews.get(jobId);
|
|
12029
|
+
if (!job || !ctx || !session) throw new GenerateRequestError("no review is open for this job", 404);
|
|
12030
|
+
const result = await session.apply(body, ctx.lockPath);
|
|
12031
|
+
say(job, `selected ${result.selected}, left in review ${result.skipped}`);
|
|
12032
|
+
reviews.delete(jobId);
|
|
12033
|
+
const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
|
|
12034
|
+
void settle(job, ctx, specs).catch((error) => {
|
|
12035
|
+
job.phase = "failed";
|
|
12036
|
+
job.error = message5(error);
|
|
12037
|
+
job.finishedAt = now().toISOString();
|
|
12038
|
+
say(job, `failed: ${job.error}`);
|
|
12039
|
+
});
|
|
12040
|
+
return result;
|
|
12041
|
+
}
|
|
8901
12042
|
};
|
|
8902
12043
|
}
|
|
8903
|
-
function mode(values) {
|
|
8904
|
-
const counts = /* @__PURE__ */ new Map();
|
|
8905
|
-
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
8906
|
-
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
|
|
8907
|
-
}
|
|
8908
12044
|
|
|
8909
12045
|
// src/pipeline/salvage.ts
|
|
8910
|
-
import { readFile as
|
|
8911
|
-
import { existsSync as
|
|
8912
|
-
import
|
|
12046
|
+
import { readFile as readFile20 } from "fs/promises";
|
|
12047
|
+
import { existsSync as existsSync19 } from "fs";
|
|
12048
|
+
import path23 from "path";
|
|
8913
12049
|
function providerClaimId(provider, objectId) {
|
|
8914
12050
|
return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
|
|
8915
12051
|
}
|
|
8916
12052
|
async function loadClaims(lockPaths, opts = {}) {
|
|
8917
12053
|
const claimed = /* @__PURE__ */ new Set();
|
|
8918
12054
|
for (const p of lockPaths) {
|
|
8919
|
-
if (!
|
|
12055
|
+
if (!existsSync19(p)) throw new Error(`Claim lockfile not found: ${p}`);
|
|
8920
12056
|
let parsed;
|
|
8921
12057
|
try {
|
|
8922
|
-
parsed = parseLock(JSON.parse(await
|
|
12058
|
+
parsed = parseLock(JSON.parse(await readFile20(p, "utf8")));
|
|
8923
12059
|
} catch {
|
|
8924
12060
|
throw new Error(`Claim lockfile is malformed: ${p}`);
|
|
8925
12061
|
}
|
|
@@ -8972,20 +12108,20 @@ function matchOrphanStyle(prompt, manifest) {
|
|
|
8972
12108
|
return matchStyleByPattern(prompt, manifest);
|
|
8973
12109
|
}
|
|
8974
12110
|
async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
|
|
8975
|
-
const own =
|
|
12111
|
+
const own = path23.resolve(ownManifestPath);
|
|
8976
12112
|
const siblingManifestPaths = [
|
|
8977
12113
|
.../* @__PURE__ */ new Set([
|
|
8978
12114
|
...workspaceManifestPaths,
|
|
8979
|
-
...claimPaths.map((c) =>
|
|
12115
|
+
...claimPaths.map((c) => path23.join(path23.dirname(path23.resolve(c)), "pixelkiln.manifest.json"))
|
|
8980
12116
|
])
|
|
8981
12117
|
];
|
|
8982
12118
|
const siblings = [];
|
|
8983
12119
|
for (const siblingManifestPath of siblingManifestPaths) {
|
|
8984
|
-
if (
|
|
8985
|
-
if (!
|
|
12120
|
+
if (path23.resolve(siblingManifestPath) === own) continue;
|
|
12121
|
+
if (!existsSync19(siblingManifestPath)) continue;
|
|
8986
12122
|
try {
|
|
8987
12123
|
const { manifest } = await loadManifest(siblingManifestPath);
|
|
8988
|
-
siblings.push({ label:
|
|
12124
|
+
siblings.push({ label: path23.basename(path23.dirname(siblingManifestPath)), manifest });
|
|
8989
12125
|
} catch {
|
|
8990
12126
|
}
|
|
8991
12127
|
}
|
|
@@ -9101,16 +12237,16 @@ async function applyTags(provider, decisions, existing, opts = {}) {
|
|
|
9101
12237
|
}
|
|
9102
12238
|
|
|
9103
12239
|
// src/pick/salvage-server.ts
|
|
9104
|
-
import { mkdir as
|
|
9105
|
-
import
|
|
12240
|
+
import { mkdir as mkdir6, writeFile as writeFile9, readFile as readFile21 } from "fs/promises";
|
|
12241
|
+
import path24 from "path";
|
|
9106
12242
|
|
|
9107
12243
|
// src/pick/salvage-sheet.ts
|
|
9108
|
-
var
|
|
12244
|
+
var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
9109
12245
|
function renderSalvageSheet(orphans, ctx) {
|
|
9110
|
-
const safe = orphans.map((o) => ({ ...o, prompt:
|
|
12246
|
+
const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt) }));
|
|
9111
12247
|
const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
|
|
9112
|
-
const styleId = ctx ?
|
|
9113
|
-
const importDir = ctx ?
|
|
12248
|
+
const styleId = ctx ? escapeHtml3(ctx.styleId) : null;
|
|
12249
|
+
const importDir = ctx ? escapeHtml3(ctx.importDir) : null;
|
|
9114
12250
|
return `<!doctype html>
|
|
9115
12251
|
<html lang="en">
|
|
9116
12252
|
<head>
|
|
@@ -9267,7 +12403,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9267
12403
|
});
|
|
9268
12404
|
const html = renderSalvageSheet(orphans, {
|
|
9269
12405
|
styleId: ctx.styleId,
|
|
9270
|
-
importDir:
|
|
12406
|
+
importDir: path24.relative(process.cwd(), ctx.importDir) || "."
|
|
9271
12407
|
});
|
|
9272
12408
|
const byId = new Map(orphans.map((o) => [o.id, o]));
|
|
9273
12409
|
const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
|
|
@@ -9296,10 +12432,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9296
12432
|
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
|
|
9297
12433
|
decodePng(buf);
|
|
9298
12434
|
const assetId = idFromPrompt(orphan.prompt, taken);
|
|
9299
|
-
const rel =
|
|
9300
|
-
const outFile =
|
|
9301
|
-
await
|
|
9302
|
-
await
|
|
12435
|
+
const rel = path24.join("_salvaged", `${assetId}.png`);
|
|
12436
|
+
const outFile = path24.resolve(ctx.importDir, rel);
|
|
12437
|
+
await mkdir6(path24.dirname(outFile), { recursive: true });
|
|
12438
|
+
await writeFile9(outFile, buf);
|
|
9303
12439
|
ctx.manifest.assets[assetId] = {
|
|
9304
12440
|
prompt: orphan.prompt,
|
|
9305
12441
|
promptByStyle: {},
|
|
@@ -9330,7 +12466,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9330
12466
|
sourceUrl: durableSource,
|
|
9331
12467
|
sourceUrls: durableSource ? [{ url: durableSource }] : [],
|
|
9332
12468
|
outputs: [{
|
|
9333
|
-
path: portableOutputPath(outFile,
|
|
12469
|
+
path: portableOutputPath(outFile, path24.dirname(ctx.manifestPath)),
|
|
9334
12470
|
sha256: sha256(buf)
|
|
9335
12471
|
}],
|
|
9336
12472
|
submittedAt: orphan.createdAt,
|
|
@@ -9354,148 +12490,15 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9354
12490
|
}
|
|
9355
12491
|
}
|
|
9356
12492
|
await applyTags(provider, decisions, existingTags, { onProgress: log });
|
|
9357
|
-
const raw = JSON.parse(await
|
|
12493
|
+
const raw = JSON.parse(await readFile21(ctx.manifestPath, "utf8"));
|
|
9358
12494
|
for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
|
|
9359
|
-
await
|
|
12495
|
+
await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
|
|
9360
12496
|
await saveLock(ctx.lockPath, ctx.lock);
|
|
9361
12497
|
return result;
|
|
9362
12498
|
}
|
|
9363
12499
|
});
|
|
9364
12500
|
}
|
|
9365
12501
|
|
|
9366
|
-
// src/workspace.ts
|
|
9367
|
-
import { mkdir as mkdir6, readFile as readFile17, rename as rename5, rm as rm7, writeFile as writeFile8 } from "fs/promises";
|
|
9368
|
-
import { existsSync as existsSync17 } from "fs";
|
|
9369
|
-
import path22 from "path";
|
|
9370
|
-
import { z as z6 } from "zod";
|
|
9371
|
-
var WorkspaceProjectSchema = z6.object({
|
|
9372
|
-
id: z6.string().min(1),
|
|
9373
|
-
/** Manifest path, relative to the catalog file's own directory. */
|
|
9374
|
-
manifest: z6.string().min(1),
|
|
9375
|
-
/** Lockfile path, relative to the catalog file's own directory. */
|
|
9376
|
-
lock: z6.string().min(1),
|
|
9377
|
-
provider: z6.string().min(1).default("pixellab"),
|
|
9378
|
-
/** Free-form label for a shared account, e.g. distinguishing sandboxes. */
|
|
9379
|
-
account: z6.string().optional()
|
|
9380
|
-
}).strict();
|
|
9381
|
-
var WorkspaceSchema = z6.object({
|
|
9382
|
-
version: z6.literal(1),
|
|
9383
|
-
projects: z6.array(WorkspaceProjectSchema).default([])
|
|
9384
|
-
}).strict();
|
|
9385
|
-
function parseWorkspace(raw) {
|
|
9386
|
-
const parsed = WorkspaceSchema.safeParse(raw);
|
|
9387
|
-
if (parsed.success) return parsed.data;
|
|
9388
|
-
throw new Error(
|
|
9389
|
-
`Workspace catalog is not valid v1:
|
|
9390
|
-
${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
|
|
9391
|
-
);
|
|
9392
|
-
}
|
|
9393
|
-
async function loadWorkspace(workspacePath) {
|
|
9394
|
-
if (!existsSync17(workspacePath)) return { version: 1, projects: [] };
|
|
9395
|
-
let raw;
|
|
9396
|
-
try {
|
|
9397
|
-
raw = JSON.parse(await readFile17(workspacePath, "utf8"));
|
|
9398
|
-
} catch (err) {
|
|
9399
|
-
throw new Error(
|
|
9400
|
-
`Workspace catalog at ${workspacePath} is malformed:
|
|
9401
|
-
${err instanceof Error ? err.message : String(err)}`
|
|
9402
|
-
);
|
|
9403
|
-
}
|
|
9404
|
-
return parseWorkspace(raw);
|
|
9405
|
-
}
|
|
9406
|
-
async function saveWorkspace(workspacePath, ws) {
|
|
9407
|
-
const sorted = {
|
|
9408
|
-
version: 1,
|
|
9409
|
-
projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
|
|
9410
|
-
};
|
|
9411
|
-
await mkdir6(path22.dirname(path22.resolve(workspacePath)), { recursive: true });
|
|
9412
|
-
const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
9413
|
-
try {
|
|
9414
|
-
await writeFile8(tmp, JSON.stringify(sorted, null, 2) + "\n");
|
|
9415
|
-
await rename5(tmp, workspacePath);
|
|
9416
|
-
} finally {
|
|
9417
|
-
await rm7(tmp, { force: true });
|
|
9418
|
-
}
|
|
9419
|
-
}
|
|
9420
|
-
function toPortablePath(dir, absolute) {
|
|
9421
|
-
return path22.relative(dir, absolute).split(path22.sep).join("/");
|
|
9422
|
-
}
|
|
9423
|
-
function resolveProject(dir, project) {
|
|
9424
|
-
return {
|
|
9425
|
-
manifestPath: path22.resolve(dir, project.manifest.split("/").join(path22.sep)),
|
|
9426
|
-
lockPath: path22.resolve(dir, project.lock.split("/").join(path22.sep))
|
|
9427
|
-
};
|
|
9428
|
-
}
|
|
9429
|
-
function validateWorkspace(ws, dir) {
|
|
9430
|
-
const diagnostics = [];
|
|
9431
|
-
const idCounts = /* @__PURE__ */ new Map();
|
|
9432
|
-
const lockOwners = /* @__PURE__ */ new Map();
|
|
9433
|
-
const manifestOwners = /* @__PURE__ */ new Map();
|
|
9434
|
-
for (const project of ws.projects) {
|
|
9435
|
-
idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
|
|
9436
|
-
const { manifestPath, lockPath } = resolveProject(dir, project);
|
|
9437
|
-
lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
|
|
9438
|
-
manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
|
|
9439
|
-
if (path22.isAbsolute(project.manifest) || path22.isAbsolute(project.lock)) {
|
|
9440
|
-
diagnostics.push({
|
|
9441
|
-
id: "absolute-path",
|
|
9442
|
-
level: "warning",
|
|
9443
|
-
message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
|
|
9444
|
-
});
|
|
9445
|
-
}
|
|
9446
|
-
if (!existsSync17(manifestPath)) {
|
|
9447
|
-
diagnostics.push({
|
|
9448
|
-
id: "missing-manifest",
|
|
9449
|
-
level: "error",
|
|
9450
|
-
message: `project "${project.id}" manifest not found: ${manifestPath}`
|
|
9451
|
-
});
|
|
9452
|
-
}
|
|
9453
|
-
if (!existsSync17(lockPath)) {
|
|
9454
|
-
diagnostics.push({
|
|
9455
|
-
id: "missing-lock",
|
|
9456
|
-
level: "error",
|
|
9457
|
-
message: `project "${project.id}" lockfile not found: ${lockPath}`
|
|
9458
|
-
});
|
|
9459
|
-
}
|
|
9460
|
-
}
|
|
9461
|
-
for (const [id, count] of idCounts) {
|
|
9462
|
-
if (count > 1) {
|
|
9463
|
-
diagnostics.push({
|
|
9464
|
-
id: "duplicate-id",
|
|
9465
|
-
level: "error",
|
|
9466
|
-
message: `project id "${id}" is registered ${count} times`
|
|
9467
|
-
});
|
|
9468
|
-
}
|
|
9469
|
-
}
|
|
9470
|
-
for (const [lockPath, ids] of lockOwners) {
|
|
9471
|
-
if (ids.length > 1) {
|
|
9472
|
-
diagnostics.push({
|
|
9473
|
-
id: "duplicate-lock",
|
|
9474
|
-
level: "error",
|
|
9475
|
-
message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
|
|
9476
|
-
});
|
|
9477
|
-
}
|
|
9478
|
-
}
|
|
9479
|
-
for (const [manifestPath, ids] of manifestOwners) {
|
|
9480
|
-
if (ids.length > 1) {
|
|
9481
|
-
diagnostics.push({
|
|
9482
|
-
id: "duplicate-manifest",
|
|
9483
|
-
level: "warning",
|
|
9484
|
-
message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
|
|
9485
|
-
});
|
|
9486
|
-
}
|
|
9487
|
-
}
|
|
9488
|
-
const providers = new Set(ws.projects.map((p) => p.provider));
|
|
9489
|
-
if (providers.size > 1) {
|
|
9490
|
-
diagnostics.push({
|
|
9491
|
-
id: "mixed-provider",
|
|
9492
|
-
level: "warning",
|
|
9493
|
-
message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
|
|
9494
|
-
});
|
|
9495
|
-
}
|
|
9496
|
-
return diagnostics;
|
|
9497
|
-
}
|
|
9498
|
-
|
|
9499
12502
|
// src/pipeline/workspace.ts
|
|
9500
12503
|
async function workspaceClaims(ws, dir) {
|
|
9501
12504
|
const lockPaths = [];
|
|
@@ -9605,6 +12608,8 @@ export {
|
|
|
9605
12608
|
DEFAULT_RATE_LIMIT,
|
|
9606
12609
|
FAKE_PNG,
|
|
9607
12610
|
FakeProvider,
|
|
12611
|
+
GenerateRequestError,
|
|
12612
|
+
GenerateRequestSchema,
|
|
9608
12613
|
GeneratorSchema,
|
|
9609
12614
|
GridConfidenceSchema,
|
|
9610
12615
|
ImageQualityMetricsSchema,
|
|
@@ -9613,6 +12618,9 @@ export {
|
|
|
9613
12618
|
LockSchema,
|
|
9614
12619
|
MAX_DOWNLOAD_BYTES,
|
|
9615
12620
|
MAX_RETRIES,
|
|
12621
|
+
ManifestDriftError,
|
|
12622
|
+
ManifestEditError,
|
|
12623
|
+
ManifestEditSchema,
|
|
9616
12624
|
ManifestInputSchema,
|
|
9617
12625
|
ManifestSchema,
|
|
9618
12626
|
MediaType,
|
|
@@ -9635,13 +12643,16 @@ export {
|
|
|
9635
12643
|
WorkspaceProjectSchema,
|
|
9636
12644
|
WorkspaceSchema,
|
|
9637
12645
|
adopt,
|
|
12646
|
+
applyManifestEdit,
|
|
9638
12647
|
applyTags,
|
|
9639
12648
|
approveQualityRecord,
|
|
9640
12649
|
auditStyle,
|
|
9641
12650
|
availableProviders,
|
|
9642
12651
|
backoffMs,
|
|
12652
|
+
buildGallerySnapshot,
|
|
9643
12653
|
buildManifest,
|
|
9644
12654
|
buildPlan,
|
|
12655
|
+
buildWorkspaceGallerySnapshot,
|
|
9645
12656
|
bundledRecipeRoot,
|
|
9646
12657
|
cacheFileName,
|
|
9647
12658
|
candidateCount,
|
|
@@ -9651,6 +12662,8 @@ export {
|
|
|
9651
12662
|
colorDistance,
|
|
9652
12663
|
countNumberedDescriptions,
|
|
9653
12664
|
createArtifactBundleManifest,
|
|
12665
|
+
createGalleryEditHandler,
|
|
12666
|
+
createGenerateHandlers,
|
|
9654
12667
|
createProvider,
|
|
9655
12668
|
currentEntryOutputPath,
|
|
9656
12669
|
currentOutputPath,
|
|
@@ -9663,6 +12676,8 @@ export {
|
|
|
9663
12676
|
fetchAssets,
|
|
9664
12677
|
findOrphans,
|
|
9665
12678
|
formatCost,
|
|
12679
|
+
galleryMediaId,
|
|
12680
|
+
galleryMediaRoute,
|
|
9666
12681
|
generationCost,
|
|
9667
12682
|
groupOrphansByStyle,
|
|
9668
12683
|
hex,
|
|
@@ -9702,6 +12717,7 @@ export {
|
|
|
9702
12717
|
pngSize,
|
|
9703
12718
|
poll,
|
|
9704
12719
|
portableOutputPath,
|
|
12720
|
+
prepareReview,
|
|
9705
12721
|
primaryOutput,
|
|
9706
12722
|
providerCredentialEnvs,
|
|
9707
12723
|
providerFactory,
|
|
@@ -9717,6 +12733,7 @@ export {
|
|
|
9717
12733
|
refineQualityProfiles,
|
|
9718
12734
|
registerProvider,
|
|
9719
12735
|
remove,
|
|
12736
|
+
renderGallery,
|
|
9720
12737
|
renderSalvageSheet,
|
|
9721
12738
|
renderSheet,
|
|
9722
12739
|
requireApprovedQualitySources,
|
|
@@ -9745,6 +12762,7 @@ export {
|
|
|
9745
12762
|
saveWorkspace,
|
|
9746
12763
|
scanAssets,
|
|
9747
12764
|
selectEntryOutput,
|
|
12765
|
+
serveGallery,
|
|
9748
12766
|
sha256,
|
|
9749
12767
|
sha256File,
|
|
9750
12768
|
shouldPersistSourceUrl,
|