Voice and avatar7 of 9
The Live2D model
On this page
No model ships with Kotoba. Searching the repository for *.model3.json returns nothing, and
public/ contains no model folder.
That is a licence decision, not an oversight. The good free models — Live2D's own samples included — are licensed for use but not for redistribution. Kotoba may not hand you a copy. So the repository carries the expression contract and the code that adapts to any model, and the model itself arrives on your machine, from its author.
Everything except the face works with no model installed. The avatar card says none is installed and names the folder to put one in.
Where models live
~/.kotoba/models/ # measured: model_library.models_dir()
One folder per model. Override with KOTOBA_MODELS_DIR.
Outside the repository on purpose: public/ is not writable after an install. A pip install has no
repository at all, and a container freezes its image.
Getting a model onto your machine
The offer
GET /api/models/default answers what the download would fetch and whose licence it is: the three
fields of model_install.DEFAULT_MODEL, plus installed_dir for whatever the sample last unpacked
into. The constant, read out of the module:
{"name": "niziiro-mao",
"url": "https://cubism.live2d.com/sample-data/bin/mao/mao_en.zip",
"licence": "https://www.live2d.com/en/learn/sample/model-terms/"}
A setup screen has to show both before it offers the button, since the reason nothing ships is that it may not be redistributed. The browser's first-run screen does: the button itself is the acceptance, "I accept those terms — fetch her", beside a link to Live2D's licence.
kotoba setup does not offer this. Measured: nothing anywhere in api/src/kotoba/cli/ mentions
model installation. A terminal-only install finishes with no face, and this is the step that gives
her one.
By hand, the same thing:
curl -X POST http://127.0.0.1:8000/api/models/install/default \
-H 'Content-Type: application/json' -d '{"accept_license": true}'
Without accept_license it answers 400 and the licence URL. Nothing else about that request takes
a URL — the address is fixed in the code. An operator can point KOTOBA_DEFAULT_MODEL_URL at their
own mirror, and it is checked exactly like the original: an outbound-request guard on the address,
then a fresh check on every redirect hop, up to five.
Your own model, as a zip
curl -X POST http://127.0.0.1:8000/api/models/install/upload --data-binary @my-model.zip
The body is the archive. A model is tens of megabytes, so it streams to disk as it arrives and no part of it is held in memory. The first-run screen has a drop zone that does the same thing.
Add -H "Authorization: Bearer $KOTOBA_WEB_PASSWORD" when a password is set.
By hand
Unpack into its own folder so the entry file lands somewhere like:
~/.kotoba/models/mao_pro/runtime/mao_pro.model3.json
Keep the licence file beside it. Then reload the page. No rebuild, no environment variable.
What a model must contain
A .model3.json. That is the whole requirement, and an archive without one is refused:
that archive holds no .model3.json, so it is not a Live2D model
Everything else follows from Cubism: it resolves textures, motions and expressions relative to that entry file, so the whole folder has to come with it and stay reachable. A model whose siblings 404 loads and then shows nothing at all.
Which entry is chosen when an archive holds more than one: shallowest first, then alphabetical by the path text. Alphabetical by text and not by path object, because Windows compares those case-folded and the same download would otherwise wear a different face there.
If the entry sits in a folder called runtime/ — which the official samples do — the parent becomes
the installed folder, so the licence file stays beside the model and the entry stays within the depth
the scanner walks (three levels).
What the installer refuses, and what it drops
Measured against install_zip:
| Archive larger than | 200 MB |
| Unpacks to more than | 300 MB |
| More than | 4000 entries |
| A name longer than | 200 characters |
| An entry naming a file outside the folder | refused |
| An entry that is a link or a device, not a file | refused |
No .model3.json | refused |
File types that are written: .json .moc .moc3 .png .jpg .jpeg .webp .txt .md
.wav .mp3. Anything else is skipped and counted, not refused — the archive still installs.
A measured install of a small test archive containing an evil.html:
{"dir": "demo", "entry": "runtime/demo.model3.json", "replaced": false,
"skipped": 1, "skipped_kinds": [".html"], "files": 4, "bytes": 106,
"path": "/…/models/demo"}
The .html never reached disk. The licence file beside the model did.
The archive is unpacked to one side and moved into place only once all of it has passed, under a lock, so two installs cannot interleave and a failure never leaves a half-written model. Reinstalling replaces: one model, one folder, the newer one.
How the files reach the browser
Kotoba serves them itself, read-only, under /api/models/raw/…, under their real paths so the entry
file's own relative references resolve to sibling URLs.
Two refusals decide what leaves, and both were measured:
resolve("demo/runtime/demo.model3.json") -> the real path
resolve("demo/runtime/evil.html") -> None
resolve("demo/../../etc/passwd") -> None
The type check runs on the resolved path, so it judges the file that would actually be opened
rather than the spelling asked for. Servable types: .json .moc3 .moc .png .jpg .jpeg
.webp. This is somebody's unpacked download being served on the app's own origin, and a stray
.html or .js in it would run there.
Note the two lists are not the same. Four types are installed but never served — .txt, .md,
.wav, .mp3. Measured:
resolve("demo/runtime/motion.wav") -> None
resolve("demo/LICENSE.txt") -> None
resolve("demo/runtime/t.png") -> the real path
For the licence and the readme that is the intent: they belong on your disk, beside the model, not on
a URL. For motion audio it is a gap rather than a decision — a model whose motions reference a .wav
will have that file installed and will get a 404 if anything asks for it. In practice nothing does
yet, because Kotoba disables the model's auto-played motions (see
Lip sync and framing).
The first fetch carries ?token=, the only credential a browser can attach to it; the backend swaps
it for a short-lived cookie scoped to /api/models/raw that the model's own relative fetches then
ride. That cookie is derived separately from the file-viewer one, so a cookie minted to draw a face
cannot read your files.
Choosing between several
GET /api/avatar reports what is installed and which one she wears. With exactly one installed, that
one is used. With several, pick one in Settings → Personality → Avatar model, which applies on
reload. Only something actually on disk is accepted — a stored value that 404s is a control that
looks like it works and does not.
A configured model that nobody installed is the fresh-install case, not an error: the first model on disk stands in, because a face nobody asked for beats no face at all.
Which model she wears is a backend value (soul_config.avatar_model, seeded once from
soul/default.md). It used to be a build-time environment variable, which meant a prebuilt image
could never change model and no setup screen could choose one.
