@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.
@@ -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, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION } = require('./importConvert.js');
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 <= MAX_IMAGE_BYTES && dims.width <= MAX_IMAGE_DIMENSION && dims.height <= MAX_IMAGE_DIMENSION;
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
 
@@ -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 limit.
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: MAX_IMAGE_DIMENSION, quality: 0 },
48
- { format: 'jpeg', max: MAX_IMAGE_DIMENSION, quality: 90 },
49
- { format: 'jpeg', max: 8192, quality: 85 },
50
- { format: 'jpeg', max: 4096, quality: 80 },
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', '-r', '150', '-scale-to', String(attempt.max), input, output.replace(/\.(png|jpg)$/, '')] };
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 coder = CONVERT_CODERS[ext] || NATIVE_CODERS[ext];
146
- const isPdf = coder === 'pdf';
147
- return tools.filter((t) => (t === 'pdftoppm' ? isPdf : true));
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
- /** Read, strip and size-check one converter output. Returns {bytes, family, width, height} or null. */
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 null;
163
- const fits = clean.length <= MAX_IMAGE_BYTES && dims.width <= MAX_IMAGE_DIMENSION && dims.height <= MAX_IMAGE_DIMENSION;
164
- return fits ? { bytes: clean, ...dims } : null;
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 too-large native image) with the first tool that works.
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
- return { ok: false, reason: 'error', detail: `${ext} could not be converted to a PNG or JPEG under 20 MB and 16384 px` };
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrymore/scry-deployer",
3
- "version": "0.11.0-next.20261001044855",
3
+ "version": "0.11.0",
4
4
  "description": "A CLI to automate the deployment of Storybook static builds.",
5
5
  "main": "index.js",
6
6
  "bin": {