Optimize every game asset with one command.
Images, sounds, 3D models, Spine and data files, each compressed with the right tool. Names and formats stay and no file ever grows, so your game code does not change.
npm install --save-dev @smoud/assets
npx smoud-assets build --source assets --destination assetsEvery file in assets/ is replaced by its optimized version, so keep the originals in git.
Making a playable ad? Add --preset playable or --preset playable-max to convert formats, downmix sounds and shrink textures. This saves up to a third of the asset size, see typical results.
- 🛡️ Safe by default: models keep their nodes, names and UVs, and a failed build leaves the previous output untouched.
- ⚡ One command: works on any
assets/folder as it is, ffmpeg included. Results are cached, so rebuilds skip unchanged files. - 📱 Playable presets:
playableandplayable-maxconvert formats, downmix sounds and shrink models for the smallest ad. - 🖼️ Images: Sharp with palette png, mozjpeg, webp and avif. Try several formats and keep the smallest.
- 🧩 Texture atlases: MaxRects packing, trimming, extrusion, TexturePacker-compatible JSON.
- 🔊 Audio: mp3, ogg, opus or m4a, mono downmix, bitrate by quality, never above the source. Audio sprites for Howler, Phaser and PixiJS Sound.
- 🧊 3D models: glTF Transform and meshoptimizer: dedup, weld, prune, simplify, quantize, texture extraction, meshopt compression.
- 🦴 Spine: pages compressed, converted and scaled with the
.atlasrewritten, skeleton numbers rounded, animations and skins filtered,.jsonand.skelconverted for Spine 4.2. - ✒️ Fonts: HarfBuzz subsetting to the characters you use, WOFF2 and WOFF. Bitmap fonts drawn from any font, with outline and shadow.
- 🎬 Video: mp4 or webm, or animated webp and gif with transparency.
- 📄 Data files: JSON and XML minified.
- 🎯 Selection and chaining: globs,
#rest, filters on size, image, sound and model facts, virtual#foldersbetween actions. - 🧰 Toolkit: a JSON config or the JavaScript API, every action's raw function, your own actions. Output is plain files for PixiJS, Phaser, Three.js, Cocos or your own loader.
Install:
npm install --save-dev @smoud/assetsPut your source assets in assets/:
assets/
├─ sprites/
│ ├─ hero/idle_01.png
│ └─ coin/coin_01.png
├─ ui/
│ ├─ logo.png
│ └─ icons/star.svg
├─ backgrounds/sky.jpg
└─ data/levels.json
Create assets.json next to package.json:
{
"actions": [
{ "action": "atlas", "source": "sprites" },
{ "action": "image", "source": "ui", "format": "webp", "quality": 80 },
{ "action": "image", "source": "#rest" },
{ "action": "json" },
{ "action": "copy", "source": "#rest" }
]
}Run the build:
npx smoud-assets buildThe result is in assets/optimized/:
assets/optimized/
├─ sprites.png
├─ sprites.json
├─ ui/logo.webp
├─ backgrounds/sky.jpg
└─ data/levels.json
assets.json lives in the project root. Folders in it are relative to the file.
{
"preset": "playable",
"source": "assets",
"destination": "assets/optimized",
"ignore": ["**/*.psd", "raw"],
"defaults": {
"image": { "quality": 80 }
},
"actions": [{ "action": "atlas", "source": "sprites" }],
"clean": true
}| Key | What it does | Default |
|---|---|---|
preset |
A base config yours extends, see Presets. | default when there are no actions |
source |
Folder with your source assets. | assets |
destination |
Output folder. Must not contain the source folder or be the project root. | assets/optimized |
ignore |
Files the build never sees. Dot files and node_modules are always ignored. |
none |
defaults |
Starting options per action, see Defaults. | none |
actions |
The list of actions, run top to bottom. | none |
clean |
Remove files from the destination that this build did not produce. | true |
cache |
Reuse earlier results for unchanged files. | true |
source and destination are always folders, never patterns. The command line flags --source and --destination override them.
A destination equal to source rewrites the files in place. With clean, files no action takes would be deleted, so the build fails instead. Commit your assets first. watch does not run in place.
With cache on, results are kept in node_modules/.cache/smoud-assets and reused as long as the file, the options and the action stay the same. Entries older than 30 days are removed, and when the cache passes 1 GB the oldest go until half is left.
Each entry in actions is one step: which files, what to do, where to put the result.
{ "action": "image", "source": "ui", "destination": "interface", "format": "webp", "quality": 80 }| Key | What it does |
|---|---|
action |
The action name: image, atlas, audio, audiosprite, gltf, meshopt, spine, font, bitmapfont, video, json, xml, copy, or one you registered. |
source |
Which files. Omitted: every file the action can handle. |
destination |
Where the results go. Omitted: each result lands where its source was. |
| anything else | Options of the action, see Action reference. |
A plain string is an action with default settings: "image" equals { "action": "image" }.
Actions run top to bottom, and each one selects its files on its own. The same file can go through two actions, for example once at full size and once at half size. Two actions writing the same output path is an error.
A misspelled key is a warning with a hint, unknown option "qualty", did you mean "quality"?. A wrong value is an error.
source says which files an action takes. Patterns are relative to the source folder.
| Form | Selects |
|---|---|
"ui" |
Everything in the folder ui. |
"ui/logo.png" |
One file. |
"ui/**/*.png" |
A glob, in picomatch syntax. |
"*.json" |
A pattern without a slash matches the file name at any depth. |
["ui", "hud/**/*.png"] |
Several patterns. |
"#rest" |
Every file the actions above have not taken. |
"#icons" |
A virtual folder, see Virtual folders. |
{ "include", "exclude", "when" } |
The long form, with patterns to leave out and a filter. |
An action only takes files it can handle. image never selects a .json, and copy takes anything.
{
"action": "image",
"source": {
"include": ["ui", "hud"],
"exclude": ["*.psd", "ui/raw"],
"when": { "alpha": false, "size": ">200kb" }
},
"format": "jpeg"
}when filters on facts about a file. size is known for every file. Other facts come from the action that selects: image knows width, height, alpha and animated, audio knows duration, channels, sampleRate and bitrate, and gltf and meshopt know triangles, vertices, textures and animations.
| Fact | Example | Meaning |
|---|---|---|
size |
">200kb", ["<2mb", ">10kb"] |
File size, with b, kb, mb, gb. |
width |
">=1024" |
Width in pixels. |
height |
"<512" |
Height in pixels. |
alpha |
false |
Has transparency. |
animated |
true |
Has more than one frame. |
duration |
">10" |
Length of a sound in seconds. |
channels |
"1" |
1 for mono, 2 for stereo. |
sampleRate |
">=44100" |
Sample rate of a sound in Hz. |
bitrate |
">128" |
Bitrate of a sound in kbps. |
triangles |
">50000" |
Triangles in a model. |
vertices |
">10000" |
Vertices in a model. |
textures |
"0" |
Textures in a model. |
animations |
">0" |
Animations in a model. |
Comparisons are >, >=, <, <=, !=, =, or a bare number for equality. In a list, every bound must hold. A fact the action cannot read excludes the file, with a warning.
#rest takes what is left. Several #rest actions share the leftovers in order:
{
"actions": [
{ "action": "atlas", "source": "sprites" },
{ "action": "image", "source": { "include": "#rest", "when": { "size": ">200kb" } }, "quality": 70 },
{ "action": "image", "source": "#rest" },
{ "action": "copy", "source": "#rest" }
]
}Big images are compressed harder, other images get the default settings, and everything else is copied. A file no action takes is listed in the report as not selected.
destination says where results go, relative to the destination folder.
| Form | Result for the source ui/buttons/play.png, selected by "ui" |
|---|---|
| omitted | ui/buttons/play.png, where it was. |
"interface" |
interface/buttons/play.png. The folder replaces ui. |
"#buttons" |
Kept in memory for a later action, see Virtual folders. |
{ "folder": "interface", "filename": "[name].[hash][ext]" } |
interface/buttons/play.1a2b3c4d.png. |
Filename templates know [name], [ext] with the dot, [hash] for 8 characters of the content hash, and [hash:n] for n characters. The default is [name][ext].
Results that stand for many files, like an atlas page, land at the root of the destination folder, or in the folder you give.
A destination starting with # keeps the results in memory instead of writing them. An action further down reads them back with the same name as its source:
{
"actions": [
{ "action": "image", "source": "ui/icons", "format": "png", "density": 144, "destination": "#icons" },
{ "action": "atlas", "source": "#icons", "padding": 4 }
]
}The svg icons are rasterized at 144 dpi, then packed into icons.png and icons.json, named after the virtual folder. A virtual folder must be produced above the action that reads it. A virtual folder nothing reads is a warning.
A source can list several virtual folders, mixed with patterns on disk. A file two entries select is taken once:
{ "action": "atlas", "source": ["#icons", "#buttons/small/**", "ui/logo.png"] }#rest is the exception: it stands alone, or with its own sub-pattern like "#rest/ui/**".
In the report, an action that writes to a virtual folder shows its size change dimmed, followed by → #icons. Those bytes never reach disk, so the total leaves them out, also when an action reads them together with files from disk.
defaults sets starting options per action for the whole config. Options written on an action win.
{
"defaults": {
"image": { "quality": 80, "png": { "palette": true } },
"atlas": { "extrude": 1 }
},
"actions": [
{ "action": "image", "source": "hud", "quality": 90 },
{ "action": "image", "source": "#rest" }
]
}Every key in defaults must be an action name. An unknown name is an error.
A preset is a base config yours extends. It is a JSON file with only defaults and actions:
{
"preset": "playable",
"defaults": {
"image": { "quality": 60 }
},
"actions": [{ "action": "atlas", "source": "sprites" }]
}The rules:
defaultsare merged, and yours win key by key. In the example the preset's atlas quality applies to your atlas, and your image quality of 60 replaces the preset's.actions: yours run first. The preset's actions then only see the files yours did not take. Above, the atlas takessprites, and the preset handles everything else.- No
presetand noactions: the built-indefaultpreset runs.
preset takes a built-in name, a path to a JSON file, or the name of an installed package. On the command line, --preset sets it and wins over the config's own.
Built-in presets:
| Preset | What it does |
|---|---|
default |
Optimizes models and Spine, compresses images and sounds in their own format, minifies json and xml, copies everything else, svg included. |
playable |
For playable ads. Images are shrunk to fit 2048×2048, opaque png becomes jpeg, quality 70, or 50 for files over 400 KB. Atlas, Spine and bitmap font pages use quality 70. Sounds become mono mp3 at 96 kbps. Models extract their textures. Fonts become woff2. Then json, xml and copy like default. |
playable-max |
Like playable, with stronger compression: every png becomes webp, quality 60, or 45 for files over 300 KB. Atlas, Spine and bitmap font pages: webp, quality 60. Sounds become mono mp3 at 64 kbps. Models drop normals, textures become webp, models over 50,000 triangles are halved. Fonts become woff2 without hinting. |
Approximate savings of each preset per genre, based on benchmarks of shipped playable ads. They count images, sounds, models and Spine files; fonts and code are copied as they are.
| Game | Assets | default |
playable |
playable-max |
|---|---|---|---|---|
| 2D match-3 with Spine | 2 MB | -12% | -16% | -35% |
| 2D merge | 3 MB | -10% | -18% | -32% |
| 2D hidden objects | 5 MB | -14% | -20% | -38% |
| 2D slots | 4 MB | -10% | -15% | -30% |
| 3D idle tycoon | 6 MB | -12% | -16% | -22% |
| 3D farm builder | 3 MB | -8% | -17% | -34% |
| 3D runner | 4 MB | -9% | -14% | -25% |
| 3D restaurant management | 4 MB | -11% | -15% | -18% |
- Every action selects its files, top to bottom.
- Every action processes its files in memory.
- Results are written to
<destination>/.staging. - When nothing failed, the staged files move into the destination. With
cleanon, files no action produced are removed.
If anything fails, the staging folder is deleted and the destination stays exactly as it was. Warnings do not stop a build unless you pass --strict. With --dry-run nothing is written to the destination. The cache, when on, is still filled.
npx smoud-assets build [options] # build once
npx smoud-assets watch [options] # build, then rebuild on every change
npx smoud-assets init [options] # write a starter assets.jsonwatch follows the source folder, the config and a preset file.
| Option | What it does |
|---|---|
--config <file> |
Config file. Without it, assets.json is used when it exists. |
--no-config |
Ignore any config file. |
--source <dir> |
Source folder. Wins over the config. |
--destination <dir> |
Destination folder. Wins over the config. |
--preset <name> |
Preset the config extends. |
--strict |
Warnings count as failure. |
--verbose |
One line per file, and every warning instead of the first ten. |
--dry-run |
Process everything, write nothing. |
--[no-]cache |
Use the cache or not. Wins over the config. |
-h, --help |
Show help. |
-v, --version |
Show the version. |
The exit code is 0 on success, 1 for a failed build, and 2 for a wrong command. Colours follow NO_COLOR and FORCE_COLOR.
Default optimization, no config needed. Takes assets/ and writes to assets/optimized/. Every file keeps its name and format:
npx smoud-assets buildA playable ad. Takes assets/ and writes to assets/optimized/ with the playable preset. Or { "preset": "playable" } in assets.json:
npx smoud-assets build --preset playableRewrite the files in assets/ with their optimized versions, no copy. Commit your assets first. --no-config keeps the command the same even when an assets.json is added later:
npx smoud-assets build --no-config --destination assetsTakes assets/ and writes to ../dist/, outside the project. Or { "destination": "../dist" } in assets.json:
npx smoud-assets build --destination ../distEach action has the same options in assets.json and in code. In code, each action also has a raw function that works on bytes, see Raw functions.
Resizes, trims, converts and compresses images. Takes png, jpg, jpeg, webp, gif, avif, tif, tiff, svg. Steps always run in this order: size limit, trim, resize, flatten, encode.
{ "action": "image", "source": "ui", "resize": { "scale": 0.5 }, "format": ["webp", "png"], "quality": 80 }| Option | What it does | Default |
|---|---|---|
format |
png, jpeg, webp, avif, gif, or a list. With a list every format is tried and the smallest wins. |
source format |
quality |
1 to 100, for every format. A format's own quality wins over it. |
80 |
lossless |
Lossless encoding where the format allows it. | false |
maxWidth |
Shrink images wider than this, keeping the aspect ratio. Narrower images are untouched. | none |
maxHeight |
Shrink images taller than this, keeping the aspect ratio. Shorter images are untouched. | none |
resize |
Resize every image, see the table below. | none |
trim |
true, or { "threshold": n }: cut borders of the same colour. |
false |
flatten |
Background colour under transparent pixels, like "#ffffff". |
none |
keepSmaller |
When only re-encoding, keep the original if the new file is bigger. | true |
metadata |
Keep EXIF and ICC data. Orientation is always applied. | false |
animated |
Keep gif and webp animation. false keeps the first frame. |
true |
density |
DPI for svg sources. 72 is the svg's own size, 144 doubles it. | 72 |
png |
{ "palette": true, "colors": 256, "dither": 1, "effort": 7 } |
|
jpeg |
{ "quality", "progressive": false, "mozjpeg": true, "chromaSubsampling": "4:2:0" } |
|
webp |
{ "quality", "preset": "default", "alphaQuality": 100, "nearLossless": false, "smartSubsample": false, "effort": 6 } |
|
avif |
{ "quality": 50, "chromaSubsampling": "4:4:4", "effort": 4 } |
|
gif |
{ "colors": 256, "dither": 1, "effort": 7, "interFrameMaxError": 0 } |
resize:
| Option | What it does | Default |
|---|---|---|
width |
Target width in pixels. | |
height |
Target height in pixels. | |
scale |
Multiply both sides, 0.5 halves the image. Wins over width and height. |
|
fit |
cover, contain, fill, inside, outside. |
cover with both sides, else inside |
upscale |
Allow enlarging. A scale above 1 enlarges unless this is false. |
false |
kernel |
nearest for pixel art, cubic, mitchell, lanczos2, lanczos3. |
lanczos3 |
position |
Part of the image cover and contain keep: center, top, right, bottom, left, left top and the other corners, entropy or attention. |
center |
Good to know:
- avif has its own
qualityof 50, because avif reaches the same look at lower numbers. The top-levelqualitydoes not change it. Set"avif": { "quality": 60 }instead. - The extension follows the format.
jpegwrites.jpg. A wrong extension, like a png named.jpg, is fixed with a warning. - jpeg cannot hold transparency. Without
flattenit is painted on black, with a warning. In a format list, jpeg is skipped for transparent images. - svg sources need a raster
format, otherwise they are skipped with a warning. - An animation converted to png, jpeg or avif keeps its first frame.
Packs images into one or more pages, with a data file per page. By default it is TexturePacker JSON, which PixiJS, Phaser, Babylon.js, PlayCanvas and most other engines load directly. Takes png, jpg, jpeg, webp, gif (first frame), avif, tif, tiff.
{ "action": "atlas", "source": "sprites", "padding": 2, "extrude": 1 }| Option | What it does | Default |
|---|---|---|
maxWidth |
Widest page in pixels. What does not fit goes to the next page. | 2048 |
maxHeight |
Tallest page in pixels. | 2048 |
padding |
Empty pixels between sprites and to the page edge. | 2 |
extrude |
Repeat each sprite's edge pixels this far outwards, against bleeding. | 0 |
trim |
Cut transparent borders. Frames keep their original size and offset. | true |
scale |
Shrink every sprite before packing, 0 to 1. The frames follow. | 1 |
keepExtensions |
Keep file extensions in frame names, like hero/idle_01.png. |
false |
powerOfTwo |
Round page sizes up to a power of two. | false |
format |
Page format: png, jpeg, webp, avif. |
png |
quality |
1 to 100. For png pages, setting it switches to palette compression. | not set |
data |
Layout of the data file, see the table below. | json-hash |
data layouts:
| Value | What it writes |
|---|---|
json-hash |
TexturePacker JSON hash, shown below. PixiJS, Phaser, Babylon.js, PlayCanvas. |
json-array |
TexturePacker JSON array: the frames as a list, each with a filename. Phaser, Cocos. |
json |
Your own loader: [{ name, x, y, width, height }] |
css |
HTML elements: one class per frame with background, width and height. |
"source": "sprites" writes sprites.png and sprites.json. "destination": { "filename": "world" } names them world.png and world.json. Frame names are the paths inside the selected folder without the extension, like hero/idle_01:
{
"frames": {
"hero/idle_01": {
"frame": { "x": 2, "y": 2, "w": 55, "h": 55 },
"rotated": false,
"trimmed": true,
"spriteSourceSize": { "x": 5, "y": 5, "w": 55, "h": 55 },
"sourceSize": { "w": 64, "h": 64 }
}
},
"meta": { "image": "sprites.png", "format": "RGBA8888", "size": { "w": 128, "h": 64 }, "scale": "1" }
}Good to know:
- When sprites do not fit one page, pages are numbered:
sprites_1.png,sprites_2.png. Each TexturePacker JSON lists the others undermeta.related_multi_packs. scaleis written tometa.scale. PixiJS reads it and shows the sprites at their original size.- With
css, a frame name becomes a class:hero/idle_01is.hero_idle_01. - With a
[hash]filename template, a page and its data file get the same hash. - png pages are lossless unless you set
quality. Withqualitythey are reduced to at most 256 colours, which makes them much smaller. - Without
quality, jpeg and webp pages use 80 and avif pages 50, like theimageaction. - The same input always gives identical bytes. Frames are never rotated.
- On a lossless png page, a frame cut back out equals the trimmed sprite pixel for pixel.
Converts, downmixes, resamples and compresses sounds. Takes mp3, ogg, oga, opus, m4a, aac, wav, flac, webm.
{ "action": "audio", "source": "sfx", "format": "mp3", "bitrate": 64, "channels": 1 }| Option | What it does | Default |
|---|---|---|
format |
mp3, ogg, opus, m4a, wav, flac. |
source format |
quality |
1 to 100 for mp3, ogg, opus and m4a. Sounds about the same in every format. | 80 |
bitrate |
8 to 320 kbps, for mp3, ogg, opus and m4a. Wins over quality. |
none |
channels |
1 for mono, 2 for stereo. |
source |
sampleRate |
8000 to 48000 Hz, standard rates. opus always uses 48000. |
source |
trim |
Cut silence from the start and end, see below. | false |
normalize |
Bring every sound to the same loudness, see below. | false |
keepSmaller |
Keep the original when encoding would not make it smaller. | true |
trim:truecuts everything quieter than -50 dB.{ "threshold": -40 }cuts louder noise too.normalize:truebrings the sound to -16 LUFS,{ "loudness": -14 }to another level. One even gain for the whole sound, so its dynamics stay.
Good to know:
- Channels, sample rate and bitrate never go up. With the same format and nothing to reduce, the original bytes are kept.
quality80 gives 128 kbps for stereo mp3. Mono gets half, and ogg, m4a and opus get less for the same sound.- mp3 plays everywhere. ogg and opus are smaller, but older Safari cannot play them.
"when": { "duration": ">10" }separates music from sound effects.- Runs on ffmpeg through @smoud/ffmpeg. Set
FFMPEG_PATHto use your own.
Joins many sounds into one file with silence between them, plus a JSON that tells the player where each sound is. Takes the same files as audio.
{ "action": "audiosprite", "source": "sfx", "channels": 1, "quality": 60 }"source": "sfx" writes sfx.mp3 and sfx.json. "destination": { "filename": "effects" } names them effects.mp3 and effects.json:
{ "src": ["sfx.mp3"], "sprite": { "click": [0, 200], "coin": [450, 100], "ui/close": [800, 350] } }| Option | What it does | Default |
|---|---|---|
format |
mp3, ogg, opus, m4a, wav. |
mp3 |
quality |
1 to 100, as in audio. | 80 |
bitrate |
8 to 320 kbps. Wins over quality. |
none |
channels |
1 or 2. Never more than the sounds have. |
from the sounds |
sampleRate |
As in audio. Never above the sounds. | from the sounds |
gap |
Seconds of silence between sounds, so one never bleeds into the next. | 0.25 |
data |
Layout of the JSON, see the table below. | howler |
data layouts:
| Value | What it writes | Times in |
|---|---|---|
howler |
Howler 2: { src: [file], sprite: { name: [start, duration] } } |
ms |
howler1 |
Howler 1: { urls: [file], sprite: { name: [start, duration] } } |
ms |
phaser |
Phaser 3 load.audioSprite: { resources: [file], spritemap: { name: { start, end, loop } } } |
seconds |
pixi |
PixiJS Sound sound.add: { url: file, sprites: { name: { start, end } } } |
seconds |
json |
Your own loader: [{ name, start, duration }] |
ms |
Good to know:
- Sound names are the paths inside the folder without the extension, like
ui/close. - Channels and sample rate come from the sounds, never higher.
- Looping is set in the game, not in the JSON.
Optimizes glTF models, .glb and .gltf. Everything it writes loads in any engine without a decoder.
{ "action": "gltf", "source": "models", "normals": false, "textures": { "extract": true } }| Option | What it does | Default |
|---|---|---|
format |
glb, gltf (separate files) or gltf-embedded. |
as the source |
quantize |
Smaller vertex data, KHR_mesh_quantization. See below. |
false |
simplify |
Fewer triangles, see below. | none |
textures |
Re-encode or extract textures, see below. | as they are |
normals |
false removes them; shading turns flat unless the game computes them. |
true |
tangents |
false removes them. |
true |
uvs |
false removes them, and the textures with them. |
true |
vertexColors |
false removes them. |
true |
materials |
false removes them, and the textures with them. |
true |
animations |
false removes them, or pick them by name, see below. |
true |
resample |
Remove animation keyframes that change nothing, see below. | true |
removeUnusedAttributes |
Remove vertex attributes no material uses, like UVs without a texture. | false |
keepSmaller |
Keep the original when cleanup alone would not make it smaller. | true |
simplify:
ratio: share of triangles to keep, 0 to 1. Simplify runs only with it.error: the most the shape may change, as a share of the model size. Default0.01; simplifying stops there.method:collapsekeeps the shape and UV seams.sloppyis coarser, for far LODs, and needs a highererror, like0.1.
textures:
format,quality,maxWidth,maxHeight: re-encode through the image action. Without them, textures stay as they are.extract: true: textures are written next to the model, likecrate_wood_baseColor.png(model, material, slot), and removed from it. UVs stay, so the game can put them back.
resample:
true: removes keyframes that the ones around them already give, within0.0001. Baked animations often shrink a lot.{ "tolerance": 0.001 }: removes more. A higher tolerance can change the motion slightly.false: keeps every keyframe.
animations:
{ "include": ["idle", "run*"] }keeps only these clips,{ "exclude": ["debug*"] }removes these. Names or patterns, not both. Bones stay.
LODs are one action per level:
{ "action": "gltf", "source": "hero", "destination": { "filename": "[name]_lod0" } },
{ "action": "gltf", "source": "hero", "destination": { "filename": "[name]_lod1" }, "simplify": { "ratio": 0.25 } }Extracted textures can go on to another action through a virtual folder:
{ "action": "gltf", "source": "models", "textures": { "extract": true }, "destination": "#models" },
{ "action": "image", "source": "#models", "format": "webp", "destination": "models" },
{ "action": "copy", "source": { "include": "#models", "exclude": "*.png" }, "destination": "models" }Good to know:
- A
.gltfbrings its.binand textures, and no later action takes them. Putgltfbefore your image actions. - Cleanup always runs: unused parts go, identical geometry is shared and vertices are welded. Names, nodes, empty nodes and vertex attributes stay, so game code finds what it expects. When that alone does not make a model smaller, the original is kept.
quantizeadds a scale and offset to the nodes. Code that reusesmesh.geometrywithout its node, likeInstancedMeshor merged geometry, sees the model shrunk and moved.removeUnusedAttributesjudges by the materials in the file: the UVs of a model the game textures at runtime look unused and go. UVs of extracted textures always stay.- Extracted textures fit an atlas only when they do not repeat; the game maps the UVs onto the frame.
- Reads meshopt-compressed models too, and always writes them uncompressed. For compression, see meshopt.
Compresses glTF models, .glb and .gltf, with EXT_meshopt_compression. The game needs meshoptimizer's decoder to load them.
{ "action": "gltf", "source": "models", "destination": "#models" },
{ "action": "meshopt", "source": "#models", "destination": "models" }| Option | What it does | Default |
|---|---|---|
level |
medium, or high, which also packs normals and tangents tighter. |
high |
In three.js:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);Works on any glTF model. Run gltf before it to also clean up, simplify or re-encode textures, as in the example above.
Optimizes Spine exports. Takes .atlas files. The pages an atlas names and the skeleton with its name, hero.json or hero.skel, come with it, and no later action takes them.
{ "action": "spine", "source": "spine", "format": "webp", "quality": 70, "animations": { "exclude": ["debug/*"] } }| Option | What it does | Default |
|---|---|---|
format |
Page format: png, jpeg, webp, avif. The .atlas is rewritten to match. |
as the source |
quality |
1 to 100. For png pages, setting it switches to palette compression. | not set |
webp |
WebP encoder for webp pages, as in image: { "alphaQuality": 50 }. |
as in image |
scale |
Shrink pages, 0 to 1. Every coordinate in the .atlas scales with them. |
1 |
maxWidth |
Shrink pages wider than this. Their coordinates in the .atlas follow. |
none |
maxHeight |
Shrink pages taller than this. Their coordinates in the .atlas follow. |
none |
precision |
Decimals kept for numbers in the skeleton .json. |
3 |
animations |
false, { "include": [...] } or { "exclude": [...] }: names or patterns, like run*. |
all kept |
skins |
[...], { "include": [...] } or { "exclude": [...] }. The default skin stays. |
all kept |
skeleton |
Skeleton format: json or skel. |
as the source |
Good to know:
.jsonand.skelconvert for Spine 4.2 only. Other versions keep their format, andanimationsandskinsthen work on a.jsononly.- jpeg cannot hold transparency, so a page with it keeps its format, with a warning.
- Put
spinebefore your image and json actions.
Keeps only the characters you use, and converts to WOFF2. Takes ttf, otf, woff, woff2.
{ "action": "font", "source": "fonts", "format": "woff2", "subset": "Play Again! Score: 0123456789" }| Option | What it does | Default |
|---|---|---|
format |
woff2, woff, or plain ttf or otf with the font's own outlines. |
as the source |
subset |
Characters to keep, see below. | all |
removeHinting |
Remove hinting, which only sharpens small text on low-resolution screens. | false |
keepSmaller |
Keep the original when converting would not make it smaller. | true |
subset: a string of characters, or{ "text": "...", "ranges": ["U+0020-007E", "U+0400-04FF"] }. Ligatures and kerning between the kept characters keep working.
Arial, 1 MB, becomes 37 KB as WOFF2 with Latin letters and digits, or 9 KB without hinting.
Draws a font into a BMFont .fnt and its page, for BitmapText in Phaser and PixiJS. Takes ttf, otf, woff, woff2, and text or XML .fnt files, whose pages come with them.
{ "action": "bitmapfont", "source": "fonts/score.ttf", "characters": "0123456789", "size": 48, "outline": { "width": 3 } }| Option | What it does | Default |
|---|---|---|
characters |
Characters to draw or keep. | ASCII, or all of a .fnt |
size |
Font size in px. | 32 |
color |
Glyph colour. White can be tinted in the game. | #ffffff |
outline |
{ "width": 3, "color": "#000000" } |
none |
shadow |
{ "x": 2, "y": 2, "color": "#000000" }, no blur. |
none |
format |
Page format: png, webp, avif. |
png, or as the .fnt |
quality |
1 to 100. For png pages, switches to a palette. | not set |
data |
xml, which Phaser needs, or text. |
xml, or as the .fnt |
padding, maxWidth, maxHeight, powerOfTwo |
As in atlas, when glyphs are packed. | as in atlas |
Good to know:
- A
.fntkeeps its glyphs where they are: pages are only converted. They are repacked whencharactersdrops some. size,color,outlineandshadowapply when drawing from a font. A binary.fntis refused.
Shrinks and compresses videos. Takes mp4, webm, mov, m4v, mkv, avi, ogv.
{ "action": "video", "source": "videos", "maxWidth": 720, "fps": 30 }| Option | What it does | Default |
|---|---|---|
format |
mp4, webm, or animated webp or gif without sound. A webm stays webm. |
mp4 |
quality |
1 to 100, for mp4, webm and webp. | 80 |
maxWidth |
Shrink videos wider than this, keeping the aspect ratio. | none |
maxHeight |
Shrink videos taller than this, keeping the aspect ratio. | none |
fps |
Frames per second. Never above the source. | source |
audio |
false removes the sound. |
true |
gif |
{ "colors": 256, "dither": true } |
|
keepSmaller |
Keep the original when re-encoding would not make it smaller. | true |
Transparency stays in webm, webp and gif. mp4 cannot hold it.
Minifies JSON files. An invalid file is an error.
| Option | What it does | Default |
|---|---|---|
pack |
Smaller raw size, for single-file playables. Read it in the game with unpackJson. |
false |
Minifies XML files: whitespace between tags is removed. Text and CDATA sections stay exactly as written.
| Option | What it does | Default |
|---|---|---|
comments |
Keep comments. | false |
Copies files unchanged. Takes every file. No options.
import {
actions,
image,
atlas,
audio,
audiosprite,
gltf,
meshopt,
spine,
font,
bitmapfont,
video,
json,
xml,
copy,
build,
selectFiles,
writeFiles,
formatReport
} from '@smoud/assets';CommonJS works the same with require('@smoud/assets').
Every action has a process function. It takes a source, an optional destination and the action's options, the same ones as in assets.json:
await image.process({ source: 'assets/ui', destination: 'public/ui', format: 'webp', quality: 80 });
await atlas.process({ source: 'assets/sprites', destination: 'public', extrude: 1 });sourceis a folder, file, glob or long-form object, relative to the working directory. Absolute paths work too. It can also be a list of files you already have in memory.destinationis a folder, or{ folder, filename }for a filename template. Results keep their path inside the selected folder:assets/ui/buttons/play.pngbecomespublic/ui/buttons/play.webp.- Without a destination nothing is written, and the files come back on the result.
onProgressis called as the action moves through its files, with the same events as in build.
Chain actions by passing one result's files to the next:
const icons = await image.process({ source: 'assets/icons', format: 'png', density: 144 });
await atlas.process({ source: icons.outputs, destination: 'public', padding: 4 });#rest and #virtual folders exist only inside a build. In code, a variable holding the files does the same job.
process never deletes other files in its destination, so several calls can share one folder. When an action reports an error, nothing is written. An unknown option throws.
Every action is also in the actions registry, by name:
await actions.image.process({ source: 'assets/ui', format: 'webp' });Each action has a raw function that works on bytes, with the same options as the action. No files, no folders.
const webp = await image.optimizeImage(pngBuffer, { format: 'webp', quality: 80 });
const meta = await image.readImageMeta(pngBuffer);
const packed = await atlas.packAtlas(
[
{ name: 'hero', data: heroPng },
{ name: 'coin', data: coinPng }
],
{ padding: 2 }
);
const mp3 = await audio.encodeAudio(wavBuffer, { format: 'mp3', bitrate: 96 });
const sprite = await audiosprite.packAudioSprite(
[
{ name: 'click', data: clickWav },
{ name: 'coin', data: coinWav }
],
{ gap: 0.5 }
);
const model = await gltf.optimizeGltf({ data: glbBuffer }, { simplify: { ratio: 0.5 } });
const compressed = await meshopt.compressGltf({ data: model.data });
const hero = await spine.optimizeSpine(
{ atlas: atlasText, files: { 'hero.png': heroPng, 'hero.json': heroJson } },
{ format: 'webp', scale: 0.5 }
);
const small = json.minifyJson(jsonBuffer);
const tight = xml.minifyXml(xmlBuffer, { comments: false });| Function | Returns |
|---|---|
image.optimizeImage(data, options) |
{ data, format, width, height, alpha, animated, keptOriginal, warnings }, or null for an svg without a format. |
image.readImageMeta(data) |
{ format, width, height, alpha, animated } |
audio.encodeAudio(data, options) |
{ data, format, duration, channels, sampleRate, bitrate, keptOriginal }, or null for a codec with no matching format and no format given. |
audio.readAudioMeta(data) |
{ format, duration, channels, sampleRate, bitrate } |
atlas.packAtlas(sprites, options) |
{ pages, warnings, errors }. Each page is { image, width, height, frames }. errors lists sprites that were left out. |
audiosprite.packAudioSprite(sounds, options) |
{ data, format, duration, channels, sampleRate, bitrate, sounds }. Each sound is { name, start, duration }, in ms. |
gltf.optimizeGltf(model, options) |
{ data, format, files, triangles, vertices, textures, animations }. files are the .bin and textures the model refers to, by name. |
gltf.readGltfMeta(data) |
{ format, triangles, vertices, textures, animations } |
meshopt.compressGltf(model, options) |
As gltf.optimizeGltf, with the model compressed. |
spine.optimizeSpine(input, options) |
{ atlas, files, warnings }. files are the pages and skeleton, by the names the .atlas refers to. |
spine.toSkel(json) / spine.toJson(skel) |
A Spine 4.2 skeleton converted, as a buffer. Throws on another version. |
font.optimizeFont(data, options) |
{ data, format, keptOriginal } |
bitmapfont.makeBitmapFont(input, options) |
{ fnt, pages, warnings }. input is { data, path }, plus files, the pages by name, for a .fnt. |
video.encodeVideo(data, options) |
{ data, format, width, height, duration, fps, audio, alpha, keptOriginal, warnings } |
json.minifyJson(data) |
A buffer. Throws on invalid JSON. |
json.packJson(data) |
A buffer, packed as the pack option does. Throws on invalid JSON. |
xml.minifyXml(data, options) |
A buffer. |
selectFiles finds files with the source syntax and reads them. writeFiles writes them with the destination syntax.
const { files, warnings } = await selectFiles(
{ include: 'assets/ui', when: { width: '>512' } },
{ probe: image.readImageMeta }
);
for (const file of files) file.data = (await image.optimizeImage(file.data, { quality: 70 })).data;
await writeFiles(files, { folder: 'public/ui', filename: '[name].[hash][ext]' });selectFiles option |
What it does |
|---|---|
root |
Folder the patterns are relative to. Default: the working directory. |
accepts |
Keep only these extensions, like ['png', 'jpg']. |
exclude |
Patterns that never match. |
probe |
A function that reads facts from a file, for when filters other than size. |
selectFiles knows nothing about images. Pass a probe, like image.readImageMeta, to filter on width or alpha.
Each file is { path, data, meta }, where path is relative to the selected folder and meta.size is the size in bytes.
Runs a config, the same as the command line.
const result = await build({ config: 'assets.json', strict: true });
console.log(formatReport(result));
if (!result.ok) process.exit(1);| Option | What it does | Default |
|---|---|---|
config |
A path to a config file, or the config as an object. | assets.json, if it exists |
preset |
Preset the config extends, like --preset. |
from the config |
source |
Source folder, like --source. |
from the config |
destination |
Destination folder, like --destination. |
from the config |
dryRun |
Process everything, write nothing. | false |
strict |
Warnings make result.ok false. |
false |
cache |
Reuse earlier results, like --cache. |
from the config |
onProgress |
Called as each action moves through its files. | none |
onProgress receives { label, index, actions, done, total, file }: the action's label and its place among all actions, how many of its total files are done, and the file that just started.
Problems inside the build land in result.errors. build throws only for a broken setup, like a missing source folder or a destination that would overwrite the source.
Turns results into the report the command line prints. Pass one action result, a list of them, or a build result.
const results = [
await json.process({ source: 'assets/data', destination: 'public' }),
await copy.process({ source: 'assets/fonts', destination: 'public/fonts' })
];
console.log(formatReport(results, { verbose: true }));verbose adds one line per file and shows every warning.
process returns an action result:
| Field | Meaning |
|---|---|
ok |
No errors. |
action |
The action name. |
label |
Short description, like image, source "ui". |
selected |
How many files the action received. |
fromCache |
How many of them came from the cache. |
files |
One row per source file: source, outputs, bytesIn, bytesOut, keptOriginal. |
outputs |
The produced files, in memory. |
destination |
The folder written to, or null when nothing was written. |
bytesIn |
Bytes in. |
bytesOut |
Bytes out. |
warnings |
Messages that did not stop the action. |
errors |
Messages that did. |
duration |
Milliseconds. |
build returns a build result:
| Field | Meaning |
|---|---|
ok |
No errors, and no warnings when strict. |
dryRun |
Whether this was a dry run. |
source |
Absolute source folder. |
destination |
Absolute destination folder. |
actions |
One action result per action. |
unselected |
Source files no action took. |
warnings |
Messages that did not stop the build. |
errors |
Messages that did. Nothing is written when there are any. |
duration |
Milliseconds. |
An action is a plain object. You write transform: it gets all the selected files and the options, and returns the outputs. Register the action once, and it works in code and in assets.json:
import { actions } from '@smoud/assets';
const upper = actions.register({
name: 'upper',
version: 1,
cache: true,
accepts: ['txt'],
options: {
suffix: { kind: 'string', description: 'Added to the file name', default: '' }
},
// a raw function: bytes in, bytes out, usable on its own as upper.toUpper(buffer)
toUpper(data) {
return Buffer.from(data.toString().toUpperCase());
},
async transform(files, { suffix }) {
return files.map((file) => ({
...file,
path: file.path.replace(/\.txt$/, `${suffix}.txt`),
data: upper.toUpper(file.data)
}));
}
});
await upper.process({ source: 'texts', destination: 'public/texts', suffix: '-loud' });{ "actions": [{ "action": "upper", "source": "texts", "suffix": "-loud" }] }register adds process around your transform. It selects the files with accepts and probe, checks the options and fills their defaults, writes the outputs, and builds the result for the report.
A file that points to other files, like a .gltf to its .bin, needs findCompanions(file): return those paths, relative to the file. They are read into file.companions, and no later action takes them.
cache: true lets the cache keep each file's result. Use cache: 'together' when every output depends on all the files, like an atlas. Caching needs a version: raise it whenever you change what the action produces, or old results keep coming back. Without cache, the action always runs.
What transform gets and returns:
files: every selected file,{ path, data, meta }. One file in, one out is the usual case, but an atlas takes many and returns two.options: checked, with every default filled in.context: two values that most actions never need. They exist for actions that combine files, like atlas.folderNameis the name of the folder the files were selected from, likespritesfor"source": "sprites". It is empty when the files come from several folders. Use it to name a combined output. The atlas names its pagesprites.pngthis way.filenameTemplateis the destination's filename template, like[name].[hash][ext]. Pass it todestinationPath(file, filenameTemplate)to know an output's final name before it is written. The atlas JSON uses this to point at its page image.
- It returns a list of output files, or
{ outputs, warnings, errors }to report problems. A throw fails the action.
Spread the input file into each output, { ...file, data }, so the report links them. An output without that link stands for all inputs, like an atlas page.
Helpers for transform:
| Helper | What it does |
|---|---|
transformFiles(files, transformFile) |
Runs transformFile on every file, several at a time. A broken file becomes an error that names it, and the rest go on. Returns { outputs, errors }, which transform can return as it is. |
resolveOptions(table, input) |
Checks options against a table and fills the defaults. Useful in raw functions. |
Writing process yourself. An action that defines process instead of transform is used exactly as written, for example when it needs its own file selection. Use selectFiles to find files, and finishAction to write the outputs and build a result the report and build understand.
Each option has a kind (boolean, number, string, enum, object, list or oneOf), a description and an optional default. Values that don't fit the kind are errors.
Small utilities for your game, to read what the build wrote. No dependencies, safe to bundle into a browser game.
| Function | What it does |
|---|---|
unpackJson(json) |
Reads a file written with the json action's pack option. Takes the parsed JSON or its text; other JSON is returned as it is. Source |
import { unpackJson } from '@smoud/assets/runtime';
import packed from './assets/level.json';
const level = unpackJson(packed);Found a bug or missing something? Open an issue.