Files
2026-10-14 04:20:24 +02:00

218 lines
9.7 KiB
JavaScript

/*
* Copyright 2026 Cory Lamming
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Bundles a demo (React app + reconciler + host config) into a single classic (IIFE) script that
// QuickJS can run with a plain JS_Eval, and bakes the images/fonts the demo imports into a generated
// C translation unit (dist/assets.generated.c) exposing er_register_assets(). Globals the bundle
// expects at runtime (NativeUI, screen, console, timer shims) are provided by the C host (e.g.,
// examples/linux/main_js.c); the example firmware compiles the generated assets and calls
// er_register_assets() at boot.
//
// Demos live in the top-level demos/ folder, one folder per demo. Pick one with:
// npm run build # default demo (thermostat)
// npm run build -- marine-dash # a specific demo by folder name
// Outputs are always dist/app.bundle.js + dist/assets.generated.{c,h} — the single "active" app the
// example hosts pick up.
import {createRequire} from 'node:module';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {dirname, resolve, basename} from 'node:path';
import {existsSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
const here = resolve(process.argv[2], 'bridges/quickjs/js');
const require = createRequire(resolve(here, 'package.json'));
const {build} = require('esbuild');
const {bakeAssets} = await import(pathToFileURL(resolve(here, 'assets/index.mjs')).href);
const {resolveFontJobs} = await import(pathToFileURL(resolve(here, 'assets/font-config.mjs')).href);
const {analyzeFontSizes} = await import(pathToFileURL(resolve(here, 'assets/font-sizes.mjs')).href);
const {registerSvgVectorLoader} = await import(pathToFileURL(resolve(here, 'assets/svg-loader.mjs')).href);
const {tailwindEsbuildPlugin} = await import('./tailwind/esbuild-plugin.mjs');
const {readWav} = await import('./wav.mjs');
// Adapted for per-app outputs by PS5 React; asset baking remains upstream.
const repoRoot = resolve(here, '../../..');
const demosDir = resolve(repoRoot, 'demos');
const libEntry = resolve(here, 'src/embedded-react/index.js');
const nodeModules = resolve(here, 'node_modules');
const distDir = resolve(process.argv[4]);
// Added by PS5 React: Tailwind classes compile for this render size.
const render = {width: Number(process.argv[5]), height: Number(process.argv[6])};
const DEFAULT_DEMO = 'thermostat';
const demoDir = resolve(process.argv[3]);
const demo = basename(demoDir);
const entry = resolve(demoDir, 'index.jsx');
if (!existsSync(entry)) {
const available = existsSync(demosDir)
? readdirSync(demosDir, {withFileTypes: true})
.filter(d => d.isDirectory())
.map(d => d.name)
: [];
console.error(`Demo "${demo}" not found (expected ${entry}).`);
console.error(`Available demos: ${available.join(', ') || '(none)'}`);
process.exit(1);
}
// Asset discovery is import-driven: an `import x from './x.png'` (image) or `import F from './F.ttf'`
// (font) is intercepted here. The import resolves to the asset's NAME (its file basename) — the
// string an <Image source>/imageName looks up, or the family a fontFamily uses — and the path is
// recorded, so it gets baked below. Only what the app imports is baked.
const images = new Map(); // name -> path
const fonts = new Map(); // family -> path
const sounds = new Map(); // name -> {rate, channels, samples} (added by PS5 React)
// 16-bit PCM WAV, mono or stereo: decoded here so the hosts only copy samples (added by PS5 React).
function decodeWav(path) {
const {rate, channels, bits, samples} = readWav(path);
if (bits !== 16 || channels > 2 || rate < 8000 || rate > 96000) {
throw new Error(`expected 16-bit PCM, mono or stereo, 8-96 kHz; got ${bits}-bit, ${channels} channels, ${rate} Hz`);
}
if (samples.length < channels * 2 || samples.length > 10 * rate * channels) {
throw new Error('expected between 2 frames and 10 seconds of audio');
}
return {rate, channels, samples};
}
const assetPlugin = {
name: 'embedded-react-assets',
setup(build) {
build.onLoad({filter: /\.(png|jpe?g|webp|gif|bmp)$/i}, args => {
const name = basename(args.path).replace(/\.[^.]+$/, '');
images.set(name, args.path);
return {
contents: `module.exports = ${JSON.stringify(name)};`,
loader: 'js',
};
});
build.onLoad({filter: /\.(ttf|otf)$/i}, args => {
const family = basename(args.path).replace(/\.[^.]+$/, '');
fonts.set(family, args.path);
return {
contents: `module.exports = ${JSON.stringify(family)};`,
loader: 'js',
};
});
build.onLoad({filter: /\.wav$/i}, args => {
const name = basename(args.path).replace(/\.[^.]+$/, '');
try {
sounds.set(name, decodeWav(args.path));
} catch (error) {
return {errors: [{text: `${args.path}: ${error.message}`}]};
}
return {
contents: `module.exports = ${JSON.stringify(name)};`,
loader: 'js',
};
});
registerSvgVectorLoader(build, (name, p) => images.set(name, p));
},
};
const bundlePath = resolve(distDir, 'app.bundle.js');
await build({
entryPoints: [entry],
bundle: true,
format: 'iife',
outfile: bundlePath,
platform: 'neutral',
target: 'es2020',
jsx: 'automatic',
// Demos live outside the embedded-react package, so the package self-reference doesn't resolve for
// them: map the bare `embedded-react` import to the library source and let the demo's bare deps
// (react, react-reconciler) resolve from this package's node_modules. (The library's own internal
// imports still resolve relatively / from node_modules as before.)
// Pin React to THIS package's copy: a demo/consumer project may carry its own node_modules
// (e.g., after `npm install` for device-dev deps), and esbuild resolving `react` from there while
// the library resolves its copy from here yields two React instances — the hook dispatcher is
// null and every component throws. One copy, always.
alias: {
'embedded-react': libEntry,
'@ps5-react/core': resolve(dirname(fileURLToPath(import.meta.url)), '../runtime/js/index.js'),
react: resolve(nodeModules, 'react'),
'react-reconciler': resolve(nodeModules, 'react-reconciler'),
scheduler: resolve(nodeModules, 'scheduler'),
},
nodePaths: [nodeModules],
plugins: [
// Added by PS5 React: build-time className/tw compilation for the app's sources.
tailwindEsbuildPlugin({appDir: demoDir, render, babel: {
core: require('@babel/core'),
syntaxJsx: require('@babel/plugin-syntax-jsx').default,
}}),
assetPlugin,
],
// Production React: smaller and avoids dev-only warning machinery that needs more shims.
define: {'process.env.NODE_ENV': '"production"'},
legalComments: 'none',
logLevel: 'info',
// esbuild has already printed each error with its source location; skip Node's stack trace.
}).catch(() => process.exit(1));
console.log(`Bundled demo "${demo}" -> dist/app.bundle.js`);
// --- Bake imported assets ---------------------------------------------------------------------
// Fonts are pre-rasterized at fixed sizes (the engine has no runtime rasterizer), so bake exactly the
// fontSize values the bundle uses — constants and token scales folded, not just literals. A size that
// only exists at runtime can't be discovered; the bake reports those rather than snapping silently.
const bundleSrc = readFileSync(bundlePath, 'utf8');
const usedSizes = analyzeFontSizes(bundleSrc);
// Optional per-demo overrides: demos/<demo>/assets.config.js
// export default { fonts: { 'Family': { sizes: [..], bpp: 4, glyphs: 'ascii'|'common'|[cps],
// extraGlyphs: '°±' } } }
let config = {};
const configPath = resolve(demoDir, 'assets.config.js');
if (existsSync(configPath)) {
config = (await import(pathToFileURL(configPath).href)).default || {};
}
const fontConfig = config.fonts || {};
const imageConfig = config.images || {};
const fontJobs = resolveFontJobs(fonts, fontConfig, usedSizes.sizes);
const imageJobs = [...images.entries()].map(([name, path]) => ({
path,
name,
format: imageConfig[name]?.format,
}));
const summary = bakeAssets({
images: imageJobs,
fonts: fontJobs,
outDir: distDir,
source: bundleSrc,
usedSizes,
});
const fontDesc = fontJobs.length
? fontJobs
.map(f => `${f.family}@[${f.sizes.join(',')}]x${f.bpp}bpp`)
.join(', ')
: 'none';
console.log(
`Baked ${summary.images} image(s), ${summary.fonts} font size(s) -> dist/assets.generated.c\n` +
` fonts: ${fontDesc}`,
);
// Added by PS5 React: imported WAVs -> sounds.generated.c (native/shared/baked_sounds.h), always written.
const entries = [...sounds.entries()];
writeFileSync(resolve(distDir, 'sounds.generated.c'), [
'#include "baked_sounds.h"',
...entries.map(([, {samples}], i) => `static const int16_t sound_${i}[] = {${samples.join(',')}};`),
`const BakedSound ps5_react_sounds[] = {${entries.length ? entries.map(([name, {rate, channels, samples}], i) =>
`{${JSON.stringify(name)}, sound_${i}, ${Math.floor(samples.length / channels)}, ${rate}, ${channels}}`).join(',\n ') : '{0}'}};`,
`const uint32_t ps5_react_sound_count = ${entries.length};`,
'',
].join('\n'));
console.log(`Baked ${entries.length} sound(s)${entries.length ? `: ${entries.map(([name]) => name).join(', ')}` : ''}`);