@forgeax/engine-image 0.1.19 → 0.1.21

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 (42) hide show
  1. package/README.md +61 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/pixel-surface.unit.test.d.ts +2 -0
  4. package/dist/__tests__/pixel-surface.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/texture-source-descriptor.integration.test.d.ts +2 -0
  6. package/dist/__tests__/texture-source-descriptor.integration.test.d.ts.map +1 -0
  7. package/dist/decode-image-from-file.mjs +2 -1
  8. package/dist/decode-image-from-file.mjs.map +1 -1
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/hdr-decoder.mjs +2 -1
  11. package/dist/hdr-decoder.mjs.map +1 -1
  12. package/dist/image-importer.d.ts.map +1 -1
  13. package/dist/image-importer.mjs +222 -16
  14. package/dist/image-importer.mjs.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.mjs +341 -43
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/parse-image.mjs +2 -1
  20. package/dist/parse-image.mjs.map +1 -1
  21. package/dist/pixel-surface.d.ts +53 -0
  22. package/dist/pixel-surface.d.ts.map +1 -0
  23. package/dist/runtime/asset-decoders.d.ts.map +1 -1
  24. package/dist/texture/importer.d.ts +42 -0
  25. package/dist/texture/importer.d.ts.map +1 -0
  26. package/dist/texture/source-descriptor.d.ts +24 -0
  27. package/dist/texture/source-descriptor.d.ts.map +1 -0
  28. package/package.json +6 -6
  29. package/src/__tests__/errors.test-d.ts +11 -3
  30. package/src/__tests__/image-importer-conversion-failure.unit.test.ts +31 -2
  31. package/src/__tests__/image-importer-topology.unit.test.ts +2 -2
  32. package/src/__tests__/image.unit.test.ts +2 -4
  33. package/src/__tests__/ktx2-basis-importer.unit.test.ts +2 -3
  34. package/src/__tests__/pixel-surface.unit.test.ts +94 -0
  35. package/src/__tests__/texture-source-descriptor.integration.test.ts +117 -0
  36. package/src/errors.ts +2 -0
  37. package/src/image-importer.ts +24 -14
  38. package/src/index.ts +8 -0
  39. package/src/pixel-surface.ts +428 -0
  40. package/src/runtime/asset-decoders.ts +40 -11
  41. package/src/texture/importer.ts +198 -0
  42. package/src/texture/source-descriptor.ts +123 -0
package/dist/index.mjs CHANGED
@@ -19,7 +19,8 @@ var IMAGE_ERROR_EXPECTED = {
19
19
  // .hint after switch (err.code) without parsing the message).
20
20
  "atlas-empty-input": "images.length >= 1",
21
21
  "atlas-size-exceeded": "image width x height <= maxAtlasSize^2 and each image fits in the atlas footprint",
22
- "atlas-region-mismatch": "sum(regions[i].w x regions[i].h) <= atlasWidth x atlasHeight"
22
+ "atlas-region-mismatch": "sum(regions[i].w x regions[i].h) <= atlasWidth x atlasHeight",
23
+ "image-surface-invalid": "PixelSurface dimensions and authoring inputs satisfy the RGBA8 contract"
23
24
  };
