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.cjs
CHANGED
|
@@ -36,6 +36,8 @@ __export(index_exports, {
|
|
|
36
36
|
DEFAULT_RATE_LIMIT: () => DEFAULT_RATE_LIMIT,
|
|
37
37
|
FAKE_PNG: () => FAKE_PNG,
|
|
38
38
|
FakeProvider: () => FakeProvider,
|
|
39
|
+
GenerateRequestError: () => GenerateRequestError,
|
|
40
|
+
GenerateRequestSchema: () => GenerateRequestSchema,
|
|
39
41
|
GeneratorSchema: () => GeneratorSchema,
|
|
40
42
|
GridConfidenceSchema: () => GridConfidenceSchema,
|
|
41
43
|
ImageQualityMetricsSchema: () => ImageQualityMetricsSchema,
|
|
@@ -44,6 +46,9 @@ __export(index_exports, {
|
|
|
44
46
|
LockSchema: () => LockSchema,
|
|
45
47
|
MAX_DOWNLOAD_BYTES: () => MAX_DOWNLOAD_BYTES,
|
|
46
48
|
MAX_RETRIES: () => MAX_RETRIES,
|
|
49
|
+
ManifestDriftError: () => ManifestDriftError,
|
|
50
|
+
ManifestEditError: () => ManifestEditError,
|
|
51
|
+
ManifestEditSchema: () => ManifestEditSchema,
|
|
47
52
|
ManifestInputSchema: () => ManifestInputSchema,
|
|
48
53
|
ManifestSchema: () => ManifestSchema,
|
|
49
54
|
MediaType: () => MediaType,
|
|
@@ -66,13 +71,16 @@ __export(index_exports, {
|
|
|
66
71
|
WorkspaceProjectSchema: () => WorkspaceProjectSchema,
|
|
67
72
|
WorkspaceSchema: () => WorkspaceSchema,
|
|
68
73
|
adopt: () => adopt,
|
|
74
|
+
applyManifestEdit: () => applyManifestEdit,
|
|
69
75
|
applyTags: () => applyTags,
|
|
70
76
|
approveQualityRecord: () => approveQualityRecord,
|
|
71
77
|
auditStyle: () => auditStyle,
|
|
72
78
|
availableProviders: () => availableProviders,
|
|
73
79
|
backoffMs: () => backoffMs,
|
|
80
|
+
buildGallerySnapshot: () => buildGallerySnapshot,
|
|
74
81
|
buildManifest: () => buildManifest,
|
|
75
82
|
buildPlan: () => buildPlan,
|
|
83
|
+
buildWorkspaceGallerySnapshot: () => buildWorkspaceGallerySnapshot,
|
|
76
84
|
bundledRecipeRoot: () => bundledRecipeRoot,
|
|
77
85
|
cacheFileName: () => cacheFileName,
|
|
78
86
|
candidateCount: () => candidateCount,
|
|
@@ -82,6 +90,8 @@ __export(index_exports, {
|
|
|
82
90
|
colorDistance: () => colorDistance,
|
|
83
91
|
countNumberedDescriptions: () => countNumberedDescriptions,
|
|
84
92
|
createArtifactBundleManifest: () => createArtifactBundleManifest,
|
|
93
|
+
createGalleryEditHandler: () => createGalleryEditHandler,
|
|
94
|
+
createGenerateHandlers: () => createGenerateHandlers,
|
|
85
95
|
createProvider: () => createProvider,
|
|
86
96
|
currentEntryOutputPath: () => currentEntryOutputPath,
|
|
87
97
|
currentOutputPath: () => currentOutputPath,
|
|
@@ -94,6 +104,8 @@ __export(index_exports, {
|
|
|
94
104
|
fetchAssets: () => fetchAssets,
|
|
95
105
|
findOrphans: () => findOrphans,
|
|
96
106
|
formatCost: () => formatCost,
|
|
107
|
+
galleryMediaId: () => galleryMediaId,
|
|
108
|
+
galleryMediaRoute: () => galleryMediaRoute,
|
|
97
109
|
generationCost: () => generationCost,
|
|
98
110
|
groupOrphansByStyle: () => groupOrphansByStyle,
|
|
99
111
|
hex: () => hex,
|
|
@@ -133,6 +145,7 @@ __export(index_exports, {
|
|
|
133
145
|
pngSize: () => pngSize,
|
|
134
146
|
poll: () => poll,
|
|
135
147
|
portableOutputPath: () => portableOutputPath,
|
|
148
|
+
prepareReview: () => prepareReview,
|
|
136
149
|
primaryOutput: () => primaryOutput,
|
|
137
150
|
providerCredentialEnvs: () => providerCredentialEnvs,
|
|
138
151
|
providerFactory: () => providerFactory,
|
|
@@ -148,6 +161,7 @@ __export(index_exports, {
|
|
|
148
161
|
refineQualityProfiles: () => refineQualityProfiles,
|
|
149
162
|
registerProvider: () => registerProvider,
|
|
150
163
|
remove: () => remove,
|
|
164
|
+
renderGallery: () => renderGallery,
|
|
151
165
|
renderSalvageSheet: () => renderSalvageSheet,
|
|
152
166
|
renderSheet: () => renderSheet,
|
|
153
167
|
requireApprovedQualitySources: () => requireApprovedQualitySources,
|
|
@@ -176,6 +190,7 @@ __export(index_exports, {
|
|
|
176
190
|
saveWorkspace: () => saveWorkspace,
|
|
177
191
|
scanAssets: () => scanAssets,
|
|
178
192
|
selectEntryOutput: () => selectEntryOutput,
|
|
193
|
+
serveGallery: () => serveGallery,
|
|
179
194
|
sha256: () => sha256,
|
|
180
195
|
sha256File: () => sha256File,
|
|
181
196
|
shouldPersistSourceUrl: () => shouldPersistSourceUrl,
|
|
@@ -718,8 +733,8 @@ function validateResponse(schema, raw, operation) {
|
|
|
718
733
|
throw new Error(`Invalid PixelLab response for ${operation}: ${issues}`);
|
|
719
734
|
}
|
|
720
735
|
var PixelLabError = class extends Error {
|
|
721
|
-
constructor(
|
|
722
|
-
super(
|
|
736
|
+
constructor(message6, status, body) {
|
|
737
|
+
super(message6);
|
|
723
738
|
this.status = status;
|
|
724
739
|
this.body = body;
|
|
725
740
|
this.name = "PixelLabError";
|
|
@@ -744,11 +759,11 @@ var PixelLabClient = class {
|
|
|
744
759
|
* common than the failure mode of retrying (a duplicate object), and a
|
|
745
760
|
* duplicate is visible and free to delete whereas a silent gap is neither.
|
|
746
761
|
*/
|
|
747
|
-
async request(
|
|
762
|
+
async request(path25, init, attempt = 0) {
|
|
748
763
|
const auth = this.apiKey.startsWith("Bearer ") ? this.apiKey : `Bearer ${this.apiKey}`;
|
|
749
764
|
let res;
|
|
750
765
|
try {
|
|
751
|
-
res = await fetch(`${BASE}${
|
|
766
|
+
res = await fetch(`${BASE}${path25}`, {
|
|
752
767
|
...init,
|
|
753
768
|
signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs),
|
|
754
769
|
headers: {
|
|
@@ -760,24 +775,24 @@ var PixelLabClient = class {
|
|
|
760
775
|
} catch (err) {
|
|
761
776
|
if (attempt < MAX_RETRIES) {
|
|
762
777
|
await sleep(backoffMs(attempt));
|
|
763
|
-
return this.request(
|
|
778
|
+
return this.request(path25, init, attempt + 1);
|
|
764
779
|
}
|
|
765
780
|
throw err;
|
|
766
781
|
}
|
|
767
782
|
if (!res.ok && shouldRetry(res.status) && attempt < MAX_RETRIES) {
|
|
768
783
|
const waitMs = retryAfterMs(res.headers.get("retry-after")) ?? backoffMs(attempt);
|
|
769
784
|
await sleep(waitMs);
|
|
770
|
-
return this.request(
|
|
785
|
+
return this.request(path25, init, attempt + 1);
|
|
771
786
|
}
|
|
772
787
|
const text = await res.text();
|
|
773
788
|
if (!res.ok) {
|
|
774
|
-
throw new PixelLabError(`${init?.method ?? "GET"} ${
|
|
789
|
+
throw new PixelLabError(`${init?.method ?? "GET"} ${path25} \u2192 ${res.status}`, res.status, text);
|
|
775
790
|
}
|
|
776
791
|
if (!text) return {};
|
|
777
792
|
try {
|
|
778
793
|
return JSON.parse(text);
|
|
779
794
|
} catch {
|
|
780
|
-
throw new Error(`${init?.method ?? "GET"} ${
|
|
795
|
+
throw new Error(`${init?.method ?? "GET"} ${path25} returned invalid JSON`);
|
|
781
796
|
}
|
|
782
797
|
}
|
|
783
798
|
async balance() {
|
|
@@ -1824,9 +1839,9 @@ var RetroDiffusionClient = class {
|
|
|
1824
1839
|
}
|
|
1825
1840
|
return balance;
|
|
1826
1841
|
}
|
|
1827
|
-
async call(
|
|
1842
|
+
async call(path25, init = {}) {
|
|
1828
1843
|
if (!this.token) throw new Error("RD_API_KEY is not set");
|
|
1829
|
-
const response = await this.request(`${this.baseUrl}${
|
|
1844
|
+
const response = await this.request(`${this.baseUrl}${path25}`, {
|
|
1830
1845
|
...init,
|
|
1831
1846
|
headers: {
|
|
1832
1847
|
"Content-Type": "application/json",
|
|
@@ -2128,8 +2143,8 @@ function retroError(value) {
|
|
|
2128
2143
|
if (details.length) return details.join("; ");
|
|
2129
2144
|
}
|
|
2130
2145
|
if (record.detail && typeof record.detail === "object") {
|
|
2131
|
-
const
|
|
2132
|
-
if (typeof
|
|
2146
|
+
const message6 = record.detail.message;
|
|
2147
|
+
if (typeof message6 === "string") return message6;
|
|
2133
2148
|
}
|
|
2134
2149
|
return JSON.stringify(value);
|
|
2135
2150
|
}
|
|
@@ -2585,13 +2600,13 @@ function validProgress(progress) {
|
|
|
2585
2600
|
}
|
|
2586
2601
|
return Math.round(progress * 100);
|
|
2587
2602
|
}
|
|
2588
|
-
function finiteNonnegative(value,
|
|
2589
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(
|
|
2603
|
+
function finiteNonnegative(value, message6) {
|
|
2604
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(message6);
|
|
2590
2605
|
return value;
|
|
2591
2606
|
}
|
|
2592
|
-
function optionalFiniteNonnegative(value,
|
|
2607
|
+
function optionalFiniteNonnegative(value, message6) {
|
|
2593
2608
|
if (value == null) return 0;
|
|
2594
|
-
return finiteNonnegative(value,
|
|
2609
|
+
return finiteNonnegative(value, message6);
|
|
2595
2610
|
}
|
|
2596
2611
|
function numericRecord(value) {
|
|
2597
2612
|
if (value == null) return {};
|
|
@@ -2629,10 +2644,10 @@ var import_node_fs2 = require("fs");
|
|
|
2629
2644
|
function sha256(data) {
|
|
2630
2645
|
return (0, import_node_crypto2.createHash)("sha256").update(data).digest("hex");
|
|
2631
2646
|
}
|
|
2632
|
-
async function sha256File(
|
|
2647
|
+
async function sha256File(path25) {
|
|
2633
2648
|
return new Promise((resolve, reject) => {
|
|
2634
2649
|
const hash = (0, import_node_crypto2.createHash)("sha256");
|
|
2635
|
-
const stream = (0, import_node_fs2.createReadStream)(
|
|
2650
|
+
const stream = (0, import_node_fs2.createReadStream)(path25);
|
|
2636
2651
|
stream.on("data", (chunk2) => hash.update(chunk2));
|
|
2637
2652
|
stream.on("error", reject);
|
|
2638
2653
|
stream.on("end", () => resolve(hash.digest("hex")));
|
|
@@ -3521,9 +3536,9 @@ function normalizeBaseUrl(value) {
|
|
|
3521
3536
|
}
|
|
3522
3537
|
function historyError(status) {
|
|
3523
3538
|
const messages = Array.isArray(status.messages) ? status.messages : [];
|
|
3524
|
-
for (const
|
|
3525
|
-
if (!Array.isArray(
|
|
3526
|
-
const detail =
|
|
3539
|
+
for (const message6 of messages) {
|
|
3540
|
+
if (!Array.isArray(message6) || message6[0] !== "execution_error" || !isObject(message6[1])) continue;
|
|
3541
|
+
const detail = message6[1].exception_message;
|
|
3527
3542
|
if (typeof detail === "string" && detail.trim()) {
|
|
3528
3543
|
return `ComfyUI execution failed: ${detail.trim().slice(0, 300)}`;
|
|
3529
3544
|
}
|
|
@@ -6956,10 +6971,10 @@ function isSha256Hash(value) {
|
|
|
6956
6971
|
function parseCache(value) {
|
|
6957
6972
|
return HashCacheSchema.parse(value);
|
|
6958
6973
|
}
|
|
6959
|
-
async function loadCache(
|
|
6960
|
-
if (!(0, import_node_fs11.existsSync)(
|
|
6974
|
+
async function loadCache(path25) {
|
|
6975
|
+
if (!(0, import_node_fs11.existsSync)(path25)) return { version: 1, hashes: {} };
|
|
6961
6976
|
try {
|
|
6962
|
-
const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises9.readFile)(
|
|
6977
|
+
const parsed = HashCacheSchema.safeParse(JSON.parse(await (0, import_promises9.readFile)(path25, "utf8")));
|
|
6963
6978
|
if (!parsed.success) return { version: 1, hashes: {} };
|
|
6964
6979
|
return {
|
|
6965
6980
|
version: 1,
|
|
@@ -6971,18 +6986,18 @@ async function loadCache(path23) {
|
|
|
6971
6986
|
return { version: 1, hashes: {} };
|
|
6972
6987
|
}
|
|
6973
6988
|
}
|
|
6974
|
-
async function saveCache(
|
|
6989
|
+
async function saveCache(path25, cache) {
|
|
6975
6990
|
const sorted = {};
|
|
6976
6991
|
for (const key of Object.keys(cache.hashes).sort()) {
|
|
6977
6992
|
const hash = cache.hashes[key];
|
|
6978
6993
|
if (!isSha256Hash(hash)) throw new Error(`Refusing to cache invalid SHA-256 for ${key}`);
|
|
6979
6994
|
sorted[key] = hash;
|
|
6980
6995
|
}
|
|
6981
|
-
await (0, import_promises9.mkdir)(import_node_path14.default.dirname(import_node_path14.default.resolve(
|
|
6982
|
-
const tmp = `${
|
|
6996
|
+
await (0, import_promises9.mkdir)(import_node_path14.default.dirname(import_node_path14.default.resolve(path25)), { recursive: true });
|
|
6997
|
+
const tmp = `${path25}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
6983
6998
|
try {
|
|
6984
6999
|
await (0, import_promises9.writeFile)(tmp, JSON.stringify({ version: 1, hashes: sorted }, null, 2) + "\n");
|
|
6985
|
-
await (0, import_promises9.rename)(tmp,
|
|
7000
|
+
await (0, import_promises9.rename)(tmp, path25);
|
|
6986
7001
|
} finally {
|
|
6987
7002
|
await (0, import_promises9.rm)(tmp, { force: true });
|
|
6988
7003
|
}
|
|
@@ -7813,7 +7828,7 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
7813
7828
|
` ${key} \u2192 ${jobId} (${spec.width}x${spec.height}` + (estimate.candidates > 1 ? `, ${estimate.candidates} ${spec.tileFeature ? "outputs" : "candidates"}` : "") + `, ${estimate.amount})`
|
|
7814
7829
|
);
|
|
7815
7830
|
} catch (err) {
|
|
7816
|
-
const
|
|
7831
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
7817
7832
|
const entry = lock.entries[key];
|
|
7818
7833
|
if (entry.jobId && entry.submissionComplete === true) {
|
|
7819
7834
|
upsert(lock, key, { status: "processing", error: null });
|
|
@@ -7825,10 +7840,10 @@ async function submit(provider, loaded, items, lock, lockPath, opts = {}) {
|
|
|
7825
7840
|
failed++;
|
|
7826
7841
|
upsert(lock, key, {
|
|
7827
7842
|
status: "failed",
|
|
7828
|
-
error:
|
|
7843
|
+
error: message6,
|
|
7829
7844
|
cost: entry.jobId ? estimate.amount : 0
|
|
7830
7845
|
});
|
|
7831
|
-
log(` FAILED ${key}: ${
|
|
7846
|
+
log(` FAILED ${key}: ${message6}`);
|
|
7832
7847
|
}
|
|
7833
7848
|
}
|
|
7834
7849
|
await saveLock(lockPath, lock);
|
|
@@ -8057,14 +8072,14 @@ async function fetchAssets(provider, specs, lock, lockPath, opts = {}) {
|
|
|
8057
8072
|
});
|
|
8058
8073
|
result.downloaded++;
|
|
8059
8074
|
} catch (err) {
|
|
8060
|
-
const
|
|
8075
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
8061
8076
|
upsert(lock, key, {
|
|
8062
8077
|
status: "download-failed",
|
|
8063
8078
|
outputs: mergeOutputs(entry.outputs, outputs),
|
|
8064
|
-
error: `download failed: ${
|
|
8079
|
+
error: `download failed: ${message6}`
|
|
8065
8080
|
});
|
|
8066
8081
|
result.failed++;
|
|
8067
|
-
log(` FAILED ${key}: ${
|
|
8082
|
+
log(` FAILED ${key}: ${message6}`);
|
|
8068
8083
|
}
|
|
8069
8084
|
await saveLock(lockPath, lock);
|
|
8070
8085
|
}
|
|
@@ -8134,7 +8149,7 @@ var import_promises12 = require("fs/promises");
|
|
|
8134
8149
|
var import_node_path18 = __toESM(require("path"), 1);
|
|
8135
8150
|
async function doctor(loaded, specs, lock, lockPath, opts = {}) {
|
|
8136
8151
|
const checks = [];
|
|
8137
|
-
const add = (id, level,
|
|
8152
|
+
const add = (id, level, message6) => checks.push({ id, level, message: message6 });
|
|
8138
8153
|
add(
|
|
8139
8154
|
"manifest",
|
|
8140
8155
|
"ok",
|
|
@@ -8438,9 +8453,107 @@ async function tagAdopted(provider, specs, lock, opts = {}) {
|
|
|
8438
8453
|
return count;
|
|
8439
8454
|
}
|
|
8440
8455
|
|
|
8456
|
+
// src/pick/server.ts
|
|
8457
|
+
var import_node_fs18 = require("fs");
|
|
8458
|
+
var import_promises16 = require("fs/promises");
|
|
8459
|
+
|
|
8460
|
+
// src/pipeline/init.ts
|
|
8461
|
+
var import_promises14 = require("fs/promises");
|
|
8462
|
+
var import_node_fs17 = require("fs");
|
|
8463
|
+
var import_node_path20 = __toESM(require("path"), 1);
|
|
8464
|
+
function pngSize(buf) {
|
|
8465
|
+
if (buf.length < 24) return null;
|
|
8466
|
+
const isPng = buf.readUInt32BE(0) === 2303741511 && buf.readUInt32BE(4) === 218765834;
|
|
8467
|
+
if (!isPng) return null;
|
|
8468
|
+
if (buf.toString("ascii", 12, 16) !== "IHDR") return null;
|
|
8469
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
8470
|
+
}
|
|
8471
|
+
function slugify(value) {
|
|
8472
|
+
return value.replace(/\.png$/i, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
|
|
8473
|
+
}
|
|
8474
|
+
async function walk(dir, exclude) {
|
|
8475
|
+
const out = [];
|
|
8476
|
+
for (const entry of await (0, import_promises14.readdir)(dir, { withFileTypes: true })) {
|
|
8477
|
+
const full = import_node_path20.default.join(dir, entry.name);
|
|
8478
|
+
if (exclude.some((x) => full.includes(x))) continue;
|
|
8479
|
+
if (entry.isDirectory()) out.push(...await walk(full, exclude));
|
|
8480
|
+
else if (entry.name.toLowerCase().endsWith(".png")) out.push(full);
|
|
8481
|
+
}
|
|
8482
|
+
return out;
|
|
8483
|
+
}
|
|
8484
|
+
async function scanAssets(root, opts = {}) {
|
|
8485
|
+
const exclude = opts.exclude ?? [];
|
|
8486
|
+
const files = await walk(root, exclude);
|
|
8487
|
+
const assets = [];
|
|
8488
|
+
const skipped = [];
|
|
8489
|
+
const seen = /* @__PURE__ */ new Map();
|
|
8490
|
+
for (const file of files.sort()) {
|
|
8491
|
+
const size = pngSize(await (0, import_promises14.readFile)(file));
|
|
8492
|
+
if (!size) {
|
|
8493
|
+
skipped.push(`${file} (not a readable PNG)`);
|
|
8494
|
+
continue;
|
|
8495
|
+
}
|
|
8496
|
+
const rel = import_node_path20.default.relative(root, file);
|
|
8497
|
+
const category = import_node_path20.default.dirname(rel) === "." ? "" : import_node_path20.default.dirname(rel);
|
|
8498
|
+
let id = slugify(import_node_path20.default.basename(rel));
|
|
8499
|
+
if (seen.has(id)) {
|
|
8500
|
+
const n = seen.get(id) + 1;
|
|
8501
|
+
seen.set(id, n);
|
|
8502
|
+
id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
|
|
8503
|
+
} else {
|
|
8504
|
+
seen.set(id, 1);
|
|
8505
|
+
}
|
|
8506
|
+
assets.push({ id, category, file: rel, width: size.width, height: size.height });
|
|
8507
|
+
}
|
|
8508
|
+
return { assets, skipped };
|
|
8509
|
+
}
|
|
8510
|
+
function buildManifest(name, styleId, generator, outDir, scanned) {
|
|
8511
|
+
const squares = scanned.filter((a) => a.width === a.height);
|
|
8512
|
+
const commonSize = squares.length ? mode(squares.map((a) => a.width)) : 64;
|
|
8513
|
+
const assets = {};
|
|
8514
|
+
for (const asset of scanned) {
|
|
8515
|
+
assets[asset.id] = {
|
|
8516
|
+
prompt: "",
|
|
8517
|
+
promptByStyle: {},
|
|
8518
|
+
category: asset.category || void 0,
|
|
8519
|
+
file: asset.file,
|
|
8520
|
+
tags: [],
|
|
8521
|
+
styles: [],
|
|
8522
|
+
providerInputs: {},
|
|
8523
|
+
...generator === "map" ? { width: asset.width, height: asset.height } : asset.width === asset.height && asset.width !== commonSize ? { size: asset.width } : {}
|
|
8524
|
+
};
|
|
8525
|
+
}
|
|
8526
|
+
return {
|
|
8527
|
+
name,
|
|
8528
|
+
provider: "pixellab",
|
|
8529
|
+
styles: {
|
|
8530
|
+
[styleId]: {
|
|
8531
|
+
generator,
|
|
8532
|
+
size: commonSize,
|
|
8533
|
+
// Matches the schema default: a scaffolded style is for sprites, and
|
|
8534
|
+
// a sprite wants its background stripped.
|
|
8535
|
+
noBackground: true,
|
|
8536
|
+
promptPrefix: "",
|
|
8537
|
+
promptSuffix: "",
|
|
8538
|
+
styleImages: [],
|
|
8539
|
+
palette: [],
|
|
8540
|
+
outDir,
|
|
8541
|
+
tags: [name],
|
|
8542
|
+
providerOptions: {}
|
|
8543
|
+
}
|
|
8544
|
+
},
|
|
8545
|
+
assets
|
|
8546
|
+
};
|
|
8547
|
+
}
|
|
8548
|
+
function mode(values) {
|
|
8549
|
+
const counts = /* @__PURE__ */ new Map();
|
|
8550
|
+
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
8551
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
|
|
8552
|
+
}
|
|
8553
|
+
|
|
8441
8554
|
// src/pick/sheet.ts
|
|
8442
8555
|
var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8443
|
-
function renderSheet(groups) {
|
|
8556
|
+
function renderSheet(groups, options = {}) {
|
|
8444
8557
|
const safe = groups.map((g) => ({
|
|
8445
8558
|
...g,
|
|
8446
8559
|
styleId: escapeHtml(g.styleId),
|
|
@@ -8454,6 +8567,11 @@ function renderSheet(groups) {
|
|
|
8454
8567
|
} : {}
|
|
8455
8568
|
}));
|
|
8456
8569
|
const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
|
|
8570
|
+
const apply = JSON.stringify({
|
|
8571
|
+
url: options.applyUrl ?? "/apply",
|
|
8572
|
+
headers: options.applyHeaders ?? {},
|
|
8573
|
+
embedded: options.embedded === true
|
|
8574
|
+
}).replace(/<\//g, "<\\/");
|
|
8457
8575
|
return `<!doctype html>
|
|
8458
8576
|
<html lang="en">
|
|
8459
8577
|
<head>
|
|
@@ -8496,7 +8614,7 @@ function renderSheet(groups) {
|
|
|
8496
8614
|
align-items:start; }
|
|
8497
8615
|
.review.no-context { display:block; }
|
|
8498
8616
|
.context { border:1px solid var(--line); padding:8px; background:var(--panel-deep);
|
|
8499
|
-
min-width:0; }
|
|
8617
|
+
min-width:0; display:grid; gap:12px; align-content:start; }
|
|
8500
8618
|
.context-label { display:flex; justify-content:space-between; gap:8px; margin-bottom:7px;
|
|
8501
8619
|
color:var(--dim); font-size:10.5px; font-variant-numeric:tabular-nums; }
|
|
8502
8620
|
.context img { display:block; width:100%; height:auto; max-height:420px; object-fit:contain;
|
|
@@ -8565,6 +8683,7 @@ function renderSheet(groups) {
|
|
|
8565
8683
|
</footer>
|
|
8566
8684
|
<script>
|
|
8567
8685
|
const GROUPS = ${data};
|
|
8686
|
+
const APPLY = ${apply};
|
|
8568
8687
|
const picks = new Map();
|
|
8569
8688
|
const root = document.getElementById('root');
|
|
8570
8689
|
document.getElementById('total').textContent = GROUPS.length;
|
|
@@ -8585,24 +8704,40 @@ GROUPS.forEach((g, gi) => {
|
|
|
8585
8704
|
'<div class="ghead"><span class="style">' + g.styleId + '</span>' +
|
|
8586
8705
|
'<span class="aid">' + g.assetId + '</span>' +
|
|
8587
8706
|
'<span class="prompt">' + g.prompt + '</span></div>' +
|
|
8588
|
-
'<div class="review' + (g.revision ? '' : ' no-context') + '">' +
|
|
8707
|
+
'<div class="review' + (g.revision || g.current ? '' : ' no-context') + '">' +
|
|
8589
8708
|
'<div class="frames"></div></div>';
|
|
8590
8709
|
const review = el.querySelector('.review');
|
|
8591
8710
|
const frames = el.querySelector('.frames');
|
|
8592
|
-
|
|
8593
|
-
const
|
|
8594
|
-
context.className = 'context';
|
|
8711
|
+
const contextPanel = (identityText, dimensionsText, url, alt) => {
|
|
8712
|
+
const block = document.createElement('div');
|
|
8595
8713
|
const label = document.createElement('div');
|
|
8596
8714
|
label.className = 'context-label';
|
|
8597
8715
|
const identity = document.createElement('span');
|
|
8598
|
-
identity.textContent =
|
|
8716
|
+
identity.textContent = identityText;
|
|
8599
8717
|
const dimensions = document.createElement('span');
|
|
8600
|
-
dimensions.textContent =
|
|
8718
|
+
dimensions.textContent = dimensionsText;
|
|
8601
8719
|
label.append(identity, dimensions);
|
|
8602
|
-
const
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8720
|
+
const img = document.createElement('img');
|
|
8721
|
+
img.src = url;
|
|
8722
|
+
img.alt = alt;
|
|
8723
|
+
block.append(label, img);
|
|
8724
|
+
return block;
|
|
8725
|
+
};
|
|
8726
|
+
if (g.revision || g.current) {
|
|
8727
|
+
const context = document.createElement('aside');
|
|
8728
|
+
context.className = 'context';
|
|
8729
|
+
if (g.revision) {
|
|
8730
|
+
context.append(contextPanel(
|
|
8731
|
+
g.revision.mode.toUpperCase() + ' \xB7 SOURCE ' + g.revision.sourceAssetId,
|
|
8732
|
+
g.revision.width + '\xD7' + g.revision.height,
|
|
8733
|
+
g.revision.sourceUrl, 'Revision source ' + g.revision.sourceAssetId));
|
|
8734
|
+
}
|
|
8735
|
+
if (g.current) {
|
|
8736
|
+
context.append(contextPanel(
|
|
8737
|
+
'CURRENT ART \xB7 replaced if you choose',
|
|
8738
|
+
g.current.width + '\xD7' + g.current.height,
|
|
8739
|
+
g.current.url, 'Current ' + g.assetId));
|
|
8740
|
+
}
|
|
8606
8741
|
review.insertBefore(context, frames);
|
|
8607
8742
|
}
|
|
8608
8743
|
if (g.mode === 'frame-set') {
|
|
@@ -8786,13 +8921,17 @@ document.getElementById('submit').onclick = async () => {
|
|
|
8786
8921
|
status.textContent = 'applying\u2026';
|
|
8787
8922
|
const selections = [...picks.entries()].map(([gi, index]) => ({ key: GROUPS[gi].key, index }));
|
|
8788
8923
|
try {
|
|
8789
|
-
const res = await fetch(
|
|
8790
|
-
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
8924
|
+
const res = await fetch(APPLY.url, {
|
|
8925
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', ...APPLY.headers },
|
|
8791
8926
|
body: JSON.stringify({ selections }),
|
|
8792
8927
|
});
|
|
8793
8928
|
if (!res.ok) throw new Error(await res.text());
|
|
8794
|
-
|
|
8929
|
+
const result = await res.json();
|
|
8930
|
+
status.textContent = APPLY.embedded ? 'applied' : 'done \u2014 you can close this tab';
|
|
8795
8931
|
document.body.style.opacity = '.6';
|
|
8932
|
+
if (APPLY.embedded && window.parent !== window) {
|
|
8933
|
+
window.parent.postMessage({ type: 'pixelkiln:review-applied', result }, location.origin);
|
|
8934
|
+
}
|
|
8796
8935
|
} catch (err) {
|
|
8797
8936
|
status.textContent = 'failed: ' + err.message;
|
|
8798
8937
|
btn.disabled = false;
|
|
@@ -8806,7 +8945,7 @@ document.getElementById('submit').onclick = async () => {
|
|
|
8806
8945
|
// src/pick/review-server.ts
|
|
8807
8946
|
var import_node_http = require("http");
|
|
8808
8947
|
var import_node_child_process2 = require("child_process");
|
|
8809
|
-
var
|
|
8948
|
+
var import_promises15 = require("fs/promises");
|
|
8810
8949
|
function serveReviewPage(opts) {
|
|
8811
8950
|
const log = opts.onProgress ?? (() => {
|
|
8812
8951
|
});
|
|
@@ -8815,7 +8954,7 @@ function serveReviewPage(opts) {
|
|
|
8815
8954
|
if (req.method === "GET" && req.url && opts.assets?.has(req.url)) {
|
|
8816
8955
|
try {
|
|
8817
8956
|
const asset = opts.assets.get(req.url);
|
|
8818
|
-
const bytes = await (0,
|
|
8957
|
+
const bytes = await (0, import_promises15.readFile)(asset.path);
|
|
8819
8958
|
res.writeHead(200, {
|
|
8820
8959
|
"Content-Type": asset.contentType,
|
|
8821
8960
|
"Cache-Control": "no-store",
|
|
@@ -8864,10 +9003,10 @@ function serveReviewPage(opts) {
|
|
|
8864
9003
|
res.end(JSON.stringify(result));
|
|
8865
9004
|
server.close(() => resolve(result));
|
|
8866
9005
|
} catch (err) {
|
|
8867
|
-
const
|
|
8868
|
-
log(` apply failed: ${
|
|
9006
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
9007
|
+
log(` apply failed: ${message6}`);
|
|
8869
9008
|
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
8870
|
-
res.end(
|
|
9009
|
+
res.end(message6);
|
|
8871
9010
|
}
|
|
8872
9011
|
return;
|
|
8873
9012
|
}
|
|
@@ -8887,7 +9026,23 @@ function serveReviewPage(opts) {
|
|
|
8887
9026
|
}
|
|
8888
9027
|
|
|
8889
9028
|
// src/pick/server.ts
|
|
8890
|
-
async function
|
|
9029
|
+
async function currentArt(entry, spec) {
|
|
9030
|
+
if (!spec || !entry.supersededOutputs?.length) return null;
|
|
9031
|
+
const previous = primaryOutput({ ...entry, outputs: entry.supersededOutputs });
|
|
9032
|
+
if (!previous) return null;
|
|
9033
|
+
const file = resolveOutputPath(previous.path, spec.root);
|
|
9034
|
+
if (!(0, import_node_fs18.existsSync)(file)) return null;
|
|
9035
|
+
const contentType = previous.mediaType ?? mediaTypeFromExtension(file);
|
|
9036
|
+
if (!contentType) return null;
|
|
9037
|
+
let width = entry.width;
|
|
9038
|
+
let height = entry.height;
|
|
9039
|
+
if (contentType === "image/png") {
|
|
9040
|
+
const size = pngSize(await (0, import_promises16.readFile)(file));
|
|
9041
|
+
if (size) ({ width, height } = size);
|
|
9042
|
+
}
|
|
9043
|
+
return { path: file, contentType, width, height };
|
|
9044
|
+
}
|
|
9045
|
+
async function prepareReview(provider, lock, opts = {}) {
|
|
8891
9046
|
const log = opts.onProgress ?? (() => {
|
|
8892
9047
|
});
|
|
8893
9048
|
const selectedKeys = opts.keys ? new Set(opts.keys) : null;
|
|
@@ -8895,6 +9050,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
8895
9050
|
(opts.specs ?? []).map((spec) => [lockKey(spec.styleId, spec.assetId), spec])
|
|
8896
9051
|
);
|
|
8897
9052
|
const reviewAssets = /* @__PURE__ */ new Map();
|
|
9053
|
+
const prefix = opts.routePrefix ?? "";
|
|
8898
9054
|
const groups = [];
|
|
8899
9055
|
for (const [key, entry] of Object.entries(lock.entries)) {
|
|
8900
9056
|
if (entry.provider !== provider.id || selectedKeys && !selectedKeys.has(key)) continue;
|
|
@@ -8904,13 +9060,18 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
8904
9060
|
const state = await provider.poll(entry.reviewObjectId, entry.generator, { spec });
|
|
8905
9061
|
if (state.status !== "review" && state.status !== "review-set" || (state.status === "review" ? !state.candidateUrls.length : !state.frameUrls.length)) continue;
|
|
8906
9062
|
const frameUrls = state.status === "review" ? state.candidateUrls : state.frameUrls;
|
|
8907
|
-
const sourceRoute = spec?.revision ?
|
|
9063
|
+
const sourceRoute = spec?.revision ? `${prefix}/revision-source/${encodeURIComponent(String(groups.length))}` : null;
|
|
8908
9064
|
if (sourceRoute && spec?.revision) {
|
|
8909
9065
|
reviewAssets.set(sourceRoute, {
|
|
8910
9066
|
path: spec.revision.sourceFile,
|
|
8911
9067
|
contentType: spec.revision.sourceFormat === "jpeg" ? "image/jpeg" : "image/png"
|
|
8912
9068
|
});
|
|
8913
9069
|
}
|
|
9070
|
+
const current = await currentArt(entry, spec);
|
|
9071
|
+
const currentRoute = current ? `${prefix}/current-art/${encodeURIComponent(String(groups.length))}` : null;
|
|
9072
|
+
if (current && currentRoute) {
|
|
9073
|
+
reviewAssets.set(currentRoute, { path: current.path, contentType: current.contentType });
|
|
9074
|
+
}
|
|
8914
9075
|
groups.push({
|
|
8915
9076
|
key,
|
|
8916
9077
|
assetId: entry.assetId,
|
|
@@ -8933,33 +9094,20 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
8933
9094
|
width: spec.revision.sourceWidth,
|
|
8934
9095
|
height: spec.revision.sourceHeight
|
|
8935
9096
|
}
|
|
8936
|
-
} : {}
|
|
9097
|
+
} : {},
|
|
9098
|
+
...current && currentRoute ? { current: { url: currentRoute, width: current.width, height: current.height } } : {}
|
|
8937
9099
|
});
|
|
8938
9100
|
} catch (err) {
|
|
8939
9101
|
log(` could not load candidates for ${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8940
9102
|
}
|
|
8941
9103
|
}
|
|
8942
|
-
if (groups.length === 0) return
|
|
8943
|
-
const html = renderSheet(groups);
|
|
9104
|
+
if (groups.length === 0) return null;
|
|
8944
9105
|
const byKey = new Map(groups.map((g) => [g.key, g]));
|
|
8945
|
-
return
|
|
8946
|
-
|
|
8947
|
-
port: opts.port,
|
|
8948
|
-
open: opts.open,
|
|
8949
|
-
onProgress: log,
|
|
9106
|
+
return {
|
|
9107
|
+
groups,
|
|
8950
9108
|
assets: reviewAssets,
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
if (opts.onReady) {
|
|
8954
|
-
opts.onReady(info);
|
|
8955
|
-
} else {
|
|
8956
|
-
log(`
|
|
8957
|
-
${groups.length} asset(s) awaiting selection: ${url}`);
|
|
8958
|
-
log(` (leave this running; it exits once you apply)
|
|
8959
|
-
`);
|
|
8960
|
-
}
|
|
8961
|
-
},
|
|
8962
|
-
handleApply: async (body) => {
|
|
9109
|
+
html: (options) => renderSheet(groups, options),
|
|
9110
|
+
apply: async (body, lockPath) => {
|
|
8963
9111
|
const { selections } = body;
|
|
8964
9112
|
let selected = 0;
|
|
8965
9113
|
for (const { key, index } of selections) {
|
|
@@ -9019,117 +9167,3120 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
9019
9167
|
await saveLock(lockPath, lock);
|
|
9020
9168
|
return { selected, skipped: groups.length - selected };
|
|
9021
9169
|
}
|
|
9170
|
+
};
|
|
9171
|
+
}
|
|
9172
|
+
async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
9173
|
+
const log = opts.onProgress ?? (() => {
|
|
9174
|
+
});
|
|
9175
|
+
const session = await prepareReview(provider, lock, {
|
|
9176
|
+
onProgress: log,
|
|
9177
|
+
keys: opts.keys,
|
|
9178
|
+
specs: opts.specs
|
|
9179
|
+
});
|
|
9180
|
+
if (!session) return { selected: 0, skipped: 0 };
|
|
9181
|
+
const groups = session.groups;
|
|
9182
|
+
return serveReviewPage({
|
|
9183
|
+
html: session.html(),
|
|
9184
|
+
port: opts.port,
|
|
9185
|
+
open: opts.open,
|
|
9186
|
+
onProgress: log,
|
|
9187
|
+
assets: session.assets,
|
|
9188
|
+
onReady: (url) => {
|
|
9189
|
+
const info = { url, keys: groups.map((group) => group.key) };
|
|
9190
|
+
if (opts.onReady) {
|
|
9191
|
+
opts.onReady(info);
|
|
9192
|
+
} else {
|
|
9193
|
+
log(`
|
|
9194
|
+
${groups.length} asset(s) awaiting selection: ${url}`);
|
|
9195
|
+
log(` (leave this running; it exits once you apply)
|
|
9196
|
+
`);
|
|
9197
|
+
}
|
|
9198
|
+
},
|
|
9199
|
+
handleApply: (body) => session.apply(body, lockPath)
|
|
9022
9200
|
});
|
|
9023
9201
|
}
|
|
9024
9202
|
|
|
9025
|
-
// src/
|
|
9026
|
-
var
|
|
9027
|
-
var
|
|
9028
|
-
var
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
|
|
9037
|
-
|
|
9203
|
+
// src/gallery/snapshot.ts
|
|
9204
|
+
var import_node_crypto6 = require("crypto");
|
|
9205
|
+
var import_node_fs20 = require("fs");
|
|
9206
|
+
var import_promises19 = require("fs/promises");
|
|
9207
|
+
var import_node_path23 = __toESM(require("path"), 1);
|
|
9208
|
+
|
|
9209
|
+
// src/workspace.ts
|
|
9210
|
+
var import_promises17 = require("fs/promises");
|
|
9211
|
+
var import_node_fs19 = require("fs");
|
|
9212
|
+
var import_node_path21 = __toESM(require("path"), 1);
|
|
9213
|
+
var import_zod6 = require("zod");
|
|
9214
|
+
var WorkspaceProjectSchema = import_zod6.z.object({
|
|
9215
|
+
id: import_zod6.z.string().min(1),
|
|
9216
|
+
/** Manifest path, relative to the catalog file's own directory. */
|
|
9217
|
+
manifest: import_zod6.z.string().min(1),
|
|
9218
|
+
/** Lockfile path, relative to the catalog file's own directory. */
|
|
9219
|
+
lock: import_zod6.z.string().min(1),
|
|
9220
|
+
provider: import_zod6.z.string().min(1).default("pixellab"),
|
|
9221
|
+
/** Free-form label for a shared account, e.g. distinguishing sandboxes. */
|
|
9222
|
+
account: import_zod6.z.string().optional()
|
|
9223
|
+
}).strict();
|
|
9224
|
+
var WorkspaceSchema = import_zod6.z.object({
|
|
9225
|
+
version: import_zod6.z.literal(1),
|
|
9226
|
+
projects: import_zod6.z.array(WorkspaceProjectSchema).default([])
|
|
9227
|
+
}).strict();
|
|
9228
|
+
function parseWorkspace(raw) {
|
|
9229
|
+
const parsed = WorkspaceSchema.safeParse(raw);
|
|
9230
|
+
if (parsed.success) return parsed.data;
|
|
9231
|
+
throw new Error(
|
|
9232
|
+
`Workspace catalog is not valid v1:
|
|
9233
|
+
${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
|
|
9234
|
+
);
|
|
9038
9235
|
}
|
|
9039
|
-
async function
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
9236
|
+
async function loadWorkspace(workspacePath) {
|
|
9237
|
+
if (!(0, import_node_fs19.existsSync)(workspacePath)) return { version: 1, projects: [] };
|
|
9238
|
+
let raw;
|
|
9239
|
+
try {
|
|
9240
|
+
raw = JSON.parse(await (0, import_promises17.readFile)(workspacePath, "utf8"));
|
|
9241
|
+
} catch (err) {
|
|
9242
|
+
throw new Error(
|
|
9243
|
+
`Workspace catalog at ${workspacePath} is malformed:
|
|
9244
|
+
${err instanceof Error ? err.message : String(err)}`
|
|
9245
|
+
);
|
|
9046
9246
|
}
|
|
9047
|
-
return
|
|
9247
|
+
return parseWorkspace(raw);
|
|
9048
9248
|
}
|
|
9049
|
-
async function
|
|
9050
|
-
const
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
}
|
|
9061
|
-
const rel = import_node_path20.default.relative(root, file);
|
|
9062
|
-
const category = import_node_path20.default.dirname(rel) === "." ? "" : import_node_path20.default.dirname(rel);
|
|
9063
|
-
let id = slugify(import_node_path20.default.basename(rel));
|
|
9064
|
-
if (seen.has(id)) {
|
|
9065
|
-
const n = seen.get(id) + 1;
|
|
9066
|
-
seen.set(id, n);
|
|
9067
|
-
id = `${slugify(category.replace(/[/\\]/g, "_"))}_${id}`.replace(/^_+/, "");
|
|
9068
|
-
} else {
|
|
9069
|
-
seen.set(id, 1);
|
|
9070
|
-
}
|
|
9071
|
-
assets.push({ id, category, file: rel, width: size.width, height: size.height });
|
|
9249
|
+
async function saveWorkspace(workspacePath, ws) {
|
|
9250
|
+
const sorted = {
|
|
9251
|
+
version: 1,
|
|
9252
|
+
projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
|
|
9253
|
+
};
|
|
9254
|
+
await (0, import_promises17.mkdir)(import_node_path21.default.dirname(import_node_path21.default.resolve(workspacePath)), { recursive: true });
|
|
9255
|
+
const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
9256
|
+
try {
|
|
9257
|
+
await (0, import_promises17.writeFile)(tmp, JSON.stringify(sorted, null, 2) + "\n");
|
|
9258
|
+
await (0, import_promises17.rename)(tmp, workspacePath);
|
|
9259
|
+
} finally {
|
|
9260
|
+
await (0, import_promises17.rm)(tmp, { force: true });
|
|
9072
9261
|
}
|
|
9073
|
-
return { assets, skipped };
|
|
9074
9262
|
}
|
|
9075
|
-
function
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
9263
|
+
function toPortablePath(dir, absolute) {
|
|
9264
|
+
return import_node_path21.default.relative(dir, absolute).split(import_node_path21.default.sep).join("/");
|
|
9265
|
+
}
|
|
9266
|
+
function resolveProject(dir, project) {
|
|
9267
|
+
return {
|
|
9268
|
+
manifestPath: import_node_path21.default.resolve(dir, project.manifest.split("/").join(import_node_path21.default.sep)),
|
|
9269
|
+
lockPath: import_node_path21.default.resolve(dir, project.lock.split("/").join(import_node_path21.default.sep))
|
|
9270
|
+
};
|
|
9271
|
+
}
|
|
9272
|
+
function validateWorkspace(ws, dir) {
|
|
9273
|
+
const diagnostics = [];
|
|
9274
|
+
const idCounts = /* @__PURE__ */ new Map();
|
|
9275
|
+
const lockOwners = /* @__PURE__ */ new Map();
|
|
9276
|
+
const manifestOwners = /* @__PURE__ */ new Map();
|
|
9277
|
+
for (const project of ws.projects) {
|
|
9278
|
+
idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
|
|
9279
|
+
const { manifestPath, lockPath } = resolveProject(dir, project);
|
|
9280
|
+
lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
|
|
9281
|
+
manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
|
|
9282
|
+
if (import_node_path21.default.isAbsolute(project.manifest) || import_node_path21.default.isAbsolute(project.lock)) {
|
|
9283
|
+
diagnostics.push({
|
|
9284
|
+
id: "absolute-path",
|
|
9285
|
+
level: "warning",
|
|
9286
|
+
message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
|
|
9287
|
+
});
|
|
9288
|
+
}
|
|
9289
|
+
if (!(0, import_node_fs19.existsSync)(manifestPath)) {
|
|
9290
|
+
diagnostics.push({
|
|
9291
|
+
id: "missing-manifest",
|
|
9292
|
+
level: "error",
|
|
9293
|
+
message: `project "${project.id}" manifest not found: ${manifestPath}`
|
|
9294
|
+
});
|
|
9295
|
+
}
|
|
9296
|
+
if (!(0, import_node_fs19.existsSync)(lockPath)) {
|
|
9297
|
+
diagnostics.push({
|
|
9298
|
+
id: "missing-lock",
|
|
9299
|
+
level: "error",
|
|
9300
|
+
message: `project "${project.id}" lockfile not found: ${lockPath}`
|
|
9301
|
+
});
|
|
9302
|
+
}
|
|
9303
|
+
}
|
|
9304
|
+
for (const [id, count] of idCounts) {
|
|
9305
|
+
if (count > 1) {
|
|
9306
|
+
diagnostics.push({
|
|
9307
|
+
id: "duplicate-id",
|
|
9308
|
+
level: "error",
|
|
9309
|
+
message: `project id "${id}" is registered ${count} times`
|
|
9310
|
+
});
|
|
9311
|
+
}
|
|
9312
|
+
}
|
|
9313
|
+
for (const [lockPath, ids] of lockOwners) {
|
|
9314
|
+
if (ids.length > 1) {
|
|
9315
|
+
diagnostics.push({
|
|
9316
|
+
id: "duplicate-lock",
|
|
9317
|
+
level: "error",
|
|
9318
|
+
message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
|
|
9319
|
+
});
|
|
9320
|
+
}
|
|
9321
|
+
}
|
|
9322
|
+
for (const [manifestPath, ids] of manifestOwners) {
|
|
9323
|
+
if (ids.length > 1) {
|
|
9324
|
+
diagnostics.push({
|
|
9325
|
+
id: "duplicate-manifest",
|
|
9326
|
+
level: "warning",
|
|
9327
|
+
message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
|
|
9328
|
+
});
|
|
9329
|
+
}
|
|
9330
|
+
}
|
|
9331
|
+
const providers = new Set(ws.projects.map((p) => p.provider));
|
|
9332
|
+
if (providers.size > 1) {
|
|
9333
|
+
diagnostics.push({
|
|
9334
|
+
id: "mixed-provider",
|
|
9335
|
+
level: "warning",
|
|
9336
|
+
message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
|
|
9337
|
+
});
|
|
9338
|
+
}
|
|
9339
|
+
return diagnostics;
|
|
9340
|
+
}
|
|
9341
|
+
|
|
9342
|
+
// src/gallery/edit.ts
|
|
9343
|
+
var import_promises18 = require("fs/promises");
|
|
9344
|
+
var import_node_path22 = __toESM(require("path"), 1);
|
|
9345
|
+
var import_zod7 = require("zod");
|
|
9346
|
+
var HexSha = import_zod7.z.string().regex(/^[0-9a-f]{64}$/);
|
|
9347
|
+
var AssetPatchSchema = import_zod7.z.object({
|
|
9348
|
+
prompt: import_zod7.z.string().optional(),
|
|
9349
|
+
/** Per-style override for the style being viewed; null removes it. */
|
|
9350
|
+
promptForStyle: import_zod7.z.object({ styleId: import_zod7.z.string().min(1), prompt: import_zod7.z.string().nullable() }).optional(),
|
|
9351
|
+
category: import_zod7.z.string().nullable().optional(),
|
|
9352
|
+
width: import_zod7.z.number().int().nullable().optional(),
|
|
9353
|
+
height: import_zod7.z.number().int().nullable().optional(),
|
|
9354
|
+
size: import_zod7.z.number().int().nullable().optional(),
|
|
9355
|
+
tags: import_zod7.z.array(import_zod7.z.string()).optional()
|
|
9356
|
+
}).strict();
|
|
9357
|
+
var NewAssetSchema = import_zod7.z.object({
|
|
9358
|
+
prompt: import_zod7.z.string(),
|
|
9359
|
+
category: import_zod7.z.string().optional(),
|
|
9360
|
+
width: import_zod7.z.number().int().optional(),
|
|
9361
|
+
height: import_zod7.z.number().int().optional(),
|
|
9362
|
+
size: import_zod7.z.number().int().optional(),
|
|
9363
|
+
tags: import_zod7.z.array(import_zod7.z.string()).optional(),
|
|
9364
|
+
/** Restrict to these styles; empty or omitted means every style. */
|
|
9365
|
+
styles: import_zod7.z.array(import_zod7.z.string().min(1)).optional()
|
|
9366
|
+
}).strict();
|
|
9367
|
+
var CANDIDATE_OPTION = {
|
|
9368
|
+
retrodiffusion: "numImages",
|
|
9369
|
+
comfyui: "numImages",
|
|
9370
|
+
scenario: "numOutputs"
|
|
9371
|
+
};
|
|
9372
|
+
var ManifestEditSchema = import_zod7.z.discriminatedUnion("action", [
|
|
9373
|
+
import_zod7.z.object({
|
|
9374
|
+
action: import_zod7.z.literal("patch-asset"),
|
|
9375
|
+
/** Workspace project id; omitted for a single-project gallery. */
|
|
9376
|
+
project: import_zod7.z.string().min(1).optional(),
|
|
9377
|
+
assetId: import_zod7.z.string().min(1),
|
|
9378
|
+
/** Manifest bytes the page last saw; a mismatch refuses the write. */
|
|
9379
|
+
expectedSha256: HexSha,
|
|
9380
|
+
patch: AssetPatchSchema
|
|
9381
|
+
}).strict(),
|
|
9382
|
+
import_zod7.z.object({
|
|
9383
|
+
action: import_zod7.z.literal("add-asset"),
|
|
9384
|
+
project: import_zod7.z.string().min(1).optional(),
|
|
9385
|
+
assetId: import_zod7.z.string().min(1).regex(/^[^/\\]+$/, "asset ids cannot contain slashes"),
|
|
9386
|
+
expectedSha256: HexSha,
|
|
9387
|
+
asset: NewAssetSchema
|
|
9388
|
+
}).strict(),
|
|
9389
|
+
import_zod7.z.object({
|
|
9390
|
+
action: import_zod7.z.literal("patch-style"),
|
|
9391
|
+
project: import_zod7.z.string().min(1).optional(),
|
|
9392
|
+
styleId: import_zod7.z.string().min(1),
|
|
9393
|
+
expectedSha256: HexSha,
|
|
9394
|
+
patch: import_zod7.z.object({
|
|
9395
|
+
/** Candidates per generation, written to the provider's own option. */
|
|
9396
|
+
candidates: import_zod7.z.number().int().min(1).max(64).optional(),
|
|
9397
|
+
/** Empty clears the style's own value (inherit, or the default). */
|
|
9398
|
+
promptPrefix: import_zod7.z.string().optional(),
|
|
9399
|
+
promptSuffix: import_zod7.z.string().optional(),
|
|
9400
|
+
/** `#rrggbb` values; null or empty clears the style's own palette. */
|
|
9401
|
+
palette: import_zod7.z.array(import_zod7.z.string().regex(/^#?[0-9a-f]{6}$/i, "expected a six-digit hex colour")).max(256).nullable().optional()
|
|
9402
|
+
}).strict().refine((patch) => Object.keys(patch).length > 0, { message: "nothing to change" })
|
|
9403
|
+
}).strict()
|
|
9404
|
+
]);
|
|
9405
|
+
var ManifestDriftError = class extends Error {
|
|
9406
|
+
status = 409;
|
|
9407
|
+
constructor(manifestPath) {
|
|
9408
|
+
super(`${manifestPath} changed on disk since this page loaded; refresh and try again`);
|
|
9409
|
+
this.name = "ManifestDriftError";
|
|
9410
|
+
}
|
|
9411
|
+
};
|
|
9412
|
+
var ManifestEditError = class extends Error {
|
|
9413
|
+
status = 400;
|
|
9414
|
+
constructor(message6) {
|
|
9415
|
+
super(message6);
|
|
9416
|
+
this.name = "ManifestEditError";
|
|
9417
|
+
}
|
|
9418
|
+
};
|
|
9419
|
+
function serializeLike(original, value) {
|
|
9420
|
+
const indentMatch = /\n([ \t]+)"/.exec(original);
|
|
9421
|
+
const indent = indentMatch ? indentMatch[1].startsWith(" ") ? " " : indentMatch[1].length : 2;
|
|
9422
|
+
return JSON.stringify(value, null, indent) + (original.endsWith("\n") ? "\n" : "");
|
|
9423
|
+
}
|
|
9424
|
+
function styleProvider(raw, styleId, seen = /* @__PURE__ */ new Set()) {
|
|
9425
|
+
const style = raw.styles?.[styleId];
|
|
9426
|
+
if (!style || seen.has(styleId)) return raw.provider ?? "pixellab";
|
|
9427
|
+
if (style.provider) return style.provider;
|
|
9428
|
+
seen.add(styleId);
|
|
9429
|
+
return style.extends ? styleProvider(raw, style.extends, seen) : raw.provider ?? "pixellab";
|
|
9430
|
+
}
|
|
9431
|
+
function setOrDelete(target, key, value) {
|
|
9432
|
+
if (value === null || value === void 0) delete target[key];
|
|
9433
|
+
else target[key] = value;
|
|
9434
|
+
}
|
|
9435
|
+
function applyEdit(raw, edit) {
|
|
9436
|
+
raw.assets ??= {};
|
|
9437
|
+
if (edit.action === "patch-style") {
|
|
9438
|
+
const style = raw.styles && Object.hasOwn(raw.styles, edit.styleId) ? raw.styles[edit.styleId] : void 0;
|
|
9439
|
+
if (!style) throw new ManifestEditError(`style "${edit.styleId}" is not declared by the manifest`);
|
|
9440
|
+
const { patch: patch2 } = edit;
|
|
9441
|
+
if (patch2.candidates !== void 0) {
|
|
9442
|
+
const provider = styleProvider(raw, edit.styleId);
|
|
9443
|
+
const option = CANDIDATE_OPTION[provider];
|
|
9444
|
+
if (!option) {
|
|
9445
|
+
throw new ManifestEditError(
|
|
9446
|
+
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`
|
|
9447
|
+
);
|
|
9448
|
+
}
|
|
9449
|
+
const options = { ...style.providerOptions ?? {} };
|
|
9450
|
+
options[provider] = { ...options[provider] ?? {}, [option]: patch2.candidates };
|
|
9451
|
+
style.providerOptions = options;
|
|
9452
|
+
}
|
|
9453
|
+
if (patch2.promptPrefix !== void 0) setOrDelete(style, "promptPrefix", patch2.promptPrefix || null);
|
|
9454
|
+
if (patch2.promptSuffix !== void 0) setOrDelete(style, "promptSuffix", patch2.promptSuffix || null);
|
|
9455
|
+
if (patch2.palette !== void 0) {
|
|
9456
|
+
const colors = (patch2.palette ?? []).map((color) => "#" + color.replace(/^#/, "").toLowerCase());
|
|
9457
|
+
setOrDelete(style, "palette", colors.length ? colors : null);
|
|
9458
|
+
}
|
|
9459
|
+
return;
|
|
9460
|
+
}
|
|
9461
|
+
if (edit.action === "add-asset") {
|
|
9462
|
+
if (Object.hasOwn(raw.assets, edit.assetId)) {
|
|
9463
|
+
throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
|
|
9464
|
+
}
|
|
9465
|
+
const { styles, tags, ...rest } = edit.asset;
|
|
9466
|
+
const asset2 = { ...rest };
|
|
9467
|
+
if (tags?.length) asset2.tags = tags;
|
|
9468
|
+
if (styles?.length) {
|
|
9469
|
+
for (const styleId of styles) {
|
|
9470
|
+
if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
|
|
9471
|
+
throw new ManifestEditError(`unknown style "${styleId}"`);
|
|
9472
|
+
}
|
|
9473
|
+
}
|
|
9474
|
+
asset2.styles = styles;
|
|
9475
|
+
}
|
|
9476
|
+
raw.assets[edit.assetId] = asset2;
|
|
9477
|
+
return;
|
|
9478
|
+
}
|
|
9479
|
+
const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
|
|
9480
|
+
if (!asset) throw new ManifestEditError(`asset "${edit.assetId}" is not declared by the manifest`);
|
|
9481
|
+
const { patch } = edit;
|
|
9482
|
+
if (patch.prompt !== void 0) asset.prompt = patch.prompt;
|
|
9483
|
+
if (patch.promptForStyle) {
|
|
9484
|
+
const { styleId, prompt } = patch.promptForStyle;
|
|
9485
|
+
if (!raw.styles || !Object.hasOwn(raw.styles, styleId)) {
|
|
9486
|
+
throw new ManifestEditError(`unknown style "${styleId}"`);
|
|
9487
|
+
}
|
|
9488
|
+
const byStyle = { ...asset.promptByStyle ?? {} };
|
|
9489
|
+
if (prompt === null) delete byStyle[styleId];
|
|
9490
|
+
else byStyle[styleId] = prompt;
|
|
9491
|
+
if (Object.keys(byStyle).length) asset.promptByStyle = byStyle;
|
|
9492
|
+
else delete asset.promptByStyle;
|
|
9493
|
+
}
|
|
9494
|
+
if ("category" in patch) setOrDelete(asset, "category", patch.category);
|
|
9495
|
+
if ("width" in patch) setOrDelete(asset, "width", patch.width);
|
|
9496
|
+
if ("height" in patch) setOrDelete(asset, "height", patch.height);
|
|
9497
|
+
if ("size" in patch) setOrDelete(asset, "size", patch.size);
|
|
9498
|
+
if (patch.tags !== void 0) {
|
|
9499
|
+
if (patch.tags.length) asset.tags = patch.tags;
|
|
9500
|
+
else delete asset.tags;
|
|
9501
|
+
}
|
|
9502
|
+
}
|
|
9503
|
+
async function applyManifestEdit(manifestPath, edit) {
|
|
9504
|
+
const absolute = import_node_path22.default.resolve(manifestPath);
|
|
9505
|
+
const original = await (0, import_promises18.readFile)(absolute, "utf8");
|
|
9506
|
+
const before = sha256(original);
|
|
9507
|
+
if (before !== edit.expectedSha256) throw new ManifestDriftError(absolute);
|
|
9508
|
+
let raw;
|
|
9509
|
+
try {
|
|
9510
|
+
raw = JSON.parse(original);
|
|
9511
|
+
} catch (error) {
|
|
9512
|
+
throw new ManifestEditError(
|
|
9513
|
+
`manifest is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
9514
|
+
);
|
|
9515
|
+
}
|
|
9516
|
+
applyEdit(raw, edit);
|
|
9517
|
+
const next = serializeLike(original, raw);
|
|
9518
|
+
if (next === original) return { manifestPath: absolute, sha256: before, changed: false };
|
|
9519
|
+
const tmp = import_node_path22.default.join(
|
|
9520
|
+
import_node_path22.default.dirname(absolute),
|
|
9521
|
+
`.${import_node_path22.default.basename(absolute)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
|
|
9522
|
+
);
|
|
9523
|
+
try {
|
|
9524
|
+
await (0, import_promises18.writeFile)(tmp, next);
|
|
9525
|
+
try {
|
|
9526
|
+
const loaded = await loadManifest(tmp);
|
|
9527
|
+
await resolveSpecs(loaded);
|
|
9528
|
+
} catch (error) {
|
|
9529
|
+
const message6 = error instanceof Error ? error.message : String(error);
|
|
9530
|
+
throw new ManifestEditError(message6.replace(tmp, absolute));
|
|
9531
|
+
}
|
|
9532
|
+
await (0, import_promises18.rename)(tmp, absolute);
|
|
9533
|
+
} finally {
|
|
9534
|
+
await (0, import_promises18.rm)(tmp, { force: true });
|
|
9535
|
+
}
|
|
9536
|
+
return { manifestPath: absolute, sha256: sha256(next), changed: true };
|
|
9537
|
+
}
|
|
9538
|
+
function createGalleryEditHandler(opts) {
|
|
9539
|
+
const log = opts.onProgress ?? (() => {
|
|
9540
|
+
});
|
|
9541
|
+
return async (body) => {
|
|
9542
|
+
const parsed = ManifestEditSchema.safeParse(body);
|
|
9543
|
+
if (!parsed.success) {
|
|
9544
|
+
throw new ManifestEditError(
|
|
9545
|
+
"invalid edit: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
|
|
9546
|
+
);
|
|
9547
|
+
}
|
|
9548
|
+
let manifestPath;
|
|
9549
|
+
try {
|
|
9550
|
+
manifestPath = await opts.manifestFor(parsed.data.project);
|
|
9551
|
+
} catch (error) {
|
|
9552
|
+
throw new ManifestEditError(error instanceof Error ? error.message : String(error));
|
|
9553
|
+
}
|
|
9554
|
+
const result = await applyManifestEdit(manifestPath, parsed.data);
|
|
9555
|
+
if (result.changed) {
|
|
9556
|
+
const relative = import_node_path22.default.relative(process.cwd(), result.manifestPath);
|
|
9557
|
+
const shown = !relative || relative.startsWith("..") ? result.manifestPath : relative;
|
|
9558
|
+
const subject = parsed.data.action === "patch-style" ? `style ${parsed.data.styleId}` : parsed.data.assetId;
|
|
9559
|
+
log(` manifest edited: ${parsed.data.action === "add-asset" ? "added" : "changed"} ${subject} in ${shown}`);
|
|
9560
|
+
}
|
|
9561
|
+
return opts.reload();
|
|
9562
|
+
};
|
|
9563
|
+
}
|
|
9564
|
+
|
|
9565
|
+
// src/gallery/snapshot.ts
|
|
9566
|
+
function galleryMediaId(absolutePath) {
|
|
9567
|
+
return (0, import_node_crypto6.createHash)("sha256").update(import_node_path23.default.resolve(absolutePath)).digest("hex").slice(0, 24);
|
|
9568
|
+
}
|
|
9569
|
+
function galleryMediaRoute(id) {
|
|
9570
|
+
return `/media/${id}`;
|
|
9571
|
+
}
|
|
9572
|
+
function isDeclared(loaded, styleId, assetId) {
|
|
9573
|
+
const asset = loaded.manifest.assets[assetId];
|
|
9574
|
+
if (!asset || !loaded.manifest.styles[styleId]) return false;
|
|
9575
|
+
return asset.styles.length === 0 || asset.styles.includes(styleId);
|
|
9576
|
+
}
|
|
9577
|
+
function matchesFilter(filter, styleId, assetId) {
|
|
9578
|
+
if (filter?.styles?.length && !filter.styles.includes(styleId)) return false;
|
|
9579
|
+
if (filter?.assets?.length && !filter.assets.includes(assetId)) return false;
|
|
9580
|
+
return true;
|
|
9581
|
+
}
|
|
9582
|
+
function metadataFps(entry) {
|
|
9583
|
+
if (!entry) return null;
|
|
9584
|
+
const namespace = entry.providerMetadata?.[entry.provider];
|
|
9585
|
+
const frameSet = namespace?.frameSet;
|
|
9586
|
+
if (frameSet && typeof frameSet === "object" && "fps" in frameSet) {
|
|
9587
|
+
const fps = frameSet.fps;
|
|
9588
|
+
if (typeof fps === "number" && Number.isFinite(fps) && fps > 0) return fps;
|
|
9589
|
+
}
|
|
9590
|
+
return null;
|
|
9591
|
+
}
|
|
9592
|
+
async function fileBytes(absolutePath) {
|
|
9593
|
+
try {
|
|
9594
|
+
const info = await (0, import_promises19.stat)(absolutePath);
|
|
9595
|
+
return info.isFile() ? info.size : null;
|
|
9596
|
+
} catch {
|
|
9597
|
+
return null;
|
|
9598
|
+
}
|
|
9599
|
+
}
|
|
9600
|
+
async function describeOutput(media, root, absolutePath, recorded = {}) {
|
|
9601
|
+
const bytes = await fileBytes(absolutePath);
|
|
9602
|
+
const exists = bytes !== null;
|
|
9603
|
+
const mediaType = recorded.mediaType ?? mediaTypeFromExtension(absolutePath);
|
|
9604
|
+
let url = null;
|
|
9605
|
+
if (exists && mediaType) {
|
|
9606
|
+
const id = galleryMediaId(absolutePath);
|
|
9607
|
+
media.set(id, { path: absolutePath, contentType: mediaType });
|
|
9608
|
+
url = `${galleryMediaRoute(id)}?v=${(recorded.sha256 ?? String(bytes)).slice(0, 16)}`;
|
|
9609
|
+
}
|
|
9610
|
+
return {
|
|
9611
|
+
path: portableOutputPath(absolutePath, root),
|
|
9612
|
+
absolutePath,
|
|
9613
|
+
sha256: recorded.sha256 ?? null,
|
|
9614
|
+
role: recorded.role ?? null,
|
|
9615
|
+
mediaType,
|
|
9616
|
+
exists,
|
|
9617
|
+
bytes,
|
|
9618
|
+
url
|
|
9619
|
+
};
|
|
9620
|
+
}
|
|
9621
|
+
async function describeQuality(media, root, inspection) {
|
|
9622
|
+
const outputPaths = inspection.outputs ?? [inspection.output];
|
|
9623
|
+
const outputs = await Promise.all(
|
|
9624
|
+
outputPaths.map((file) => describeOutput(media, root, import_node_path23.default.resolve(root, file)))
|
|
9625
|
+
);
|
|
9626
|
+
const recordPath = import_node_path23.default.resolve(root, inspection.record);
|
|
9627
|
+
const recordExists = (0, import_node_fs20.existsSync)(recordPath);
|
|
9628
|
+
let options = null;
|
|
9629
|
+
let check = null;
|
|
9630
|
+
if (recordExists) {
|
|
9631
|
+
try {
|
|
9632
|
+
const result = await checkQualityRecord(recordPath);
|
|
9633
|
+
options = result.options;
|
|
9634
|
+
check = { safe: result.safe, current: result.current, reasons: result.reasons };
|
|
9635
|
+
} catch (error) {
|
|
9636
|
+
check = {
|
|
9637
|
+
safe: false,
|
|
9638
|
+
current: false,
|
|
9639
|
+
reasons: [error instanceof Error ? error.message : String(error)]
|
|
9640
|
+
};
|
|
9641
|
+
}
|
|
9642
|
+
}
|
|
9643
|
+
return {
|
|
9644
|
+
state: inspection.state,
|
|
9645
|
+
reason: inspection.reason,
|
|
9646
|
+
output: portableOutputPath(import_node_path23.default.resolve(root, inspection.output), root),
|
|
9647
|
+
record: portableOutputPath(recordPath, root),
|
|
9648
|
+
recordExists,
|
|
9649
|
+
outputs,
|
|
9650
|
+
palette: options?.palette.colors ?? null,
|
|
9651
|
+
review: options ? options.review.status === "approved" ? {
|
|
9652
|
+
status: "approved",
|
|
9653
|
+
reviewer: options.review.reviewer,
|
|
9654
|
+
approvedAt: options.review.approvedAt,
|
|
9655
|
+
note: options.review.note ?? null
|
|
9656
|
+
} : { status: "pending" } : null,
|
|
9657
|
+
nativeGrid: options ? {
|
|
9658
|
+
confidence: options.nativeGrid.confidence,
|
|
9659
|
+
consensus: options.nativeGrid.consensus,
|
|
9660
|
+
stepX: options.nativeGrid.stepX,
|
|
9661
|
+
stepY: options.nativeGrid.stepY,
|
|
9662
|
+
sourceWidth: options.nativeGrid.sourceWidth,
|
|
9663
|
+
sourceHeight: options.nativeGrid.sourceHeight,
|
|
9664
|
+
nativeWidth: options.nativeGrid.nativeWidth,
|
|
9665
|
+
nativeHeight: options.nativeGrid.nativeHeight
|
|
9666
|
+
} : null,
|
|
9667
|
+
audit: options ? {
|
|
9668
|
+
safe: options.audit.safe,
|
|
9669
|
+
colorCount: options.audit.colorCount,
|
|
9670
|
+
transparency: options.audit.transparency,
|
|
9671
|
+
reasons: options.audit.reasons
|
|
9672
|
+
} : null,
|
|
9673
|
+
frameSet: options?.frameSet ? { fps: options.frameSet.fps, count: options.frameSet.count, roles: options.frameSet.roles } : null,
|
|
9674
|
+
check
|
|
9675
|
+
};
|
|
9676
|
+
}
|
|
9677
|
+
async function buildGallerySnapshot(opts) {
|
|
9678
|
+
const { loaded, specs, lock } = opts;
|
|
9679
|
+
const root = loaded.root;
|
|
9680
|
+
const media = /* @__PURE__ */ new Map();
|
|
9681
|
+
const plan = await buildPlan(specs, lock);
|
|
9682
|
+
const items = [];
|
|
9683
|
+
const manifestText = await (0, import_promises19.readFile)(loaded.path, "utf8");
|
|
9684
|
+
const manifestSha256 = sha256(manifestText);
|
|
9685
|
+
let rawStyles = {};
|
|
9686
|
+
try {
|
|
9687
|
+
const raw = JSON.parse(manifestText);
|
|
9688
|
+
if (raw && typeof raw === "object" && raw.styles && typeof raw.styles === "object") rawStyles = raw.styles;
|
|
9689
|
+
} catch {
|
|
9690
|
+
}
|
|
9691
|
+
for (const planItem of plan.items) {
|
|
9692
|
+
const { spec, key } = planItem;
|
|
9693
|
+
const entry = lock.entries[key];
|
|
9694
|
+
const asset = loaded.manifest.assets[spec.assetId] ?? null;
|
|
9695
|
+
let outputs;
|
|
9696
|
+
if (entry && entry.outputs.length) {
|
|
9697
|
+
outputs = await Promise.all(
|
|
9698
|
+
entry.outputs.map(
|
|
9699
|
+
(output, index) => describeOutput(media, root, currentEntryOutputPath(entry, spec, index), output)
|
|
9700
|
+
)
|
|
9701
|
+
);
|
|
9702
|
+
} else if (spec.source) {
|
|
9703
|
+
outputs = [await describeOutput(media, root, import_node_path23.default.resolve(root, spec.source))];
|
|
9704
|
+
} else if (!entry && (0, import_node_fs20.existsSync)(spec.outFile)) {
|
|
9705
|
+
outputs = [await describeOutput(media, root, spec.outFile)];
|
|
9706
|
+
} else {
|
|
9707
|
+
outputs = [];
|
|
9708
|
+
}
|
|
9709
|
+
const quality = planItem.quality ? await describeQuality(media, root, planItem.quality) : null;
|
|
9710
|
+
items.push({
|
|
9711
|
+
id: key,
|
|
9712
|
+
project: null,
|
|
9713
|
+
key,
|
|
9714
|
+
styleId: spec.styleId,
|
|
9715
|
+
assetId: spec.assetId,
|
|
9716
|
+
declared: true,
|
|
9717
|
+
state: planItem.state,
|
|
9718
|
+
reason: planItem.reason,
|
|
9719
|
+
status: entry?.status ?? null,
|
|
9720
|
+
provider: entry?.provider ?? spec.provider,
|
|
9721
|
+
generator: entry?.generator ?? spec.generator,
|
|
9722
|
+
prompt: entry?.prompt ?? spec.prompt,
|
|
9723
|
+
currentPrompt: entry && entry.prompt !== spec.prompt ? spec.prompt : null,
|
|
9724
|
+
width: entry?.width ?? spec.width,
|
|
9725
|
+
height: entry?.height ?? spec.height,
|
|
9726
|
+
tileFeature: entry?.tileFeature ?? spec.tileFeature ?? null,
|
|
9727
|
+
fps: quality?.frameSet?.fps ?? metadataFps(entry) ?? spec.quality?.fps ?? null,
|
|
9728
|
+
cost: entry?.cost ?? 0,
|
|
9729
|
+
costUnit: entry?.costUnit ?? spec.costUnit,
|
|
9730
|
+
estimatedCost: spec.cost,
|
|
9731
|
+
candidates: spec.candidates,
|
|
9732
|
+
submittedAt: entry?.submittedAt ?? null,
|
|
9733
|
+
downloadedAt: entry?.downloadedAt ?? null,
|
|
9734
|
+
jobId: entry?.jobId ?? null,
|
|
9735
|
+
objectId: entry?.objectId ?? null,
|
|
9736
|
+
reviewObjectId: entry?.reviewObjectId ?? null,
|
|
9737
|
+
candidateIndex: entry?.candidateIndex ?? null,
|
|
9738
|
+
error: entry?.error ?? null,
|
|
9739
|
+
recordedSpecHash: entry?.specHash ?? null,
|
|
9740
|
+
currentSpecHash: spec.specHash,
|
|
9741
|
+
revision: entry?.revision ?? null,
|
|
9742
|
+
revisionParentKey: spec.revision ? lockKey(spec.styleId, spec.revision.sourceAssetId) : entry?.revision ? lockKey(spec.styleId, entry.revision.sourceAssetId) : null,
|
|
9743
|
+
outputs,
|
|
9744
|
+
quality,
|
|
9745
|
+
providerMetadata: entry?.providerMetadata ?? {},
|
|
9746
|
+
asset,
|
|
9747
|
+
source: spec.source ?? null,
|
|
9748
|
+
tags: spec.tags,
|
|
9749
|
+
category: asset?.category ?? null
|
|
9750
|
+
});
|
|
9751
|
+
}
|
|
9752
|
+
const shown = new Set(items.map((item) => item.key));
|
|
9753
|
+
for (const [key, entry] of Object.entries(lock.entries)) {
|
|
9754
|
+
if (shown.has(key)) continue;
|
|
9755
|
+
if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
|
|
9756
|
+
if (!matchesFilter(opts.filter, entry.styleId, entry.assetId)) continue;
|
|
9757
|
+
const outputs = await Promise.all(
|
|
9758
|
+
entry.outputs.map(
|
|
9759
|
+
(output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
|
|
9760
|
+
)
|
|
9761
|
+
);
|
|
9762
|
+
items.push({
|
|
9763
|
+
id: key,
|
|
9764
|
+
project: null,
|
|
9765
|
+
key,
|
|
9766
|
+
styleId: entry.styleId,
|
|
9767
|
+
assetId: entry.assetId,
|
|
9768
|
+
declared: false,
|
|
9769
|
+
state: "undeclared",
|
|
9770
|
+
reason: "not declared by the current manifest; `pixelkiln prune` removes the entry",
|
|
9771
|
+
status: entry.status,
|
|
9772
|
+
provider: entry.provider,
|
|
9773
|
+
generator: entry.generator,
|
|
9774
|
+
prompt: entry.prompt,
|
|
9775
|
+
currentPrompt: null,
|
|
9776
|
+
width: entry.width,
|
|
9777
|
+
height: entry.height,
|
|
9778
|
+
tileFeature: entry.tileFeature,
|
|
9779
|
+
fps: metadataFps(entry),
|
|
9780
|
+
cost: entry.cost,
|
|
9781
|
+
costUnit: entry.costUnit,
|
|
9782
|
+
estimatedCost: null,
|
|
9783
|
+
candidates: null,
|
|
9784
|
+
submittedAt: entry.submittedAt,
|
|
9785
|
+
downloadedAt: entry.downloadedAt,
|
|
9786
|
+
jobId: entry.jobId,
|
|
9787
|
+
objectId: entry.objectId,
|
|
9788
|
+
reviewObjectId: entry.reviewObjectId,
|
|
9789
|
+
candidateIndex: entry.candidateIndex,
|
|
9790
|
+
error: entry.error,
|
|
9791
|
+
recordedSpecHash: entry.specHash,
|
|
9792
|
+
currentSpecHash: null,
|
|
9793
|
+
revision: entry.revision,
|
|
9794
|
+
revisionParentKey: entry.revision ? lockKey(entry.styleId, entry.revision.sourceAssetId) : null,
|
|
9795
|
+
outputs,
|
|
9796
|
+
quality: null,
|
|
9797
|
+
providerMetadata: entry.providerMetadata ?? {},
|
|
9798
|
+
asset: null,
|
|
9799
|
+
source: null,
|
|
9800
|
+
tags: [],
|
|
9801
|
+
category: null
|
|
9802
|
+
});
|
|
9803
|
+
}
|
|
9804
|
+
items.sort((a, b) => a.key.localeCompare(b.key));
|
|
9805
|
+
const byState = {};
|
|
9806
|
+
for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
|
|
9807
|
+
const byStatus = {};
|
|
9808
|
+
for (const entry of Object.values(lock.entries)) {
|
|
9809
|
+
byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1;
|
|
9810
|
+
}
|
|
9811
|
+
const styleIds = /* @__PURE__ */ new Set([
|
|
9812
|
+
...Object.keys(loaded.manifest.styles),
|
|
9813
|
+
...items.map((item) => item.styleId)
|
|
9814
|
+
]);
|
|
9815
|
+
const styles = [...styleIds].filter((id) => !opts.filter?.styles?.length || opts.filter.styles.includes(id) || items.some((item) => item.styleId === id)).sort().map((id) => {
|
|
9816
|
+
const style = loaded.manifest.styles[id];
|
|
9817
|
+
const styleItems = items.filter((item) => item.styleId === id);
|
|
9818
|
+
const spend = {};
|
|
9819
|
+
for (const item of styleItems) {
|
|
9820
|
+
if (item.cost) spend[item.costUnit] = (spend[item.costUnit] ?? 0) + item.cost;
|
|
9821
|
+
}
|
|
9822
|
+
const counts = new Set(styleItems.filter((item) => item.declared && item.candidates !== null).map((item) => item.candidates));
|
|
9823
|
+
const provider = style ? style.provider ?? loaded.manifest.provider : styleItems[0]?.provider ?? loaded.manifest.provider;
|
|
9824
|
+
const actionableItems = styleItems.filter((item) => item.declared && (item.state === "missing" || item.state === "stale" || item.state === "failed"));
|
|
9825
|
+
const declaredItems = styleItems.filter((item) => item.declared);
|
|
9826
|
+
const rawStyle = Object.hasOwn(rawStyles, id) ? rawStyles[id] : void 0;
|
|
9827
|
+
return {
|
|
9828
|
+
id,
|
|
9829
|
+
project: null,
|
|
9830
|
+
// A declared style inherits the manifest default; only a style the
|
|
9831
|
+
// manifest no longer has falls back to what its entries recorded.
|
|
9832
|
+
provider,
|
|
9833
|
+
generator: style?.generator ?? styleItems[0]?.generator ?? "map",
|
|
9834
|
+
outDir: style?.outDir ?? "",
|
|
9835
|
+
promptPrefix: style?.promptPrefix ?? "",
|
|
9836
|
+
promptSuffix: style?.promptSuffix ?? "",
|
|
9837
|
+
palette: style?.palette ?? [],
|
|
9838
|
+
quality: Boolean(style?.quality),
|
|
9839
|
+
tags: style?.tags ?? [],
|
|
9840
|
+
extends: typeof rawStyle?.extends === "string" ? rawStyle.extends : null,
|
|
9841
|
+
ownFields: rawStyle ? Object.keys(rawStyle).filter((key) => key !== "extends") : [],
|
|
9842
|
+
items: styleItems.length,
|
|
9843
|
+
spendByUnit: spend,
|
|
9844
|
+
regenerate: {
|
|
9845
|
+
assets: declaredItems.length,
|
|
9846
|
+
cost: declaredItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
|
|
9847
|
+
costUnit: declaredItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
|
|
9848
|
+
},
|
|
9849
|
+
candidates: counts.size === 1 ? [...counts][0] : null,
|
|
9850
|
+
candidatesEditable: Boolean(style) && Object.hasOwn(CANDIDATE_OPTION, provider),
|
|
9851
|
+
actionable: {
|
|
9852
|
+
keys: actionableItems.map((item) => item.key),
|
|
9853
|
+
cost: actionableItems.reduce((sum, item) => sum + (item.estimatedCost ?? 0), 0),
|
|
9854
|
+
costUnit: actionableItems[0]?.costUnit ?? styleItems[0]?.costUnit ?? "generations"
|
|
9855
|
+
}
|
|
9856
|
+
};
|
|
9857
|
+
}).filter((style) => style.items > 0 || !opts.filter?.styles?.length);
|
|
9858
|
+
const snapshot = {
|
|
9859
|
+
version: 1,
|
|
9860
|
+
generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
9861
|
+
project: {
|
|
9862
|
+
id: loaded.manifest.name,
|
|
9863
|
+
name: loaded.manifest.name,
|
|
9864
|
+
manifest: loaded.path,
|
|
9865
|
+
lock: import_node_path23.default.resolve(opts.lockPath),
|
|
9866
|
+
root,
|
|
9867
|
+
provider: loaded.manifest.provider,
|
|
9868
|
+
account: null,
|
|
9869
|
+
manifestSha256,
|
|
9870
|
+
entries: Object.keys(lock.entries).length,
|
|
9871
|
+
items: items.length,
|
|
9872
|
+
spendByUnit: spendByUnit(lock),
|
|
9873
|
+
error: null
|
|
9874
|
+
},
|
|
9875
|
+
workspace: null,
|
|
9876
|
+
filter: { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] },
|
|
9877
|
+
totals: {
|
|
9878
|
+
entries: Object.keys(lock.entries).length,
|
|
9879
|
+
items: items.length,
|
|
9880
|
+
byState,
|
|
9881
|
+
byStatus,
|
|
9882
|
+
spendByUnit: spendByUnit(lock)
|
|
9883
|
+
},
|
|
9884
|
+
styles,
|
|
9885
|
+
items
|
|
9886
|
+
};
|
|
9887
|
+
return { snapshot, media };
|
|
9888
|
+
}
|
|
9889
|
+
async function buildWorkspaceGallerySnapshot(opts) {
|
|
9890
|
+
const dir = import_node_path23.default.dirname(import_node_path23.default.resolve(opts.workspacePath));
|
|
9891
|
+
const media = /* @__PURE__ */ new Map();
|
|
9892
|
+
const projects = [];
|
|
9893
|
+
const items = [];
|
|
9894
|
+
const styles = [];
|
|
9895
|
+
const filter = { styles: opts.filter?.styles ?? [], assets: opts.filter?.assets ?? [] };
|
|
9896
|
+
for (const project of opts.workspace.projects) {
|
|
9897
|
+
const { manifestPath, lockPath } = resolveProject(dir, project);
|
|
9898
|
+
try {
|
|
9899
|
+
const loaded = await loadManifest(manifestPath);
|
|
9900
|
+
const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
|
|
9901
|
+
const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
|
|
9902
|
+
const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
|
|
9903
|
+
const lock = await loadLock(lockPath);
|
|
9904
|
+
let projectItems = [];
|
|
9905
|
+
let projectStyles = [];
|
|
9906
|
+
let manifestSha256 = null;
|
|
9907
|
+
if (!excluded) {
|
|
9908
|
+
const specs = await resolveSpecs(loaded, { styles: styleFilter, assets: assetFilter });
|
|
9909
|
+
normalizeLockOutputPaths(lock, specs);
|
|
9910
|
+
const build = await buildGallerySnapshot({
|
|
9911
|
+
loaded,
|
|
9912
|
+
specs,
|
|
9913
|
+
lock,
|
|
9914
|
+
lockPath,
|
|
9915
|
+
filter: { styles: styleFilter, assets: assetFilter },
|
|
9916
|
+
now: opts.now
|
|
9917
|
+
});
|
|
9918
|
+
for (const [id, file] of build.media) media.set(id, file);
|
|
9919
|
+
manifestSha256 = build.snapshot.project?.manifestSha256 ?? null;
|
|
9920
|
+
projectItems = build.snapshot.items.map((item) => ({
|
|
9921
|
+
...item,
|
|
9922
|
+
id: `${project.id}:${item.key}`,
|
|
9923
|
+
project: project.id
|
|
9924
|
+
}));
|
|
9925
|
+
projectStyles = build.snapshot.styles.map((style) => ({ ...style, project: project.id }));
|
|
9926
|
+
}
|
|
9927
|
+
items.push(...projectItems);
|
|
9928
|
+
styles.push(...projectStyles);
|
|
9929
|
+
projects.push({
|
|
9930
|
+
id: project.id,
|
|
9931
|
+
name: loaded.manifest.name,
|
|
9932
|
+
manifest: manifestPath,
|
|
9933
|
+
lock: import_node_path23.default.resolve(lockPath),
|
|
9934
|
+
root: loaded.root,
|
|
9935
|
+
provider: loaded.manifest.provider,
|
|
9936
|
+
account: project.account ?? null,
|
|
9937
|
+
manifestSha256: manifestSha256 ?? sha256(await (0, import_promises19.readFile)(manifestPath, "utf8")),
|
|
9938
|
+
entries: Object.keys(lock.entries).length,
|
|
9939
|
+
items: projectItems.length,
|
|
9940
|
+
spendByUnit: spendByUnit(lock),
|
|
9941
|
+
error: null
|
|
9942
|
+
});
|
|
9943
|
+
} catch (err) {
|
|
9944
|
+
projects.push({
|
|
9945
|
+
id: project.id,
|
|
9946
|
+
name: project.id,
|
|
9947
|
+
manifest: manifestPath,
|
|
9948
|
+
lock: import_node_path23.default.resolve(lockPath),
|
|
9949
|
+
root: import_node_path23.default.dirname(manifestPath),
|
|
9950
|
+
provider: project.provider,
|
|
9951
|
+
account: project.account ?? null,
|
|
9952
|
+
manifestSha256: null,
|
|
9953
|
+
entries: 0,
|
|
9954
|
+
items: 0,
|
|
9955
|
+
spendByUnit: {},
|
|
9956
|
+
error: err instanceof Error ? err.message : String(err)
|
|
9957
|
+
});
|
|
9958
|
+
}
|
|
9959
|
+
}
|
|
9960
|
+
const byState = {};
|
|
9961
|
+
for (const item of items) byState[item.state] = (byState[item.state] ?? 0) + 1;
|
|
9962
|
+
const byStatus = {};
|
|
9963
|
+
for (const item of items) if (item.status) byStatus[item.status] = (byStatus[item.status] ?? 0) + 1;
|
|
9964
|
+
const spend = {};
|
|
9965
|
+
for (const project of projects) {
|
|
9966
|
+
for (const [unit, amount] of Object.entries(project.spendByUnit)) {
|
|
9967
|
+
spend[unit] = (spend[unit] ?? 0) + amount;
|
|
9968
|
+
}
|
|
9969
|
+
}
|
|
9970
|
+
return {
|
|
9971
|
+
snapshot: {
|
|
9972
|
+
version: 1,
|
|
9973
|
+
generatedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
9974
|
+
project: null,
|
|
9975
|
+
workspace: { path: import_node_path23.default.resolve(opts.workspacePath), dir, projects },
|
|
9976
|
+
filter,
|
|
9977
|
+
totals: {
|
|
9978
|
+
entries: projects.reduce((sum, project) => sum + project.entries, 0),
|
|
9979
|
+
items: items.length,
|
|
9980
|
+
byState,
|
|
9981
|
+
byStatus,
|
|
9982
|
+
spendByUnit: spend
|
|
9983
|
+
},
|
|
9984
|
+
styles,
|
|
9985
|
+
items
|
|
9986
|
+
},
|
|
9987
|
+
media
|
|
9988
|
+
};
|
|
9989
|
+
}
|
|
9990
|
+
|
|
9991
|
+
// src/gallery/page.ts
|
|
9992
|
+
function renderGallery(snapshot, opts = {}) {
|
|
9993
|
+
const data = JSON.stringify(snapshot).replace(/<\//g, "<\\/").replace(/<!--/g, "<\\u0021--");
|
|
9994
|
+
const session = JSON.stringify(opts.session ?? null);
|
|
9995
|
+
const editable = JSON.stringify(Boolean(opts.editable && opts.session));
|
|
9996
|
+
const generation = JSON.stringify(Boolean(opts.generation && opts.session));
|
|
9997
|
+
const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
|
|
9998
|
+
return `<!doctype html>
|
|
9999
|
+
<html lang="en">
|
|
10000
|
+
<head>
|
|
10001
|
+
<meta charset="utf-8">
|
|
10002
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
10003
|
+
<title>${escapeHtml2(title)}</title>
|
|
10004
|
+
<link rel="icon" href="data:,">
|
|
10005
|
+
<style>
|
|
10006
|
+
:root {
|
|
10007
|
+
--bg: #17150f; --panel: #201d17; --panel-deep: #100f0c;
|
|
10008
|
+
--line: rgba(243,234,214,.16); --line-strong: rgba(243,234,214,.3);
|
|
10009
|
+
--text: #f3ead6; --dim: #9b9384; --accent: #ff6b35; --accent-soft: #ff9c5f;
|
|
10010
|
+
--ok: #b9f27c; --warn: #ff9c5f; --bad: #ff7a6b; --cool: #8fc7ff;
|
|
10011
|
+
--content: 1520px; --drawer: 540px;
|
|
10012
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
10013
|
+
--checker:
|
|
10014
|
+
linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%),
|
|
10015
|
+
linear-gradient(45deg,#0000 25%,#7f7f7f22 25%,#7f7f7f22 75%,#0000 75%);
|
|
10016
|
+
}
|
|
10017
|
+
* { box-sizing: border-box; }
|
|
10018
|
+
html { color-scheme: dark; }
|
|
10019
|
+
body { margin:0; background:var(--bg); color:var(--text);
|
|
10020
|
+
font:14px/1.5 ui-sans-serif,-apple-system,"SF Pro Text",Inter,system-ui,sans-serif; }
|
|
10021
|
+
button, input, select { font:inherit; color:inherit; }
|
|
10022
|
+
button { border-radius:0; padding:6px 11px; border:1px solid var(--line);
|
|
10023
|
+
background:transparent; cursor:pointer; }
|
|
10024
|
+
button:hover { border-color:var(--line-strong); }
|
|
10025
|
+
button.primary { background:var(--accent); border-color:var(--accent); color:var(--bg); font-weight:600; }
|
|
10026
|
+
button:disabled { opacity:.45; cursor:not-allowed; }
|
|
10027
|
+
:focus-visible { outline:2px solid var(--accent-soft); outline-offset:2px; }
|
|
10028
|
+
a { color:var(--accent-soft); }
|
|
10029
|
+
code, .mono { font-family:var(--mono); }
|
|
10030
|
+
|
|
10031
|
+
header { position:sticky; top:0; z-index:10; background:var(--panel);
|
|
10032
|
+
border-bottom:1px solid var(--line-strong); }
|
|
10033
|
+
.bar { width:min(100%,var(--content)); margin:0 auto; padding:13px 22px 0;
|
|
10034
|
+
display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
|
|
10035
|
+
h1 { font:700 14px/1 var(--mono); margin:0; letter-spacing:-.02em; white-space:nowrap; }
|
|
10036
|
+
h1::before { content:'\u25C6'; color:var(--accent); margin-right:9px; font-size:11px; }
|
|
10037
|
+
.project { color:var(--dim); font-size:12.5px; min-width:0; overflow:hidden;
|
|
10038
|
+
text-overflow:ellipsis; white-space:nowrap; }
|
|
10039
|
+
.project b { color:var(--text); font-weight:600; }
|
|
10040
|
+
.grow { flex:1 1 auto; }
|
|
10041
|
+
.search { display:flex; align-items:center; gap:8px; border:1px solid var(--line);
|
|
10042
|
+
background:var(--panel-deep); padding:0 10px; min-width:min(320px, 100%); }
|
|
10043
|
+
.search input { flex:1; border:0; background:transparent; padding:7px 0; min-width:0; }
|
|
10044
|
+
.search input:focus { outline:none; }
|
|
10045
|
+
.search kbd { color:var(--dim); border:1px solid var(--line); border-bottom-width:2px;
|
|
10046
|
+
padding:0 5px; font-size:11px; font-family:var(--mono); }
|
|
10047
|
+
select { background:var(--panel-deep); border:1px solid var(--line); padding:6px 8px; border-radius:0; }
|
|
10048
|
+
.totals { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 0;
|
|
10049
|
+
display:flex; gap:14px; flex-wrap:wrap; align-items:baseline;
|
|
10050
|
+
color:var(--dim); font-size:12.5px; font-variant-numeric:tabular-nums; }
|
|
10051
|
+
.totals b { color:var(--text); font-weight:600; }
|
|
10052
|
+
.chips { width:min(100%,var(--content)); margin:0 auto; padding:9px 22px 13px;
|
|
10053
|
+
display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
|
|
10054
|
+
.chips .sep { width:1px; height:18px; background:var(--line); margin:0 4px; }
|
|
10055
|
+
.chip { padding:3px 9px; font-size:12px; border:1px solid var(--line); color:var(--dim);
|
|
10056
|
+
display:inline-flex; align-items:center; gap:6px; font-variant-numeric:tabular-nums; }
|
|
10057
|
+
.chip.on { color:var(--text); border-color:var(--line-strong); background:var(--panel-deep); }
|
|
10058
|
+
.chip .n { color:var(--dim); }
|
|
10059
|
+
.dot { width:7px; height:7px; display:inline-block; background:var(--dim); flex:none; }
|
|
10060
|
+
.dot.ok { background:var(--ok); } .dot.warn { background:var(--warn); }
|
|
10061
|
+
.dot.bad { background:var(--bad); } .dot.cool { background:var(--cool); }
|
|
10062
|
+
.state-ok { color:var(--ok); } .state-warn { color:var(--warn); }
|
|
10063
|
+
.state-bad { color:var(--bad); } .state-cool { color:var(--cool); } .state-dim { color:var(--dim); }
|
|
10064
|
+
|
|
10065
|
+
main { width:min(100%,var(--content)); margin:0 auto; border-inline:1px solid var(--line);
|
|
10066
|
+
min-height:60vh; }
|
|
10067
|
+
.style { border-bottom:1px solid var(--line); padding:20px 22px 24px; }
|
|
10068
|
+
.project { border-bottom:1px solid var(--line-strong); background:var(--panel); padding:14px 22px; }
|
|
10069
|
+
.phead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; }
|
|
10070
|
+
.phead h2 { margin:0; font:700 15px/1.2 var(--mono); }
|
|
10071
|
+
.phead h2::before { content:'\u25C6'; color:var(--accent); margin-right:8px; font-size:10px; }
|
|
10072
|
+
.phead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap; }
|
|
10073
|
+
.sid.project { color:var(--dim); border-color:var(--line-strong); }
|
|
10074
|
+
.shead { display:flex; align-items:baseline; gap:14px; flex-wrap:wrap; margin-bottom:14px; }
|
|
10075
|
+
.shead h2 { margin:0; font:650 15px/1.2 var(--mono); }
|
|
10076
|
+
.shead .meta { color:var(--dim); font-size:12.5px; display:flex; gap:12px; flex-wrap:wrap;
|
|
10077
|
+
align-items:center; }
|
|
10078
|
+
.swatches { display:inline-flex; gap:2px; vertical-align:middle; }
|
|
10079
|
+
.swatches i { width:12px; height:12px; display:block; border:1px solid var(--line); }
|
|
10080
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(176px, 1fr)); gap:10px; }
|
|
10081
|
+
.card { border:1px solid var(--line); background:var(--panel); text-align:left; padding:0;
|
|
10082
|
+
display:flex; flex-direction:column; cursor:pointer; min-width:0;
|
|
10083
|
+
content-visibility:auto; contain-intrinsic-size:auto 176px auto 196px; }
|
|
10084
|
+
.card:hover { border-color:var(--line-strong); }
|
|
10085
|
+
.card.active { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); }
|
|
10086
|
+
.card.ghost { border-style:dashed; background:transparent; }
|
|
10087
|
+
.cell { height:132px; display:grid; place-items:center; background:var(--panel-deep);
|
|
10088
|
+
background-image:var(--checker); background-size:12px 12px; background-position:0 0,6px 6px;
|
|
10089
|
+
overflow:hidden; position:relative; padding:8px; }
|
|
10090
|
+
.card.ghost .cell { background-image:none; color:var(--dim); font-size:12px; }
|
|
10091
|
+
.cell img { image-rendering:pixelated; display:block; max-width:100%; max-height:100%; }
|
|
10092
|
+
.cell img.fit { width:auto; height:auto; object-fit:contain; }
|
|
10093
|
+
.cell .multi { display:grid; grid-template-columns:1fr 1fr; gap:4px; width:100%; height:100%; }
|
|
10094
|
+
.cell .multi div { display:grid; place-items:center; overflow:hidden; }
|
|
10095
|
+
.badge { position:absolute; right:6px; bottom:6px; background:var(--panel); color:var(--dim);
|
|
10096
|
+
border:1px solid var(--line); font:11px/1 var(--mono); padding:3px 6px; }
|
|
10097
|
+
.cbody { padding:8px 10px 10px; display:flex; flex-direction:column; gap:3px; min-width:0; }
|
|
10098
|
+
.aid { font:600 13px/1.3 var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
10099
|
+
.cmeta { display:flex; gap:8px; align-items:center; color:var(--dim); font-size:11.5px;
|
|
10100
|
+
font-variant-numeric:tabular-nums; white-space:nowrap; overflow:hidden; }
|
|
10101
|
+
.cmeta .st { display:inline-flex; align-items:center; gap:5px; }
|
|
10102
|
+
.empty { padding:64px 22px; color:var(--dim); text-align:center; }
|
|
10103
|
+
.empty p { margin:0 0 12px; }
|
|
10104
|
+
footer { width:min(100%,var(--content)); margin:0 auto; padding:22px 22px 60px; color:var(--dim);
|
|
10105
|
+
font-size:12.5px; }
|
|
10106
|
+
kbd { border:1px solid var(--line); border-bottom-width:2px; padding:1px 5px; font-size:11px;
|
|
10107
|
+
font-family:var(--mono); }
|
|
10108
|
+
|
|
10109
|
+
.scrim { position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:19; }
|
|
10110
|
+
.drawer { position:fixed; top:0; right:0; bottom:0; width:min(var(--drawer), 100vw); z-index:20;
|
|
10111
|
+
background:var(--panel); border-left:1px solid var(--line-strong); overflow-y:auto;
|
|
10112
|
+
display:flex; flex-direction:column; }
|
|
10113
|
+
.dhead { position:sticky; top:0; background:var(--panel); border-bottom:1px solid var(--line);
|
|
10114
|
+
padding:14px 18px; display:flex; align-items:center; gap:10px; z-index:1; }
|
|
10115
|
+
.dhead .title { flex:1; min-width:0; }
|
|
10116
|
+
.dhead .title .sid { color:var(--accent-soft); font:650 10.5px/1 var(--mono); border:1px solid var(--accent);
|
|
10117
|
+
padding:2px 6px; display:inline-block; margin-bottom:6px; }
|
|
10118
|
+
.dhead .title .aid { font-size:16px; white-space:normal; word-break:break-all; }
|
|
10119
|
+
.dhead button { padding:5px 9px; }
|
|
10120
|
+
.dbody { padding:0 18px 40px; }
|
|
10121
|
+
.preview { margin:16px 0 10px; background:var(--panel-deep); background-image:var(--checker);
|
|
10122
|
+
background-size:12px 12px; background-position:0 0,6px 6px; border:1px solid var(--line);
|
|
10123
|
+
min-height:200px; max-height:60vh; overflow:auto; display:grid; place-items:center; padding:12px; }
|
|
10124
|
+
.preview img { image-rendering:pixelated; display:block; }
|
|
10125
|
+
.preview .none { color:var(--dim); font-size:12.5px; text-align:center; padding:40px 12px; }
|
|
10126
|
+
.zoom { display:flex; gap:6px; align-items:center; flex-wrap:wrap; font-size:12px; color:var(--dim); }
|
|
10127
|
+
.zoom button { padding:3px 8px; font-size:12px; }
|
|
10128
|
+
.zoom button.on { border-color:var(--accent); color:var(--text); }
|
|
10129
|
+
.zoom .grow { text-align:right; font-variant-numeric:tabular-nums; }
|
|
10130
|
+
.members { display:flex; gap:6px; overflow-x:auto; padding:10px 0 4px; }
|
|
10131
|
+
.members button { flex:none; padding:4px; display:flex; flex-direction:column; align-items:center; gap:3px;
|
|
10132
|
+
background:var(--panel-deep); background-image:var(--checker); background-size:8px 8px;
|
|
10133
|
+
background-position:0 0,4px 4px; }
|
|
10134
|
+
.members button.on { border-color:var(--accent); }
|
|
10135
|
+
.members img { image-rendering:pixelated; display:block; width:40px; height:40px; object-fit:contain; }
|
|
10136
|
+
.members span { font:10.5px/1 var(--mono); color:var(--dim); background:var(--panel); padding:2px 4px; }
|
|
10137
|
+
.status { margin:14px 0 0; padding:10px 12px; border:1px solid var(--line); display:flex; gap:10px;
|
|
10138
|
+
align-items:baseline; font-size:13px; }
|
|
10139
|
+
.status .st { font-weight:600; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
|
|
10140
|
+
.status .why { color:var(--dim); }
|
|
10141
|
+
section.meta { border-top:1px solid var(--line); padding:14px 0 6px; margin-top:14px; }
|
|
10142
|
+
section.meta h3 { margin:0 0 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
|
|
10143
|
+
dl { margin:0; display:grid; grid-template-columns:minmax(96px, 30%) 1fr; gap:6px 12px; font-size:12.5px; }
|
|
10144
|
+
dt { color:var(--dim); }
|
|
10145
|
+
dd { margin:0; min-width:0; word-break:break-word; }
|
|
10146
|
+
dd.mono { font-family:var(--mono); font-size:12px; }
|
|
10147
|
+
dd .copy { margin-left:6px; padding:0 5px; font:11px/1.4 var(--mono); color:var(--dim); border-color:transparent; }
|
|
10148
|
+
dd .copy:hover { border-color:var(--line); color:var(--text); }
|
|
10149
|
+
.prompt { white-space:pre-wrap; }
|
|
10150
|
+
.out { border:1px solid var(--line); padding:8px 10px; margin-bottom:8px; }
|
|
10151
|
+
.out dl { grid-template-columns:minmax(72px, 24%) 1fr; }
|
|
10152
|
+
.out .role { font:600 12px/1 var(--mono); margin-bottom:6px; display:flex; gap:8px; align-items:center; }
|
|
10153
|
+
.miss { color:var(--bad); }
|
|
10154
|
+
.pal { display:flex; flex-wrap:wrap; gap:3px; }
|
|
10155
|
+
.pal i { width:16px; height:16px; display:block; border:1px solid var(--line); }
|
|
10156
|
+
details { border:1px solid var(--line); padding:0; margin-top:8px; }
|
|
10157
|
+
details summary { cursor:pointer; padding:7px 10px; font:650 12.5px/1 var(--mono); color:var(--dim); }
|
|
10158
|
+
details pre { margin:0; padding:10px; border-top:1px solid var(--line); background:var(--panel-deep);
|
|
10159
|
+
font:11.5px/1.45 var(--mono); overflow:auto; max-height:320px; }
|
|
10160
|
+
.linkish { background:none; border:0; padding:0; color:var(--accent-soft); cursor:pointer;
|
|
10161
|
+
font-family:var(--mono); font-size:12px; text-decoration:underline; }
|
|
10162
|
+
.nav { display:flex; gap:6px; }
|
|
10163
|
+
#note { color:var(--warn); font-size:12.5px; }
|
|
10164
|
+
.editing { color:var(--accent-soft); border:1px solid var(--accent); padding:2px 7px; font:650 10.5px/1.4 var(--mono); }
|
|
10165
|
+
form.edit { border:1px solid var(--accent); padding:12px 14px 14px; margin-top:14px; display:grid; gap:10px; }
|
|
10166
|
+
form.edit h3 { margin:0; font:650 12.5px/1 var(--mono); color:var(--accent-soft); }
|
|
10167
|
+
form.edit .row { display:grid; grid-template-columns:repeat(auto-fit, minmax(110px, 1fr)); gap:10px; }
|
|
10168
|
+
.field { display:grid; gap:4px; font-size:12px; color:var(--dim); min-width:0; }
|
|
10169
|
+
.field span { font-family:var(--mono); font-size:11px; }
|
|
10170
|
+
.field small { font-size:11px; color:var(--dim); }
|
|
10171
|
+
.field input, .field textarea, .field select { background:var(--panel-deep); border:1px solid var(--line);
|
|
10172
|
+
color:var(--text); padding:6px 8px; border-radius:0; font:13px/1.45 inherit; width:100%; min-width:0; }
|
|
10173
|
+
.field textarea { resize:vertical; min-height:64px; font-family:inherit; }
|
|
10174
|
+
.field input:focus, .field textarea:focus, .field select:focus { outline:none; border-color:var(--accent); }
|
|
10175
|
+
.field.check { display:flex; align-items:center; gap:8px; }
|
|
10176
|
+
.field.check input { width:auto; }
|
|
10177
|
+
form.edit .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
10178
|
+
form.edit .msg { font-size:12.5px; }
|
|
10179
|
+
form.edit .msg.bad { color:var(--bad); }
|
|
10180
|
+
.notice { margin:14px 0 0; padding:10px 12px; border:1px solid var(--ok); color:var(--ok); font-size:13px; }
|
|
10181
|
+
.shead .tools { margin-left:auto; display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
|
10182
|
+
.shead .tools button { padding:3px 9px; font-size:12px; }
|
|
10183
|
+
.shead .cand { display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--dim); }
|
|
10184
|
+
.shead .cand input { width:56px; background:var(--panel-deep); border:1px solid var(--line); color:var(--text);
|
|
10185
|
+
padding:2px 6px; font:12px/1.4 inherit; border-radius:0; }
|
|
10186
|
+
form.edit.style-form { margin:0 0 14px; }
|
|
10187
|
+
form.edit .blast { padding:8px 10px; border:1px solid var(--line); font-size:12.5px; color:var(--dim); }
|
|
10188
|
+
form.edit .blast.hot { border-color:var(--warn); color:var(--text); }
|
|
10189
|
+
form.edit .blast b { color:var(--warn); font-weight:600; }
|
|
10190
|
+
form.edit .pal { margin-top:4px; }
|
|
10191
|
+
.style .notice { margin:0 0 14px; }
|
|
10192
|
+
#jobs { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; display:grid; gap:6px; }
|
|
10193
|
+
#jobs:empty { display:none; }
|
|
10194
|
+
.job { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
|
|
10195
|
+
grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
|
|
10196
|
+
.job .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
|
|
10197
|
+
.job .last { color:var(--dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
|
10198
|
+
.job .acts { display:flex; gap:6px; }
|
|
10199
|
+
.job .acts button { padding:3px 9px; font-size:12px; }
|
|
10200
|
+
.job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
|
|
10201
|
+
font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
|
|
10202
|
+
.budget { color:var(--dim); }
|
|
10203
|
+
.budget b { color:var(--text); }
|
|
10204
|
+
.dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
|
|
10205
|
+
.dialog form { background:var(--panel); border:1px solid var(--accent); width:min(560px, calc(100vw - 32px));
|
|
10206
|
+
max-height:calc(100vh - 32px); overflow:auto; padding:16px 18px 18px; display:grid; gap:12px; }
|
|
10207
|
+
.dialog h3 { margin:0; font:650 14px/1.2 var(--mono); }
|
|
10208
|
+
.dialog table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
|
10209
|
+
.dialog td, .dialog th { text-align:left; padding:4px 6px; border-bottom:1px solid var(--line); }
|
|
10210
|
+
.dialog th { color:var(--dim); font-weight:500; }
|
|
10211
|
+
.dialog td.n, .dialog th.n { text-align:right; font-variant-numeric:tabular-nums; }
|
|
10212
|
+
.dialog .sum { display:grid; gap:4px; font-size:13px; }
|
|
10213
|
+
.dialog .sum b { font-weight:600; }
|
|
10214
|
+
.dialog .warn { color:var(--warn); font-size:12.5px; }
|
|
10215
|
+
.dialog .actions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
10216
|
+
.dialog .msg { font-size:12.5px; color:var(--bad); }
|
|
10217
|
+
.sheet-scrim { position:fixed; inset:0; z-index:24; background:rgba(0,0,0,.5); }
|
|
10218
|
+
.sheet { position:fixed; top:0; right:0; bottom:0; z-index:25; background:var(--bg);
|
|
10219
|
+
border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
|
|
10220
|
+
.sheet.review-host { width:min(1180px, 94vw); }
|
|
10221
|
+
.sheet.compare-host { width:min(1500px, 96vw); }
|
|
10222
|
+
.sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
|
|
10223
|
+
background:var(--panel); font-size:13px; flex-wrap:wrap; }
|
|
10224
|
+
.sheet .rbar span { color:var(--dim); }
|
|
10225
|
+
.sheet .rbar .zoom { margin-left:auto; }
|
|
10226
|
+
.sheet iframe { border:0; width:100%; height:100%; background:var(--bg); }
|
|
10227
|
+
@media (prefers-reduced-motion: no-preference) { .sheet { animation: slide .18s ease-out; } }
|
|
10228
|
+
.compare-body { overflow:auto; padding:16px; }
|
|
10229
|
+
.compare-body table { border-collapse:separate; border-spacing:0; min-width:100%; font-size:12.5px; }
|
|
10230
|
+
.compare-body th, .compare-body td { text-align:left; vertical-align:top; padding:6px 10px; border-bottom:1px solid var(--line);
|
|
10231
|
+
min-width:180px; max-width:420px; }
|
|
10232
|
+
.compare-body th:first-child, .compare-body td:first-child { min-width:96px; max-width:120px; color:var(--dim);
|
|
10233
|
+
position:sticky; left:0; background:var(--bg); z-index:1; }
|
|
10234
|
+
.compare-body thead th { position:sticky; top:0; background:var(--bg); z-index:2; border-bottom:1px solid var(--line-strong); }
|
|
10235
|
+
.compare-body .chead { display:grid; gap:4px; }
|
|
10236
|
+
.compare-body .chead .aid { font-size:14px; }
|
|
10237
|
+
.compare-body .chead .badges { display:flex; gap:6px; }
|
|
10238
|
+
.compare-body .chead .badges .sid { display:inline-block; }
|
|
10239
|
+
.compare-body .cimg { background:var(--panel-deep); background-image:var(--checker); background-size:12px 12px;
|
|
10240
|
+
background-position:0 0,6px 6px; border:1px solid var(--line); display:grid; place-items:center; padding:10px;
|
|
10241
|
+
min-height:120px; max-height:56vh; overflow:auto; }
|
|
10242
|
+
.compare-body .cimg img { image-rendering:pixelated; display:block; }
|
|
10243
|
+
.compare-body .cimg .none { color:var(--dim); font-size:12px; }
|
|
10244
|
+
.compare-body td.diff { background:color-mix(in srgb, var(--warn) 9%, transparent); }
|
|
10245
|
+
.compare-body td.mono { font-family:var(--mono); font-size:12px; word-break:break-all; }
|
|
10246
|
+
.compare-body td .prompt { white-space:pre-wrap; }
|
|
10247
|
+
.compare-body td .rm { float:right; padding:1px 6px; font-size:11px; color:var(--dim); border-color:transparent; }
|
|
10248
|
+
.card.compared { outline:2px dashed var(--accent-soft); outline-offset:-2px; }
|
|
10249
|
+
.card .slot { position:absolute; left:6px; top:6px; background:var(--accent); color:var(--bg); font:700 11px/1 var(--mono);
|
|
10250
|
+
padding:3px 6px; }
|
|
10251
|
+
.tray { position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:18; background:var(--panel);
|
|
10252
|
+
border:1px solid var(--accent); padding:8px 10px; display:flex; gap:8px; align-items:center; max-width:calc(100vw - 32px);
|
|
10253
|
+
box-shadow:0 10px 30px rgba(0,0,0,.4); }
|
|
10254
|
+
.tray .chipset { display:flex; gap:6px; overflow-x:auto; }
|
|
10255
|
+
.tray .pick { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); padding:3px 4px 3px 6px;
|
|
10256
|
+
font:12px/1.2 var(--mono); white-space:nowrap; }
|
|
10257
|
+
.tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
|
|
10258
|
+
.tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
|
|
10259
|
+
.tray > button { padding:5px 11px; font-size:12.5px; }
|
|
10260
|
+
@media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
|
|
10261
|
+
.drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
|
10262
|
+
.drawer .gen button.primary { padding:7px 14px; }
|
|
10263
|
+
@media (max-width: 720px) {
|
|
10264
|
+
.bar, .totals, .chips, .style, footer { padding-inline:14px; }
|
|
10265
|
+
.grid { grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)); }
|
|
10266
|
+
.cell { height:112px; }
|
|
10267
|
+
.drawer { width:100vw; }
|
|
10268
|
+
}
|
|
10269
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
10270
|
+
.drawer { animation: slide .16s ease-out; }
|
|
10271
|
+
@keyframes slide { from { transform: translateX(16px); opacity:.6; } to { transform:none; opacity:1; } }
|
|
10272
|
+
}
|
|
10273
|
+
</style>
|
|
10274
|
+
</head>
|
|
10275
|
+
<body>
|
|
10276
|
+
<header>
|
|
10277
|
+
<div class="bar">
|
|
10278
|
+
<h1>pixelkiln</h1>
|
|
10279
|
+
<span class="project" id="project"></span>
|
|
10280
|
+
<span class="grow"></span>
|
|
10281
|
+
<label class="search"><input id="q" type="search" placeholder="Search assets, prompts, ids, hashes" autocomplete="off"><kbd>/</kbd></label>
|
|
10282
|
+
<select id="sort" aria-label="Sort">
|
|
10283
|
+
<option value="key">Sort: style / asset</option>
|
|
10284
|
+
<option value="newest">Sort: newest first</option>
|
|
10285
|
+
<option value="oldest">Sort: oldest first</option>
|
|
10286
|
+
<option value="cost">Sort: cost</option>
|
|
10287
|
+
<option value="size">Sort: size</option>
|
|
10288
|
+
</select>
|
|
10289
|
+
<select id="group" aria-label="Group">
|
|
10290
|
+
<option value="style">Group: by style</option>
|
|
10291
|
+
<option value="none">Group: none</option>
|
|
10292
|
+
</select>
|
|
10293
|
+
<span id="editing" class="editing" hidden title="This gallery can write the manifest. It never contacts a provider.">editing</span>
|
|
10294
|
+
<button id="refresh" type="button" title="Re-read the manifest, lockfile, and disk">Refresh</button>
|
|
10295
|
+
<label class="chip" title="Refresh every 5 seconds while this tab is visible"><input id="auto" type="checkbox"> auto</label>
|
|
10296
|
+
</div>
|
|
10297
|
+
<div class="totals" id="totals"></div>
|
|
10298
|
+
<div class="chips" id="chips"></div>
|
|
10299
|
+
<div id="jobs"></div>
|
|
10300
|
+
</header>
|
|
10301
|
+
<main id="root"></main>
|
|
10302
|
+
<footer>
|
|
10303
|
+
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
|
|
10304
|
+
is open, <kbd>Esc</kbd> closes it, and <kbd>/</kbd> jumps to search. This page reads the manifest, lockfile,
|
|
10305
|
+
and disk only \u2014 it never contacts a provider<span id="foot-edit"> and never writes anything</span><span id="foot-editing" hidden>.
|
|
10306
|
+
Editing is on: saving rewrites the manifest and nothing else</span><span id="foot-gen" hidden>.
|
|
10307
|
+
Generation is on under the session budget shown above; every run is the same submit, poll, and fetch as <code>pixelkiln gen</code></span>.
|
|
10308
|
+
<span id="note"></span>
|
|
10309
|
+
</footer>
|
|
10310
|
+
<div id="drawer-host"></div>
|
|
10311
|
+
<div id="dialog-host"></div>
|
|
10312
|
+
<script>
|
|
10313
|
+
const INITIAL = ${data};
|
|
10314
|
+
const SESSION = ${session};
|
|
10315
|
+
const EDITABLE = ${editable};
|
|
10316
|
+
const GENERATION = ${generation};
|
|
10317
|
+
let snap = INITIAL;
|
|
10318
|
+
const STATE_TONE = {
|
|
10319
|
+
ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
|
|
10320
|
+
failed: 'bad', 'in-flight': 'cool', recoverable: 'cool', missing: 'dim', undeclared: 'dim',
|
|
10321
|
+
};
|
|
10322
|
+
const STATE_ORDER = ['ok','stale','orphaned','untracked','in-flight','recoverable','blocked','failed','missing','undeclared'];
|
|
10323
|
+
const ui = {
|
|
10324
|
+
q: '', states: new Set(), providers: new Set(), generators: new Set(), projects: new Set(),
|
|
10325
|
+
sort: 'key', group: 'style', open: null, member: 0, zoom: 'auto', playing: null,
|
|
10326
|
+
/** Cards rendered per pass; a project with thousands of assets opts into the rest. */
|
|
10327
|
+
limit: 600,
|
|
10328
|
+
/** Item id whose edit form is open, or 'new:<project>:<style>' for an add form. */
|
|
10329
|
+
editing: null,
|
|
10330
|
+
/** One-shot confirmation shown in the drawer after a save. */
|
|
10331
|
+
notice: null,
|
|
10332
|
+
/** Record ids picked for side-by-side comparison, in pick order (max 4). */
|
|
10333
|
+
compare: [],
|
|
10334
|
+
/** Zoom for the comparison panel. */
|
|
10335
|
+
compareZoom: 'auto',
|
|
10336
|
+
/** Jobs expanded to show their log. */
|
|
10337
|
+
logs: new Set(),
|
|
10338
|
+
/** Job ids already seen finished, so a completion refreshes exactly once. */
|
|
10339
|
+
settled: new Set(),
|
|
10340
|
+
};
|
|
10341
|
+
/** Generation status from /api/jobs: jobs, session budget, spend so far. */
|
|
10342
|
+
let GEN = { jobs: [], budget: { byProvider: {} }, spent: {}, units: {} };
|
|
10343
|
+
const $ = (id) => document.getElementById(id);
|
|
10344
|
+
const el = (tag, cls, text) => {
|
|
10345
|
+
const n = document.createElement(tag);
|
|
10346
|
+
if (cls) n.className = cls;
|
|
10347
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
10348
|
+
return n;
|
|
10349
|
+
};
|
|
10350
|
+
const fmtCost = (unit, amount) => {
|
|
10351
|
+
if (unit === 'free') return 'free';
|
|
10352
|
+
if (unit === 'usd') return '$' + Number(amount).toFixed(2);
|
|
10353
|
+
if (unit === 'generations') return amount + ' generation' + (amount === 1 ? '' : 's');
|
|
10354
|
+
return amount + ' ' + unit;
|
|
10355
|
+
};
|
|
10356
|
+
const fmtSpend = (spend) => Object.entries(spend).filter(([, n]) => n).sort()
|
|
10357
|
+
.map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + ') || 'no spend recorded';
|
|
10358
|
+
const fmtBytes = (n) => n == null ? '\u2014' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB';
|
|
10359
|
+
const fmtWhen = (iso) => {
|
|
10360
|
+
if (!iso) return null;
|
|
10361
|
+
const d = new Date(iso);
|
|
10362
|
+
return isNaN(d) ? iso : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
10363
|
+
};
|
|
10364
|
+
const isFrameSet = (item) => item.generator === 'frames' ||
|
|
10365
|
+
(item.outputs.length > 1 && item.outputs.every((o) => o.role && /^frame/i.test(o.role)));
|
|
10366
|
+
const shortPath = (p) => {
|
|
10367
|
+
if (p.length <= 64) return p;
|
|
10368
|
+
const parts = p.split('/');
|
|
10369
|
+
return parts.length > 4 ? '\u2026/' + parts.slice(-3).join('/') : p;
|
|
10370
|
+
};
|
|
10371
|
+
const displayScale = (w, h, boxW, boxH) => {
|
|
10372
|
+
const s = Math.min(boxW / w, boxH / h);
|
|
10373
|
+
return s >= 1 ? Math.floor(s) : s;
|
|
10374
|
+
};
|
|
10375
|
+
|
|
10376
|
+
const projectOf = (item) => snap.workspace
|
|
10377
|
+
? snap.workspace.projects.find((pr) => pr.id === item.project)
|
|
10378
|
+
: snap.project;
|
|
10379
|
+
|
|
10380
|
+
// The only write this page ever makes: one manifest edit, quoting the
|
|
10381
|
+
// manifest hash it was rendered from so a concurrent hand edit is refused
|
|
10382
|
+
// rather than overwritten. The server answers with the rebuilt gallery.
|
|
10383
|
+
async function postEdit(body) {
|
|
10384
|
+
const res = await fetch('/api/edit', {
|
|
10385
|
+
method: 'POST',
|
|
10386
|
+
headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
|
|
10387
|
+
body: JSON.stringify(body),
|
|
10388
|
+
});
|
|
10389
|
+
if (!res.ok) {
|
|
10390
|
+
const err = new Error(await res.text());
|
|
10391
|
+
err.status = res.status;
|
|
10392
|
+
throw err;
|
|
10393
|
+
}
|
|
10394
|
+
return res.json();
|
|
10395
|
+
}
|
|
10396
|
+
const field = (label, input, hint) => {
|
|
10397
|
+
const w = el('label', 'field');
|
|
10398
|
+
w.append(el('span', null, label), input);
|
|
10399
|
+
if (hint) w.append(el('small', null, hint));
|
|
10400
|
+
return w;
|
|
10401
|
+
};
|
|
10402
|
+
const numberInput = (value, placeholder) => {
|
|
10403
|
+
const i = el('input'); i.type = 'number'; i.min = '16'; i.step = '1';
|
|
10404
|
+
i.value = value == null ? '' : String(value); i.placeholder = placeholder || '';
|
|
10405
|
+
return i;
|
|
10406
|
+
};
|
|
10407
|
+
const numberOrNull = (input) => input.value.trim() === '' ? null : Number(input.value);
|
|
10408
|
+
const splitTags = (value) => value.split(',').map((t) => t.trim()).filter(Boolean);
|
|
10409
|
+
|
|
10410
|
+
// ---- generation (only when the server holds a session budget) -------------
|
|
10411
|
+
|
|
10412
|
+
const ACTIVE_PHASES = new Set(['queued', 'submitting', 'polling', 'fetching', 'review']);
|
|
10413
|
+
const PHASE_TONE = { queued: 'cool', submitting: 'cool', polling: 'cool', fetching: 'cool', review: 'warn', done: 'ok', failed: 'bad' };
|
|
10414
|
+
const remainingBudget = (provider) => {
|
|
10415
|
+
const keyed = GEN.budget.byProvider[provider];
|
|
10416
|
+
const ceiling = keyed !== undefined ? keyed : GEN.budget.amount;
|
|
10417
|
+
return ceiling === undefined ? null : Math.max(0, ceiling - (GEN.spent[provider] || 0));
|
|
10418
|
+
};
|
|
10419
|
+
async function postGenerate(body) {
|
|
10420
|
+
const res = await fetch('/api/generate', {
|
|
10421
|
+
method: 'POST',
|
|
10422
|
+
headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
|
|
10423
|
+
body: JSON.stringify(body),
|
|
10424
|
+
});
|
|
10425
|
+
if (!res.ok) { const err = new Error(await res.text()); err.status = res.status; throw err; }
|
|
10426
|
+
return res.json();
|
|
10427
|
+
}
|
|
10428
|
+
let jobsTimer = null;
|
|
10429
|
+
async function pollJobs() {
|
|
10430
|
+
if (!GENERATION) return;
|
|
10431
|
+
try {
|
|
10432
|
+
const res = await fetch('/api/jobs', { cache: 'no-store' });
|
|
10433
|
+
if (!res.ok) throw new Error(await res.text());
|
|
10434
|
+
GEN = await res.json();
|
|
10435
|
+
} catch (err) {
|
|
10436
|
+
$('note').textContent = ' Job status unavailable: ' + err.message;
|
|
10437
|
+
return;
|
|
10438
|
+
}
|
|
10439
|
+
renderJobs();
|
|
10440
|
+
renderHeader();
|
|
10441
|
+
let changed = false;
|
|
10442
|
+
for (const job of GEN.jobs) {
|
|
10443
|
+
if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) { ui.settled.add(job.id); changed = true; }
|
|
10444
|
+
if (job.phase === 'review' && !ui.settled.has(job.id + ':review')) { ui.settled.add(job.id + ':review'); changed = true; }
|
|
10445
|
+
}
|
|
10446
|
+
if (changed) refresh();
|
|
10447
|
+
const active = GEN.jobs.some((job) => ACTIVE_PHASES.has(job.phase) && job.phase !== 'review');
|
|
10448
|
+
clearTimeout(jobsTimer);
|
|
10449
|
+
if (active) jobsTimer = setTimeout(pollJobs, 2000);
|
|
10450
|
+
}
|
|
10451
|
+
function renderJobs() {
|
|
10452
|
+
const host = $('jobs');
|
|
10453
|
+
host.textContent = '';
|
|
10454
|
+
if (!GENERATION) return;
|
|
10455
|
+
for (const job of GEN.jobs) {
|
|
10456
|
+
if (ui.settled.has(job.id + ':dismissed')) continue;
|
|
10457
|
+
const row = el('div', 'job');
|
|
10458
|
+
const ph = el('span', 'ph');
|
|
10459
|
+
ph.append(el('i', 'dot ' + PHASE_TONE[job.phase]), document.createTextNode(job.phase));
|
|
10460
|
+
const what = (job.mode === 'resume' ? 'resume ' : 'generate ') + job.keys.length + (job.keys.length === 1 ? ' asset' : ' assets') +
|
|
10461
|
+
(job.project ? ' in ' + job.project : '');
|
|
10462
|
+
const last = el('span', 'last', what + (job.messages.length ? ' \u2014 ' + job.messages[job.messages.length - 1].trim() : ''));
|
|
10463
|
+
last.title = job.keys.join('\\n');
|
|
10464
|
+
const acts = el('div', 'acts');
|
|
10465
|
+
if (job.phase === 'review' && job.review.length) {
|
|
10466
|
+
const b = el('button', 'primary', 'Review ' + job.review.length);
|
|
10467
|
+
b.type = 'button'; b.onclick = () => openReview(job.id);
|
|
10468
|
+
acts.append(b);
|
|
10469
|
+
}
|
|
10470
|
+
const logBtn = el('button', null, ui.logs.has(job.id) ? 'Hide log' : 'Log');
|
|
10471
|
+
logBtn.type = 'button';
|
|
10472
|
+
logBtn.onclick = () => { ui.logs.has(job.id) ? ui.logs.delete(job.id) : ui.logs.add(job.id); renderJobs(); };
|
|
10473
|
+
acts.append(logBtn);
|
|
10474
|
+
if (!ACTIVE_PHASES.has(job.phase)) {
|
|
10475
|
+
const d = el('button', null, 'Dismiss');
|
|
10476
|
+
d.type = 'button'; d.onclick = () => { ui.settled.add(job.id + ':dismissed'); renderJobs(); };
|
|
10477
|
+
acts.append(d);
|
|
10478
|
+
}
|
|
10479
|
+
row.append(ph, last, acts);
|
|
10480
|
+
if (ui.logs.has(job.id)) row.append(el('pre', null, job.messages.join('\\n')));
|
|
10481
|
+
host.append(row);
|
|
10482
|
+
}
|
|
10483
|
+
}
|
|
10484
|
+
function budgetLine() {
|
|
10485
|
+
const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
|
|
10486
|
+
const parts = [];
|
|
10487
|
+
for (const provider of [...providers].sort()) {
|
|
10488
|
+
const left = remainingBudget(provider);
|
|
10489
|
+
const unit = GEN.units[provider] || 'generations';
|
|
10490
|
+
if (left !== null) parts.push(provider + ': ' + fmtCost(unit, Math.round(left * 100) / 100) + ' left');
|
|
10491
|
+
}
|
|
10492
|
+
// An unkeyed ceiling belongs to whichever single provider first spends it.
|
|
10493
|
+
if (GEN.budget.amount !== undefined && !Object.keys(GEN.spent).length) parts.push(GEN.budget.amount + ' available');
|
|
10494
|
+
return parts.length ? 'session budget \u2014 ' + parts.join(' \xB7 ') : '';
|
|
10495
|
+
}
|
|
10496
|
+
|
|
10497
|
+
// The confirm step: what will be sent, what it is estimated to cost, and what
|
|
10498
|
+
// this session may still spend. Mirrors gen's "Spend \u2026 on N asset(s)?" prompt.
|
|
10499
|
+
function generateDialog(items, { project = null, force = false, resume = false } = {}) {
|
|
10500
|
+
const host = $('dialog-host');
|
|
10501
|
+
host.textContent = '';
|
|
10502
|
+
const wrap = el('div', 'dialog');
|
|
10503
|
+
const form = el('form');
|
|
10504
|
+
form.append(el('h3', null, resume ? 'Resume ' + items.length + (items.length === 1 ? ' asset' : ' assets')
|
|
10505
|
+
: (force ? 'Regenerate ' : 'Generate ') + items.length + (items.length === 1 ? ' asset' : ' assets')));
|
|
10506
|
+
const table = el('table');
|
|
10507
|
+
const thead = el('tr'); thead.append(el('th', null, 'asset'), el('th', null, 'state'), el('th', 'n', 'candidates'), el('th', 'n', 'estimate'));
|
|
10508
|
+
table.append(thead);
|
|
10509
|
+
const byProvider = new Map();
|
|
10510
|
+
for (const item of items) {
|
|
10511
|
+
const tr = el('tr');
|
|
10512
|
+
tr.append(el('td', 'mono', item.key), el('td', null, item.state), el('td', 'n', item.candidates ?? '\u2014'),
|
|
10513
|
+
el('td', 'n', resume ? 'no cost' : fmtCost(item.costUnit, item.estimatedCost ?? 0)));
|
|
10514
|
+
table.append(tr);
|
|
10515
|
+
if (!resume) {
|
|
10516
|
+
const g = byProvider.get(item.provider) || { cost: 0, unit: item.costUnit };
|
|
10517
|
+
g.cost += item.estimatedCost ?? 0; byProvider.set(item.provider, g);
|
|
10518
|
+
}
|
|
10519
|
+
}
|
|
10520
|
+
form.append(table);
|
|
10521
|
+
const sum = el('div', 'sum');
|
|
10522
|
+
if (resume) {
|
|
10523
|
+
sum.append(el('div', null, 'Polls, reviews, and downloads existing provider work. Nothing is submitted.'));
|
|
10524
|
+
} else {
|
|
10525
|
+
for (const [provider, g] of byProvider) {
|
|
10526
|
+
const left = remainingBudget(provider);
|
|
10527
|
+
const line = el('div');
|
|
10528
|
+
line.append(el('b', null, provider + ': ' + fmtCost(g.unit, Math.round(g.cost * 100) / 100)));
|
|
10529
|
+
line.append(document.createTextNode(left === null ? ' \u2014 no session budget for this provider'
|
|
10530
|
+
: ' \xB7 ' + fmtCost(g.unit, Math.round(left * 100) / 100) + ' of the session budget left'));
|
|
10531
|
+
if (left !== null && g.cost > left) line.className = 'warn';
|
|
10532
|
+
sum.append(line);
|
|
10533
|
+
}
|
|
10534
|
+
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.'));
|
|
10535
|
+
sum.append(el('div', null, 'Candidate sets land in review; you choose from them here before anything is downloaded.'));
|
|
10536
|
+
}
|
|
10537
|
+
form.append(sum);
|
|
10538
|
+
const actions = el('div', 'actions');
|
|
10539
|
+
const go = el('button', 'primary', resume ? 'Resume' : 'Generate');
|
|
10540
|
+
go.type = 'submit';
|
|
10541
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { host.textContent = ''; };
|
|
10542
|
+
const msg = el('span', 'msg');
|
|
10543
|
+
actions.append(go, cancel, msg);
|
|
10544
|
+
form.append(actions);
|
|
10545
|
+
form.onsubmit = async (e) => {
|
|
10546
|
+
e.preventDefault();
|
|
10547
|
+
go.disabled = true; msg.textContent = '';
|
|
10548
|
+
try {
|
|
10549
|
+
const body = { keys: items.map((i) => i.key) };
|
|
10550
|
+
if (project) body.project = project;
|
|
10551
|
+
if (force) body.force = true;
|
|
10552
|
+
if (resume) body.resume = true;
|
|
10553
|
+
const job = await postGenerate(body);
|
|
10554
|
+
host.textContent = '';
|
|
10555
|
+
GEN.jobs.unshift(job);
|
|
10556
|
+
renderJobs();
|
|
10557
|
+
pollJobs();
|
|
10558
|
+
} catch (err) {
|
|
10559
|
+
go.disabled = false;
|
|
10560
|
+
msg.textContent = err.message;
|
|
10561
|
+
}
|
|
10562
|
+
};
|
|
10563
|
+
wrap.append(form);
|
|
10564
|
+
host.append(wrap);
|
|
10565
|
+
setTimeout(() => go.focus(), 0);
|
|
10566
|
+
}
|
|
10567
|
+
|
|
10568
|
+
function openReview(jobId) {
|
|
10569
|
+
const host = $('dialog-host');
|
|
10570
|
+
host.textContent = '';
|
|
10571
|
+
const scrim = el('div', 'sheet-scrim');
|
|
10572
|
+
scrim.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
|
|
10573
|
+
const panel = el('div', 'sheet review-host');
|
|
10574
|
+
const bar = el('div', 'rbar');
|
|
10575
|
+
const close = el('button', null, 'Back to gallery'); close.type = 'button';
|
|
10576
|
+
close.onclick = () => { host.textContent = ''; pollJobs(); refresh(); };
|
|
10577
|
+
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.'));
|
|
10578
|
+
const frame = el('iframe');
|
|
10579
|
+
frame.src = '/review/' + encodeURIComponent(jobId);
|
|
10580
|
+
frame.title = 'Candidate review';
|
|
10581
|
+
panel.append(bar, frame);
|
|
10582
|
+
host.append(scrim, panel);
|
|
10583
|
+
}
|
|
10584
|
+
window.addEventListener('message', (e) => {
|
|
10585
|
+
if (e.origin !== location.origin || !e.data || e.data.type !== 'pixelkiln:review-applied') return;
|
|
10586
|
+
setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
|
|
10587
|
+
});
|
|
10588
|
+
|
|
10589
|
+
function visibleItems() {
|
|
10590
|
+
const q = ui.q.trim().toLowerCase();
|
|
10591
|
+
let items = snap.items.filter((item) => {
|
|
10592
|
+
if (ui.states.size && !ui.states.has(item.state)) return false;
|
|
10593
|
+
if (ui.providers.size && !ui.providers.has(item.provider)) return false;
|
|
10594
|
+
if (ui.generators.size && !ui.generators.has(item.generator)) return false;
|
|
10595
|
+
if (ui.projects.size && !ui.projects.has(item.project)) return false;
|
|
10596
|
+
if (!q) return true;
|
|
10597
|
+
const hay = [item.id, item.prompt, item.currentPrompt, item.jobId, item.objectId,
|
|
10598
|
+
item.recordedSpecHash, item.currentSpecHash, item.category, ...(item.tags || []),
|
|
10599
|
+
...item.outputs.flatMap((o) => [o.path, o.sha256])].filter(Boolean).join('\\n').toLowerCase();
|
|
10600
|
+
return hay.includes(q);
|
|
10601
|
+
});
|
|
10602
|
+
const when = (item) => item.downloadedAt || item.submittedAt || '';
|
|
10603
|
+
const sorters = {
|
|
10604
|
+
key: (a, b) => a.id.localeCompare(b.id),
|
|
10605
|
+
newest: (a, b) => when(b).localeCompare(when(a)) || a.id.localeCompare(b.id),
|
|
10606
|
+
oldest: (a, b) => when(a).localeCompare(when(b)) || a.id.localeCompare(b.id),
|
|
10607
|
+
cost: (a, b) => (b.cost - a.cost) || a.id.localeCompare(b.id),
|
|
10608
|
+
size: (a, b) => (b.width * b.height - a.width * a.height) || a.id.localeCompare(b.id),
|
|
10609
|
+
};
|
|
10610
|
+
items.sort(sorters[ui.sort] || sorters.key);
|
|
10611
|
+
return items;
|
|
10612
|
+
}
|
|
10613
|
+
|
|
10614
|
+
function renderHeader() {
|
|
10615
|
+
$('editing').hidden = !EDITABLE;
|
|
10616
|
+
$('foot-edit').hidden = EDITABLE;
|
|
10617
|
+
$('foot-editing').hidden = !EDITABLE;
|
|
10618
|
+
$('foot-gen').hidden = !GENERATION;
|
|
10619
|
+
const p = $('project');
|
|
10620
|
+
p.textContent = '';
|
|
10621
|
+
if (snap.workspace) {
|
|
10622
|
+
p.append(el('b', null, 'workspace'), ' ', document.createTextNode(shortPath(snap.workspace.path)));
|
|
10623
|
+
p.title = snap.workspace.path;
|
|
10624
|
+
} else {
|
|
10625
|
+
p.append(el('b', null, snap.project.name), ' ', document.createTextNode(shortPath(snap.project.manifest)));
|
|
10626
|
+
p.title = snap.project.manifest + '\\nlockfile: ' + snap.project.lock;
|
|
10627
|
+
}
|
|
10628
|
+
|
|
10629
|
+
const t = $('totals');
|
|
10630
|
+
t.textContent = '';
|
|
10631
|
+
if (snap.workspace) {
|
|
10632
|
+
const broken = snap.workspace.projects.filter((pr) => pr.error).length;
|
|
10633
|
+
const w = el('span', null, '');
|
|
10634
|
+
w.append(el('b', null, snap.workspace.projects.length), document.createTextNode(' project' +
|
|
10635
|
+
(snap.workspace.projects.length === 1 ? '' : 's') + (broken ? ' (' + broken + ' unreadable)' : '')));
|
|
10636
|
+
t.append(w);
|
|
10637
|
+
}
|
|
10638
|
+
const n = snap.totals.entries;
|
|
10639
|
+
t.append(el('span', null, ''));
|
|
10640
|
+
t.lastChild.append(el('b', null, n), document.createTextNode(' lock ' + (n === 1 ? 'entry' : 'entries')));
|
|
10641
|
+
const declared = snap.items.filter((i) => i.declared).length;
|
|
10642
|
+
t.append(el('span', null, declared + ' declared by ' + (snap.workspace ? 'a manifest' : 'the manifest')));
|
|
10643
|
+
const recorded = fmtSpend(snap.totals.spendByUnit);
|
|
10644
|
+
t.append(el('span', null, recorded === 'no spend recorded' ? recorded : recorded + ' recorded'));
|
|
10645
|
+
if (snap.filter.styles.length || snap.filter.assets.length) {
|
|
10646
|
+
t.append(el('span', 'state-warn', 'filtered: ' +
|
|
10647
|
+
[snap.filter.styles.length ? '--style ' + snap.filter.styles.join(',') : '',
|
|
10648
|
+
snap.filter.assets.length ? '--only ' + snap.filter.assets.join(',') : ''].filter(Boolean).join(' ')));
|
|
10649
|
+
}
|
|
10650
|
+
t.append(el('span', null, 'snapshot ' + fmtWhen(snap.generatedAt)));
|
|
10651
|
+
if (GENERATION) {
|
|
10652
|
+
const line = budgetLine();
|
|
10653
|
+
if (line) t.append(el('span', 'budget', line));
|
|
10654
|
+
}
|
|
10655
|
+
|
|
10656
|
+
const chips = $('chips');
|
|
10657
|
+
chips.textContent = '';
|
|
10658
|
+
const counts = (pick) => {
|
|
10659
|
+
const m = new Map();
|
|
10660
|
+
for (const item of snap.items) { const k = pick(item); m.set(k, (m.get(k) || 0) + 1); }
|
|
10661
|
+
return m;
|
|
10662
|
+
};
|
|
10663
|
+
const chip = (label, count, set, key, tone) => {
|
|
10664
|
+
const b = el('button', 'chip' + (set.has(key) ? ' on' : ''));
|
|
10665
|
+
b.type = 'button';
|
|
10666
|
+
b.setAttribute('aria-pressed', String(set.has(key)));
|
|
10667
|
+
if (tone) b.append(el('i', 'dot ' + tone));
|
|
10668
|
+
b.append(document.createTextNode(label), el('span', 'n', count));
|
|
10669
|
+
b.onclick = () => { set.has(key) ? set.delete(key) : set.add(key); render(); };
|
|
10670
|
+
return b;
|
|
10671
|
+
};
|
|
10672
|
+
if (snap.workspace) {
|
|
10673
|
+
const byProject = counts((i) => i.project);
|
|
10674
|
+
for (const pr of snap.workspace.projects) {
|
|
10675
|
+
if (byProject.has(pr.id) || ui.projects.has(pr.id)) chips.append(chip(pr.id, byProject.get(pr.id) || 0, ui.projects, pr.id));
|
|
10676
|
+
}
|
|
10677
|
+
chips.append(el('span', 'sep'));
|
|
10678
|
+
}
|
|
10679
|
+
const byState = counts((i) => i.state);
|
|
10680
|
+
for (const s of STATE_ORDER) if (byState.has(s)) chips.append(chip(s, byState.get(s), ui.states, s, STATE_TONE[s]));
|
|
10681
|
+
const byProvider = counts((i) => i.provider);
|
|
10682
|
+
if (byProvider.size > 1 || ui.providers.size) {
|
|
10683
|
+
chips.append(el('span', 'sep'));
|
|
10684
|
+
for (const [k, v] of [...byProvider].sort()) chips.append(chip(k, v, ui.providers, k));
|
|
10685
|
+
}
|
|
10686
|
+
const byGen = counts((i) => i.generator);
|
|
10687
|
+
if (byGen.size > 1 || ui.generators.size) {
|
|
10688
|
+
chips.append(el('span', 'sep'));
|
|
10689
|
+
for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
|
|
10690
|
+
}
|
|
10691
|
+
if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
|
|
10692
|
+
const clear = el('button', 'chip', 'clear filters');
|
|
10693
|
+
clear.type = 'button';
|
|
10694
|
+
clear.onclick = () => clearFilters();
|
|
10695
|
+
chips.append(el('span', 'sep'), clear);
|
|
10696
|
+
}
|
|
10697
|
+
}
|
|
10698
|
+
|
|
10699
|
+
function clearFilters() {
|
|
10700
|
+
ui.states.clear(); ui.providers.clear(); ui.generators.clear(); ui.projects.clear();
|
|
10701
|
+
ui.q = ''; $('q').value = '';
|
|
10702
|
+
render();
|
|
10703
|
+
}
|
|
10704
|
+
|
|
10705
|
+
function thumb(item) {
|
|
10706
|
+
const cell = el('div', 'cell');
|
|
10707
|
+
const shown = item.outputs.filter((o) => o.url);
|
|
10708
|
+
if (!shown.length) {
|
|
10709
|
+
cell.classList.add('none');
|
|
10710
|
+
cell.append(el('span', null, item.state === 'missing' ? 'not generated yet'
|
|
10711
|
+
: item.state === 'in-flight' ? (item.status === 'review' ? 'awaiting review' : 'awaiting provider')
|
|
10712
|
+
: item.state === 'recoverable' ? 'ready to fetch'
|
|
10713
|
+
: item.state === 'failed' ? 'generation failed'
|
|
10714
|
+
: item.state === 'blocked' ? 'waiting on its parent'
|
|
10715
|
+
: item.outputs.length ? 'file missing on disk' : 'no output recorded'));
|
|
10716
|
+
return cell;
|
|
10717
|
+
}
|
|
10718
|
+
const w = item.width, h = item.height;
|
|
10719
|
+
if (shown.length > 1 && !isFrameSet(item)) {
|
|
10720
|
+
const multi = el('div', 'multi');
|
|
10721
|
+
for (const o of shown.slice(0, 4)) {
|
|
10722
|
+
const box = el('div');
|
|
10723
|
+
const img = el('img');
|
|
10724
|
+
img.src = o.url; img.alt = o.role || item.assetId; img.loading = 'lazy';
|
|
10725
|
+
const s = displayScale(w, h, 66, 52);
|
|
10726
|
+
if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
|
|
10727
|
+
box.append(img); multi.append(box);
|
|
10728
|
+
}
|
|
10729
|
+
cell.append(multi, el('span', 'badge', shown.length + ' outputs'));
|
|
10730
|
+
return cell;
|
|
10731
|
+
}
|
|
10732
|
+
const img = el('img');
|
|
10733
|
+
img.src = shown[0].url; img.alt = item.assetId; img.loading = 'lazy';
|
|
10734
|
+
const s = displayScale(w, h, 156, 116);
|
|
10735
|
+
if (s >= 1) { img.width = w * s; img.height = h * s; } else img.className = 'fit';
|
|
10736
|
+
cell.append(img);
|
|
10737
|
+
if (isFrameSet(item)) cell.append(el('span', 'badge', '\u25B8 ' + shown.length + ' frames'));
|
|
10738
|
+
else if (s >= 1 && s !== 1) cell.append(el('span', 'badge', s + '\xD7'));
|
|
10739
|
+
return cell;
|
|
10740
|
+
}
|
|
10741
|
+
|
|
10742
|
+
function card(item) {
|
|
10743
|
+
const slot = ui.compare.indexOf(item.id);
|
|
10744
|
+
const c = el('button', 'card' + (item.outputs.some((o) => o.url) ? '' : ' ghost') + (ui.open === item.id ? ' active' : '') + (slot >= 0 ? ' compared' : ''));
|
|
10745
|
+
c.type = 'button';
|
|
10746
|
+
c.dataset.key = item.id;
|
|
10747
|
+
c.setAttribute('aria-label', item.id + ', ' + item.state + (slot >= 0 ? ', in comparison' : ''));
|
|
10748
|
+
const body = el('div', 'cbody');
|
|
10749
|
+
body.append(el('div', 'aid', item.assetId));
|
|
10750
|
+
const meta = el('div', 'cmeta');
|
|
10751
|
+
const st = el('span', 'st');
|
|
10752
|
+
st.append(el('i', 'dot ' + STATE_TONE[item.state]), document.createTextNode(item.state));
|
|
10753
|
+
meta.append(st, el('span', null, item.width + '\xD7' + item.height));
|
|
10754
|
+
if (item.cost) meta.append(el('span', null, fmtCost(item.costUnit, item.cost)));
|
|
10755
|
+
body.append(meta);
|
|
10756
|
+
const cell = thumb(item);
|
|
10757
|
+
if (slot >= 0) cell.append(el('span', 'slot', String(slot + 1)));
|
|
10758
|
+
c.append(cell, body);
|
|
10759
|
+
c.onclick = (e) => { if (e.shiftKey) toggleCompare(item.id); else openItem(item.id); };
|
|
10760
|
+
return c;
|
|
10761
|
+
}
|
|
10762
|
+
|
|
10763
|
+
function renderMain(items) {
|
|
10764
|
+
const root = $('root');
|
|
10765
|
+
root.textContent = '';
|
|
10766
|
+
if (!snap.items.length) {
|
|
10767
|
+
const e = el('div', 'empty');
|
|
10768
|
+
e.append(el('p', null, 'Nothing has been generated for this project yet.'));
|
|
10769
|
+
e.append(el('p', null, 'pixelkiln plan shows what the manifest would make; pixelkiln gen makes it.'));
|
|
10770
|
+
root.append(e);
|
|
10771
|
+
return;
|
|
10772
|
+
}
|
|
10773
|
+
if (!items.length) {
|
|
10774
|
+
const e = el('div', 'empty');
|
|
10775
|
+
e.append(el('p', null, 'No generations match these filters.'));
|
|
10776
|
+
const b = el('button', null, 'Clear filters');
|
|
10777
|
+
b.type = 'button';
|
|
10778
|
+
b.onclick = clearFilters;
|
|
10779
|
+
e.append(b);
|
|
10780
|
+
root.append(e);
|
|
10781
|
+
return;
|
|
10782
|
+
}
|
|
10783
|
+
// Building every card for a very large project on each keystroke is the one
|
|
10784
|
+
// thing that makes this page feel slow, so a render is capped and the rest
|
|
10785
|
+
// is one click away. Filters usually narrow well below the cap anyway.
|
|
10786
|
+
const hidden = Math.max(0, items.length - ui.limit);
|
|
10787
|
+
const shown = hidden ? items.slice(0, ui.limit) : items;
|
|
10788
|
+
// A workspace always breaks on project: lock keys repeat across projects,
|
|
10789
|
+
// so a flat wall of "anvil" cards would be ambiguous. Style grouping stays
|
|
10790
|
+
// optional inside each project, exactly as it is for one project.
|
|
10791
|
+
const unfiltered = !ui.projects.size && !ui.q && !ui.states.size && !ui.providers.size && !ui.generators.size;
|
|
10792
|
+
const projects = snap.workspace
|
|
10793
|
+
? snap.workspace.projects.filter((pr) => ui.projects.size
|
|
10794
|
+
? ui.projects.has(pr.id)
|
|
10795
|
+
: unfiltered || shown.some((i) => i.project === pr.id))
|
|
10796
|
+
: [null];
|
|
10797
|
+
const sections = [];
|
|
10798
|
+
for (const pr of projects) {
|
|
10799
|
+
const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
|
|
10800
|
+
const styleSections = ui.group === 'style'
|
|
10801
|
+
? snap.styles.filter((s) => !pr || s.project === pr.id)
|
|
10802
|
+
.map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length)
|
|
10803
|
+
: (inProject.length ? [{ style: null, items: inProject }] : []);
|
|
10804
|
+
if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
|
|
10805
|
+
sections.push(...styleSections);
|
|
10806
|
+
}
|
|
10807
|
+
for (const sec of sections) {
|
|
10808
|
+
if (sec.project) {
|
|
10809
|
+
root.append(projectHeader(sec.project, sec.count));
|
|
10810
|
+
continue;
|
|
10811
|
+
}
|
|
10812
|
+
const wrap = el('section', 'style');
|
|
10813
|
+
if (sec.style) {
|
|
10814
|
+
const s = sec.style;
|
|
10815
|
+
const head = el('div', 'shead');
|
|
10816
|
+
head.append(el('h2', null, s.id));
|
|
10817
|
+
const meta = el('div', 'meta');
|
|
10818
|
+
meta.append(el('span', null, s.provider), el('span', null, s.generator));
|
|
10819
|
+
if (s.outDir) meta.append(el('span', 'mono', s.outDir));
|
|
10820
|
+
if (s.palette.length) {
|
|
10821
|
+
const sw = el('span', 'swatches');
|
|
10822
|
+
for (const c of s.palette) { const i = el('i'); i.style.background = c; i.title = c; sw.append(i); }
|
|
10823
|
+
meta.append(sw);
|
|
10824
|
+
}
|
|
10825
|
+
if (s.quality) meta.append(el('span', null, 'quality profile'));
|
|
10826
|
+
meta.append(el('span', null, sec.items.length + ' of ' + s.items + (s.items === 1 ? ' asset' : ' assets')));
|
|
10827
|
+
if (Object.keys(s.spendByUnit).length) meta.append(el('span', null, fmtSpend(s.spendByUnit)));
|
|
10828
|
+
head.append(meta);
|
|
10829
|
+
const addKey = 'new:' + (s.project || '') + ':' + s.id;
|
|
10830
|
+
const tools = el('div', 'tools');
|
|
10831
|
+
if (s.outDir && s.candidates !== null) tools.append(candidatesControl(s));
|
|
10832
|
+
if (GENERATION && s.actionable.keys.length) {
|
|
10833
|
+
const g = el('button', 'primary', 'Generate ' + s.actionable.keys.length + ' \xB7 ' + fmtCost(s.actionable.costUnit, Math.round(s.actionable.cost * 100) / 100));
|
|
10834
|
+
g.type = 'button';
|
|
10835
|
+
g.onclick = () => generateDialog(snap.items.filter((i) => i.project === s.project && s.actionable.keys.includes(i.key)), { project: s.project });
|
|
10836
|
+
tools.append(g);
|
|
10837
|
+
}
|
|
10838
|
+
const styleKey = 'style:' + (s.project || '') + ':' + s.id;
|
|
10839
|
+
if (EDITABLE && s.outDir) {
|
|
10840
|
+
const es = el('button', null, ui.editing === styleKey ? 'Cancel' : 'Edit style');
|
|
10841
|
+
es.type = 'button';
|
|
10842
|
+
es.onclick = () => { ui.editing = ui.editing === styleKey ? null : styleKey; ui.notice = null; render(); };
|
|
10843
|
+
tools.append(es);
|
|
10844
|
+
const add = el('button', 'add', ui.editing === addKey ? 'Cancel' : '+ Add asset');
|
|
10845
|
+
add.type = 'button';
|
|
10846
|
+
add.onclick = () => { ui.editing = ui.editing === addKey ? null : addKey; render(); };
|
|
10847
|
+
tools.append(add);
|
|
10848
|
+
}
|
|
10849
|
+
if (tools.childNodes.length) head.append(tools);
|
|
10850
|
+
wrap.append(head);
|
|
10851
|
+
if (ui.notice && ui.notice.id === styleKey) wrap.append(el('div', 'notice', ui.notice.text));
|
|
10852
|
+
if (ui.editing === styleKey) wrap.append(styleForm(s));
|
|
10853
|
+
if (ui.editing === addKey) wrap.append(addAssetForm(s));
|
|
10854
|
+
}
|
|
10855
|
+
const grid = el('div', 'grid');
|
|
10856
|
+
for (const item of sec.items) grid.append(card(item));
|
|
10857
|
+
wrap.append(grid);
|
|
10858
|
+
root.append(wrap);
|
|
10859
|
+
}
|
|
10860
|
+
if (hidden) {
|
|
10861
|
+
const more = el('div', 'empty');
|
|
10862
|
+
more.append(el('p', null, hidden + ' more ' + (hidden === 1 ? 'generation is' : 'generations are') + ' not shown.'));
|
|
10863
|
+
const b = el('button', null, 'Show all ' + items.length);
|
|
10864
|
+
b.type = 'button';
|
|
10865
|
+
b.onclick = () => { ui.limit = Infinity; render(); };
|
|
10866
|
+
more.append(b);
|
|
10867
|
+
root.append(more);
|
|
10868
|
+
}
|
|
10869
|
+
}
|
|
10870
|
+
|
|
10871
|
+
function projectHeader(pr, count) {
|
|
10872
|
+
const head = el('section', 'project');
|
|
10873
|
+
const line = el('div', 'phead');
|
|
10874
|
+
line.append(el('h2', null, pr.id));
|
|
10875
|
+
const meta = el('div', 'meta');
|
|
10876
|
+
if (pr.name !== pr.id) meta.append(el('span', null, pr.name));
|
|
10877
|
+
meta.append(el('span', 'mono', shortPath(pr.manifest)));
|
|
10878
|
+
if (pr.account) meta.append(el('span', null, pr.account));
|
|
10879
|
+
if (pr.error) {
|
|
10880
|
+
meta.append(el('span', 'state-bad', 'unreadable: ' + pr.error));
|
|
10881
|
+
} else {
|
|
10882
|
+
meta.append(el('span', null, count + ' of ' + pr.items + ' shown \xB7 ' + pr.entries + ' lock ' + (pr.entries === 1 ? 'entry' : 'entries')));
|
|
10883
|
+
if (Object.keys(pr.spendByUnit).length) meta.append(el('span', null, fmtSpend(pr.spendByUnit)));
|
|
10884
|
+
}
|
|
10885
|
+
line.append(meta);
|
|
10886
|
+
head.append(line);
|
|
10887
|
+
return head;
|
|
10888
|
+
}
|
|
10889
|
+
|
|
10890
|
+
// How many images one generation returns for this style. A provider option
|
|
10891
|
+
// where the adapter has one; a fact of the generator where it does not.
|
|
10892
|
+
function candidatesControl(style) {
|
|
10893
|
+
const wrap = el('span', 'cand');
|
|
10894
|
+
const editKey = 'cand:' + (style.project || '') + ':' + style.id;
|
|
10895
|
+
const declared = snap.items.filter((i) => i.project === style.project && i.styleId === style.id && i.declared).length;
|
|
10896
|
+
if (!(EDITABLE && style.candidatesEditable) || ui.editing !== editKey) {
|
|
10897
|
+
wrap.append(el('span', null, style.candidates + (style.candidates === 1 ? ' candidate' : ' candidates') + ' per generation'));
|
|
10898
|
+
if (EDITABLE && style.candidatesEditable) {
|
|
10899
|
+
const b = el('button', null, 'change'); b.type = 'button';
|
|
10900
|
+
b.onclick = () => { ui.editing = editKey; render(); };
|
|
10901
|
+
wrap.append(b);
|
|
10902
|
+
} else if (style.provider === 'pixellab') {
|
|
10903
|
+
wrap.title = 'PixelLab: map and pixflux return one image per generation; a 1dir style returns 4\u201364 for its size.';
|
|
10904
|
+
}
|
|
10905
|
+
return wrap;
|
|
10906
|
+
}
|
|
10907
|
+
const input = el('input'); input.type = 'number'; input.min = '1'; input.max = '64'; input.value = String(style.candidates);
|
|
10908
|
+
const save = el('button', 'primary', 'Save'); save.type = 'button';
|
|
10909
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
|
|
10910
|
+
const note = el('span', null, 'affects ' + declared + (declared === 1 ? ' asset' : ' assets') + ' \u2014 they become stale');
|
|
10911
|
+
save.onclick = async () => {
|
|
10912
|
+
save.disabled = true;
|
|
10913
|
+
try {
|
|
10914
|
+
const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
|
|
10915
|
+
const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch: { candidates: Number(input.value) } };
|
|
10916
|
+
if (style.project) body.project = style.project;
|
|
10917
|
+
snap = await postEdit(body);
|
|
10918
|
+
ui.editing = null;
|
|
10919
|
+
render();
|
|
10920
|
+
} catch (err) {
|
|
10921
|
+
save.disabled = false;
|
|
10922
|
+
note.textContent = err.message;
|
|
10923
|
+
note.className = 'state-bad';
|
|
10924
|
+
}
|
|
10925
|
+
};
|
|
10926
|
+
wrap.append(input, save, cancel, note);
|
|
10927
|
+
setTimeout(() => input.focus(), 0);
|
|
10928
|
+
return wrap;
|
|
10929
|
+
}
|
|
10930
|
+
|
|
10931
|
+
function generateActions(item) {
|
|
10932
|
+
const row = el('div', 'gen');
|
|
10933
|
+
if (!GENERATION || !item.declared || item.currentSpecHash === null) return row;
|
|
10934
|
+
const cost = item.estimatedCost === null ? '' : ' \xB7 ' + fmtCost(item.costUnit, item.estimatedCost);
|
|
10935
|
+
const add = (label, cls, opts) => {
|
|
10936
|
+
const b = el('button', cls, label); b.type = 'button';
|
|
10937
|
+
b.onclick = () => generateDialog([item], { project: item.project, ...opts });
|
|
10938
|
+
row.append(b);
|
|
10939
|
+
};
|
|
10940
|
+
if (item.state === 'missing' || item.state === 'stale' || item.state === 'failed') add('Generate' + cost, 'primary', {});
|
|
10941
|
+
else if (item.state === 'ok') add('Regenerate' + cost, null, { force: true });
|
|
10942
|
+
if (item.state === 'in-flight' || item.state === 'recoverable') {
|
|
10943
|
+
// A job already parked this asset in review: go straight to its sheet.
|
|
10944
|
+
const holder = GEN.jobs.find((job) => job.phase === 'review' && job.project === item.project && job.review.includes(item.key));
|
|
10945
|
+
if (holder) {
|
|
10946
|
+
const b = el('button', 'primary', 'Review candidates'); b.type = 'button';
|
|
10947
|
+
b.onclick = () => openReview(holder.id);
|
|
10948
|
+
row.append(b);
|
|
10949
|
+
} else {
|
|
10950
|
+
add(item.status === 'review' ? 'Resume review' : 'Resume \xB7 no cost', 'primary', { resume: true });
|
|
10951
|
+
}
|
|
10952
|
+
}
|
|
10953
|
+
return row;
|
|
10954
|
+
}
|
|
10955
|
+
|
|
10956
|
+
// ---- compare ---------------------------------------------------------------
|
|
10957
|
+
|
|
10958
|
+
function toggleCompare(id) {
|
|
10959
|
+
const at = ui.compare.indexOf(id);
|
|
10960
|
+
if (at >= 0) ui.compare.splice(at, 1);
|
|
10961
|
+
else if (ui.compare.length >= 4) { $('note').textContent = ' Compare holds four records; remove one first.'; return; }
|
|
10962
|
+
else ui.compare.push(id);
|
|
10963
|
+
$('note').textContent = '';
|
|
10964
|
+
render();
|
|
10965
|
+
}
|
|
10966
|
+
function renderTray() {
|
|
10967
|
+
let tray = $('tray');
|
|
10968
|
+
if (!ui.compare.length) { if (tray) tray.remove(); return; }
|
|
10969
|
+
if (!tray) { tray = el('div', 'tray'); tray.id = 'tray'; document.body.append(tray); }
|
|
10970
|
+
tray.textContent = '';
|
|
10971
|
+
const chips = el('div', 'chipset');
|
|
10972
|
+
for (const id of ui.compare) {
|
|
10973
|
+
const item = snap.items.find((i) => i.id === id);
|
|
10974
|
+
if (!item) continue;
|
|
10975
|
+
const pick = el('span', 'pick');
|
|
10976
|
+
const shown = item.outputs.find((o) => o.url);
|
|
10977
|
+
if (shown) { const t = el('img'); t.src = shown.url; t.alt = ''; pick.append(t); }
|
|
10978
|
+
pick.append(document.createTextNode((item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId));
|
|
10979
|
+
const x = el('button', null, '\xD7'); x.type = 'button'; x.title = 'Remove from comparison';
|
|
10980
|
+
x.onclick = () => toggleCompare(id);
|
|
10981
|
+
pick.append(x);
|
|
10982
|
+
chips.append(pick);
|
|
10983
|
+
}
|
|
10984
|
+
const go = el('button', 'primary', 'Compare ' + ui.compare.length); go.type = 'button';
|
|
10985
|
+
go.disabled = ui.compare.length < 2;
|
|
10986
|
+
go.onclick = () => openCompare();
|
|
10987
|
+
const clear = el('button', null, 'Clear'); clear.type = 'button';
|
|
10988
|
+
clear.onclick = () => { ui.compare = []; render(); };
|
|
10989
|
+
tray.append(chips, go, clear);
|
|
10990
|
+
}
|
|
10991
|
+
function openCompare() {
|
|
10992
|
+
const items = ui.compare.map((id) => snap.items.find((i) => i.id === id)).filter(Boolean);
|
|
10993
|
+
if (items.length < 2) return;
|
|
10994
|
+
const host = $('dialog-host');
|
|
10995
|
+
host.textContent = '';
|
|
10996
|
+
const scrim = el('div', 'sheet-scrim'); scrim.onclick = () => { host.textContent = ''; };
|
|
10997
|
+
const panel = el('div', 'sheet compare-host');
|
|
10998
|
+
const bar = el('div', 'rbar');
|
|
10999
|
+
const close = el('button', null, 'Back to gallery'); close.type = 'button'; close.onclick = () => { host.textContent = ''; };
|
|
11000
|
+
bar.append(close, el('span', null, 'Fields that differ are tinted. Shift-click cards in the gallery to change the set.'));
|
|
11001
|
+
const zoomBar = el('div', 'zoom');
|
|
11002
|
+
zoomBar.append(el('span', null, 'zoom'));
|
|
11003
|
+
const columnWidth = Math.max(160, Math.floor((Math.min(window.innerWidth * 0.96, 1500) - 140) / items.length) - 24);
|
|
11004
|
+
const largest = Math.max(...items.map((i) => Math.max(i.width, i.height)));
|
|
11005
|
+
const auto = displayScale(largest, largest, columnWidth, Math.min(window.innerHeight * 0.5, 520));
|
|
11006
|
+
for (const z of ['fit', 1, 2, 4, 8]) {
|
|
11007
|
+
const b = el('button', ui.compareZoom === String(z) || (ui.compareZoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
|
|
11008
|
+
b.type = 'button'; b.onclick = () => { ui.compareZoom = String(z); openCompare(); };
|
|
11009
|
+
zoomBar.append(b);
|
|
11010
|
+
}
|
|
11011
|
+
bar.append(zoomBar);
|
|
11012
|
+
const zoom = ui.compareZoom === 'auto' ? auto : ui.compareZoom === 'fit' ? Math.min(auto, 1) : Number(ui.compareZoom);
|
|
11013
|
+
|
|
11014
|
+
const body = el('div', 'compare-body');
|
|
11015
|
+
const table = el('table');
|
|
11016
|
+
const thead = el('thead'); const hr = el('tr'); hr.append(el('th', null, ''));
|
|
11017
|
+
for (const item of items) {
|
|
11018
|
+
const th = el('th');
|
|
11019
|
+
const head = el('div', 'chead');
|
|
11020
|
+
const badges = el('div', 'badges');
|
|
11021
|
+
if (item.project) badges.append(el('span', 'sid project', item.project));
|
|
11022
|
+
badges.append(el('span', 'sid', item.styleId));
|
|
11023
|
+
const aid = el('div', 'aid', item.assetId);
|
|
11024
|
+
const openBtn = el('button', 'linkish', 'open record'); openBtn.type = 'button';
|
|
11025
|
+
openBtn.onclick = () => { host.textContent = ''; openItem(item.id); };
|
|
11026
|
+
head.append(badges, aid, openBtn);
|
|
11027
|
+
th.append(head);
|
|
11028
|
+
hr.append(th);
|
|
11029
|
+
}
|
|
11030
|
+
thead.append(hr); table.append(thead);
|
|
11031
|
+
const tbody = el('tbody');
|
|
11032
|
+
|
|
11033
|
+
// Images first, at one shared zoom, so like is compared with like.
|
|
11034
|
+
const imgRow = el('tr'); imgRow.append(el('td', null, 'art'));
|
|
11035
|
+
for (const item of items) {
|
|
11036
|
+
const td = el('td');
|
|
11037
|
+
const box = el('div', 'cimg');
|
|
11038
|
+
const shown = item.outputs.find((o) => o.url);
|
|
11039
|
+
if (shown) {
|
|
11040
|
+
const img = el('img'); img.src = shown.url; img.alt = item.assetId;
|
|
11041
|
+
if (zoom >= 1) { img.width = Math.round(item.width * zoom); img.height = Math.round(item.height * zoom); }
|
|
11042
|
+
else { img.style.maxWidth = '100%'; img.style.height = 'auto'; }
|
|
11043
|
+
box.append(img);
|
|
11044
|
+
} else box.append(el('span', 'none', 'no art on disk'));
|
|
11045
|
+
td.append(box);
|
|
11046
|
+
if (item.outputs.filter((o) => o.url).length > 1) td.append(el('div', 'state-dim', item.outputs.length + ' outputs; first shown'));
|
|
11047
|
+
imgRow.append(td);
|
|
11048
|
+
}
|
|
11049
|
+
tbody.append(imgRow);
|
|
11050
|
+
|
|
11051
|
+
const rows = [
|
|
11052
|
+
['state', (i) => i.state + (i.reason ? ' \u2014 ' + i.reason : ''), (i) => i.state],
|
|
11053
|
+
['provider', (i) => i.provider],
|
|
11054
|
+
['generator', (i) => i.generator + (i.tileFeature ? ' \xB7 ' + i.tileFeature : '')],
|
|
11055
|
+
['candidates', (i) => i.candidates ?? '\u2014'],
|
|
11056
|
+
['size', (i) => i.width + ' \xD7 ' + i.height],
|
|
11057
|
+
['cost', (i) => i.status ? fmtCost(i.costUnit, i.cost) : (i.estimatedCost !== null ? 'est. ' + fmtCost(i.costUnit, i.estimatedCost) : '\u2014')],
|
|
11058
|
+
['prompt', (i) => i.prompt, null, 'prompt'],
|
|
11059
|
+
['submitted', (i) => fmtWhen(i.submittedAt) || '\u2014'],
|
|
11060
|
+
['downloaded', (i) => fmtWhen(i.downloadedAt) || '\u2014'],
|
|
11061
|
+
['quality', (i) => i.quality ? i.quality.state + (i.quality.review && i.quality.review.status === 'approved' ? ' by ' + i.quality.review.reviewer : '') : '\u2014'],
|
|
11062
|
+
['spec hash', (i) => i.recordedSpecHash ? i.recordedSpecHash.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
|
|
11063
|
+
['sha256', (i) => i.outputs[0] && i.outputs[0].sha256 ? i.outputs[0].sha256.slice(0, 16) + '\u2026' : '\u2014', null, 'mono'],
|
|
11064
|
+
['tags', (i) => (i.tags || []).filter((t) => !/^(pixelkiln|asset|style):/.test(t)).join(', ') || '\u2014'],
|
|
11065
|
+
];
|
|
11066
|
+
for (const [label, show, keyOf, cls] of rows) {
|
|
11067
|
+
const tr = el('tr'); tr.append(el('td', null, label));
|
|
11068
|
+
const keys = items.map((i) => String((keyOf || show)(i)));
|
|
11069
|
+
const differs = new Set(keys).size > 1;
|
|
11070
|
+
for (const item of items) {
|
|
11071
|
+
const td = el('td', (differs ? 'diff' : '') + (cls === 'mono' ? ' mono' : ''));
|
|
11072
|
+
if (cls === 'prompt') td.append(el('div', 'prompt', show(item))); else td.textContent = String(show(item));
|
|
11073
|
+
tr.append(td);
|
|
11074
|
+
}
|
|
11075
|
+
tbody.append(tr);
|
|
11076
|
+
}
|
|
11077
|
+
table.append(tbody);
|
|
11078
|
+
body.append(table);
|
|
11079
|
+
panel.append(bar, body);
|
|
11080
|
+
host.append(scrim, panel);
|
|
11081
|
+
close.focus({ preventScroll: true });
|
|
11082
|
+
}
|
|
11083
|
+
|
|
11084
|
+
// ---- editing (only when the server minted a session) ---------------------
|
|
11085
|
+
|
|
11086
|
+
// Styles a parent edit reaches: every descendant (via extends) that does not
|
|
11087
|
+
// declare the field itself. Child-wins means an omitted field is inherited,
|
|
11088
|
+
// so the count follows the manifest's own rule rather than guessing.
|
|
11089
|
+
function inheritorsOf(style, field) {
|
|
11090
|
+
const byId = new Map(snap.styles.filter((x) => x.project === style.project).map((x) => [x.id, x]));
|
|
11091
|
+
const out = [];
|
|
11092
|
+
for (const other of byId.values()) {
|
|
11093
|
+
if (other.id === style.id) continue;
|
|
11094
|
+
let cur = other, blocked = false, hops = 0;
|
|
11095
|
+
while (cur && cur.id !== style.id && hops++ < 32) {
|
|
11096
|
+
if (cur.ownFields.includes(field)) { blocked = true; break; }
|
|
11097
|
+
cur = cur.extends ? byId.get(cur.extends) : null;
|
|
11098
|
+
}
|
|
11099
|
+
if (cur && cur.id === style.id && !blocked) out.push(other.id);
|
|
11100
|
+
}
|
|
11101
|
+
return out;
|
|
11102
|
+
}
|
|
11103
|
+
function blastRadius(style, fields) {
|
|
11104
|
+
const styleIds = new Set([style.id]);
|
|
11105
|
+
for (const field of fields) for (const id of inheritorsOf(style, field)) styleIds.add(id);
|
|
11106
|
+
const affected = snap.items.filter((i) => i.project === style.project && i.declared && styleIds.has(i.styleId));
|
|
11107
|
+
const units = new Map();
|
|
11108
|
+
for (const i of affected) if (i.estimatedCost !== null) units.set(i.costUnit, (units.get(i.costUnit) || 0) + i.estimatedCost);
|
|
11109
|
+
return {
|
|
11110
|
+
styles: [...styleIds],
|
|
11111
|
+
assets: affected.length,
|
|
11112
|
+
cost: [...units].map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(' + '),
|
|
11113
|
+
};
|
|
11114
|
+
}
|
|
11115
|
+
const parsePalette = (value) => value.split(/[s,]+/).map((c) => c.trim()).filter(Boolean)
|
|
11116
|
+
.map((c) => '#' + c.replace(/^#/, '').toLowerCase());
|
|
11117
|
+
const paletteValid = (colors) => colors.every((c) => /^#[0-9a-f]{6}$/.test(c));
|
|
11118
|
+
|
|
11119
|
+
function styleForm(style) {
|
|
11120
|
+
const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
|
|
11121
|
+
const own = (field) => style.ownFields.includes(field);
|
|
11122
|
+
const provenance = (field) => own(field) ? 'set on this style'
|
|
11123
|
+
: style.extends ? 'inherited from ' + style.extends + ' \u2014 saving sets a value on this style itself; ' + style.extends + ' is unchanged'
|
|
11124
|
+
: 'default \u2014 saving sets a value on this style itself';
|
|
11125
|
+
const form = el('form', 'edit style-form');
|
|
11126
|
+
form.append(el('h3', null, 'Edit style ' + style.id));
|
|
11127
|
+
const prefix = el('input'); prefix.type = 'text'; prefix.value = style.promptPrefix; prefix.placeholder = 'none';
|
|
11128
|
+
const suffix = el('input'); suffix.type = 'text'; suffix.value = style.promptSuffix; suffix.placeholder = 'none';
|
|
11129
|
+
const palette = el('input'); palette.type = 'text'; palette.value = style.palette.join(', '); palette.placeholder = '#rrggbb, #rrggbb \u2014 leave empty for no forced palette';
|
|
11130
|
+
const swatches = el('div', 'pal');
|
|
11131
|
+
const drawSwatches = () => {
|
|
11132
|
+
swatches.textContent = '';
|
|
11133
|
+
for (const c of parsePalette(palette.value)) { const i = el('i'); i.style.background = c; i.title = c; swatches.append(i); }
|
|
11134
|
+
};
|
|
11135
|
+
drawSwatches();
|
|
11136
|
+
form.append(
|
|
11137
|
+
field('prompt prefix', prefix, 'Prepended to every asset prompt in this style. ' + provenance('promptPrefix')),
|
|
11138
|
+
field('prompt suffix', suffix, 'Appended to every asset prompt in this style. ' + provenance('promptSuffix')),
|
|
11139
|
+
field('palette', palette, 'Forced colours where the provider supports them (pixflux). ' + provenance('palette')),
|
|
11140
|
+
swatches,
|
|
11141
|
+
);
|
|
11142
|
+
const blast = el('div', 'blast');
|
|
11143
|
+
const changed = () => {
|
|
11144
|
+
const out = [];
|
|
11145
|
+
if (prefix.value !== style.promptPrefix) out.push('promptPrefix');
|
|
11146
|
+
if (suffix.value !== style.promptSuffix) out.push('promptSuffix');
|
|
11147
|
+
if (parsePalette(palette.value).join(',') !== style.palette.map((c) => c.toLowerCase()).join(',')) out.push('palette');
|
|
11148
|
+
return out;
|
|
11149
|
+
};
|
|
11150
|
+
const updateBlast = () => {
|
|
11151
|
+
const fields = changed();
|
|
11152
|
+
drawSwatches();
|
|
11153
|
+
if (!fields.length) { blast.className = 'blast'; blast.textContent = 'No changes yet. A style change alters the request for every asset that uses it.'; return; }
|
|
11154
|
+
const r = blastRadius(style, fields);
|
|
11155
|
+
blast.className = 'blast hot';
|
|
11156
|
+
blast.textContent = '';
|
|
11157
|
+
const others = r.styles.filter((id) => id !== style.id);
|
|
11158
|
+
blast.append(el('b', null, 'Affects ' + r.assets + (r.assets === 1 ? ' asset' : ' assets')));
|
|
11159
|
+
blast.append(document.createTextNode(others.length
|
|
11160
|
+
? ' in ' + style.id + ' and ' + others.length + (others.length === 1 ? ' style that inherits it (' : ' styles that inherit it (') + others.join(', ') + ').'
|
|
11161
|
+
: ' in ' + style.id + '.'));
|
|
11162
|
+
blast.append(document.createTextNode(' Generated ones become stale; regenerating all of them is about ' + (r.cost || 'nothing') + '. Nothing is spent until you generate.'));
|
|
11163
|
+
};
|
|
11164
|
+
for (const input of [prefix, suffix, palette]) input.addEventListener('input', updateBlast);
|
|
11165
|
+
updateBlast();
|
|
11166
|
+
form.append(blast);
|
|
11167
|
+
const actions = el('div', 'actions');
|
|
11168
|
+
const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
|
|
11169
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
|
|
11170
|
+
const msg = el('span', 'msg');
|
|
11171
|
+
actions.append(save, cancel, msg);
|
|
11172
|
+
form.append(actions);
|
|
11173
|
+
form.onsubmit = async (e) => {
|
|
11174
|
+
e.preventDefault();
|
|
11175
|
+
const fields = changed();
|
|
11176
|
+
if (!fields.length) { ui.editing = null; render(); return; }
|
|
11177
|
+
const colors = parsePalette(palette.value);
|
|
11178
|
+
if (fields.includes('palette') && !paletteValid(colors)) { msg.className = 'msg bad'; msg.textContent = 'palette must be six-digit hex colours'; return; }
|
|
11179
|
+
const patch = {};
|
|
11180
|
+
if (fields.includes('promptPrefix')) patch.promptPrefix = prefix.value;
|
|
11181
|
+
if (fields.includes('promptSuffix')) patch.promptSuffix = suffix.value;
|
|
11182
|
+
if (fields.includes('palette')) patch.palette = colors;
|
|
11183
|
+
save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
|
|
11184
|
+
const r = blastRadius(style, fields);
|
|
11185
|
+
try {
|
|
11186
|
+
const body = { action: 'patch-style', styleId: style.id, expectedSha256: pr.manifestSha256, patch };
|
|
11187
|
+
if (style.project) body.project = style.project;
|
|
11188
|
+
snap = await postEdit(body);
|
|
11189
|
+
ui.editing = null;
|
|
11190
|
+
ui.notice = { id: 'style:' + (style.project || '') + ':' + style.id,
|
|
11191
|
+
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.' };
|
|
11192
|
+
render();
|
|
11193
|
+
} catch (err) {
|
|
11194
|
+
save.disabled = false;
|
|
11195
|
+
msg.className = 'msg bad';
|
|
11196
|
+
msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
|
|
11197
|
+
}
|
|
11198
|
+
};
|
|
11199
|
+
setTimeout(() => prefix.focus(), 0);
|
|
11200
|
+
return form;
|
|
11201
|
+
}
|
|
11202
|
+
|
|
11203
|
+
function addAssetForm(style) {
|
|
11204
|
+
const pr = snap.workspace ? snap.workspace.projects.find((x) => x.id === style.project) : snap.project;
|
|
11205
|
+
const siblings = snap.styles.filter((x) => x.project === style.project);
|
|
11206
|
+
const form = el('form', 'edit');
|
|
11207
|
+
form.append(el('h3', null, 'New asset in ' + style.id));
|
|
11208
|
+
const id = el('input'); id.type = 'text'; id.placeholder = 'asset-id'; id.required = true; id.autocomplete = 'off';
|
|
11209
|
+
id.pattern = '[^/\\\\]+';
|
|
11210
|
+
const prompt = el('textarea'); prompt.placeholder = 'What to generate. The style adds its prefix and suffix.'; prompt.required = true;
|
|
11211
|
+
const width = numberInput(null, 'style default'), height = numberInput(null, 'style default');
|
|
11212
|
+
const category = el('input'); category.type = 'text'; category.placeholder = 'optional subfolder';
|
|
11213
|
+
const only = el('input'); only.type = 'checkbox'; only.checked = siblings.length > 1;
|
|
11214
|
+
const onlyField = el('label', 'field check'); onlyField.append(only, el('span', null, 'only in ' + style.id));
|
|
11215
|
+
const row1 = el('div', 'row'); row1.append(field('id', id), field('category', category));
|
|
11216
|
+
const row2 = el('div', 'row'); row2.append(field('width', width), field('height', height));
|
|
11217
|
+
form.append(row1, field('prompt', prompt), row2);
|
|
11218
|
+
if (siblings.length > 1) form.append(onlyField);
|
|
11219
|
+
const actions = el('div', 'actions');
|
|
11220
|
+
const save = el('button', 'primary', 'Add asset'); save.type = 'submit';
|
|
11221
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; render(); };
|
|
11222
|
+
const msg = el('span', 'msg');
|
|
11223
|
+
actions.append(save, cancel, msg);
|
|
11224
|
+
form.append(actions);
|
|
11225
|
+
form.onsubmit = async (e) => {
|
|
11226
|
+
e.preventDefault();
|
|
11227
|
+
save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
|
|
11228
|
+
const asset = { prompt: prompt.value };
|
|
11229
|
+
if (numberOrNull(width) !== null) asset.width = numberOrNull(width);
|
|
11230
|
+
if (numberOrNull(height) !== null) asset.height = numberOrNull(height);
|
|
11231
|
+
if (category.value.trim()) asset.category = category.value.trim();
|
|
11232
|
+
if (siblings.length > 1 && only.checked) asset.styles = [style.id];
|
|
11233
|
+
try {
|
|
11234
|
+
const body = { action: 'add-asset', assetId: id.value.trim(), expectedSha256: pr.manifestSha256, asset };
|
|
11235
|
+
if (style.project) body.project = style.project;
|
|
11236
|
+
snap = await postEdit(body);
|
|
11237
|
+
const newId = (style.project ? style.project + ':' : '') + style.id + '/' + id.value.trim();
|
|
11238
|
+
ui.editing = null;
|
|
11239
|
+
ui.notice = { id: newId, text: 'Added to the manifest. Nothing is generated until you run pixelkiln gen.' };
|
|
11240
|
+
render();
|
|
11241
|
+
if (snap.items.some((i) => i.id === newId)) openItem(newId);
|
|
11242
|
+
} catch (err) {
|
|
11243
|
+
save.disabled = false;
|
|
11244
|
+
msg.className = 'msg bad';
|
|
11245
|
+
msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
|
|
11246
|
+
}
|
|
11247
|
+
};
|
|
11248
|
+
setTimeout(() => id.focus(), 0);
|
|
11249
|
+
return form;
|
|
11250
|
+
}
|
|
11251
|
+
|
|
11252
|
+
function editForm(item) {
|
|
11253
|
+
const pr = projectOf(item);
|
|
11254
|
+
const a = item.asset;
|
|
11255
|
+
const styled = !!(a.promptByStyle && Object.hasOwn(a.promptByStyle, item.styleId));
|
|
11256
|
+
const form = el('form', 'edit');
|
|
11257
|
+
form.append(el('h3', null, 'Edit intent'));
|
|
11258
|
+
const scope = el('select');
|
|
11259
|
+
scope.append(new Option('Prompt for every style', 'all'), new Option('Prompt only for ' + item.styleId, 'style'));
|
|
11260
|
+
scope.value = styled ? 'style' : 'all';
|
|
11261
|
+
const prompt = el('textarea');
|
|
11262
|
+
prompt.value = styled ? a.promptByStyle[item.styleId] : a.prompt;
|
|
11263
|
+
scope.onchange = () => {
|
|
11264
|
+
prompt.value = scope.value === 'style' ? (a.promptByStyle?.[item.styleId] ?? a.prompt) : a.prompt;
|
|
11265
|
+
};
|
|
11266
|
+
const width = numberInput(a.width, 'style default'), height = numberInput(a.height, 'style default'), size = numberInput(a.size, 'style default');
|
|
11267
|
+
const category = el('input'); category.type = 'text'; category.value = a.category || '';
|
|
11268
|
+
const tags = el('input'); tags.type = 'text'; tags.value = (a.tags || []).join(', '); tags.placeholder = 'comma-separated';
|
|
11269
|
+
form.append(field('prompt', prompt, 'The style adds its prefix and suffix; the sent prompt is shown below.'), field('applies to', scope));
|
|
11270
|
+
const row = el('div', 'row'); row.append(field('width', width), field('height', height), field('size', size));
|
|
11271
|
+
form.append(row);
|
|
11272
|
+
const row2 = el('div', 'row');
|
|
11273
|
+
row2.append(field('category', category, 'Output subfolder. The existing file stays put; restore or gen writes the new path.'), field('tags', tags));
|
|
11274
|
+
form.append(row2);
|
|
11275
|
+
const actions = el('div', 'actions');
|
|
11276
|
+
const save = el('button', 'primary', 'Save to manifest'); save.type = 'submit';
|
|
11277
|
+
const cancel = el('button', null, 'Cancel'); cancel.type = 'button'; cancel.onclick = () => { ui.editing = null; renderDrawer(); };
|
|
11278
|
+
const msg = el('span', 'msg');
|
|
11279
|
+
actions.append(save, cancel, msg);
|
|
11280
|
+
form.append(actions);
|
|
11281
|
+
form.onsubmit = async (e) => {
|
|
11282
|
+
e.preventDefault();
|
|
11283
|
+
const patch = {};
|
|
11284
|
+
if (scope.value === 'style') {
|
|
11285
|
+
if (!styled || prompt.value !== a.promptByStyle[item.styleId]) patch.promptForStyle = { styleId: item.styleId, prompt: prompt.value };
|
|
11286
|
+
} else {
|
|
11287
|
+
if (prompt.value !== a.prompt) patch.prompt = prompt.value;
|
|
11288
|
+
if (styled) patch.promptForStyle = { styleId: item.styleId, prompt: null };
|
|
11289
|
+
}
|
|
11290
|
+
for (const [key, input] of [['width', width], ['height', height], ['size', size]]) {
|
|
11291
|
+
const next = numberOrNull(input);
|
|
11292
|
+
if (next !== (a[key] ?? null)) patch[key] = next;
|
|
11293
|
+
}
|
|
11294
|
+
const nextCategory = category.value.trim() || null;
|
|
11295
|
+
if (nextCategory !== (a.category || null)) patch.category = nextCategory;
|
|
11296
|
+
const nextTags = splitTags(tags.value);
|
|
11297
|
+
if (nextTags.join('\\u0000') !== (a.tags || []).join('\\u0000')) patch.tags = nextTags;
|
|
11298
|
+
if (!Object.keys(patch).length) { ui.editing = null; renderDrawer(); return; }
|
|
11299
|
+
save.disabled = true; msg.className = 'msg'; msg.textContent = 'saving\u2026';
|
|
11300
|
+
try {
|
|
11301
|
+
const body = { action: 'patch-asset', assetId: item.assetId, expectedSha256: pr.manifestSha256, patch };
|
|
11302
|
+
if (item.project) body.project = item.project;
|
|
11303
|
+
snap = await postEdit(body);
|
|
11304
|
+
const after = snap.items.find((i) => i.id === item.id);
|
|
11305
|
+
ui.editing = null;
|
|
11306
|
+
ui.notice = {
|
|
11307
|
+
id: item.id,
|
|
11308
|
+
text: after
|
|
11309
|
+
? 'Saved. plan now reports ' + after.state +
|
|
11310
|
+
(after.estimatedCost !== null && (after.state === 'stale' || after.state === 'missing')
|
|
11311
|
+
? ' \u2014 ' + fmtCost(after.costUnit, after.estimatedCost) + ' to generate. Nothing is spent until you run pixelkiln gen.'
|
|
11312
|
+
: '.')
|
|
11313
|
+
: 'Saved.',
|
|
11314
|
+
};
|
|
11315
|
+
render();
|
|
11316
|
+
} catch (err) {
|
|
11317
|
+
save.disabled = false;
|
|
11318
|
+
msg.className = 'msg bad';
|
|
11319
|
+
msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh.' : '');
|
|
11320
|
+
}
|
|
11321
|
+
};
|
|
11322
|
+
setTimeout(() => prompt.focus(), 0);
|
|
11323
|
+
return form;
|
|
11324
|
+
}
|
|
11325
|
+
|
|
11326
|
+
// ---- detail drawer -------------------------------------------------------
|
|
11327
|
+
|
|
11328
|
+
function row(dl, label, value, opts = {}) {
|
|
11329
|
+
if (value === null || value === undefined || value === '') return;
|
|
11330
|
+
dl.append(el('dt', null, label));
|
|
11331
|
+
const dd = el('dd', opts.mono ? 'mono' : null);
|
|
11332
|
+
if (value instanceof Node) dd.append(value); else dd.textContent = String(value);
|
|
11333
|
+
if (opts.copy) {
|
|
11334
|
+
const b = el('button', 'copy', 'copy');
|
|
11335
|
+
b.type = 'button';
|
|
11336
|
+
b.onclick = async () => {
|
|
11337
|
+
try { await navigator.clipboard.writeText(String(opts.copy)); b.textContent = 'copied'; }
|
|
11338
|
+
catch { b.textContent = 'select to copy'; }
|
|
11339
|
+
setTimeout(() => (b.textContent = 'copy'), 1200);
|
|
11340
|
+
};
|
|
11341
|
+
dd.append(b);
|
|
11342
|
+
}
|
|
11343
|
+
dl.append(dd);
|
|
11344
|
+
}
|
|
11345
|
+
function section(title) {
|
|
11346
|
+
const s = el('section', 'meta');
|
|
11347
|
+
s.append(el('h3', null, title));
|
|
11348
|
+
const dl = el('dl');
|
|
11349
|
+
s.append(dl);
|
|
11350
|
+
return { s, dl };
|
|
11351
|
+
}
|
|
11352
|
+
function stateNode(state, reason) {
|
|
11353
|
+
const box = el('div', 'status');
|
|
11354
|
+
const st = el('span', 'st state-' + STATE_TONE[state]);
|
|
11355
|
+
st.append(el('i', 'dot ' + STATE_TONE[state]), document.createTextNode(state));
|
|
11356
|
+
box.append(st, el('span', 'why', reason));
|
|
11357
|
+
return box;
|
|
11358
|
+
}
|
|
11359
|
+
function sibling(item, key) {
|
|
11360
|
+
return snap.items.find((i) => i.key === key && i.project === item.project);
|
|
11361
|
+
}
|
|
11362
|
+
function keyLink(item, key, label) {
|
|
11363
|
+
const b = el('button', 'linkish', label || key);
|
|
11364
|
+
b.type = 'button';
|
|
11365
|
+
const target = sibling(item, key);
|
|
11366
|
+
if (!target) { b.disabled = true; b.title = 'not in this snapshot'; }
|
|
11367
|
+
b.onclick = () => openItem(target.id);
|
|
11368
|
+
return b;
|
|
11369
|
+
}
|
|
11370
|
+
function jsonDetails(title, value) {
|
|
11371
|
+
const d = el('details');
|
|
11372
|
+
d.append(el('summary', null, title), el('pre', null, JSON.stringify(value, null, 2)));
|
|
11373
|
+
return d;
|
|
11374
|
+
}
|
|
11375
|
+
|
|
11376
|
+
function stopPlayback() {
|
|
11377
|
+
if (ui.playing) { clearInterval(ui.playing); ui.playing = null; }
|
|
11378
|
+
}
|
|
11379
|
+
|
|
11380
|
+
function renderPreview(item, host) {
|
|
11381
|
+
host.textContent = '';
|
|
11382
|
+
stopPlayback();
|
|
11383
|
+
const shown = item.outputs.filter((o) => o.url);
|
|
11384
|
+
const preview = el('div', 'preview');
|
|
11385
|
+
if (!shown.length) {
|
|
11386
|
+
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.'));
|
|
11387
|
+
host.append(preview);
|
|
11388
|
+
return;
|
|
11389
|
+
}
|
|
11390
|
+
const member = shown[Math.min(ui.member, shown.length - 1)] || shown[0];
|
|
11391
|
+
const frameSet = isFrameSet(item);
|
|
11392
|
+
const w = item.width, h = item.height;
|
|
11393
|
+
const box = Math.min(window.innerWidth, 540) - 62;
|
|
11394
|
+
const auto = displayScale(w, h, box, Math.min(window.innerHeight * 0.55, 560));
|
|
11395
|
+
const zoom = ui.zoom === 'auto' ? auto : ui.zoom === 'fit' ? Math.min(auto, 1) : Number(ui.zoom);
|
|
11396
|
+
const img = el('img');
|
|
11397
|
+
img.src = member.url; img.alt = member.role || item.assetId;
|
|
11398
|
+
if (zoom >= 1) { img.width = Math.round(w * zoom); img.height = Math.round(h * zoom); }
|
|
11399
|
+
else { img.style.maxWidth = '100%'; img.style.maxHeight = '55vh'; img.style.width = 'auto'; img.style.height = 'auto'; }
|
|
11400
|
+
preview.append(img);
|
|
11401
|
+
|
|
11402
|
+
const zoomBar = el('div', 'zoom');
|
|
11403
|
+
zoomBar.append(el('span', null, 'zoom'));
|
|
11404
|
+
for (const z of ['fit', 1, 2, 4, 8]) {
|
|
11405
|
+
const b = el('button', ui.zoom === String(z) || (ui.zoom === 'auto' && z === auto) ? 'on' : null, z === 'fit' ? 'fit' : z + '\xD7');
|
|
11406
|
+
b.type = 'button';
|
|
11407
|
+
b.onclick = () => { ui.zoom = String(z); renderPreview(item, host); };
|
|
11408
|
+
zoomBar.append(b);
|
|
11409
|
+
}
|
|
11410
|
+
const label = el('span', 'grow', w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : ' fitted'));
|
|
11411
|
+
zoomBar.append(label);
|
|
11412
|
+
if (frameSet && shown.length > 1) {
|
|
11413
|
+
const fps = item.fps || 12;
|
|
11414
|
+
const play = el('button', null, 'Play ' + fps + ' fps');
|
|
11415
|
+
play.type = 'button';
|
|
11416
|
+
let idx = shown.indexOf(member);
|
|
11417
|
+
play.onclick = () => {
|
|
11418
|
+
if (ui.playing) { stopPlayback(); play.textContent = 'Play ' + fps + ' fps'; return; }
|
|
11419
|
+
play.textContent = 'Pause';
|
|
11420
|
+
ui.playing = setInterval(() => {
|
|
11421
|
+
if (document.hidden) return;
|
|
11422
|
+
idx = (idx + 1) % shown.length;
|
|
11423
|
+
img.src = shown[idx].url;
|
|
11424
|
+
label.textContent = (shown[idx].role || 'frame ' + (idx + 1)) + ' \xB7 ' + w + '\xD7' + h + (zoom >= 1 ? ' at ' + zoom + '\xD7' : '');
|
|
11425
|
+
for (const [i, b] of [...host.querySelectorAll('.members button')].entries()) b.classList.toggle('on', i === idx);
|
|
11426
|
+
}, Math.max(16, Math.round(1000 / fps)));
|
|
11427
|
+
};
|
|
11428
|
+
zoomBar.append(play);
|
|
11429
|
+
}
|
|
11430
|
+
host.append(preview, zoomBar);
|
|
11431
|
+
|
|
11432
|
+
if (shown.length > 1) {
|
|
11433
|
+
const strip = el('div', 'members');
|
|
11434
|
+
shown.forEach((o, i) => {
|
|
11435
|
+
const b = el('button', o === member ? 'on' : null);
|
|
11436
|
+
b.type = 'button';
|
|
11437
|
+
const t = el('img'); t.src = o.url; t.alt = o.role || 'output ' + (i + 1); t.loading = 'lazy';
|
|
11438
|
+
b.append(t, el('span', null, o.role || String(i + 1)));
|
|
11439
|
+
b.onclick = () => { ui.member = i; renderPreview(item, host); };
|
|
11440
|
+
strip.append(b);
|
|
11441
|
+
});
|
|
11442
|
+
host.append(strip);
|
|
11443
|
+
}
|
|
11444
|
+
}
|
|
11445
|
+
|
|
11446
|
+
function renderDrawer() {
|
|
11447
|
+
const host = $('drawer-host');
|
|
11448
|
+
host.textContent = '';
|
|
11449
|
+
stopPlayback();
|
|
11450
|
+
const item = ui.open && snap.items.find((i) => i.id === ui.open);
|
|
11451
|
+
if (!item) { ui.open = null; return; }
|
|
11452
|
+
const items = visibleItems();
|
|
11453
|
+
const pos = items.findIndex((i) => i.id === item.id);
|
|
11454
|
+
|
|
11455
|
+
const scrim = el('div', 'scrim');
|
|
11456
|
+
scrim.onclick = () => closeItem();
|
|
11457
|
+
const drawer = el('aside', 'drawer');
|
|
11458
|
+
drawer.setAttribute('role', 'dialog');
|
|
11459
|
+
drawer.setAttribute('aria-label', item.id);
|
|
11460
|
+
|
|
11461
|
+
const head = el('div', 'dhead');
|
|
11462
|
+
const title = el('div', 'title');
|
|
11463
|
+
if (item.project) title.append(el('span', 'sid project', item.project), ' ');
|
|
11464
|
+
title.append(el('span', 'sid', item.styleId), el('div', 'aid', item.assetId));
|
|
11465
|
+
const nav = el('div', 'nav');
|
|
11466
|
+
const prev = el('button', null, '\u2190'); prev.type = 'button'; prev.title = 'Previous (\u2190)';
|
|
11467
|
+
prev.disabled = pos <= 0; prev.onclick = () => step(-1);
|
|
11468
|
+
const next = el('button', null, '\u2192'); next.type = 'button'; next.title = 'Next (\u2192)';
|
|
11469
|
+
next.disabled = pos < 0 || pos >= items.length - 1; next.onclick = () => step(1);
|
|
11470
|
+
const close = el('button', null, 'Close'); close.type = 'button'; close.onclick = () => closeItem();
|
|
11471
|
+
const cmp = el('button', null, ui.compare.includes(item.id) ? 'Remove from compare' : 'Compare +');
|
|
11472
|
+
cmp.type = 'button'; cmp.onclick = () => toggleCompare(item.id);
|
|
11473
|
+
nav.append(cmp);
|
|
11474
|
+
const canEdit = EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256;
|
|
11475
|
+
if (canEdit) {
|
|
11476
|
+
const edit = el('button', null, ui.editing === item.id ? 'Cancel edit' : 'Edit');
|
|
11477
|
+
edit.type = 'button';
|
|
11478
|
+
edit.onclick = () => { ui.editing = ui.editing === item.id ? null : item.id; ui.notice = null; renderDrawer(); };
|
|
11479
|
+
nav.append(edit);
|
|
11480
|
+
}
|
|
11481
|
+
nav.append(prev, next, close);
|
|
11482
|
+
head.append(title, nav);
|
|
11483
|
+
|
|
11484
|
+
const body = el('div', 'dbody');
|
|
11485
|
+
const previewHost = el('div');
|
|
11486
|
+
renderPreview(item, previewHost);
|
|
11487
|
+
body.append(previewHost);
|
|
11488
|
+
if (ui.notice && ui.notice.id === item.id) body.append(el('div', 'notice', ui.notice.text));
|
|
11489
|
+
body.append(stateNode(item.state, item.reason));
|
|
11490
|
+
if (GENERATION) body.append(generateActions(item));
|
|
11491
|
+
if (canEdit && ui.editing === item.id) body.append(editForm(item));
|
|
11492
|
+
// Plan already quotes the error as the reason for a failed entry; only a
|
|
11493
|
+
// stale or superseded failure needs its own line.
|
|
11494
|
+
if (item.error && !item.reason.includes(item.error)) body.append(stateNode('failed', item.error));
|
|
11495
|
+
|
|
11496
|
+
// Generation \u2014 what was sent and what it cost.
|
|
11497
|
+
{
|
|
11498
|
+
const { s, dl } = section('Generation');
|
|
11499
|
+
row(dl, 'provider', item.provider);
|
|
11500
|
+
row(dl, 'generator', item.generator + (item.tileFeature ? ' \xB7 ' + item.tileFeature : ''));
|
|
11501
|
+
row(dl, 'lock status', item.status || 'none \u2014 nothing submitted');
|
|
11502
|
+
const pr = el('div', 'prompt', item.prompt); row(dl, item.status ? 'prompt sent' : 'prompt', pr);
|
|
11503
|
+
if (item.currentPrompt) row(dl, 'prompt now', el('div', 'prompt state-warn', item.currentPrompt));
|
|
11504
|
+
row(dl, 'size', item.width + ' \xD7 ' + item.height + ' px');
|
|
11505
|
+
if (item.status) row(dl, 'cost', fmtCost(item.costUnit, item.cost));
|
|
11506
|
+
if (item.estimatedCost !== null && (item.estimatedCost !== item.cost || !item.status)) {
|
|
11507
|
+
row(dl, 'estimate now', fmtCost(item.costUnit, item.estimatedCost) +
|
|
11508
|
+
(item.candidates && item.candidates > 1 ? ' for ' + item.candidates + ' candidates' : ''));
|
|
11509
|
+
}
|
|
11510
|
+
if (item.candidateIndex !== null) row(dl, 'candidate', '#' + (item.candidateIndex + 1) + ' chosen in review');
|
|
11511
|
+
row(dl, 'submitted', fmtWhen(item.submittedAt));
|
|
11512
|
+
row(dl, 'downloaded', fmtWhen(item.downloadedAt));
|
|
11513
|
+
row(dl, 'job id', item.jobId, { mono: true, copy: item.jobId });
|
|
11514
|
+
row(dl, 'object id', item.objectId, { mono: true, copy: item.objectId });
|
|
11515
|
+
row(dl, 'review object', item.reviewObjectId, { mono: true, copy: item.reviewObjectId });
|
|
11516
|
+
body.append(s);
|
|
11517
|
+
}
|
|
11518
|
+
|
|
11519
|
+
// Outputs \u2014 every file with its hash and whether it is really there.
|
|
11520
|
+
{
|
|
11521
|
+
const s = el('section', 'meta');
|
|
11522
|
+
s.append(el('h3', null, item.outputs.length === 1 ? 'Output' : 'Outputs (' + item.outputs.length + ')'));
|
|
11523
|
+
if (!item.outputs.length) s.append(el('div', 'state-dim', item.source ? 'placed from ' + item.source : 'none recorded'));
|
|
11524
|
+
for (const o of item.outputs) {
|
|
11525
|
+
const box = el('div', 'out');
|
|
11526
|
+
const role = el('div', 'role');
|
|
11527
|
+
role.append(document.createTextNode(o.role || (item.outputs.length > 1 ? 'primary' : 'file')));
|
|
11528
|
+
if (!o.exists) role.append(el('span', 'miss', 'missing on disk'));
|
|
11529
|
+
if (o.url) { const a = el('a', null, 'open'); a.href = o.url.split('?')[0]; a.target = '_blank'; a.rel = 'noopener'; role.append(a); }
|
|
11530
|
+
box.append(role);
|
|
11531
|
+
const dl = el('dl');
|
|
11532
|
+
row(dl, 'path', o.path, { mono: true, copy: o.absolutePath });
|
|
11533
|
+
row(dl, 'sha256', o.sha256 ? o.sha256.slice(0, 16) + '\u2026' : (o.exists ? 'not recorded' : null), { mono: true, copy: o.sha256 || undefined });
|
|
11534
|
+
row(dl, 'bytes', o.exists ? fmtBytes(o.bytes) : null);
|
|
11535
|
+
row(dl, 'type', o.mediaType);
|
|
11536
|
+
box.append(dl);
|
|
11537
|
+
s.append(box);
|
|
11538
|
+
}
|
|
11539
|
+
body.append(s);
|
|
11540
|
+
}
|
|
11541
|
+
|
|
11542
|
+
if (item.revision || item.revisionParentKey) {
|
|
11543
|
+
const { s, dl } = section('Lineage');
|
|
11544
|
+
row(dl, 'mode', item.revision ? item.revision.mode : (item.asset && item.asset.revision && item.asset.revision.mode));
|
|
11545
|
+
if (item.revisionParentKey) row(dl, 'parent', keyLink(item, item.revisionParentKey));
|
|
11546
|
+
if (item.revision) {
|
|
11547
|
+
row(dl, 'parent sha256', item.revision.sourceSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.sourceSha256 });
|
|
11548
|
+
if (item.revision.maskSha256) row(dl, 'mask sha256', item.revision.maskSha256.slice(0, 16) + '\u2026', { mono: true, copy: item.revision.maskSha256 });
|
|
11549
|
+
if (item.revision.strength !== undefined) row(dl, 'strength', item.revision.strength);
|
|
11550
|
+
}
|
|
11551
|
+
body.append(s);
|
|
11552
|
+
}
|
|
11553
|
+
const children = snap.items.filter((i) => i.revisionParentKey === item.key && i.project === item.project);
|
|
11554
|
+
if (children.length) {
|
|
11555
|
+
const { s, dl } = section('Revisions from this asset');
|
|
11556
|
+
for (const c of children) row(dl, c.revision ? c.revision.mode : 'child', keyLink(item, c.key));
|
|
11557
|
+
body.append(s);
|
|
11558
|
+
}
|
|
11559
|
+
|
|
11560
|
+
if (item.quality) {
|
|
11561
|
+
const q = item.quality;
|
|
11562
|
+
const { s, dl } = section('Quality');
|
|
11563
|
+
const tone = q.state === 'approved' ? 'ok' : q.state === 'blocked' ? 'bad' : 'warn';
|
|
11564
|
+
const st = el('span', 'state-' + tone); st.append(el('i', 'dot ' + tone + ' '), document.createTextNode(' ' + q.state));
|
|
11565
|
+
row(dl, 'state', st);
|
|
11566
|
+
row(dl, 'why', q.reason);
|
|
11567
|
+
if (q.review) {
|
|
11568
|
+
row(dl, 'review', q.review.status === 'approved'
|
|
11569
|
+
? 'approved by ' + q.review.reviewer + ' \xB7 ' + fmtWhen(q.review.approvedAt)
|
|
11570
|
+
: 'pending human 1\xD7 review');
|
|
11571
|
+
if (q.review.status === 'approved' && q.review.note) row(dl, 'note', q.review.note);
|
|
11572
|
+
}
|
|
11573
|
+
if (q.palette) {
|
|
11574
|
+
const pal = el('div', 'pal');
|
|
11575
|
+
for (const c of q.palette) { const i = el('i'); i.style.background = c; i.title = c; pal.append(i); }
|
|
11576
|
+
row(dl, 'palette', pal);
|
|
11577
|
+
row(dl, 'colors', q.palette.length + ' declared' + (q.audit ? ', ' + q.audit.colorCount + ' used' : ''));
|
|
11578
|
+
}
|
|
11579
|
+
if (q.nativeGrid) {
|
|
11580
|
+
row(dl, 'native grid', q.nativeGrid.sourceWidth + '\xD7' + q.nativeGrid.sourceHeight + ' \u2192 ' +
|
|
11581
|
+
q.nativeGrid.nativeWidth + '\xD7' + q.nativeGrid.nativeHeight + ' (step ' + q.nativeGrid.stepX + '\xD7' + q.nativeGrid.stepY +
|
|
11582
|
+
', ' + q.nativeGrid.confidence + ' confidence)');
|
|
11583
|
+
}
|
|
11584
|
+
if (q.audit) row(dl, 'audit', (q.audit.safe ? 'passed' : 'failed') + ' \xB7 ' + Math.round(q.audit.transparency * 100) + '% transparent' +
|
|
11585
|
+
(q.audit.reasons.length ? ' \xB7 ' + q.audit.reasons.join('; ') : ''));
|
|
11586
|
+
if (q.frameSet) row(dl, 'frame set', q.frameSet.count + ' frames at ' + q.frameSet.fps + ' fps');
|
|
11587
|
+
row(dl, 'output', q.output, { mono: true });
|
|
11588
|
+
row(dl, 'record', q.record + (q.recordExists ? '' : ' (not written yet)'), { mono: true });
|
|
11589
|
+
if (q.check && !q.check.safe) row(dl, 'check', el('div', 'state-warn', q.check.reasons.join('\\n')));
|
|
11590
|
+
body.append(s);
|
|
11591
|
+
if (q.outputs.some((o) => o.url)) {
|
|
11592
|
+
const strip = el('div', 'members');
|
|
11593
|
+
for (const o of q.outputs.filter((o) => o.url)) {
|
|
11594
|
+
const b = el('button'); b.type = 'button';
|
|
11595
|
+
const t = el('img'); t.src = o.url; t.alt = 'refined ' + (o.role || item.assetId);
|
|
11596
|
+
b.append(t, el('span', null, 'refined')); b.onclick = () => window.open(o.url.split('?')[0], '_blank');
|
|
11597
|
+
strip.append(b);
|
|
11598
|
+
}
|
|
11599
|
+
s.append(strip);
|
|
11600
|
+
}
|
|
11601
|
+
}
|
|
11602
|
+
|
|
11603
|
+
{
|
|
11604
|
+
const { s, dl } = section('Identity');
|
|
11605
|
+
if (item.project) row(dl, 'project', item.project);
|
|
11606
|
+
row(dl, 'lock key', item.key, { mono: true, copy: item.key });
|
|
11607
|
+
if (item.recordedSpecHash) {
|
|
11608
|
+
row(dl, 'spec hash', item.recordedSpecHash.slice(0, 16) + '\u2026', { mono: true, copy: item.recordedSpecHash });
|
|
11609
|
+
}
|
|
11610
|
+
if (item.currentSpecHash && item.currentSpecHash !== item.recordedSpecHash) {
|
|
11611
|
+
row(dl, item.recordedSpecHash ? 'spec hash now' : 'spec hash',
|
|
11612
|
+
el('span', item.recordedSpecHash ? 'state-warn' : null, item.currentSpecHash.slice(0, 16) + '\u2026'), { mono: true, copy: item.currentSpecHash });
|
|
11613
|
+
}
|
|
11614
|
+
row(dl, 'declared', item.declared ? 'yes' : 'no \u2014 the manifest no longer has this asset in this style');
|
|
11615
|
+
row(dl, 'category', item.category);
|
|
11616
|
+
row(dl, 'tags', item.tags && item.tags.length ? item.tags.join(', ') : null);
|
|
11617
|
+
row(dl, 'source art', item.source, { mono: true });
|
|
11618
|
+
body.append(s);
|
|
11619
|
+
}
|
|
11620
|
+
|
|
11621
|
+
if (item.asset) {
|
|
11622
|
+
// Show the asset as the author would write it: schema defaults that are
|
|
11623
|
+
// empty add nothing but noise to a record.
|
|
11624
|
+
const declared = Object.fromEntries(Object.entries(item.asset).filter(([, v]) =>
|
|
11625
|
+
!(Array.isArray(v) && !v.length) && !(v && typeof v === 'object' && !Array.isArray(v) && !Object.keys(v).length)));
|
|
11626
|
+
body.append(jsonDetails('Manifest asset', declared));
|
|
11627
|
+
}
|
|
11628
|
+
if (item.providerMetadata && Object.keys(item.providerMetadata).length) {
|
|
11629
|
+
body.append(jsonDetails('Provider metadata', item.providerMetadata));
|
|
11630
|
+
}
|
|
11631
|
+
|
|
11632
|
+
drawer.append(head, body);
|
|
11633
|
+
host.append(scrim, drawer);
|
|
11634
|
+
close.focus({ preventScroll: true });
|
|
11635
|
+
}
|
|
11636
|
+
|
|
11637
|
+
// View state lives in the URL \u2014 filters and sort in the query, the open
|
|
11638
|
+
// record in the hash \u2014 so a link to "every failed generation" or to one
|
|
11639
|
+
// specific record can be pasted into a review thread and land there.
|
|
11640
|
+
function readUrlState() {
|
|
11641
|
+
const params = new URLSearchParams(location.search);
|
|
11642
|
+
const list = (name) => (params.get(name) || '').split(',').map((v) => v.trim()).filter(Boolean);
|
|
11643
|
+
ui.q = params.get('q') || '';
|
|
11644
|
+
ui.states = new Set(list('state'));
|
|
11645
|
+
ui.providers = new Set(list('provider'));
|
|
11646
|
+
ui.generators = new Set(list('generator'));
|
|
11647
|
+
ui.projects = new Set(list('project'));
|
|
11648
|
+
ui.compare = list('compare').slice(0, 4);
|
|
11649
|
+
if (['key', 'newest', 'oldest', 'cost', 'size'].includes(params.get('sort'))) ui.sort = params.get('sort');
|
|
11650
|
+
if (['style', 'none'].includes(params.get('group'))) ui.group = params.get('group');
|
|
11651
|
+
}
|
|
11652
|
+
function writeUrlState() {
|
|
11653
|
+
const params = new URLSearchParams();
|
|
11654
|
+
if (ui.q) params.set('q', ui.q);
|
|
11655
|
+
if (ui.states.size) params.set('state', [...ui.states].join(','));
|
|
11656
|
+
if (ui.providers.size) params.set('provider', [...ui.providers].join(','));
|
|
11657
|
+
if (ui.generators.size) params.set('generator', [...ui.generators].join(','));
|
|
11658
|
+
if (ui.projects.size) params.set('project', [...ui.projects].join(','));
|
|
11659
|
+
if (ui.compare.length) params.set('compare', ui.compare.join(','));
|
|
11660
|
+
if (ui.sort !== 'key') params.set('sort', ui.sort);
|
|
11661
|
+
if (ui.group !== 'style') params.set('group', ui.group);
|
|
11662
|
+
const query = params.toString();
|
|
11663
|
+
const next = location.pathname + (query ? '?' + query : '') + (ui.open ? '#' + encodeURIComponent(ui.open) : '');
|
|
11664
|
+
if (next !== location.pathname + location.search + location.hash) history.replaceState(null, '', next);
|
|
11665
|
+
}
|
|
11666
|
+
function syncHash() { writeUrlState(); }
|
|
11667
|
+
function keyFromHash() {
|
|
11668
|
+
try { return location.hash.length > 1 ? decodeURIComponent(location.hash.slice(1)) : null; }
|
|
11669
|
+
catch { return null; }
|
|
11670
|
+
}
|
|
11671
|
+
// Opening a record must not rebuild the grid: at a few thousand cards that
|
|
11672
|
+
// is a visible pause, and nothing in the grid changes except one highlight.
|
|
11673
|
+
function markActive(key) {
|
|
11674
|
+
document.querySelector('.card.active')?.classList.remove('active');
|
|
11675
|
+
const card = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
|
|
11676
|
+
if (card) card.classList.add('active');
|
|
11677
|
+
return card;
|
|
11678
|
+
}
|
|
11679
|
+
function openItem(key) {
|
|
11680
|
+
if (ui.open !== key) { ui.editing = null; if (ui.notice && ui.notice.id !== key) ui.notice = null; }
|
|
11681
|
+
ui.open = key; ui.member = 0; ui.zoom = 'auto';
|
|
11682
|
+
syncHash();
|
|
11683
|
+
const card = markActive(key);
|
|
11684
|
+
renderDrawer();
|
|
11685
|
+
if (card) card.scrollIntoView({ block: 'nearest' });
|
|
11686
|
+
}
|
|
11687
|
+
function closeItem() {
|
|
11688
|
+
const key = ui.open;
|
|
11689
|
+
ui.open = null;
|
|
11690
|
+
ui.editing = null;
|
|
11691
|
+
ui.notice = null;
|
|
11692
|
+
syncHash();
|
|
11693
|
+
const card = markActive(null);
|
|
11694
|
+
renderDrawer();
|
|
11695
|
+
const focus = key && document.querySelector('.card[data-key="' + CSS.escape(key) + '"]');
|
|
11696
|
+
if (focus) focus.focus({ preventScroll: true });
|
|
11697
|
+
}
|
|
11698
|
+
window.addEventListener('hashchange', () => {
|
|
11699
|
+
const key = keyFromHash();
|
|
11700
|
+
if (key && key !== ui.open && snap.items.some((i) => i.id === key)) openItem(key);
|
|
11701
|
+
else if (!key && ui.open) closeItem();
|
|
11702
|
+
});
|
|
11703
|
+
function step(delta) {
|
|
11704
|
+
const items = visibleItems();
|
|
11705
|
+
const pos = items.findIndex((i) => i.id === ui.open);
|
|
11706
|
+
const next = items[pos + delta];
|
|
11707
|
+
if (next) openItem(next.id);
|
|
11708
|
+
}
|
|
11709
|
+
|
|
11710
|
+
function render() {
|
|
11711
|
+
writeUrlState();
|
|
11712
|
+
renderHeader();
|
|
11713
|
+
renderMain(visibleItems());
|
|
11714
|
+
renderDrawer();
|
|
11715
|
+
renderTray();
|
|
11716
|
+
}
|
|
11717
|
+
|
|
11718
|
+
// ---- refresh --------------------------------------------------------------
|
|
11719
|
+
|
|
11720
|
+
let refreshing = false;
|
|
11721
|
+
async function refresh() {
|
|
11722
|
+
if (refreshing) return;
|
|
11723
|
+
refreshing = true;
|
|
11724
|
+
const b = $('refresh');
|
|
11725
|
+
b.disabled = true;
|
|
11726
|
+
try {
|
|
11727
|
+
const res = await fetch('/api/gallery.json', { cache: 'no-store' });
|
|
11728
|
+
if (!res.ok) throw new Error(await res.text());
|
|
11729
|
+
snap = await res.json();
|
|
11730
|
+
$('note').textContent = '';
|
|
11731
|
+
render();
|
|
11732
|
+
} catch (err) {
|
|
11733
|
+
$('note').textContent = ' Refresh failed: ' + err.message + ' \u2014 is pixelkiln gallery still running?';
|
|
11734
|
+
} finally {
|
|
11735
|
+
refreshing = false;
|
|
11736
|
+
b.disabled = false;
|
|
11737
|
+
}
|
|
11738
|
+
}
|
|
11739
|
+
$('refresh').onclick = refresh;
|
|
11740
|
+
let autoTimer = null;
|
|
11741
|
+
$('auto').onchange = (e) => {
|
|
11742
|
+
if (autoTimer) clearInterval(autoTimer);
|
|
11743
|
+
autoTimer = e.target.checked ? setInterval(() => { if (!document.hidden) refresh(); }, 5000) : null;
|
|
11744
|
+
};
|
|
11745
|
+
|
|
11746
|
+
// ---- input ----------------------------------------------------------------
|
|
11747
|
+
|
|
11748
|
+
let searchTimer = null;
|
|
11749
|
+
$('q').addEventListener('input', (e) => {
|
|
11750
|
+
ui.q = e.target.value;
|
|
11751
|
+
clearTimeout(searchTimer);
|
|
11752
|
+
searchTimer = setTimeout(render, 90);
|
|
11753
|
+
});
|
|
11754
|
+
$('sort').onchange = (e) => { ui.sort = e.target.value; render(); };
|
|
11755
|
+
$('group').onchange = (e) => { ui.group = e.target.value; render(); };
|
|
11756
|
+
document.addEventListener('keydown', (e) => {
|
|
11757
|
+
const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || '');
|
|
11758
|
+
if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
|
|
11759
|
+
if (e.key === 'Escape') {
|
|
11760
|
+
if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
|
|
11761
|
+
if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
|
|
11762
|
+
if (ui.open) { e.preventDefault(); closeItem(); }
|
|
11763
|
+
return;
|
|
11764
|
+
}
|
|
11765
|
+
if (ui.open && !typing) {
|
|
11766
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
|
|
11767
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
|
|
11768
|
+
}
|
|
11769
|
+
});
|
|
11770
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) stopPlayback(); });
|
|
11771
|
+
|
|
11772
|
+
readUrlState();
|
|
11773
|
+
$('q').value = ui.q;
|
|
11774
|
+
$('sort').value = ui.sort;
|
|
11775
|
+
$('group').value = ui.group;
|
|
11776
|
+
const initialKey = keyFromHash();
|
|
11777
|
+
if (initialKey && snap.items.some((i) => i.id === initialKey)) ui.open = initialKey;
|
|
11778
|
+
ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
|
|
11779
|
+
render();
|
|
11780
|
+
if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
|
|
11781
|
+
if (GENERATION) pollJobs();
|
|
11782
|
+
if (ui.compare.length >= 2 && !ui.open) openCompare();
|
|
11783
|
+
</script>
|
|
11784
|
+
</body>
|
|
11785
|
+
</html>`;
|
|
11786
|
+
}
|
|
11787
|
+
var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
11788
|
+
|
|
11789
|
+
// src/gallery/server.ts
|
|
11790
|
+
var import_node_child_process3 = require("child_process");
|
|
11791
|
+
var import_node_crypto7 = require("crypto");
|
|
11792
|
+
var import_node_http2 = require("http");
|
|
11793
|
+
var import_promises20 = require("fs/promises");
|
|
11794
|
+
var MAX_EDIT_BYTES = 64 * 1024;
|
|
11795
|
+
async function readJsonBody(req) {
|
|
11796
|
+
const chunks = [];
|
|
11797
|
+
let bytes = 0;
|
|
11798
|
+
for await (const chunk2 of req) {
|
|
11799
|
+
bytes += chunk2.length;
|
|
11800
|
+
if (bytes > MAX_EDIT_BYTES) throw Object.assign(new Error("edit request is too large"), { status: 413 });
|
|
11801
|
+
chunks.push(chunk2);
|
|
11802
|
+
}
|
|
11803
|
+
try {
|
|
11804
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
11805
|
+
} catch {
|
|
11806
|
+
throw Object.assign(new Error("edit request is not valid JSON"), { status: 400 });
|
|
11807
|
+
}
|
|
11808
|
+
}
|
|
11809
|
+
async function serveGallery(opts) {
|
|
11810
|
+
const log = opts.onProgress ?? (() => {
|
|
11811
|
+
});
|
|
11812
|
+
const session = opts.edit || opts.generate ? (0, import_node_crypto7.randomBytes)(16).toString("hex") : null;
|
|
11813
|
+
let media = /* @__PURE__ */ new Map();
|
|
11814
|
+
let loading = null;
|
|
11815
|
+
const reviewAssets = /* @__PURE__ */ new Map();
|
|
11816
|
+
const load = () => {
|
|
11817
|
+
if (!loading) {
|
|
11818
|
+
loading = opts.load().then((build) => {
|
|
11819
|
+
media = build.media;
|
|
11820
|
+
return build;
|
|
11821
|
+
}).finally(() => {
|
|
11822
|
+
loading = null;
|
|
11823
|
+
});
|
|
11824
|
+
}
|
|
11825
|
+
return loading;
|
|
11826
|
+
};
|
|
11827
|
+
const server = (0, import_node_http2.createServer)(async (req, res) => {
|
|
11828
|
+
const url2 = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
11829
|
+
const fail = (status, message6) => {
|
|
11830
|
+
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
|
|
11831
|
+
res.end(message6);
|
|
11832
|
+
};
|
|
11833
|
+
const json = (status, value) => {
|
|
11834
|
+
res.writeHead(status, {
|
|
11835
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
11836
|
+
"Cache-Control": "no-store",
|
|
11837
|
+
"X-Content-Type-Options": "nosniff"
|
|
11838
|
+
});
|
|
11839
|
+
res.end(JSON.stringify(value));
|
|
11840
|
+
};
|
|
11841
|
+
const guardedBody = async (what) => {
|
|
11842
|
+
const expectedOrigin = req.headers.host ? `http://${req.headers.host}` : null;
|
|
11843
|
+
if (!req.headers.origin || req.headers.origin !== expectedOrigin) {
|
|
11844
|
+
fail(403, `cross-origin ${what} are not allowed`);
|
|
11845
|
+
return void 0;
|
|
11846
|
+
}
|
|
11847
|
+
if (req.headers["x-pixelkiln-session"] !== session) {
|
|
11848
|
+
fail(403, "missing or stale gallery session");
|
|
11849
|
+
return void 0;
|
|
11850
|
+
}
|
|
11851
|
+
if (!req.headers["content-type"]?.toLowerCase().startsWith("application/json")) {
|
|
11852
|
+
fail(415, "application/json is required");
|
|
11853
|
+
return void 0;
|
|
11854
|
+
}
|
|
11855
|
+
return readJsonBody(req);
|
|
11856
|
+
};
|
|
11857
|
+
const reportError = (what, err) => {
|
|
11858
|
+
const status = typeof err?.status === "number" ? err.status : 500;
|
|
11859
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
11860
|
+
if (status >= 500) log(` gallery ${what} failed: ${message6}`);
|
|
11861
|
+
fail(status, message6);
|
|
11862
|
+
};
|
|
11863
|
+
if (req.method === "POST" && url2.pathname === "/api/edit") {
|
|
11864
|
+
if (!opts.edit || !session) return fail(405, "the gallery is read-only; start it with --edit to change the manifest");
|
|
11865
|
+
try {
|
|
11866
|
+
const body = await guardedBody("edits");
|
|
11867
|
+
if (body === void 0) return;
|
|
11868
|
+
const build = await opts.edit(body);
|
|
11869
|
+
media = build.media;
|
|
11870
|
+
json(200, build.snapshot);
|
|
11871
|
+
} catch (err) {
|
|
11872
|
+
reportError("edit", err);
|
|
11873
|
+
}
|
|
11874
|
+
return;
|
|
11875
|
+
}
|
|
11876
|
+
if (req.method === "POST" && url2.pathname === "/api/generate") {
|
|
11877
|
+
if (!opts.generate || !session) return fail(405, "generation is off; start the gallery with --budget to enable it");
|
|
11878
|
+
try {
|
|
11879
|
+
const body = await guardedBody("generation requests");
|
|
11880
|
+
if (body === void 0) return;
|
|
11881
|
+
json(202, await opts.generate.start(body));
|
|
11882
|
+
} catch (err) {
|
|
11883
|
+
reportError("generate", err);
|
|
11884
|
+
}
|
|
11885
|
+
return;
|
|
11886
|
+
}
|
|
11887
|
+
const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
|
|
11888
|
+
if (reviewMatch && opts.generate) {
|
|
11889
|
+
const [, jobId, rest = ""] = reviewMatch;
|
|
11890
|
+
if (req.method === "POST" && rest === "/apply") {
|
|
11891
|
+
try {
|
|
11892
|
+
const body = await guardedBody("review submissions");
|
|
11893
|
+
if (body === void 0) return;
|
|
11894
|
+
const result = await opts.generate.applyReview(jobId, body);
|
|
11895
|
+
reviewAssets.delete(jobId);
|
|
11896
|
+
json(200, result);
|
|
11897
|
+
} catch (err) {
|
|
11898
|
+
reportError("review", err);
|
|
11899
|
+
}
|
|
11900
|
+
return;
|
|
11901
|
+
}
|
|
11902
|
+
if (req.method === "GET" && rest === "") {
|
|
11903
|
+
try {
|
|
11904
|
+
const routePrefix = `/review/${jobId}`;
|
|
11905
|
+
const review = await opts.generate.review(jobId, {
|
|
11906
|
+
routePrefix,
|
|
11907
|
+
applyUrl: `${routePrefix}/apply`,
|
|
11908
|
+
applyHeaders: { "X-Pixelkiln-Session": session },
|
|
11909
|
+
embedded: true
|
|
11910
|
+
});
|
|
11911
|
+
if (!review) return fail(404, "nothing is waiting for review on this job");
|
|
11912
|
+
reviewAssets.set(jobId, review.assets);
|
|
11913
|
+
res.writeHead(200, {
|
|
11914
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
11915
|
+
"Cache-Control": "no-store",
|
|
11916
|
+
"X-Content-Type-Options": "nosniff"
|
|
11917
|
+
});
|
|
11918
|
+
res.end(review.html({
|
|
11919
|
+
applyUrl: `${routePrefix}/apply`,
|
|
11920
|
+
applyHeaders: { "X-Pixelkiln-Session": session },
|
|
11921
|
+
embedded: true
|
|
11922
|
+
}));
|
|
11923
|
+
} catch (err) {
|
|
11924
|
+
reportError("review", err);
|
|
11925
|
+
}
|
|
11926
|
+
return;
|
|
11927
|
+
}
|
|
11928
|
+
if (req.method === "GET") {
|
|
11929
|
+
const asset = reviewAssets.get(jobId)?.get(url2.pathname);
|
|
11930
|
+
if (!asset) return fail(404, "no such review asset");
|
|
11931
|
+
try {
|
|
11932
|
+
const bytes = await (0, import_promises20.readFile)(asset.path);
|
|
11933
|
+
res.writeHead(200, {
|
|
11934
|
+
"Content-Type": asset.contentType,
|
|
11935
|
+
"Cache-Control": "no-store",
|
|
11936
|
+
"X-Content-Type-Options": "nosniff"
|
|
11937
|
+
});
|
|
11938
|
+
res.end(bytes);
|
|
11939
|
+
} catch {
|
|
11940
|
+
fail(404, "review asset is unavailable");
|
|
11941
|
+
}
|
|
11942
|
+
return;
|
|
11943
|
+
}
|
|
11944
|
+
}
|
|
11945
|
+
if (req.method !== "GET" && req.method !== "HEAD") return fail(405, "the gallery is read-only");
|
|
11946
|
+
if (url2.pathname === "/api/jobs") {
|
|
11947
|
+
if (!opts.generate) return fail(404, "generation is off");
|
|
11948
|
+
return json(200, opts.generate.status());
|
|
11949
|
+
}
|
|
11950
|
+
try {
|
|
11951
|
+
if (url2.pathname === "/") {
|
|
11952
|
+
const { snapshot } = await load();
|
|
11953
|
+
res.writeHead(200, {
|
|
11954
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
11955
|
+
"Cache-Control": "no-store",
|
|
11956
|
+
"X-Content-Type-Options": "nosniff"
|
|
11957
|
+
});
|
|
11958
|
+
res.end(renderGallery(snapshot, {
|
|
11959
|
+
...session ? { session } : {},
|
|
11960
|
+
editable: Boolean(opts.edit),
|
|
11961
|
+
generation: Boolean(opts.generate)
|
|
11962
|
+
}));
|
|
11963
|
+
return;
|
|
11964
|
+
}
|
|
11965
|
+
if (url2.pathname === "/api/gallery.json") {
|
|
11966
|
+
const { snapshot } = await load();
|
|
11967
|
+
res.writeHead(200, {
|
|
11968
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
11969
|
+
"Cache-Control": "no-store",
|
|
11970
|
+
"X-Content-Type-Options": "nosniff"
|
|
11971
|
+
});
|
|
11972
|
+
res.end(JSON.stringify(snapshot));
|
|
11973
|
+
return;
|
|
11974
|
+
}
|
|
11975
|
+
const mediaMatch = /^\/media\/([0-9a-f]{24})$/.exec(url2.pathname);
|
|
11976
|
+
if (mediaMatch) {
|
|
11977
|
+
const asset = media.get(mediaMatch[1]);
|
|
11978
|
+
if (!asset) return fail(404, "no such gallery media");
|
|
11979
|
+
let bytes;
|
|
11980
|
+
try {
|
|
11981
|
+
bytes = await (0, import_promises20.readFile)(asset.path);
|
|
11982
|
+
} catch {
|
|
11983
|
+
return fail(404, "gallery media is no longer on disk; refresh the page");
|
|
11984
|
+
}
|
|
11985
|
+
res.writeHead(200, {
|
|
11986
|
+
"Content-Type": asset.contentType,
|
|
11987
|
+
"Content-Length": bytes.length,
|
|
11988
|
+
// The page cache-busts with the recorded hash, so a regenerated file
|
|
11989
|
+
// is never shown stale; `no-cache` keeps a hard reload honest too.
|
|
11990
|
+
"Cache-Control": "no-cache",
|
|
11991
|
+
"X-Content-Type-Options": "nosniff"
|
|
11992
|
+
});
|
|
11993
|
+
res.end(req.method === "HEAD" ? void 0 : bytes);
|
|
11994
|
+
return;
|
|
11995
|
+
}
|
|
11996
|
+
fail(404, "not found");
|
|
11997
|
+
} catch (err) {
|
|
11998
|
+
const message6 = err instanceof Error ? err.message : String(err);
|
|
11999
|
+
log(` gallery request failed: ${message6}`);
|
|
12000
|
+
fail(500, message6);
|
|
12001
|
+
}
|
|
12002
|
+
});
|
|
12003
|
+
const url = await new Promise((resolve, reject) => {
|
|
12004
|
+
server.once("error", reject);
|
|
12005
|
+
server.listen(opts.port ?? 0, "127.0.0.1", () => {
|
|
12006
|
+
const address = server.address();
|
|
12007
|
+
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
12008
|
+
resolve(`http://127.0.0.1:${port}/`);
|
|
12009
|
+
});
|
|
12010
|
+
});
|
|
12011
|
+
opts.onReady?.(url);
|
|
12012
|
+
if (opts.open !== false && process.platform === "darwin") {
|
|
12013
|
+
(0, import_node_child_process3.spawn)("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
9090
12014
|
}
|
|
9091
12015
|
return {
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
|
|
12016
|
+
url,
|
|
12017
|
+
session,
|
|
12018
|
+
close: () => new Promise((resolve, reject) => {
|
|
12019
|
+
server.closeAllConnections?.();
|
|
12020
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
12021
|
+
})
|
|
12022
|
+
};
|
|
12023
|
+
}
|
|
12024
|
+
|
|
12025
|
+
// src/gallery/generate.ts
|
|
12026
|
+
var import_node_crypto8 = require("crypto");
|
|
12027
|
+
var import_zod8 = require("zod");
|
|
12028
|
+
var GenerateRequestSchema = import_zod8.z.object({
|
|
12029
|
+
project: import_zod8.z.string().min(1).optional(),
|
|
12030
|
+
keys: import_zod8.z.array(import_zod8.z.string().min(1)).min(1).max(500),
|
|
12031
|
+
/** Regenerate work that is up to date, as `gen --force` does. */
|
|
12032
|
+
force: import_zod8.z.boolean().optional(),
|
|
12033
|
+
/** Advance in-flight, review, or selected work without submitting anything. */
|
|
12034
|
+
resume: import_zod8.z.boolean().optional()
|
|
12035
|
+
}).strict();
|
|
12036
|
+
var GenerateRequestError = class extends Error {
|
|
12037
|
+
constructor(message6, status = 400) {
|
|
12038
|
+
super(message6);
|
|
12039
|
+
this.status = status;
|
|
12040
|
+
this.name = "GenerateRequestError";
|
|
12041
|
+
}
|
|
12042
|
+
status;
|
|
12043
|
+
};
|
|
12044
|
+
function message5(error) {
|
|
12045
|
+
return error instanceof Error ? error.message : String(error);
|
|
12046
|
+
}
|
|
12047
|
+
function remainingFor(group, groupCount, budget, spent) {
|
|
12048
|
+
const keyed = budget.byProvider[group.provider];
|
|
12049
|
+
if (keyed === void 0 && groupCount > 1 && budget.amount !== void 0) {
|
|
12050
|
+
throw new GenerateRequestError(
|
|
12051
|
+
"This run spans several providers; start the gallery with a keyed budget for each, for example --budget pixellab=40 --budget retrodiffusion=1.25."
|
|
12052
|
+
);
|
|
12053
|
+
}
|
|
12054
|
+
const ceiling = keyed ?? (groupCount === 1 ? budget.amount : void 0);
|
|
12055
|
+
if (ceiling === void 0) {
|
|
12056
|
+
throw new GenerateRequestError(
|
|
12057
|
+
`No session budget for ${group.provider}; start the gallery with --budget ${group.provider}=<amount>.`
|
|
12058
|
+
);
|
|
12059
|
+
}
|
|
12060
|
+
return Math.max(0, ceiling - (spent[group.provider] ?? 0));
|
|
12061
|
+
}
|
|
12062
|
+
function createGenerateHandlers(opts) {
|
|
12063
|
+
const log = opts.onProgress ?? (() => {
|
|
12064
|
+
});
|
|
12065
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
12066
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
12067
|
+
const spent = {};
|
|
12068
|
+
const units = {};
|
|
12069
|
+
const contexts = /* @__PURE__ */ new Map();
|
|
12070
|
+
const reviews = /* @__PURE__ */ new Map();
|
|
12071
|
+
const busyKeys = () => {
|
|
12072
|
+
const busy = /* @__PURE__ */ new Map();
|
|
12073
|
+
for (const job of jobs.values()) {
|
|
12074
|
+
if (job.phase === "done" || job.phase === "failed") continue;
|
|
12075
|
+
for (const key of job.keys) busy.set(`${job.project ?? ""}:${key}`, job.id);
|
|
12076
|
+
}
|
|
12077
|
+
return busy;
|
|
12078
|
+
};
|
|
12079
|
+
const say = (job, line) => {
|
|
12080
|
+
job.messages.push(line);
|
|
12081
|
+
if (job.messages.length > 400) job.messages.splice(0, job.messages.length - 400);
|
|
12082
|
+
log(` [${job.id.slice(0, 6)}] ${line.trim()}`);
|
|
12083
|
+
};
|
|
12084
|
+
const groupByRecordedProvider = (specs, lock) => {
|
|
12085
|
+
const groups = /* @__PURE__ */ new Map();
|
|
12086
|
+
for (const spec of specs) {
|
|
12087
|
+
const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
|
|
12088
|
+
const providerId = entry?.provider ?? spec.provider;
|
|
12089
|
+
groups.set(providerId, [...groups.get(providerId) ?? [], spec]);
|
|
12090
|
+
}
|
|
12091
|
+
return groups;
|
|
12092
|
+
};
|
|
12093
|
+
async function settle(job, ctx, specs) {
|
|
12094
|
+
job.phase = "polling";
|
|
12095
|
+
for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
|
|
12096
|
+
const provider = opts.providerFor(job.project ?? void 0, providerId);
|
|
12097
|
+
const res = await poll(provider, ctx.lock, ctx.lockPath, {
|
|
12098
|
+
onProgress: (line) => say(job, line),
|
|
12099
|
+
specs: providerSpecs,
|
|
12100
|
+
intervalMs: opts.pollIntervalMs
|
|
12101
|
+
});
|
|
12102
|
+
say(job, `${providerId}: ${res.completed} completed, ${res.review} awaiting review, ${res.failed} failed, ${res.stillRunning} still running`);
|
|
12103
|
+
}
|
|
12104
|
+
job.review = specs.map((spec) => lockKey(spec.styleId, spec.assetId)).filter((key) => ctx.lock.entries[key]?.status === "review");
|
|
12105
|
+
job.phase = "fetching";
|
|
12106
|
+
for (const [providerId, providerSpecs] of groupByRecordedProvider(specs, ctx.lock)) {
|
|
12107
|
+
const provider = opts.providerFor(job.project ?? void 0, providerId);
|
|
12108
|
+
const res = await fetchAssets(provider, providerSpecs, ctx.lock, ctx.lockPath, {
|
|
12109
|
+
onProgress: (line) => say(job, line)
|
|
12110
|
+
});
|
|
12111
|
+
job.counts.downloaded += res.downloaded;
|
|
12112
|
+
if (res.downloaded || res.failed) {
|
|
12113
|
+
say(job, `${providerId}: downloaded ${res.downloaded}, skipped ${res.skipped}, failed ${res.failed}`);
|
|
12114
|
+
}
|
|
12115
|
+
}
|
|
12116
|
+
if (job.review.length) {
|
|
12117
|
+
job.phase = "review";
|
|
12118
|
+
say(job, `${job.review.length} asset(s) awaiting selection`);
|
|
12119
|
+
} else {
|
|
12120
|
+
job.phase = "done";
|
|
12121
|
+
job.finishedAt = now().toISOString();
|
|
12122
|
+
}
|
|
12123
|
+
}
|
|
12124
|
+
async function run(job, ctx, specs, groups) {
|
|
12125
|
+
try {
|
|
12126
|
+
if (job.mode === "generate") {
|
|
12127
|
+
job.phase = "submitting";
|
|
12128
|
+
for (const group of groups) {
|
|
12129
|
+
const provider = opts.providerFor(job.project ?? void 0, group.provider);
|
|
12130
|
+
const ceiling = remainingFor(group, groups.length, opts.budget, spent);
|
|
12131
|
+
if (provider.balance) {
|
|
12132
|
+
const balance = await provider.balance();
|
|
12133
|
+
say(job, `balance: ${formatCost(balance.unit, balance.remaining)} remaining (${group.provider})`);
|
|
12134
|
+
if (balance.unit !== group.costUnit) {
|
|
12135
|
+
throw new Error(
|
|
12136
|
+
`Provider ${group.provider} estimate unit ${group.costUnit} does not match balance unit ${balance.unit}.`
|
|
12137
|
+
);
|
|
12138
|
+
}
|
|
12139
|
+
if (balance.unit !== "free" && group.cost > balance.remaining) {
|
|
12140
|
+
throw new Error(
|
|
12141
|
+
`${group.provider} needs ${formatCost(balance.unit, group.cost)} but only ${formatCost(balance.unit, balance.remaining)} remain.`
|
|
12142
|
+
);
|
|
12143
|
+
}
|
|
12144
|
+
}
|
|
12145
|
+
const res = await submit(provider, ctx.loaded, group.actionable, ctx.lock, ctx.lockPath, {
|
|
12146
|
+
budget: ceiling,
|
|
12147
|
+
onProgress: (line) => say(job, line),
|
|
12148
|
+
spacingMs: opts.submitSpacingMs
|
|
12149
|
+
});
|
|
12150
|
+
spent[group.provider] = (spent[group.provider] ?? 0) + res.spent;
|
|
12151
|
+
units[group.provider] = res.unit;
|
|
12152
|
+
job.spent[group.provider] = (job.spent[group.provider] ?? 0) + res.spent;
|
|
12153
|
+
job.counts.submitted += res.submitted;
|
|
12154
|
+
job.counts.failed += res.failed;
|
|
12155
|
+
say(job, `${group.provider}: submitted ${res.submitted}, failed ${res.failed}, estimated ${formatCost(res.unit, res.spent)}`);
|
|
12156
|
+
}
|
|
12157
|
+
}
|
|
12158
|
+
await settle(job, ctx, specs);
|
|
12159
|
+
} catch (error) {
|
|
12160
|
+
job.phase = "failed";
|
|
12161
|
+
job.error = message5(error);
|
|
12162
|
+
job.finishedAt = now().toISOString();
|
|
12163
|
+
say(job, `failed: ${job.error}`);
|
|
12164
|
+
}
|
|
12165
|
+
}
|
|
12166
|
+
return {
|
|
12167
|
+
async start(body) {
|
|
12168
|
+
const parsed = GenerateRequestSchema.safeParse(body);
|
|
12169
|
+
if (!parsed.success) {
|
|
12170
|
+
throw new GenerateRequestError(
|
|
12171
|
+
"invalid request: " + parsed.error.issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
|
|
12172
|
+
);
|
|
12173
|
+
}
|
|
12174
|
+
const request = parsed.data;
|
|
12175
|
+
const ctx = await opts.loadProject(request.project);
|
|
12176
|
+
const byKey = new Map(ctx.specs.map((spec) => [lockKey(spec.styleId, spec.assetId), spec]));
|
|
12177
|
+
const specs = [];
|
|
12178
|
+
for (const key of request.keys) {
|
|
12179
|
+
const spec = byKey.get(key);
|
|
12180
|
+
if (!spec) throw new GenerateRequestError(`"${key}" is not declared by the manifest`);
|
|
12181
|
+
specs.push(spec);
|
|
9108
12182
|
}
|
|
12183
|
+
const busy = busyKeys();
|
|
12184
|
+
for (const key of request.keys) {
|
|
12185
|
+
const holder = busy.get(`${request.project ?? ""}:${key}`);
|
|
12186
|
+
if (holder) throw new GenerateRequestError(`"${key}" is already being worked on by job ${holder}`, 409);
|
|
12187
|
+
}
|
|
12188
|
+
let groups = [];
|
|
12189
|
+
if (!request.resume) {
|
|
12190
|
+
const plan = await buildPlan(specs, ctx.lock, { force: request.force });
|
|
12191
|
+
if (!plan.actionable.length) {
|
|
12192
|
+
const reasons = plan.items.map((item) => `${item.key}: ${item.state} \u2014 ${item.reason}`).slice(0, 5);
|
|
12193
|
+
throw new GenerateRequestError(`nothing to generate; ${reasons.join("; ")}`);
|
|
12194
|
+
}
|
|
12195
|
+
groups = plan.groups;
|
|
12196
|
+
for (const group of groups) {
|
|
12197
|
+
const remaining = remainingFor(group, groups.length, opts.budget, spent);
|
|
12198
|
+
if (group.cost > remaining) {
|
|
12199
|
+
throw new GenerateRequestError(
|
|
12200
|
+
`${group.provider} would spend ${formatCost(group.costUnit, group.cost)} but only ${formatCost(group.costUnit, remaining)} of this session's budget remain.`
|
|
12201
|
+
);
|
|
12202
|
+
}
|
|
12203
|
+
}
|
|
12204
|
+
}
|
|
12205
|
+
const job = {
|
|
12206
|
+
id: (0, import_node_crypto8.randomBytes)(8).toString("hex"),
|
|
12207
|
+
project: request.project ?? null,
|
|
12208
|
+
keys: request.keys,
|
|
12209
|
+
mode: request.resume ? "resume" : "generate",
|
|
12210
|
+
phase: "queued",
|
|
12211
|
+
startedAt: now().toISOString(),
|
|
12212
|
+
finishedAt: null,
|
|
12213
|
+
messages: [],
|
|
12214
|
+
error: null,
|
|
12215
|
+
spent: {},
|
|
12216
|
+
review: [],
|
|
12217
|
+
counts: { submitted: 0, failed: 0, downloaded: 0 }
|
|
12218
|
+
};
|
|
12219
|
+
jobs.set(job.id, job);
|
|
12220
|
+
contexts.set(job.id, ctx);
|
|
12221
|
+
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("; "));
|
|
12222
|
+
void run(job, ctx, specs, groups);
|
|
12223
|
+
return job;
|
|
9109
12224
|
},
|
|
9110
|
-
|
|
12225
|
+
status() {
|
|
12226
|
+
return {
|
|
12227
|
+
jobs: [...jobs.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt)),
|
|
12228
|
+
budget: opts.budget,
|
|
12229
|
+
spent,
|
|
12230
|
+
units
|
|
12231
|
+
};
|
|
12232
|
+
},
|
|
12233
|
+
async review(jobId, options) {
|
|
12234
|
+
const job = jobs.get(jobId);
|
|
12235
|
+
const ctx = contexts.get(jobId);
|
|
12236
|
+
if (!job || !ctx || job.phase !== "review" || !job.review.length) return null;
|
|
12237
|
+
const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
|
|
12238
|
+
const providers = [...groupByRecordedProvider(specs, ctx.lock).keys()];
|
|
12239
|
+
const providerId = providers[0];
|
|
12240
|
+
if (!providerId) return null;
|
|
12241
|
+
const session = await prepareReview(opts.providerFor(job.project ?? void 0, providerId), ctx.lock, {
|
|
12242
|
+
keys: job.review,
|
|
12243
|
+
specs,
|
|
12244
|
+
onProgress: (line) => say(job, line),
|
|
12245
|
+
routePrefix: options.routePrefix
|
|
12246
|
+
});
|
|
12247
|
+
if (session) reviews.set(jobId, session);
|
|
12248
|
+
return session;
|
|
12249
|
+
},
|
|
12250
|
+
async applyReview(jobId, body) {
|
|
12251
|
+
const job = jobs.get(jobId);
|
|
12252
|
+
const ctx = contexts.get(jobId);
|
|
12253
|
+
const session = reviews.get(jobId);
|
|
12254
|
+
if (!job || !ctx || !session) throw new GenerateRequestError("no review is open for this job", 404);
|
|
12255
|
+
const result = await session.apply(body, ctx.lockPath);
|
|
12256
|
+
say(job, `selected ${result.selected}, left in review ${result.skipped}`);
|
|
12257
|
+
reviews.delete(jobId);
|
|
12258
|
+
const specs = ctx.specs.filter((spec) => job.review.includes(lockKey(spec.styleId, spec.assetId)));
|
|
12259
|
+
void settle(job, ctx, specs).catch((error) => {
|
|
12260
|
+
job.phase = "failed";
|
|
12261
|
+
job.error = message5(error);
|
|
12262
|
+
job.finishedAt = now().toISOString();
|
|
12263
|
+
say(job, `failed: ${job.error}`);
|
|
12264
|
+
});
|
|
12265
|
+
return result;
|
|
12266
|
+
}
|
|
9111
12267
|
};
|
|
9112
12268
|
}
|
|
9113
|
-
function mode(values) {
|
|
9114
|
-
const counts = /* @__PURE__ */ new Map();
|
|
9115
|
-
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
9116
|
-
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 64;
|
|
9117
|
-
}
|
|
9118
12269
|
|
|
9119
12270
|
// src/pipeline/salvage.ts
|
|
9120
|
-
var
|
|
9121
|
-
var
|
|
9122
|
-
var
|
|
12271
|
+
var import_promises21 = require("fs/promises");
|
|
12272
|
+
var import_node_fs21 = require("fs");
|
|
12273
|
+
var import_node_path24 = __toESM(require("path"), 1);
|
|
9123
12274
|
function providerClaimId(provider, objectId) {
|
|
9124
12275
|
return `${encodeURIComponent(provider)}:${encodeURIComponent(objectId)}`;
|
|
9125
12276
|
}
|
|
9126
12277
|
async function loadClaims(lockPaths, opts = {}) {
|
|
9127
12278
|
const claimed = /* @__PURE__ */ new Set();
|
|
9128
12279
|
for (const p of lockPaths) {
|
|
9129
|
-
if (!(0,
|
|
12280
|
+
if (!(0, import_node_fs21.existsSync)(p)) throw new Error(`Claim lockfile not found: ${p}`);
|
|
9130
12281
|
let parsed;
|
|
9131
12282
|
try {
|
|
9132
|
-
parsed = parseLock(JSON.parse(await (0,
|
|
12283
|
+
parsed = parseLock(JSON.parse(await (0, import_promises21.readFile)(p, "utf8")));
|
|
9133
12284
|
} catch {
|
|
9134
12285
|
throw new Error(`Claim lockfile is malformed: ${p}`);
|
|
9135
12286
|
}
|
|
@@ -9182,20 +12333,20 @@ function matchOrphanStyle(prompt, manifest) {
|
|
|
9182
12333
|
return matchStyleByPattern(prompt, manifest);
|
|
9183
12334
|
}
|
|
9184
12335
|
async function loadSiblingManifests(ownManifestPath, workspaceManifestPaths, claimPaths) {
|
|
9185
|
-
const own =
|
|
12336
|
+
const own = import_node_path24.default.resolve(ownManifestPath);
|
|
9186
12337
|
const siblingManifestPaths = [
|
|
9187
12338
|
.../* @__PURE__ */ new Set([
|
|
9188
12339
|
...workspaceManifestPaths,
|
|
9189
|
-
...claimPaths.map((c) =>
|
|
12340
|
+
...claimPaths.map((c) => import_node_path24.default.join(import_node_path24.default.dirname(import_node_path24.default.resolve(c)), "pixelkiln.manifest.json"))
|
|
9190
12341
|
])
|
|
9191
12342
|
];
|
|
9192
12343
|
const siblings = [];
|
|
9193
12344
|
for (const siblingManifestPath of siblingManifestPaths) {
|
|
9194
|
-
if (
|
|
9195
|
-
if (!(0,
|
|
12345
|
+
if (import_node_path24.default.resolve(siblingManifestPath) === own) continue;
|
|
12346
|
+
if (!(0, import_node_fs21.existsSync)(siblingManifestPath)) continue;
|
|
9196
12347
|
try {
|
|
9197
12348
|
const { manifest } = await loadManifest(siblingManifestPath);
|
|
9198
|
-
siblings.push({ label:
|
|
12349
|
+
siblings.push({ label: import_node_path24.default.basename(import_node_path24.default.dirname(siblingManifestPath)), manifest });
|
|
9199
12350
|
} catch {
|
|
9200
12351
|
}
|
|
9201
12352
|
}
|
|
@@ -9311,16 +12462,16 @@ async function applyTags(provider, decisions, existing, opts = {}) {
|
|
|
9311
12462
|
}
|
|
9312
12463
|
|
|
9313
12464
|
// src/pick/salvage-server.ts
|
|
9314
|
-
var
|
|
9315
|
-
var
|
|
12465
|
+
var import_promises22 = require("fs/promises");
|
|
12466
|
+
var import_node_path25 = __toESM(require("path"), 1);
|
|
9316
12467
|
|
|
9317
12468
|
// src/pick/salvage-sheet.ts
|
|
9318
|
-
var
|
|
12469
|
+
var escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
9319
12470
|
function renderSalvageSheet(orphans, ctx) {
|
|
9320
|
-
const safe = orphans.map((o) => ({ ...o, prompt:
|
|
12471
|
+
const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml3(o.prompt) }));
|
|
9321
12472
|
const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
|
|
9322
|
-
const styleId = ctx ?
|
|
9323
|
-
const importDir = ctx ?
|
|
12473
|
+
const styleId = ctx ? escapeHtml3(ctx.styleId) : null;
|
|
12474
|
+
const importDir = ctx ? escapeHtml3(ctx.importDir) : null;
|
|
9324
12475
|
return `<!doctype html>
|
|
9325
12476
|
<html lang="en">
|
|
9326
12477
|
<head>
|
|
@@ -9477,7 +12628,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9477
12628
|
});
|
|
9478
12629
|
const html = renderSalvageSheet(orphans, {
|
|
9479
12630
|
styleId: ctx.styleId,
|
|
9480
|
-
importDir:
|
|
12631
|
+
importDir: import_node_path25.default.relative(process.cwd(), ctx.importDir) || "."
|
|
9481
12632
|
});
|
|
9482
12633
|
const byId = new Map(orphans.map((o) => [o.id, o]));
|
|
9483
12634
|
const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
|
|
@@ -9506,10 +12657,10 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9506
12657
|
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
|
|
9507
12658
|
decodePng(buf);
|
|
9508
12659
|
const assetId = idFromPrompt(orphan.prompt, taken);
|
|
9509
|
-
const rel =
|
|
9510
|
-
const outFile =
|
|
9511
|
-
await (0,
|
|
9512
|
-
await (0,
|
|
12660
|
+
const rel = import_node_path25.default.join("_salvaged", `${assetId}.png`);
|
|
12661
|
+
const outFile = import_node_path25.default.resolve(ctx.importDir, rel);
|
|
12662
|
+
await (0, import_promises22.mkdir)(import_node_path25.default.dirname(outFile), { recursive: true });
|
|
12663
|
+
await (0, import_promises22.writeFile)(outFile, buf);
|
|
9513
12664
|
ctx.manifest.assets[assetId] = {
|
|
9514
12665
|
prompt: orphan.prompt,
|
|
9515
12666
|
promptByStyle: {},
|
|
@@ -9540,7 +12691,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9540
12691
|
sourceUrl: durableSource,
|
|
9541
12692
|
sourceUrls: durableSource ? [{ url: durableSource }] : [],
|
|
9542
12693
|
outputs: [{
|
|
9543
|
-
path: portableOutputPath(outFile,
|
|
12694
|
+
path: portableOutputPath(outFile, import_node_path25.default.dirname(ctx.manifestPath)),
|
|
9544
12695
|
sha256: sha256(buf)
|
|
9545
12696
|
}],
|
|
9546
12697
|
submittedAt: orphan.createdAt,
|
|
@@ -9564,148 +12715,15 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
9564
12715
|
}
|
|
9565
12716
|
}
|
|
9566
12717
|
await applyTags(provider, decisions, existingTags, { onProgress: log });
|
|
9567
|
-
const raw = JSON.parse(await (0,
|
|
12718
|
+
const raw = JSON.parse(await (0, import_promises22.readFile)(ctx.manifestPath, "utf8"));
|
|
9568
12719
|
for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
|
|
9569
|
-
await (0,
|
|
12720
|
+
await (0, import_promises22.writeFile)(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
|
|
9570
12721
|
await saveLock(ctx.lockPath, ctx.lock);
|
|
9571
12722
|
return result;
|
|
9572
12723
|
}
|
|
9573
12724
|
});
|
|
9574
12725
|
}
|
|
9575
12726
|
|
|
9576
|
-
// src/workspace.ts
|
|
9577
|
-
var import_promises18 = require("fs/promises");
|
|
9578
|
-
var import_node_fs19 = require("fs");
|
|
9579
|
-
var import_node_path23 = __toESM(require("path"), 1);
|
|
9580
|
-
var import_zod6 = require("zod");
|
|
9581
|
-
var WorkspaceProjectSchema = import_zod6.z.object({
|
|
9582
|
-
id: import_zod6.z.string().min(1),
|
|
9583
|
-
/** Manifest path, relative to the catalog file's own directory. */
|
|
9584
|
-
manifest: import_zod6.z.string().min(1),
|
|
9585
|
-
/** Lockfile path, relative to the catalog file's own directory. */
|
|
9586
|
-
lock: import_zod6.z.string().min(1),
|
|
9587
|
-
provider: import_zod6.z.string().min(1).default("pixellab"),
|
|
9588
|
-
/** Free-form label for a shared account, e.g. distinguishing sandboxes. */
|
|
9589
|
-
account: import_zod6.z.string().optional()
|
|
9590
|
-
}).strict();
|
|
9591
|
-
var WorkspaceSchema = import_zod6.z.object({
|
|
9592
|
-
version: import_zod6.z.literal(1),
|
|
9593
|
-
projects: import_zod6.z.array(WorkspaceProjectSchema).default([])
|
|
9594
|
-
}).strict();
|
|
9595
|
-
function parseWorkspace(raw) {
|
|
9596
|
-
const parsed = WorkspaceSchema.safeParse(raw);
|
|
9597
|
-
if (parsed.success) return parsed.data;
|
|
9598
|
-
throw new Error(
|
|
9599
|
-
`Workspace catalog is not valid v1:
|
|
9600
|
-
${parsed.error.issues.slice(0, 5).map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
|
|
9601
|
-
);
|
|
9602
|
-
}
|
|
9603
|
-
async function loadWorkspace(workspacePath) {
|
|
9604
|
-
if (!(0, import_node_fs19.existsSync)(workspacePath)) return { version: 1, projects: [] };
|
|
9605
|
-
let raw;
|
|
9606
|
-
try {
|
|
9607
|
-
raw = JSON.parse(await (0, import_promises18.readFile)(workspacePath, "utf8"));
|
|
9608
|
-
} catch (err) {
|
|
9609
|
-
throw new Error(
|
|
9610
|
-
`Workspace catalog at ${workspacePath} is malformed:
|
|
9611
|
-
${err instanceof Error ? err.message : String(err)}`
|
|
9612
|
-
);
|
|
9613
|
-
}
|
|
9614
|
-
return parseWorkspace(raw);
|
|
9615
|
-
}
|
|
9616
|
-
async function saveWorkspace(workspacePath, ws) {
|
|
9617
|
-
const sorted = {
|
|
9618
|
-
version: 1,
|
|
9619
|
-
projects: [...ws.projects].sort((a, b) => a.id.localeCompare(b.id))
|
|
9620
|
-
};
|
|
9621
|
-
await (0, import_promises18.mkdir)(import_node_path23.default.dirname(import_node_path23.default.resolve(workspacePath)), { recursive: true });
|
|
9622
|
-
const tmp = `${workspacePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
9623
|
-
try {
|
|
9624
|
-
await (0, import_promises18.writeFile)(tmp, JSON.stringify(sorted, null, 2) + "\n");
|
|
9625
|
-
await (0, import_promises18.rename)(tmp, workspacePath);
|
|
9626
|
-
} finally {
|
|
9627
|
-
await (0, import_promises18.rm)(tmp, { force: true });
|
|
9628
|
-
}
|
|
9629
|
-
}
|
|
9630
|
-
function toPortablePath(dir, absolute) {
|
|
9631
|
-
return import_node_path23.default.relative(dir, absolute).split(import_node_path23.default.sep).join("/");
|
|
9632
|
-
}
|
|
9633
|
-
function resolveProject(dir, project) {
|
|
9634
|
-
return {
|
|
9635
|
-
manifestPath: import_node_path23.default.resolve(dir, project.manifest.split("/").join(import_node_path23.default.sep)),
|
|
9636
|
-
lockPath: import_node_path23.default.resolve(dir, project.lock.split("/").join(import_node_path23.default.sep))
|
|
9637
|
-
};
|
|
9638
|
-
}
|
|
9639
|
-
function validateWorkspace(ws, dir) {
|
|
9640
|
-
const diagnostics = [];
|
|
9641
|
-
const idCounts = /* @__PURE__ */ new Map();
|
|
9642
|
-
const lockOwners = /* @__PURE__ */ new Map();
|
|
9643
|
-
const manifestOwners = /* @__PURE__ */ new Map();
|
|
9644
|
-
for (const project of ws.projects) {
|
|
9645
|
-
idCounts.set(project.id, (idCounts.get(project.id) ?? 0) + 1);
|
|
9646
|
-
const { manifestPath, lockPath } = resolveProject(dir, project);
|
|
9647
|
-
lockOwners.set(lockPath, [...lockOwners.get(lockPath) ?? [], project.id]);
|
|
9648
|
-
manifestOwners.set(manifestPath, [...manifestOwners.get(manifestPath) ?? [], project.id]);
|
|
9649
|
-
if (import_node_path23.default.isAbsolute(project.manifest) || import_node_path23.default.isAbsolute(project.lock)) {
|
|
9650
|
-
diagnostics.push({
|
|
9651
|
-
id: "absolute-path",
|
|
9652
|
-
level: "warning",
|
|
9653
|
-
message: `project "${project.id}" stores an absolute path \u2014 the catalog will not resolve correctly if this tree is cloned or moved elsewhere`
|
|
9654
|
-
});
|
|
9655
|
-
}
|
|
9656
|
-
if (!(0, import_node_fs19.existsSync)(manifestPath)) {
|
|
9657
|
-
diagnostics.push({
|
|
9658
|
-
id: "missing-manifest",
|
|
9659
|
-
level: "error",
|
|
9660
|
-
message: `project "${project.id}" manifest not found: ${manifestPath}`
|
|
9661
|
-
});
|
|
9662
|
-
}
|
|
9663
|
-
if (!(0, import_node_fs19.existsSync)(lockPath)) {
|
|
9664
|
-
diagnostics.push({
|
|
9665
|
-
id: "missing-lock",
|
|
9666
|
-
level: "error",
|
|
9667
|
-
message: `project "${project.id}" lockfile not found: ${lockPath}`
|
|
9668
|
-
});
|
|
9669
|
-
}
|
|
9670
|
-
}
|
|
9671
|
-
for (const [id, count] of idCounts) {
|
|
9672
|
-
if (count > 1) {
|
|
9673
|
-
diagnostics.push({
|
|
9674
|
-
id: "duplicate-id",
|
|
9675
|
-
level: "error",
|
|
9676
|
-
message: `project id "${id}" is registered ${count} times`
|
|
9677
|
-
});
|
|
9678
|
-
}
|
|
9679
|
-
}
|
|
9680
|
-
for (const [lockPath, ids] of lockOwners) {
|
|
9681
|
-
if (ids.length > 1) {
|
|
9682
|
-
diagnostics.push({
|
|
9683
|
-
id: "duplicate-lock",
|
|
9684
|
-
level: "error",
|
|
9685
|
-
message: `${ids.join(", ")} all register the same lockfile: ${lockPath}`
|
|
9686
|
-
});
|
|
9687
|
-
}
|
|
9688
|
-
}
|
|
9689
|
-
for (const [manifestPath, ids] of manifestOwners) {
|
|
9690
|
-
if (ids.length > 1) {
|
|
9691
|
-
diagnostics.push({
|
|
9692
|
-
id: "duplicate-manifest",
|
|
9693
|
-
level: "warning",
|
|
9694
|
-
message: `${ids.join(", ")} share manifest ${manifestPath} \u2014 expected only when they are variant lockfiles beside one manifest`
|
|
9695
|
-
});
|
|
9696
|
-
}
|
|
9697
|
-
}
|
|
9698
|
-
const providers = new Set(ws.projects.map((p) => p.provider));
|
|
9699
|
-
if (providers.size > 1) {
|
|
9700
|
-
diagnostics.push({
|
|
9701
|
-
id: "mixed-provider",
|
|
9702
|
-
level: "warning",
|
|
9703
|
-
message: `registered projects use different providers: ${[...providers].sort().join(", ")} \u2014 spend totals are kept separate per unit, but confirm this is intentional`
|
|
9704
|
-
});
|
|
9705
|
-
}
|
|
9706
|
-
return diagnostics;
|
|
9707
|
-
}
|
|
9708
|
-
|
|
9709
12727
|
// src/pipeline/workspace.ts
|
|
9710
12728
|
async function workspaceClaims(ws, dir) {
|
|
9711
12729
|
const lockPaths = [];
|
|
@@ -9816,6 +12834,8 @@ async function workspaceStatus(ws, dir) {
|
|
|
9816
12834
|
DEFAULT_RATE_LIMIT,
|
|
9817
12835
|
FAKE_PNG,
|
|
9818
12836
|
FakeProvider,
|
|
12837
|
+
GenerateRequestError,
|
|
12838
|
+
GenerateRequestSchema,
|
|
9819
12839
|
GeneratorSchema,
|
|
9820
12840
|
GridConfidenceSchema,
|
|
9821
12841
|
ImageQualityMetricsSchema,
|
|
@@ -9824,6 +12844,9 @@ async function workspaceStatus(ws, dir) {
|
|
|
9824
12844
|
LockSchema,
|
|
9825
12845
|
MAX_DOWNLOAD_BYTES,
|
|
9826
12846
|
MAX_RETRIES,
|
|
12847
|
+
ManifestDriftError,
|
|
12848
|
+
ManifestEditError,
|
|
12849
|
+
ManifestEditSchema,
|
|
9827
12850
|
ManifestInputSchema,
|
|
9828
12851
|
ManifestSchema,
|
|
9829
12852
|
MediaType,
|
|
@@ -9846,13 +12869,16 @@ async function workspaceStatus(ws, dir) {
|
|
|
9846
12869
|
WorkspaceProjectSchema,
|
|
9847
12870
|
WorkspaceSchema,
|
|
9848
12871
|
adopt,
|
|
12872
|
+
applyManifestEdit,
|
|
9849
12873
|
applyTags,
|
|
9850
12874
|
approveQualityRecord,
|
|
9851
12875
|
auditStyle,
|
|
9852
12876
|
availableProviders,
|
|
9853
12877
|
backoffMs,
|
|
12878
|
+
buildGallerySnapshot,
|
|
9854
12879
|
buildManifest,
|
|
9855
12880
|
buildPlan,
|
|
12881
|
+
buildWorkspaceGallerySnapshot,
|
|
9856
12882
|
bundledRecipeRoot,
|
|
9857
12883
|
cacheFileName,
|
|
9858
12884
|
candidateCount,
|
|
@@ -9862,6 +12888,8 @@ async function workspaceStatus(ws, dir) {
|
|
|
9862
12888
|
colorDistance,
|
|
9863
12889
|
countNumberedDescriptions,
|
|
9864
12890
|
createArtifactBundleManifest,
|
|
12891
|
+
createGalleryEditHandler,
|
|
12892
|
+
createGenerateHandlers,
|
|
9865
12893
|
createProvider,
|
|
9866
12894
|
currentEntryOutputPath,
|
|
9867
12895
|
currentOutputPath,
|
|
@@ -9874,6 +12902,8 @@ async function workspaceStatus(ws, dir) {
|
|
|
9874
12902
|
fetchAssets,
|
|
9875
12903
|
findOrphans,
|
|
9876
12904
|
formatCost,
|
|
12905
|
+
galleryMediaId,
|
|
12906
|
+
galleryMediaRoute,
|
|
9877
12907
|
generationCost,
|
|
9878
12908
|
groupOrphansByStyle,
|
|
9879
12909
|
hex,
|
|
@@ -9913,6 +12943,7 @@ async function workspaceStatus(ws, dir) {
|
|
|
9913
12943
|
pngSize,
|
|
9914
12944
|
poll,
|
|
9915
12945
|
portableOutputPath,
|
|
12946
|
+
prepareReview,
|
|
9916
12947
|
primaryOutput,
|
|
9917
12948
|
providerCredentialEnvs,
|
|
9918
12949
|
providerFactory,
|
|
@@ -9928,6 +12959,7 @@ async function workspaceStatus(ws, dir) {
|
|
|
9928
12959
|
refineQualityProfiles,
|
|
9929
12960
|
registerProvider,
|
|
9930
12961
|
remove,
|
|
12962
|
+
renderGallery,
|
|
9931
12963
|
renderSalvageSheet,
|
|
9932
12964
|
renderSheet,
|
|
9933
12965
|
requireApprovedQualitySources,
|
|
@@ -9956,6 +12988,7 @@ async function workspaceStatus(ws, dir) {
|
|
|
9956
12988
|
saveWorkspace,
|
|
9957
12989
|
scanAssets,
|
|
9958
12990
|
selectEntryOutput,
|
|
12991
|
+
serveGallery,
|
|
9959
12992
|
sha256,
|
|
9960
12993
|
sha256File,
|
|
9961
12994
|
shouldPersistSourceUrl,
|