@studiokloek/vite-assets-plugin
v0.6.1
Published
Vite plugin and CLI that packs sprites, sounds and fonts from tagged asset directories.
Maintainers
Readme
@studiokloek/vite-assets-plugin
Packs the sprites, sounds and fonts of a project from tagged directories: a Vite plugin that runs before every vite serve or build, and a kloek-assets CLI for everything else. It replaces @studiokloek/pixi-texturepacker, @studiokloek/sounds-packer and the AssetPack pipes of @studiokloek/vite-react-config.
- Sprites: every
{spr}directory becomes one PixiJS spritesheet (json + png/jpg), in every configured resolution (@2x), with trimming, rotation, multipack and content-hashed names. - Sounds: every
{snd}directory has its.wavfiles encoded to mp3 with thefxorvoicepreset. - Fonts: every
{fnt}directory has its.ttf/.otffiles converted to.woff2, with{weight=700}and{italic}on a file name to mark a variant. - For every sprites and sounds directory a typed
sprites-<name>.ts/sounds-<name>.tsis generated, in the shape@studiokloek/ts-core-libandkloek-coreload. A fonts directory gets afonts-<name>.tswith its families and afonts-<name>.csswith the@font-facerules.
Install
pnpm add -D @studiokloek/vite-assets-pluginNode 22.22 or newer. sharp, ffmpeg-static and @napi-rs/woff-build ship native binaries; with pnpm 10+ approve their build scripts once (pnpm approve-builds).
Usage
As a Vite plugin
// vite.config.ts
import { defineConfig } from 'vite';
import { kloekAssets } from '@studiokloek/vite-assets-plugin';
export default defineConfig({
plugins: [
kloekAssets({
output: { directory: 'public/assets' },
scriptDirectory: 'src/assets',
sounds: { prefixes: ['sj25', 'sj25_pz'] },
}),
],
});The plugin packs everything in buildStart, so the generated code exists before Vite resolves your imports, and keeps watching the entry directory while the dev server runs. The sheets are compressed (palette png, mozjpeg) in development too, so you see what production ships; set compress: false to write them fast and lossless.
As a CLI
kloek-assets # pack everything (development mode)
kloek-assets --mode production
kloek-assets --watch # keep running and repack on changes (the config file itself is not watched)
kloek-assets --force # ignore the cache
kloek-assets --no-cache
kloek-assets unused # report sprites, sounds and fonts no code refers to
kloek-assets unused --search src --search lib
kloek-assets clean # remove the cache directoryThe CLI reads kloek-assets.config.{ts,js,mjs,json} from the working directory, or the kloekAssets key of package.json, or --config <file>.
// kloek-assets.config.ts
import { defineConfig } from '@studiokloek/vite-assets-plugin';
export default defineConfig({
output: { directory: 'source/static' },
scriptDirectory: 'source/script',
sprites: { maxSize: 4096 },
});Directory tags
Everything below entry (default assets) is walked. A directory name can carry {tag} and {tag=value} options; the tags are stripped from every name and path that ends up in the output. Several tags fit in one pair of braces, joined by a plus: menu{snd+sheet+voice} is the same as menu{snd}{sheet}{voice}, and tiles{spr+jpg+max=1024+res=1,2} works too.
assets/
mutsop/
sprites/
menu{spr}/ → one atlas "mutsop/menu"
game/background{spr}{jpg}/ → jpg atlas "mutsop/game/background"
sounds/
menu{snd}{voice}/ → voice preset
game{snd}/ → fx preset
generic/
fonts{fnt}/ → one fonts item "generic"
Bungee.ttf → fonts/generic/bungee.woff2
Bungee{weight=700}.ttf → fonts/generic/bungee-700.woff2, the same family| Tag | Applies to | Meaning | Default |
|---|---|---|---|
| {spr} | sprites | the directory is an atlas: everything below it is packed into one spritesheet | |
| {jpg} / {png} | sprites | sheet format; {jpg=60} / {png=60} also sets the compression quality (1–100) of this directory | sprites.format (png), quality of sprites.compression |
| {notrim} | sprites | do not trim transparent borders | sprites.trim (true) |
| {extrude} / {extrude=N} | sprites | extrude sprites by N pixels (1 without a value) | sprites.extrude (0) |
| {max=N} | sprites | maximum sheet size | sprites.maxSize (2048) |
| {res=1,2} | sprites | resolutions to write; 2 gets the @2x suffix | sprites.resolutions ([1, 2]) |
| {src=N} | sprites | resolution the sources were drawn at | sprites.sourceResolution (2) |
| {meta=full\|size\|none} | sprites | per-sprite info in the generated code | sprites.metadata (full) |
| {nohash} | sprites | no content hash in the output names | sprites.hash (true) |
| {frames} | sprites | frame rectangles in the generated code, for loaders that do not read the json | sprites.frames (false) |
| {snd} | sounds | the directory is a sound set: every .wav below it is encoded | |
| {fx} / {voice} | sounds | preset: stereo, or mono at half the bitrate | sounds.preset (fx) |
| {low} / {normal} / {high} | sounds | quality (bitrate) | sounds.quality (normal) |
| {sheet} / {sheet=N} | sounds | join the short sounds into sheets of about N seconds, see Sound sheets; needs a loader that reads sheet and start | off, sounds.sheet.maxDuration (60) |
| {fnt} | fonts | the directory is a font set: every .ttf/.otf below it becomes a .woff2, files with the same name are variants of one family | |
| {weight=N} | fonts | font-weight (1–1000) of the font; on a file name, or on the directory for every font in it | 400 |
| {italic} | fonts | the font is italic; on a file name, or on the directory for every font in it | normal |
| {codeonly} | sprites, sounds | only generate code; the output is final and comes from elsewhere | |
| {name=N} | all | trailing path parts used in the exported name | nameParts (2) |
| {ignore} | all | skip the directory and everything below it | |
Rules:
- Option tags on a parent directory apply to every item below it; a tag on the item itself wins.
{spr},{snd}and{fnt}never inherit. - A
{spr},{snd}or{fnt}directory inside another one is its own item and is left out of the enclosing one. - Fonts carry their variant on the file name:
KG-Primary{weight=700}{italic}.ttf. The family is the file name without tags, soKG-Primary.ttfandKG-Primary{weight=700}.ttfare one family with two variants. A font outside a{fnt}directory is skipped with a warning. - With
typeFolders(default) the segmentssprites,soundsandfontsare dropped from item paths:mutsop/sprites/menu{spr}is the itemmutsop/menu. - Tags on directories below an item configure nothing; they are stripped with a warning.
- A tag without a value is not the same as leaving it out:
{extrude}is{extrude=1}, while a directory without the tag getssprites.extrude(0, no extrusion).
Output
<output>/sprites/<item path>-<hash>/<name>-<hash>-<n>[@2x].{json,png|jpg}
<output>/sounds/<hash>.mp3 a sound, or a sheet of sounds with {sheet}
<output>/fonts/<item path>/<slug>[-<weight>][-italic].woff2 -<weight> only when it is not 400
<scriptDirectory>/<parents>/assets/{sprites,sounds,fonts}-<name>.ts
<scriptDirectory>/<parents>/assets/fonts-<name>.cssThe spritesheet json is the PixiJS format (frames, meta.scale, meta.related_multi_packs, meta.number_of_packs), loadable by PixiJS 4 through 8. The generated code:
export const WorldDuinSprites = {
eiland: { STREEPJES: { id: 'world/duin/eiland/streepjes', width: 41, height: 34 } },
};
export const WorldDuinSpritesLoaderInfo = {
assets: WorldDuinSprites,
fileName: 'world/duin-f9025187d7',
numberOfParts: 1,
type: 'sprites',
bundle: 'world/duin',
sources: [
{
part: 1,
alias: 'world/duin-f9025187d7-1',
variants: [
{ resolution: 1, format: 'png', json: 'sprites/world/duin-f9025187d7/duin-f9025187d7-1.json', image: 'sprites/world/duin-f9025187d7/duin-f9025187d7-1.png' },
{ resolution: 2, format: 'png', json: 'sprites/world/duin-f9025187d7/[email protected]', image: 'sprites/world/duin-f9025187d7/[email protected]' },
],
},
],
};
export const GenericFxSounds = { SWISH_1: { id: 'eb8779673e', duration: 0.5724, name: 'swish_1' } };
export const GenericFxSoundsLoaderInfo = { assetName: 'generic/fx', assets: GenericFxSounds, numberOfSounds: 1, type: 'sounds', bundle: 'generic/fx' };Sprite ids are identical to what pixi-texturepacker produced and sound hashes to what sounds-packer 2 produced (a project still on sounds-packer 1 gets new sound ids once), and so are assets, fileName, numberOfParts, assetName, numberOfSounds and type.
bundle and sources describe the files themselves, as plain data for any loader:
bundleis the item path, without a hash: a stable name for the group.sourceshas an entry per sheet.partcounts from 1, andaliasis a unique name for the sheet (<fileName>-<part>).variantslists every resolution written for that sheet, lowest first.formatis the extension ofimage, andjsonandimageare relative tooutput.directory, so a loader only puts its base url in front.- A
{codeonly}item lists the sheets it expects from elsewhere, under the same names.
The types are exported as SpriteSource, SpriteSourceVariant and AssetFrameData.
Fonts
A {fnt} directory generates a constant with its families and a stylesheet with an @font-face per variant:
export const GenericFonts = {
KG_PRIMARY: {
family: 'KG-Primary',
variants: [
{ weight: 400, style: 'normal', url: '/fonts/generic/kg-primary.woff2' },
{ weight: 700, style: 'italic', url: '/fonts/generic/kg-primary-700-italic.woff2' },
],
},
};@font-face {
font-family: 'KG-Primary';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/generic/kg-primary.woff2') format('woff2');
}Import the stylesheet once (import './assets/fonts-generic.css') instead of writing @font-face rules by hand: when a font moves, the generated paths follow. url is relative to the directory served as /: Vite's publicDir when the output lies inside it, else public under the root, else the output directory itself. The family is the file name as written, so font-family: 'KG-Primary' in CSS and GenericFonts.KG_PRIMARY.family in code name the same font.
Frames in the code
With sprites.frames (or {frames} on a directory) every sprite also carries where it sits in its sheet, for loaders that draw from the image without reading the json (DOM or canvas sprites):
STREEPJES: {
id: 'world/duin/eiland/streepjes', width: 41, height: 34,
part: 1,
frame: { x: 31, y: 43, w: 41, h: 34 },
rotated: true,
trimmed: false,
trimmedOffset: { x: 0, y: 0 },
},partis thepartof the entry insourcesthat holds the sprite.frameandtrimmedOffsetare in 1x units, likewidthandheight. Multiply by theresolutionof a variant for the pixels in its image; every variant has the same layout.trimmedOffsetis where the trimmedframesits inside the untrimmedwidth×height.rotatedsprites are turned 90° clockwise in the sheet: they takeh×wpixels there, andframe.wandframe.hare the size before rotating, as in the json.- With
metadata: 'none'a sprite is an object with itsidand the frame fields instead of a plain string. - A
{codeonly}item gets no frames: its sheets are not packed here.
Sound sheets
With {sheet} on a {snd} directory its short sounds are joined into a few mp3 files instead of one file each, so a set of a hundred sounds loads in a handful of requests:
export const GenericFxSounds = {
SWISH_1: { id: 'eb8779673e', duration: 0.5724, name: 'swish_1', sheet: '2c2c19885f', start: 1.013 },
};
export const GenericFxSoundsLoaderInfo = { /* as above */ sheets: ['2c2c19885f'] };- A sound with a
sheethas no file of its own: it is thedurationseconds that start atstartinsounds/<sheet>.mp3.idstays the hash of the sound itself, so it is still unique and the same as without{sheet}. - Sounds longer than
sounds.sheet.maxSoundDuration(music, long voice-overs) keep their own file and get nosheet; so does a directory with a single short sound. - The sounds are spread evenly, in the order of their path, over as many sheets as it takes to stay around
sounds.sheet.maxDurationseconds ({sheet=90}sets it for one directory). 60 seconds is about 1 MB at thefx/normalbitrate. - Every sound is followed by
sounds.sheet.gapseconds of silence, and the sounds are joined before encoding, so the starts are exact to the sample. sheetsin the loader info lists the sheets of the set. Without{sheet}the generated code is exactly what it was.- The loader has to know about
sheetandstart: one that does not looks forsounds/<id>.mp3, which is not there. With Howler it is a sprite on the sheet,new Howl({ src: 'sounds/<sheet>.mp3', sprite: { main: [start * 1000, duration * 1000] } }); load one sound of a sheet before the others, as Howler only shares a file once it is decoded. - Changing one sound renames the sheets of its directory, and a sound that is in two
{sheet}directories is downloaded with both.
Configuration
Every option with its default:
{
entry: 'assets',
output: { directory: undefined /* Vite publicDir, else 'public' */, sprites: 'sprites', sounds: 'sounds', fonts: 'fonts' },
scriptDirectory: 'src/assets',
cacheDirectory: undefined /* node_modules/.cache/kloek-assets/<project id> */,
typeFolders: true,
nameParts: 2,
sprites: {
resolutions: [1, 2],
sourceResolution: 2,
maxSize: 2048,
format: 'png',
trim: true,
extrude: 0,
metadata: 'full',
hash: true,
frames: false,
compression: { png: { quality: 80, dither: 1 }, jpeg: { quality: 75 } },
},
sounds: {
prefixes: [],
preset: 'fx',
quality: 'normal',
sheet: { maxDuration: 60, maxSoundDuration: 10, gap: 0.05 } /* seconds, for {sheet} directories */,
},
fonts: { enabled: true },
compress: true /* false: fast and lossless */,
watch: { delay: 500 },
}sounds.prefixes are stripped from sound names. Write a prefix without its separator (sj25): it matches both sj25_ and sj25-, and a trailing separator in the config is ignored. Unused prefixes and likely missing ones are reported.
Environment: KLOEK_ASSETS_WORKERS=0 disables the worker threads, KLOEK_ASSETS_DIRECTORIES=n limits how many sprite directories pack at once, KLOEK_ASSETS_FFMPEG=<path> overrides the ffmpeg binary.
Cache and cleanup
Unknown keys in the config are an error, so a typo like scriptDir cannot silently do nothing. Tag values are checked the same way ({max=0} or {name=x} stop the run with a message).
Everything is cached in node_modules/.cache/kloek-assets/<project id> (the id is derived from the project root, so projects that share a hoisted node_modules keep separate caches): per item a key over its sources, options, mode and the package version, encoded sounds and sprite sheets as reusable blobs, and a manifest of the last successful run. An unchanged item costs a few milliseconds. The sheets of every sprites item are kept as blobs too, uncompressed and with the current compression: when the output is gone (a cleared output directory, a CI machine that restores only the cache) or compress is switched, they are written from the cache instead of packed, and png sheets are compressed again from the uncompressed blobs when only the compression settings change. To profit from this on CI, keep the cache directory between runs. After a successful run, output and generated code that no item claims any more is removed: old hashed sprite directories, sounds of a removed directory, the script of a directory that lost its tag. Nothing is removed while any item fails: a failed item claims nothing, and pruning then would take the output of its last good run.
Migrating
From pixi-texturepacker and sounds-packer (Pixi projects): remove the assets-*.json files and put the tags on the directories themselves (generic → generic{spr}, dagupdate/states/start → start{snd}, items{voice}{high} → items{snd}{voice}{high}, ui{codeonly} → ui{spr}{codeonly}). Add a config with output.directory: 'source/static' and scriptDirectory: 'source/script'. Item paths, exported names and script locations stay the same; sprite directory names get a new hash once, and so do the sound ids when the project was still on sounds-packer 1. Code and files are regenerated together, so nothing needs to change by hand.
From vite-react-config's AssetPack pipes (React projects): the directory tags already match. Move vite.assets.sounds.prefixes to sounds.prefixes, and vite.assets.sprites.quality to sprites.compression. The generated code and the output layout change to the shapes above; adapt the loaders accordingly. Turn on sprites.frames for the frame rectangles in the code, and take the image of a sprite from sources (by its part) instead of building the path. Frames are in 1x units and can be rotated.
For coding agents
llms.txt in the package root (node_modules/@studiokloek/vite-assets-plugin/llms.txt) holds the tags, the config, the output layout and the shape of the generated code in one file.
License
MIT
