Skip to content

Commit 5311631

Browse files
committed
feat: external library urls and new generator
1 parent eab2d02 commit 5311631

17 files changed

Lines changed: 4473 additions & 2008 deletions

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/scratch-gui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"lodash.omit": "4.5.0",
146146
"lodash.throttle": "4.1.1",
147147
"omggif": "1.0.10",
148+
"pako": "^2.1.0",
148149
"papaparse": "5.5.3",
149150
"postcss-import": "12.0.1",
150151
"postcss-loader": "4.3.0",

packages/scratch-gui/src/lib/fetch-project-file.js

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,49 +48,126 @@ const fetchProjectFile = async function (url) {
4848
}
4949

5050
// Resolve asset URLs (sounds, costumes, backdrops)
51-
const resolveAssetUrls = assets => {
51+
const resolveAssetUrls = (assets, resolveUrlFn) => {
52+
const resolve = resolveUrlFn || resolveUrl;
5253
if (!Array.isArray(assets)) return assets;
5354
return assets.map(item => {
5455
const resolved = Object.assign({}, item);
5556
if (resolved.url) {
56-
resolved.url = resolveUrl(resolved.url);
57+
resolved.url = resolve(resolved.url);
5758
}
5859
return resolved;
5960
});
6061
};
6162

63+
// Fetch a remote library JSON and resolve its relative URLs against its own base
64+
const fetchLibrary = async libraryUrl => {
65+
const absLibUrl = resolveUrl(libraryUrl);
66+
const res = await fetch(absLibUrl);
67+
if (!res.ok) {
68+
throw new Error(`Failed to fetch library: ${res.status} ${res.statusText}`);
69+
}
70+
const items = await res.json();
71+
const libBase = absLibUrl.substring(0, absLibUrl.lastIndexOf('/') + 1);
72+
const resolveLibUrl = relative => {
73+
if (!relative) return null;
74+
if (/^https?:\/\//.test(relative)) return relative;
75+
return new URL(relative, libBase).href;
76+
};
77+
return resolveAssetUrls(items, resolveLibUrl);
78+
};
79+
80+
// Resolve a library value: string URL → fetch, array → mixed items, object array → resolve URLs
81+
const resolveLibrary = async library => {
82+
if (typeof library === 'string') {
83+
return fetchLibrary(library);
84+
}
85+
if (Array.isArray(library)) {
86+
const parts = await Promise.all(library.map(item => {
87+
if (typeof item === 'string') return fetchLibrary(item);
88+
// inline asset object
89+
const resolved = Object.assign({}, item);
90+
if (resolved.url) resolved.url = resolveUrl(resolved.url);
91+
return [resolved];
92+
}));
93+
return parts.flat();
94+
}
95+
return library;
96+
};
97+
6298
// Normalize an asset-type field that may be a flat array or {tags, library, showBuiltin}
63-
const normalizeAssetField = field => {
99+
// library can be a URL string, an array of items, or a mixed array of URLs and objects
100+
const normalizeAssetField = async field => {
64101
if (!field) return field;
65102
if (Array.isArray(field)) return resolveAssetUrls(field);
66103
if (typeof field === 'object') {
67104
const normalized = Object.assign({}, field);
68-
if (Array.isArray(normalized.library)) {
69-
normalized.library = resolveAssetUrls(normalized.library);
70-
}
105+
normalized.library = await resolveLibrary(normalized.library);
71106
return normalized;
72107
}
73108
return field;
74109
};
75110

76-
projectFile.sounds = normalizeAssetField(projectFile.sounds);
77-
projectFile.costumes = normalizeAssetField(projectFile.costumes);
78-
projectFile.backdrops = normalizeAssetField(projectFile.backdrops);
111+
projectFile.sounds = await normalizeAssetField(projectFile.sounds);
112+
projectFile.costumes = await normalizeAssetField(projectFile.costumes);
113+
projectFile.backdrops = await normalizeAssetField(projectFile.backdrops);
79114

80115
// Resolve sprite asset URLs (nested costumes and sounds)
81-
const normalizeSprites = sprites => {
82-
if (!sprites) return sprites;
83-
const list = Array.isArray(sprites) ? sprites : (sprites.library || []);
84-
const resolved = list.map(sprite => {
116+
// Fetch a single sprite library URL and resolve its relative asset URLs
117+
const fetchSpriteLibrary = async libraryUrl => {
118+
const absLibUrl = resolveUrl(libraryUrl);
119+
const res = await fetch(absLibUrl);
120+
if (!res.ok) {
121+
throw new Error(`Failed to fetch sprite library: ${res.status} ${res.statusText}`);
122+
}
123+
const items = await res.json();
124+
const libBase = absLibUrl.substring(0, absLibUrl.lastIndexOf('/') + 1);
125+
const resolveLibUrl = relative => {
126+
if (!relative) return null;
127+
if (/^https?:\/\//.test(relative)) return relative;
128+
return new URL(relative, libBase).href;
129+
};
130+
return items.map(sprite => {
85131
const s = Object.assign({}, sprite);
86-
s.costumes = resolveAssetUrls(s.costumes);
87-
s.sounds = resolveAssetUrls(s.sounds);
132+
s.costumes = resolveAssetUrls(s.costumes, resolveLibUrl);
133+
s.sounds = resolveAssetUrls(s.sounds, resolveLibUrl);
88134
return s;
89135
});
90-
if (Array.isArray(sprites)) return resolved;
91-
return Object.assign({}, sprites, {library: resolved});
92136
};
93-
projectFile.sprites = normalizeSprites(projectFile.sprites);
137+
138+
const resolveSpriteLibrary = async library => {
139+
if (typeof library === 'string') {
140+
return fetchSpriteLibrary(library);
141+
}
142+
if (Array.isArray(library)) {
143+
const parts = await Promise.all(library.map(item => {
144+
if (typeof item === 'string') return fetchSpriteLibrary(item);
145+
// inline sprite object
146+
const s = Object.assign({}, item);
147+
s.costumes = resolveAssetUrls(s.costumes);
148+
s.sounds = resolveAssetUrls(s.sounds);
149+
return [s];
150+
}));
151+
return parts.flat();
152+
}
153+
return library;
154+
};
155+
156+
const normalizeSprites = async sprites => {
157+
if (!sprites) return sprites;
158+
if (Array.isArray(sprites)) {
159+
return sprites.map(sprite => {
160+
const s = Object.assign({}, sprite);
161+
s.costumes = resolveAssetUrls(s.costumes);
162+
s.sounds = resolveAssetUrls(s.sounds);
163+
return s;
164+
});
165+
}
166+
const normalized = Object.assign({}, sprites);
167+
normalized.library = await resolveSpriteLibrary(normalized.library);
168+
return normalized;
169+
};
170+
projectFile.sprites = await normalizeSprites(projectFile.sprites);
94171

95172
return projectFile;
96173
};

packages/scratch-gui/src/lib/project-file-hoc.jsx

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import queryString from 'query-string';
44
import {connect} from 'react-redux';
55

66
import fetchProjectFile from './fetch-project-file';
7-
import {loadProjectFileAssets} from './project-file-assets';
7+
import {loadProjectFileAssets, getShowBuiltin} from './project-file-assets';
88
import {setProjectFile, setProjectFileLoading, setProjectFileError} from '../reducers/project-file';
99
import {setDynamicAssets} from '../reducers/dynamic-assets';
1010
import {setExternalDeck} from '../reducers/cards';
@@ -14,14 +14,15 @@ import {
1414
requestProjectUpload,
1515
onLoadedProject
1616
} from '../reducers/project-state';
17+
import pako from 'pako';
1718
import log from './log';
1819

1920
const EXTERNAL_DECK_ID = '__external__';
2021

2122
/**
2223
* Higher Order Component to load external project files via ?project=<value>.
2324
* The value can be:
24-
* - A base64-encoded JSON project file
25+
* - A pako-compressed JSON project file (prefixed with "pako:")
2526
* - A URL to a .sb3 file
2627
* - A URL to a JSON project file
2728
*
@@ -68,10 +69,16 @@ const ProjectFileHOC = function (WrappedComponent) {
6869
} else {
6970
this.loadProjectFile(resolvedValue);
7071
}
71-
} else {
72-
// Treat as base64-encoded JSON
72+
} else if (value.startsWith('pako:')) {
73+
// Treat as pako-compressed JSON
74+
(async () => {
7375
try {
74-
const json = decodeURIComponent(atob(value));
76+
const compressed = atob(value.slice(5));
77+
const bytes = new Uint8Array(compressed.length);
78+
for (let i = 0; i < compressed.length; i++) {
79+
bytes[i] = compressed.charCodeAt(i);
80+
}
81+
const json = pako.inflate(bytes, {to: 'string'});
7582
const projectFile = JSON.parse(json);
7683
if (!projectFile.title || typeof projectFile.title !== 'string') {
7784
throw new Error('Project file must have a "title" string field');
@@ -93,32 +100,59 @@ const ProjectFileHOC = function (WrappedComponent) {
93100
});
94101
}
95102
// Resolve asset URLs (sounds, costumes, backdrops)
96-
const resolveAssetUrls = assets => {
103+
const resolveAssetUrls = (assets, resolveUrlFn) => {
104+
const resolve = resolveUrlFn || resolveUrl;
97105
if (!Array.isArray(assets)) return assets;
98106
return assets.map(item => {
99107
const resolved = Object.assign({}, item);
100108
if (resolved.url) {
101-
resolved.url = resolveUrl(resolved.url);
109+
resolved.url = resolve(resolved.url);
102110
}
103111
return resolved;
104112
});
105113
};
114+
const fetchLibrary = async libraryUrl => {
115+
const absLibUrl = resolveUrl(libraryUrl);
116+
const res = await fetch(absLibUrl);
117+
if (!res.ok) {
118+
throw new Error(`Failed to fetch library: ${res.status}`);
119+
}
120+
const items = await res.json();
121+
const libBase = absLibUrl.substring(0, absLibUrl.lastIndexOf('/') + 1);
122+
const resolveLibUrl = relative => {
123+
if (!relative) return null;
124+
if (/^https?:\/\//.test(relative)) return relative;
125+
return new URL(relative, libBase).href;
126+
};
127+
return resolveAssetUrls(items, resolveLibUrl);
128+
};
129+
const resolveLibrary = async library => {
130+
if (typeof library === 'string') return fetchLibrary(library);
131+
if (Array.isArray(library)) {
132+
const parts = await Promise.all(library.map(item => {
133+
if (typeof item === 'string') return fetchLibrary(item);
134+
const resolved = Object.assign({}, item);
135+
if (resolved.url) resolved.url = resolveUrl(resolved.url);
136+
return [resolved];
137+
}));
138+
return parts.flat();
139+
}
140+
return library;
141+
};
106142
// Normalize asset-type field (flat array or {tags, library, showBuiltin})
107-
const normalizeAssetField = field => {
143+
const normalizeAssetField = async field => {
108144
if (!field) return field;
109145
if (Array.isArray(field)) return resolveAssetUrls(field);
110146
if (typeof field === 'object') {
111147
const normalized = Object.assign({}, field);
112-
if (Array.isArray(normalized.library)) {
113-
normalized.library = resolveAssetUrls(normalized.library);
114-
}
148+
normalized.library = await resolveLibrary(normalized.library);
115149
return normalized;
116150
}
117151
return field;
118152
};
119-
projectFile.sounds = normalizeAssetField(projectFile.sounds);
120-
projectFile.costumes = normalizeAssetField(projectFile.costumes);
121-
projectFile.backdrops = normalizeAssetField(projectFile.backdrops);
153+
projectFile.sounds = await normalizeAssetField(projectFile.sounds);
154+
projectFile.costumes = await normalizeAssetField(projectFile.costumes);
155+
projectFile.backdrops = await normalizeAssetField(projectFile.backdrops);
122156
if (Array.isArray(projectFile.sprites)) {
123157
projectFile.sprites = projectFile.sprites.map(sprite => {
124158
const resolved = Object.assign({}, sprite);
@@ -127,15 +161,42 @@ const ProjectFileHOC = function (WrappedComponent) {
127161
return resolved;
128162
});
129163
} else if (projectFile.sprites && typeof projectFile.sprites === 'object') {
130-
const normalized = Object.assign({}, projectFile.sprites);
131-
if (Array.isArray(normalized.library)) {
132-
normalized.library = normalized.library.map(sprite => {
133-
const resolved = Object.assign({}, sprite);
134-
resolved.costumes = resolveAssetUrls(resolved.costumes);
135-
resolved.sounds = resolveAssetUrls(resolved.sounds);
136-
return resolved;
164+
const fetchSpriteLibrary = async libraryUrl => {
165+
const absLibUrl = resolveUrl(libraryUrl);
166+
const res = await fetch(absLibUrl);
167+
if (!res.ok) {
168+
throw new Error(`Failed to fetch sprite library: ${res.status}`);
169+
}
170+
const items = await res.json();
171+
const libBase = absLibUrl.substring(0, absLibUrl.lastIndexOf('/') + 1);
172+
const resolveLibUrl = relative => {
173+
if (!relative) return null;
174+
if (/^https?:\/\//.test(relative)) return relative;
175+
return new URL(relative, libBase).href;
176+
};
177+
return items.map(sprite => {
178+
const s = Object.assign({}, sprite);
179+
s.costumes = resolveAssetUrls(s.costumes, resolveLibUrl);
180+
s.sounds = resolveAssetUrls(s.sounds, resolveLibUrl);
181+
return s;
137182
});
138-
}
183+
};
184+
const resolveSpriteLibrary = async library => {
185+
if (typeof library === 'string') return fetchSpriteLibrary(library);
186+
if (Array.isArray(library)) {
187+
const parts = await Promise.all(library.map(item => {
188+
if (typeof item === 'string') return fetchSpriteLibrary(item);
189+
const s = Object.assign({}, item);
190+
s.costumes = resolveAssetUrls(s.costumes);
191+
s.sounds = resolveAssetUrls(s.sounds);
192+
return [s];
193+
}));
194+
return parts.flat();
195+
}
196+
return library;
197+
};
198+
const normalized = Object.assign({}, projectFile.sprites);
199+
normalized.library = await resolveSpriteLibrary(normalized.library);
139200
projectFile.sprites = normalized;
140201
}
141202
this.props.onSetProjectFile(projectFile);
@@ -151,6 +212,7 @@ const ProjectFileHOC = function (WrappedComponent) {
151212
this.props.onSetProjectFileError(error.message);
152213
this.setState({isLoadingProjectFromUrl: false});
153214
}
215+
})();
154216
}
155217
}
156218
}
@@ -169,6 +231,7 @@ const ProjectFileHOC = function (WrappedComponent) {
169231
if (!field) return false;
170232
if (Array.isArray(field)) return field.length > 0;
171233
if (typeof field === 'object') {
234+
if (field.showBuiltin === false) return true;
172235
return (field.library && field.library.length > 0) ||
173236
(field.tags && field.tags.length > 0);
174237
}
@@ -192,6 +255,21 @@ const ProjectFileHOC = function (WrappedComponent) {
192255
})
193256
.catch(error => {
194257
log.error('Failed to load project file assets:', error);
258+
// Still propagate showBuiltin flags even if asset fetches failed
259+
this.props.onSetDynamicAssets({
260+
costumes: [],
261+
sounds: [],
262+
backdrops: [],
263+
sprites: [],
264+
costumeTags: null,
265+
soundTags: null,
266+
backdropTags: null,
267+
spriteTags: null,
268+
showBuiltinCostumes: getShowBuiltin(projectFile.costumes),
269+
showBuiltinSounds: getShowBuiltin(projectFile.sounds),
270+
showBuiltinBackdrops: getShowBuiltin(projectFile.backdrops),
271+
showBuiltinSprites: getShowBuiltin(projectFile.sprites)
272+
});
195273
});
196274
}
197275
setupDeck (projectFile) {
@@ -248,6 +326,10 @@ const ProjectFileHOC = function (WrappedComponent) {
248326
})
249327
.catch(error => {
250328
log.error('Failed to load sb3:', error);
329+
// Still load assets (costumes, sprites, etc.) even if sb3 failed
330+
if (projectFile) {
331+
this.loadAssets(projectFile);
332+
}
251333
this.setState({isLoadingProjectFromUrl: false});
252334
});
253335
}

0 commit comments

Comments
 (0)