pixelkiln 0.62.0 → 0.64.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/dist/cli.js +384 -48
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +240 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +150 -48
- package/dist/index.d.ts +150 -48
- package/dist/index.js +240 -29
- package/dist/index.js.map +1 -1
- package/docs/CHARACTERS.md +39 -0
- package/docs/CLI.md +6 -3
- package/docs/MANIFEST.md +1 -1
- package/docs/RECOVERY.md +36 -3
- package/package.json +1 -1
- package/schema/manifest.schema.json +17 -0
- package/skills/pixelkiln/SKILL.md +5 -2
package/dist/cli.js
CHANGED
|
@@ -956,6 +956,14 @@ var CharacterAnimationSchema = z.object({
|
|
|
956
956
|
template: z.string().min(1).optional(),
|
|
957
957
|
/** Direction to animate. */
|
|
958
958
|
direction: CharacterDirectionSchema.default("south"),
|
|
959
|
+
/**
|
|
960
|
+
* Shorthand for one loop in several directions, instead of `direction`.
|
|
961
|
+
* Expanded when the manifest loads into one loop per named direction
|
|
962
|
+
* (`<id>.<direction>`) plus a mirror for each direction whose flip is
|
|
963
|
+
* not named, exactly as if each had been written out. See
|
|
964
|
+
* `src/loop-directions.ts`.
|
|
965
|
+
*/
|
|
966
|
+
directions: z.array(CharacterDirectionSchema).min(1).optional(),
|
|
959
967
|
/** Frames to generate without a template (v3). Even, 4 to 16. */
|
|
960
968
|
frames: z.number().int().min(4).max(16).optional(),
|
|
961
969
|
/** Playback rate recorded with the frames; PixelLab does not store one. */
|
|
@@ -984,6 +992,13 @@ var CharacterAnimationSchema = z.object({
|
|
|
984
992
|
/** v3 text loops only: let PixelLab expand the action into a fuller motion description first. */
|
|
985
993
|
enhancePrompt: z.boolean().optional()
|
|
986
994
|
}).strict().superRefine((animation, context) => {
|
|
995
|
+
if (animation.directions !== void 0) {
|
|
996
|
+
context.addIssue({
|
|
997
|
+
code: z.ZodIssueCode.custom,
|
|
998
|
+
message: "directions is expanded when the manifest loads; validate the loaded manifest, not the raw file",
|
|
999
|
+
path: ["directions"]
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
987
1002
|
if (animation.frames !== void 0 && animation.frames % 2 !== 0) {
|
|
988
1003
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "frames must be even", path: ["frames"] });
|
|
989
1004
|
}
|
|
@@ -8358,8 +8373,8 @@ var PixelLabProvider = class _PixelLabProvider {
|
|
|
8358
8373
|
const sources = [];
|
|
8359
8374
|
const terrainTiles = [];
|
|
8360
8375
|
for (const [index, tile] of tileset.tiles.entries()) {
|
|
8361
|
-
const
|
|
8362
|
-
const role = `tile-${String(index).padStart(2, "0")}-${
|
|
8376
|
+
const slug2 = tile.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tile";
|
|
8377
|
+
const role = `tile-${String(index).padStart(2, "0")}-${slug2}`;
|
|
8363
8378
|
const file = path12.join(_PixelLabProvider.cacheDir(), `${tilesetId}-${index}.png`);
|
|
8364
8379
|
writeFileSync(file, Buffer.from(tile.image.base64, "base64"));
|
|
8365
8380
|
sources.push({ url: `file://${file}`, role });
|
|
@@ -8478,6 +8493,9 @@ var PixelLabProvider = class _PixelLabProvider {
|
|
|
8478
8493
|
async delete(assetId) {
|
|
8479
8494
|
await this.client.deleteObject(assetId);
|
|
8480
8495
|
}
|
|
8496
|
+
async deleteCharacter(id) {
|
|
8497
|
+
await this.client.deleteCharacter(id);
|
|
8498
|
+
}
|
|
8481
8499
|
async *listCharacters() {
|
|
8482
8500
|
for await (const character of this.client.iterateCharacters(100)) {
|
|
8483
8501
|
yield remoteCharacter(character);
|
|
@@ -9549,10 +9567,102 @@ registerProvider({
|
|
|
9549
9567
|
import { readFile as readFile6 } from "fs/promises";
|
|
9550
9568
|
import { existsSync as existsSync8 } from "fs";
|
|
9551
9569
|
import path13 from "path";
|
|
9570
|
+
|
|
9571
|
+
// src/loop-directions.ts
|
|
9572
|
+
var SINGLE_OUTPUT_FIELDS = ["file", "cell", "remoteId", "source", "sourceByStyle", "outputRole"];
|
|
9573
|
+
var MIRROR_FIELDS = ["category", "tags", "styles"];
|
|
9574
|
+
function loopDirectionId(shorthandId, direction) {
|
|
9575
|
+
return `${shorthandId}.${direction}`;
|
|
9576
|
+
}
|
|
9577
|
+
function expandLoopDirections(raw) {
|
|
9578
|
+
const families = {};
|
|
9579
|
+
const issues = [];
|
|
9580
|
+
if (!isObject2(raw) || !isObject2(raw.assets)) return { raw, families, issues };
|
|
9581
|
+
const assets = raw.assets;
|
|
9582
|
+
if (!Object.values(assets).some((asset) => isObject2(asset) && isObject2(asset.animation) && "directions" in asset.animation)) {
|
|
9583
|
+
return { raw, families, issues };
|
|
9584
|
+
}
|
|
9585
|
+
const expanded = {};
|
|
9586
|
+
const claimed = new Set(Object.keys(assets));
|
|
9587
|
+
for (const [id, asset] of Object.entries(assets)) {
|
|
9588
|
+
if (!isObject2(asset) || !isObject2(asset.animation) || !("directions" in asset.animation)) {
|
|
9589
|
+
expanded[id] = asset;
|
|
9590
|
+
continue;
|
|
9591
|
+
}
|
|
9592
|
+
const where = `assets.${id}.animation.directions`;
|
|
9593
|
+
const { directions: rawDirections, ...animation } = asset.animation;
|
|
9594
|
+
const parsed = CharacterDirectionSchema.array().min(1).safeParse(rawDirections);
|
|
9595
|
+
if (!parsed.success) {
|
|
9596
|
+
issues.push(`${where}: expected a non-empty list of directions (${CharacterDirectionSchema.options.join(", ")})`);
|
|
9597
|
+
continue;
|
|
9598
|
+
}
|
|
9599
|
+
const directions = parsed.data;
|
|
9600
|
+
const duplicate = directions.find((direction, index) => directions.indexOf(direction) !== index);
|
|
9601
|
+
if (duplicate) {
|
|
9602
|
+
issues.push(`${where}: "${duplicate}" is listed twice`);
|
|
9603
|
+
continue;
|
|
9604
|
+
}
|
|
9605
|
+
if ("direction" in animation) {
|
|
9606
|
+
issues.push(`assets.${id}.animation: direction and directions are mutually exclusive; directions names every loop to make`);
|
|
9607
|
+
continue;
|
|
9608
|
+
}
|
|
9609
|
+
const single = SINGLE_OUTPUT_FIELDS.filter((field) => field in asset);
|
|
9610
|
+
if (single.length) {
|
|
9611
|
+
issues.push(
|
|
9612
|
+
`assets.${id}: ${single.join(", ")} name${single.length === 1 ? "s" : ""} one output, but directions makes ${directions.length} loops; declare the loops explicitly to set ${single.length === 1 ? "it" : "them"} per direction`
|
|
9613
|
+
);
|
|
9614
|
+
continue;
|
|
9615
|
+
}
|
|
9616
|
+
const loops = directions.map((direction) => ({ id: loopDirectionId(id, direction), direction }));
|
|
9617
|
+
const named = new Set(directions);
|
|
9618
|
+
const mirrors = [];
|
|
9619
|
+
for (const { id: loopId, direction } of loops) {
|
|
9620
|
+
const flipped = MIRRORED_DIRECTION[direction];
|
|
9621
|
+
if (flipped === direction || named.has(flipped)) continue;
|
|
9622
|
+
named.add(flipped);
|
|
9623
|
+
mirrors.push({ id: loopDirectionId(id, flipped), of: loopId });
|
|
9624
|
+
}
|
|
9625
|
+
const ids = [...loops.map((loop) => loop.id), ...mirrors.map((mirror) => mirror.id)];
|
|
9626
|
+
const collision = ids.find((expandedId) => claimed.has(expandedId));
|
|
9627
|
+
if (collision) {
|
|
9628
|
+
issues.push(`${where}: expands to "${collision}", which is already declared`);
|
|
9629
|
+
continue;
|
|
9630
|
+
}
|
|
9631
|
+
for (const expandedId of ids) claimed.add(expandedId);
|
|
9632
|
+
for (const loop of loops) {
|
|
9633
|
+
expanded[loop.id] = { ...asset, animation: { ...animation, direction: loop.direction } };
|
|
9634
|
+
}
|
|
9635
|
+
for (const mirror of mirrors) {
|
|
9636
|
+
const inherited = {};
|
|
9637
|
+
for (const field of MIRROR_FIELDS) if (field in asset) inherited[field] = asset[field];
|
|
9638
|
+
expanded[mirror.id] = { mirror: mirror.of, ...inherited };
|
|
9639
|
+
}
|
|
9640
|
+
families[id] = ids;
|
|
9641
|
+
}
|
|
9642
|
+
return { raw: { ...raw, assets: expanded }, families, issues };
|
|
9643
|
+
}
|
|
9644
|
+
function expandAssetFilter(ids, families) {
|
|
9645
|
+
if (!families) return [...ids];
|
|
9646
|
+
return [...new Set(ids.flatMap((id) => families[id] ?? [id]))];
|
|
9647
|
+
}
|
|
9648
|
+
function loopShorthandOf(assetId, families) {
|
|
9649
|
+
if (!families) return void 0;
|
|
9650
|
+
return Object.keys(families).find((id) => families[id].includes(assetId));
|
|
9651
|
+
}
|
|
9652
|
+
function isObject2(value) {
|
|
9653
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9654
|
+
}
|
|
9655
|
+
|
|
9656
|
+
// src/manifest.ts
|
|
9552
9657
|
async function loadManifest(manifestPath) {
|
|
9553
9658
|
const abs = path13.resolve(manifestPath);
|
|
9554
9659
|
if (!existsSync8(abs)) throw new ProjectError(`No manifest at ${abs}`);
|
|
9555
|
-
const
|
|
9660
|
+
const expansion = expandLoopDirections(JSON.parse(await readFile6(abs, "utf8")));
|
|
9661
|
+
if (expansion.issues.length) {
|
|
9662
|
+
throw new ProjectError(`Manifest at ${abs} is invalid:
|
|
9663
|
+
${expansion.issues.map((i) => ` ${i}`).join("\n")}`);
|
|
9664
|
+
}
|
|
9665
|
+
const input = ManifestInputSchema.safeParse(expansion.raw);
|
|
9556
9666
|
if (!input.success) {
|
|
9557
9667
|
const issues = formatManifestIssues(input.error.issues);
|
|
9558
9668
|
throw new ProjectError(`Manifest at ${abs} is invalid:
|
|
@@ -9637,7 +9747,8 @@ ${issues}`);
|
|
|
9637
9747
|
throw new ProjectError(`Manifest at ${abs} is invalid:
|
|
9638
9748
|
${unknownReferences.map((i) => ` ${i}`).join("\n")}`);
|
|
9639
9749
|
}
|
|
9640
|
-
|
|
9750
|
+
const loopFamilies = Object.keys(expansion.families).length ? { loopFamilies: expansion.families } : {};
|
|
9751
|
+
return { manifest: parsed.data, root: path13.dirname(abs), path: abs, ...loopFamilies };
|
|
9641
9752
|
}
|
|
9642
9753
|
function parentAssetId(asset) {
|
|
9643
9754
|
return asset?.revision?.from ?? asset?.state?.of ?? asset?.animation?.of;
|
|
@@ -9721,12 +9832,13 @@ async function resolveSpecs(loaded, filter) {
|
|
|
9721
9832
|
);
|
|
9722
9833
|
}
|
|
9723
9834
|
}
|
|
9724
|
-
|
|
9835
|
+
const assetFilter = expandAssetFilter(filter?.assets ?? [], loaded.loopFamilies);
|
|
9836
|
+
for (const unknownAsset of assetFilter) {
|
|
9725
9837
|
if (!manifest.assets[unknownAsset]) {
|
|
9726
9838
|
throw new UsageError(`Unknown asset "${unknownAsset}".`);
|
|
9727
9839
|
}
|
|
9728
9840
|
}
|
|
9729
|
-
const requestedAssetIds = new Set(
|
|
9841
|
+
const requestedAssetIds = new Set(assetFilter.length ? assetFilter : Object.keys(manifest.assets));
|
|
9730
9842
|
const resolutionAssetIds = new Set(requestedAssetIds);
|
|
9731
9843
|
for (const assetId of [...requestedAssetIds]) {
|
|
9732
9844
|
let current = parentAssetId(manifest.assets[assetId]);
|
|
@@ -11297,6 +11409,9 @@ async function loadClaims(lockPaths, opts = {}) {
|
|
|
11297
11409
|
add(entry.objectId);
|
|
11298
11410
|
add(entry.reviewObjectId);
|
|
11299
11411
|
add(entry.jobId);
|
|
11412
|
+
for (const id of [entry.objectId, entry.jobId]) {
|
|
11413
|
+
if (id?.includes("#")) add(id.split("#")[0]);
|
|
11414
|
+
}
|
|
11300
11415
|
}
|
|
11301
11416
|
}
|
|
11302
11417
|
return claimed;
|
|
@@ -11320,6 +11435,122 @@ async function findOrphans(provider, claimed, opts = {}) {
|
|
|
11320
11435
|
})).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
11321
11436
|
return { orphans, total: all.length };
|
|
11322
11437
|
}
|
|
11438
|
+
async function findOrphanCharacters(provider, claimed, opts = {}) {
|
|
11439
|
+
const log2 = opts.onProgress ?? (() => {
|
|
11440
|
+
});
|
|
11441
|
+
if (!provider.listCharacters) return { orphans: [], total: 0, partlyClaimed: 0 };
|
|
11442
|
+
const all = [];
|
|
11443
|
+
for await (const character of provider.listCharacters()) all.push(character);
|
|
11444
|
+
log2(` scanned ${all.length} character(s) on the account`);
|
|
11445
|
+
const isClaimed = (id) => claimed.has(id) || claimed.has(providerClaimId(provider.id, id));
|
|
11446
|
+
const groups = /* @__PURE__ */ new Map();
|
|
11447
|
+
for (const character of all) {
|
|
11448
|
+
const key = character.groupId ?? character.id;
|
|
11449
|
+
groups.set(key, [...groups.get(key) ?? [], character]);
|
|
11450
|
+
}
|
|
11451
|
+
let partlyClaimed = 0;
|
|
11452
|
+
const orphans = [];
|
|
11453
|
+
for (const members of groups.values()) {
|
|
11454
|
+
const unclaimed = members.filter((character) => !isClaimed(character.id));
|
|
11455
|
+
if (!unclaimed.length) continue;
|
|
11456
|
+
if (unclaimed.length < members.length) {
|
|
11457
|
+
partlyClaimed++;
|
|
11458
|
+
continue;
|
|
11459
|
+
}
|
|
11460
|
+
const ordered = [...members].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
|
|
11461
|
+
const base = ordered[0];
|
|
11462
|
+
if (base.status !== "completed" || !base.previewUrl) continue;
|
|
11463
|
+
const states = ordered.length - 1;
|
|
11464
|
+
const loops = ordered.reduce((sum, character) => sum + character.animationCount, 0);
|
|
11465
|
+
orphans.push({
|
|
11466
|
+
id: base.id,
|
|
11467
|
+
prompt: base.prompt || base.name || "(no prompt recorded)",
|
|
11468
|
+
width: base.width,
|
|
11469
|
+
height: base.height,
|
|
11470
|
+
createdAt: base.createdAt,
|
|
11471
|
+
previewUrl: base.previewUrl,
|
|
11472
|
+
tags: base.tags ?? [],
|
|
11473
|
+
kind: "character",
|
|
11474
|
+
note: `character \xB7 ${base.directions} directions \xB7 ${states} state${states === 1 ? "" : "s"} \xB7 ${loops} loop${loops === 1 ? "" : "s"}`,
|
|
11475
|
+
members: ordered.map((character) => ({ id: character.id, tags: character.tags ?? [] }))
|
|
11476
|
+
});
|
|
11477
|
+
}
|
|
11478
|
+
orphans.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
11479
|
+
return { orphans, total: all.length, partlyClaimed };
|
|
11480
|
+
}
|
|
11481
|
+
function isState(character) {
|
|
11482
|
+
return Boolean(character.stateName && character.stateName !== "Idle");
|
|
11483
|
+
}
|
|
11484
|
+
function characterStyleIds(manifest) {
|
|
11485
|
+
return Object.keys(manifest.styles).filter((id) => manifest.styles[id].generator === "character");
|
|
11486
|
+
}
|
|
11487
|
+
function withoutCharacterStyles(manifest) {
|
|
11488
|
+
const characters = new Set(characterStyleIds(manifest));
|
|
11489
|
+
return {
|
|
11490
|
+
...manifest,
|
|
11491
|
+
styles: Object.fromEntries(Object.entries(manifest.styles).filter(([id]) => !characters.has(id)))
|
|
11492
|
+
};
|
|
11493
|
+
}
|
|
11494
|
+
function routeCharacterOrphans(orphans, manifest) {
|
|
11495
|
+
const styleIds = characterStyleIds(manifest);
|
|
11496
|
+
const matched = /* @__PURE__ */ new Map();
|
|
11497
|
+
const unmatched = [];
|
|
11498
|
+
const only = { ...manifest, styles: Object.fromEntries(styleIds.map((id) => [id, manifest.styles[id]])) };
|
|
11499
|
+
for (const orphan of orphans) {
|
|
11500
|
+
const styleId = styleIds.length === 1 ? styleIds[0] : styleIds.length ? matchStyleByPattern(orphan.prompt, only) : null;
|
|
11501
|
+
if (!styleId) {
|
|
11502
|
+
unmatched.push(orphan);
|
|
11503
|
+
continue;
|
|
11504
|
+
}
|
|
11505
|
+
matched.set(styleId, [...matched.get(styleId) ?? [], orphan]);
|
|
11506
|
+
}
|
|
11507
|
+
return { matched, unmatched };
|
|
11508
|
+
}
|
|
11509
|
+
function characterSalvageAssets(characters, ctx) {
|
|
11510
|
+
const ordered = [...characters].sort((a, b) => Number(isState(a)) - Number(isState(b)) || a.createdAt.localeCompare(b.createdAt));
|
|
11511
|
+
const base = ordered[0];
|
|
11512
|
+
const common = { styles: [ctx.styleId], tags: ["salvaged"] };
|
|
11513
|
+
const assets = {};
|
|
11514
|
+
const skipped = [];
|
|
11515
|
+
const baseId = idFromPrompt(base.name || base.prompt, ctx.taken);
|
|
11516
|
+
const size = base.width === base.height && base.width >= 32 && base.width <= 256 && base.width !== ctx.styleSize ? { size: base.width } : {};
|
|
11517
|
+
assets[baseId] = { prompt: base.prompt || base.name || baseId, remoteId: base.id, ...size, ...common };
|
|
11518
|
+
const assetIdOf = /* @__PURE__ */ new Map([[base.id, baseId]]);
|
|
11519
|
+
for (const state of ordered.slice(1)) {
|
|
11520
|
+
const stateId = uniqueId(`${baseId}_${slug(state.stateName ?? "state")}`, ctx.taken);
|
|
11521
|
+
assetIdOf.set(state.id, stateId);
|
|
11522
|
+
assets[stateId] = { prompt: state.prompt || state.stateName || stateId, state: { of: baseId }, remoteId: state.id, ...common };
|
|
11523
|
+
}
|
|
11524
|
+
for (const character of ordered) {
|
|
11525
|
+
const parentId = assetIdOf.get(character.id);
|
|
11526
|
+
for (const animation of character.animations) {
|
|
11527
|
+
if (!animation.frames.length) continue;
|
|
11528
|
+
const label = animation.name || animation.type;
|
|
11529
|
+
if (!animation.groupId) {
|
|
11530
|
+
skipped.push(`${parentId}: "${label}" ${animation.direction} loop has no animation group id to adopt it by`);
|
|
11531
|
+
continue;
|
|
11532
|
+
}
|
|
11533
|
+
const loopId = uniqueId(`${parentId}.${slug(label)}.${animation.direction}`, ctx.taken);
|
|
11534
|
+
assets[loopId] = {
|
|
11535
|
+
prompt: label,
|
|
11536
|
+
animation: { of: parentId, direction: animation.direction },
|
|
11537
|
+
remoteId: `${character.id}#${animation.groupId}`,
|
|
11538
|
+
...common
|
|
11539
|
+
};
|
|
11540
|
+
}
|
|
11541
|
+
}
|
|
11542
|
+
return { assets, skipped };
|
|
11543
|
+
}
|
|
11544
|
+
function slug(text) {
|
|
11545
|
+
return text.toLowerCase().replace(/^pixelkiln:.*\//, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "loop";
|
|
11546
|
+
}
|
|
11547
|
+
function uniqueId(base, taken) {
|
|
11548
|
+
let id = base;
|
|
11549
|
+
let n = 2;
|
|
11550
|
+
while (taken.has(id)) id = `${base}_${n++}`;
|
|
11551
|
+
taken.add(id);
|
|
11552
|
+
return id;
|
|
11553
|
+
}
|
|
11323
11554
|
var MIN_STYLE_FRAGMENT_LENGTH = 12;
|
|
11324
11555
|
function matchStyleByPattern(prompt, manifest) {
|
|
11325
11556
|
const p = prompt.toLowerCase();
|
|
@@ -11444,18 +11675,22 @@ async function applyTags(provider, decisions, existing, opts = {}) {
|
|
|
11444
11675
|
let tagged = 0;
|
|
11445
11676
|
let failed = 0;
|
|
11446
11677
|
for (const { id, action } of decisions) {
|
|
11447
|
-
const
|
|
11448
|
-
|
|
11449
|
-
)
|
|
11450
|
-
|
|
11451
|
-
|
|
11452
|
-
|
|
11453
|
-
|
|
11454
|
-
|
|
11455
|
-
tagged
|
|
11456
|
-
|
|
11457
|
-
|
|
11458
|
-
|
|
11678
|
+
const group = opts.characters?.get(id);
|
|
11679
|
+
const targets = group ?? [{ id, tags: existing.get(id) ?? [] }];
|
|
11680
|
+
for (const target of targets) {
|
|
11681
|
+
const current = target.tags.filter(
|
|
11682
|
+
(t) => t !== "pixelkiln:keep" && t !== "pixelkiln:discard" && t !== "pixelkiln:imported"
|
|
11683
|
+
);
|
|
11684
|
+
const decisionTag = `pixelkiln:${action === "import" ? "imported" : action}`;
|
|
11685
|
+
const next = [...current.slice(0, 19), decisionTag];
|
|
11686
|
+
if (!provider.setTags) return { tagged, failed };
|
|
11687
|
+
try {
|
|
11688
|
+
await provider.setTags(target.id, next, group ? "character" : void 0);
|
|
11689
|
+
tagged++;
|
|
11690
|
+
} catch (err) {
|
|
11691
|
+
failed++;
|
|
11692
|
+
log2(` tag failed ${target.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
11693
|
+
}
|
|
11459
11694
|
}
|
|
11460
11695
|
}
|
|
11461
11696
|
return { tagged, failed };
|
|
@@ -11601,7 +11836,7 @@ import path19 from "path";
|
|
|
11601
11836
|
// src/pick/salvage-sheet.ts
|
|
11602
11837
|
var escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
11603
11838
|
function renderSalvageSheet(orphans, ctx) {
|
|
11604
|
-
const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml(o.prompt) }));
|
|
11839
|
+
const safe = orphans.map((o) => ({ ...o, prompt: escapeHtml(o.prompt), ...o.note ? { note: escapeHtml(o.note) } : {} }));
|
|
11605
11840
|
const data = JSON.stringify(safe).replace(/<\//g, "<\\/");
|
|
11606
11841
|
const styleId = ctx ? escapeHtml(ctx.styleId) : null;
|
|
11607
11842
|
const importDir = ctx ? escapeHtml(ctx.importDir) : null;
|
|
@@ -11687,6 +11922,7 @@ ITEMS.forEach((o, i) => {
|
|
|
11687
11922
|
el.innerHTML =
|
|
11688
11923
|
'<div class="thumb"><img src="' + o.previewUrl + '" loading="lazy"></div>' +
|
|
11689
11924
|
'<div class="meta">' + o.width + '\xD7' + o.height + ' \xB7 ' + o.createdAt.slice(0,10) + '</div>' +
|
|
11925
|
+
(o.note ? '<div class="meta">' + o.note + '</div>' : '') +
|
|
11690
11926
|
'<div class="prompt">' + o.prompt + '</div>' +
|
|
11691
11927
|
'<div class="acts">' +
|
|
11692
11928
|
'<button data-a="import">import</button>' +
|
|
@@ -11864,6 +12100,9 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
11864
12100
|
});
|
|
11865
12101
|
const byId = new Map(orphans.map((o) => [o.id, o]));
|
|
11866
12102
|
const existingTags = new Map(orphans.map((o) => [o.id, o.tags]));
|
|
12103
|
+
const characterMembers = new Map(
|
|
12104
|
+
orphans.flatMap((o) => o.kind === "character" && o.members ? [[o.id, o.members]] : [])
|
|
12105
|
+
);
|
|
11867
12106
|
return serveReviewPage({
|
|
11868
12107
|
html,
|
|
11869
12108
|
port: opts.port,
|
|
@@ -11880,10 +12119,34 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
11880
12119
|
const result = { imported: 0, kept: 0, discarded: 0, failed: 0 };
|
|
11881
12120
|
const taken = new Set(Object.keys(ctx.manifest.assets));
|
|
11882
12121
|
const importedAssetIds = [];
|
|
12122
|
+
const characterAssetIds = [];
|
|
11883
12123
|
for (const decision of decisions) {
|
|
11884
12124
|
const orphan = byId.get(decision.id);
|
|
11885
12125
|
if (!orphan) continue;
|
|
11886
|
-
if (decision.action === "import") {
|
|
12126
|
+
if (decision.action === "import" && orphan.kind === "character") {
|
|
12127
|
+
try {
|
|
12128
|
+
if (!provider.getCharacter) throw new Error(`provider ${provider.id} cannot read characters`);
|
|
12129
|
+
const details = [];
|
|
12130
|
+
for (const member of orphan.members ?? [{ id: orphan.id }]) details.push(await provider.getCharacter(member.id));
|
|
12131
|
+
const style = ctx.manifest.styles[ctx.styleId];
|
|
12132
|
+
const { assets, skipped } = characterSalvageAssets(details, {
|
|
12133
|
+
styleId: ctx.styleId,
|
|
12134
|
+
styleSize: style?.size,
|
|
12135
|
+
taken
|
|
12136
|
+
});
|
|
12137
|
+
for (const [assetId, asset] of Object.entries(assets)) {
|
|
12138
|
+
ctx.manifest.assets[assetId] = asset;
|
|
12139
|
+
importedAssetIds.push(assetId);
|
|
12140
|
+
characterAssetIds.push(assetId);
|
|
12141
|
+
}
|
|
12142
|
+
for (const line of skipped) log2(` not imported: ${line}`);
|
|
12143
|
+
result.imported++;
|
|
12144
|
+
log2(` imported character ${orphan.id} as ${Object.keys(assets).length} asset(s)`);
|
|
12145
|
+
} catch (err) {
|
|
12146
|
+
result.failed++;
|
|
12147
|
+
log2(` import failed ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
12148
|
+
}
|
|
12149
|
+
} else if (decision.action === "import") {
|
|
11887
12150
|
try {
|
|
11888
12151
|
const buf = await provider.download(orphan.previewUrl);
|
|
11889
12152
|
if (!buf.subarray(0, 8).equals(PNG_SIGNATURE2)) throw new Error("not a PNG");
|
|
@@ -11947,11 +12210,12 @@ async function runSalvage(provider, orphans, ctx, opts = {}) {
|
|
|
11947
12210
|
result.discarded++;
|
|
11948
12211
|
}
|
|
11949
12212
|
}
|
|
11950
|
-
await applyTags(provider, decisions, existingTags, { onProgress: log2 });
|
|
12213
|
+
await applyTags(provider, decisions, existingTags, { onProgress: log2, characters: characterMembers });
|
|
11951
12214
|
const raw = JSON.parse(await readFile14(ctx.manifestPath, "utf8"));
|
|
11952
12215
|
for (const id of importedAssetIds) raw.assets[id] = ctx.manifest.assets[id];
|
|
11953
12216
|
await writeFile9(ctx.manifestPath, JSON.stringify(raw, null, 2) + "\n");
|
|
11954
12217
|
await saveLock(ctx.lockPath, ctx.lock);
|
|
12218
|
+
if (characterAssetIds.length) result.characterAssetIds = characterAssetIds;
|
|
11955
12219
|
return result;
|
|
11956
12220
|
}
|
|
11957
12221
|
});
|
|
@@ -12393,7 +12657,18 @@ async function runSalvage2(args) {
|
|
|
12393
12657
|
const claimed = await loadClaims(lockPaths, { provider: provider.id });
|
|
12394
12658
|
const { orphans, total } = await findOrphans(provider, claimed, { onProgress: diag });
|
|
12395
12659
|
diag(` ${claimed.size} claimed \xB7 ${orphans.length} unclaimed of ${total}`);
|
|
12396
|
-
|
|
12660
|
+
const characterScan = await findOrphanCharacters(provider, claimed, { onProgress: diag });
|
|
12661
|
+
const characterOrphans = characterScan.orphans;
|
|
12662
|
+
if (characterScan.total) {
|
|
12663
|
+
diag(` ${characterOrphans.length} unclaimed character group(s) of ${characterScan.total} character(s)`);
|
|
12664
|
+
}
|
|
12665
|
+
if (characterScan.partlyClaimed) {
|
|
12666
|
+
diag(
|
|
12667
|
+
` ${characterScan.partlyClaimed} character group(s) are partly claimed (a base or state some project uses);
|
|
12668
|
+
they are left out here. Declare their other states and run \`pixelkiln adopt\` instead.`
|
|
12669
|
+
);
|
|
12670
|
+
}
|
|
12671
|
+
if (!orphans.length && !characterOrphans.length) {
|
|
12397
12672
|
if (jsonMode) log("[]");
|
|
12398
12673
|
else log(`
|
|
12399
12674
|
nothing to triage`);
|
|
@@ -12404,9 +12679,10 @@ async function runSalvage2(args) {
|
|
|
12404
12679
|
workspaceProjects.map((p) => resolveProject(workspaceDir, p).manifestPath),
|
|
12405
12680
|
args.claims
|
|
12406
12681
|
);
|
|
12407
|
-
const
|
|
12408
|
-
const
|
|
12409
|
-
|
|
12682
|
+
const objectManifest = withoutCharacterStyles(accountManifest);
|
|
12683
|
+
const { matched, unmatched, elsewhere } = groupOrphansByStyle(orphans, objectManifest, siblings);
|
|
12684
|
+
const multiStyle = Object.keys(objectManifest.styles).length > 1;
|
|
12685
|
+
if (multiStyle && orphans.length) {
|
|
12410
12686
|
diag(`
|
|
12411
12687
|
by style (matched against each style's prompt prefix/suffix):`);
|
|
12412
12688
|
for (const [id, list] of matched) diag(` ${id.padEnd(24)} ${list.length}`);
|
|
@@ -12427,16 +12703,33 @@ async function runSalvage2(args) {
|
|
|
12427
12703
|
out of the sessions below; force them into one style with --style <id>.`
|
|
12428
12704
|
);
|
|
12429
12705
|
}
|
|
12706
|
+
const characterRoutes = routeCharacterOrphans(characterOrphans, accountManifest);
|
|
12707
|
+
if (characterOrphans.length && !characterStyleIds(accountManifest).length) {
|
|
12708
|
+
diag(
|
|
12709
|
+
`
|
|
12710
|
+
${characterOrphans.length} unclaimed character group(s), but this manifest has no character style
|
|
12711
|
+
to import them into. Add a style with "generator": "character" to triage them.`
|
|
12712
|
+
);
|
|
12713
|
+
} else if (characterRoutes.unmatched.length) {
|
|
12714
|
+
diag(
|
|
12715
|
+
`
|
|
12716
|
+
${characterRoutes.unmatched.length} character group(s) don't match any character style's pattern;
|
|
12717
|
+
force them into one with --style <character style id>.`
|
|
12718
|
+
);
|
|
12719
|
+
}
|
|
12430
12720
|
if (args.dryRun) {
|
|
12721
|
+
const listed = [...orphans, ...characterOrphans];
|
|
12431
12722
|
if (args.json) {
|
|
12432
|
-
log(JSON.stringify(
|
|
12723
|
+
log(JSON.stringify(listed, null, 2));
|
|
12433
12724
|
} else {
|
|
12434
|
-
const shown = args.all ?
|
|
12725
|
+
const shown = args.all ? listed : listed.slice(0, 30);
|
|
12435
12726
|
for (const o of shown) {
|
|
12436
|
-
log(
|
|
12727
|
+
log(
|
|
12728
|
+
` ${o.id} ${o.width}x${o.height} ${o.createdAt.slice(0, 10)} ` + (o.kind === "character" ? `[${o.note}] ` : "") + o.prompt.slice(0, 50)
|
|
12729
|
+
);
|
|
12437
12730
|
}
|
|
12438
|
-
if (!args.all &&
|
|
12439
|
-
log(` \u2026 and ${
|
|
12731
|
+
if (!args.all && listed.length > 30) {
|
|
12732
|
+
log(` \u2026 and ${listed.length - 30} more (--all for the full list, --json for machine-readable)`);
|
|
12440
12733
|
}
|
|
12441
12734
|
}
|
|
12442
12735
|
diag(`
|
|
@@ -12457,6 +12750,12 @@ async function runSalvage2(args) {
|
|
|
12457
12750
|
await saveLock(args.lock, lock);
|
|
12458
12751
|
if (n) log(` baselined ${n} imported asset(s) against the manifest`);
|
|
12459
12752
|
};
|
|
12753
|
+
const adoptImported = async (assetIds) => {
|
|
12754
|
+
const resolve = async () => (await resolveSpecs(await loadManifest(args.manifest), { assets: assetIds })).filter((spec) => spec.provider === provider.id);
|
|
12755
|
+
const adopted = await adoptCharacters(provider, await resolve(), lock, args.lock, { onProgress: log, resolve });
|
|
12756
|
+
log(` adopted ${adopted.matched} of ${assetIds.length} imported character asset(s)`);
|
|
12757
|
+
for (const line of adopted.unmatched) log(` not adopted: ${line}`);
|
|
12758
|
+
};
|
|
12460
12759
|
const runOne = async (styleId, list) => {
|
|
12461
12760
|
const style = accountManifest.styles[styleId];
|
|
12462
12761
|
const res = await runSalvage(
|
|
@@ -12476,10 +12775,19 @@ async function runSalvage2(args) {
|
|
|
12476
12775
|
` imported ${res.imported} \xB7 kept ${res.kept} \xB7 tagged-discard ${res.discarded}` + (res.failed ? ` \xB7 failed ${res.failed}` : "")
|
|
12477
12776
|
);
|
|
12478
12777
|
if (res.imported > 0) await rebaseline();
|
|
12778
|
+
if (res.characterAssetIds?.length) await adoptImported(res.characterAssetIds);
|
|
12479
12779
|
return res;
|
|
12480
12780
|
};
|
|
12481
12781
|
if (args.styles.length) {
|
|
12482
|
-
const
|
|
12782
|
+
const styleId = args.styles[0];
|
|
12783
|
+
const isCharacterStyle = characterStyleIds(accountManifest).includes(styleId);
|
|
12784
|
+
const list = isCharacterStyle ? characterOrphans : orphans;
|
|
12785
|
+
if (!list.length) {
|
|
12786
|
+
log(`
|
|
12787
|
+
nothing unclaimed to triage into ${styleId}`);
|
|
12788
|
+
return;
|
|
12789
|
+
}
|
|
12790
|
+
const res = await runOne(styleId, list);
|
|
12483
12791
|
if (res.discarded) {
|
|
12484
12792
|
log(`
|
|
12485
12793
|
Nothing was deleted. To actually remove the discarded objects:`);
|
|
@@ -12488,17 +12796,18 @@ async function runSalvage2(args) {
|
|
|
12488
12796
|
}
|
|
12489
12797
|
return;
|
|
12490
12798
|
}
|
|
12491
|
-
|
|
12799
|
+
const sessions = [...matched, ...characterRoutes.matched];
|
|
12800
|
+
if (!sessions.length) {
|
|
12492
12801
|
log(`
|
|
12493
12802
|
nothing matched a known style; nothing to triage`);
|
|
12494
12803
|
return;
|
|
12495
12804
|
}
|
|
12496
12805
|
log(`
|
|
12497
|
-
${
|
|
12806
|
+
${sessions.length} session(s), one style at a time:`);
|
|
12498
12807
|
let totalDiscarded = 0;
|
|
12499
|
-
for (const [styleId, list] of
|
|
12808
|
+
for (const [styleId, list] of sessions) {
|
|
12500
12809
|
log(`
|
|
12501
|
-
${styleId} (${list.length})`);
|
|
12810
|
+
${styleId} (${list.length}${list[0]?.kind === "character" ? " character group(s)" : ""})`);
|
|
12502
12811
|
const res = await runOne(styleId, list);
|
|
12503
12812
|
totalDiscarded += res.discarded;
|
|
12504
12813
|
}
|
|
@@ -12516,16 +12825,30 @@ async function runPurge(args) {
|
|
|
12516
12825
|
const doomed = [];
|
|
12517
12826
|
for await (const obj of requireList(provider)()) {
|
|
12518
12827
|
if (obj.tags.includes("pixelkiln:discard")) {
|
|
12519
|
-
doomed.push({ id: obj.id, prompt: obj.prompt });
|
|
12828
|
+
doomed.push({ id: obj.id, prompt: obj.prompt, kind: "object" });
|
|
12829
|
+
}
|
|
12830
|
+
}
|
|
12831
|
+
if (provider.listCharacters && provider.deleteCharacter) {
|
|
12832
|
+
for await (const character of provider.listCharacters()) {
|
|
12833
|
+
if (character.tags.includes("pixelkiln:discard")) {
|
|
12834
|
+
const label = character.stateName && character.stateName !== "Idle" ? ` (${character.stateName})` : "";
|
|
12835
|
+
doomed.push({ id: character.id, prompt: `${character.prompt || character.name}${label}`, kind: "character" });
|
|
12836
|
+
}
|
|
12520
12837
|
}
|
|
12521
12838
|
}
|
|
12522
12839
|
if (!doomed.length) {
|
|
12523
12840
|
log(` nothing tagged pixelkiln:discard; run \`pixelkiln salvage\` first`);
|
|
12524
12841
|
return;
|
|
12525
12842
|
}
|
|
12843
|
+
const characters = doomed.filter((d) => d.kind === "character").length;
|
|
12844
|
+
const objects = doomed.length - characters;
|
|
12845
|
+
const counted = [
|
|
12846
|
+
objects ? `${objects} object(s)` : "",
|
|
12847
|
+
characters ? `${characters} character(s)` : ""
|
|
12848
|
+
].filter(Boolean).join(" and ");
|
|
12526
12849
|
log(`
|
|
12527
|
-
${
|
|
12528
|
-
for (const d of doomed.slice(0, 20)) log(` ${d.id} ${d.prompt.slice(0, 60)}`);
|
|
12850
|
+
${counted} tagged for discard:`);
|
|
12851
|
+
for (const d of doomed.slice(0, 20)) log(` ${d.id} ${d.kind === "character" ? "[character] " : ""}${d.prompt.slice(0, 60)}`);
|
|
12529
12852
|
if (doomed.length > 20) log(` \u2026 and ${doomed.length - 20} more`);
|
|
12530
12853
|
if (args.dryRun) {
|
|
12531
12854
|
log(`
|
|
@@ -12536,7 +12859,8 @@ async function runPurge(args) {
|
|
|
12536
12859
|
This permanently deletes them from your ${provider.id} account.`);
|
|
12537
12860
|
log(` Any local files already downloaded are untouched, but the objects`);
|
|
12538
12861
|
log(` and their URLs are gone and cannot be re-downloaded.`);
|
|
12539
|
-
if (
|
|
12862
|
+
if (characters) log(` A deleted character takes its animations with it.`);
|
|
12863
|
+
if (!await confirm(` Delete ${counted}?`, args.yes)) {
|
|
12540
12864
|
log(` aborted`);
|
|
12541
12865
|
return;
|
|
12542
12866
|
}
|
|
@@ -12544,7 +12868,8 @@ async function runPurge(args) {
|
|
|
12544
12868
|
let failed = 0;
|
|
12545
12869
|
for (const d of doomed) {
|
|
12546
12870
|
try {
|
|
12547
|
-
await
|
|
12871
|
+
if (d.kind === "character") await provider.deleteCharacter(d.id);
|
|
12872
|
+
else await requireDelete(provider)(d.id);
|
|
12548
12873
|
deleted++;
|
|
12549
12874
|
} catch (err) {
|
|
12550
12875
|
failed++;
|
|
@@ -12733,11 +13058,21 @@ function assetStyles(raw, asset) {
|
|
|
12733
13058
|
const own = Array.isArray(asset.styles) ? asset.styles : [];
|
|
12734
13059
|
return own.length ? own : Object.keys(raw.styles ?? {});
|
|
12735
13060
|
}
|
|
13061
|
+
function declaredAssets(raw) {
|
|
13062
|
+
const expanded = expandLoopDirections(raw).raw;
|
|
13063
|
+
return new Set(Object.keys(expanded.assets ?? {}));
|
|
13064
|
+
}
|
|
13065
|
+
function undeclaredAsset(raw, assetId) {
|
|
13066
|
+
const shorthand = loopShorthandOf(assetId, expandLoopDirections(raw).families);
|
|
13067
|
+
return new ManifestEditError(
|
|
13068
|
+
shorthand ? `asset "${assetId}" is expanded from "${shorthand}"'s animation.directions; edit "${shorthand}" (it applies to every direction), or write this direction out as its own asset to change it alone` : `asset "${assetId}" is not declared by the manifest`
|
|
13069
|
+
);
|
|
13070
|
+
}
|
|
12736
13071
|
function applyEdit(raw, edit) {
|
|
12737
13072
|
raw.assets ??= {};
|
|
12738
13073
|
if (edit.action === "set-source" || edit.action === "clear-source") {
|
|
12739
13074
|
const asset2 = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
|
|
12740
|
-
if (!asset2) throw
|
|
13075
|
+
if (!asset2) throw undeclaredAsset(raw, edit.assetId);
|
|
12741
13076
|
if (!raw.styles || !Object.hasOwn(raw.styles, edit.styleId)) {
|
|
12742
13077
|
throw new ManifestEditError(`unknown style "${edit.styleId}"`);
|
|
12743
13078
|
}
|
|
@@ -12789,7 +13124,7 @@ function applyEdit(raw, edit) {
|
|
|
12789
13124
|
return;
|
|
12790
13125
|
}
|
|
12791
13126
|
if (edit.action === "add-asset") {
|
|
12792
|
-
if (
|
|
13127
|
+
if (declaredAssets(raw).has(edit.assetId)) {
|
|
12793
13128
|
throw new ManifestEditError(`asset "${edit.assetId}" already exists`);
|
|
12794
13129
|
}
|
|
12795
13130
|
const { styles, tags, ...rest } = edit.asset;
|
|
@@ -12803,20 +13138,20 @@ function applyEdit(raw, edit) {
|
|
|
12803
13138
|
}
|
|
12804
13139
|
asset2.styles = styles;
|
|
12805
13140
|
}
|
|
12806
|
-
if (edit.asset.revision && !
|
|
13141
|
+
if (edit.asset.revision && !declaredAssets(raw).has(edit.asset.revision.from)) {
|
|
12807
13142
|
throw new ManifestEditError(`revision parent "${edit.asset.revision.from}" is not declared by the manifest`);
|
|
12808
13143
|
}
|
|
12809
|
-
if (edit.asset.state && !
|
|
13144
|
+
if (edit.asset.state && !declaredAssets(raw).has(edit.asset.state.of)) {
|
|
12810
13145
|
throw new ManifestEditError(`state parent "${edit.asset.state.of}" is not declared by the manifest`);
|
|
12811
13146
|
}
|
|
12812
|
-
if (edit.asset.animation && !
|
|
13147
|
+
if (edit.asset.animation && !declaredAssets(raw).has(edit.asset.animation.of)) {
|
|
12813
13148
|
throw new ManifestEditError(`animation parent "${edit.asset.animation.of}" is not declared by the manifest`);
|
|
12814
13149
|
}
|
|
12815
13150
|
raw.assets[edit.assetId] = asset2;
|
|
12816
13151
|
return;
|
|
12817
13152
|
}
|
|
12818
13153
|
const asset = Object.hasOwn(raw.assets, edit.assetId) ? raw.assets[edit.assetId] : void 0;
|
|
12819
|
-
if (!asset) throw
|
|
13154
|
+
if (!asset) throw undeclaredAsset(raw, edit.assetId);
|
|
12820
13155
|
const { patch } = edit;
|
|
12821
13156
|
if (patch.prompt !== void 0) asset.prompt = patch.prompt;
|
|
12822
13157
|
if (patch.promptForStyle) {
|
|
@@ -13604,6 +13939,7 @@ async function describeQuality(media, root, inspection) {
|
|
|
13604
13939
|
}
|
|
13605
13940
|
async function buildGallerySnapshot(opts) {
|
|
13606
13941
|
const { loaded, specs, lock } = opts;
|
|
13942
|
+
const filter = opts.filter?.assets?.length ? { ...opts.filter, assets: expandAssetFilter(opts.filter.assets, loaded.loopFamilies) } : opts.filter;
|
|
13607
13943
|
const root = loaded.root;
|
|
13608
13944
|
const media = /* @__PURE__ */ new Map();
|
|
13609
13945
|
const plan = await buildPlan(specs, lock);
|
|
@@ -13748,7 +14084,7 @@ async function buildGallerySnapshot(opts) {
|
|
|
13748
14084
|
for (const [key, entry] of Object.entries(lock.entries)) {
|
|
13749
14085
|
if (shown.has(key)) continue;
|
|
13750
14086
|
if (isDeclared(loaded, entry.styleId, entry.assetId)) continue;
|
|
13751
|
-
if (!matchesFilter(
|
|
14087
|
+
if (!matchesFilter(filter, entry.styleId, entry.assetId)) continue;
|
|
13752
14088
|
const outputs = await Promise.all(
|
|
13753
14089
|
entry.outputs.map(
|
|
13754
14090
|
(output) => describeOutput(media, root, resolveOutputPath(output.path, root), output)
|
|
@@ -13914,7 +14250,7 @@ async function buildWorkspaceGallerySnapshot(opts) {
|
|
|
13914
14250
|
try {
|
|
13915
14251
|
const loaded = await loadManifest(manifestPath);
|
|
13916
14252
|
const styleFilter = filter.styles.filter((id) => loaded.manifest.styles[id]);
|
|
13917
|
-
const assetFilter = filter.assets.filter((id) => loaded.manifest.assets[id]);
|
|
14253
|
+
const assetFilter = expandAssetFilter(filter.assets, loaded.loopFamilies).filter((id) => loaded.manifest.assets[id]);
|
|
13918
14254
|
const excluded = filter.styles.length > 0 && styleFilter.length === 0 || filter.assets.length > 0 && assetFilter.length === 0;
|
|
13919
14255
|
const lock = await loadLock(lockPath);
|
|
13920
14256
|
let projectItems = [];
|