@triflare/fx 0.0.0-stage → 1.0.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/LICENSE +201 -0
- package/README.md +73 -2
- package/bin/fx.js +949 -0
- package/package.json +64 -4
- package/product.yml +28 -0
- package/src/brand.js +105 -0
- package/src/build.js +181 -0
- package/src/catalog.js +753 -0
- package/src/compiler.js +1658 -0
- package/src/config-edit.js +338 -0
- package/src/config.js +400 -0
- package/src/decompiler.js +504 -0
- package/src/dev.js +390 -0
- package/src/errors.js +56 -0
- package/src/extensions.js +707 -0
- package/src/index.js +31 -0
- package/src/language.js +12 -0
- package/src/lexer.js +202 -0
- package/src/log.js +70 -0
- package/src/package.js +202 -0
- package/src/packager.js +177 -0
- package/src/parser.js +608 -0
- package/src/product.js +27 -0
- package/src/project.js +156 -0
- package/src/sb3.js +340 -0
- package/src/sprite.js +81 -0
- package/vendor/pm-packager-dist/.fx-build.json +6 -0
- package/vendor/pm-packager-dist/LICENSE +202 -0
- package/vendor/pm-packager-dist/NOTICE +6 -0
- package/vendor/pm-packager-dist/dist/290e09e569a1cab8e61ba93b0d23863f.png +0 -0
- package/vendor/pm-packager-dist/dist/icns.js +1 -0
- package/vendor/pm-packager-dist/dist/packager.js +154 -0
- package/vendor/pm-packager-dist/dist/scaffolding/addons.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/extension worker.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-full.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-min.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/vendors~iframe-extension-worker.js +2 -0
- package/vendor/pm-packager-dist/dist/vendors~sha256.js +3 -0
- package/vendor/pm-packager-dist/package.json +3 -0
package/src/project.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-file project assembly.
|
|
3
|
+
*
|
|
4
|
+
* A Triflare Fx project can be spread across any number of `.tfx` files in a
|
|
5
|
+
* directory tree. This module discovers those files and merges their ASTs into
|
|
6
|
+
* a single `Program`, so the rest of the pipeline keeps working with one AST.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ParseError } from "./errors.js";
|
|
11
|
+
import { configFilename, sourceExtension } from "./brand.js";
|
|
12
|
+
|
|
13
|
+
/** Directories never scanned for `.tfx` sources. */
|
|
14
|
+
const IGNORED_DIRS = new Set([
|
|
15
|
+
"node_modules",
|
|
16
|
+
"build",
|
|
17
|
+
"dist",
|
|
18
|
+
"coverage",
|
|
19
|
+
".cache",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recursively find every `.tfx` file under `dir`, skipping hidden entries and
|
|
24
|
+
* well-known build/dependency directories. Results are deterministic (sorted by
|
|
25
|
+
* path) so builds are reproducible.
|
|
26
|
+
* @param {string} dir
|
|
27
|
+
* @returns {string[]} absolute file paths
|
|
28
|
+
*/
|
|
29
|
+
export function discoverSources(dir) {
|
|
30
|
+
const root = path.resolve(dir);
|
|
31
|
+
const found = [];
|
|
32
|
+
const extension = sourceExtension().toLowerCase();
|
|
33
|
+
|
|
34
|
+
const walk = (current) => {
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.name.startsWith(".")) continue;
|
|
44
|
+
const full = path.join(current, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
47
|
+
walk(full);
|
|
48
|
+
} else if (
|
|
49
|
+
entry.isFile() &&
|
|
50
|
+
entry.name.toLowerCase().endsWith(extension)
|
|
51
|
+
) {
|
|
52
|
+
found.push(full);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
walk(root);
|
|
58
|
+
return found.sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {object} loc */
|
|
62
|
+
function errorInfo(loc) {
|
|
63
|
+
return {
|
|
64
|
+
file: loc && loc.file,
|
|
65
|
+
line: loc && loc.line,
|
|
66
|
+
column: loc && loc.column,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Merge parsed programs into a single project AST.
|
|
72
|
+
*
|
|
73
|
+
* - At most one `stage { }` declaration is allowed across all files.
|
|
74
|
+
* - Top-level statements from every file become stage content.
|
|
75
|
+
* - At most one plain `sprite "Name" { }` declaration is allowed per name.
|
|
76
|
+
* Additional `partial sprite "Name" { }` fragments are appended to it.
|
|
77
|
+
* - Partial sprites only merge when `partialSprites` is enabled.
|
|
78
|
+
*
|
|
79
|
+
* Bodies are composed base-first, then fragments in build order, so costume
|
|
80
|
+
* order and local variable initializers stay predictable regardless of which
|
|
81
|
+
* file happens to sort first.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array<{ast: object, file: string}>} entries
|
|
84
|
+
* @param {{partialSprites?: boolean}} [options]
|
|
85
|
+
* @returns {object} a `Program` node
|
|
86
|
+
*/
|
|
87
|
+
export function mergePrograms(entries, { partialSprites = false } = {}) {
|
|
88
|
+
let stage = null;
|
|
89
|
+
const topItems = [];
|
|
90
|
+
/** @type {Map<string, {name: string, loc: object, base: object|null, fragments: object[], files: string[]}>} */
|
|
91
|
+
const groups = new Map();
|
|
92
|
+
|
|
93
|
+
for (const { ast, file } of entries) {
|
|
94
|
+
if (ast.stage) {
|
|
95
|
+
if (stage) {
|
|
96
|
+
throw new ParseError(
|
|
97
|
+
"A project may only declare one stage",
|
|
98
|
+
errorInfo(ast.stage.loc),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
stage = ast.stage;
|
|
102
|
+
}
|
|
103
|
+
topItems.push(...ast.topItems);
|
|
104
|
+
for (const sprite of ast.sprites) {
|
|
105
|
+
if (sprite.partial && !partialSprites) {
|
|
106
|
+
throw new ParseError(
|
|
107
|
+
`partial sprite ${JSON.stringify(sprite.name)} is disabled; ` +
|
|
108
|
+
`set "fx.partialSprites: true" in ${configFilename()} to enable it`,
|
|
109
|
+
errorInfo(sprite.loc),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let group = groups.get(sprite.name);
|
|
114
|
+
if (!group) {
|
|
115
|
+
group = {
|
|
116
|
+
name: sprite.name,
|
|
117
|
+
loc: sprite.loc,
|
|
118
|
+
base: null,
|
|
119
|
+
fragments: [],
|
|
120
|
+
files: [],
|
|
121
|
+
};
|
|
122
|
+
groups.set(sprite.name, group);
|
|
123
|
+
}
|
|
124
|
+
group.files.push(file);
|
|
125
|
+
|
|
126
|
+
if (sprite.partial) {
|
|
127
|
+
group.fragments.push({ decl: sprite, file });
|
|
128
|
+
} else if (group.base) {
|
|
129
|
+
throw new ParseError(
|
|
130
|
+
`Duplicate sprite ${JSON.stringify(sprite.name)} ` +
|
|
131
|
+
`(declared in ${group.base.file} and ${file}); ` +
|
|
132
|
+
`use \`partial sprite ${JSON.stringify(sprite.name)}\` to split it across files`,
|
|
133
|
+
errorInfo(sprite.loc),
|
|
134
|
+
);
|
|
135
|
+
} else {
|
|
136
|
+
group.base = { decl: sprite, file };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const sprites = [...groups.values()].map((group) => {
|
|
142
|
+
const base = group.base ? [group.base.decl] : [];
|
|
143
|
+
const fragments = group.fragments.map(({ decl }) => decl);
|
|
144
|
+
const [first] = base.length ? base : fragments;
|
|
145
|
+
return {
|
|
146
|
+
type: "SpriteDecl",
|
|
147
|
+
name: group.name,
|
|
148
|
+
body: [...base, ...fragments].flatMap((decl) => decl.body),
|
|
149
|
+
loc: first.loc,
|
|
150
|
+
partial: false,
|
|
151
|
+
files: group.files,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { type: "Program", stage, sprites, topItems };
|
|
156
|
+
}
|
package/src/sb3.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free ZIP reader/writer for Scratch 3 project archives
|
|
3
|
+
* (both `.sb3` and PenguinMod's `.pmp`, which share the same format) plus
|
|
4
|
+
* asset helpers.
|
|
5
|
+
*
|
|
6
|
+
* The Scratch 3 project format is a ZIP archive containing `project.json`
|
|
7
|
+
* alongside content-addressed asset files named `<md5>.<ext>`.
|
|
8
|
+
*/
|
|
9
|
+
import zlib from "node:zlib";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
|
|
12
|
+
// --------------------------------------------------------------------- crc32
|
|
13
|
+
|
|
14
|
+
let CRC_TABLE = null;
|
|
15
|
+
function crcTable() {
|
|
16
|
+
if (CRC_TABLE) return CRC_TABLE;
|
|
17
|
+
CRC_TABLE = new Uint32Array(256);
|
|
18
|
+
for (let n = 0; n < 256; n++) {
|
|
19
|
+
let c = n;
|
|
20
|
+
for (let k = 0; k < 8; k++) {
|
|
21
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
22
|
+
}
|
|
23
|
+
CRC_TABLE[n] = c >>> 0;
|
|
24
|
+
}
|
|
25
|
+
return CRC_TABLE;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {Buffer|Uint8Array} buf */
|
|
29
|
+
export function crc32(buf) {
|
|
30
|
+
const table = crcTable();
|
|
31
|
+
let crc = 0xffffffff;
|
|
32
|
+
for (let i = 0; i < buf.length; i++) {
|
|
33
|
+
crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
34
|
+
}
|
|
35
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {Buffer|Uint8Array} buf */
|
|
39
|
+
export function md5hex(buf) {
|
|
40
|
+
return crypto.createHash("md5").update(buf).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ----------------------------------------------------------------------- zip
|
|
44
|
+
|
|
45
|
+
const DOS_TIME = 0; // 00:00:00
|
|
46
|
+
const DOS_DATE = ((2024 - 1980) << 9) | (1 << 5) | 1; // 2024-01-01
|
|
47
|
+
|
|
48
|
+
// Upper bound on a single entry's uncompressed size, guarding against
|
|
49
|
+
// decompression bombs. 512 MiB is far beyond any realistic Scratch asset.
|
|
50
|
+
const MAX_UNCOMPRESSED_SIZE = 512 * 1024 * 1024;
|
|
51
|
+
// Cap cumulative output retained while extracting an archive as well.
|
|
52
|
+
const MAX_ARCHIVE_UNCOMPRESSED_SIZE = 512 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a ZIP archive entirely in memory.
|
|
56
|
+
* @param {Array<{name: string, data: Buffer|Uint8Array, compress?: boolean}>} files
|
|
57
|
+
* @returns {Buffer}
|
|
58
|
+
*/
|
|
59
|
+
export function zipSync(files) {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
const central = [];
|
|
62
|
+
let offset = 0;
|
|
63
|
+
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const nameBuf = Buffer.from(file.name, "utf8");
|
|
66
|
+
const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data);
|
|
67
|
+
const crc = crc32(raw);
|
|
68
|
+
let method = 0;
|
|
69
|
+
let stored = raw;
|
|
70
|
+
if (file.compress !== false && raw.length > 0) {
|
|
71
|
+
const deflated = zlib.deflateRawSync(raw, { level: 9 });
|
|
72
|
+
if (deflated.length < raw.length) {
|
|
73
|
+
method = 8;
|
|
74
|
+
stored = deflated;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const local = Buffer.alloc(30);
|
|
79
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
80
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
81
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 flag
|
|
82
|
+
local.writeUInt16LE(method, 8);
|
|
83
|
+
local.writeUInt16LE(DOS_TIME, 10);
|
|
84
|
+
local.writeUInt16LE(DOS_DATE, 12);
|
|
85
|
+
local.writeUInt32LE(crc, 14);
|
|
86
|
+
local.writeUInt32LE(stored.length, 18);
|
|
87
|
+
local.writeUInt32LE(raw.length, 22);
|
|
88
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
89
|
+
local.writeUInt16LE(0, 28);
|
|
90
|
+
chunks.push(local, nameBuf, stored);
|
|
91
|
+
|
|
92
|
+
const cd = Buffer.alloc(46);
|
|
93
|
+
cd.writeUInt32LE(0x02014b50, 0);
|
|
94
|
+
cd.writeUInt16LE(20, 4); // version made by
|
|
95
|
+
cd.writeUInt16LE(20, 6); // version needed
|
|
96
|
+
cd.writeUInt16LE(0x0800, 8);
|
|
97
|
+
cd.writeUInt16LE(method, 10);
|
|
98
|
+
cd.writeUInt16LE(DOS_TIME, 12);
|
|
99
|
+
cd.writeUInt16LE(DOS_DATE, 14);
|
|
100
|
+
cd.writeUInt32LE(crc, 16);
|
|
101
|
+
cd.writeUInt32LE(stored.length, 20);
|
|
102
|
+
cd.writeUInt32LE(raw.length, 24);
|
|
103
|
+
cd.writeUInt16LE(nameBuf.length, 28);
|
|
104
|
+
cd.writeUInt16LE(0, 30); // extra
|
|
105
|
+
cd.writeUInt16LE(0, 32); // comment
|
|
106
|
+
cd.writeUInt16LE(0, 34); // disk
|
|
107
|
+
cd.writeUInt16LE(0, 36); // internal attrs
|
|
108
|
+
cd.writeUInt32LE(0, 38); // external attrs
|
|
109
|
+
cd.writeUInt32LE(offset, 42);
|
|
110
|
+
central.push(cd, nameBuf);
|
|
111
|
+
|
|
112
|
+
offset += local.length + nameBuf.length + stored.length;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const centralBuf = Buffer.concat(central);
|
|
116
|
+
const eocd = Buffer.alloc(22);
|
|
117
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
118
|
+
eocd.writeUInt16LE(0, 4);
|
|
119
|
+
eocd.writeUInt16LE(0, 6);
|
|
120
|
+
eocd.writeUInt16LE(files.length, 8);
|
|
121
|
+
eocd.writeUInt16LE(files.length, 10);
|
|
122
|
+
eocd.writeUInt32LE(centralBuf.length, 12);
|
|
123
|
+
eocd.writeUInt32LE(offset, 16);
|
|
124
|
+
eocd.writeUInt16LE(0, 20);
|
|
125
|
+
|
|
126
|
+
return Buffer.concat([...chunks, centralBuf, eocd]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read a ZIP archive produced by {@link zipSync} (or any standard 32-bit zip).
|
|
131
|
+
* @param {Buffer|Uint8Array} buf
|
|
132
|
+
* @returns {Map<string, Buffer>}
|
|
133
|
+
*/
|
|
134
|
+
export function unzipSync(buf) {
|
|
135
|
+
const data = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
136
|
+
// Find the End Of Central Directory record.
|
|
137
|
+
let eocd = -1;
|
|
138
|
+
for (let i = data.length - 22; i >= 0; i--) {
|
|
139
|
+
if (data.readUInt32LE(i) === 0x06054b50) {
|
|
140
|
+
eocd = i;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (eocd < 0)
|
|
145
|
+
throw new Error("Not a ZIP archive (no end-of-central-directory record)");
|
|
146
|
+
|
|
147
|
+
const count = data.readUInt16LE(eocd + 10);
|
|
148
|
+
let ptr = data.readUInt32LE(eocd + 16);
|
|
149
|
+
const out = new Map();
|
|
150
|
+
let totalUncompressedSize = 0;
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < count; i++) {
|
|
153
|
+
if (data.readUInt32LE(ptr) !== 0x02014b50)
|
|
154
|
+
throw new Error("Corrupt ZIP central directory");
|
|
155
|
+
const method = data.readUInt16LE(ptr + 10);
|
|
156
|
+
const compSize = data.readUInt32LE(ptr + 20);
|
|
157
|
+
const uncompSize = data.readUInt32LE(ptr + 24);
|
|
158
|
+
const nameLen = data.readUInt16LE(ptr + 28);
|
|
159
|
+
const extraLen = data.readUInt16LE(ptr + 30);
|
|
160
|
+
const commentLen = data.readUInt16LE(ptr + 32);
|
|
161
|
+
const localOffset = data.readUInt32LE(ptr + 42);
|
|
162
|
+
const name = data.slice(ptr + 46, ptr + 46 + nameLen).toString("utf8");
|
|
163
|
+
|
|
164
|
+
if (uncompSize > MAX_UNCOMPRESSED_SIZE)
|
|
165
|
+
throw new Error(
|
|
166
|
+
`ZIP entry "${name}" exceeds the ${MAX_UNCOMPRESSED_SIZE}-byte size limit`,
|
|
167
|
+
);
|
|
168
|
+
const remainingSize = MAX_ARCHIVE_UNCOMPRESSED_SIZE - totalUncompressedSize;
|
|
169
|
+
if (uncompSize > remainingSize)
|
|
170
|
+
throw new Error(
|
|
171
|
+
`ZIP archive exceeds the ${MAX_ARCHIVE_UNCOMPRESSED_SIZE}-byte size limit`,
|
|
172
|
+
);
|
|
173
|
+
if (method !== 0 && method !== 8)
|
|
174
|
+
throw new Error(`Unsupported ZIP compression method: ${method}`);
|
|
175
|
+
|
|
176
|
+
const localNameLen = data.readUInt16LE(localOffset + 26);
|
|
177
|
+
const localExtraLen = data.readUInt16LE(localOffset + 28);
|
|
178
|
+
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
|
|
179
|
+
const raw = data.slice(dataStart, dataStart + compSize);
|
|
180
|
+
const uncompressed =
|
|
181
|
+
method === 8
|
|
182
|
+
? zlib.inflateRawSync(raw, {
|
|
183
|
+
// zlib requires a positive limit, even for an empty entry.
|
|
184
|
+
maxOutputLength: Math.max(
|
|
185
|
+
1,
|
|
186
|
+
Math.min(MAX_UNCOMPRESSED_SIZE, remainingSize),
|
|
187
|
+
),
|
|
188
|
+
})
|
|
189
|
+
: raw;
|
|
190
|
+
// Account for actual output too, in case a header understates its size.
|
|
191
|
+
totalUncompressedSize += Math.max(uncompSize, uncompressed.length);
|
|
192
|
+
if (totalUncompressedSize > MAX_ARCHIVE_UNCOMPRESSED_SIZE)
|
|
193
|
+
throw new Error(
|
|
194
|
+
`ZIP archive exceeds the ${MAX_ARCHIVE_UNCOMPRESSED_SIZE}-byte size limit`,
|
|
195
|
+
);
|
|
196
|
+
out.set(name, uncompressed);
|
|
197
|
+
|
|
198
|
+
ptr += 46 + nameLen + extraLen + commentLen;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// -------------------------------------------------------------------- assets
|
|
204
|
+
|
|
205
|
+
const EXT_FORMAT = {
|
|
206
|
+
svg: "svg",
|
|
207
|
+
png: "png",
|
|
208
|
+
jpg: "jpg",
|
|
209
|
+
jpeg: "jpg",
|
|
210
|
+
gif: "gif",
|
|
211
|
+
bmp: "bmp",
|
|
212
|
+
wav: "wav",
|
|
213
|
+
mp3: "mp3",
|
|
214
|
+
ogg: "ogg",
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {string} filePath
|
|
219
|
+
* @returns {string} the Scratch dataFormat for a path/URL.
|
|
220
|
+
*/
|
|
221
|
+
export function extensionOf(filePath) {
|
|
222
|
+
const clean = String(filePath).split(/[?#]/)[0];
|
|
223
|
+
const match = /\.([a-z0-9]+)$/i.exec(clean);
|
|
224
|
+
return match ? match[1].toLowerCase() : "";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read the pixel dimensions of an image buffer (SVG, PNG, JPG or GIF).
|
|
229
|
+
* Falls back to 0x0 when unknown.
|
|
230
|
+
* @param {Buffer} buffer
|
|
231
|
+
* @param {string} format
|
|
232
|
+
* @returns {{width: number, height: number}}
|
|
233
|
+
*/
|
|
234
|
+
export function imageSize(buffer, format) {
|
|
235
|
+
try {
|
|
236
|
+
if (
|
|
237
|
+
format === "png" &&
|
|
238
|
+
buffer.length >= 24 &&
|
|
239
|
+
buffer.readUInt32BE(0) === 0x89504e47
|
|
240
|
+
) {
|
|
241
|
+
return {
|
|
242
|
+
width: buffer.readUInt32BE(16),
|
|
243
|
+
height: buffer.readUInt32BE(20),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (format === "gif" && buffer.length >= 10) {
|
|
247
|
+
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
|
|
248
|
+
}
|
|
249
|
+
if (format === "jpg") {
|
|
250
|
+
let i = 2;
|
|
251
|
+
while (i < buffer.length) {
|
|
252
|
+
if (buffer[i] !== 0xff) {
|
|
253
|
+
i++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const marker = buffer[i + 1];
|
|
257
|
+
if (
|
|
258
|
+
marker >= 0xc0 &&
|
|
259
|
+
marker <= 0xcf &&
|
|
260
|
+
marker !== 0xc4 &&
|
|
261
|
+
marker !== 0xc8 &&
|
|
262
|
+
marker !== 0xcc
|
|
263
|
+
) {
|
|
264
|
+
return {
|
|
265
|
+
height: buffer.readUInt16BE(i + 5),
|
|
266
|
+
width: buffer.readUInt16BE(i + 7),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
i += 2 + buffer.readUInt16BE(i + 2);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (format === "svg") {
|
|
273
|
+
const text = buffer.toString("utf8");
|
|
274
|
+
const w = /<svg[^>]*\bwidth\s*=\s*["']([\d.]+)([^"']*)["']/i.exec(text);
|
|
275
|
+
const h = /<svg[^>]*\bheight\s*=\s*["']([\d.]+)([^"']*)["']/i.exec(text);
|
|
276
|
+
if (w && h && !w[2].includes("%") && !h[2].includes("%"))
|
|
277
|
+
return { width: Number(w[1]), height: Number(h[1]) };
|
|
278
|
+
const vb = /viewBox\s*=\s*["']([^"']+)["']/i.exec(text);
|
|
279
|
+
if (vb) {
|
|
280
|
+
const nums = vb[1]
|
|
281
|
+
.trim()
|
|
282
|
+
.split(/[\s,]+/)
|
|
283
|
+
.map(Number);
|
|
284
|
+
if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
|
|
285
|
+
return { width: nums[2], height: nums[3] };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
/* fall through to zero dimensions */
|
|
291
|
+
}
|
|
292
|
+
return { width: 0, height: 0 };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parse a WAV file header to recover rate + sample count.
|
|
297
|
+
* @param {Buffer} buffer
|
|
298
|
+
* @returns {{rate: number, sampleCount: number}}
|
|
299
|
+
*/
|
|
300
|
+
export function wavInfo(buffer) {
|
|
301
|
+
try {
|
|
302
|
+
if (
|
|
303
|
+
buffer.length >= 44 &&
|
|
304
|
+
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
|
305
|
+
buffer.toString("ascii", 8, 12) === "WAVE"
|
|
306
|
+
) {
|
|
307
|
+
let fmt = null;
|
|
308
|
+
// Locate the format and data chunks, skipping unknown chunks and padding.
|
|
309
|
+
let i = 12;
|
|
310
|
+
while (i + 8 <= buffer.length) {
|
|
311
|
+
const id = buffer.toString("ascii", i, i + 4);
|
|
312
|
+
const size = buffer.readUInt32LE(i + 4);
|
|
313
|
+
if (id === "fmt " && size >= 16) {
|
|
314
|
+
fmt = {
|
|
315
|
+
rate: buffer.readUInt32LE(i + 12),
|
|
316
|
+
bits: buffer.readUInt16LE(i + 22),
|
|
317
|
+
channels: buffer.readUInt16LE(i + 10),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (id === "data" && fmt) {
|
|
321
|
+
const bytesPerSample = Math.max(1, (fmt.bits / 8) * fmt.channels);
|
|
322
|
+
return {
|
|
323
|
+
rate: fmt.rate,
|
|
324
|
+
sampleCount: Math.floor(size / bytesPerSample),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
i += 8 + size + (size % 2);
|
|
328
|
+
}
|
|
329
|
+
return { rate: fmt?.rate ?? 48000, sampleCount: 0 };
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
/* ignore */
|
|
333
|
+
}
|
|
334
|
+
return { rate: 48000, sampleCount: 0 };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @returns {string} Normalise a costume dataFormat used in project.json. */
|
|
338
|
+
export function detectFormat(filePath) {
|
|
339
|
+
return EXT_FORMAT[extensionOf(filePath)] || extensionOf(filePath) || "png";
|
|
340
|
+
}
|
package/src/sprite.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-sprite archives (`.pms`) and imports (`.sprite3`/`.pms`).
|
|
3
|
+
*
|
|
4
|
+
* A sprite archive is the same ZIP Scratch uses for `.sprite3`: `sprite.json`
|
|
5
|
+
* plus content-addressed `<md5>.<ext>` assets at the archive root. PenguinMod
|
|
6
|
+
* documents the same `sprite.json` target layout for saved sprites.
|
|
7
|
+
*/
|
|
8
|
+
import { unzipSync, zipSync } from "./sb3.js";
|
|
9
|
+
import { decompileProject } from "./decompiler.js";
|
|
10
|
+
|
|
11
|
+
const ASSET_NAME = /^[0-9a-f]{32}\.[a-z0-9]+$/i;
|
|
12
|
+
|
|
13
|
+
/** Asset filenames a sprite references through its costumes and sounds. */
|
|
14
|
+
export function spriteAssets(sprite) {
|
|
15
|
+
const names = [];
|
|
16
|
+
for (const asset of [...(sprite.costumes ?? []), ...(sprite.sounds ?? [])]) {
|
|
17
|
+
if (asset && asset.md5ext) names.push(asset.md5ext);
|
|
18
|
+
}
|
|
19
|
+
return [...new Set(names)];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Decompile one sprite target into standalone `.tfx` source. */
|
|
23
|
+
export function spriteSource(sprite, options) {
|
|
24
|
+
return decompileProject({ targets: [sprite] }, options);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pack a sprite target and its assets into a `.pms` archive. */
|
|
28
|
+
export function spriteArchive(sprite, assets) {
|
|
29
|
+
const files = [
|
|
30
|
+
{ name: "sprite.json", data: Buffer.from(JSON.stringify(sprite)) },
|
|
31
|
+
];
|
|
32
|
+
for (const name of spriteAssets(sprite)) {
|
|
33
|
+
if (!ASSET_NAME.test(name))
|
|
34
|
+
throw new Error(`Unsafe asset name ${JSON.stringify(name)}`);
|
|
35
|
+
const data = assets.get(name);
|
|
36
|
+
if (!data)
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Missing asset ${name} for sprite ${JSON.stringify(sprite.name)}`,
|
|
39
|
+
);
|
|
40
|
+
files.push({ name, data });
|
|
41
|
+
}
|
|
42
|
+
return zipSync(files);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read a `.sprite3`/`.pms` archive, or bare `sprite.json` with no assets.
|
|
47
|
+
* @param {Buffer|Uint8Array} input
|
|
48
|
+
* @returns {{sprite: object, assets: Map<string, Buffer>}}
|
|
49
|
+
*/
|
|
50
|
+
export function readSprite(input) {
|
|
51
|
+
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
52
|
+
const text = bytes.toString("utf8").trimStart();
|
|
53
|
+
const assets = new Map();
|
|
54
|
+
|
|
55
|
+
let sprite;
|
|
56
|
+
if (text.startsWith("{")) {
|
|
57
|
+
sprite = JSON.parse(text);
|
|
58
|
+
if (spriteAssets(sprite).length > 0)
|
|
59
|
+
throw new Error(
|
|
60
|
+
"That JSON has costumes or sounds but no assets; use the .sprite3/.pms archive",
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
const files = unzipSync(bytes);
|
|
64
|
+
const json = files.get("sprite.json");
|
|
65
|
+
if (!json) throw new Error("Not a sprite archive: missing sprite.json");
|
|
66
|
+
sprite = JSON.parse(json.toString("utf8"));
|
|
67
|
+
for (const name of spriteAssets(sprite)) {
|
|
68
|
+
if (!ASSET_NAME.test(name))
|
|
69
|
+
throw new Error(`Unsafe asset name ${JSON.stringify(name)}`);
|
|
70
|
+
const data = files.get(name);
|
|
71
|
+
if (!data) throw new Error(`Sprite archive is missing asset ${name}`);
|
|
72
|
+
assets.set(name, data);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!sprite || typeof sprite !== "object" || Array.isArray(sprite))
|
|
77
|
+
throw new Error("sprite.json must contain a target object");
|
|
78
|
+
if (sprite.isStage) throw new Error("That archive is a stage, not a sprite");
|
|
79
|
+
if (!sprite.name) throw new Error("sprite.json has no name");
|
|
80
|
+
return { sprite, assets };
|
|
81
|
+
}
|