@scrymore/scry-deployer 0.11.0-next.20261001044855 → 0.11.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/lib/importBundle.js +3 -3
- package/lib/importConvert.js +80 -17
- package/package.json +1 -1
package/lib/importBundle.js
CHANGED
|
@@ -11,7 +11,7 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
13
13
|
const { SCF_SCHEMA_URL } = require('./scf.js');
|
|
14
|
-
const { convertFile, measure,
|
|
14
|
+
const { convertFile, measure, MAX_RASTER_BYTES, MAX_NATIVE_DIMENSION } = require('./importConvert.js');
|
|
15
15
|
const { stripMetadata } = require('./importStrip.js');
|
|
16
16
|
const { readEmbeddedPacket, parseAllowListed, mergeXmp, mergeDuplicateMeta } = require('./importXmp.js');
|
|
17
17
|
const { readSidecarPacket } = require('./importScan.js');
|
|
@@ -63,10 +63,10 @@ async function prepareNative(file, size, deps) {
|
|
|
63
63
|
const dims = clean ? measure(clean) : null;
|
|
64
64
|
if (!clean || !dims) return { skip: { reason: 'error', detail: `not a readable ${file.ext} image` } };
|
|
65
65
|
const idHex = sha256Hex(clean);
|
|
66
|
-
const fits = clean.length <=
|
|
66
|
+
const fits = clean.length <= MAX_RASTER_BYTES && dims.width <= MAX_NATIVE_DIMENSION && dims.height <= MAX_NATIVE_DIMENSION;
|
|
67
67
|
if (fits) return { idHex, picture: { bytes: clean, ...dims }, converted: false };
|
|
68
68
|
const result = await deps.convert(file.abs, file.ext, { tools: deps.tools });
|
|
69
|
-
if (!result.ok) return { idHex, skip: { reason: result.reason, detail: result.detail } };
|
|
69
|
+
if (!result.ok) return { idHex, skip: { reason: result.reason, detail: `over the ${MAX_RASTER_BYTES / 1048576} MB or ${MAX_NATIVE_DIMENSION} px the AI services accept, and could not be reduced: ${result.detail}` } };
|
|
70
70
|
return { idHex, picture: result, converted: true };
|
|
71
71
|
}
|
|
72
72
|
|
package/lib/importConvert.js
CHANGED
|
@@ -12,10 +12,21 @@ const fs = require('fs');
|
|
|
12
12
|
const os = require('os');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const { spawnSync } = require('child_process');
|
|
15
|
+
const zlib = require('zlib');
|
|
15
16
|
const { stripMetadata } = require('./importStrip.js');
|
|
16
17
|
|
|
18
|
+
// SCF limits (what the validator accepts) ...
|
|
17
19
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
18
20
|
const MAX_IMAGE_DIMENSION = 16384;
|
|
21
|
+
// ... and the smaller bounds every picture is held to. Stage proved the SCF limits are not what the models behind
|
|
22
|
+
// captioning and embeddings accept: a 16384 x 16384 PNG passes the validator and is then refused by OpenAI with
|
|
23
|
+
// `image_parse_error`, and an image over 5 MB is refused by Jina (422); either fails the whole chunk it is in.
|
|
24
|
+
// Anything we convert is rendered at or below these.
|
|
25
|
+
const MAX_RASTER_DIMENSION = 2048;
|
|
26
|
+
const MAX_RASTER_BYTES = 4 * 1024 * 1024; // Jina refuses an image over 5,242,880 bytes (422), so keep clear of it
|
|
27
|
+
// A picture that is already PNG/JPEG/WebP is used as it is only when it is within the bytes above and this edge
|
|
28
|
+
// (stage indexed 4096 px; 16384 px was refused). Larger ones are re-encoded down to MAX_RASTER_DIMENSION.
|
|
29
|
+
const MAX_NATIVE_DIMENSION = 4096;
|
|
19
30
|
const TOOL_TIMEOUT_MS = 120_000;
|
|
20
31
|
|
|
21
32
|
// ImageMagick resource limits on every call: a hostile or corrupt PSD/TIFF/HEIC/PDF must not exhaust
|
|
@@ -42,12 +53,13 @@ const CONVERT_CODERS = Object.freeze({
|
|
|
42
53
|
const NATIVE_EXTENSIONS = new Set(Object.keys(NATIVE_CODERS));
|
|
43
54
|
const CONVERTIBLE_EXTENSIONS = new Set(Object.keys(CONVERT_CODERS));
|
|
44
55
|
|
|
45
|
-
// Largest first: lossless PNG, then JPEG at shrinking sizes until the file is under the
|
|
56
|
+
// Largest first: lossless PNG, then JPEG at shrinking sizes until the file is under the byte cap.
|
|
57
|
+
// No attempt is ever larger than MAX_RASTER_DIMENSION.
|
|
46
58
|
const ATTEMPTS = Object.freeze([
|
|
47
|
-
{ format: 'png', max:
|
|
48
|
-
{ format: 'jpeg', max:
|
|
49
|
-
{ format: 'jpeg', max:
|
|
50
|
-
{ format: 'jpeg', max:
|
|
59
|
+
{ format: 'png', max: MAX_RASTER_DIMENSION, quality: 0 },
|
|
60
|
+
{ format: 'jpeg', max: MAX_RASTER_DIMENSION, quality: 90 },
|
|
61
|
+
{ format: 'jpeg', max: Math.round(MAX_RASTER_DIMENSION * 0.75), quality: 85 },
|
|
62
|
+
{ format: 'jpeg', max: Math.round(MAX_RASTER_DIMENSION * 0.5), quality: 80 },
|
|
51
63
|
]);
|
|
52
64
|
|
|
53
65
|
const JPEG_SOF = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
|
@@ -136,15 +148,17 @@ function toolCommand(tool, { input, coder, output, attempt }) {
|
|
|
136
148
|
const options = attempt.quality ? ['-s', 'formatOptions', String(attempt.quality)] : [];
|
|
137
149
|
return { file: 'sips', args: ['-s', 'format', attempt.format, ...options, '-Z', String(attempt.max), input, '--out', output] };
|
|
138
150
|
}
|
|
139
|
-
// pdftoppm writes <prefix>.png or <prefix>.jpg itself.
|
|
151
|
+
// pdftoppm writes <prefix>.png or <prefix>.jpg itself. -scale-to N makes the LONGEST edge exactly N (it
|
|
152
|
+
// also enlarges a small page, which is how a 624 pt page became 16384 px), so N is always the bound.
|
|
140
153
|
const flag = attempt.format === 'png' ? '-png' : '-jpeg';
|
|
141
|
-
return { file: 'pdftoppm', args: [flag, '-singlefile', '-
|
|
154
|
+
return { file: 'pdftoppm', args: [flag, '-singlefile', '-scale-to', String(attempt.max), input, output.replace(/\.(png|jpg)$/, '')] };
|
|
142
155
|
}
|
|
143
156
|
|
|
157
|
+
/** Converters for one file type, in the order they are tried. PDF/AI: pdftoppm first, it renders straight at the bound. */
|
|
144
158
|
function toolsFor(ext, tools) {
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
return
|
|
159
|
+
const isPdf = (CONVERT_CODERS[ext] || NATIVE_CODERS[ext]) === 'pdf';
|
|
160
|
+
const usable = tools.filter((t) => (t === 'pdftoppm' ? isPdf : true));
|
|
161
|
+
return isPdf ? [...usable.filter((t) => t === 'pdftoppm'), ...usable.filter((t) => t !== 'pdftoppm')] : usable;
|
|
148
162
|
}
|
|
149
163
|
|
|
150
164
|
function runAttempt(tool, request, run) {
|
|
@@ -153,19 +167,62 @@ function runAttempt(tool, request, run) {
|
|
|
153
167
|
return !result.error && result.status === 0 && fs.existsSync(request.output);
|
|
154
168
|
}
|
|
155
169
|
|
|
156
|
-
|
|
170
|
+
const PNG_CHANNELS = Object.freeze({ 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 });
|
|
171
|
+
|
|
172
|
+
/** Why the PNG pixel data cannot be read (truncated, corrupt or short), or null when it inflates to the full size. */
|
|
173
|
+
function pngDecodeProblem(buf) {
|
|
174
|
+
const idat = [];
|
|
175
|
+
let header = null;
|
|
176
|
+
let ended = false;
|
|
177
|
+
for (let offset = 8; offset + 12 <= buf.length && !ended; ) {
|
|
178
|
+
const length = buf.readUInt32BE(offset);
|
|
179
|
+
const type = buf.toString('latin1', offset + 4, offset + 8);
|
|
180
|
+
if (offset + 12 + length > buf.length) return 'PNG is truncated';
|
|
181
|
+
const data = buf.subarray(offset + 8, offset + 8 + length);
|
|
182
|
+
if (type === 'IHDR') header = { width: data.readUInt32BE(0), height: data.readUInt32BE(4), depth: data[8], colour: data[9], interlace: data[12] };
|
|
183
|
+
if (type === 'IDAT') idat.push(data);
|
|
184
|
+
ended = type === 'IEND';
|
|
185
|
+
offset += 12 + length;
|
|
186
|
+
}
|
|
187
|
+
if (!header || !ended || idat.length === 0) return 'PNG is truncated';
|
|
188
|
+
let pixels;
|
|
189
|
+
try {
|
|
190
|
+
pixels = zlib.inflateSync(Buffer.concat(idat));
|
|
191
|
+
} catch {
|
|
192
|
+
return 'PNG pixel data is corrupt';
|
|
193
|
+
}
|
|
194
|
+
const channels = PNG_CHANNELS[header.colour];
|
|
195
|
+
if (header.interlace !== 0 || !channels) return null;
|
|
196
|
+
const expected = header.height * (Math.ceil((header.width * channels * header.depth) / 8) + 1);
|
|
197
|
+
return pixels.length === expected ? null : 'PNG pixel data is incomplete';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Why the converted picture must not be uploaded (does not decode, too big in pixels or bytes), or null when it is fine. */
|
|
201
|
+
function outputProblem(buf, dims) {
|
|
202
|
+
if (dims.width > MAX_RASTER_DIMENSION || dims.height > MAX_RASTER_DIMENSION) {
|
|
203
|
+
return `output was ${dims.width}x${dims.height}, over ${MAX_RASTER_DIMENSION} px`;
|
|
204
|
+
}
|
|
205
|
+
if (buf.length > MAX_RASTER_BYTES) return `output was ${(buf.length / 1048576).toFixed(1)} MB, over ${MAX_RASTER_BYTES / 1048576} MB`;
|
|
206
|
+
if (dims.family === 'png') return pngDecodeProblem(buf);
|
|
207
|
+
if (dims.family === 'jpeg' && !(buf[buf.length - 2] === 0xff && buf[buf.length - 1] === 0xd9)) return 'JPEG is truncated';
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Read, strip and check one converter output. Returns {bytes, family, width, height} or {problem}. */
|
|
157
212
|
function acceptOutput(outputPath) {
|
|
158
213
|
const raw = fs.readFileSync(outputPath);
|
|
159
214
|
const family = measure(raw)?.family;
|
|
160
215
|
const clean = family ? stripMetadata(raw, family) : null;
|
|
161
216
|
const dims = clean ? measure(clean) : null;
|
|
162
|
-
if (!clean || !dims) return
|
|
163
|
-
const
|
|
164
|
-
return
|
|
217
|
+
if (!clean || !dims) return { problem: 'output is not a readable PNG or JPEG' };
|
|
218
|
+
const problem = outputProblem(clean, dims);
|
|
219
|
+
return problem ? { problem } : { bytes: clean, ...dims };
|
|
165
220
|
}
|
|
166
221
|
|
|
167
222
|
/**
|
|
168
|
-
* Convert (or re-encode a
|
|
223
|
+
* Convert (or re-encode a native image that is over the bound) with the first tool that works.
|
|
224
|
+
* Every output is held to MAX_RASTER_DIMENSION and MAX_RASTER_BYTES and checked to decode; a file that
|
|
225
|
+
* cannot be made valid comes back as a named skip, never as an image that is known to be unreadable.
|
|
169
226
|
*
|
|
170
227
|
* @param {string} file absolute path of the source file
|
|
171
228
|
* @param {string} ext lower-case extension with the dot
|
|
@@ -181,16 +238,19 @@ async function convertFile(file, ext, opts = {}) {
|
|
|
181
238
|
}
|
|
182
239
|
const coder = CONVERT_CODERS[ext] || NATIVE_CODERS[ext];
|
|
183
240
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'scry-import-conv-'));
|
|
241
|
+
let lastProblem = 'the converter failed';
|
|
184
242
|
try {
|
|
185
243
|
for (const attempt of ATTEMPTS) {
|
|
186
244
|
const output = path.join(dir, `out-${attempt.format}-${attempt.max}.${EXT_OF[attempt.format]}`);
|
|
187
245
|
for (const tool of tools) {
|
|
188
246
|
if (!runAttempt(tool, { input: file, coder, output, attempt }, run)) continue;
|
|
189
247
|
const accepted = acceptOutput(output);
|
|
190
|
-
if (accepted) return { ok: true, ...accepted };
|
|
248
|
+
if (!accepted.problem) return { ok: true, ...accepted };
|
|
249
|
+
lastProblem = accepted.problem;
|
|
191
250
|
}
|
|
192
251
|
}
|
|
193
|
-
|
|
252
|
+
const limits = `${MAX_RASTER_DIMENSION} px and ${MAX_RASTER_BYTES / 1048576} MB`;
|
|
253
|
+
return { ok: false, reason: 'error', detail: `${ext} could not be converted to a valid PNG or JPEG within ${limits} (${lastProblem})` };
|
|
194
254
|
} finally {
|
|
195
255
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
196
256
|
}
|
|
@@ -200,6 +260,9 @@ module.exports = {
|
|
|
200
260
|
MAGICK_LIMITS,
|
|
201
261
|
MAX_IMAGE_BYTES,
|
|
202
262
|
MAX_IMAGE_DIMENSION,
|
|
263
|
+
MAX_NATIVE_DIMENSION,
|
|
264
|
+
MAX_RASTER_BYTES,
|
|
265
|
+
MAX_RASTER_DIMENSION,
|
|
203
266
|
NATIVE_EXTENSIONS,
|
|
204
267
|
CONVERTIBLE_EXTENSIONS,
|
|
205
268
|
detectTools,
|