pixelkiln 0.26.0 → 0.28.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 +2 -2
- package/SECURITY.md +12 -2
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +1158 -307
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +512 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +101 -3
- package/dist/index.d.ts +101 -3
- package/dist/index.js +525 -44
- package/dist/index.js.map +1 -1
- package/docs/ARCHITECTURE.md +16 -0
- package/docs/ARTIFACTS.md +25 -0
- package/docs/CLI.md +56 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -9030,7 +9030,7 @@ async function runPicker(provider, lock, lockPath, opts = {}) {
|
|
|
9030
9030
|
// src/gallery/snapshot.ts
|
|
9031
9031
|
import { createHash as createHash4 } from "crypto";
|
|
9032
9032
|
import { existsSync as existsSync19 } from "fs";
|
|
9033
|
-
import { readFile as
|
|
9033
|
+
import { readFile as readFile19, stat as stat3 } from "fs/promises";
|
|
9034
9034
|
import path24 from "path";
|
|
9035
9035
|
|
|
9036
9036
|
// src/workspace.ts
|
|
@@ -9168,7 +9168,7 @@ function validateWorkspace(ws, dir) {
|
|
|
9168
9168
|
|
|
9169
9169
|
// src/gallery/edit.ts
|
|
9170
9170
|
import path23 from "path";
|
|
9171
|
-
import { z as
|
|
9171
|
+
import { z as z9 } from "zod";
|
|
9172
9172
|
|
|
9173
9173
|
// src/manifest-edit.ts
|
|
9174
9174
|
import { readFile as readFile17, rename as rename6, rm as rm8, writeFile as writeFile8 } from "fs/promises";
|
|
@@ -9422,8 +9422,9 @@ async function applyManifestEdit(manifestPath, edit) {
|
|
|
9422
9422
|
// src/pipeline/hand-edit.ts
|
|
9423
9423
|
import { spawn as spawn2 } from "child_process";
|
|
9424
9424
|
import { existsSync as existsSync18 } from "fs";
|
|
9425
|
-
import { copyFile, mkdir as mkdir6 } from "fs/promises";
|
|
9425
|
+
import { copyFile, mkdir as mkdir6, readFile as readFile18, rename as rename7, rm as rm9, writeFile as writeFile9 } from "fs/promises";
|
|
9426
9426
|
import path22 from "path";
|
|
9427
|
+
import { z as z8 } from "zod";
|
|
9427
9428
|
var HAND_EDIT_DIR = "edits";
|
|
9428
9429
|
function handEditPath(loaded, spec) {
|
|
9429
9430
|
const style = loaded.manifest.styles[spec.styleId];
|
|
@@ -9493,6 +9494,89 @@ async function detachHandEdit(loaded, spec, opts = {}) {
|
|
|
9493
9494
|
});
|
|
9494
9495
|
return { manifestSha256: result.sha256, changed: result.changed };
|
|
9495
9496
|
}
|
|
9497
|
+
var HandEditCompanionSchema = z8.object({
|
|
9498
|
+
version: z8.literal(1),
|
|
9499
|
+
/** sha256 of the generated art the edit started from; null when it started from untracked art. */
|
|
9500
|
+
basedOn: z8.string().regex(/^[0-9a-f]{64}$/).nullable(),
|
|
9501
|
+
/** sha256 of the edit PNG as saved, so a later change by another tool is visible. */
|
|
9502
|
+
sha256: z8.string().regex(/^[0-9a-f]{64}$/),
|
|
9503
|
+
/** Editor identity the bridge reported, e.g. `pixelorama@v1.2.2-stable`. */
|
|
9504
|
+
editor: z8.string().min(1),
|
|
9505
|
+
protocol: z8.number().int().positive(),
|
|
9506
|
+
savedAt: z8.string().datetime(),
|
|
9507
|
+
/** Basename of the layered project file beside the edit, when one was kept. */
|
|
9508
|
+
project: z8.string().min(1).nullable()
|
|
9509
|
+
}).strict();
|
|
9510
|
+
function handEditCompanionPath(editPath) {
|
|
9511
|
+
return editPath.replace(/\.png$/i, "") + ".edit.json";
|
|
9512
|
+
}
|
|
9513
|
+
function handEditProjectPath(editPath) {
|
|
9514
|
+
return editPath.replace(/\.png$/i, "") + ".pxo";
|
|
9515
|
+
}
|
|
9516
|
+
async function readHandEditCompanion(editPath) {
|
|
9517
|
+
let text;
|
|
9518
|
+
try {
|
|
9519
|
+
text = await readFile18(handEditCompanionPath(editPath), "utf8");
|
|
9520
|
+
} catch {
|
|
9521
|
+
return null;
|
|
9522
|
+
}
|
|
9523
|
+
try {
|
|
9524
|
+
return HandEditCompanionSchema.parse(JSON.parse(text));
|
|
9525
|
+
} catch {
|
|
9526
|
+
return null;
|
|
9527
|
+
}
|
|
9528
|
+
}
|
|
9529
|
+
var MAX_HAND_EDIT_BYTES = 16 * 1024 * 1024;
|
|
9530
|
+
async function saveHandEdit(loaded, lock, spec, input) {
|
|
9531
|
+
if (input.png.length > MAX_HAND_EDIT_BYTES) {
|
|
9532
|
+
throw new ManifestEditError(`the edit is ${input.png.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
|
|
9533
|
+
}
|
|
9534
|
+
if (input.project && input.project.length > MAX_HAND_EDIT_BYTES) {
|
|
9535
|
+
throw new ManifestEditError(`the project file is ${input.project.length} bytes; the limit is ${MAX_HAND_EDIT_BYTES}`);
|
|
9536
|
+
}
|
|
9537
|
+
let decoded;
|
|
9538
|
+
try {
|
|
9539
|
+
validateMedia(input.png, MediaType.PNG);
|
|
9540
|
+
decoded = decodePng(input.png);
|
|
9541
|
+
} catch (error) {
|
|
9542
|
+
throw new ManifestEditError(`the edit is not a valid PNG: ${error instanceof Error ? error.message : String(error)}`);
|
|
9543
|
+
}
|
|
9544
|
+
const base = handEditBase(spec, lock);
|
|
9545
|
+
const expected = base ? decodePng(await readFile18(base)) : { width: spec.width, height: spec.height };
|
|
9546
|
+
if (decoded.width !== expected.width || decoded.height !== expected.height) {
|
|
9547
|
+
throw new ManifestEditError(
|
|
9548
|
+
`the edit is ${decoded.width}\xD7${decoded.height}; ${spec.styleId}/${spec.assetId} is ${expected.width}\xD7${expected.height}`
|
|
9549
|
+
);
|
|
9550
|
+
}
|
|
9551
|
+
const started = await startHandEdit(loaded, lock, spec, { expectedSha256: input.expectedSha256 });
|
|
9552
|
+
const entry = lock.entries[lockKey(spec.styleId, spec.assetId)];
|
|
9553
|
+
const basedOn = entry ? primaryOutput(entry)?.sha256 ?? null : null;
|
|
9554
|
+
const digest2 = sha256(input.png);
|
|
9555
|
+
const projectPath = input.project ? handEditProjectPath(started.editPath) : null;
|
|
9556
|
+
const companion = {
|
|
9557
|
+
version: 1,
|
|
9558
|
+
basedOn,
|
|
9559
|
+
sha256: digest2,
|
|
9560
|
+
editor: input.editor,
|
|
9561
|
+
protocol: input.protocol,
|
|
9562
|
+
savedAt: (input.now ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
9563
|
+
project: projectPath ? path22.basename(projectPath) : null
|
|
9564
|
+
};
|
|
9565
|
+
await replaceFile(started.editPath, input.png);
|
|
9566
|
+
if (projectPath && input.project) await replaceFile(projectPath, input.project);
|
|
9567
|
+
const companionPath = handEditCompanionPath(started.editPath);
|
|
9568
|
+
await replaceFile(companionPath, JSON.stringify(companion, null, 2) + "\n");
|
|
9569
|
+
return { ...started, sha256: digest2, basedOn, companionPath, projectPath };
|
|
9570
|
+
}
|
|
9571
|
+
async function replaceFile(file, bytes) {
|
|
9572
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
9573
|
+
try {
|
|
9574
|
+
await writeFile9(tmp, bytes);
|
|
9575
|
+
await rename7(tmp, file);
|
|
9576
|
+
} finally {
|
|
9577
|
+
await rm9(tmp, { force: true });
|
|
9578
|
+
}
|
|
9579
|
+
}
|
|
9496
9580
|
function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
|
|
9497
9581
|
const command = editor?.trim() ? editor.trim().split(/\s+/) : process.platform === "darwin" ? ["open"] : process.platform === "win32" ? ["cmd", "/c", "start", ""] : ["xdg-open"];
|
|
9498
9582
|
const [program, ...args] = command;
|
|
@@ -9504,22 +9588,37 @@ function openInEditor(file, editor = process.env.PIXELKILN_EDITOR) {
|
|
|
9504
9588
|
}
|
|
9505
9589
|
|
|
9506
9590
|
// src/gallery/edit.ts
|
|
9507
|
-
var
|
|
9508
|
-
|
|
9509
|
-
|
|
9510
|
-
|
|
9511
|
-
|
|
9512
|
-
|
|
9513
|
-
|
|
9591
|
+
var Base64 = z9.string().regex(/^[A-Za-z0-9+/]*={0,2}$/).max(Math.ceil(MAX_HAND_EDIT_BYTES / 3) * 4);
|
|
9592
|
+
var HandEditRequestSchema = z9.discriminatedUnion("action", [
|
|
9593
|
+
z9.object({
|
|
9594
|
+
action: z9.literal("start-edit"),
|
|
9595
|
+
project: z9.string().min(1).optional(),
|
|
9596
|
+
assetId: z9.string().min(1),
|
|
9597
|
+
styleId: z9.string().min(1),
|
|
9598
|
+
expectedSha256: z9.string().regex(/^[0-9a-f]{64}$/),
|
|
9514
9599
|
/** Also launch the author's editor on the file. */
|
|
9515
|
-
open:
|
|
9600
|
+
open: z9.boolean().optional()
|
|
9601
|
+
}).strict(),
|
|
9602
|
+
z9.object({
|
|
9603
|
+
action: z9.literal("detach-edit"),
|
|
9604
|
+
project: z9.string().min(1).optional(),
|
|
9605
|
+
assetId: z9.string().min(1),
|
|
9606
|
+
styleId: z9.string().min(1),
|
|
9607
|
+
expectedSha256: z9.string().regex(/^[0-9a-f]{64}$/)
|
|
9516
9608
|
}).strict(),
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
|
|
9609
|
+
/** From the in-browser editor: the page holds the bytes, the server validates and writes. */
|
|
9610
|
+
z9.object({
|
|
9611
|
+
action: z9.literal("save-edit"),
|
|
9612
|
+
project: z9.string().min(1).optional(),
|
|
9613
|
+
assetId: z9.string().min(1),
|
|
9614
|
+
styleId: z9.string().min(1),
|
|
9615
|
+
expectedSha256: z9.string().regex(/^[0-9a-f]{64}$/),
|
|
9616
|
+
png: Base64.min(1),
|
|
9617
|
+
/** The editor's layered project file, kept beside the edit. */
|
|
9618
|
+
pxo: Base64.optional(),
|
|
9619
|
+
/** Editor identity from the bridge's ready message. */
|
|
9620
|
+
editor: z9.string().min(1).max(200),
|
|
9621
|
+
protocol: z9.number().int().positive()
|
|
9523
9622
|
}).strict()
|
|
9524
9623
|
]);
|
|
9525
9624
|
function createGalleryEditHandler(opts) {
|
|
@@ -9540,6 +9639,15 @@ function createGalleryEditHandler(opts) {
|
|
|
9540
9639
|
const command = (opts.openEditor ?? openInEditor)(started.editPath);
|
|
9541
9640
|
log(` opened with: ${command}`);
|
|
9542
9641
|
}
|
|
9642
|
+
} else if (request.action === "save-edit") {
|
|
9643
|
+
const saved = await saveHandEdit(ctx.loaded, ctx.lock, spec, {
|
|
9644
|
+
png: Buffer.from(request.png, "base64"),
|
|
9645
|
+
...request.pxo ? { project: Buffer.from(request.pxo, "base64") } : {},
|
|
9646
|
+
editor: request.editor,
|
|
9647
|
+
protocol: request.protocol,
|
|
9648
|
+
expectedSha256: request.expectedSha256
|
|
9649
|
+
});
|
|
9650
|
+
log(` hand edit saved from ${request.editor}: ${saved.source}${saved.declared ? " (declared in the manifest)" : ""}${saved.projectPath ? " + project file" : ""}`);
|
|
9543
9651
|
} else {
|
|
9544
9652
|
const result2 = await detachHandEdit(ctx.loaded, spec, { expectedSha256: request.expectedSha256 });
|
|
9545
9653
|
if (result2.changed) log(` hand edit detached: ${request.styleId}/${request.assetId} (file kept)`);
|
|
@@ -9570,6 +9678,7 @@ function createGalleryEditHandler(opts) {
|
|
|
9570
9678
|
}
|
|
9571
9679
|
|
|
9572
9680
|
// src/gallery/snapshot.ts
|
|
9681
|
+
var PROJECT_FILE_TYPE = "application/octet-stream";
|
|
9573
9682
|
function galleryMediaId(absolutePath) {
|
|
9574
9683
|
return createHash4("sha256").update(path24.resolve(absolutePath)).digest("hex").slice(0, 24);
|
|
9575
9684
|
}
|
|
@@ -9689,7 +9798,7 @@ async function buildGallerySnapshot(opts) {
|
|
|
9689
9798
|
const media = /* @__PURE__ */ new Map();
|
|
9690
9799
|
const plan = await buildPlan(specs, lock);
|
|
9691
9800
|
const items = [];
|
|
9692
|
-
const manifestText = await
|
|
9801
|
+
const manifestText = await readFile19(loaded.path, "utf8");
|
|
9693
9802
|
const manifestSha256 = sha256(manifestText);
|
|
9694
9803
|
let rawStyles = {};
|
|
9695
9804
|
try {
|
|
@@ -9704,6 +9813,7 @@ async function buildGallerySnapshot(opts) {
|
|
|
9704
9813
|
let outputs;
|
|
9705
9814
|
let edit = null;
|
|
9706
9815
|
let editStatus = null;
|
|
9816
|
+
let editMeta = null;
|
|
9707
9817
|
if (entry && entry.outputs.length) {
|
|
9708
9818
|
outputs = await Promise.all(
|
|
9709
9819
|
entry.outputs.map(
|
|
@@ -9715,7 +9825,27 @@ async function buildGallerySnapshot(opts) {
|
|
|
9715
9825
|
const editSha = existsSync19(editPath) ? await sha256File(editPath) : null;
|
|
9716
9826
|
edit = await describeOutput(media, root, editPath, { sha256: editSha });
|
|
9717
9827
|
const generated = outputs[0];
|
|
9718
|
-
|
|
9828
|
+
const companion = await readHandEditCompanion(editPath);
|
|
9829
|
+
if (companion) {
|
|
9830
|
+
const projectPath = handEditProjectPath(editPath);
|
|
9831
|
+
const project = companion.project && existsSync19(projectPath) ? await fileInfo(projectPath) : null;
|
|
9832
|
+
let projectUrl = null;
|
|
9833
|
+
if (project) {
|
|
9834
|
+
const id = galleryMediaId(projectPath);
|
|
9835
|
+
media.set(id, { path: projectPath, contentType: PROJECT_FILE_TYPE });
|
|
9836
|
+
projectUrl = `${galleryMediaRoute(id)}?v=${project.bytes}-${Date.parse(project.modifiedAt).toString(36)}`;
|
|
9837
|
+
}
|
|
9838
|
+
editMeta = {
|
|
9839
|
+
editor: companion.editor,
|
|
9840
|
+
savedAt: companion.savedAt,
|
|
9841
|
+
basedOn: companion.basedOn,
|
|
9842
|
+
changedSince: editSha !== null && editSha !== companion.sha256,
|
|
9843
|
+
project: project ? portableOutputPath(projectPath, root) : null,
|
|
9844
|
+
projectUrl
|
|
9845
|
+
};
|
|
9846
|
+
}
|
|
9847
|
+
const regenerated = companion ? generated?.sha256 !== void 0 && generated.sha256 !== null && companion.basedOn !== generated.sha256 : Boolean(generated?.modifiedAt && edit.modifiedAt && generated.modifiedAt > edit.modifiedAt && generated.exists);
|
|
9848
|
+
editStatus = !edit.exists ? "missing" : generated && editSha === generated.sha256 ? "same" : regenerated ? "regenerated-since" : "edited";
|
|
9719
9849
|
}
|
|
9720
9850
|
} else if (spec.source) {
|
|
9721
9851
|
outputs = [await describeOutput(media, root, path24.resolve(root, spec.source))];
|
|
@@ -9765,6 +9895,7 @@ async function buildGallerySnapshot(opts) {
|
|
|
9765
9895
|
source: spec.source ?? null,
|
|
9766
9896
|
edit,
|
|
9767
9897
|
editStatus,
|
|
9898
|
+
editMeta,
|
|
9768
9899
|
upstreamUrl: entry?.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
|
|
9769
9900
|
refreshable: Boolean(entry && entry.status === "downloaded" && entry.outputs.length && (entry.sourceUrls?.length || entry.sourceUrl)),
|
|
9770
9901
|
tags: spec.tags,
|
|
@@ -9821,6 +9952,7 @@ async function buildGallerySnapshot(opts) {
|
|
|
9821
9952
|
source: null,
|
|
9822
9953
|
edit: null,
|
|
9823
9954
|
editStatus: null,
|
|
9955
|
+
editMeta: null,
|
|
9824
9956
|
upstreamUrl: entry.provider === "pixellab" ? pixelLabObjectUrl(entry.generator, entry.objectId) : null,
|
|
9825
9957
|
refreshable: false,
|
|
9826
9958
|
tags: [],
|
|
@@ -9962,7 +10094,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
|
|
|
9962
10094
|
root: loaded.root,
|
|
9963
10095
|
provider: loaded.manifest.provider,
|
|
9964
10096
|
account: project.account ?? null,
|
|
9965
|
-
manifestSha256: manifestSha256 ?? sha256(await
|
|
10097
|
+
manifestSha256: manifestSha256 ?? sha256(await readFile19(manifestPath, "utf8")),
|
|
9966
10098
|
entries: Object.keys(lock.entries).length,
|
|
9967
10099
|
items: projectItems.length,
|
|
9968
10100
|
spendByUnit: spendByUnit(lock),
|
|
@@ -10022,6 +10154,7 @@ function renderGallery(snapshot, opts = {}) {
|
|
|
10022
10154
|
const session = JSON.stringify(opts.session ?? null);
|
|
10023
10155
|
const editable = JSON.stringify(Boolean(opts.editable && opts.session));
|
|
10024
10156
|
const generation = JSON.stringify(Boolean(opts.generation && opts.session));
|
|
10157
|
+
const editor = JSON.stringify(Boolean(opts.editor && opts.session));
|
|
10025
10158
|
const title = `pixelkiln \u2014 ${snapshot.project?.name ?? "workspace"}`;
|
|
10026
10159
|
return `<!doctype html>
|
|
10027
10160
|
<html lang="en">
|
|
@@ -10227,6 +10360,15 @@ function renderGallery(snapshot, opts = {}) {
|
|
|
10227
10360
|
.job .acts button { padding:3px 9px; font-size:12px; }
|
|
10228
10361
|
.job pre { grid-column:1 / -1; margin:0; padding:8px 10px; border-top:1px solid var(--line);
|
|
10229
10362
|
font:11.5px/1.45 var(--mono); color:var(--dim); max-height:220px; overflow:auto; white-space:pre-wrap; }
|
|
10363
|
+
#tools { width:min(100%,var(--content)); margin:0 auto; padding:0 22px 12px; }
|
|
10364
|
+
#tools:empty { display:none; }
|
|
10365
|
+
.tool { border:1px solid var(--line); background:var(--panel-deep); padding:8px 12px; display:grid;
|
|
10366
|
+
grid-template-columns:auto 1fr auto; gap:6px 14px; align-items:center; font-size:12.5px; }
|
|
10367
|
+
.tool .ph { display:inline-flex; align-items:center; gap:6px; font:650 12px/1 var(--mono); white-space:nowrap; }
|
|
10368
|
+
.tool .last { color:var(--dim); min-width:0; }
|
|
10369
|
+
.tool .acts { display:flex; gap:6px; }
|
|
10370
|
+
.tool .acts button { padding:3px 9px; font-size:12px; }
|
|
10371
|
+
.tool progress { grid-column:1 / -1; width:100%; height:6px; accent-color:var(--accent); }
|
|
10230
10372
|
.budget { color:var(--dim); }
|
|
10231
10373
|
.budget b { color:var(--text); }
|
|
10232
10374
|
.dialog { position:fixed; inset:0; z-index:30; display:grid; place-items:center; background:rgba(0,0,0,.55); }
|
|
@@ -10247,6 +10389,16 @@ function renderGallery(snapshot, opts = {}) {
|
|
|
10247
10389
|
border-left:1px solid var(--line-strong); display:grid; grid-template-rows:auto 1fr; min-width:0; }
|
|
10248
10390
|
.sheet.review-host { width:min(1180px, 94vw); }
|
|
10249
10391
|
.sheet.compare-host { width:min(1500px, 96vw); }
|
|
10392
|
+
.sheet.editor-host { width:min(1400px, 96vw); grid-template-rows:auto 1fr; }
|
|
10393
|
+
.sheet.editor-host .rbar b { font:650 12.5px/1.4 var(--mono); color:var(--text); }
|
|
10394
|
+
.sheet.editor-host .rbar .acts { margin-left:auto; display:flex; gap:8px; align-items:center; }
|
|
10395
|
+
.sheet.editor-host .rbar .st { display:inline-flex; align-items:center; gap:6px; font:650 11.5px/1 var(--mono); }
|
|
10396
|
+
.sheet.editor-host .rbar .msg { color:var(--bad); font-size:12.5px; }
|
|
10397
|
+
.sheet.editor-host .stage { position:relative; min-height:0; }
|
|
10398
|
+
.sheet.editor-host .stage iframe { position:absolute; inset:0; }
|
|
10399
|
+
.sheet.editor-host .loading { position:absolute; inset:0; display:grid; place-items:center; background:var(--bg);
|
|
10400
|
+
color:var(--dim); text-align:center; padding:24px; }
|
|
10401
|
+
.sheet.editor-host .loading b { display:block; color:var(--text); margin-bottom:6px; }
|
|
10250
10402
|
.sheet .rbar { display:flex; align-items:center; gap:12px; padding:10px 16px; border-bottom:1px solid var(--line-strong);
|
|
10251
10403
|
background:var(--panel); font-size:13px; flex-wrap:wrap; }
|
|
10252
10404
|
.sheet .rbar span { color:var(--dim); }
|
|
@@ -10285,7 +10437,7 @@ function renderGallery(snapshot, opts = {}) {
|
|
|
10285
10437
|
.tray .pick img { width:22px; height:22px; object-fit:contain; image-rendering:pixelated; }
|
|
10286
10438
|
.tray .pick button { padding:0 5px; border-color:transparent; color:var(--dim); font-size:12px; }
|
|
10287
10439
|
.tray > button { padding:5px 11px; font-size:12.5px; }
|
|
10288
|
-
@media (max-width: 900px) { .sheet.review-host, .sheet.compare-host { width:100vw; } }
|
|
10440
|
+
@media (max-width: 900px) { .sheet.review-host, .sheet.compare-host, .sheet.editor-host { width:100vw; } }
|
|
10289
10441
|
.drawer .gen { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
|
10290
10442
|
.pair { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:8px 0 10px; }
|
|
10291
10443
|
.pair figure { margin:0; border:1px solid var(--line); background:var(--panel-deep); background-image:var(--checker);
|
|
@@ -10333,6 +10485,7 @@ function renderGallery(snapshot, opts = {}) {
|
|
|
10333
10485
|
<div class="totals" id="totals"></div>
|
|
10334
10486
|
<div class="chips" id="chips"></div>
|
|
10335
10487
|
<div id="jobs"></div>
|
|
10488
|
+
<div id="tools"></div>
|
|
10336
10489
|
</header>
|
|
10337
10490
|
<main id="root"></main>
|
|
10338
10491
|
<footer>
|
|
@@ -10351,6 +10504,7 @@ const INITIAL = ${data};
|
|
|
10351
10504
|
const SESSION = ${session};
|
|
10352
10505
|
const EDITABLE = ${editable};
|
|
10353
10506
|
const GENERATION = ${generation};
|
|
10507
|
+
const EDITOR = ${editor};
|
|
10354
10508
|
let snap = INITIAL;
|
|
10355
10509
|
const STATE_TONE = {
|
|
10356
10510
|
ok: 'ok', stale: 'warn', orphaned: 'warn', untracked: 'warn', blocked: 'warn',
|
|
@@ -10518,6 +10672,92 @@ function renderJobs() {
|
|
|
10518
10672
|
host.append(row);
|
|
10519
10673
|
}
|
|
10520
10674
|
}
|
|
10675
|
+
// ---- the in-browser editor (only when the server serves one) -------------
|
|
10676
|
+
// The build is a 46 MB web export fetched once into a user-level cache and
|
|
10677
|
+
// verified against hashes this PixelKiln release carries; the page only
|
|
10678
|
+
// reports and asks, the server downloads. Nothing is fetched without a click.
|
|
10679
|
+
|
|
10680
|
+
let ED = null;
|
|
10681
|
+
let edTimer = null;
|
|
10682
|
+
const fmtMb = (bytes) => (bytes / 1e6).toFixed(1) + ' MB';
|
|
10683
|
+
async function pollEditor() {
|
|
10684
|
+
if (!EDITOR) return;
|
|
10685
|
+
try {
|
|
10686
|
+
const res = await fetch('/api/editor', { cache: 'no-store' });
|
|
10687
|
+
if (!res.ok) throw new Error(await res.text());
|
|
10688
|
+
ED = await res.json();
|
|
10689
|
+
} catch (err) {
|
|
10690
|
+
ED = { error: 'status unavailable: ' + err.message, installed: false, installing: null, missing: [], totalBytes: 0 };
|
|
10691
|
+
}
|
|
10692
|
+
renderTools();
|
|
10693
|
+
clearTimeout(edTimer);
|
|
10694
|
+
if (ED.installing) edTimer = setTimeout(pollEditor, 1000);
|
|
10695
|
+
else if (ui.open) renderDrawer();
|
|
10696
|
+
}
|
|
10697
|
+
async function installEditor() {
|
|
10698
|
+
try {
|
|
10699
|
+
const res = await fetch('/api/editor/install', {
|
|
10700
|
+
method: 'POST',
|
|
10701
|
+
headers: { 'Content-Type': 'application/json', 'X-Pixelkiln-Session': SESSION },
|
|
10702
|
+
body: '{}',
|
|
10703
|
+
});
|
|
10704
|
+
if (!res.ok) throw new Error(await res.text());
|
|
10705
|
+
ED = await res.json();
|
|
10706
|
+
} catch (err) {
|
|
10707
|
+
ED = Object.assign({}, ED, { error: err.message });
|
|
10708
|
+
}
|
|
10709
|
+
renderTools();
|
|
10710
|
+
clearTimeout(edTimer);
|
|
10711
|
+
edTimer = setTimeout(pollEditor, 500);
|
|
10712
|
+
}
|
|
10713
|
+
function renderTools() {
|
|
10714
|
+
const host = $('tools');
|
|
10715
|
+
host.textContent = '';
|
|
10716
|
+
if (!EDITOR || !ED) return;
|
|
10717
|
+
const row = el('div', 'tool');
|
|
10718
|
+
const ph = el('span', 'ph');
|
|
10719
|
+
const last = el('span', 'last');
|
|
10720
|
+
const acts = el('div', 'acts');
|
|
10721
|
+
const name = 'Browser editor' + (ED.pixelorama ? ' (Pixelorama ' + ED.pixelorama + ')' : '');
|
|
10722
|
+
if (ED.installing) {
|
|
10723
|
+
const p = ED.installing;
|
|
10724
|
+
ph.append(el('i', 'dot cool'), document.createTextNode('installing'));
|
|
10725
|
+
last.textContent = name + ': fetching ' + (p.file || 'the build') + ' \u2014 ' + fmtMb(p.fetchedBytes) + ' of ' + fmtMb(p.totalBytes);
|
|
10726
|
+
row.append(ph, last, acts);
|
|
10727
|
+
const bar = el('progress');
|
|
10728
|
+
if (p.totalBytes) { bar.max = p.totalBytes; bar.value = p.fetchedBytes; }
|
|
10729
|
+
row.append(bar);
|
|
10730
|
+
} else if (ED.installed) {
|
|
10731
|
+
ph.append(el('i', 'dot ok'), document.createTextNode('ready'));
|
|
10732
|
+
last.textContent = name + ' is installed and verified; a record\u2019s Hand edit section opens sprites in it.';
|
|
10733
|
+
last.title = ED.dir || '';
|
|
10734
|
+
const b = el('button', null, 'Open editor');
|
|
10735
|
+
b.type = 'button'; b.onclick = () => window.open(ED.url, '_blank', 'noopener');
|
|
10736
|
+
acts.append(b);
|
|
10737
|
+
row.append(ph, last, acts);
|
|
10738
|
+
} else if (ED.error) {
|
|
10739
|
+
ph.append(el('i', 'dot bad'), document.createTextNode('failed'));
|
|
10740
|
+
last.textContent = name + ': ' + ED.error;
|
|
10741
|
+
const b = el('button', 'primary', 'Retry install');
|
|
10742
|
+
b.type = 'button'; b.onclick = installEditor;
|
|
10743
|
+
acts.append(b);
|
|
10744
|
+
row.append(ph, last, acts);
|
|
10745
|
+
} else if (!ED.release) {
|
|
10746
|
+
ph.append(el('i', 'dot dim'), document.createTextNode('unavailable'));
|
|
10747
|
+
last.textContent = name + ': this PixelKiln version pins no published build.';
|
|
10748
|
+
row.append(ph, last, acts);
|
|
10749
|
+
} else {
|
|
10750
|
+
ph.append(el('i', 'dot dim'), document.createTextNode('not installed'));
|
|
10751
|
+
last.textContent = name + ': ' + fmtMb(ED.totalBytes - (ED.installedBytes || 0)) + ' fetched once from the PixelKiln release ' +
|
|
10752
|
+
ED.release + ', verified against the hashes this version carries, and kept outside the project.';
|
|
10753
|
+
const b = el('button', 'primary', 'Install editor');
|
|
10754
|
+
b.type = 'button'; b.onclick = installEditor;
|
|
10755
|
+
acts.append(b);
|
|
10756
|
+
row.append(ph, last, acts);
|
|
10757
|
+
}
|
|
10758
|
+
host.append(row);
|
|
10759
|
+
}
|
|
10760
|
+
|
|
10521
10761
|
function budgetLine() {
|
|
10522
10762
|
const providers = new Set([...Object.keys(GEN.budget.byProvider), ...Object.keys(GEN.spent)]);
|
|
10523
10763
|
const parts = [];
|
|
@@ -10624,8 +10864,183 @@ function openReview(jobId) {
|
|
|
10624
10864
|
host.append(scrim, panel);
|
|
10625
10865
|
}
|
|
10626
10866
|
window.addEventListener('message', (e) => {
|
|
10627
|
-
if (e.origin !== location.origin || !e.data || e.data.type !== '
|
|
10628
|
-
|
|
10867
|
+
if (e.origin !== location.origin || !e.data || typeof e.data.type !== 'string') return;
|
|
10868
|
+
if (e.data.type === 'pixelkiln:review-applied') {
|
|
10869
|
+
setTimeout(() => { $('dialog-host').textContent = ''; pollJobs(); refresh(); }, 600);
|
|
10870
|
+
return;
|
|
10871
|
+
}
|
|
10872
|
+
if (SHEET && e.source === SHEET.frame.contentWindow) onEditorMessage(e.data);
|
|
10873
|
+
});
|
|
10874
|
+
|
|
10875
|
+
// ---- the in-browser editor sheet -------------------------------------------
|
|
10876
|
+
// The page is the protocol host: it hands the editor the PNG and palette,
|
|
10877
|
+
// asks it for the image back, and does the authenticated write itself. The
|
|
10878
|
+
// iframe never sees the session token, and the server validates, sizes, and
|
|
10879
|
+
// declares the file the same way pixelkiln edit does.
|
|
10880
|
+
|
|
10881
|
+
let SHEET = null;
|
|
10882
|
+
const editorSource = (item) => item.edit && item.edit.exists ? item.edit
|
|
10883
|
+
: item.outputs.length === 1 && item.outputs[0].exists && item.outputs[0].mediaType === 'image/png' ? item.outputs[0] : null;
|
|
10884
|
+
const canEditInBrowser = (item) => EDITOR && EDITABLE && item.declared && item.asset && projectOf(item)?.manifestSha256 && editorSource(item) &&
|
|
10885
|
+
!(item.outputs.length > 1);
|
|
10886
|
+
const toBase64 = (buf) => new Promise((resolve, reject) => {
|
|
10887
|
+
const r = new FileReader();
|
|
10888
|
+
r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
|
|
10889
|
+
r.onerror = () => reject(r.error);
|
|
10890
|
+
r.readAsDataURL(new Blob([buf]));
|
|
10891
|
+
});
|
|
10892
|
+
function sheetStatus(tone, text) {
|
|
10893
|
+
if (!SHEET) return;
|
|
10894
|
+
SHEET.status.textContent = '';
|
|
10895
|
+
SHEET.status.append(el('i', 'dot ' + tone), document.createTextNode(text));
|
|
10896
|
+
}
|
|
10897
|
+
function sheetButtons() {
|
|
10898
|
+
if (!SHEET) return;
|
|
10899
|
+
const busy = !SHEET.opened || SHEET.pending !== null;
|
|
10900
|
+
SHEET.save.disabled = busy || !SHEET.dirty;
|
|
10901
|
+
SHEET.saveClose.disabled = busy;
|
|
10902
|
+
}
|
|
10903
|
+
function openEditorSheet(item) {
|
|
10904
|
+
if (SHEET) return;
|
|
10905
|
+
const src = editorSource(item);
|
|
10906
|
+
const host = $('dialog-host');
|
|
10907
|
+
host.textContent = '';
|
|
10908
|
+
const scrim = el('div', 'sheet-scrim');
|
|
10909
|
+
scrim.onclick = () => closeEditorSheet();
|
|
10910
|
+
const panel = el('div', 'sheet editor-host');
|
|
10911
|
+
const bar = el('div', 'rbar');
|
|
10912
|
+
const close = el('button', null, 'Close'); close.type = 'button';
|
|
10913
|
+
close.onclick = () => closeEditorSheet();
|
|
10914
|
+
const title = el('b', null, (item.project ? item.project + ':' : '') + item.styleId + '/' + item.assetId + ' \xB7 ' + item.width + '\xD7' + item.height);
|
|
10915
|
+
const status = el('span', 'st');
|
|
10916
|
+
const acts = el('div', 'acts');
|
|
10917
|
+
const msg = el('span', 'msg');
|
|
10918
|
+
const save = el('button', 'primary', 'Save to project'); save.type = 'button';
|
|
10919
|
+
save.onclick = () => requestEditorSave(false);
|
|
10920
|
+
const saveClose = el('button', null, 'Save & close'); saveClose.type = 'button';
|
|
10921
|
+
saveClose.onclick = () => requestEditorSave(true);
|
|
10922
|
+
acts.append(msg, save, saveClose);
|
|
10923
|
+
bar.append(close, title, status, acts);
|
|
10924
|
+
const stage = el('div', 'stage');
|
|
10925
|
+
const frame = el('iframe');
|
|
10926
|
+
frame.title = 'Pixelorama';
|
|
10927
|
+
frame.src = ED.url;
|
|
10928
|
+
const loading = el('div', 'loading');
|
|
10929
|
+
const loadText = el('div');
|
|
10930
|
+
loadText.append(el('b', null, 'Loading the editor'), document.createTextNode('The first open compiles a 40 MB build; later opens come from the browser cache.'));
|
|
10931
|
+
loading.append(loadText);
|
|
10932
|
+
stage.append(frame, loading);
|
|
10933
|
+
panel.append(bar, stage);
|
|
10934
|
+
host.append(scrim, panel);
|
|
10935
|
+
SHEET = { item, frame, loading, status, msg, save, saveClose, dirty: false, opened: false, ready: null, pending: null, requests: 0, source: src, sentProject: false };
|
|
10936
|
+
sheetStatus('cool', 'loading');
|
|
10937
|
+
sheetButtons();
|
|
10938
|
+
close.focus({ preventScroll: true });
|
|
10939
|
+
}
|
|
10940
|
+
// The edit file is what the editor gets; when a browser save kept the layered
|
|
10941
|
+
// project beside it, that goes along too and the editor restores the layers,
|
|
10942
|
+
// falling back to the flattened PNG if the file cannot be read.
|
|
10943
|
+
async function sendOpen() {
|
|
10944
|
+
const { item, source } = SHEET;
|
|
10945
|
+
const style = snap.styles.find((s) => s.id === item.styleId && s.project === item.project);
|
|
10946
|
+
const projectUrl = source === item.edit && item.editMeta && item.editMeta.projectUrl && SHEET.ready.version >= 2 ? item.editMeta.projectUrl : null;
|
|
10947
|
+
let png, pxo = null;
|
|
10948
|
+
try {
|
|
10949
|
+
const res = await fetch(source.url, { cache: 'no-store' });
|
|
10950
|
+
if (!res.ok) throw new Error(await res.text());
|
|
10951
|
+
png = await res.arrayBuffer();
|
|
10952
|
+
if (projectUrl) {
|
|
10953
|
+
const p = await fetch(projectUrl, { cache: 'no-store' });
|
|
10954
|
+
if (p.ok) pxo = await p.arrayBuffer();
|
|
10955
|
+
}
|
|
10956
|
+
} catch (err) {
|
|
10957
|
+
if (SHEET) { SHEET.msg.textContent = 'Could not load the image: ' + err.message; sheetStatus('bad', 'failed'); }
|
|
10958
|
+
return;
|
|
10959
|
+
}
|
|
10960
|
+
if (!SHEET) return;
|
|
10961
|
+
const request = 'open-' + (++SHEET.requests);
|
|
10962
|
+
SHEET.sentProject = !!pxo;
|
|
10963
|
+
const message = {
|
|
10964
|
+
type: 'pixelkiln:open', request,
|
|
10965
|
+
asset: { key: item.styleId + '/' + item.assetId, id: item.id, name: item.assetId, width: item.width, height: item.height },
|
|
10966
|
+
png, palette: style ? style.palette : [],
|
|
10967
|
+
};
|
|
10968
|
+
const transfer = [png];
|
|
10969
|
+
if (pxo) { message.pxo = pxo; transfer.push(pxo); }
|
|
10970
|
+
SHEET.frame.contentWindow.postMessage(message, location.origin, transfer);
|
|
10971
|
+
sheetStatus('cool', 'opening');
|
|
10972
|
+
}
|
|
10973
|
+
function requestEditorSave(close) {
|
|
10974
|
+
if (!SHEET || !SHEET.opened || SHEET.pending) return;
|
|
10975
|
+
const request = 'save-' + (++SHEET.requests);
|
|
10976
|
+
SHEET.pending = { request, close };
|
|
10977
|
+
SHEET.msg.textContent = '';
|
|
10978
|
+
sheetStatus('cool', 'saving');
|
|
10979
|
+
sheetButtons();
|
|
10980
|
+
SHEET.frame.contentWindow.postMessage({ type: 'pixelkiln:request-save', request }, location.origin);
|
|
10981
|
+
}
|
|
10982
|
+
async function onEditorMessage(m) {
|
|
10983
|
+
switch (m.type) {
|
|
10984
|
+
case 'pixelkiln:ready':
|
|
10985
|
+
SHEET.ready = m;
|
|
10986
|
+
if (m.version !== ED.protocol) {
|
|
10987
|
+
SHEET.msg.textContent = 'The editor speaks protocol ' + m.version + '; this gallery expects ' + ED.protocol + '. Reinstall it with pixelkiln tools install editor.';
|
|
10988
|
+
sheetStatus('bad', 'mismatch');
|
|
10989
|
+
return;
|
|
10990
|
+
}
|
|
10991
|
+
sendOpen();
|
|
10992
|
+
break;
|
|
10993
|
+
case 'pixelkiln:opened':
|
|
10994
|
+
SHEET.opened = true;
|
|
10995
|
+
SHEET.loading.remove();
|
|
10996
|
+
if (m.source === 'pxo') sheetStatus('ok', 'editing the edit file with its ' + (m.layers === 1 ? 'layer' : m.layers + ' layers') + ' restored');
|
|
10997
|
+
else if (SHEET.sentProject) sheetStatus('warn', 'editing the flattened edit file; its layer file could not be opened');
|
|
10998
|
+
else sheetStatus('ok', SHEET.item.edit && SHEET.item.edit.exists ? 'editing the edit file' : 'editing a copy of the generated art');
|
|
10999
|
+
sheetButtons();
|
|
11000
|
+
break;
|
|
11001
|
+
case 'pixelkiln:dirty':
|
|
11002
|
+
SHEET.dirty = !!m.dirty;
|
|
11003
|
+
if (SHEET.opened && !SHEET.pending) sheetStatus(SHEET.dirty ? 'warn' : 'ok', SHEET.dirty ? 'unsaved changes' : 'saved');
|
|
11004
|
+
sheetButtons();
|
|
11005
|
+
break;
|
|
11006
|
+
case 'pixelkiln:save': {
|
|
11007
|
+
const pending = SHEET.pending;
|
|
11008
|
+
if (!pending || pending.request !== m.request) return;
|
|
11009
|
+
try {
|
|
11010
|
+
const body = { png: await toBase64(m.png), editor: SHEET.ready.editor, protocol: SHEET.ready.version };
|
|
11011
|
+
if (m.pxo && m.pxo.byteLength) body.pxo = await toBase64(m.pxo);
|
|
11012
|
+
await postHandEdit(SHEET.item, 'save-edit', body);
|
|
11013
|
+
const saved = snap.items.find((i) => i.id === SHEET.item.id);
|
|
11014
|
+
if (saved) SHEET.item = saved;
|
|
11015
|
+
SHEET.dirty = false;
|
|
11016
|
+
SHEET.pending = null;
|
|
11017
|
+
ui.notice = { id: SHEET.item.id, text: 'Saved ' + (saved && saved.edit ? saved.edit.path : 'the edit') + '. mount and pack place it in place of the generated art.' };
|
|
11018
|
+
render();
|
|
11019
|
+
if (pending.close) { closeEditorSheet(true); return; }
|
|
11020
|
+
sheetStatus('ok', 'saved');
|
|
11021
|
+
} catch (err) {
|
|
11022
|
+
SHEET.pending = null;
|
|
11023
|
+
SHEET.msg.textContent = err.message + (err.status === 409 ? ' \u2014 press Refresh, then save again.' : '');
|
|
11024
|
+
sheetStatus('bad', 'not saved');
|
|
11025
|
+
}
|
|
11026
|
+
sheetButtons();
|
|
11027
|
+
break;
|
|
11028
|
+
}
|
|
11029
|
+
case 'pixelkiln:error':
|
|
11030
|
+
SHEET.msg.textContent = m.message || 'The editor reported an error.';
|
|
11031
|
+
if (SHEET.pending) { SHEET.pending = null; sheetStatus('bad', 'not saved'); sheetButtons(); }
|
|
11032
|
+
break;
|
|
11033
|
+
}
|
|
11034
|
+
}
|
|
11035
|
+
function closeEditorSheet(force) {
|
|
11036
|
+
if (!SHEET) return;
|
|
11037
|
+
if (SHEET.dirty && !force && !confirm('Discard unsaved changes in the editor?')) return;
|
|
11038
|
+
SHEET = null;
|
|
11039
|
+
$('dialog-host').textContent = '';
|
|
11040
|
+
refresh();
|
|
11041
|
+
}
|
|
11042
|
+
window.addEventListener('beforeunload', (e) => {
|
|
11043
|
+
if (SHEET && SHEET.dirty) { e.preventDefault(); e.returnValue = ''; }
|
|
10629
11044
|
});
|
|
10630
11045
|
|
|
10631
11046
|
function visibleItems() {
|
|
@@ -11201,6 +11616,11 @@ function handEditSection(item) {
|
|
|
11201
11616
|
row(dl, 'file', item.edit.path, { mono: true, copy: item.edit.absolutePath });
|
|
11202
11617
|
if (item.edit.sha256) row(dl, 'sha256', item.edit.sha256.slice(0, 16) + '\u2026', { mono: true, copy: item.edit.sha256 });
|
|
11203
11618
|
row(dl, 'saved', fmtWhen(item.edit.modifiedAt));
|
|
11619
|
+
if (item.editMeta) {
|
|
11620
|
+
row(dl, 'editor', item.editMeta.editor + ', ' + fmtWhen(item.editMeta.savedAt) + (item.editMeta.changedSince ? ' \u2014 the file changed since' : ''));
|
|
11621
|
+
if (item.editMeta.basedOn) row(dl, 'based on', item.editMeta.basedOn.slice(0, 16) + '\u2026', { mono: true, copy: item.editMeta.basedOn });
|
|
11622
|
+
if (item.editMeta.project) row(dl, 'layers', item.editMeta.project, { mono: true });
|
|
11623
|
+
}
|
|
11204
11624
|
s.append(dl);
|
|
11205
11625
|
} else {
|
|
11206
11626
|
s.append(el('div', 'state-dim', 'Touch it up in your own editor. The generated file stays untouched; the edit is a sibling file the manifest points at, and mount and pack place that instead.'));
|
|
@@ -11209,7 +11629,7 @@ function handEditSection(item) {
|
|
|
11209
11629
|
const acts = el('div', 'hand-actions');
|
|
11210
11630
|
const msg = el('span', 'msg');
|
|
11211
11631
|
const go = async (label, action, extra, done) => {
|
|
11212
|
-
const b = el('button', label === 'Detach edit' ? null : 'primary', label); b.type = 'button';
|
|
11632
|
+
const b = el('button', label === 'Detach edit' || (EDITOR && ED && ED.installed) ? null : 'primary', label); b.type = 'button';
|
|
11213
11633
|
b.onclick = async () => {
|
|
11214
11634
|
b.disabled = true; msg.className = 'msg'; msg.textContent = '\u2026';
|
|
11215
11635
|
try { await postHandEdit(item, action, extra); ui.notice = { id: item.id, text: done }; render(); }
|
|
@@ -11217,8 +11637,20 @@ function handEditSection(item) {
|
|
|
11217
11637
|
};
|
|
11218
11638
|
acts.append(b);
|
|
11219
11639
|
};
|
|
11640
|
+
if (EDITOR && ED && (item.edit || canStart) && editorSource(item)) {
|
|
11641
|
+
if (ED.installed) {
|
|
11642
|
+
const b = el('button', 'primary', 'Edit in browser'); b.type = 'button';
|
|
11643
|
+
b.title = 'Open in Pixelorama here; Save writes the edit file and declares it';
|
|
11644
|
+
b.onclick = () => openEditorSheet(item);
|
|
11645
|
+
acts.append(b);
|
|
11646
|
+
} else if (ED.release && !ED.installing) {
|
|
11647
|
+
const b = el('button', null, 'Install editor to edit in browser'); b.type = 'button';
|
|
11648
|
+
b.onclick = () => { installEditor(); window.scrollTo({ top: 0 }); };
|
|
11649
|
+
acts.append(b);
|
|
11650
|
+
}
|
|
11651
|
+
}
|
|
11220
11652
|
if (item.edit) {
|
|
11221
|
-
go('Open in editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
|
|
11653
|
+
go('Open in desktop editor', 'start-edit', { open: true }, 'Opened ' + item.edit.path + ' in your editor. Save there, then Refresh.');
|
|
11222
11654
|
go('Detach edit', 'detach-edit', {}, 'Detached. The file is still at ' + item.edit.path + '; the generated art is placed again.');
|
|
11223
11655
|
} else if (canStart) {
|
|
11224
11656
|
go('Edit by hand', 'start-edit', { open: true }, 'Created the edit file and opened it in your editor. Save there, then Refresh to see it here.');
|
|
@@ -11945,6 +12377,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
11945
12377
|
if (e.key === '/' && !typing) { e.preventDefault(); $('q').focus(); return; }
|
|
11946
12378
|
if (e.key === 'Escape') {
|
|
11947
12379
|
if (typing && document.activeElement.id === 'q') { document.activeElement.blur(); return; }
|
|
12380
|
+
if (SHEET) { e.preventDefault(); closeEditorSheet(); return; }
|
|
11948
12381
|
if ($('dialog-host').childNodes.length) { e.preventDefault(); $('dialog-host').textContent = ''; return; }
|
|
11949
12382
|
if (ui.open) { e.preventDefault(); closeItem(); }
|
|
11950
12383
|
return;
|
|
@@ -11966,6 +12399,7 @@ ui.compare = ui.compare.filter((id) => snap.items.some((i) => i.id === id));
|
|
|
11966
12399
|
render();
|
|
11967
12400
|
if (ui.open) document.querySelector('.card.active')?.scrollIntoView({ block: 'center' });
|
|
11968
12401
|
if (GENERATION) pollJobs();
|
|
12402
|
+
if (EDITOR) pollEditor();
|
|
11969
12403
|
if (ui.compare.length >= 2 && !ui.open) openCompare();
|
|
11970
12404
|
</script>
|
|
11971
12405
|
</body>
|
|
@@ -11977,8 +12411,17 @@ var escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<
|
|
|
11977
12411
|
import { spawn as spawn3 } from "child_process";
|
|
11978
12412
|
import { randomBytes } from "crypto";
|
|
11979
12413
|
import { createServer as createServer2 } from "http";
|
|
11980
|
-
import { readFile as
|
|
11981
|
-
var MAX_EDIT_BYTES =
|
|
12414
|
+
import { readFile as readFile20 } from "fs/promises";
|
|
12415
|
+
var MAX_EDIT_BYTES = 48 * 1024 * 1024;
|
|
12416
|
+
var EDITOR_CSP = [
|
|
12417
|
+
"default-src 'self'",
|
|
12418
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
|
|
12419
|
+
"style-src 'self' 'unsafe-inline'",
|
|
12420
|
+
"img-src 'self' data: blob:",
|
|
12421
|
+
"worker-src 'self' blob:",
|
|
12422
|
+
"connect-src 'self'",
|
|
12423
|
+
"frame-ancestors 'self'"
|
|
12424
|
+
].join("; ");
|
|
11982
12425
|
async function readJsonBody(req) {
|
|
11983
12426
|
const chunks = [];
|
|
11984
12427
|
let bytes = 0;
|
|
@@ -11996,7 +12439,7 @@ async function readJsonBody(req) {
|
|
|
11996
12439
|
async function serveGallery(opts) {
|
|
11997
12440
|
const log = opts.onProgress ?? (() => {
|
|
11998
12441
|
});
|
|
11999
|
-
const session = opts.edit || opts.generate ? randomBytes(16).toString("hex") : null;
|
|
12442
|
+
const session = opts.edit || opts.generate || opts.editor ? randomBytes(16).toString("hex") : null;
|
|
12000
12443
|
let media = /* @__PURE__ */ new Map();
|
|
12001
12444
|
let loading = null;
|
|
12002
12445
|
const reviewAssets = /* @__PURE__ */ new Map();
|
|
@@ -12071,6 +12514,17 @@ async function serveGallery(opts) {
|
|
|
12071
12514
|
}
|
|
12072
12515
|
return;
|
|
12073
12516
|
}
|
|
12517
|
+
if (req.method === "POST" && url2.pathname === "/api/editor/install") {
|
|
12518
|
+
if (!opts.editor || !session) return fail(405, "the in-browser editor is off for this gallery");
|
|
12519
|
+
try {
|
|
12520
|
+
const body = await guardedBody("editor installs");
|
|
12521
|
+
if (body === void 0) return;
|
|
12522
|
+
json(202, await opts.editor.install());
|
|
12523
|
+
} catch (err) {
|
|
12524
|
+
reportError("editor install", err);
|
|
12525
|
+
}
|
|
12526
|
+
return;
|
|
12527
|
+
}
|
|
12074
12528
|
const reviewMatch = /^\/review\/([0-9a-f]{16})(\/.*)?$/.exec(url2.pathname);
|
|
12075
12529
|
if (reviewMatch && opts.generate) {
|
|
12076
12530
|
const [, jobId, rest = ""] = reviewMatch;
|
|
@@ -12116,7 +12570,7 @@ async function serveGallery(opts) {
|
|
|
12116
12570
|
const asset = reviewAssets.get(jobId)?.get(url2.pathname);
|
|
12117
12571
|
if (!asset) return fail(404, "no such review asset");
|
|
12118
12572
|
try {
|
|
12119
|
-
const bytes = await
|
|
12573
|
+
const bytes = await readFile20(asset.path);
|
|
12120
12574
|
res.writeHead(200, {
|
|
12121
12575
|
"Content-Type": asset.contentType,
|
|
12122
12576
|
"Cache-Control": "no-store",
|
|
@@ -12134,6 +12588,32 @@ async function serveGallery(opts) {
|
|
|
12134
12588
|
if (!opts.generate) return fail(404, "generation is off");
|
|
12135
12589
|
return json(200, opts.generate.status());
|
|
12136
12590
|
}
|
|
12591
|
+
if (url2.pathname === "/api/editor") {
|
|
12592
|
+
if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
|
|
12593
|
+
try {
|
|
12594
|
+
return json(200, await opts.editor.status());
|
|
12595
|
+
} catch (err) {
|
|
12596
|
+
return reportError("editor status", err);
|
|
12597
|
+
}
|
|
12598
|
+
}
|
|
12599
|
+
const editorMatch = /^\/editor\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/.exec(url2.pathname);
|
|
12600
|
+
if (editorMatch) {
|
|
12601
|
+
if (!opts.editor) return fail(404, "the in-browser editor is off for this gallery");
|
|
12602
|
+
const file = await opts.editor.file(editorMatch[1], editorMatch[2]);
|
|
12603
|
+
if (!file) return fail(404, "that editor file is not installed; check /api/editor");
|
|
12604
|
+
res.writeHead(200, {
|
|
12605
|
+
"Content-Type": file.contentType,
|
|
12606
|
+
"Content-Length": file.bytes.length,
|
|
12607
|
+
// The release tag is in the path, so these bytes never change under
|
|
12608
|
+
// this URL; the 40 MB wasm should come from the browser cache.
|
|
12609
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
12610
|
+
"X-Content-Type-Options": "nosniff",
|
|
12611
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
12612
|
+
...editorMatch[2] === "index.html" ? { "Content-Security-Policy": EDITOR_CSP } : {}
|
|
12613
|
+
});
|
|
12614
|
+
res.end(req.method === "HEAD" ? void 0 : file.bytes);
|
|
12615
|
+
return;
|
|
12616
|
+
}
|
|
12137
12617
|
try {
|
|
12138
12618
|
if (url2.pathname === "/") {
|
|
12139
12619
|
const { snapshot } = await load();
|
|
@@ -12145,7 +12625,8 @@ async function serveGallery(opts) {
|
|
|
12145
12625
|
res.end(renderGallery(snapshot, {
|
|
12146
12626
|
...session ? { session } : {},
|
|
12147
12627
|
editable: Boolean(opts.edit),
|
|
12148
|
-
generation: Boolean(opts.generate)
|
|
12628
|
+
generation: Boolean(opts.generate),
|
|
12629
|
+
editor: Boolean(opts.editor)
|
|
12149
12630
|
}));
|
|
12150
12631
|
return;
|
|
12151
12632
|
}
|
|
@@ -12165,7 +12646,7 @@ async function serveGallery(opts) {
|
|
|
12165
12646
|
if (!asset) return fail(404, "no such gallery media");
|
|
12166
12647
|
let bytes;
|
|
12167
12648
|
try {
|
|
12168
|
-
bytes = await
|
|
12649
|
+
bytes = await readFile20(asset.path);
|
|
12169
12650
|
} catch {
|
|
12170
12651
|
return fail(404, "gallery media is no longer on disk; refresh the page");
|
|
12171
12652
|
}
|
|
@@ -12211,16 +12692,16 @@ async function serveGallery(opts) {
|
|
|
12211
12692
|
|
|
12212
12693
|
// src/gallery/generate.ts
|
|
12213
12694
|
import { randomBytes as randomBytes2 } from "crypto";
|
|
12214
|
-
import { z as
|
|
12215
|
-
var GenerateRequestSchema =
|
|
12216
|
-
project:
|
|
12217
|
-
keys:
|
|
12695
|
+
import { z as z10 } from "zod";
|
|
12696
|
+
var GenerateRequestSchema = z10.object({
|
|
12697
|
+
project: z10.string().min(1).optional(),
|
|
12698
|
+
keys: z10.array(z10.string().min(1)).min(1).max(500),
|
|
12218
12699
|
/** Regenerate work that is up to date, as `gen --force` does. */
|
|
12219
|
-
force:
|
|
12700
|
+
force: z10.boolean().optional(),
|
|
12220
12701
|
/** Advance in-flight, review, or selected work without submitting anything. */
|
|
12221
|
-
resume:
|
|
12702
|
+
resume: z10.boolean().optional(),
|
|
12222
12703
|
/** Re-download downloaded outputs and replace files whose object changed upstream. */
|
|
12223
|
-
refresh:
|
|
12704
|
+
refresh: z10.boolean().optional()
|
|
12224
12705
|
}).strict().refine((request) => !(request.resume && request.refresh), { message: "resume and refresh are exclusive" });
|
|
12225
12706
|
var GenerateRequestError = class extends Error {
|
|
12226
12707
|
constructor(message6, status = 400) {
|
|
@@ -12475,7 +12956,7 @@ function createGenerateHandlers(opts) {
|
|
|
12475
12956
|
}
|
|
12476
12957
|
|
|
12477
12958
|
// src/pipeline/salvage.ts
|
|
12478
|
-
import { readFile as
|
|
12959
|
+
import { readFile as readFile21 } from "fs/promises";
|
|
12479
12960
|
import { existsSync as existsSync20 } from "fs";
|
|
12480
12961
|
import path25 from "path";
|
|
12481
12962
|
function providerClaimId(provider, objectId) {
|
|
@@ -12487,7 +12968,7 @@ async function loadClaims(lockPaths, opts = {}) {
|
|
|
12487
12968
|
if (!existsSync20(p)) throw new Error(`Claim lockfile not found: ${p}`);
|
|
12488
12969
|
let parsed;
|
|
12489
12970
|
try {
|
|
12490
|
-
parsed = parseLock(JSON.parse(await
|
|
12971
|
+
parsed = parseLock(JSON.parse(await readFile21(p, "utf8")));
|
|
12491
12972
|
} catch {
|
|
12492
12973
|
throw new Error(`Claim lockfile is malformed: ${p}`);
|
|
12493
12974
|
}
|
|
@@ -12669,7 +13150,7 @@ async function applyTags(provider, decisions, existing, opts = {}) {
|
|
|
12669
13150
|
}
|
|
12670
13151
|
|
|
12671
13152
|
// src/pick/salvage-server.ts
|
|
12672
|
-
import { mkdir as mkdir7, writeFile as
|
|
13153
|
+
import { mkdir as mkdir7, writeFile as writeFile10, readFile as readFile22 } from "fs/promises";
|
|
12673
13154
|
import path26 from "path";
|
|
12674
13155
|
|
|
12675
13156
|
// src/pick/salvage-sheet.ts
|
|
@@ -12867,7 +13348,7 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
12867
13348
|
const rel = path26.join("_salvaged", `${assetId}.png`);
|
|
12868
13349
|
const outFile = path26.resolve(ctx.importDir, rel);
|
|
12869
13350
|
await mkdir7(path26.dirname(outFile), { recursive: true });
|
|
12870
|
-
await
|
|
13351
|
+
await writeFile10(outFile, buf);
|
|
12871
13352
|
ctx.manifest.assets[assetId] = {
|
|
12872
13353
|
prompt: orphan.prompt,
|
|
12873
13354
|
promptByStyle: {},
|
|
@@ -12923,9 +13404,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
12923
13404
|
}
|
|
12924
13405
|
}
|
|
12925
13406
|
await applyTags(provider, decisions, existingTags, { onProgress: log });
|
|
12926
|
-
const raw = JSON.parse(await
|
|
13407
|
+
const raw = JSON.parse(await readFile22(ctx.manifestPath, "utf8"));
|
|
12927
13408
|
for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
|
|
12928
|
-
await
|
|
13409
|
+
await writeFile10(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
|
|
12929
13410
|
await saveLock(ctx.lockPath, ctx.lock);
|
|
12930
13411
|
return result;
|
|
12931
13412
|
}
|