cutan.characters¶
Character art system: Spine-shaped descriptor + SVG sidecars.
Public API:
CharacterDescriptor— the on-disk schema for a character (bones, slots, skins, viseme map, idle animations).new_character()— generate a fresh character (DiceBear or built-in).generate_default_mouths()— produce the 9-shape default mouth set.render_silhouette(),compare_silhouettes()— silhouette test.breath_animation(),blink_animation()— idle animation factories.validate_character()— completeness check against the schema.promote()— lift an inline character into the reusable mall.
Conventions (locked in):
Slot/skin/animation separation modeled on Spine’s JSON format.
SVG layout: a
<g id="skeleton">of named<circle>pivots and a sibling<g id="illustration">containing named part groups (Pose Animator convention).9 mouth shapes, named
mouth_athroughmouth_hplusmouth_x(the rest position), matching Rhubarb’s A–H + X visemes.Time in seconds (float);
bone:<name>.<prop>andslot:<name>.attachmentare the two animation target syntaxes.
>>> from cutan.characters import MOUTH_SHAPES
>>> MOUTH_SHAPES
('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'x')
Functions
|
Promote Inkscape labels to ids and ensure a viewBox is set. |
|
Emit a standalone SVG tree containing only the group with the given id. |
|
Return |
|
Serialize an |
Copy |
|
|
Return |
|
Write the default mouth SVGs into |
|
Sine-wave breath on torso Y + head rotation; optional weight shift. |
|
Step-animation that snaps both eye slots closed → open. |
|
Render an SVG to a binary silhouette PNG (black on white). |
|
Return IoU between two silhouette PNGs (0..1; lower = more distinct). |
|
Fetch an avatar SVG from DiceBear's HTTP API. |
|
Build a complete character on disk. |
|
Check an art package against the contract, offline. |
|
Promote |
|
Render preview.html for the character at |
|
Record |
Classes
|
The on-disk character schema. |
|
A skeleton joint with a local transform relative to its parent. |
|
A draw-order slot bound to a bone, displaying one attachment at a time. |
|
A drawable: an SVG path + anchor point (in 0..1 per-axis units). |
|
A named outfit/variant: maps slot → {attachment_name → Attachment}. |
|
A named idle loop (e.g., breath, blink). |
|
A single channel inside an idle animation. |
- class cutan.characters.AnimationTrack(*, target, type='sine', amplitude=0.0, phase=0.0, frames=<factory>, **extra_data)[source]¶
Bases:
RigModelA single channel inside an idle animation.
The
targetis a path-string per the architecture pillar:bone:<name>.<prop>for bone transforms (x,y,rotation_deg,scale_x,scale_y).slot:<name>.attachmentfor swap animations (eyes blinking, mouth visemes).
For
type="sine":amplitudeis the peak deviation;phaseis in cycles (0..1). Fortype="step"/type="linear":framesis a list of[time_s, value]pairs evaluated in order.>>> t = AnimationTrack(target="bone:torso.y", type="sine", amplitude=2.0) >>> t.amplitude 2.0
- class cutan.characters.Attachment(*, path, anchor=(0.5, 0.5), x=0.0, y=0.0, width=None, height=None, source=None, **extra_data)[source]¶
Bases:
RigModelA drawable: an SVG path + anchor point (in 0..1 per-axis units).
>>> a = Attachment(path="parts/head.svg", anchor=(0.5, 0.78)) >>> a.anchor (0.5, 0.78)- model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}¶
Configuration for the model, should be a dictionary conforming to [
ConfigDict][pydantic.config.ConfigDict].
- source: AssetSource | None¶
Where THIS part’s art came from, when it is not the descriptor’s
source— a character composed from several clips, or a carved head on a CC0 body, credits each (an#220).None= the descriptor’ssourcecovers it.an creditslists every one; an all-rights- reserved part makes the render NOT PUBLISHABLE like any other. Omitted from the stored document when unset.
- width: Optional[float]¶
The size the part draws at, in view_box units — the rig’s units, the ones
x/yand the bones use (an#220). **A declared size wins** over the art’s own extent, asPlane.sizedoes for plates. Unset, the art’s own extent is the size: an SVG’swidth/height(else its viewBox), a raster’s PIXEL count — so a PNG carved at one pixel per unit needs nothing, and one carved at any other scale declares its size here instead of being resampled. The aspect is the art’s, always (an#74): with ONE of the two declared the other follows the art’s aspect; with both, the art is contained in the box (uniformly scaled to fit, never stretched) andan character validatesays when the two aspects disagree. Seeattachment_box().
- x: float¶
Offset from the slot’s bone, in view_box units.
This is where a part’s position lives, and it is the reference data model’s answer, not an invention: DragonBones puts it in
display.transform, Spine in the region attachment’s{x, y}, and in both the slot carries no transform at all. It is what lets five face parts share oneheadbone and still land in different places — before this field they all stacked on the bone, because the descriptor had no way to say otherwise and the compiler used hardcoded literals instead.
- class cutan.characters.Bone(*, name, parent=None, x=0.0, y=0.0, rotation_deg=0.0, scale_x=1.0, scale_y=1.0, pivot=None, **extra_data)[source]¶
Bases:
RigModelA skeleton joint with a local transform relative to its parent.
>>> b = Bone(name="head", parent="torso", x=0, y=-260, pivot="neck") >>> b.parent 'torso'
- class cutan.characters.CharacterDescriptor(*, origin=None, rest_rotation=None, nesting=None, schema_version='0.4.0', kind='CharacterDescriptor', name, display_name=None, view_box=(0, 0, 1024, 1024), voice_ref=None, source_svg=None, bones=<factory>, slots=<factory>, skins=<factory>, asset_sets=<factory>, animations=<factory>, source=None, face_overlay=True, expression_binding=None, gaze_travel=None, colour_roles=<factory>, metadata=<factory>, swap_poses=<factory>, rest_view=None, gait=None, speech=None, occluded=<factory>, **extra_data)[source]¶
Bases:
RigDocumentThe on-disk character schema. Saved as
character.json.The descriptor is the SSOT for a character’s identity, body part inventory, pivot geometry, viseme map, and built-in idle behaviors. Binary art lives as SVG sidecars referenced by
Attachment.path(relative to the descriptor file).>>> c = CharacterDescriptor(name="maya") >>> c.schema_version == CHARACTER_SCHEMA_VERSION True >>> # all 9 mouths are wired into the default skin >>> sorted(c.skins["default"].slots["mouth"].keys()) == [ ... 'mouth_a', 'mouth_b', 'mouth_c', 'mouth_d', ... 'mouth_e', 'mouth_f', 'mouth_g', 'mouth_h', 'mouth_x', ... ] True >>> # round-trip >>> raw = c.model_dump_json() >>> back = CharacterDescriptor.model_validate_json(raw) >>> back.name == c.name True- asset_sets: dict[str, dict[str, str]]¶
{channel: {key: attachment_name}}— what a swap key SELECTS, layered overskins, which is the SSOT for what art EXISTS. The indirection is deliberate: a channel key is not an attachment name. Today’s viseme map happens to be one-to-one (9 keys, 9 attachments), but real mouth charts are many-to-one — ~10 drawings carrying ~40 phonemes — and collapsing the two namespaces makes the first shared drawing a schema change instead of a data change. Replacesviseme_map(schema 0.2.0).
- colour_roles: dict[str, dict[str, str]]¶
Which colour literal in which part plays which
StylePackrole —{part path: {"#rrggbb": role}}, e.g.{"parts/torso.svg": {"#a83249": "clothing"}}. Written by the factory, which KNOWS what it drew as skin or clothing; read by the compiler, which rewrites the tagged literals under a pack (palette swapping — seecutan.characters.colour_roles). Empty = untagged art (hand-drawn, DiceBear): a pack cannot reach it and the compiler says so, because the alternative is inferring a role from a pixel (an#99’s wrong-tone lid). Additive: no schema bump, and a descriptor without it reads back as untagged. Keys are normalised to lowercase#rrggbb; a role must be one a pack can set (an.styles.REACHABLE_ROLES).
- expression_binding: Optional[list[dict[str, Any]]]¶
How expression axes reach this rig (an#98), as a list of binding dicts —
{"axis", "slot", "property", "gain"[, "rig_scaled"]}for a transform channel,{"axis", "slot", "set_family"}for a swap set.Nonemeans the default binding derived from the slots the rig has (cutan.expression.binding.default_binding()). Additive: no schema bump, and a pre-Wave-6 descriptor reads back unchanged.
- face_overlay: bool¶
Whether this character’s face is drawn as separate overlay parts (eyes, brows, mouth as their own slots — the default) or baked into the head art (DiceBear / external avatars).
Falsesuppresses the face overlay slots at rig build AND the viseme/emotion channels at dialogue compile — a baked face has no overlay mouth to drive.This is a declared fact, replacing the old vendor-name check on
metadata.art_provenance(an#87): provenance says where art came from; this says what the art IS. The 0.2.0 → 0.3.0 migration derives it from the provenance string once, andart_provenancereverts to pure provenance/licensing metadata.
- gait: Optional[str]¶
This character’s default walk
gait(one ofGAITS, an#220); an author’sgaitarg overrides it.None= the locomotion chain:legswhen the rig affords a leg pair, elseglide(an#224). A robe figure whose leg slots are hem halves declares"hem"once, here, rather than on every walk. Omitted from the stored document when unset.
- gaze_travel: Optional[dict[str, float]]¶
How far a pupil may travel from its rest, in view-box units per axis (an#99): the sclera’s clearance minus the pupil’s radius, written by
an character add-gazefrom the parts it synthesized.None= the rig has no pupil layer (gaze is a no-op on it) or uses the default travel. The travel maps the gaze axes’ unit circle onto the sclera’s inner ellipse; the compiler clamps the summed (x, y) to 0.95 of that circle, which keeps the whole pupil disc inside the white at every angle (a per-axis box pokes out at the diagonal) — no runtime mask.
- metadata: dict[str, Any]¶
Free-form metadata (dicebear style/seed, etc.). Schema-evolution friendly: anything an external tool wants to record can land here.
This comment used to say “art license, etc.” — an invitation nothing ever took up. Rights live in
sourcenow, typed, so they can be found.
- model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}¶
Configuration for the model, should be a dictionary conforming to [
ConfigDict][pydantic.config.ConfigDict].
- model_post_init(_CharacterDescriptor__context)[source]¶
Override this method to perform additional initialization after
__init__andmodel_construct. This is useful if you want to do some validation that requires the entire model to be initialized.- Return type:
- occluded: dict[str, str]¶
The face features another drawing of this character covers, and what covers them —
{feature: what}, e.g.{"brows": "the cap hat at head_scale 0.3"}(an#252). A declared fact, written by whoever knows the geometry: the factory measures its hat against the brows’ acting range and records an overlap it could not seat away; an illustrator declares a helmet over the brows. Read by the character analyser: a covered feature is not afforded (brows→face.brows), so the methods needing it fall to their default, said byan character capabilities. Keys areOCCLUDABLE_FEATURES. Omitted from the stored document when empty.
- rest_view: Optional[str]¶
The view the DEFAULT art is drawn in (an#220) — a declared fact about the art, like
face_overlay.NonemeansDFLT_VIEW(front). A character carved from a profile (a silhouette film, a side- on figure) says"side", and everything that asks which view is in force before any turn —walkswinging its legs rather than lifting them — reads it instead of the author passingview: sideby hand. Omitted from the stored document when unset.
- source: AssetSource | None¶
Where this character’s art came from, and what its licence obliges.
Nonemeans “we made this” — not “unknown”. Anything acquired should carry one, because a licence defect is the only failure that reaches BACKWARDS through completed work: a video shipped with an unattributed CC BY asset cannot be un-shipped.Field names match
illustration.ImageResultexactly, so an adapter is a dict copy rather than a rename table — and a rename table is where a field quietly stops being carried. Pinned by test.
- source_svg: Optional[str]¶
Optional source SVG (relative path) that the parts/ folder was extracted from. Useful for re-slicing.
- speech: Union[str, dict[str, Any], None]¶
a method’s spelling (
mouth_chart,pulse), its id (speech.pose_only) or a choice with args and an optional version pin ({method: pulse, args: {strength: 0}}— a mime).None= the default chain: lip-sync when the character has a mouth chart, else a head pulse, recorded. Declaring it is also how a baked-face character renders under--strict-assets: a declared pulse is the request, not a fallback. Resolved (and refused when unknown) by the capability registry. Omitted from the stored document when unset.- Type:
How this character shows it is speaking (the speech aspect, an#248)
- swap_poses: dict[str, dict[str, dict[str, SlotPose]]]¶
{slot: SlotPose}}}`` (an#197). A
setof a swap set on the ENTITY itself ({kind: set, target: maya, property: view, value: side}) fans the key out to every slot the set projects onto AND poses the slots listed under that key; a slot listed under another key of the set returns to rest. That is how one key turns a whole character: the head and torso swap art, the far eye and arm hide, the mouth slides to the profile edge — while blinks, gaze and lip-sync keep running on what is visible (the face solver folds a pose into its own channels). Additive: no schema bump, and a descriptor without it reads back unposed.- Type:
How slots are POSED while a swap key shows — ``{set
- Type:
{key
- class cutan.characters.IdleAnimation(*, name, duration=1.0, loop=True, tracks=<factory>, **extra_data)[source]¶
Bases:
RigModelA named idle loop (e.g., breath, blink).
>>> a = IdleAnimation(name="idle_breath", duration=4.0) >>> a.loop True
- class cutan.characters.Skin(*, name='default', slots=<factory>, **extra_data)[source]¶
Bases:
RigModelA named outfit/variant: maps slot → {attachment_name → Attachment}.
>>> skin = Skin(name="default", slots={"mouth": {"mouth_a": Attachment(path="parts/mouth/mouth_a.svg")}}) >>> skin.slots["mouth"]["mouth_a"].path 'parts/mouth/mouth_a.svg'
- class cutan.characters.Slot(*, name, bone, draw_order=0, attachment=None, **extra_data)[source]¶
Bases:
RigModelA draw-order slot bound to a bone, displaying one attachment at a time.
>>> s = Slot(name="mouth", bone="head", draw_order=7, attachment="mouth_x") >>> s.attachment 'mouth_x'
- cutan.characters.blink_animation(*, closure_s=0.13, duration_s=0.18, eye_l_slot='left_eye', eye_r_slot='right_eye', open_attachment_l='open', closed_attachment_l='closed', open_attachment_r='open', closed_attachment_r='closed', name='blink')[source]¶
Step-animation that snaps both eye slots closed → open.
The closure is centred: open → closed at
(duration - closure) / 2→ open againclosurelater. With the defaults (0.13s closure in an 0.18s envelope) that is closed at 0.025s, open at 0.155s. (An earlier docstring claimed 0.05/0.13 — numbers from an older closure value; and the slot/attachment defaults were the stale pre-0.2.0 spellingseye_l/eye_l_open, unnoticed for as long as nothing consumeddescriptor.animations— both fixed in an#87.)- Return type:
- cutan.characters.breath_animation(*, period_s=4.0, amplitude_px=2.0, head_tilt_deg=0.5, include_weight_shift=True, weight_shift_amplitude_px=1.5, weight_shift_period_s=6.0, name='idle_breath')[source]¶
Sine-wave breath on torso Y + head rotation; optional weight shift.
The head tilt is phase-offset by 0.25 cycles to follow the chest with a natural lag. The optional weight shift is on a slower 6-second period to avoid a metronomic feel when both run at the same time.
The animation’s
durationis the LCM-ish combined period: the longest sub-track period, so the overall loop closes cleanly.- Return type:
- cutan.characters.compare_silhouettes(a, b, *, size=(256, 256))[source]¶
Return IoU between two silhouette PNGs (0..1; lower = more distinct).
Both images are resized to
size, converted to grayscale, thresholded at the midpoint, and the intersection-over-union of the foreground (dark) pixels is computed.>>> # Two identical silhouettes → IoU = 1.0; two empty → 0.0 (no overlap). >>> # Tested via test suite, not doctest, since it requires Playwright.- Return type:
- cutan.characters.extract_part(source, part_id, *, crop_viewbox=True, padding=8.0)[source]¶
Emit a standalone SVG tree containing only the group with the given id.
Any top-level
<defs>from the source is copied so the part can resolve gradient / pattern / filter references likefill="url(#some_gradient)". The matched group is appended unchanged.When
crop_viewboxis True (the default), the new SVG’s viewBox is cropped to the bounding box of the part’s primitive content (rect / circle / ellipse / path) pluspaddingunits on each side. This keeps a part’s texture proportional to its content instead of to the whole character canvas. Falls back to the source viewBox when no bbox can be derived.The emitted
width/heightalways match the emitted viewBox, so the part rasterises at its own extent and is never letterboxed inside a canvas it does not fill. The crop rect’s **parent-space origin survives as the viewBox’s first two numbers**, so where the part sat relative to its siblings is not lost and needs no separate record.If no match is found, raises
KeyError.- Return type:
- cutan.characters.extract_pivots(source, *, skeleton_id='skeleton')[source]¶
Return
{name: (cx, cy)}for every named<circle>under skeleton.Pivots use the Pose Animator convention: a
<g id="skeleton">group sibling of the illustration, containing one<circle>per named joint. The circle’scx/cyis the pivot in the same coordinate system as the art (the SVG’s viewBox).
- cutan.characters.fetch_dicebear(seed, *, style='lorelei', api_version='9.x', timeout_s=10.0, extra_params=None)[source]¶
Fetch an avatar SVG from DiceBear’s HTTP API.
Returns the SVG string. Raises
RuntimeErrorif the API call fails (network error, HTTP error, non-SVG response).The URL pattern is:
https://api.dicebear.com/<api_version>/<style>/svg?seed=<seed>Pass
extra_paramsto forward style-specific options (e.g.backgroundColor=transparent).- Return type:
- cutan.characters.generate_default_mouths(*, canvas=(256, 128), palette=None, shapes=('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'x'), smile=0.0, form=None)[source]¶
Return
{"mouth_<letter>[_<form>]": <svg-string>, ...}for every shape.>>> svgs = generate_default_mouths() >>> 'mouth_x' in svgs and 'viewBox' in svgs['mouth_x'] True >>> sorted(generate_default_mouths(shapes=["a"], smile=0.35, form="happy")) ['mouth_a_happy']
- cutan.characters.new_character(out_dir, *, name, seed=None, style='lorelei', voice_ref=None, use_dicebear=True, acknowledge_attribution=False, overwrite=False, mouth_variants=None, gaze=True, palette=None, build='regular', head_scale=1.0, hat='none', sash=False, views=True, hair_style='peak', hair_length='short', feet_origin=False)[source]¶
Build a complete character on disk.
Variety knobs (every default reproduces the pre-knob character byte for byte, which a golden test holds):
palette—{role: "#rrggbb"}overPALETTE_ROLES(skin,hair,clothing,leg,accessory) — the SAME role names aStylePackuses. Unset roles keep the seed’s colours.skinalso paints the hands and the gaze lid;hairthe brows and the collar. On a DiceBear head only the body follows (its face is baked).build— a key ofBUILDS:regular,squat(round body, short legs),tall,stick(small blocky body, stick limbs).head_scale— the head and its whole face (eyes, brows, mouths, their offsets, the pupil travel) scaled together, so a big head keeps its face.hat— a key ofHATS(offline head only), inaccessory, worn above the brows’ acting range at this head scale (an#252): lifted, and flattened toward its crown when lifting is not enough. A hat that still covers the brows (a very small head) is recorded in the descriptor’soccluded, so the character does not affordface.browsand expressions fall to the lids, gaze and mouth.hair_style—HAIR_STYLES:peak(the default),bald,bun,curly;hair_length—HAIR_LENGTHS:short(the default),medium,long(offline head only). Drawn in thehairrole (the palette’shaircolours them), in every view.sash— a diagonal band across the torso, inaccessory.views(an#197) — draw the turnaround:back,side(a profile facing the viewer’s right) andthree_quarterbeside the front, as aviewswap set with a pose per view (add_views()), socutan.motion.turn()can turn the character around. Offline head only(a DiceBear face is baked into its art); ignored for a DiceBear head.
Additive: a shot that never sets a view renders exactly as without it.
feet_origin(an#285) — declare the rig’soriginat its ROOT bone, the ground contact, sostage.atis where the feet stand and every build placed at oneystands on one line (stage_extent’sfeetis then 0).an character newturns it on by default (the maintainer’s decision on an#423, 2026-10-09); here it stays off by default, so code that rebuilds a character (goldens, a recipe’s replay, the demos) draws exactly what it drew.stand_on_feet()converts an existing character, one at a time.
Every colour the factory draws in a role is recorded in the descriptor’s
colour_rolesso a style pack can recolour it later (palette swapping,cutan.characters.colour_roles).gaze(an#99) adds the eye stack — sclera and pupil slots under each lid, a filled closed lid, thegaze_travelclamp — throughadd_gaze(), sogaze_x/gaze_yand the ambient saccades reach the pupils. Off, the eye is the single pre-stack drawing.mouth_variants(an#98) —{form: smile offset}— writes one more 9-shape mouth set per form (mouth_<shape>_<form>.svg) and declares it as theviseme@<form>swap set, with its attachments in the default skin’smouthslot, so an expression preset preferring that form selects it.NonemeansDEFAULT_MOUTH_VARIANTS(happy, sad);{}means the neutral set only.Steps:
Fetch a DiceBear avatar (skip if
use_dicebear=False— useful for offline tests).Wrap it into the canonical
ancutout SVG (skeleton + illustration groups), saved as<name>.svg.Slice each part into
parts/<part>.svg.Write the 9-shape default mouth set into
parts/mouth/.Synthesize a few derived parts (open/closed eyes, brows) so the character is complete out of the box.
Emit a
character.jsondescriptor.
Returns the path to the created
character.json.Raises
FileExistsErrorifout_dir/namealready exists andoverwrite=False.- Return type:
- cutan.characters.normalize_svg(source, *, fallback_viewbox='0 0 1024 1024')[source]¶
Promote Inkscape labels to ids and ensure a viewBox is set.
Returns the parsed
ElementTree. Idempotent: running it twice is a no-op on the second pass.- Return type:
- cutan.characters.promote(project_dir, entity, as_, *, source_svg=None, voice_ref=None, use_dicebear=True, overwrite=False)[source]¶
Promote
entityfromproject_dir’s inline assets into the mall.- Parameters:
project_dir (
str|Path) – Path to ananproject (must containassets/characters/).entity (
str) – Inline entity id used inside the scene (the directory underassets/characters/<entity>, or the SVG file atassets/characters/<entity>.svg).as_ (
str) – The mall character id to register the result as. Becomes the directory name underassets/characters/and the descriptor’snamefield.source_svg (
str|Path|None) – Optional explicit path to a source SVG. If omitted, the function looks forassets/characters/<entity>.svgorassets/characters/<entity>/<entity>.svg.voice_ref (
Optional[str]) – Voice reference to embed in the descriptor.use_dicebear (
bool) – Forwarded tonew_character()on the no-source fallback path. PassFalseto keep the call offline — without it that fallback always reaches the DiceBear API, andnew_characterswallows the failure and generates geometry instead, so an offline test looks like it passed rather than like it was skipped.overwrite (
bool) – If False and the target already exists, raisesFileExistsError.character.json. (Returns the path to the new)
- Return type:
- cutan.characters.promote_inkscape_labels_to_ids(tree)[source]¶
Copy
inkscape:labeltoidon each group missing an id.Returns the number of groups updated.
Inkscape stores the user-visible name in the
inkscape:labelattribute and does NOT promote it toidon save. This is a long-standing UX issue (Inkscape bug #243383); the workaround is to promote at parse time.- Return type:
- cutan.characters.record_character(char_dir, *, name=None, out_mp4=None, duration_s=8.0, size=(640, 480))[source]¶
Render preview.html for the character at
char_dirand record it.The preview HTML is generated/refreshed via the same writer used by
an character preview, so this command is self-contained.- Return type:
- cutan.characters.record_preview_to_mp4(preview_html, out_mp4, *, duration_s=8.0, size=(640, 480), fps=30, crf=23)[source]¶
Record
preview_htmltoout_mp4forduration_sseconds.Returns the output mp4 path.
Pipeline:
Playwright launches headless Chromium with video recording on.
Navigates to
preview_html(file:// URL).Waits
duration_sreal-time so the browser captures frames.Closes the context to flush the webm.
ffmpeg re-encodes the webm to H.264 mp4 (better compatibility, smaller files, plays in
quicktime/ GitHub previews).
Both Playwright (project dep) and ffmpeg (system dep, already required by the renderer) must be installed.
- Return type:
- cutan.characters.render_silhouette(svg_source, out_png, *, size=(256, 256), background='#ffffff')[source]¶
Render an SVG to a binary silhouette PNG (black on white).
Uses Playwright/Chromium to rasterize, then PIL to threshold by alpha or luminance. Returns the output path.
The output is RGB; the silhouette is filled with black (#000) and the background with the given color.
- Return type:
- cutan.characters.validate_character(char_dir, *, name=None)[source]¶
Check an art package against the contract, offline.
Reports a
Findingper problem: a missing or unparseable descriptor, absent required parts or mouth shapes, a part that draws nothing, a prohibited construct, a letterboxed part, a joint name colliding with a part id, and an unpopulatedAssetSource.>>> import tempfile, pathlib >>> with tempfile.TemporaryDirectory() as d: ... report = validate_character(d, name="nobody") >>> report.passed False >>> any("character.json" in f.description for f in report.findings) True- Return type:
VerificationReport
- cutan.characters.write_default_mouths(out_dir, *, canvas=(256, 128), palette=None, shapes=('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'x'), variants=None)[source]¶
Write the default mouth SVGs into
out_dir(created if missing), plus onemouth_<shape>_<form>.svgper shape for everyvariantsentry ({form: smile offset};None= none).Returns the list of paths written: the neutral set in shape order, then each variant’s.
- cutan.characters.write_svg(tree_or_element, path=None)[source]¶
Serialize an
ElementTreeorElementto bytes (and optionally disk).Always emits
<?xml version="1.0" encoding="UTF-8"?>and the SVG namespace as the default, so the output is a valid standalone SVG.- Return type:
Modules
Brow acting: where the brows can go, what may not draw there, and the |
|
The cut-out genre's semantic checks: |
|
User-facing character CLI subcommands. |
|
Colour roles: which colour literal in which part is skin, clothing, hair… |
|
DiceBear HTTP API client + best-effort post-processing. |
|
What the character factory wrote, as it wrote it: the in-memory log behind its record of drawn bytes. |
|
High-level entry points: build and inspect a character. |
|
Idle animation factories: breath, blink, weight-shift. |
|
DiceBear per-style licences, as data. |
|
A HALF eyelid drawing, made from a character's own OPEN and CLOSED lid art (cutan#65). |
|
The cut-out genre's methods and aspects, and their compile-time resolution (ADR 0002). |
|
Generate the 9-shape default mouth set as parametric SVGs. |
|
Resolve a |
|
Record a character's preview HTML to an mp4. |
|
The character side of the cut-out genre, as declarations: |
|
Character descriptor schema (Spine-shaped, Pydantic v2). |
|
Silhouette rendering and comparison for the silhouette test. |
|
SVG manipulation: namespace-aware DOM helpers using stdlib |
|
Whether an art package is one the compiler can actually render. |
|
The cut-out genre's vocabulary entries: motion presets, expression presets, IR-field notes. |