24
25
  var ImageErrorImpl = class extends Error {
25
26
  code;
@@ -273,6 +274,314 @@ async function loadJpeg() {
273
274
  return mod.default ?? mod;
274
275
  }
275
276
 
277
+ // src/to-asset-pack.ts
278
+ function toAssetPack(decoded, meta) {
279
+ return {
280
+ schemaVersion: "1.0.0",
281
+ kind: "external-asset-package",
282
+ importer: "image",
283
+ source: "",
284
+ importSettings: {
285
+ colorSpace: meta.colorSpace,
286
+ mipmap: meta.mipmap,
287
+ addressMode: meta.addressMode,
288
+ filterMode: meta.filterMode,
289
+ ...meta.downscaleMaxDimension !== void 0 ? { downscaleMaxDimension: meta.downscaleMaxDimension } : {}
290
+ },
291
+ subAssets: [
292
+ {
293
+ guid: meta.guid,
294
+ sourceIndex: 0,
295
+ kind: "texture"
296
+ }
297
+ ]
298
+ };
299
+ }
300
+
301
+ // src/pixel-surface.ts
302
+ function invalid(operation, field, value, expected) {
303
+ return err(
304
+ imageError({
305
+ code: "image-surface-invalid",
306
+ operation,
307
+ field,
308
+ value,
309
+ expected
310
+ })
311
+ );
312
+ }
313
+ function finiteNumber(operation, field, value) {
314
+ if (!Number.isFinite(value)) {
315
+ return invalid(operation, field, String(value), "a finite number");
316
+ }
317
+ return ok(value);
318
+ }
319
+ function positiveDimension(operation, field, value) {
320
+ if (!Number.isInteger(value) || value <= 0) {
321
+ return invalid(operation, field, value, "a positive integer");
322
+ }
323
+ return ok(value);
324
+ }
325
+ function colorChannels(operation, color) {
326
+ const channels = Array.isArray(color) ? color : color !== null && typeof color === "object" ? [
327
+ color.r,
328
+ color.g,
329
+ color.b,
330
+ color.a
331
+ ] : void 0;
332
+ if (channels === void 0 || channels.length !== 4) {
333
+ return invalid(operation, "color", "malformed", "four finite RGBA8 channels");
334
+ }
335
+ const normalized = [0, 0, 0, 0];
336
+ for (let index = 0; index < channels.length; index += 1) {
337
+ const channel = channels[index];
338
+ if (channel === void 0 || !Number.isFinite(channel) || channel < 0 || channel > 255) {
339
+ return invalid(
340
+ operation,
341
+ `color[${index}]`,
342
+ channel ?? "missing",
343
+ "a finite number in [0, 255]"
344
+ );
345
+ }
346
+ normalized[index] = Math.round(channel);
347
+ }
348
+ return ok(normalized);
349
+ }
350
+ function rounded(operation, field, value) {
351
+ const result = finiteNumber(operation, field, value);
352
+ return result.ok ? ok(Math.round(result.value)) : result;
353
+ }
354
+ function rectangle(operation, x, y, width, height) {
355
+ const values = [x, y, width, height];
356
+ for (let index = 0; index < values.length; index += 1) {
357
+ const value = values[index];
358
+ if (value === void 0 || !Number.isFinite(value)) {
359
+ return invalid(
360
+ operation,
361
+ ["x", "y", "width", "height"][index] ?? "rectangle",
362
+ String(value),
363
+ "a finite number"
364
+ );
365
+ }
366
+ }
367
+ if (width <= 0 || height <= 0) {
368
+ return invalid(
369
+ operation,
370
+ width <= 0 ? "width" : "height",
371
+ width <= 0 ? width : height,
372
+ "a positive number"
373
+ );
374
+ }
375
+ return ok([Math.round(x), Math.round(y), Math.round(width), Math.round(height)]);
376
+ }
377
+ function writePixel(data, width, x, y, color) {
378
+ if (x < 0 || y < 0 || x >= width) return;
379
+ const offset = (y * width + x) * 4;
380
+ data[offset] = color[0] ?? 0;
381
+ data[offset + 1] = color[1] ?? 0;
382
+ data[offset + 2] = color[2] ?? 0;
383
+ data[offset + 3] = color[3] ?? 0;
384
+ }
385
+ function fillClippedRect(data, width, height, x, y, rectWidth, rectHeight, color) {
386
+ const left = Math.max(0, x);
387
+ const top = Math.max(0, y);
388
+ const right = Math.min(width, x + rectWidth);
389
+ const bottom = Math.min(height, y + rectHeight);
390
+ for (let row = top; row < bottom; row += 1) {
391
+ for (let column = left; column < right; column += 1) {
392
+ writePixel(data, width, column, row, color);
393
+ }
394
+ }
395
+ }
396
+ function nextRandom(state) {
397
+ let value = state >>> 0;
398
+ value ^= value << 13;
399
+ value ^= value >>> 17;
400
+ value ^= value << 5;
401
+ return value >>> 0;
402
+ }
403
+ function makeSurface(options) {
404
+ const width = positiveDimension("create", "width", options.width);
405
+ if (!width.ok) return width;
406
+ const height = positiveDimension("create", "height", options.height);
407
+ if (!height.ok) return height;
408
+ if (options.colorSpace !== void 0 && options.colorSpace !== "srgb" && options.colorSpace !== "linear") {
409
+ return invalid("create", "colorSpace", String(options.colorSpace), "'srgb' or 'linear'");
410
+ }
411
+ if (options.mipmap !== void 0 && typeof options.mipmap !== "boolean") {
412
+ return invalid("create", "mipmap", String(options.mipmap), "a boolean");
413
+ }
414
+ const data = new Uint8Array(width.value * height.value * 4);
415
+ const colorSpace = options.colorSpace ?? "srgb";
416
+ const mipmap = options.mipmap ?? false;
417
+ const surface = {
418
+ width: width.value,
419
+ height: height.value,
420
+ colorSpace,
421
+ mipmap,
422
+ data,
423
+ setPixel(x, y, color) {
424
+ const px = rounded("set-pixel", "x", x);
425
+ if (!px.ok) return px;
426
+ const py = rounded("set-pixel", "y", y);
427
+ if (!py.ok) return py;
428
+ const normalized = colorChannels("set-pixel", color);
429
+ if (!normalized.ok) return normalized;
430
+ writePixel(data, width.value, px.value, py.value, normalized.value);
431
+ return ok(void 0);
432
+ },
433
+ fillRect(x, y, rectWidth, rectHeight, color) {
434
+ const rect = rectangle("fill-rect", x, y, rectWidth, rectHeight);
435
+ if (!rect.ok) return rect;
436
+ const normalized = colorChannels("fill-rect", color);
437
+ if (!normalized.ok) return normalized;
438
+ fillClippedRect(
439
+ data,
440
+ width.value,
441
+ height.value,
442
+ rect.value[0] ?? 0,
443
+ rect.value[1] ?? 0,
444
+ rect.value[2] ?? 0,
445
+ rect.value[3] ?? 0,
446
+ normalized.value
447
+ );
448
+ return ok(void 0);
449
+ },
450
+ fillCircle(cx, cy, radius, color) {
451
+ const centerX = rounded("fill-circle", "cx", cx);
452
+ if (!centerX.ok) return centerX;
453
+ const centerY = rounded("fill-circle", "cy", cy);
454
+ if (!centerY.ok) return centerY;
455
+ const circleRadius = rounded("fill-circle", "radius", radius);
456
+ if (!circleRadius.ok) return circleRadius;
457
+ if (circleRadius.value <= 0) {
458
+ return invalid("fill-circle", "radius", circleRadius.value, "a positive number");
459
+ }
460
+ const normalized = colorChannels("fill-circle", color);
461
+ if (!normalized.ok) return normalized;
462
+ const radiusSquared = circleRadius.value * circleRadius.value;
463
+ const left = Math.max(0, centerX.value - circleRadius.value);
464
+ const right = Math.min(width.value - 1, centerX.value + circleRadius.value);
465
+ const top = Math.max(0, centerY.value - circleRadius.value);
466
+ const bottom = Math.min(height.value - 1, centerY.value + circleRadius.value);
467
+ for (let row = top; row <= bottom; row += 1) {
468
+ for (let column = left; column <= right; column += 1) {
469
+ const dx = column - centerX.value;
470
+ const dy = row - centerY.value;
471
+ if (dx * dx + dy * dy <= radiusSquared)
472
+ writePixel(data, width.value, column, row, normalized.value);
473
+ }
474
+ }
475
+ return ok(void 0);
476
+ },
477
+ blit(source, destinationX, destinationY, sourceRect) {
478
+ const destination = rounded("blit", "destinationX", destinationX);
479
+ if (!destination.ok) return destination;
480
+ const destinationYResult = rounded("blit", "destinationY", destinationY);
481
+ if (!destinationYResult.ok) return destinationYResult;
482
+ if (source === null || typeof source !== "object" || !Number.isInteger(source.width) || !Number.isInteger(source.height) || !(source.data instanceof Uint8Array) || source.data.length !== source.width * source.height * 4) {
483
+ return invalid("blit", "source", "malformed", "a valid PixelSurface");
484
+ }
485
+ const rect = rectangle(
486
+ "blit",
487
+ sourceRect?.x ?? 0,
488
+ sourceRect?.y ?? 0,
489
+ sourceRect?.width ?? source.width,
490
+ sourceRect?.height ?? source.height
491
+ );
492
+ if (!rect.ok) return rect;
493
+ const sourceX = rect.value[0] ?? 0;
494
+ const sourceY = rect.value[1] ?? 0;
495
+ const sourceWidth = rect.value[2] ?? 0;
496
+ const sourceHeight = rect.value[3] ?? 0;
497
+ const left = Math.max(0, sourceX);
498
+ const top = Math.max(0, sourceY);
499
+ const right = Math.min(source.width, sourceX + sourceWidth);
500
+ const bottom = Math.min(source.height, sourceY + sourceHeight);
501
+ if (right <= left || bottom <= top) return ok(void 0);
502
+ const snapshot = source.data.slice();
503
+ for (let row = top; row < bottom; row += 1) {
504
+ for (let column = left; column < right; column += 1) {
505
+ const targetX = destination.value + column - sourceX;
506
+ const targetY = destinationYResult.value + row - sourceY;
507
+ if (targetX < 0 || targetY < 0 || targetX >= width.value || targetY >= height.value)
508
+ continue;
509
+ const sourceOffset = (row * source.width + column) * 4;
510
+ writePixel(
511
+ data,
512
+ width.value,
513
+ targetX,
514
+ targetY,
515
+ snapshot.subarray(sourceOffset, sourceOffset + 4)
516
+ );
517
+ }
518
+ }
519
+ return ok(void 0);
520
+ },
521
+ fillNoise(seed, noiseOptions) {
522
+ if (!Number.isFinite(seed)) return invalid("noise", "seed", String(seed), "a finite number");
523
+ const min = noiseOptions?.min ?? 0;
524
+ const max = noiseOptions?.max ?? 255;
525
+ const alpha = noiseOptions?.alpha ?? 255;
526
+ for (const [field, value] of [
527
+ ["min", min],
528
+ ["max", max],
529
+ ["alpha", alpha]
530
+ ]) {
531
+ if (!Number.isFinite(value) || value < 0 || value > 255) {
532
+ return invalid("noise", field, value, "a finite number in [0, 255]");
533
+ }
534
+ }
535
+ if (min > max) return invalid("noise", "min", min, "a value no greater than max");
536
+ let state = Math.trunc(seed) >>> 0 || 1831565813;
537
+ const span = max - min;
538
+ for (let index = 0; index < data.length; index += 4) {
539
+ state = nextRandom(state);
540
+ const value = min + state / 4294967296 * span;
541
+ const channel = Math.round(value);
542
+ data[index] = channel;
543
+ data[index + 1] = channel;
544
+ data[index + 2] = channel;
545
+ data[index + 3] = Math.round(alpha);
546
+ }
547
+ return ok(void 0);
548
+ },
549
+ noise(seed, noiseOptions) {
550
+ return surface.fillNoise(seed, noiseOptions);
551
+ },
552
+ toDecodedImage() {
553
+ return {
554
+ bytes: data.slice(),
555
+ width: width.value,
556
+ height: height.value,
557
+ mime: "image/png",
558
+ colorSpace,
559
+ mipmap
560
+ };
561
+ },
562
+ toTextureAsset() {
563
+ return {
564
+ kind: "texture",
565
+ shape: {
566
+ viewDimension: "2d",
567
+ extent: { width: width.value, height: height.value }
568
+ },
569
+ format: colorSpace === "srgb" ? "rgba8unorm-srgb" : "rgba8unorm",
570
+ data: data.slice(),
571
+ colorSpace,
572
+ mips: mipmap ? { kind: "generate" } : { kind: "none" }
573
+ };
574
+ },
575
+ toAssetPack(meta) {
576
+ return toAssetPack(surface.toDecodedImage(), meta);
577
+ }
578
+ };
579
+ return ok(surface);
580
+ }
581
+ function createPixelSurface(options) {
582
+ return makeSurface(options);
583
+ }
584
+
276
585
  // src/source-key.ts
277
586
  function deriveImageSourceKey(role, _locator) {
278
587
  const normalizedRole = role.trim();
@@ -338,7 +647,7 @@ function reimportReuseMeta(decoded, existing) {
338
647
  }
339
648
  return out;
340
649
  }
341
- function invalid(guid, expected, reason) {
650
+ function invalid2(guid, expected, reason) {
342
651
  return err({
343
652
  code: "asset-package-invalid",
344
653
  expected,
@@ -360,7 +669,15 @@ function imageBytes(value) {
360
669
  function compressedImageTarget(colorSpace) {
361
670
  return colorSpace === "srgb" ? "rgba8unorm-srgb" : "rgba8unorm";
362
671
  }
363
- function validImageSurface(value) {
672
+ function validTextureSurface(value) {
673
+ if (value === null || typeof value !== "object") return false;
674
+ const candidate = value;
675
+ const shape = candidate.shape;
676
+ const extent = shape?.viewDimension === "2d" ? shape.extent : void 0;
677
+ const mips = candidate.mips;
678
+ return extent !== void 0 && validDimensions(extent.width, extent.height) && mips !== void 0 && (mips.kind === "none" || mips.kind === "generate" || mips.kind === "packed" && Number.isSafeInteger(mips.levelCount) && mips.levelCount > 0) && typeof candidate.format === "string" && imageBytes(candidate.data) !== void 0 && (candidate.colorSpace === "srgb" || candidate.colorSpace === "linear");
679
+ }
680
+ function validEquirectSurface(value) {
364
681
  if (value === null || typeof value !== "object") return false;
365
682
  const candidate = value;
366
683
  return validDimensions(candidate.width ?? 0, candidate.height ?? 0) && typeof candidate.format === "string" && imageBytes(candidate.data) !== void 0 && (candidate.colorSpace === "srgb" || candidate.colorSpace === "linear");
@@ -369,7 +686,7 @@ async function readImageSurface(input, kind, expected) {
369
686
  const { envelope, artifacts } = input;
370
687
  const payload = envelope.payload;
371
688
  if (payload === null || typeof payload !== "object" || payload.kind !== kind) {
372
- return invalid(envelope.guid, expected, `${kind} owner validation failed`);
689
+ return invalid2(envelope.guid, expected, `${kind} owner validation failed`);
373
690
  }
374
691
  const body = envelope.artifacts.body ?? envelope.artifacts.atlas;
375
692
  let data = imageBytes(payload.data);
@@ -381,35 +698,40 @@ async function readImageSurface(input, kind, expected) {
381
698
  if (body.assetCodec?.name === "basis" && body.assetCodec.container !== void 0) {
382
699
  const candidate = payload;
383
700
  if (candidate.colorSpace !== "srgb" && candidate.colorSpace !== "linear") {
384
- return invalid(envelope.guid, expected, `${kind} color space is invalid`);
701
+ return invalid2(envelope.guid, expected, `${kind} color space is invalid`);
385
702
  }
386
703
  const target = compressedImageTarget(candidate.colorSpace);
387
704
  const transcoded = body.assetCodec.container === "ktx2" ? await parseKtx2(bytes).then(
388
705
  (parsed) => parsed.ok ? transcodeKtx2(parsed.value, target) : parsed
389
706
  ) : await transcodeBasis(bytes, target);
390
707
  if (!transcoded.ok) {
391
- return invalid(envelope.guid, expected, `codec:${transcoded.error.code}`);
708
+ return invalid2(envelope.guid, expected, `codec:${transcoded.error.code}`);
392
709
  }
393
710
  const mip = transcoded.value.mips[0];
394
- if (mip === void 0) return invalid(envelope.guid, expected, "codec:base-mip-missing");
711
+ if (mip === void 0) return invalid2(envelope.guid, expected, "codec:base-mip-missing");
395
712
  data = mip.data;
396
- return readDecodedSurface(envelope.guid, expected, {
397
- ...payload,
398
- width: mip.width,
399
- height: mip.height,
400
- format: target,
401
- data
402
- });
713
+ return readDecodedSurface(
714
+ envelope.guid,
715
+ expected,
716
+ kind,
717
+ kind === "texture" ? {
718
+ ...payload,
719
+ shape: { viewDimension: "2d", extent: { width: mip.width, height: mip.height } },
720
+ format: target,
721
+ data,
722
+ mips: { kind: "none" }
723
+ } : { ...payload, width: mip.width, height: mip.height, format: target, data }
724
+ );
403
725
  }
404
726
  }
405
- return readDecodedSurface(envelope.guid, expected, {
727
+ return readDecodedSurface(envelope.guid, expected, kind, {
406
728
  ...payload,
407
729
  ...data === void 0 ? {} : { data }
408
730
  });
409
731
  }
410
- function readDecodedSurface(guid, expected, candidate) {
411
- if (!validImageSurface(candidate)) {
412
- return invalid(guid, expected, "image owner validation failed");
732
+ function readDecodedSurface(guid, expected, kind, candidate) {
733
+ if (kind === "texture" ? !validTextureSurface(candidate) : !validEquirectSurface(candidate)) {
734
+ return invalid2(guid, expected, "image owner validation failed");
413
735
  }
414
736
  return ok(candidate);
415
737
  }
@@ -440,30 +762,6 @@ var equirectContribution = {
440
762
  }
441
763
  };
442
764
 
443
- // src/to-asset-pack.ts
444
- function toAssetPack(decoded, meta) {
445
- return {
446
- schemaVersion: "1.0.0",
447
- kind: "external-asset-package",
448
- importer: "image",
449
- source: "",
450
- importSettings: {
451
- colorSpace: meta.colorSpace,
452
- mipmap: meta.mipmap,
453
- addressMode: meta.addressMode,
454
- filterMode: meta.filterMode,
455
- ...meta.downscaleMaxDimension !== void 0 ? { downscaleMaxDimension: meta.downscaleMaxDimension } : {}
456
- },
457
- subAssets: [
458
- {
459
- guid: meta.guid,
460
- sourceIndex: 0,
461
- kind: "texture"
462
- }
463
- ]
464
- };
465
- }
466
-
467
- export { IMAGE_ERROR_EXPECTED, ImageErrorImpl, decodeHdr, decodeImageInBrowser, deriveImageSourceKey, equirectContribution, imageError, loadJpeg, loadUpng, reimportReuseMeta, subAssetKey, subAssetKeyEqual, textureContribution, toAssetPack };
765
+ export { IMAGE_ERROR_EXPECTED, ImageErrorImpl, createPixelSurface, decodeHdr, decodeImageInBrowser, deriveImageSourceKey, equirectContribution, imageError, loadJpeg, loadUpng, reimportReuseMeta, subAssetKey, subAssetKeyEqual, textureContribution, toAssetPack };
468
766
  //# sourceMappingURL=index.mjs.map
469
767
  //# sourceMappingURL=index.mjs.map