@openfairygui/functions 0.2.0-alpha.34 → 0.2.0-alpha.36

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/jta.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import {
2
+ deriveMovieClipModel,
3
+ parseJta,
4
+ probeRasterImage,
5
+ type RasterImageFormat,
6
+ } from '@openfairygui/core';
7
+ import type { AtlasRasterBackend } from '../publish/contracts.js';
8
+
1
9
  export interface JtaFrameMeta {
2
10
  addDelay: number;
3
11
  offsetX: number;
@@ -18,194 +26,132 @@ export interface JtaMeta {
18
26
 
19
27
  export interface ExtractedJtaData {
20
28
  frames: Uint8Array[];
21
- meta?: JtaMeta;
29
+ meta: JtaMeta;
22
30
  }
23
31
 
24
- const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
25
-
26
- export function extractJtaFrames(data: Uint8Array): ExtractedJtaData {
27
- const frames: Uint8Array[] = [];
28
- let offset = 0;
29
- let firstPngOffset = -1;
30
-
31
- while (offset < data.length) {
32
- const signatureIndex = findPngSignature(data, offset);
33
- if (signatureIndex === -1) break;
34
- if (firstPngOffset === -1) firstPngOffset = signatureIndex;
35
- const end = findPngEnd(data, signatureIndex);
36
- if (end === -1) break;
37
- frames.push(data.subarray(signatureIndex, end));
38
- offset = end;
39
- }
32
+ export interface PreparedJtaTexture {
33
+ textureIndex: number;
34
+ firstFrameIndex: number;
35
+ /** PNG bytes validated during preflight and reused by atlas compositing. */
36
+ buffer: Uint8Array;
37
+ width: number;
38
+ height: number;
39
+ }
40
40
 
41
- if (firstPngOffset === -1 || frames.length === 0) {
42
- return { frames: [] };
43
- }
41
+ export interface PreparedJtaData extends ExtractedJtaData {
42
+ referencedTextures: PreparedJtaTexture[];
43
+ }
44
44
 
45
+ export function extractJtaFrames(data: Uint8Array): ExtractedJtaData {
46
+ const parsed = parseJta(data);
47
+ const derived = deriveMovieClipModel(parsed);
45
48
  return {
46
- frames,
47
- meta: parseJtaHeader(data, firstPngOffset, frames.length),
49
+ frames: parsed.textures.map((texture) => texture.raw),
50
+ meta: {
51
+ interval: derived.interval,
52
+ repeatDelay: derived.repeatDelay,
53
+ swing: derived.swing,
54
+ width: derived.dimensions.width,
55
+ height: derived.dimensions.height,
56
+ frames: derived.frames.map((frame) => ({
57
+ addDelay: frame.addDelay,
58
+ offsetX: frame.rectX,
59
+ offsetY: frame.rectY,
60
+ width: frame.rectWidth,
61
+ height: frame.rectHeight,
62
+ textureIndex: frame.textureIndex,
63
+ })),
64
+ },
48
65
  };
49
66
  }
50
67
 
51
- function findPngSignature(data: Uint8Array, fromIndex: number): number {
52
- for (let index = fromIndex; index <= data.length - PNG_SIGNATURE.length; index += 1) {
53
- let matched = true;
54
- for (let signatureIndex = 0; signatureIndex < PNG_SIGNATURE.length; signatureIndex += 1) {
55
- if (data[index + signatureIndex] !== PNG_SIGNATURE[signatureIndex]) {
56
- matched = false;
57
- break;
58
- }
59
- }
60
- if (matched) return index;
68
+ function detectSupportedRasterFormat(data: Uint8Array): RasterImageFormat | null {
69
+ if (
70
+ data.length >= 8 &&
71
+ data[0] === 0x89 &&
72
+ data[1] === 0x50 &&
73
+ data[2] === 0x4e &&
74
+ data[3] === 0x47 &&
75
+ data[4] === 0x0d &&
76
+ data[5] === 0x0a &&
77
+ data[6] === 0x1a &&
78
+ data[7] === 0x0a
79
+ ) {
80
+ return 'png';
61
81
  }
62
- return -1;
82
+ if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8) return 'jpeg';
83
+ return null;
63
84
  }
64
85
 
65
- function findPngEnd(data: Uint8Array, start: number): number {
66
- let position = start + PNG_SIGNATURE.length;
67
- while (position + 8 <= data.length) {
68
- const length = readUint32BE(data, position);
69
- position += 8;
70
- if (position + length + 4 > data.length) return -1;
71
- const isEnd =
72
- data[position - 4] === 0x49 &&
73
- data[position - 3] === 0x45 &&
74
- data[position - 2] === 0x4e &&
75
- data[position - 1] === 0x44;
76
- position += length + 4;
77
- if (isEnd) return position;
78
- }
79
- return -1;
86
+ function couldNotDecode(filePath: string, frameIndex: number, textureIndex: number): Error {
87
+ return new Error(
88
+ `atlas: Could not decode MovieClip "${filePath}" frame ${frameIndex} (texture ${textureIndex}).`,
89
+ );
80
90
  }
81
91
 
82
- function parseJtaHeader(data: Uint8Array, firstPngOffset: number, frameCount: number): JtaMeta | undefined {
83
- if (data.length < 10) return undefined;
84
-
85
- const state = { offset: 0 };
86
- const end = Math.min(firstPngOffset, data.length);
87
- const mark = readUtfBE(data, state, end);
88
- if (!mark) return undefined;
89
-
90
- const version = readInt32BEAt(data, state, end);
91
- if (version == null) return undefined;
92
-
93
- const fpsRaw = readInt8At(data, state, end);
94
- if (fpsRaw == null) return undefined;
95
- const fps = fpsRaw > 0 ? fpsRaw : 24;
96
-
97
- if (state.offset + 3 > end) return undefined;
98
- state.offset += 3;
99
-
100
- if (version < 102) return undefined;
101
-
102
- readUint16BEAt(data, state, end);
103
- readUint16BEAt(data, state, end);
104
- const width = readUint16BEAt(data, state, end);
105
- const height = readUint16BEAt(data, state, end);
106
- if (width == null || height == null) return undefined;
92
+ export async function prepareJtaForPublish(
93
+ data: Uint8Array,
94
+ encoder: AtlasRasterBackend | undefined,
95
+ filePath: string,
96
+ ): Promise<PreparedJtaData> {
97
+ const extracted = extractJtaFrames(data);
98
+ const firstFrameIndexByTextureIndex = new Map<number, number>();
99
+
100
+ for (let frameIndex = 0; frameIndex < extracted.meta.frames.length; frameIndex += 1) {
101
+ const textureIndex = extracted.meta.frames[frameIndex]!.textureIndex;
102
+ if (textureIndex >= 0 && !firstFrameIndexByTextureIndex.has(textureIndex)) {
103
+ firstFrameIndexByTextureIndex.set(textureIndex, frameIndex);
104
+ }
105
+ }
107
106
 
108
- const speedRaw = readUint8At(data, state, end);
109
- const repeatDelayRaw = readUint8At(data, state, end);
110
- const swingRaw = readInt8At(data, state, end);
111
- const frameTableCount = readInt16BEAt(data, state, end);
112
- if (speedRaw == null || repeatDelayRaw == null || swingRaw == null || frameTableCount == null) return undefined;
107
+ const referencedTextures: PreparedJtaTexture[] = [];
108
+ for (let textureIndex = 0; textureIndex < extracted.frames.length; textureIndex += 1) {
109
+ const firstFrameIndex = firstFrameIndexByTextureIndex.get(textureIndex);
110
+ if (firstFrameIndex === undefined) continue;
111
+ const raw = extracted.frames[textureIndex]!;
112
+ if (raw.byteLength === 0) {
113
+ throw new Error(
114
+ `atlas: MovieClip "${filePath}" frame ${firstFrameIndex} references empty texture ${textureIndex}.`,
115
+ );
116
+ }
117
+ const detectedFormat = detectSupportedRasterFormat(raw);
118
+ if (!detectedFormat) {
119
+ throw new Error(
120
+ `atlas: MovieClip "${filePath}" frame ${firstFrameIndex} (texture ${textureIndex}) uses an unsupported ` +
121
+ 'raster format; only PNG and JPEG are supported.',
122
+ );
123
+ }
124
+ const imageInfo = probeRasterImage(raw);
125
+ if (!imageInfo || imageInfo.format !== detectedFormat) {
126
+ throw couldNotDecode(filePath, firstFrameIndex, textureIndex);
127
+ }
113
128
 
114
- const frames: JtaFrameMeta[] = [];
115
- for (let index = 0; index < frameTableCount; index += 1) {
116
- const delayRaw = readInt16BEAt(data, state, end);
117
- const offsetX = readInt16BEAt(data, state, end);
118
- const offsetY = readInt16BEAt(data, state, end);
119
- const frameWidth = readInt16BEAt(data, state, end);
120
- const frameHeight = readInt16BEAt(data, state, end);
121
- const textureIndex = readInt16BEAt(data, state, end);
122
- if (
123
- delayRaw == null ||
124
- offsetX == null ||
125
- offsetY == null ||
126
- frameWidth == null ||
127
- frameHeight == null ||
128
- textureIndex == null
129
- ) {
130
- break;
129
+ let buffer = raw;
130
+ if (encoder) {
131
+ try {
132
+ buffer = await encoder(raw).png().toBuffer();
133
+ } catch {
134
+ throw couldNotDecode(filePath, firstFrameIndex, textureIndex);
135
+ }
136
+ const normalizedInfo = probeRasterImage(buffer);
137
+ if (
138
+ !normalizedInfo ||
139
+ normalizedInfo.format !== 'png' ||
140
+ normalizedInfo.width !== imageInfo.width ||
141
+ normalizedInfo.height !== imageInfo.height
142
+ ) {
143
+ throw couldNotDecode(filePath, firstFrameIndex, textureIndex);
144
+ }
131
145
  }
132
- frames.push({
133
- addDelay: Math.trunc((1000 / fps) * delayRaw),
134
- offsetX,
135
- offsetY,
136
- width: frameWidth,
137
- height: frameHeight,
146
+
147
+ referencedTextures.push({
138
148
  textureIndex,
149
+ firstFrameIndex,
150
+ buffer,
151
+ width: imageInfo.width,
152
+ height: imageInfo.height,
139
153
  });
140
154
  }
141
155
 
142
- return {
143
- interval: Math.trunc((1000 / fps) * (speedRaw || 1)),
144
- repeatDelay: Math.trunc((1000 / fps) * repeatDelayRaw),
145
- swing: swingRaw === 1,
146
- width,
147
- height,
148
- frames: frames.length === 0 && frameCount > 0 ? [] : frames,
149
- };
150
- }
151
-
152
- function readUtfBE(data: Uint8Array, state: { offset: number }, end: number): string | null {
153
- const length = readUint16BEAt(data, state, end);
154
- if (length == null || state.offset + length > end) return null;
155
- const value = new TextDecoder().decode(data.subarray(state.offset, state.offset + length));
156
- state.offset += length;
157
- return value;
158
- }
159
-
160
- function readUint8At(data: Uint8Array, state: { offset: number }, end: number): number | null {
161
- if (state.offset + 1 > end) return null;
162
- const value = data[state.offset];
163
- state.offset += 1;
164
- return value ?? 0;
165
- }
166
-
167
- function readInt8At(data: Uint8Array, state: { offset: number }, end: number): number | null {
168
- if (state.offset + 1 > end) return null;
169
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
170
- const value = view.getInt8(state.offset);
171
- state.offset += 1;
172
- return value;
173
- }
174
-
175
- function readUint16BEAt(data: Uint8Array, state: { offset: number }, end: number): number | null {
176
- if (state.offset + 2 > end) return null;
177
- const value = readUint16BE(data, state.offset);
178
- state.offset += 2;
179
- return value;
180
- }
181
-
182
- function readInt16BEAt(data: Uint8Array, state: { offset: number }, end: number): number | null {
183
- if (state.offset + 2 > end) return null;
184
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
185
- const value = view.getInt16(state.offset, false);
186
- state.offset += 2;
187
- return value;
188
- }
189
-
190
- function readInt32BEAt(data: Uint8Array, state: { offset: number }, end: number): number | null {
191
- if (state.offset + 4 > end) return null;
192
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
193
- const value = view.getInt32(state.offset, false);
194
- state.offset += 4;
195
- return value;
196
- }
197
-
198
- function readUint16BE(data: Uint8Array, offset: number): number {
199
- if (offset + 1 >= data.length) return 0;
200
- return (data[offset] << 8) | data[offset + 1];
201
- }
202
-
203
- function readUint32BE(data: Uint8Array, offset: number): number {
204
- if (offset + 3 >= data.length) return 0;
205
- return (
206
- data[offset] * 0x1000000 +
207
- ((data[offset + 1] ?? 0) << 16) +
208
- ((data[offset + 2] ?? 0) << 8) +
209
- (data[offset + 3] ?? 0)
210
- );
156
+ return { ...extracted, referencedTextures };
211
157
  }
package/src/atlas.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  type Document,
4
4
  GearType,
5
5
  type Package,
6
+ type MovieClipResource,
6
7
  type Transform,
7
8
  TransitionActionType,
8
9
  } from '@openfairygui/core';
@@ -27,6 +28,7 @@ import {
27
28
  type PackageResource,
28
29
  } from './atlas/inputs.js';
29
30
  import { emitAtlasInputs, sortResourcesByOrder } from './atlas/packing.js';
31
+ import type { PreparedJtaData } from './atlas/jta.js';
30
32
 
31
33
  export interface AtlasOptions {
32
34
  /**
@@ -106,6 +108,12 @@ export interface AtlasOptions {
106
108
  */
107
109
  readFileRaw?: (path: string) => Promise<Uint8Array>;
108
110
 
111
+ /**
112
+ * MovieClip parse/decode results prepared by publish() before output begins.
113
+ * @internal
114
+ */
115
+ preparedMovieClips?: ReadonlyMap<MovieClipResource, PreparedJtaData>;
116
+
109
117
  /**
110
118
  * Keep original input order when MaxRects tie-break scores are equal.
111
119
  * This is an internal publish detail used to mirror editor/CLI behavior.
@@ -133,7 +141,10 @@ export interface AtlasOptions {
133
141
  }
134
142
 
135
143
  const ATLAS_DEFAULTS: Required<
136
- Omit<AtlasOptions, 'packages' | 'encoder' | 'basePath' | 'outputPath' | 'mkdir' | 'readFileRaw'>
144
+ Omit<
145
+ AtlasOptions,
146
+ 'packages' | 'encoder' | 'basePath' | 'outputPath' | 'mkdir' | 'readFileRaw' | 'preparedMovieClips'
147
+ >
137
148
  > = {
138
149
  maxSize: 2048,
139
150
  fast: true,
package/src/publish.ts CHANGED
@@ -3,16 +3,21 @@ import {
3
3
  type BinaryWriterOptions,
4
4
  type Document,
5
5
  type FileSystem,
6
+ type MovieClipResource,
6
7
  type Package,
7
8
  type Transform,
8
9
  } from '@openfairygui/core';
9
10
  import { atlas } from './atlas.js';
11
+ import { prepareMovieClipResource } from './atlas/inputs.js';
12
+ import type { PreparedJtaData } from './atlas/jta.js';
10
13
  import { publishCodeGeneration, resolveProjectBasePath } from './codegen.js';
11
14
  import { dirname, isAbsolutePathLike, trimTrailingSlashes } from './path-utils.js';
12
15
  import { formatPluginError, type LoadedPlugin } from './plugins/types.js';
13
16
  import type { PublishFileSystem } from './publish/contracts.js';
14
17
  import {
15
18
  annotatePackagePublishArtifacts,
19
+ getAnnotatedPublishedResourceIds,
20
+ isMovieClipResource,
16
21
  } from './publish/package-context.js';
17
22
  import {
18
23
  exportPackageExternalResources,
@@ -217,7 +222,12 @@ export function publish(options: PublishOptions): Transform {
217
222
  },
218
223
  });
219
224
 
220
- const publishPackage = async (plan: ResolvedPackagePublishPlan, writerFs: FileSystem, packageIndex: number) => {
225
+ const publishPackage = async (
226
+ plan: ResolvedPackagePublishPlan,
227
+ writerFs: FileSystem,
228
+ packageIndex: number,
229
+ preparedMovieClips?: ReadonlyMap<MovieClipResource, PreparedJtaData>,
230
+ ) => {
221
231
  if (options.fs && !plan.outputDir) {
222
232
  throw new Error(
223
233
  'publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.',
@@ -253,6 +263,7 @@ export function publish(options: PublishOptions): Transform {
253
263
  mkdir: options.fs ? options.fs.mkdir : undefined,
254
264
  readFileRaw: options.atlas?.readFileRaw ?? options.fs?.readFileRaw,
255
265
  strictOutput: options.fs !== undefined,
266
+ preparedMovieClips,
256
267
  packages: [plan.pkg.getName()],
257
268
  ...atlasRuntimeOptions,
258
269
  })(doc);
@@ -340,11 +351,43 @@ export function publish(options: PublishOptions): Transform {
340
351
  );
341
352
  }
342
353
 
354
+ // publishPackage starts with mkdir and loose-resource writes. Preflight the complete
355
+ // selected MovieClip set first so a failure in a later package leaves zero output.
356
+ const publishedMovieClips = allPackages.flatMap((pkg) => {
357
+ const publishedResourceIds = getAnnotatedPublishedResourceIds(pkg);
358
+ return pkg
359
+ .listResources()
360
+ .filter((resource): resource is MovieClipResource => {
361
+ return publishedResourceIds.has(resource.getId()) && isMovieClipResource(resource);
362
+ })
363
+ .map((resource) => ({ pkg, resource }));
364
+ });
365
+ const preparedMovieClips = new Map<MovieClipResource, PreparedJtaData>();
366
+ if (publishedMovieClips.length > 0) {
367
+ if (!options.encoder) {
368
+ throw new Error('publish: MovieClip output requires an encoder.');
369
+ }
370
+ if (!options.basePath) {
371
+ throw new Error('publish: MovieClip output requires basePath.');
372
+ }
373
+ const readFileRaw = options.atlas?.readFileRaw ?? options.fs.readFileRaw;
374
+ if (!readFileRaw) {
375
+ throw new Error('publish: MovieClip output requires readFileRaw.');
376
+ }
377
+
378
+ for (const { pkg, resource } of publishedMovieClips) {
379
+ preparedMovieClips.set(
380
+ resource,
381
+ await prepareMovieClipResource(resource, pkg, options.encoder, options.basePath, readFileRaw),
382
+ );
383
+ }
384
+ }
385
+
343
386
  const writerFs = toBinaryWriterFileSystem(options.fs);
344
387
 
345
388
  for (const plan of plans) {
346
389
  const pkgIndex = allDocPackages.indexOf(plan.pkg);
347
- await publishPackage(plan, writerFs, pkgIndex);
390
+ await publishPackage(plan, writerFs, pkgIndex, preparedMovieClips);
348
391
  }
349
392
 
350
393
  if (options.codeGeneration !== false) {
package/src/restore.ts CHANGED
@@ -466,6 +466,16 @@ class RestoreWorkflow {
466
466
  common: {},
467
467
  adaptation: {},
468
468
  });
469
+ for (const pkg of doc.getRoot().listPackages()) {
470
+ pkg.setSourceAtlasSettings({
471
+ ...pkg.getSourceAtlasSettings(),
472
+ atlases: pkg.listAtlases().map((atlas) => ({
473
+ index: atlas.getIndex(),
474
+ name: atlas.getIndex() === 0 ? 'Default' : atlas.getName(),
475
+ compression: false,
476
+ })),
477
+ });
478
+ }
469
479
  }
470
480
 
471
481
  private _initializeImageFileNames(doc: Document): void {