@openfairygui/functions 0.2.0-alpha.1 → 0.2.0-alpha.11

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/src/atlas.ts CHANGED
@@ -2,9 +2,15 @@ import { GearType, TransitionActionType, type Component, type Document, type Dra
2
2
  import { COMPAT_NODE_RECT_FLAGS, type CompatNodeRect } from './max-rects-compat.js';
3
3
  import { MaxRectsPackerCompat } from './max-rects-packer-compat.js';
4
4
  import type { ExtrasMap, HasOptionalSrc, HasOptionalUrl } from './shared-types.js';
5
- import { createTransform } from './utils.js';
5
+ import { createTransform, parseTextureSetMode, type TextureSetMode } from './utils.js';
6
6
 
7
7
  export interface AtlasOptions {
8
+ /**
9
+ * Limit atlas generation to specific package names.
10
+ * When omitted, all packages are processed.
11
+ */
12
+ packages?: string[];
13
+
8
14
  /**
9
15
  * Sharp module instance, injected by the caller.
10
16
  * Required for actual image compositing and trimImage.
@@ -95,7 +101,7 @@ export interface AtlasOptions {
95
101
 
96
102
  }
97
103
 
98
- const ATLAS_DEFAULTS: Required<Omit<AtlasOptions, 'encoder' | 'basePath' | 'outputPath' | 'mkdir' | 'readFileRaw'>> = {
104
+ const ATLAS_DEFAULTS: Required<Omit<AtlasOptions, 'packages' | 'encoder' | 'basePath' | 'outputPath' | 'mkdir' | 'readFileRaw'>> = {
99
105
  maxSize: 2048,
100
106
  fast: true,
101
107
  allowRotation: true,
@@ -220,8 +226,23 @@ interface AtlasCompositeInput {
220
226
  top: number;
221
227
  }
222
228
 
229
+ function getSelectedSkeletonDependencyImageIds(resources: PackageResource[]): Set<string> {
230
+ const imageIds = new Set<string>();
231
+ const resourcesById = new Map(resources.map((resource) => [resource.getId(), resource] as const));
232
+ for (const resource of resources) {
233
+ if (!isSkeletonResource(resource)) continue;
234
+ for (const requiredId of resource.getRequireIds()) {
235
+ if (!requiredId) continue;
236
+ const required = resourcesById.get(requiredId);
237
+ if (required && isImageResource(required)) imageIds.add(requiredId);
238
+ }
239
+ }
240
+ return imageIds;
241
+ }
242
+
223
243
  interface AtlasEncoderPipeline {
224
244
  ensureAlpha(): AtlasEncoderPipeline;
245
+ resize(width: number, height: number, options?: { fit?: 'fill' }): AtlasEncoderPipeline;
225
246
  raw(): AtlasEncoderPipeline;
226
247
  extract(options: { left: number; top: number; width: number; height: number }): AtlasEncoderPipeline;
227
248
  toBuffer(options: { resolveWithObject: true }): Promise<AtlasEncoderResolvedBuffer>;
@@ -425,19 +446,25 @@ export function atlas(_options: AtlasOptions = {}): Transform {
425
446
  const logger = doc.getLogger();
426
447
  const encoder = options.encoder as AtlasEncoder | undefined;
427
448
  const doTrim = options.trimImage && !!encoder && !!options.basePath;
449
+ const packageFilter = options.packages ? new Set(options.packages) : null;
428
450
 
429
451
  for (const pkg of root.listPackages()) {
452
+ if (packageFilter && !packageFilter.has(pkg.getName())) continue;
430
453
  // Respect publish-selected resources when publish() precomputes a merged branch view.
431
454
  const selectedPublishIds = new Set(((pkg.getExtras() as PackageAtlasExtras | undefined) ?? {}).publishedResourceIds ?? []);
432
455
  const allResources = selectedPublishIds.size > 0
433
456
  ? pkg.listResources().filter((resource) => selectedPublishIds.has(resource.getId()))
434
457
  : pkg.listResources();
458
+ const skeletonDependencyImageIds = getSelectedSkeletonDependencyImageIds(allResources);
435
459
  // Process resources in declaration order (matching editor behavior)
436
460
  const orderedResources = await resolveEditorCompatibleResourceOrder(pkg, allResources, options);
437
461
  const resourceOrder = new Map(orderedResources.map((resource, index) => [resource.getId(), index]));
438
462
  const inputOrder = new Map(allResources.map((resource, index) => [resource.getId(), index]));
439
463
  const orderedAllResources = sortResourcesByOrder(allResources, resourceOrder, inputOrder);
440
- const hasPackable = allResources.some((resource) => isPackableResource(resource));
464
+ const hasPackable = allResources.some((resource) => {
465
+ if (isImageResource(resource) && skeletonDependencyImageIds.has(resource.getId())) return false;
466
+ return isPackableResource(resource);
467
+ });
441
468
  if (!hasPackable) continue;
442
469
 
443
470
  // Collect packable items in declaration order
@@ -531,169 +558,81 @@ export function atlas(_options: AtlasOptions = {}): Transform {
531
558
  if (isImageResource(res)) {
532
559
  // Pack referenced images, plus explicitly exported standalone images.
533
560
  const resId = res.getId();
534
- if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
561
+ if (skeletonDependencyImageIds.has(resId)) continue;
562
+ if (selectedPublishIds.size === 0 && !res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
535
563
  await _collectImage(res, pkg, inputs, encoder, options, doTrim, logger);
536
564
  } else if (isMovieClipResource(res)) {
537
565
  const resId = res.getId();
538
- if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
566
+ if (selectedPublishIds.size === 0 && !res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
539
567
  await _collectMovieClipFrames(doc, res, pkg, inputs, encoder, options, logger);
540
568
  } else if (isFontResource(res)) {
541
569
  const resId = res.getId();
542
- if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
570
+ if (selectedPublishIds.size === 0 && !res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
543
571
  await _collectFontTexture(doc, res, pkg, options);
544
572
  }
545
573
  }
546
574
 
547
575
  if (inputs.length === 0) continue;
548
- const branchGroups = buildBranchAtlasGroups(doc, inputs, options);
549
576
  let totalPageCount = 0;
550
577
  let usedDirectOutput = false;
578
+ const { autoInputs, fixedPageGroups, standaloneGroups, reservedPageIndexes } = groupStandaloneInputs(doc, inputs, options);
579
+ const branchGroups = buildBranchAtlasGroups(doc, autoInputs, options);
580
+ const branchPageOffsets = new Map<number, number>();
551
581
 
552
582
  for (const group of branchGroups) {
553
- const directOutput = resolveDirectImageOutput(group.inputs, options);
583
+ const directOutput = fixedPageGroups.length === 0 && standaloneGroups.length === 0
584
+ ? resolveDirectImageOutput(group.inputs, options)
585
+ : null;
554
586
  if (directOutput) {
555
587
  await emitDirectImageOutput(doc, pkg, directOutput, encoder, options, logger, group.branchName, group.branchOrdinal);
556
588
  usedDirectOutput = true;
557
589
  totalPageCount += 1;
558
590
  continue;
559
591
  }
560
-
561
- const hasDuplicatePadding = group.inputs.some((i) => {
562
- return isImageResource(i.resource) && i.resource.getDuplicatePadding?.() === true;
592
+ const pageStart = reserveAutoPageStart(branchPageOffsets, group.branchOrdinal, reservedPageIndexes);
593
+ const emittedPageCount = await emitPagedAtlasGroup(doc, pkg, allResources, group.inputs, {
594
+ branchName: group.branchName,
595
+ branchOrdinal: group.branchOrdinal,
596
+ pageStart,
597
+ fileNameAt: (pageIndex) => resolveAtlasOutputFileName(pkg, pageIndex, group.branchName),
598
+ options,
599
+ encoder,
600
+ logger,
563
601
  });
602
+ totalPageCount += emittedPageCount;
603
+ branchPageOffsets.set(group.branchOrdinal, pageStart + emittedPageCount);
604
+ }
564
605
 
565
- const packer = new MaxRectsPackerCompat({
566
- pot: options.powerOfTwo,
567
- mof: !options.powerOfTwo,
568
- padding: options.padding,
569
- rotation: options.allowRotation,
570
- minWidth: 16,
571
- minHeight: 16,
572
- maxWidth: options.maxSize,
573
- maxHeight: options.maxSize,
574
- square: options.square,
575
- fast: options.fast,
576
- edgePadding: false,
577
- duplicatePadding: hasDuplicatePadding,
578
- multiPage: options.multiPage,
579
- preserveInputOrderOnTie: options.preserveInputOrderOnTie,
606
+ for (const group of fixedPageGroups) {
607
+ const emittedPageCount = await emitPagedAtlasGroup(doc, pkg, allResources, group.inputs, {
608
+ branchName: group.branchName,
609
+ branchOrdinal: group.branchOrdinal,
610
+ pageStart: group.pageIndex,
611
+ forceSinglePage: true,
612
+ fileNameAt: () => resolveAtlasOutputFileName(pkg, group.pageIndex, group.branchName),
613
+ options,
614
+ encoder,
615
+ logger,
580
616
  });
581
- const pages = packer.pack(group.inputs.map((input, index) => inputToCompatRect(input, index)));
582
- if (!pages || pages.length === 0) continue;
583
- totalPageCount += pages.length;
584
-
585
- for (let p = 0; p < pages.length; p++) {
586
- const page = pages[p];
587
- const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(group.branchOrdinal, p)}`);
588
- atlasNode.setIndex(resolveAtlasIndex(group.branchOrdinal, p));
589
- atlasNode.setFile(resolveAtlasOutputFileName(pkg, p, group.branchName));
590
- atlasNode.setWidth(page.width);
591
- atlasNode.setHeight(page.height);
592
- pkg.addAtlas(atlasNode);
593
-
594
- for (const pr of page.outputRects) {
595
- const input = group.inputs[pr.index];
596
- if (!input) continue;
597
- const packedSize = resolvePackedRectSize(input, pr.width, pr.height, pr.rotated);
598
- const rotated = pr.rotated;
599
- const sprite = doc.createSprite();
600
- sprite.setItemId(input.id);
601
- sprite.setRectX(pr.x);
602
- sprite.setRectY(pr.y);
603
- sprite.setRectWidth(packedSize.width);
604
- sprite.setRectHeight(packedSize.height);
605
- sprite.setRotated(rotated);
606
- sprite.setOffsetX(input.offsetX);
607
- sprite.setOffsetY(input.offsetY);
608
- sprite.setOriginalWidth(input.originalWidth);
609
- sprite.setOriginalHeight(input.originalHeight);
610
- sprite.setAtlas(atlasNode);
611
- atlasNode.addSprite(sprite);
612
- }
613
-
614
- for (const res of allResources) {
615
- if (!isFontResource(res)) continue;
616
- const fextras = res.getExtras() as FontResourceExtras;
617
- const alias = fextras?._fontSpriteAlias;
618
- if (!alias) continue;
619
- const imgSprite = page.outputRects.find((result) => group.inputs[result.index]?.id === alias.textureId);
620
- if (!imgSprite) continue;
621
- const imgInput = group.inputs[imgSprite.index];
622
- const fontSprite = doc.createSprite();
623
- fontSprite.setItemId(alias.fontId);
624
- fontSprite.setRectX(imgSprite.x);
625
- fontSprite.setRectY(imgSprite.y);
626
- fontSprite.setRectWidth(imgSprite.width);
627
- fontSprite.setRectHeight(imgSprite.height);
628
- fontSprite.setRotated(imgSprite.rotated);
629
- if (imgInput) {
630
- fontSprite.setOffsetX(imgInput.offsetX);
631
- fontSprite.setOffsetY(imgInput.offsetY);
632
- fontSprite.setOriginalWidth(imgInput.originalWidth);
633
- fontSprite.setOriginalHeight(imgInput.originalHeight);
634
- }
635
- fontSprite.setAtlas(atlasNode);
636
- atlasNode.addSprite(fontSprite);
637
- }
638
- }
617
+ totalPageCount += emittedPageCount;
618
+ }
639
619
 
640
- if (encoder && options.outputPath) {
641
- if (options.mkdir) {
642
- await options.mkdir(options.outputPath);
643
- }
644
- for (let p = 0; p < pages.length; p++) {
645
- const page = pages[p];
646
- const compositeInputs: Array<{ input: Uint8Array; left: number; top: number }> = [];
647
-
648
- for (const pr of page.outputRects) {
649
- const input = group.inputs[pr.index];
650
- if (!input) continue;
651
- if (pr.width <= 0 || pr.height <= 0 || input.width <= 0 || input.height <= 0) continue;
652
- try {
653
- let imgBuffer: Uint8Array;
654
-
655
- if (input.trimBuffer) {
656
- imgBuffer = input.trimBuffer;
657
- if (imgBuffer.length === 0) continue;
658
- } else {
659
- if (!isImageResource(input.resource)) {
660
- logger.warn(`atlas: Non-image input "${input.id}" is missing inline buffer, skipping compositing.`);
661
- continue;
662
- }
663
- const filePath = _resolveImagePath(input.resource, pkg, options.basePath!);
664
- imgBuffer = await encoder(filePath).toBuffer();
665
- }
666
-
667
- if (pr.rotated) imgBuffer = await encoder(imgBuffer).rotate(270).toBuffer();
668
-
669
- compositeInputs.push({
670
- input: imgBuffer,
671
- left: pr.x,
672
- top: pr.y,
673
- });
674
- } catch {
675
- logger.warn(`atlas: Could not read image "${input.id}" for compositing.`);
676
- }
677
- }
620
+ const standalonePageOffsets = new Map(branchPageOffsets);
621
+ for (const group of fixedPageGroups) {
622
+ const nextPageIndex = group.pageIndex + 1;
623
+ const current = standalonePageOffsets.get(group.branchOrdinal) ?? 0;
624
+ if (nextPageIndex > current) standalonePageOffsets.set(group.branchOrdinal, nextPageIndex);
625
+ }
678
626
 
679
- const atlasFileName = resolveAtlasOutputFileName(pkg, p, group.branchName);
680
- const outputFile = `${options.outputPath}/${atlasFileName}`;
681
-
682
- await encoder({
683
- create: {
684
- width: page.width,
685
- height: page.height,
686
- channels: 4 as const,
687
- background: { r: 0, g: 0, b: 0, alpha: 0 },
688
- },
689
- })
690
- .composite(compositeInputs)
691
- .png()
692
- .toFile(outputFile);
693
-
694
- logger.info(`atlas: Generated ${atlasFileName} (${page.width}x${page.height}, ${page.outputRects.length} sprites)`);
695
- }
696
- }
627
+ for (const group of standaloneGroups) {
628
+ const emittedPageCount = await emitStandaloneAtlasGroup(doc, pkg, group, {
629
+ atlasIndexStart: standalonePageOffsets.get(group.branchOrdinal) ?? 0,
630
+ options,
631
+ encoder,
632
+ logger,
633
+ });
634
+ totalPageCount += emittedPageCount;
635
+ standalonePageOffsets.set(group.branchOrdinal, (standalonePageOffsets.get(group.branchOrdinal) ?? 0) + emittedPageCount);
697
636
  }
698
637
 
699
638
  if (usedDirectOutput) {
@@ -747,6 +686,251 @@ function buildBranchAtlasGroups(doc: Document, inputs: InputItem[], options: Atl
747
686
  }));
748
687
  }
749
688
 
689
+ function reserveAutoPageStart(
690
+ branchPageOffsets: Map<number, number>,
691
+ branchOrdinal: number,
692
+ reservedPageIndexes: Set<number>,
693
+ ): number {
694
+ let pageIndex = branchPageOffsets.get(branchOrdinal) ?? 0;
695
+ while (branchOrdinal === 0 && reservedPageIndexes.has(pageIndex)) {
696
+ pageIndex += 1;
697
+ }
698
+ return pageIndex;
699
+ }
700
+
701
+ async function emitPagedAtlasGroup(
702
+ doc: Document,
703
+ pkg: Package,
704
+ allResources: PackageResource[],
705
+ inputs: InputItem[],
706
+ context: {
707
+ branchName: string;
708
+ branchOrdinal: number;
709
+ pageStart: number;
710
+ fileNameAt: (pageIndex: number) => string;
711
+ options: AtlasOptions;
712
+ encoder: AtlasEncoder | undefined;
713
+ logger: ILogger;
714
+ forceSinglePage?: boolean;
715
+ },
716
+ ): Promise<number> {
717
+ if (inputs.length === 0) return 0;
718
+ const pages = packAtlasPages(inputs, context.options, context.forceSinglePage === true);
719
+ if (pages.length === 0) return 0;
720
+
721
+ for (let pageOffset = 0; pageOffset < pages.length; pageOffset += 1) {
722
+ const page = pages[pageOffset];
723
+ const pageIndex = context.pageStart + pageOffset;
724
+ const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(context.branchOrdinal, pageIndex)}`);
725
+ atlasNode.setIndex(resolveAtlasIndex(context.branchOrdinal, pageIndex));
726
+ atlasNode.setFile(context.fileNameAt(pageIndex));
727
+ atlasNode.setWidth(page.width);
728
+ atlasNode.setHeight(page.height);
729
+ pkg.addAtlas(atlasNode);
730
+
731
+ attachSpritesToAtlas(doc, allResources, inputs, page.outputRects, atlasNode);
732
+ await writeAtlasPageImage(pkg, inputs, page, atlasNode.getFile(), context.encoder, context.options, context.logger);
733
+ }
734
+
735
+ return pages.length;
736
+ }
737
+
738
+ async function emitStandaloneAtlasGroup(
739
+ doc: Document,
740
+ pkg: Package,
741
+ group: StandaloneAtlasGroup,
742
+ context: {
743
+ atlasIndexStart: number;
744
+ options: AtlasOptions;
745
+ encoder: AtlasEncoder | undefined;
746
+ logger: ILogger;
747
+ },
748
+ ): Promise<number> {
749
+ if (group.inputs.length === 0) return 0;
750
+ const pages = packAtlasPages(
751
+ group.inputs,
752
+ context.options,
753
+ true,
754
+ group.sizeMode === 'npot'
755
+ ? { powerOfTwo: false, multipleOfFour: false, square: false }
756
+ : group.sizeMode === 'multipleOf4'
757
+ ? { powerOfTwo: false, multipleOfFour: true, square: false }
758
+ : undefined,
759
+ );
760
+ if (pages.length === 0) return 0;
761
+
762
+ for (let pageOffset = 0; pageOffset < pages.length; pageOffset += 1) {
763
+ const page = pages[pageOffset];
764
+ const baseFileName = resolveStandaloneAtlasOutputFileName(pkg, group.resource, group.branchName);
765
+ const atlasFileName = pages.length <= 1 ? baseFileName : insertFileNameSuffix(baseFileName, `_${pageOffset}`);
766
+ const atlasIndex = context.atlasIndexStart + pageOffset;
767
+ const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(group.branchOrdinal, atlasIndex)}`);
768
+ atlasNode.setIndex(resolveAtlasIndex(group.branchOrdinal, atlasIndex));
769
+ atlasNode.setFile(atlasFileName);
770
+ const standaloneSize = resolveStandaloneAtlasSize(page.width, page.height, group.sizeMode, context.options);
771
+ atlasNode.setWidth(standaloneSize.width);
772
+ atlasNode.setHeight(standaloneSize.height);
773
+ pkg.addAtlas(atlasNode);
774
+
775
+ attachSpritesToAtlas(doc, [], group.inputs, page.outputRects, atlasNode);
776
+ await writeAtlasPageImage(
777
+ pkg,
778
+ group.inputs,
779
+ { ...page, width: standaloneSize.width, height: standaloneSize.height },
780
+ atlasFileName,
781
+ context.encoder,
782
+ context.options,
783
+ context.logger,
784
+ );
785
+ }
786
+
787
+ return pages.length;
788
+ }
789
+
790
+ function packAtlasPages(
791
+ inputs: InputItem[],
792
+ options: AtlasOptions,
793
+ forceSinglePage: boolean,
794
+ sizeOverrides?: {
795
+ powerOfTwo: boolean;
796
+ multipleOfFour: boolean;
797
+ square: boolean;
798
+ },
799
+ ): ReturnType<MaxRectsPackerCompat['pack']> {
800
+ const hasDuplicatePadding = inputs.some((input) => {
801
+ return isImageResource(input.resource) && input.resource.getDuplicatePadding?.() === true;
802
+ });
803
+ const packer = new MaxRectsPackerCompat({
804
+ pot: sizeOverrides?.powerOfTwo ?? options.powerOfTwo,
805
+ mof: sizeOverrides?.multipleOfFour ?? !options.powerOfTwo,
806
+ padding: options.padding,
807
+ rotation: options.allowRotation,
808
+ minWidth: 16,
809
+ minHeight: 16,
810
+ maxWidth: options.maxSize,
811
+ maxHeight: options.maxSize,
812
+ square: sizeOverrides?.square ?? options.square,
813
+ fast: options.fast,
814
+ edgePadding: false,
815
+ duplicatePadding: hasDuplicatePadding,
816
+ multiPage: forceSinglePage ? false : options.multiPage,
817
+ preserveInputOrderOnTie: options.preserveInputOrderOnTie,
818
+ });
819
+ return packer.pack(inputs.map((input, index) => inputToCompatRect(input, index))) ?? [];
820
+ }
821
+
822
+ function attachSpritesToAtlas(
823
+ doc: Document,
824
+ allResources: PackageResource[],
825
+ inputs: InputItem[],
826
+ outputRects: Array<{ index: number; x: number; y: number; width: number; height: number; rotated: boolean }>,
827
+ atlasNode: ReturnType<Document['createAtlas']>,
828
+ ): void {
829
+ for (const packedRect of outputRects) {
830
+ const input = inputs[packedRect.index];
831
+ if (!input) continue;
832
+ const packedSize = resolvePackedRectSize(input, packedRect.width, packedRect.height, packedRect.rotated);
833
+ const sprite = doc.createSprite();
834
+ sprite.setItemId(input.id);
835
+ sprite.setRectX(packedRect.x);
836
+ sprite.setRectY(packedRect.y);
837
+ sprite.setRectWidth(packedSize.width);
838
+ sprite.setRectHeight(packedSize.height);
839
+ sprite.setRotated(packedRect.rotated);
840
+ sprite.setOffsetX(input.offsetX);
841
+ sprite.setOffsetY(input.offsetY);
842
+ sprite.setOriginalWidth(input.originalWidth);
843
+ sprite.setOriginalHeight(input.originalHeight);
844
+ sprite.setAtlas(atlasNode);
845
+ atlasNode.addSprite(sprite);
846
+ }
847
+
848
+ for (const resource of allResources) {
849
+ if (!isFontResource(resource)) continue;
850
+ const extras = resource.getExtras() as FontResourceExtras;
851
+ const alias = extras?._fontSpriteAlias;
852
+ if (!alias) continue;
853
+ const imageSprite = outputRects.find((result) => inputs[result.index]?.id === alias.textureId);
854
+ if (!imageSprite) continue;
855
+ const imageInput = inputs[imageSprite.index];
856
+ const fontSprite = doc.createSprite();
857
+ fontSprite.setItemId(alias.fontId);
858
+ fontSprite.setRectX(imageSprite.x);
859
+ fontSprite.setRectY(imageSprite.y);
860
+ fontSprite.setRectWidth(imageSprite.width);
861
+ fontSprite.setRectHeight(imageSprite.height);
862
+ fontSprite.setRotated(imageSprite.rotated);
863
+ if (imageInput) {
864
+ fontSprite.setOffsetX(imageInput.offsetX);
865
+ fontSprite.setOffsetY(imageInput.offsetY);
866
+ fontSprite.setOriginalWidth(imageInput.originalWidth);
867
+ fontSprite.setOriginalHeight(imageInput.originalHeight);
868
+ }
869
+ fontSprite.setAtlas(atlasNode);
870
+ atlasNode.addSprite(fontSprite);
871
+ }
872
+ }
873
+
874
+ async function writeAtlasPageImage(
875
+ pkg: Package,
876
+ inputs: InputItem[],
877
+ page: { width: number; height: number; outputRects: Array<{ index: number; x: number; y: number; width: number; height: number; rotated: boolean }> },
878
+ atlasFileName: string,
879
+ encoder: AtlasEncoder | undefined,
880
+ options: AtlasOptions,
881
+ logger: ILogger,
882
+ ): Promise<void> {
883
+ if (!encoder || !options.outputPath) return;
884
+ if (options.mkdir) {
885
+ await options.mkdir(options.outputPath);
886
+ }
887
+
888
+ const compositeInputs: Array<{ input: Uint8Array; left: number; top: number }> = [];
889
+ for (const packedRect of page.outputRects) {
890
+ const input = inputs[packedRect.index];
891
+ if (!input) continue;
892
+ if (packedRect.width <= 0 || packedRect.height <= 0 || input.width <= 0 || input.height <= 0) continue;
893
+ try {
894
+ let imageBuffer: Uint8Array;
895
+ if (input.trimBuffer) {
896
+ imageBuffer = input.trimBuffer;
897
+ if (imageBuffer.length === 0) continue;
898
+ } else if (input.rasterizedBuffer) {
899
+ imageBuffer = input.rasterizedBuffer;
900
+ } else {
901
+ if (!isImageResource(input.resource)) {
902
+ logger.warn(`atlas: Non-image input "${input.id}" is missing inline buffer, skipping compositing.`);
903
+ continue;
904
+ }
905
+ const filePath = _resolveImagePath(input.resource, pkg, options.basePath!);
906
+ imageBuffer = await encoder(filePath).toBuffer();
907
+ }
908
+ if (packedRect.rotated) imageBuffer = await encoder(imageBuffer).rotate(270).toBuffer();
909
+ compositeInputs.push({
910
+ input: imageBuffer,
911
+ left: packedRect.x,
912
+ top: packedRect.y,
913
+ });
914
+ } catch {
915
+ logger.warn(`atlas: Could not read image "${input.id}" for compositing.`);
916
+ }
917
+ }
918
+
919
+ const outputFile = `${options.outputPath}/${atlasFileName}`;
920
+ await encoder({
921
+ create: {
922
+ width: page.width,
923
+ height: page.height,
924
+ channels: 4 as const,
925
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
926
+ },
927
+ })
928
+ .composite(compositeInputs)
929
+ .toFile(outputFile);
930
+
931
+ logger.info(`atlas: Generated ${atlasFileName} (${page.width}x${page.height}, ${page.outputRects.length} sprites)`);
932
+ }
933
+
750
934
  function inputToCompatRect(input: InputItem, index: number): CompatNodeRect {
751
935
  const duplicatePadding = isImageResource(input.resource) && input.resource.getDuplicatePadding?.() === true;
752
936
  return {
@@ -876,16 +1060,67 @@ function resolveAtlasOutputFileName(pkg: Package, pageIndex: number, branchName:
876
1060
  return `${pkg.getPublishName() || pkg.getName()}_atlas${pageIndex}${suffix}.png`;
877
1061
  }
878
1062
 
1063
+ function resolveStandaloneAtlasOutputFileName(
1064
+ pkg: Package,
1065
+ resource: PackInputResource,
1066
+ branchName: string,
1067
+ ): string {
1068
+ const baseName = `${pkg.getPublishName() || pkg.getName()}_atlas_${getPublishedItemId(resource)}`;
1069
+ const suffix = branchName ? `_${branchName}` : '';
1070
+ if (isImageResource(resource)) {
1071
+ const ext = extname(resolveImageFileName(resource)) || '.png';
1072
+ return `${baseName}${suffix}${ext}`;
1073
+ }
1074
+ return `${baseName}${suffix}.png`;
1075
+ }
1076
+
1077
+ function resolveStandaloneAtlasSize(
1078
+ width: number,
1079
+ height: number,
1080
+ sizeMode: StandaloneAtlasGroup['sizeMode'],
1081
+ options: AtlasOptions,
1082
+ ): { width: number; height: number } {
1083
+ if (sizeMode === 'npot') {
1084
+ return { width, height };
1085
+ }
1086
+ if (sizeMode === 'multipleOf4') {
1087
+ return {
1088
+ width: roundUpToMultiple(width, 4),
1089
+ height: roundUpToMultiple(height, 4),
1090
+ };
1091
+ }
1092
+ return resolveDirectOutputAtlasSize(width, height, options);
1093
+ }
1094
+
879
1095
  function resolveImageFileName(resource: ImageResource): string {
880
1096
  const extras = resource.getExtras() as ImageResourceExtras;
881
1097
  return resource.getFileName() || extras._fileName || resource.getName();
882
1098
  }
883
1099
 
1100
+ function extname(fileName: string): string {
1101
+ const normalized = fileName.replace(/\\/g, '/');
1102
+ const lastSlash = normalized.lastIndexOf('/');
1103
+ const lastDot = normalized.lastIndexOf('.');
1104
+ if (lastDot <= lastSlash) return '';
1105
+ return normalized.slice(lastDot);
1106
+ }
1107
+
1108
+ function insertFileNameSuffix(fileName: string, suffix: string): string {
1109
+ const extension = extname(fileName);
1110
+ if (!extension) return `${fileName}${suffix}`;
1111
+ return `${fileName.slice(0, -extension.length)}${suffix}${extension}`;
1112
+ }
1113
+
884
1114
  function nextPow2(value: number): number {
885
1115
  if (value <= 1) return 1;
886
1116
  return 2 ** Math.ceil(Math.log2(value));
887
1117
  }
888
1118
 
1119
+ function roundUpToMultiple(value: number, base: number): number {
1120
+ if (value <= 0) return 0;
1121
+ return Math.ceil(value / base) * base;
1122
+ }
1123
+
889
1124
  function sortResourcesByOrder(
890
1125
  resources: PackageResource[],
891
1126
  orderMap: Map<string, number>,
@@ -929,6 +1164,101 @@ interface ExtractedJtaData {
929
1164
  meta?: ExtractedJtaMeta;
930
1165
  }
931
1166
 
1167
+ function getResourceTextureSetMode(resource: PackInputResource): TextureSetMode {
1168
+ if (isImageResource(resource)) {
1169
+ return parseTextureSetMode(resource.getTextureSetMode?.());
1170
+ }
1171
+ return parseTextureSetMode(resource.getTextureSetMode?.());
1172
+ }
1173
+
1174
+ function groupStandaloneInputs(
1175
+ doc: Document,
1176
+ inputs: InputItem[],
1177
+ options: AtlasOptions,
1178
+ ): {
1179
+ autoInputs: InputItem[];
1180
+ fixedPageGroups: PagedAtlasGroup[];
1181
+ standaloneGroups: StandaloneAtlasGroup[];
1182
+ reservedPageIndexes: Set<number>;
1183
+ } {
1184
+ const autoInputs: InputItem[] = [];
1185
+ const fixedInputsByPage = new Map<string, PagedAtlasGroup>();
1186
+ const standaloneGroups = new Map<string, StandaloneAtlasGroup>();
1187
+ const reservedPageIndexes = new Set<number>();
1188
+ const discoveredBranchNames = [...new Set(inputs
1189
+ .map((input) => getInputBranchName(input))
1190
+ .filter((branchName) => !!branchName))];
1191
+ const orderedBranchNames = doc.getRoot().listBranches().filter((branchName) => discoveredBranchNames.includes(branchName));
1192
+ for (const branchName of discoveredBranchNames) {
1193
+ if (!orderedBranchNames.includes(branchName)) orderedBranchNames.push(branchName);
1194
+ }
1195
+ const branchOrdinalByName = new Map<string, number>();
1196
+ branchOrdinalByName.set('', 0);
1197
+ if (options.separatedAtlasForBranch) {
1198
+ let ordinal = 1;
1199
+ for (const branchName of orderedBranchNames) {
1200
+ branchOrdinalByName.set(branchName, ordinal++);
1201
+ }
1202
+ } else {
1203
+ for (const branchName of orderedBranchNames) {
1204
+ branchOrdinalByName.set(branchName, 0);
1205
+ }
1206
+ }
1207
+
1208
+ for (const input of inputs) {
1209
+ const branchName = getInputBranchName(input);
1210
+ const branchOrdinal = branchOrdinalByName.get(branchName) ?? 0;
1211
+ const mode = getResourceTextureSetMode(input.resource);
1212
+ if (mode.kind === 'standalone') {
1213
+ const resourceId = getPublishedItemId(input.resource);
1214
+ const key = `${branchName}\u0000${resourceId}`;
1215
+ const existing = standaloneGroups.get(key);
1216
+ if (existing) {
1217
+ existing.inputs.push(input);
1218
+ } else {
1219
+ standaloneGroups.set(key, {
1220
+ resource: input.resource,
1221
+ branchName,
1222
+ branchOrdinal,
1223
+ sizeMode: mode.sizeMode,
1224
+ inputs: [input],
1225
+ });
1226
+ }
1227
+ continue;
1228
+ }
1229
+ if (mode.kind === 'page') {
1230
+ reservedPageIndexes.add(mode.pageIndex);
1231
+ const key = `${branchName}\u0000${mode.pageIndex}`;
1232
+ const existing = fixedInputsByPage.get(key);
1233
+ if (existing) {
1234
+ existing.inputs.push(input);
1235
+ } else {
1236
+ fixedInputsByPage.set(key, {
1237
+ pageIndex: mode.pageIndex,
1238
+ branchName,
1239
+ branchOrdinal,
1240
+ inputs: [input],
1241
+ });
1242
+ }
1243
+ continue;
1244
+ }
1245
+ autoInputs.push(input);
1246
+ }
1247
+
1248
+ return {
1249
+ autoInputs,
1250
+ fixedPageGroups: [...fixedInputsByPage.values()].sort((left, right) =>
1251
+ left.branchOrdinal - right.branchOrdinal
1252
+ || left.pageIndex - right.pageIndex,
1253
+ ),
1254
+ standaloneGroups: [...standaloneGroups.values()].sort((left, right) =>
1255
+ left.branchOrdinal - right.branchOrdinal
1256
+ || getPublishedItemId(left.resource).localeCompare(getPublishedItemId(right.resource)),
1257
+ ),
1258
+ reservedPageIndexes,
1259
+ };
1260
+ }
1261
+
932
1262
  /**
933
1263
  * Trim transparent edges from an image using sharp.
934
1264
  * Returns the trimmed buffer, dimensions, and offsets.
@@ -936,12 +1266,12 @@ interface ExtractedJtaData {
936
1266
  */
937
1267
  async function _trimImage(
938
1268
  encoder: AtlasEncoder,
939
- filePath: string,
1269
+ input: AtlasEncoderInput,
940
1270
  originalWidth: number,
941
1271
  originalHeight: number,
942
1272
  ): Promise<TrimInfo> {
943
1273
  try {
944
- const trimResult = await encoder(filePath)
1274
+ const trimResult = await encoder(input)
945
1275
  .ensureAlpha()
946
1276
  .raw()
947
1277
  .toBuffer({ resolveWithObject: true });
@@ -982,7 +1312,7 @@ async function _trimImage(
982
1312
 
983
1313
  const trimmedWidth = maxX - minX + 1;
984
1314
  const trimmedHeight = maxY - minY + 1;
985
- const buffer = await encoder(filePath)
1315
+ const buffer = await encoder(input)
986
1316
  .extract({
987
1317
  left: minX,
988
1318
  top: minY,
@@ -1002,7 +1332,7 @@ async function _trimImage(
1002
1332
  };
1003
1333
  } catch {
1004
1334
  // Trim failed (e.g. JPEG without alpha, nothing to trim) — return original
1005
- const buf = await encoder(filePath).png().toBuffer();
1335
+ const buf = await encoder(input).png().toBuffer();
1006
1336
  return {
1007
1337
  buffer: buf,
1008
1338
  width: originalWidth,
@@ -1035,10 +1365,25 @@ type InputItem = {
1035
1365
  id: string; width: number; height: number;
1036
1366
  originalWidth: number; originalHeight: number;
1037
1367
  offsetX: number; offsetY: number;
1038
- resource: PackInputResource; trimBuffer?: Uint8Array;
1368
+ resource: PackInputResource; trimBuffer?: Uint8Array; rasterizedBuffer?: Uint8Array;
1039
1369
  sourceKind: 'image' | 'movieclip-frame';
1040
1370
  };
1041
1371
 
1372
+ interface StandaloneAtlasGroup {
1373
+ resource: PackInputResource;
1374
+ branchName: string;
1375
+ branchOrdinal: number;
1376
+ sizeMode: 'default' | 'npot' | 'multipleOf4';
1377
+ inputs: InputItem[];
1378
+ }
1379
+
1380
+ interface PagedAtlasGroup {
1381
+ pageIndex: number;
1382
+ branchName: string;
1383
+ branchOrdinal: number;
1384
+ inputs: InputItem[];
1385
+ }
1386
+
1042
1387
  /** Collect a single ImageResource into the inputs array. */
1043
1388
  async function _collectImage(
1044
1389
  resource: ImageResource,
@@ -1051,7 +1396,10 @@ async function _collectImage(
1051
1396
  ): Promise<void> {
1052
1397
  let origW = resource.getWidth() ?? 0;
1053
1398
  let origH = resource.getHeight() ?? 0;
1399
+ const declaredWidth = origW;
1400
+ const declaredHeight = origH;
1054
1401
  let sourceHasAlpha = false;
1402
+ let rasterizedBuffer: Uint8Array | undefined;
1055
1403
 
1056
1404
  if (encoder && options.basePath) {
1057
1405
  const filePath = _resolveImagePath(resource, pkg, options.basePath);
@@ -1064,6 +1412,13 @@ async function _collectImage(
1064
1412
  resource.setHeight(origH);
1065
1413
  }
1066
1414
  sourceHasAlpha = metadata.hasAlpha === true || metadata.channels === 4;
1415
+ if (/\.svg$/i.test(resolveImageFileName(resource)) && declaredWidth > 0 && declaredHeight > 0) {
1416
+ rasterizedBuffer = await encoder(filePath)
1417
+ .resize(declaredWidth, declaredHeight, { fit: 'fill' })
1418
+ .png()
1419
+ .toBuffer();
1420
+ sourceHasAlpha = true;
1421
+ }
1067
1422
  } catch {
1068
1423
  if (origW === 0 || origH === 0) {
1069
1424
  logger.warn(`atlas: Could not read image "${filePath}", skipping.`);
@@ -1080,7 +1435,7 @@ async function _collectImage(
1080
1435
  if (doTrim && sourceHasAlpha && options.basePath && encoder) {
1081
1436
  const filePath = _resolveImagePath(resource, pkg, options.basePath);
1082
1437
  try {
1083
- const trimResult = await _trimImage(encoder, filePath, origW, origH);
1438
+ const trimResult = await _trimImage(encoder, rasterizedBuffer ?? filePath, origW, origH);
1084
1439
  packW = trimResult.width;
1085
1440
  packH = trimResult.height;
1086
1441
  offX = trimResult.offsetX;
@@ -1097,6 +1452,7 @@ async function _collectImage(
1097
1452
  offsetX: offX, offsetY: offY,
1098
1453
  resource,
1099
1454
  trimBuffer: trimBuf,
1455
+ rasterizedBuffer,
1100
1456
  sourceKind: 'image',
1101
1457
  });
1102
1458
  }