@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.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/CHANGELOG.md +60 -0
- package/README.es.md +117 -55
- package/README.fr.md +125 -65
- package/README.hi.md +117 -68
- package/README.it.md +119 -58
- package/README.ja.md +124 -74
- package/README.md +73 -7
- package/README.pt-BR.md +128 -63
- package/README.zh.md +116 -65
- package/bin/sdlab.js +100 -4
- package/lib/adapters/ai-toolkit.js +224 -35
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +94 -11
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-author.js +350 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/canon-review-gate.js +205 -0
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +16 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +194 -6
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/lane1-render.js +668 -0
- package/lib/measure.js +422 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/paths.js +196 -7
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +113 -17
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +159 -25
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/train-runs.js +214 -0
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +4 -3
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/asset-ingest.js +160 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-draft.js +82 -0
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +31 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
- package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
- package/scripts/colab/colab_window.sh +587 -0
- package/scripts/colab/lane1_render.py +970 -0
- package/scripts/colab/launch_guards.py +564 -0
- package/scripts/colab/launch_lane1_stub.sh +309 -0
- package/scripts/colab/launch_lane3_stub.sh +366 -0
- package/scripts/colab/remote_runner.py +59 -0
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +182 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +86 -17
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +138 -20
- package/scripts/reingest.js +2 -2
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/verify.js +67 -0
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
package/lib/png-meta.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG structural reader — dependency-free IHDR/PLTE parsing and a minimal
|
|
3
|
+
* pixel decoder for the asset-lane's encoding proofs.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (asset lane, docs/asset-lane-kickoff.md): the ingest
|
|
6
|
+
* contract validates that a channel file's ACTUAL bytes match its DECLARED
|
|
7
|
+
* encoding, and that a categorical channel's palette is provably a subset
|
|
8
|
+
* of the palette the manifest declares. The brand/E09 lesson is to prove
|
|
9
|
+
* this structurally (PLTE chunk ⊆ declared palette) rather than sampling
|
|
10
|
+
* pixels and hoping. For categorical channels that are not indexed yet
|
|
11
|
+
* (facet's pre-E09 exports), the fallback proof is EXHAUSTIVE — every
|
|
12
|
+
* pixel, not a sample — which needs a decoder.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately minimal, in the repo's zero-dependency spirit:
|
|
15
|
+
* - parsePngMeta() reads only the header chunks (IHDR, PLTE, tRNS) and
|
|
16
|
+
* never inflates pixel data — cheap enough to run on every referenced
|
|
17
|
+
* file at ingest.
|
|
18
|
+
* - decodePng() supports exactly the shapes the asset contract admits:
|
|
19
|
+
* bit depth 8 for gray/RGB/RGBA (color types 0/2/6) and bit depths
|
|
20
|
+
* 1/2/4/8 for indexed (color type 3); no interlacing, no 16-bit.
|
|
21
|
+
* Anything else is a structured refusal, not a silent skip — a wrong
|
|
22
|
+
* file that looks right is the failure class this lane exists to stop.
|
|
23
|
+
*
|
|
24
|
+
* This module knows the PNG format and nothing else: no channel roles, no
|
|
25
|
+
* palettes-as-meaning, no project paths. (Parnas boundary — the manifest
|
|
26
|
+
* semantics live in lib/asset-source.js.)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { inflateSync } from 'node:zlib';
|
|
30
|
+
import { inputError } from './errors.js';
|
|
31
|
+
|
|
32
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
33
|
+
|
|
34
|
+
/** Channels per PNG color type (bit-depth-independent). */
|
|
35
|
+
const CHANNELS_BY_COLOR_TYPE = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
36
|
+
|
|
37
|
+
/** Human-readable names for error messages. */
|
|
38
|
+
export const COLOR_TYPE_NAMES = {
|
|
39
|
+
0: 'grayscale',
|
|
40
|
+
2: 'rgb',
|
|
41
|
+
3: 'indexed',
|
|
42
|
+
4: 'grayscale-alpha',
|
|
43
|
+
6: 'rgba',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function pngError(message, hint) {
|
|
47
|
+
return inputError('PNG_INVALID', message, hint);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function unsupportedError(message, hint) {
|
|
51
|
+
return inputError('PNG_UNSUPPORTED', message, hint);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Walk the chunk stream, yielding {type, offset, length} without copying
|
|
56
|
+
* data. Throws on a malformed layout (truncated chunk, missing signature).
|
|
57
|
+
*/
|
|
58
|
+
function* chunks(buf, label) {
|
|
59
|
+
if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
60
|
+
throw pngError(`${label}: not a PNG file (bad signature).`);
|
|
61
|
+
}
|
|
62
|
+
let off = 8;
|
|
63
|
+
while (off + 8 <= buf.length) {
|
|
64
|
+
const length = buf.readUInt32BE(off);
|
|
65
|
+
const type = buf.toString('latin1', off + 4, off + 8);
|
|
66
|
+
const dataStart = off + 8;
|
|
67
|
+
if (dataStart + length + 4 > buf.length) {
|
|
68
|
+
throw pngError(`${label}: truncated ${type} chunk.`);
|
|
69
|
+
}
|
|
70
|
+
yield { type, dataStart, length };
|
|
71
|
+
off = dataStart + length + 4; // skip CRC
|
|
72
|
+
if (type === 'IEND') return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read IHDR + PLTE + tRNS without touching pixel data.
|
|
78
|
+
*
|
|
79
|
+
* @param {Buffer} buf — full PNG file contents
|
|
80
|
+
* @param {string} [label] — filename for error messages
|
|
81
|
+
* @returns {{width:number, height:number, bitDepth:number, colorType:number,
|
|
82
|
+
* colorTypeName:string, interlaced:boolean,
|
|
83
|
+
* palette:Array<[number,number,number]>|null,
|
|
84
|
+
* transparency:Uint8Array|null}}
|
|
85
|
+
*/
|
|
86
|
+
export function parsePngMeta(buf, label = 'png') {
|
|
87
|
+
let ihdr = null;
|
|
88
|
+
let palette = null;
|
|
89
|
+
let transparency = null;
|
|
90
|
+
|
|
91
|
+
for (const { type, dataStart, length } of chunks(buf, label)) {
|
|
92
|
+
if (type === 'IHDR') {
|
|
93
|
+
if (length !== 13) throw pngError(`${label}: IHDR chunk has length ${length}, expected 13.`);
|
|
94
|
+
ihdr = {
|
|
95
|
+
width: buf.readUInt32BE(dataStart),
|
|
96
|
+
height: buf.readUInt32BE(dataStart + 4),
|
|
97
|
+
bitDepth: buf[dataStart + 8],
|
|
98
|
+
colorType: buf[dataStart + 9],
|
|
99
|
+
interlaced: buf[dataStart + 12] !== 0,
|
|
100
|
+
};
|
|
101
|
+
} else if (type === 'PLTE') {
|
|
102
|
+
if (length % 3 !== 0) throw pngError(`${label}: PLTE length ${length} is not a multiple of 3.`);
|
|
103
|
+
palette = [];
|
|
104
|
+
for (let i = 0; i < length; i += 3) {
|
|
105
|
+
palette.push([buf[dataStart + i], buf[dataStart + i + 1], buf[dataStart + i + 2]]);
|
|
106
|
+
}
|
|
107
|
+
} else if (type === 'tRNS') {
|
|
108
|
+
transparency = new Uint8Array(buf.subarray(dataStart, dataStart + length));
|
|
109
|
+
} else if (type === 'IDAT') {
|
|
110
|
+
break; // meta chunks precede IDAT per spec; stop before pixel data
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!ihdr) throw pngError(`${label}: missing IHDR chunk.`);
|
|
115
|
+
if (!(ihdr.colorType in CHANNELS_BY_COLOR_TYPE)) {
|
|
116
|
+
throw pngError(`${label}: unknown color type ${ihdr.colorType}.`);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...ihdr,
|
|
120
|
+
colorTypeName: COLOR_TYPE_NAMES[ihdr.colorType],
|
|
121
|
+
palette,
|
|
122
|
+
transparency,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Pixel decoding ──────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** Paeth predictor (RFC 2083 §6.6). */
|
|
129
|
+
function paeth(a, b, c) {
|
|
130
|
+
const p = a + b - c;
|
|
131
|
+
const pa = Math.abs(p - a);
|
|
132
|
+
const pb = Math.abs(p - b);
|
|
133
|
+
const pc = Math.abs(p - c);
|
|
134
|
+
if (pa <= pb && pa <= pc) return a;
|
|
135
|
+
if (pb <= pc) return b;
|
|
136
|
+
return c;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Reverse the per-scanline filters in place, returning the raw (still
|
|
141
|
+
* possibly bit-packed) scanline bytes with the filter bytes stripped.
|
|
142
|
+
*/
|
|
143
|
+
function unfilter(raw, height, bytesPerLine, bpp, label) {
|
|
144
|
+
const out = Buffer.alloc(height * bytesPerLine);
|
|
145
|
+
let inOff = 0;
|
|
146
|
+
for (let y = 0; y < height; y++) {
|
|
147
|
+
const filter = raw[inOff++];
|
|
148
|
+
const lineStart = y * bytesPerLine;
|
|
149
|
+
const prevStart = (y - 1) * bytesPerLine;
|
|
150
|
+
for (let x = 0; x < bytesPerLine; x++) {
|
|
151
|
+
const cur = raw[inOff + x];
|
|
152
|
+
const left = x >= bpp ? out[lineStart + x - bpp] : 0;
|
|
153
|
+
const up = y > 0 ? out[prevStart + x] : 0;
|
|
154
|
+
const upLeft = y > 0 && x >= bpp ? out[prevStart + x - bpp] : 0;
|
|
155
|
+
let value;
|
|
156
|
+
switch (filter) {
|
|
157
|
+
case 0: value = cur; break;
|
|
158
|
+
case 1: value = cur + left; break;
|
|
159
|
+
case 2: value = cur + up; break;
|
|
160
|
+
case 3: value = cur + ((left + up) >> 1); break;
|
|
161
|
+
case 4: value = cur + paeth(left, up, upLeft); break;
|
|
162
|
+
default:
|
|
163
|
+
throw pngError(`${label}: unknown scanline filter ${filter} at row ${y}.`);
|
|
164
|
+
}
|
|
165
|
+
out[lineStart + x] = value & 0xff;
|
|
166
|
+
}
|
|
167
|
+
inOff += bytesPerLine;
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Unpack sub-byte indexed scanlines (bit depths 1/2/4) to one index per byte. */
|
|
173
|
+
function unpackIndices(lines, width, height, bitDepth, bytesPerLine) {
|
|
174
|
+
const out = new Uint8Array(width * height);
|
|
175
|
+
const perByte = 8 / bitDepth;
|
|
176
|
+
const mask = (1 << bitDepth) - 1;
|
|
177
|
+
for (let y = 0; y < height; y++) {
|
|
178
|
+
for (let x = 0; x < width; x++) {
|
|
179
|
+
const byte = lines[y * bytesPerLine + Math.floor(x / perByte)];
|
|
180
|
+
const shift = 8 - bitDepth * ((x % perByte) + 1);
|
|
181
|
+
out[y * width + x] = (byte >> shift) & mask;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Decode a PNG's pixels into a flat typed array.
|
|
189
|
+
*
|
|
190
|
+
* Supported: color types 0/2/6 at bit depth 8, color type 3 at bit depths
|
|
191
|
+
* 1/2/4/8, non-interlaced. Everything else throws PNG_UNSUPPORTED with the
|
|
192
|
+
* exact reason — the caller decides whether that's a contract violation.
|
|
193
|
+
*
|
|
194
|
+
* @param {Buffer} buf — full PNG file contents
|
|
195
|
+
* @param {string} [label] — filename for error messages
|
|
196
|
+
* @returns {{width:number, height:number, colorType:number, bitDepth:number,
|
|
197
|
+
* channels:number, data:Uint8Array,
|
|
198
|
+
* palette:Array<[number,number,number]>|null}}
|
|
199
|
+
* For color type 3, `data` holds palette INDICES (one per pixel) and
|
|
200
|
+
* `palette` holds the PLTE entries; for other types, `data` holds
|
|
201
|
+
* interleaved channel bytes.
|
|
202
|
+
*/
|
|
203
|
+
export function decodePng(buf, label = 'png') {
|
|
204
|
+
const meta = parsePngMeta(buf, label);
|
|
205
|
+
const { width, height, bitDepth, colorType } = meta;
|
|
206
|
+
|
|
207
|
+
if (meta.interlaced) {
|
|
208
|
+
throw unsupportedError(
|
|
209
|
+
`${label}: interlaced (Adam7) PNGs are not supported by the asset contract.`,
|
|
210
|
+
'Re-export without interlacing.'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const indexed = colorType === 3;
|
|
214
|
+
if (!indexed && bitDepth !== 8) {
|
|
215
|
+
throw unsupportedError(
|
|
216
|
+
`${label}: bit depth ${bitDepth} for ${COLOR_TYPE_NAMES[colorType]} is not supported (8 only).`,
|
|
217
|
+
'Re-export at 8 bits per channel.'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (indexed && ![1, 2, 4, 8].includes(bitDepth)) {
|
|
221
|
+
throw unsupportedError(`${label}: bit depth ${bitDepth} is invalid for an indexed PNG.`);
|
|
222
|
+
}
|
|
223
|
+
if (indexed && !meta.palette) {
|
|
224
|
+
throw pngError(`${label}: indexed PNG has no PLTE chunk.`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Concatenate IDAT payloads, inflate once.
|
|
228
|
+
const idatParts = [];
|
|
229
|
+
for (const { type, dataStart, length } of chunks(buf, label)) {
|
|
230
|
+
if (type === 'IDAT') idatParts.push(buf.subarray(dataStart, dataStart + length));
|
|
231
|
+
}
|
|
232
|
+
if (idatParts.length === 0) throw pngError(`${label}: no IDAT chunks.`);
|
|
233
|
+
let raw;
|
|
234
|
+
try {
|
|
235
|
+
raw = inflateSync(Buffer.concat(idatParts));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
throw pngError(`${label}: IDAT inflate failed (${err.message}).`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const channels = CHANNELS_BY_COLOR_TYPE[colorType];
|
|
241
|
+
const bitsPerPixel = channels * bitDepth;
|
|
242
|
+
const bytesPerLine = Math.ceil((width * bitsPerPixel) / 8);
|
|
243
|
+
const bpp = Math.max(1, bitsPerPixel >> 3);
|
|
244
|
+
|
|
245
|
+
const expected = height * (bytesPerLine + 1);
|
|
246
|
+
if (raw.length < expected) {
|
|
247
|
+
throw pngError(`${label}: decompressed pixel data is short (${raw.length} < ${expected}).`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const lines = unfilter(raw, height, bytesPerLine, bpp, label);
|
|
251
|
+
|
|
252
|
+
let data;
|
|
253
|
+
if (indexed && bitDepth < 8) {
|
|
254
|
+
data = unpackIndices(lines, width, height, bitDepth, bytesPerLine);
|
|
255
|
+
} else {
|
|
256
|
+
// 8-bit paths: lines are already one byte per sample, tightly packed.
|
|
257
|
+
data = new Uint8Array(lines.buffer, lines.byteOffset, width * height * channels);
|
|
258
|
+
// Copy so callers can't be surprised by the underlying Buffer pool.
|
|
259
|
+
data = new Uint8Array(data);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { width, height, colorType, bitDepth, channels, data, palette: meta.palette };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
// ─── PNG tEXt Provenance Stamp & Read-Back (G8) ──────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Canonical PNG tEXt chunk keys for the sdlab provenance stamp (DESIGN-v3 §2.3, §4.4, G8).
|
|
270
|
+
*/
|
|
271
|
+
export const PNG_STAMP_KEYS = Object.freeze({
|
|
272
|
+
owner: 'sdlab:owner',
|
|
273
|
+
licence: 'sdlab:licence',
|
|
274
|
+
canon_sha: 'sdlab:canon_sha',
|
|
275
|
+
entry_id: 'sdlab:entry_id',
|
|
276
|
+
entry_sha256: 'sdlab:entry_sha256',
|
|
277
|
+
run_id: 'sdlab:run_id',
|
|
278
|
+
workflow_sha: 'sdlab:workflow_sha',
|
|
279
|
+
window_id: 'sdlab:window_id',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
export const REQUIRED_PNG_STAMP_FIELDS = Object.freeze([
|
|
283
|
+
'owner',
|
|
284
|
+
'licence',
|
|
285
|
+
'canon_sha',
|
|
286
|
+
'entry_id',
|
|
287
|
+
'entry_sha256',
|
|
288
|
+
'run_id',
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
const CRC32_TABLE = (() => {
|
|
292
|
+
const table = new Int32Array(256);
|
|
293
|
+
for (let n = 0; n < 256; n++) {
|
|
294
|
+
let c = n;
|
|
295
|
+
for (let k = 0; k < 8; k++) {
|
|
296
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
297
|
+
}
|
|
298
|
+
table[n] = c;
|
|
299
|
+
}
|
|
300
|
+
return table;
|
|
301
|
+
})();
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Compute IEEE 802.3 CRC-32 for a PNG chunk (type + data).
|
|
305
|
+
* @param {Buffer} buf
|
|
306
|
+
* @returns {number}
|
|
307
|
+
*/
|
|
308
|
+
export function crc32Png(buf) {
|
|
309
|
+
let c = 0xffffffff;
|
|
310
|
+
for (let i = 0; i < buf.length; i++) {
|
|
311
|
+
c = CRC32_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
312
|
+
}
|
|
313
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildTextChunk(keyword, text) {
|
|
317
|
+
const keyBuf = Buffer.from(String(keyword), 'latin1');
|
|
318
|
+
const valBuf = Buffer.from(String(text), 'latin1');
|
|
319
|
+
const payload = Buffer.concat([keyBuf, Buffer.from([0x00]), valBuf]);
|
|
320
|
+
const out = Buffer.alloc(12 + payload.length);
|
|
321
|
+
out.writeUInt32BE(payload.length, 0);
|
|
322
|
+
out.write('tEXt', 4, 'latin1');
|
|
323
|
+
payload.copy(out, 8);
|
|
324
|
+
const crc = crc32Png(out.subarray(4, 8 + payload.length));
|
|
325
|
+
out.writeUInt32BE(crc, 8 + payload.length);
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Read all PNG text chunks (tEXt, zTXt, iTXt) across the entire PNG stream.
|
|
331
|
+
*
|
|
332
|
+
* @param {Buffer} buf
|
|
333
|
+
* @param {string} [label]
|
|
334
|
+
* @returns {Record<string, string>}
|
|
335
|
+
*/
|
|
336
|
+
export function readPngTextChunks(buf, label = 'png') {
|
|
337
|
+
parsePngMeta(buf, label);
|
|
338
|
+
const out = {};
|
|
339
|
+
for (const { type, dataStart, length } of chunks(buf, label)) {
|
|
340
|
+
const payload = buf.subarray(dataStart, dataStart + length);
|
|
341
|
+
if (type === 'tEXt') {
|
|
342
|
+
const nul = payload.indexOf(0x00);
|
|
343
|
+
if (nul > 0) {
|
|
344
|
+
const key = payload.toString('latin1', 0, nul);
|
|
345
|
+
const val = payload.toString('latin1', nul + 1);
|
|
346
|
+
out[key] = val;
|
|
347
|
+
}
|
|
348
|
+
} else if (type === 'zTXt') {
|
|
349
|
+
const nul = payload.indexOf(0x00);
|
|
350
|
+
if (nul > 0 && nul + 2 <= payload.length && payload[nul + 1] === 0) {
|
|
351
|
+
const key = payload.toString('latin1', 0, nul);
|
|
352
|
+
const inflated = inflateSync(payload.subarray(nul + 2));
|
|
353
|
+
out[key] = inflated.toString('latin1');
|
|
354
|
+
}
|
|
355
|
+
} else if (type === 'iTXt') {
|
|
356
|
+
const nul = payload.indexOf(0x00);
|
|
357
|
+
if (nul > 0 && nul + 3 <= payload.length) {
|
|
358
|
+
const key = payload.toString('latin1', 0, nul);
|
|
359
|
+
const compFlag = payload[nul + 1];
|
|
360
|
+
const compMethod = payload[nul + 2];
|
|
361
|
+
let rest = payload.subarray(nul + 3);
|
|
362
|
+
const langNul = rest.indexOf(0x00);
|
|
363
|
+
if (langNul >= 0) {
|
|
364
|
+
rest = rest.subarray(langNul + 1);
|
|
365
|
+
const transNul = rest.indexOf(0x00);
|
|
366
|
+
if (transNul >= 0) {
|
|
367
|
+
const textBytes = rest.subarray(transNul + 1);
|
|
368
|
+
if (compFlag === 1 && compMethod === 0) {
|
|
369
|
+
out[key] = inflateSync(textBytes).toString('utf8');
|
|
370
|
+
} else if (compFlag === 0) {
|
|
371
|
+
out[key] = textBytes.toString('utf8');
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Write or replace PNG tEXt chunks right before the IEND chunk.
|
|
383
|
+
*
|
|
384
|
+
* @param {Buffer} buf — valid PNG buffer
|
|
385
|
+
* @param {Record<string, string>} textMap — keyword -> value map
|
|
386
|
+
* @param {string} [label]
|
|
387
|
+
* @returns {Buffer}
|
|
388
|
+
*/
|
|
389
|
+
export function writePngTextChunks(buf, textMap, label = 'png') {
|
|
390
|
+
parsePngMeta(buf, label);
|
|
391
|
+
const replaceKeys = new Set(Object.keys(textMap || {}));
|
|
392
|
+
const keptChunks = [PNG_SIGNATURE];
|
|
393
|
+
let iendChunk = null;
|
|
394
|
+
|
|
395
|
+
for (const { type, dataStart, length } of chunks(buf, label)) {
|
|
396
|
+
const rawChunk = buf.subarray(dataStart - 8, dataStart + length + 4);
|
|
397
|
+
if (type === 'tEXt') {
|
|
398
|
+
const payload = buf.subarray(dataStart, dataStart + length);
|
|
399
|
+
const nul = payload.indexOf(0x00);
|
|
400
|
+
if (nul > 0) {
|
|
401
|
+
const key = payload.toString('latin1', 0, nul);
|
|
402
|
+
if (replaceKeys.has(key)) continue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (type === 'IEND') {
|
|
406
|
+
iendChunk = rawChunk;
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
keptChunks.push(rawChunk);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const [key, val] of Object.entries(textMap || {})) {
|
|
413
|
+
if (val === undefined || val === null) continue;
|
|
414
|
+
keptChunks.push(buildTextChunk(key, String(val)));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
keptChunks.push(iendChunk || Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82]));
|
|
418
|
+
return Buffer.concat(keptChunks);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function unwrapTextValue(raw) {
|
|
422
|
+
if (typeof raw !== 'string') return null;
|
|
423
|
+
const trimmed = raw.trim();
|
|
424
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
425
|
+
try {
|
|
426
|
+
const parsed = JSON.parse(trimmed);
|
|
427
|
+
if (typeof parsed === 'string') return parsed;
|
|
428
|
+
} catch {
|
|
429
|
+
return raw;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return raw;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Stamp a PNG buffer with the required sdlab provenance tEXt chunks (G8).
|
|
437
|
+
*
|
|
438
|
+
* @param {Buffer} buf
|
|
439
|
+
* @param {Object} stamp
|
|
440
|
+
* @param {string} [label]
|
|
441
|
+
* @returns {Buffer}
|
|
442
|
+
*/
|
|
443
|
+
export function stampPngProvenance(buf, stamp, label = 'png') {
|
|
444
|
+
if (!stamp || typeof stamp !== 'object') {
|
|
445
|
+
throw inputError('PNG_STAMP_MISSING', `${label}: provenance stamp object is required.`);
|
|
446
|
+
}
|
|
447
|
+
for (const field of REQUIRED_PNG_STAMP_FIELDS) {
|
|
448
|
+
const value = stamp[field];
|
|
449
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
450
|
+
throw inputError(
|
|
451
|
+
'PNG_STAMP_MISSING',
|
|
452
|
+
`${label}: missing required provenance stamp field "${field}".`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const textMap = {};
|
|
457
|
+
for (const [field, chunkKey] of Object.entries(PNG_STAMP_KEYS)) {
|
|
458
|
+
if (typeof stamp[field] === 'string' && stamp[field].trim().length > 0) {
|
|
459
|
+
textMap[chunkKey] = stamp[field];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return writePngTextChunks(buf, textMap, label);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Read back the sdlab provenance stamp from a PNG buffer.
|
|
467
|
+
*
|
|
468
|
+
* @param {Buffer} buf
|
|
469
|
+
* @param {string} [label]
|
|
470
|
+
* @returns {Object}
|
|
471
|
+
*/
|
|
472
|
+
export function readPngProvenance(buf, label = 'png') {
|
|
473
|
+
const rawChunks = readPngTextChunks(buf, label);
|
|
474
|
+
const stamp = { rawChunks };
|
|
475
|
+
for (const [field, chunkKey] of Object.entries(PNG_STAMP_KEYS)) {
|
|
476
|
+
const val = rawChunks[chunkKey];
|
|
477
|
+
stamp[field] = val !== undefined ? unwrapTextValue(val) : null;
|
|
478
|
+
}
|
|
479
|
+
return stamp;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Verify that a PNG buffer carries all required sdlab provenance tEXt chunks
|
|
484
|
+
* and that they match any expected values supplied (G8, H7).
|
|
485
|
+
*
|
|
486
|
+
* @param {Buffer} buf
|
|
487
|
+
* @param {Object} [expected]
|
|
488
|
+
* @param {string} [label]
|
|
489
|
+
* @returns {{ ok: true, stamp: Object }}
|
|
490
|
+
*/
|
|
491
|
+
export function verifyPngProvenance(buf, expected = {}, label = 'png') {
|
|
492
|
+
const stamp = readPngProvenance(buf, label);
|
|
493
|
+
for (const field of REQUIRED_PNG_STAMP_FIELDS) {
|
|
494
|
+
if (typeof stamp[field] !== 'string' || stamp[field].trim().length === 0) {
|
|
495
|
+
throw inputError(
|
|
496
|
+
'PNG_STAMP_MISSING',
|
|
497
|
+
`${label}: missing required PNG provenance chunk "${PNG_STAMP_KEYS[field]}" (${field}).`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
for (const [field, expectedVal] of Object.entries(expected || {})) {
|
|
502
|
+
if (expectedVal === undefined || expectedVal === null) continue;
|
|
503
|
+
if (!(field in PNG_STAMP_KEYS)) continue;
|
|
504
|
+
if (stamp[field] !== String(expectedVal)) {
|
|
505
|
+
throw inputError(
|
|
506
|
+
'PNG_STAMP_MISMATCH',
|
|
507
|
+
`${label}: PNG provenance "${field}" mismatch (got "${stamp[field]}", expected "${expectedVal}").`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { ok: true, stamp };
|
|
512
|
+
}
|
package/lib/records.js
CHANGED
|
@@ -7,24 +7,70 @@
|
|
|
7
7
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
|
|
10
|
+
export const STUDIO_OWNER = 'mcp-tool-shop';
|
|
11
|
+
export const STUDIO_LICENCE = 'CC-BY-4.0';
|
|
12
|
+
export const AI_ASSISTED_DISCLOSURE_NOTE =
|
|
13
|
+
'AI-assisted generation on Apache-2.0 base models; curated and owned by mcp-tool-shop under CC-BY-4.0.';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the default rights and ownership block for a record (G5, §2.3).
|
|
17
|
+
*/
|
|
18
|
+
export function buildDefaultRights(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
owner: STUDIO_OWNER,
|
|
21
|
+
licence: STUDIO_LICENCE,
|
|
22
|
+
ai_assisted_disclosure: true,
|
|
23
|
+
disclosure_note: AI_ASSISTED_DISCLOSURE_NOTE,
|
|
24
|
+
...(overrides || {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
10
28
|
/**
|
|
11
29
|
* Build a base provenance record (generated by txt2img scripts).
|
|
30
|
+
* Supports positional `(id, assetPath, imageData, provenance, extra)` and
|
|
31
|
+
* object-style `({ id, assetPath, imageData, dimensions, provenance, canon, owner, licence, rights })`.
|
|
12
32
|
*/
|
|
13
|
-
export function buildBaseRecord(
|
|
33
|
+
export function buildBaseRecord(idOrOpts, assetPath, imageData, provenance, extra = {}) {
|
|
34
|
+
let id = idOrOpts;
|
|
35
|
+
let resolvedAssetPath = assetPath;
|
|
36
|
+
let resolvedImage = imageData;
|
|
37
|
+
let resolvedProvenance = provenance;
|
|
38
|
+
let resolvedExtra = extra || {};
|
|
39
|
+
|
|
40
|
+
if (idOrOpts && typeof idOrOpts === 'object' && !Array.isArray(idOrOpts)) {
|
|
41
|
+
id = idOrOpts.id;
|
|
42
|
+
resolvedAssetPath = idOrOpts.assetPath ?? idOrOpts.asset_path;
|
|
43
|
+
resolvedImage = idOrOpts.imageData ?? idOrOpts.dimensions ?? idOrOpts.image ?? {};
|
|
44
|
+
resolvedProvenance = idOrOpts.provenance ?? null;
|
|
45
|
+
resolvedExtra = idOrOpts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const img = resolvedImage || {};
|
|
49
|
+
const rights = buildDefaultRights(resolvedExtra.rights);
|
|
50
|
+
const owner = resolvedExtra.owner ?? rights.owner;
|
|
51
|
+
const licence = resolvedExtra.licence ?? rights.licence;
|
|
52
|
+
|
|
14
53
|
return {
|
|
15
54
|
id,
|
|
16
55
|
schema_version: '2.0.0',
|
|
17
56
|
created_at: new Date().toISOString(),
|
|
18
|
-
|
|
57
|
+
owner,
|
|
58
|
+
licence,
|
|
59
|
+
rights: {
|
|
60
|
+
...rights,
|
|
61
|
+
owner,
|
|
62
|
+
licence,
|
|
63
|
+
},
|
|
64
|
+
asset_path: resolvedAssetPath,
|
|
19
65
|
image: {
|
|
20
|
-
format: 'png',
|
|
21
|
-
width:
|
|
22
|
-
height:
|
|
23
|
-
bytes:
|
|
66
|
+
format: img.format || 'png',
|
|
67
|
+
width: img.width || 1024,
|
|
68
|
+
height: img.height || 1024,
|
|
69
|
+
bytes: img.bytes || 0,
|
|
24
70
|
},
|
|
25
|
-
provenance,
|
|
71
|
+
provenance: resolvedProvenance,
|
|
26
72
|
judgment: null,
|
|
27
|
-
canon: null,
|
|
73
|
+
canon: resolvedExtra.canon ?? null,
|
|
28
74
|
iteration: null,
|
|
29
75
|
};
|
|
30
76
|
}
|