@melite/formats 0.0.0-stage → 0.1.5-pre-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.
Files changed (62) hide show
  1. package/LICENSE +557 -0
  2. package/README.md +37 -2
  3. package/lib/b64.d.ts +2 -0
  4. package/lib/b64.js +7 -0
  5. package/lib/bodyPresets.d.ts +40 -0
  6. package/lib/bodyPresets.js +77 -0
  7. package/lib/captionSplitter.d.ts +49 -0
  8. package/lib/captionSplitter.js +172 -0
  9. package/lib/cardMutations.d.ts +52 -0
  10. package/lib/cardMutations.js +67 -0
  11. package/lib/downloads.d.ts +18 -0
  12. package/lib/downloads.js +64 -0
  13. package/lib/extractVideoFrame.d.ts +23 -0
  14. package/lib/extractVideoFrame.js +114 -0
  15. package/lib/glbLoad.d.ts +54 -0
  16. package/lib/glbLoad.js +96 -0
  17. package/lib/glbSkin.d.ts +14 -0
  18. package/lib/glbSkin.js +124 -0
  19. package/lib/handPose.d.ts +46 -0
  20. package/lib/handPose.js +122 -0
  21. package/lib/index.d.ts +25 -0
  22. package/lib/index.js +25 -0
  23. package/lib/keyframeExtract.d.ts +46 -0
  24. package/lib/keyframeExtract.js +89 -0
  25. package/lib/maskPrep.d.ts +88 -0
  26. package/lib/maskPrep.js +386 -0
  27. package/lib/motionConstraints.d.ts +237 -0
  28. package/lib/motionConstraints.js +369 -0
  29. package/lib/motionEnvelope.d.ts +56 -0
  30. package/lib/motionEnvelope.js +59 -0
  31. package/lib/motionNpz.d.ts +120 -0
  32. package/lib/motionNpz.js +497 -0
  33. package/lib/npz.d.ts +24 -0
  34. package/lib/npz.js +158 -0
  35. package/lib/openPoseCanvas.d.ts +17 -0
  36. package/lib/openPoseCanvas.js +90 -0
  37. package/lib/pngMeta.d.ts +42 -0
  38. package/lib/pngMeta.js +212 -0
  39. package/lib/pngMetaInfo.d.ts +14 -0
  40. package/lib/pngMetaInfo.js +127 -0
  41. package/lib/poseCodec.d.ts +20 -0
  42. package/lib/poseCodec.js +61 -0
  43. package/lib/poseTransforms.d.ts +66 -0
  44. package/lib/poseTransforms.js +227 -0
  45. package/lib/poserSkin.d.ts +75 -0
  46. package/lib/poserSkin.js +141 -0
  47. package/lib/prompt-tags.json +132 -0
  48. package/lib/promptTags.d.ts +42 -0
  49. package/lib/promptTags.js +116 -0
  50. package/lib/psdExport.d.ts +28 -0
  51. package/lib/psdExport.js +272 -0
  52. package/lib/readFile.d.ts +9 -0
  53. package/lib/readFile.js +16 -0
  54. package/lib/renderSnapshot.d.ts +34 -0
  55. package/lib/renderSnapshot.js +55 -0
  56. package/lib/somaSkin.d.ts +82 -0
  57. package/lib/somaSkin.js +267 -0
  58. package/lib/textToPose.d.ts +394 -0
  59. package/lib/textToPose.js +607 -0
  60. package/lib/uploadToEstate.d.ts +25 -0
  61. package/lib/uploadToEstate.js +97 -0
  62. package/package.json +149 -4
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
@@ -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