@scratch/scratch-media-lib-scripts 13.2.0-build-media-scripts
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 +661 -0
- package/package.json +50 -0
- package/schemas/media-collection-schema.json +214 -0
- package/scripts/build_media_libraries.js +307 -0
- package/scripts/tests/build_media_libraries.test.js +929 -0
- package/scripts/tests/fixtures/Abby-a.svg +30 -0
- package/scripts/tests/fixtures/Abby-b.svg +28 -0
- package/scripts/tests/fixtures/Arctic.png +0 -0
- package/scripts/tests/fixtures/Pop.wav +0 -0
- package/scripts/tests/helpers/input-helpers.js +142 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scratch/scratch-media-lib-scripts",
|
|
3
|
+
"version": "13.2.0-build-media-scripts",
|
|
4
|
+
"description": "Build scripts for Scratch media library assets",
|
|
5
|
+
"main": "scripts/build_media_libraries.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"build-media-libraries": "./scripts/build_media_libraries.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"scripts",
|
|
11
|
+
"schemas"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "node scripts/build_media_libraries.js media/editor",
|
|
18
|
+
"test": "npm run lint && jest",
|
|
19
|
+
"lint": "eslint .",
|
|
20
|
+
"format": "eslint --fix"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/scratchfoundation/scratch-editor.git"
|
|
25
|
+
},
|
|
26
|
+
"author": "Scratch Foundation",
|
|
27
|
+
"license": "AGPL-3.0-only",
|
|
28
|
+
"homepage": "https://github.com/scratchfoundation/scratch-media-lib-scripts#readme",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"json5": "^2.2.3"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"eslint": "9.39.4",
|
|
34
|
+
"eslint-config-scratch": "14.0.8",
|
|
35
|
+
"jest": "^30.3.0",
|
|
36
|
+
"jest-junit": "^16.0.0",
|
|
37
|
+
"prettier": "3.8.1"
|
|
38
|
+
},
|
|
39
|
+
"jest": {
|
|
40
|
+
"reporters": [
|
|
41
|
+
"default",
|
|
42
|
+
[
|
|
43
|
+
"jest-junit",
|
|
44
|
+
{
|
|
45
|
+
"outputDirectory": "test-results"
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schema.scratch.org/media-collection.json",
|
|
4
|
+
"title": "Media Collection",
|
|
5
|
+
"description": "A collection of media library items for use within Scratch.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"unevaluatedProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"description": "The JSON Schema version used for this document.",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"format": "uri"
|
|
13
|
+
},
|
|
14
|
+
"backdrops": {
|
|
15
|
+
"description": "A list of backdrops in this collection.",
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": { "$ref": "#/$defs/backdrop" },
|
|
18
|
+
"minItems": 1,
|
|
19
|
+
"uniqueItems": true
|
|
20
|
+
},
|
|
21
|
+
"costumes": {
|
|
22
|
+
"description": "A list of costumes in this collection.",
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"$ref": "#/$defs/costume"
|
|
26
|
+
},
|
|
27
|
+
"minItems": 1,
|
|
28
|
+
"uniqueItems": true
|
|
29
|
+
},
|
|
30
|
+
"sounds": {
|
|
31
|
+
"description": "A list of sounds in this collection.",
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": {
|
|
34
|
+
"$ref": "#/$defs/sound"
|
|
35
|
+
},
|
|
36
|
+
"minItems": 1,
|
|
37
|
+
"uniqueItems": true
|
|
38
|
+
},
|
|
39
|
+
"sprites": {
|
|
40
|
+
"description": "A list of sprites in this collection.",
|
|
41
|
+
"type": "array",
|
|
42
|
+
"items": {
|
|
43
|
+
"$ref": "#/$defs/sprite"
|
|
44
|
+
},
|
|
45
|
+
"minItems": 1,
|
|
46
|
+
"uniqueItems": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"$defs": {
|
|
50
|
+
"common": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"name": {
|
|
54
|
+
"description": "The name of this item, in English Title Case.",
|
|
55
|
+
"type": "string"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"required": ["name"]
|
|
59
|
+
},
|
|
60
|
+
"tags": {
|
|
61
|
+
"description": "Tags associated with the item, in English lower case. Used for filtering and searching.",
|
|
62
|
+
"type": "array",
|
|
63
|
+
"items": {
|
|
64
|
+
"type": "string"
|
|
65
|
+
},
|
|
66
|
+
"uniqueItems": true
|
|
67
|
+
},
|
|
68
|
+
"tagsInherit": {
|
|
69
|
+
"anyOf": [
|
|
70
|
+
{
|
|
71
|
+
"const": "inherit",
|
|
72
|
+
"description": "This item will inherit its tags from its parent sprite. Must have exactly one parent sprite defined in the same file."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"$ref": "#/$defs/tags"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
"image": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"file": {
|
|
83
|
+
"description": "The path to the image file.",
|
|
84
|
+
"type": "string",
|
|
85
|
+
"format": "data-url"
|
|
86
|
+
},
|
|
87
|
+
"bitmapResolution": {
|
|
88
|
+
"description": "The resolution multiplier for the bitmap image. New bitmaps should generally use 2.",
|
|
89
|
+
"type": "integer",
|
|
90
|
+
"minimum": 1,
|
|
91
|
+
"maximum": 2
|
|
92
|
+
},
|
|
93
|
+
"rotationCenterX": {
|
|
94
|
+
"description": "The X coordinate of the rotation center, relative to the left edge. Defaults to the image's natural center.",
|
|
95
|
+
"type": "number"
|
|
96
|
+
},
|
|
97
|
+
"rotationCenterY": {
|
|
98
|
+
"description": "The Y coordinate of the rotation center, relative to the top edge. Defaults to the image's natural center.",
|
|
99
|
+
"type": "number"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"required": ["file"]
|
|
103
|
+
},
|
|
104
|
+
"backdrop": {
|
|
105
|
+
"description": "A backdrop in the Scratch media library",
|
|
106
|
+
"allOf": [
|
|
107
|
+
{
|
|
108
|
+
"$ref": "#/$defs/common"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"$ref": "#/$defs/image"
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {
|
|
116
|
+
"tags": {
|
|
117
|
+
"$ref": "#/$defs/tags"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"required": ["file"],
|
|
121
|
+
"unevaluatedProperties": false
|
|
122
|
+
},
|
|
123
|
+
"costume": {
|
|
124
|
+
"description": "A costume in the Scratch media library",
|
|
125
|
+
"allOf": [
|
|
126
|
+
{
|
|
127
|
+
"$ref": "#/$defs/common"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"$ref": "#/$defs/image"
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {
|
|
135
|
+
"tags": {
|
|
136
|
+
"$ref": "#/$defs/tagsInherit"
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"required": ["file"],
|
|
140
|
+
"unevaluatedProperties": false
|
|
141
|
+
},
|
|
142
|
+
"sound": {
|
|
143
|
+
"description": "A sound in the Scratch media library",
|
|
144
|
+
"allOf": [
|
|
145
|
+
{
|
|
146
|
+
"$ref": "#/$defs/common"
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
"type": "object",
|
|
150
|
+
"properties": {
|
|
151
|
+
"tags": {
|
|
152
|
+
"$ref": "#/$defs/tagsInherit"
|
|
153
|
+
},
|
|
154
|
+
"file": {
|
|
155
|
+
"description": "The path to the audio file.",
|
|
156
|
+
"type": "string",
|
|
157
|
+
"format": "data-url"
|
|
158
|
+
},
|
|
159
|
+
"rate": {
|
|
160
|
+
"description": "The sampling rate of the audio file.",
|
|
161
|
+
"type": "integer",
|
|
162
|
+
"minimum": 1
|
|
163
|
+
},
|
|
164
|
+
"sampleCount": {
|
|
165
|
+
"description": "The number of samples in the audio file.",
|
|
166
|
+
"type": "integer",
|
|
167
|
+
"minimum": 0
|
|
168
|
+
},
|
|
169
|
+
"dataFormat": {
|
|
170
|
+
"description": "The format of the audio data.",
|
|
171
|
+
"type": "string",
|
|
172
|
+
"enum": ["wav", "adpcm", "mp3"]
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"required": ["file", "rate", "sampleCount"],
|
|
176
|
+
"unevaluatedProperties": false
|
|
177
|
+
},
|
|
178
|
+
"sprite": {
|
|
179
|
+
"description": "A sprite in the Scratch media library",
|
|
180
|
+
"allOf": [
|
|
181
|
+
{
|
|
182
|
+
"$ref": "#/$defs/common"
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
"type": "object",
|
|
186
|
+
"properties": {
|
|
187
|
+
"tags": {
|
|
188
|
+
"$ref": "#/$defs/tags"
|
|
189
|
+
},
|
|
190
|
+
"costumes": {
|
|
191
|
+
"description": "The list of costumes for the sprite.",
|
|
192
|
+
"type": "array",
|
|
193
|
+
"items": {
|
|
194
|
+
"type": "string",
|
|
195
|
+
"description": "The exact name of a costume defined in this or another file."
|
|
196
|
+
},
|
|
197
|
+
"minItems": 1,
|
|
198
|
+
"uniqueItems": true
|
|
199
|
+
},
|
|
200
|
+
"sounds": {
|
|
201
|
+
"description": "The list of sounds for the sprite.",
|
|
202
|
+
"type": "array",
|
|
203
|
+
"items": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"description": "The exact name of a sound defined in this or another file."
|
|
206
|
+
},
|
|
207
|
+
"uniqueItems": true
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"required": ["costumes"],
|
|
211
|
+
"unevaluatedProperties": false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const json5 = require('json5');
|
|
6
|
+
const { createHash } = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
// formatting constants
|
|
9
|
+
const INDENT = 4;
|
|
10
|
+
const ASSET_TYPES = {
|
|
11
|
+
costumes: 'costumes',
|
|
12
|
+
sounds: 'sounds',
|
|
13
|
+
sprites: 'sprites',
|
|
14
|
+
backdrops: 'backdrops'
|
|
15
|
+
};
|
|
16
|
+
const TAGS_BEHAVIOR = {
|
|
17
|
+
INHERIT: 'inherit',
|
|
18
|
+
NONE: 'none'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function parseArgs() {
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const config = {
|
|
24
|
+
inputDir: null,
|
|
25
|
+
// Should we output the constructed libraries to files or just return them?
|
|
26
|
+
outputDir: null,
|
|
27
|
+
// Should we fill costumes/sounds libs from the sprites definitions?
|
|
28
|
+
fillAssetsFromSprites: true
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
// TODO: Consider allowing custom output dir
|
|
33
|
+
if (args[i] === '--output' || args[i] === '-o') {
|
|
34
|
+
config.outputDir = 'output';
|
|
35
|
+
} else if (!config.inputDir) {
|
|
36
|
+
config.inputDir = args[i];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!config.inputDir) {
|
|
41
|
+
console.error('Usage: node build_media_libraries.js <input-dir> [--output|-o]');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
return config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function calculateMD5(filePath) {
|
|
48
|
+
const buffer = fs.readFileSync(filePath);
|
|
49
|
+
const hash = createHash('md5');
|
|
50
|
+
hash.update(buffer);
|
|
51
|
+
return hash.digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function calculateTags(assetDef, defaultBehaviour, parentTags = []) {
|
|
55
|
+
if (assetDef.tags === TAGS_BEHAVIOR.INHERIT) {
|
|
56
|
+
return parentTags;
|
|
57
|
+
} else if (Array.isArray(assetDef.tags)) {
|
|
58
|
+
return assetDef.tags;
|
|
59
|
+
} else {
|
|
60
|
+
return defaultBehaviour === TAGS_BEHAVIOR.INHERIT ? parentTags : [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Process a single asset definition (costume, sound, backdrop)
|
|
65
|
+
function processAssetDefinition(assetType, assetDef, dirPath, parentTags = []) {
|
|
66
|
+
const assetFilePath = path.resolve(dirPath, assetDef.file);
|
|
67
|
+
|
|
68
|
+
if (!fs.existsSync(assetFilePath)) {
|
|
69
|
+
console.warn(`Warning: File not found: ${assetFilePath} (referenced by ${assetDef.name})`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const md5 = calculateMD5(assetFilePath);
|
|
74
|
+
const ext = path.extname(assetFilePath).toLowerCase().substring(1); // e.g. svg
|
|
75
|
+
const md5ext = `${md5}.${ext}`;
|
|
76
|
+
|
|
77
|
+
// Construct the output object
|
|
78
|
+
let outputAsset = {
|
|
79
|
+
name: assetDef.name,
|
|
80
|
+
assetId: md5,
|
|
81
|
+
md5ext: md5ext,
|
|
82
|
+
dataFormat: ext,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (assetType === ASSET_TYPES.costumes || assetType === ASSET_TYPES.backdrops) {
|
|
86
|
+
outputAsset = {
|
|
87
|
+
...outputAsset,
|
|
88
|
+
bitmapResolution: assetDef.bitmapResolution || 1,
|
|
89
|
+
rotationCenterX: assetDef.rotationCenterX,
|
|
90
|
+
rotationCenterY: assetDef.rotationCenterY,
|
|
91
|
+
tags: calculateTags(assetDef, TAGS_BEHAVIOR.INHERIT, parentTags),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (assetType === ASSET_TYPES.sounds) {
|
|
96
|
+
outputAsset = {
|
|
97
|
+
...outputAsset,
|
|
98
|
+
tags: calculateTags(assetDef, TAGS_BEHAVIOR.NONE, parentTags),
|
|
99
|
+
rate: assetDef.rate,
|
|
100
|
+
sampleCount: assetDef.sampleCount
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (assetType === ASSET_TYPES.sprites) {
|
|
105
|
+
outputAsset = {
|
|
106
|
+
...outputAsset,
|
|
107
|
+
tags: calculateTags(assetDef, TAGS_BEHAVIOR.NONE, parentTags),
|
|
108
|
+
isStage: assetDef.isStage || false,
|
|
109
|
+
...{ variables: assetDef.variables || {} },
|
|
110
|
+
...{ blocks: assetDef.blocks || {} }
|
|
111
|
+
// TODO: Should we preserve other possible properties (no such cases exist in the current editor libraries):
|
|
112
|
+
// `lists`, `broadcasts`, `currentCostume`, `comments`, etc.?
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return outputAsset;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildLibraries(config) {
|
|
120
|
+
const { inputDir, outputDir, fillAssetsFromSprites } = config;
|
|
121
|
+
|
|
122
|
+
const collections = {
|
|
123
|
+
sprites: [],
|
|
124
|
+
backdrops: [],
|
|
125
|
+
costumes: [],
|
|
126
|
+
sounds: []
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const processAssetType = (type) => {
|
|
130
|
+
const fullPath = path.join(inputDir, type);
|
|
131
|
+
if (!fs.existsSync(fullPath)) return;
|
|
132
|
+
|
|
133
|
+
const itemFolders = fs.readdirSync(fullPath, { withFileTypes: true })
|
|
134
|
+
.filter(d => d.isDirectory());
|
|
135
|
+
|
|
136
|
+
for (const dirent of itemFolders) {
|
|
137
|
+
const folderPath = path.join(fullPath, dirent.name);
|
|
138
|
+
const files = fs.readdirSync(folderPath).filter(f => f.endsWith('.json') || f.endsWith('.json5'));
|
|
139
|
+
if (files.length === 0) {
|
|
140
|
+
console.warn(`No definition JSON found in ${folderPath}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Prefer file matching folder name
|
|
145
|
+
const defFile = files.find(f => f.startsWith(dirent.name)) || files[0];
|
|
146
|
+
|
|
147
|
+
let content;
|
|
148
|
+
try {
|
|
149
|
+
content = fs.readFileSync(path.join(folderPath, defFile), 'utf8');
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error(`Failed to read definition file ${defFile} in ${folderPath}: ${e.message}`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let collectionDef;
|
|
156
|
+
try {
|
|
157
|
+
collectionDef = json5.parse(content);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(`Error parsing definition file ${defFile}:`, err.message);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
Object.values(ASSET_TYPES).includes(type) &&
|
|
164
|
+
type !== ASSET_TYPES.sprites &&
|
|
165
|
+
collectionDef[type] &&
|
|
166
|
+
Array.isArray(collectionDef[type])
|
|
167
|
+
) {
|
|
168
|
+
collectionDef[type].forEach(def => {
|
|
169
|
+
const processedItem = processAssetDefinition(type, def, folderPath, []);
|
|
170
|
+
|
|
171
|
+
if (processedItem) {
|
|
172
|
+
collections[type].push(processedItem);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
} else if (type === ASSET_TYPES.sprites && collectionDef.sprites && Array.isArray(collectionDef.sprites)) {
|
|
176
|
+
// Preprocess costumes and sounds for lookup
|
|
177
|
+
const fileCostumes = {};
|
|
178
|
+
const fileSounds = {};
|
|
179
|
+
|
|
180
|
+
// TODO: Should we assume that it's possible to have multiple sprites in a single definition file?
|
|
181
|
+
const parentSpriteTags = collectionDef.sprites[0]?.tags || [];
|
|
182
|
+
|
|
183
|
+
// TODO: Merge these cases with the standalone ones
|
|
184
|
+
if (collectionDef.costumes && Array.isArray(collectionDef.costumes)) {
|
|
185
|
+
collectionDef.costumes.forEach(def => {
|
|
186
|
+
const processedCostume = processAssetDefinition(ASSET_TYPES.costumes, def, folderPath, parentSpriteTags);
|
|
187
|
+
if (processedCostume) {
|
|
188
|
+
fileCostumes[def.name] = processedCostume;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (collectionDef.sounds && Array.isArray(collectionDef.sounds)) {
|
|
194
|
+
collectionDef.sounds.forEach(def => {
|
|
195
|
+
const processedSound = processAssetDefinition(ASSET_TYPES.sounds, def, folderPath, parentSpriteTags);
|
|
196
|
+
if (processedSound) {
|
|
197
|
+
fileSounds[def.name] = processedSound;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
collectionDef.sprites.forEach(spriteDef => {
|
|
203
|
+
const resolvedCostumes = (spriteDef.costumes || []).map(ref => {
|
|
204
|
+
const found = fileCostumes[ref];
|
|
205
|
+
if (!found) {
|
|
206
|
+
console.warn(`Warning: Sprite ${spriteDef.name} references missing costume '${ref}' in ${defFile}`);
|
|
207
|
+
}
|
|
208
|
+
return found;
|
|
209
|
+
}).filter(c => c);
|
|
210
|
+
|
|
211
|
+
const resolvedSounds = (spriteDef.sounds || []).map(ref => {
|
|
212
|
+
const found = fileSounds[ref];
|
|
213
|
+
if (!found) {
|
|
214
|
+
console.warn(`Warning: Sprite ${spriteDef.name} references missing sound '${ref}' in ${defFile}`);
|
|
215
|
+
}
|
|
216
|
+
return found;
|
|
217
|
+
}).filter(s => s);
|
|
218
|
+
|
|
219
|
+
collections.sprites.push({
|
|
220
|
+
name: spriteDef.name,
|
|
221
|
+
tags: spriteDef.tags || [],
|
|
222
|
+
isStage: spriteDef.isStage || false,
|
|
223
|
+
costumes: resolvedCostumes,
|
|
224
|
+
sounds: resolvedSounds,
|
|
225
|
+
...{ variables: spriteDef.variables || {} },
|
|
226
|
+
...{ blocks: spriteDef.blocks || {} }
|
|
227
|
+
// TODO: Should we preserve other possible properties (no such cases exist in the current editor libraries):
|
|
228
|
+
// `lists`, `broadcasts`, `currentCostume`, `comments`, etc.?
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Add to global libs if requested
|
|
232
|
+
if (fillAssetsFromSprites) {
|
|
233
|
+
resolvedCostumes.forEach(c => collections.costumes.push(c));
|
|
234
|
+
resolvedSounds.forEach(s => collections.sounds.push(s));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Process asset types.
|
|
242
|
+
// Assume inputDir has subdirs following the asset type format:
|
|
243
|
+
// sprites/, backdrops/, costumes/, sounds/
|
|
244
|
+
processAssetType(ASSET_TYPES.sprites);
|
|
245
|
+
processAssetType(ASSET_TYPES.backdrops);
|
|
246
|
+
processAssetType(ASSET_TYPES.sounds);
|
|
247
|
+
processAssetType(ASSET_TYPES.costumes);
|
|
248
|
+
|
|
249
|
+
// Deduplication logic in case same asset appears multiple times
|
|
250
|
+
// (e.g. if multiple sprites are using pop.wav sounds)
|
|
251
|
+
const dedup = (list) => {
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const seenNames = new Set();
|
|
254
|
+
|
|
255
|
+
return list.filter(item => {
|
|
256
|
+
const key = `${item.md5ext}-${item.name.toLowerCase()}`;
|
|
257
|
+
if (seen.has(key)) return false;
|
|
258
|
+
if (seenNames.has(item.name.toLowerCase())) {
|
|
259
|
+
console.warn(`Warning: Duplicate asset name detected across different md5exts: '${item.name}'. This may cause issues in Scratch if both assets are used together.`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
seenNames.add(item.name.toLowerCase());
|
|
263
|
+
}
|
|
264
|
+
seen.add(key);
|
|
265
|
+
return true;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
collections.costumes = dedup(collections.costumes);
|
|
269
|
+
collections.sounds = dedup(collections.sounds);
|
|
270
|
+
|
|
271
|
+
// Sort everything by name and md5ext
|
|
272
|
+
const compareByNameAndMd5ext = (a, b) => {
|
|
273
|
+
const nameComparison = a.name.localeCompare(b.name);
|
|
274
|
+
if (nameComparison !== 0) return nameComparison;
|
|
275
|
+
|
|
276
|
+
// Sprites have no md5ext, so consider them equal if names are the same
|
|
277
|
+
if (!a.md5ext && !b.md5ext) return 0;
|
|
278
|
+
|
|
279
|
+
return a.md5ext.localeCompare(b.md5ext);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
collections.sprites.sort(compareByNameAndMd5ext);
|
|
283
|
+
collections.backdrops.sort(compareByNameAndMd5ext);
|
|
284
|
+
collections.costumes.sort(compareByNameAndMd5ext);
|
|
285
|
+
collections.sounds.sort(compareByNameAndMd5ext);
|
|
286
|
+
|
|
287
|
+
if (outputDir) {
|
|
288
|
+
if (!fs.existsSync(outputDir)) {
|
|
289
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fs.writeFileSync(path.join(outputDir, 'sprites.json'), JSON.stringify(collections.sprites, null, INDENT));
|
|
293
|
+
fs.writeFileSync(path.join(outputDir, 'backdrops.json'), JSON.stringify(collections.backdrops, null, INDENT));
|
|
294
|
+
fs.writeFileSync(path.join(outputDir, 'costumes.json'), JSON.stringify(collections.costumes, null, INDENT));
|
|
295
|
+
fs.writeFileSync(path.join(outputDir, 'sounds.json'), JSON.stringify(collections.sounds, null, INDENT));
|
|
296
|
+
console.log(`Libraries generated in output dir: ${outputDir}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return collections;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (require.main === module) {
|
|
303
|
+
const config = parseArgs();
|
|
304
|
+
buildLibraries(config);
|
|
305
|
+
} else {
|
|
306
|
+
module.exports = { buildLibraries };
|
|
307
|
+
}
|