@melite/formats 0.0.0-stage → 0.1.4-alpha
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/LICENSE +557 -0
- package/lib/b64.d.ts +2 -0
- package/lib/b64.js +7 -0
- package/lib/bodyPresets.d.ts +40 -0
- package/lib/bodyPresets.js +77 -0
- package/lib/capabilityCards.d.ts +54 -0
- package/lib/capabilityCards.js +127 -0
- package/lib/captionSplitter.d.ts +49 -0
- package/lib/captionSplitter.js +172 -0
- package/lib/cardMutations.d.ts +52 -0
- package/lib/cardMutations.js +67 -0
- package/lib/downloads.d.ts +18 -0
- package/lib/downloads.js +64 -0
- package/lib/extractVideoFrame.d.ts +23 -0
- package/lib/extractVideoFrame.js +114 -0
- package/lib/glbLoad.d.ts +54 -0
- package/lib/glbLoad.js +96 -0
- package/lib/glbSkin.d.ts +14 -0
- package/lib/glbSkin.js +124 -0
- package/lib/handPose.d.ts +46 -0
- package/lib/handPose.js +122 -0
- package/lib/index.d.ts +26 -0
- package/lib/index.js +26 -0
- package/lib/keyframeExtract.d.ts +46 -0
- package/lib/keyframeExtract.js +89 -0
- package/lib/maskPrep.d.ts +88 -0
- package/lib/maskPrep.js +386 -0
- package/lib/motionConstraints.d.ts +237 -0
- package/lib/motionConstraints.js +369 -0
- package/lib/motionEnvelope.d.ts +56 -0
- package/lib/motionEnvelope.js +59 -0
- package/lib/motionNpz.d.ts +120 -0
- package/lib/motionNpz.js +497 -0
- package/lib/npz.d.ts +24 -0
- package/lib/npz.js +158 -0
- package/lib/openPoseCanvas.d.ts +17 -0
- package/lib/openPoseCanvas.js +90 -0
- package/lib/pngMeta.d.ts +42 -0
- package/lib/pngMeta.js +212 -0
- package/lib/pngMetaInfo.d.ts +14 -0
- package/lib/pngMetaInfo.js +127 -0
- package/lib/poseCodec.d.ts +20 -0
- package/lib/poseCodec.js +61 -0
- package/lib/poseTransforms.d.ts +73 -0
- package/lib/poseTransforms.js +237 -0
- package/lib/poserSkin.d.ts +75 -0
- package/lib/poserSkin.js +126 -0
- package/lib/prompt-tags.json +132 -0
- package/lib/promptTags.d.ts +42 -0
- package/lib/promptTags.js +116 -0
- package/lib/psdExport.d.ts +28 -0
- package/lib/psdExport.js +272 -0
- package/lib/readFile.d.ts +9 -0
- package/lib/readFile.js +16 -0
- package/lib/renderSnapshot.d.ts +34 -0
- package/lib/renderSnapshot.js +55 -0
- package/lib/somaSkin.d.ts +82 -0
- package/lib/somaSkin.js +267 -0
- package/lib/textToPose.d.ts +388 -0
- package/lib/textToPose.js +604 -0
- package/lib/uploadToEstate.d.ts +25 -0
- package/lib/uploadToEstate.js +97 -0
- package/package.json +148 -4
- package/README.md +0 -3
package/lib/npz.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npz — minimal browser-side NPZ (NumPy ZIP) reader.
|
|
3
|
+
*
|
|
4
|
+
* NPZ files are ZIP archives whose entries are .npy arrays. NumPy's
|
|
5
|
+
* np.savez_compressed (what Kimodo emits) uses DEFLATE (ZIP method 8);
|
|
6
|
+
* np.savez uses stored (method 0). Both are handled here via the browser's
|
|
7
|
+
* native DecompressionStream — no dependency.
|
|
8
|
+
*
|
|
9
|
+
* Only the subset needed for motion preview is implemented: little-endian
|
|
10
|
+
* float32 / float64 arrays, 1-D through N-D. Integers / strings / big-endian
|
|
11
|
+
* are rejected with a clear error.
|
|
12
|
+
*/
|
|
13
|
+
export type NpyData = Float32Array | Int32Array | BigInt64Array | Uint32Array;
|
|
14
|
+
export interface NpyArray {
|
|
15
|
+
shape: number[];
|
|
16
|
+
data: NpyData;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse an NPZ file and return a map of array-name → {shape, data}.
|
|
20
|
+
* Entry names have their `.npy` extension stripped (so `posed_joints.npy`
|
|
21
|
+
* is keyed as `"posed_joints"`).
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseNpz(bytes: Uint8Array): Promise<Map<string, NpyArray>>;
|
|
24
|
+
//# sourceMappingURL=npz.d.ts.map
|
package/lib/npz.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npz — minimal browser-side NPZ (NumPy ZIP) reader.
|
|
3
|
+
*
|
|
4
|
+
* NPZ files are ZIP archives whose entries are .npy arrays. NumPy's
|
|
5
|
+
* np.savez_compressed (what Kimodo emits) uses DEFLATE (ZIP method 8);
|
|
6
|
+
* np.savez uses stored (method 0). Both are handled here via the browser's
|
|
7
|
+
* native DecompressionStream — no dependency.
|
|
8
|
+
*
|
|
9
|
+
* Only the subset needed for motion preview is implemented: little-endian
|
|
10
|
+
* float32 / float64 arrays, 1-D through N-D. Integers / strings / big-endian
|
|
11
|
+
* are rejected with a clear error.
|
|
12
|
+
*/
|
|
13
|
+
// ── ZIP signatures (little-endian on disk: 50 4b 0x 0x) ───────────────────
|
|
14
|
+
const CDFH_SIG = 0x02014b50; // central directory file header
|
|
15
|
+
// LFH_SIG isn't checked — we trust the central directory's local-header offset.
|
|
16
|
+
function readU16(d, o) {
|
|
17
|
+
return d[o] | (d[o + 1] << 8);
|
|
18
|
+
}
|
|
19
|
+
function readU32(d, o) {
|
|
20
|
+
return ((d[o] | (d[o + 1] << 8) | (d[o + 2] << 16) | (d[o + 3] << 24)) >>> 0);
|
|
21
|
+
}
|
|
22
|
+
async function inflateRaw(compressed) {
|
|
23
|
+
// 'deflate-raw' = ZIP method 8 (raw DEFLATE, no zlib header/checksum).
|
|
24
|
+
// Supported in Chrome 103+, Firefox 113+, Safari 16.4+ — fine for a
|
|
25
|
+
// WebGL dev tool. Blob → stream → pipeThrough → arrayBuffer.
|
|
26
|
+
const ds = new DecompressionStream("deflate-raw");
|
|
27
|
+
const stream = new Blob([compressed]).stream().pipeThrough(ds);
|
|
28
|
+
const buf = await new Response(stream).arrayBuffer();
|
|
29
|
+
return new Uint8Array(buf);
|
|
30
|
+
}
|
|
31
|
+
/** Parse a single .npy file from its raw (decompressed) bytes. */
|
|
32
|
+
function parseNpy(bytes) {
|
|
33
|
+
// Magic: \x93 N U M P Y (6 bytes), then version major/minor, then header.
|
|
34
|
+
if (bytes.length < 10 ||
|
|
35
|
+
bytes[0] !== 0x93 || bytes[1] !== 0x4e || bytes[2] !== 0x55 ||
|
|
36
|
+
bytes[3] !== 0x4d || bytes[4] !== 0x50 || bytes[5] !== 0x59) {
|
|
37
|
+
throw new Error("Invalid .npy magic bytes");
|
|
38
|
+
}
|
|
39
|
+
// bytes[0..5] = magic, bytes[6] = major version, bytes[7] = minor version.
|
|
40
|
+
const ver = bytes[6];
|
|
41
|
+
let headerLen;
|
|
42
|
+
let headerOff;
|
|
43
|
+
if (ver <= 1) {
|
|
44
|
+
headerLen = readU16(bytes, 8);
|
|
45
|
+
headerOff = 10;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
headerLen = readU32(bytes, 8);
|
|
49
|
+
headerOff = 12;
|
|
50
|
+
}
|
|
51
|
+
const headerBytes = bytes.subarray(headerOff, headerOff + headerLen);
|
|
52
|
+
const header = new TextDecoder().decode(headerBytes);
|
|
53
|
+
const dataBytes = bytes.subarray(headerOff + headerLen);
|
|
54
|
+
const shapeMatch = header.match(/'shape':\s*\(([^)]*)\)/);
|
|
55
|
+
const dtypeMatch = header.match(/'descr':\s*'([^']+)'/);
|
|
56
|
+
if (!shapeMatch || !dtypeMatch) {
|
|
57
|
+
throw new Error(`Unparseable .npy header: ${header.slice(0, 120)}`);
|
|
58
|
+
}
|
|
59
|
+
const shape = shapeMatch[1]
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter((s) => s.length > 0)
|
|
63
|
+
.map(Number);
|
|
64
|
+
const dtype = dtypeMatch[1].trim();
|
|
65
|
+
// Copy the data bytes into a fresh, 4/8-byte-aligned ArrayBuffer so the
|
|
66
|
+
// typed-array view is always valid (the .npy data offset may not share
|
|
67
|
+
// alignment with the enclosing NPZ buffer).
|
|
68
|
+
const copy = new Uint8Array(dataBytes.length);
|
|
69
|
+
copy.set(dataBytes);
|
|
70
|
+
if (dtype === "<f4" || dtype === "|f4" || dtype === "=f4") {
|
|
71
|
+
return { shape, data: new Float32Array(copy.buffer, 0, Math.floor(copy.length / 4)) };
|
|
72
|
+
}
|
|
73
|
+
if (dtype === "<f8" || dtype === "|f8" || dtype === "=f8") {
|
|
74
|
+
const f64 = new Float64Array(copy.buffer, 0, Math.floor(copy.length / 8));
|
|
75
|
+
return { shape, data: new Float32Array(f64) };
|
|
76
|
+
}
|
|
77
|
+
// int32 (lbs_indices in the SOMA skin) and int64 (faces) — needed for the
|
|
78
|
+
// neutral soma skin NPZ. Uint32Array for int32 (face/idx values are
|
|
79
|
+
// non-negative), BigInt64Array for int64 (face indices can exceed 2^31 in
|
|
80
|
+
// large meshes; here max is 18056 so it fits, but keep the lossless path).
|
|
81
|
+
if (dtype === "<i4" || dtype === "|i4" || dtype === "=i4") {
|
|
82
|
+
const i32 = new Int32Array(copy.buffer, 0, Math.floor(copy.length / 4));
|
|
83
|
+
return { shape, data: new Uint32Array(i32) };
|
|
84
|
+
}
|
|
85
|
+
if (dtype === "<i8" || dtype === "|i8" || dtype === "=i8") {
|
|
86
|
+
return { shape, data: new BigInt64Array(copy.buffer, 0, Math.floor(copy.length / 8)) };
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`Unsupported .npy dtype "${dtype}" (float32/64 + int32/64 supported)`);
|
|
89
|
+
}
|
|
90
|
+
/** Find the End-of-Central-Directory record, searching backward from EOF. */
|
|
91
|
+
function findEocd(bytes) {
|
|
92
|
+
const min = Math.max(0, bytes.length - 22 - 65535);
|
|
93
|
+
for (let i = bytes.length - 22; i >= min; i--) {
|
|
94
|
+
if (bytes[i] === 0x50 && bytes[i + 1] === 0x4b &&
|
|
95
|
+
bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06) {
|
|
96
|
+
return i;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse an NPZ file and return a map of array-name → {shape, data}.
|
|
103
|
+
* Entry names have their `.npy` extension stripped (so `posed_joints.npy`
|
|
104
|
+
* is keyed as `"posed_joints"`).
|
|
105
|
+
*/
|
|
106
|
+
export async function parseNpz(bytes) {
|
|
107
|
+
const out = new Map();
|
|
108
|
+
const eocd = findEocd(bytes);
|
|
109
|
+
if (eocd < 0)
|
|
110
|
+
throw new Error("NPZ parse failed: End-of-Central-Directory not found");
|
|
111
|
+
const cdEntries = readU16(bytes, eocd + 10);
|
|
112
|
+
const cdOff = readU32(bytes, eocd + 16);
|
|
113
|
+
let off = cdOff;
|
|
114
|
+
for (let i = 0; i < cdEntries; i++) {
|
|
115
|
+
if (readU32(bytes, off) !== CDFH_SIG)
|
|
116
|
+
break;
|
|
117
|
+
const method = readU16(bytes, off + 10);
|
|
118
|
+
const compSize = readU32(bytes, off + 20);
|
|
119
|
+
const nameLen = readU16(bytes, off + 28);
|
|
120
|
+
const extraLen = readU16(bytes, off + 30);
|
|
121
|
+
const commentLen = readU16(bytes, off + 32);
|
|
122
|
+
const localOff = readU32(bytes, off + 42);
|
|
123
|
+
const name = new TextDecoder().decode(bytes.subarray(off + 46, off + 46 + nameLen));
|
|
124
|
+
// Read the local file header to find the true data offset (the central
|
|
125
|
+
// directory's compressed-size is authoritative; the local header's own
|
|
126
|
+
// size fields can be zero when the ZIP was streamed).
|
|
127
|
+
const lfhNameLen = readU16(bytes, localOff + 26);
|
|
128
|
+
const lfhExtraLen = readU16(bytes, localOff + 28);
|
|
129
|
+
const dataOff = localOff + 30 + lfhNameLen + lfhExtraLen;
|
|
130
|
+
const compressed = bytes.subarray(dataOff, dataOff + compSize);
|
|
131
|
+
let raw;
|
|
132
|
+
if (method === 0) {
|
|
133
|
+
raw = compressed; // stored, no compression
|
|
134
|
+
}
|
|
135
|
+
else if (method === 8) {
|
|
136
|
+
raw = await inflateRaw(compressed);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
throw new Error(`NPZ entry "${name}": unsupported compression method ${method}`);
|
|
140
|
+
}
|
|
141
|
+
if (name.endsWith(".npy")) {
|
|
142
|
+
const key = name.slice(0, -4); // strip ".npy"
|
|
143
|
+
// Skip entries with dtypes we don't handle (bool flags, int indices,
|
|
144
|
+
// string arrays — Kimodo packs metadata alongside the float arrays).
|
|
145
|
+
// The caller asks for specific keys (posed_joints, fps) and gets a
|
|
146
|
+
// clear error if those are missing; non-float metadata is irrelevant.
|
|
147
|
+
try {
|
|
148
|
+
out.set(key, parseNpy(raw));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* unsupported dtype — skip */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
off += 46 + nameLen + extraLen + commentLen;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=npz.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** openPoseCanvas — the COCO-18 stick-figure renderer (pure DOM canvas,
|
|
2
|
+
* ex-lib/text-to-pose). Fetch-free by law. */
|
|
3
|
+
/**
|
|
4
|
+
* Render COCO-18 keypoints as an OpenPose-style stick figure on a canvas.
|
|
5
|
+
* Returns a PNG Blob.
|
|
6
|
+
*
|
|
7
|
+
* @param keypoints Flat array of 54 numbers (18 joints × [x, y, confidence])
|
|
8
|
+
* @param opts width, height, background, lineWidth, pointRadius
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderOpenPoseCanvas(keypoints: number[], opts?: {
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
background?: string | "transparent";
|
|
14
|
+
lineWidth?: number;
|
|
15
|
+
pointRadius?: number;
|
|
16
|
+
}): Blob;
|
|
17
|
+
//# sourceMappingURL=openPoseCanvas.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** openPoseCanvas — the COCO-18 stick-figure renderer (pure DOM canvas,
|
|
2
|
+
* ex-lib/text-to-pose). Fetch-free by law. */
|
|
3
|
+
// ── OpenPose canvas renderer ──────────────────────────────────────────────
|
|
4
|
+
// Draws the COCO-18 stick figure on a 2D canvas with standard OpenPose colors.
|
|
5
|
+
// Used for "OpenPose mode" export — alternative to the 3D mesh render.
|
|
6
|
+
const OPENPOSE_SKELETON = [
|
|
7
|
+
// Limbs (purple/pink)
|
|
8
|
+
[0, 1], [0, 14], [0, 15], [1, 2], [1, 5],
|
|
9
|
+
[2, 3], [3, 4], [5, 6], [6, 7],
|
|
10
|
+
[8, 9], [9, 10], [11, 12], [12, 13],
|
|
11
|
+
[14, 16], [15, 17],
|
|
12
|
+
// Body trunk
|
|
13
|
+
[1, 8], [1, 11], [8, 11],
|
|
14
|
+
];
|
|
15
|
+
const OPENPOSE_COLORS = [
|
|
16
|
+
"#8B00FF", "#FF6B00", "#00FF6B", "#FFFF00", "#FFFFFF",
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Render COCO-18 keypoints as an OpenPose-style stick figure on a canvas.
|
|
20
|
+
* Returns a PNG Blob.
|
|
21
|
+
*
|
|
22
|
+
* @param keypoints Flat array of 54 numbers (18 joints × [x, y, confidence])
|
|
23
|
+
* @param opts width, height, background, lineWidth, pointRadius
|
|
24
|
+
*/
|
|
25
|
+
export function renderOpenPoseCanvas(keypoints, opts = {}) {
|
|
26
|
+
const w = opts.width ?? 1024;
|
|
27
|
+
const h = opts.height ?? 1024;
|
|
28
|
+
const bg = opts.background ?? "transparent";
|
|
29
|
+
const lw = opts.lineWidth ?? 4;
|
|
30
|
+
const pr = opts.pointRadius ?? 5;
|
|
31
|
+
const canvas = document.createElement("canvas");
|
|
32
|
+
canvas.width = w;
|
|
33
|
+
canvas.height = h;
|
|
34
|
+
const ctx = canvas.getContext("2d");
|
|
35
|
+
// Background
|
|
36
|
+
if (bg === "transparent") {
|
|
37
|
+
ctx.clearRect(0, 0, w, h);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
ctx.fillStyle = bg;
|
|
41
|
+
ctx.fillRect(0, 0, w, h);
|
|
42
|
+
}
|
|
43
|
+
// Extract joint positions (keypoints are normalized 0..1 in SOMA space)
|
|
44
|
+
// pose-transforms renderCustomPose sends raw flat keypoints where
|
|
45
|
+
// x,y are in pixel coords already (1024×1024 space).
|
|
46
|
+
const pts = [];
|
|
47
|
+
for (let i = 0; i < 18; i++) {
|
|
48
|
+
const x = keypoints[i * 3];
|
|
49
|
+
const y = keypoints[i * 3 + 1];
|
|
50
|
+
const c = keypoints[i * 3 + 2];
|
|
51
|
+
pts.push({
|
|
52
|
+
x: typeof x === "number" && isFinite(x) ? (x / 1024) * w : -9999,
|
|
53
|
+
y: typeof y === "number" && isFinite(y) ? (y / 1024) * h : -9999,
|
|
54
|
+
c: typeof c === "number" ? c : 0,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// Draw limbs
|
|
58
|
+
ctx.lineCap = "round";
|
|
59
|
+
ctx.lineJoin = "round";
|
|
60
|
+
OPENPOSE_SKELETON.forEach(([a, b], idx) => {
|
|
61
|
+
const pa = pts[a], pb = pts[b];
|
|
62
|
+
if (pa.c < 0.1 || pb.c < 0.1)
|
|
63
|
+
return;
|
|
64
|
+
if (pa.x < 0 || pb.x < 0)
|
|
65
|
+
return;
|
|
66
|
+
ctx.strokeStyle = OPENPOSE_COLORS[idx % OPENPOSE_COLORS.length];
|
|
67
|
+
ctx.lineWidth = lw;
|
|
68
|
+
ctx.beginPath();
|
|
69
|
+
ctx.moveTo(pa.x, pa.y);
|
|
70
|
+
ctx.lineTo(pb.x, pb.y);
|
|
71
|
+
ctx.stroke();
|
|
72
|
+
});
|
|
73
|
+
// Draw joints
|
|
74
|
+
pts.forEach((p) => {
|
|
75
|
+
if (p.c < 0.1 || p.x < 0)
|
|
76
|
+
return;
|
|
77
|
+
ctx.fillStyle = "#00FFFF";
|
|
78
|
+
ctx.beginPath();
|
|
79
|
+
ctx.arc(p.x, p.y, pr, 0, Math.PI * 2);
|
|
80
|
+
ctx.fill();
|
|
81
|
+
});
|
|
82
|
+
// Return as blob (sync via toBlob needs callback, but we can use dataURL)
|
|
83
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
84
|
+
const bin = atob(dataUrl.split(",")[1]);
|
|
85
|
+
const bytes = new Uint8Array(bin.length);
|
|
86
|
+
for (let i = 0; i < bin.length; i++)
|
|
87
|
+
bytes[i] = bin.charCodeAt(i);
|
|
88
|
+
return new Blob([bytes], { type: "image/png" });
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=openPoseCanvas.js.map
|
package/lib/pngMeta.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG tEXt chunk utilities — embed and parse metadata in PNG files.
|
|
3
|
+
*
|
|
4
|
+
* Compatible with the chunk layout ComfyUI uses (keys `workflow` and
|
|
5
|
+
* `prompt`), so images saved from Pose Studio can be dragged back in
|
|
6
|
+
* to recover the full generation state, and vice-versa.
|
|
7
|
+
*
|
|
8
|
+
* PNG chunk grammar:
|
|
9
|
+
* chunk = [length:u32be][type:4 bytes][data:length][crc:u32be]
|
|
10
|
+
* tEXt data = keyword (1-79 bytes, Latin-1) + 0x00 + text (Latin-1)
|
|
11
|
+
*
|
|
12
|
+
* We insert metadata chunks immediately after IHDR so they appear
|
|
13
|
+
* before IDAT — readers that scan only the file head will find them.
|
|
14
|
+
*
|
|
15
|
+
* JSON values are forced to ASCII (via \\uXXXX escapes for any code
|
|
16
|
+
* point above 0x7F) because the tEXt chunk is Latin-1: a raw UTF-8
|
|
17
|
+
* multibyte sequence would be mis-decoded as one byte == one char.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Force JSON to pure ASCII by escaping any code point above 0x7F as
|
|
21
|
+
* \uXXXX. Keeps tEXt payloads spec-compliant (Latin-1) while preserving
|
|
22
|
+
* the full Unicode content for recovery.
|
|
23
|
+
*/
|
|
24
|
+
export declare function asciiJsonStringify(value: unknown): string;
|
|
25
|
+
/**
|
|
26
|
+
* Embed key/value pairs as tEXt chunks in a PNG data: URL. Chunks are
|
|
27
|
+
* inserted immediately after IHDR. Returns the original URL unchanged
|
|
28
|
+
* if the input is not a valid PNG data URL or metadata is empty.
|
|
29
|
+
*/
|
|
30
|
+
export declare function embedPngText(dataUrl: string, metadata: Record<string, string>): string;
|
|
31
|
+
/**
|
|
32
|
+
* Parse every tEXt chunk in a PNG data: URL into a {key: value} record.
|
|
33
|
+
* Also surfaces iTXt (uncompressed, UTF-8) if present. Last write wins
|
|
34
|
+
* on duplicate keys. Returns {} for non-PNG or unreadable input.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parsePngText(dataUrl: string): Record<string, string>;
|
|
37
|
+
/**
|
|
38
|
+
* Read the embedded `workflow` (or fallback `prompt`/`parameters`) JSON
|
|
39
|
+
* from a PNG data: URL. Returns the parsed object or null.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readWorkflowFromPng(dataUrl: string): unknown | null;
|
|
42
|
+
//# sourceMappingURL=pngMeta.d.ts.map
|
package/lib/pngMeta.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG tEXt chunk utilities — embed and parse metadata in PNG files.
|
|
3
|
+
*
|
|
4
|
+
* Compatible with the chunk layout ComfyUI uses (keys `workflow` and
|
|
5
|
+
* `prompt`), so images saved from Pose Studio can be dragged back in
|
|
6
|
+
* to recover the full generation state, and vice-versa.
|
|
7
|
+
*
|
|
8
|
+
* PNG chunk grammar:
|
|
9
|
+
* chunk = [length:u32be][type:4 bytes][data:length][crc:u32be]
|
|
10
|
+
* tEXt data = keyword (1-79 bytes, Latin-1) + 0x00 + text (Latin-1)
|
|
11
|
+
*
|
|
12
|
+
* We insert metadata chunks immediately after IHDR so they appear
|
|
13
|
+
* before IDAT — readers that scan only the file head will find them.
|
|
14
|
+
*
|
|
15
|
+
* JSON values are forced to ASCII (via \\uXXXX escapes for any code
|
|
16
|
+
* point above 0x7F) because the tEXt chunk is Latin-1: a raw UTF-8
|
|
17
|
+
* multibyte sequence would be mis-decoded as one byte == one char.
|
|
18
|
+
*/
|
|
19
|
+
const PNG_SIG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
20
|
+
// ── CRC32 (IEEE 802.3 / PNG polynomial 0xedb88320) ─────────────────────────
|
|
21
|
+
const CRC_TABLE = (() => {
|
|
22
|
+
const t = new Uint32Array(256);
|
|
23
|
+
for (let n = 0; n < 256; n++) {
|
|
24
|
+
let c = n;
|
|
25
|
+
for (let k = 0; k < 8; k++) {
|
|
26
|
+
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
27
|
+
}
|
|
28
|
+
t[n] = c >>> 0;
|
|
29
|
+
}
|
|
30
|
+
return t;
|
|
31
|
+
})();
|
|
32
|
+
function crc32(bytes) {
|
|
33
|
+
let c = 0xffffffff;
|
|
34
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
35
|
+
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
36
|
+
}
|
|
37
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
38
|
+
}
|
|
39
|
+
function u32be(n) {
|
|
40
|
+
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
|
41
|
+
}
|
|
42
|
+
function buildChunk(type, data) {
|
|
43
|
+
const typeBytes = new Uint8Array(4);
|
|
44
|
+
for (let i = 0; i < 4; i++)
|
|
45
|
+
typeBytes[i] = type.charCodeAt(i);
|
|
46
|
+
const crcInput = new Uint8Array(typeBytes.length + data.length);
|
|
47
|
+
crcInput.set(typeBytes, 0);
|
|
48
|
+
crcInput.set(data, typeBytes.length);
|
|
49
|
+
const out = new Uint8Array(12 + data.length);
|
|
50
|
+
out.set(u32be(data.length), 0);
|
|
51
|
+
out.set(typeBytes, 4);
|
|
52
|
+
out.set(data, 8);
|
|
53
|
+
out.set(u32be(crc32(crcInput)), 8 + data.length);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function encodeLatin1(s) {
|
|
57
|
+
const out = new Uint8Array(s.length);
|
|
58
|
+
for (let i = 0; i < out.length; i++)
|
|
59
|
+
out[i] = s.charCodeAt(i) & 0xff;
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
// ── data: URL ⇄ bytes ──────────────────────────────────────────────────────
|
|
63
|
+
function dataUrlToBytes(dataUrl) {
|
|
64
|
+
const comma = dataUrl.indexOf(",");
|
|
65
|
+
if (comma < 0)
|
|
66
|
+
return null;
|
|
67
|
+
const b64 = dataUrl.slice(comma + 1);
|
|
68
|
+
let bin;
|
|
69
|
+
try {
|
|
70
|
+
bin = atob(b64);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null; // malformed base64 — caller handles null gracefully
|
|
74
|
+
}
|
|
75
|
+
const out = new Uint8Array(bin.length);
|
|
76
|
+
for (let i = 0; i < out.length; i++)
|
|
77
|
+
out[i] = bin.charCodeAt(i);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
function bytesToDataUrl(bytes) {
|
|
81
|
+
let binary = "";
|
|
82
|
+
// Chunked to avoid call-stack limits on large PNGs.
|
|
83
|
+
const CHUNK = 0x8000;
|
|
84
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
85
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, Math.min(i + CHUNK, bytes.length)));
|
|
86
|
+
}
|
|
87
|
+
return `data:image/png;base64,${btoa(binary)}`;
|
|
88
|
+
}
|
|
89
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
90
|
+
/**
|
|
91
|
+
* Force JSON to pure ASCII by escaping any code point above 0x7F as
|
|
92
|
+
* \uXXXX. Keeps tEXt payloads spec-compliant (Latin-1) while preserving
|
|
93
|
+
* the full Unicode content for recovery.
|
|
94
|
+
*/
|
|
95
|
+
export function asciiJsonStringify(value) {
|
|
96
|
+
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => {
|
|
97
|
+
const hex = c.charCodeAt(0).toString(16);
|
|
98
|
+
return "\\u" + "0".repeat(4 - hex.length) + hex;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Embed key/value pairs as tEXt chunks in a PNG data: URL. Chunks are
|
|
103
|
+
* inserted immediately after IHDR. Returns the original URL unchanged
|
|
104
|
+
* if the input is not a valid PNG data URL or metadata is empty.
|
|
105
|
+
*/
|
|
106
|
+
export function embedPngText(dataUrl, metadata) {
|
|
107
|
+
if (Object.keys(metadata).length === 0)
|
|
108
|
+
return dataUrl;
|
|
109
|
+
const bytes = dataUrlToBytes(dataUrl);
|
|
110
|
+
if (!bytes || bytes.length < 8)
|
|
111
|
+
return dataUrl;
|
|
112
|
+
for (let i = 0; i < 8; i++)
|
|
113
|
+
if (bytes[i] !== PNG_SIG[i])
|
|
114
|
+
return dataUrl;
|
|
115
|
+
// IHDR is always the first chunk and always 13 bytes of data.
|
|
116
|
+
const ihdrLen = (bytes[8] << 24) | (bytes[9] << 16) | (bytes[10] << 8) | bytes[11];
|
|
117
|
+
const ihdrEnd = 8 + 8 + ihdrLen + 4; // sig + len+type + data + crc
|
|
118
|
+
const chunks = [];
|
|
119
|
+
for (const [key, val] of Object.entries(metadata)) {
|
|
120
|
+
if (!key)
|
|
121
|
+
continue;
|
|
122
|
+
const payload = encodeLatin1(`${key}\0${val}`);
|
|
123
|
+
chunks.push(buildChunk("tEXt", payload));
|
|
124
|
+
}
|
|
125
|
+
if (chunks.length === 0)
|
|
126
|
+
return dataUrl;
|
|
127
|
+
const before = bytes.subarray(0, ihdrEnd);
|
|
128
|
+
const after = bytes.subarray(ihdrEnd);
|
|
129
|
+
const total = before.length + after.length + chunks.reduce((s, c) => s + c.length, 0);
|
|
130
|
+
const out = new Uint8Array(total);
|
|
131
|
+
let off = 0;
|
|
132
|
+
out.set(before, off);
|
|
133
|
+
off += before.length;
|
|
134
|
+
for (const c of chunks) {
|
|
135
|
+
out.set(c, off);
|
|
136
|
+
off += c.length;
|
|
137
|
+
}
|
|
138
|
+
out.set(after, off);
|
|
139
|
+
return bytesToDataUrl(out);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse every tEXt chunk in a PNG data: URL into a {key: value} record.
|
|
143
|
+
* Also surfaces iTXt (uncompressed, UTF-8) if present. Last write wins
|
|
144
|
+
* on duplicate keys. Returns {} for non-PNG or unreadable input.
|
|
145
|
+
*/
|
|
146
|
+
export function parsePngText(dataUrl) {
|
|
147
|
+
const bytes = dataUrlToBytes(dataUrl);
|
|
148
|
+
if (!bytes || bytes.length < 8)
|
|
149
|
+
return {};
|
|
150
|
+
for (let i = 0; i < 8; i++)
|
|
151
|
+
if (bytes[i] !== PNG_SIG[i])
|
|
152
|
+
return {};
|
|
153
|
+
const out = {};
|
|
154
|
+
const latin1 = new TextDecoder("latin1");
|
|
155
|
+
const utf8 = new TextDecoder("utf-8");
|
|
156
|
+
let off = 8;
|
|
157
|
+
while (off + 8 <= bytes.length) {
|
|
158
|
+
const len = (bytes[off] << 24) | (bytes[off + 1] << 16) | (bytes[off + 2] << 8) | bytes[off + 3];
|
|
159
|
+
const type = latin1.decode(bytes.subarray(off + 4, off + 8));
|
|
160
|
+
const dataStart = off + 8;
|
|
161
|
+
const dataEnd = dataStart + len;
|
|
162
|
+
if (dataEnd > bytes.length)
|
|
163
|
+
break;
|
|
164
|
+
if (type === "tEXt") {
|
|
165
|
+
const payload = latin1.decode(bytes.subarray(dataStart, dataEnd));
|
|
166
|
+
const nul = payload.indexOf("\0");
|
|
167
|
+
if (nul > 0)
|
|
168
|
+
out[payload.slice(0, nul)] = payload.slice(nul + 1);
|
|
169
|
+
}
|
|
170
|
+
else if (type === "iTXt") {
|
|
171
|
+
// iTXt: keyword\0 compressionFlag(1) compressionMethod(1) lang\0 translated\0 text(UTF-8)
|
|
172
|
+
const payload = bytes.subarray(dataStart, dataEnd);
|
|
173
|
+
const nul = payload.indexOf(0);
|
|
174
|
+
if (nul > 0) {
|
|
175
|
+
const key = latin1.decode(payload.subarray(0, nul));
|
|
176
|
+
const flag = payload[nul + 1];
|
|
177
|
+
// Skip compression flag + method bytes, language tag, translated keyword
|
|
178
|
+
let p = nul + 3;
|
|
179
|
+
const nul2 = payload.indexOf(0, p);
|
|
180
|
+
const nul3 = nul2 >= 0 ? payload.indexOf(0, nul2 + 1) : -1;
|
|
181
|
+
if (nul3 >= 0 && flag === 0) {
|
|
182
|
+
out[key] = utf8.decode(payload.subarray(nul3 + 1));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else if (type === "IEND") {
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
off = dataEnd + 4;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Read the embedded `workflow` (or fallback `prompt`/`parameters`) JSON
|
|
195
|
+
* from a PNG data: URL. Returns the parsed object or null.
|
|
196
|
+
*/
|
|
197
|
+
export function readWorkflowFromPng(dataUrl) {
|
|
198
|
+
const chunks = parsePngText(dataUrl);
|
|
199
|
+
const raw = chunks["workflow"] ?? chunks["prompt"] ?? chunks["parameters"];
|
|
200
|
+
if (!raw)
|
|
201
|
+
return null;
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(raw);
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
// NO SILENT SWALLOW — embedded workflow JSON was malformed. Log so we
|
|
207
|
+
// know PNG-saved workflows are being silently dropped on import.
|
|
208
|
+
console.error('[pngMeta] readWorkflowFromPng: failed to parse embedded JSON:', e);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=pngMeta.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal PNG tEXt chunk injector / reader.
|
|
3
|
+
*
|
|
4
|
+
* PNG format: 8-byte signature, then a sequence of chunks.
|
|
5
|
+
* Each chunk: [4-byte length][4-byte type][N-byte data][4-byte CRC].
|
|
6
|
+
* tEXt chunk data: <keyword>\0<text> (both Latin-1, no compression).
|
|
7
|
+
*
|
|
8
|
+
* We use the key "tech_noir" to store generation params as JSON.
|
|
9
|
+
*/
|
|
10
|
+
/** Inject generation params into a PNG as a tEXt chunk (inserted after IHDR). */
|
|
11
|
+
export declare function injectPngMeta(b64: string, params: Record<string, unknown>): string;
|
|
12
|
+
/** Read generation params from a PNG's tEXt chunk, or null if not present. */
|
|
13
|
+
export declare function readPngMeta(b64OrDataUrl: string): Record<string, unknown> | null;
|
|
14
|
+
//# sourceMappingURL=pngMetaInfo.d.ts.map
|