agents/openai.yaml
interface: display_name: "Minecraft Skin Maker" short_description: "Create a Minecraft skin from an image or idea" default_prompt: "Use $minecraft-skin-maker to turn my image or idea into a Minecraft skin."
blcnyy/minecraft-skin-maker · GitHub
Create or revise importable Minecraft player skins from images, existing skin PNGs, text descriptions, or both. Delivers the skin PNG, previews rendered from the exported texture and a Bedrock skin pack, with technical choices handled automatically.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add blcnyy/minecraft-skin-maker --skill minecraft-skin-maker설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
agents/openai.yamlinterface: display_name: "Minecraft Skin Maker" short_description: "Create a Minecraft skin from an image or idea" default_prompt: "Use $minecraft-skin-maker to turn my image or idea into a Minecraft skin."
assets/blank-design.json{
"schema_version": 1,
"name": "My Skin",
"model": "classic",
"canvas_model": "classic",
"palette": {
"foundation": "#a57760"
},
"base": {
"head": "foundation",
"body": "foundation",
"right_arm": "foundation",
"left_arm": "foundation",
"right_leg": "foundation",
"left_leg": "foundation"
},
"components": [],
"notes": []
}
assets/classic-uv.json{
"head.base.top": [
8,
0,
8,
8
],
"head.base.bottom": [
16,
0,
8,
8
],
"head.base.right": [
0,
8,
8,
8
],
"head.base.front": [
8,
8,
8,
8
],
"head.base.left": [
16,
8,
8,
8
],
"head.base.back": [
24,
8,
8,
8
],
"head.outer.top": [
40,
0,
8,
8
],
"head.outer.bottom": [
48,
0,
8,
8
],
"head.outer.right": [
32,
8,
8,
8
],
"head.outer.front": [
40,
8,
8,
8
],
"head.outer.left": [
48,
8,
8,
8
],
"head.outer.back": [
56,
8,
8,
8
],
"body.base.top": [
20,
16,
8,
4
],
"body.base.bottom": [
28,
16,
8,
4
],
"body.base.right": [
16,
20,
4,
12
],
"body.base.front": [
20,
20,
8,
12
],
"body.base.left": [
28,
20,
4,
12
],
"body.base.back": [
32,
20,
8,
12
],
"body.outer.top": [
20,
32,
8,
4
],
"body.outer.bottom": [
28,
32,
8,
4
],
"body.outer.right": [
16,
36,
4,
12
],
"body.outer.front": [
20,
36,
8,
12
],
"body.outer.left": [
28,
36,
4,
12
],
"body.outer.back": [
32,
36,
8,
12
],
"right_arm.base.top": [
44,
16,
4,
4
],
"right_arm.base.bottom": [
48,
16,
4,
4
],
"right_arm.base.right": [
40,
20,
4,
12
],
"right_arm.base.front": [
44,
20,
4,
12
],
"right_arm.base.left": [
48,
20,
4,
12
],
"right_arm.base.back": [
52,
20,
4,
12
],
"right_arm.outer.top": [
44,
32,
4,
4
],
"right_arm.outer.bottom": [
48,
32,
4,
4
],
"right_arm.outer.right": [
40,
36,
4,
12
],
"right_arm.outer.front": [
44,
36,
4,
12
],
"right_arm.outer.left": [
48,
36,
4,
12
],
"right_arm.outer.back": [
52,
36,
4,
12
],
"left_arm.base.top": [
36,
48,
4,
4
],
"left_arm.base.bottom": [
40,
48,
4,
4
],
"left_arm.base.right": [
32,
52,
4,
12
],
"left_arm.base.front": [
36,
52,
4,
12
],
"left_arm.base.left": [
40,
52,
4,
12
],
"left_arm.base.back": [
44,
52,
4,
12
],
"left_arm.outer.top": [
52,
48,
4,
4
],
"left_arm.outer.bottom": [
56,
48,
4,
4
],
"left_arm.outer.right": [
48,
52,
4,
12
],
"left_arm.outer.front": [
52,
52,
4,
12
],
"left_arm.outer.left": [
56,
52,
4,
12
],
"left_arm.outer.back": [
60,
52,
4,
12
],
"right_leg.base.top": [
4,
16,
4,
4
],
"right_leg.base.bottom": [
8,
16,
4,
4
],
"right_leg.base.right": [
0,
20,
4,
12
],
"right_leg.base.front": [
4,
20,
4,
12
],
"right_leg.base.left": [
8,
20,
4,
12
],
"right_leg.base.back": [
12,
20,
4,
12
],
"right_leg.outer.top": [
4,
32,
4,
4
],
"right_leg.outer.bottom": [
8,
32,
4,
4
],
"right_leg.outer.right": [
0,
36,
4,
12
],
"right_leg.outer.front": [
4,
36,
4,
12
],
"right_leg.outer.left": [
8,
36,
4,
12
],
"right_leg.outer.back": [
12,
36,
4,
12
],
"left_leg.base.top": [
20,
48,
4,
4
],
"left_leg.base.bottom": [
24,
48,
4,
4
],
"left_leg.base.right": [
16,
52,
4,
12
],
"left_leg.base.front": [
20,
52,
4,
12
],
"left_leg.base.left": [
24,
52,
4,
12
],
"left_leg.base.back": [
28,
52,
4,
12
],
"left_leg.outer.top": [
4,
48,
4,
4
],
"left_leg.outer.bottom": [
8,
48,
4,
4
],
"left_leg.outer.right": [
0,
52,
4,
12
],
"left_leg.outer.front": [
4,
52,
4,
12
],
"left_leg.outer.left": [
8,
52,
4,
12
],
"left_leg.outer.back": [
12,
52,
4,
12
]
}
assets/examples/field-botanist.json{
"schema_version": 1,
"name": "Violet-haired field explorer",
"model": "classic",
"canvas_model": "classic",
"palette": {
"skin": "#995d3e",
"skin_light": "#b7754d",
"skin_shadow": "#78412e",
"skin_highlight": "#c18458",
"hair": "#301b37",
"hair_mid": "#4a2852",
"hair_light": "#643971",
"hair_shadow": "#211426",
"eye_gold": "#efb548",
"eye_white": "#ead5ad",
"brow": "#35201d",
"lip": "#6e362b",
"lip_light": "#ac6549",
"jacket": "#315c5e",
"jacket_light": "#447777",
"jacket_shadow": "#23484b",
"jacket_seam": "#254b4c",
"collar": "#e8d9b5",
"collar_light": "#fff0cd",
"collar_shadow": "#bca983",
"scarf": "#ca891f",
"scarf_light": "#e9aa37",
"scarf_shadow": "#986019",
"shirt": "#43432d",
"shirt_light": "#54543a",
"shirt_shadow": "#303225",
"trousers": "#303044",
"trousers_light": "#414159",
"trousers_shadow": "#212232",
"trousers_seam": "#242538",
"boot": "#704831",
"boot_light": "#96623d",
"boot_shadow": "#4d3226",
"sole": "#2b2727",
"lace": "#bd9870",
"belt": "#60432e",
"metal": "#b4945b",
"patch": "#e6d7af",
"patch_dark": "#4c4b2d"
},
"base": {
"head": "skin",
"body": "jacket",
"right_arm": "jacket",
"left_arm": "jacket",
"right_leg": "trousers",
"left_leg": "trousers"
},
"components": [
{
"id": "face-and-ears",
"paint": [
{
"target": "head.base.front",
"op": "pixels",
"at": [
0,
0
],
"rows": [
"HHHHHHHH",
"HHSSSHHH",
"HLSSSSSH",
"HDDSSDDH",
"SGDSSDGS",
"SSSNnSSS",
"SSSMmSSS",
"sSSssSSs"
],
"map": {
"H": "hair",
"L": "skin_light",
"S": "skin",
"D": "brow",
"W": "eye_white",
"G": "eye_gold",
"N": "skin_light",
"n": "skin_shadow",
"M": "lip",
"m": "lip_light",
"s": "skin_shadow"
}
},
{
"target": [
"head.base.left",
"head.base.right",
"head.base.top",
"head.base.back"
],
"op": "fill",
"color": "hair"
},
{
"target": "head.base.bottom",
"op": "fill",
"color": "skin_shadow"
},
{
"target": "head.base.bottom",
"op": "rect",
"box": [
2,
1,
4,
5
],
"color": "skin"
},
{
"target": "head.base.right",
"op": "rect",
"box": [
6,
3,
2,
4
],
"color": "skin"
},
{
"target": "head.base.left",
"op": "rect",
"box": [
0,
3,
2,
4
],
"color": "skin"
},
{
"target": "head.base.right",
"op": "points",
"points": [
[
6,
4
],
[
6,
5
]
],
"color": "skin_light"
},
{
"target": "head.base.left",
"op": "points",
"points": [
[
1,
4
],
[
1,
5
]
],
"color": "skin_light"
},
{
"target": "head.base.back",
"op": "rect",
"box": [
2,
7,
4,
1
],
"color": "skin_shadow"
}
]
},
{
"id": "purple-curl-patterns",
"paint": [
{
"target": "head.base.top",
"op": "pixels",
"at": [
0,
0
],
"rows": [
"HHMMHHMH",
"HMLHMMLH",
"HMDHMLMH",
"MMHHDHHM",
"LMHMMMLH",
"HHDHMDHH",
"MMLHHMML",
"HLHMMLHH"
],
"map": {
"H": "hair",
"M": "hair_mid",
"L": "hair_light",
"D": "hair_shadow"
}
},
{
"target": "head.base.back",
"op": "pixels",
"at": [
0,
0
],
"rows": [
"HHMHHMHH",
"HMLHMMLH",
"HMDHMLMH",
"MMHHDHHM",
"LMHMMMLH",
"HHDHMDHH",
"MMLHHMML",
"HL....HH"
],
"map": {
"H": "hair",
"M": "hair_mid",
"L": "hair_light",
"D": "hair_shadow"
}
},
{
"target": "head.outer.front",
"op": "pixels",
"at": [
0,
0
],
"rows": [
".HMMHHM.",
"HMH..MHH",
"MH....HM",
"H......H",
"M......H",
"H.......",
"........",
"........"
],
"map": {
"H": "hair",
"M": "hair_mid",
"L": "hair_light",
"D": "hair_shadow"
}
},
{
"target": "head.outer.top",
"op": "pixels",
"at": [
0,
0
],
"rows": [
".HMM.HM.",
"HMHLMHLM",
"MLHDMLHH",
"HHMHHMHM",
"MLHMLHHM",
"HMLHMMHL",
".HHMMHH.",
"..HHHH.."
],
"map": {
"H": "hair",
"M": "hair_mid",
"L": "hair_light",
"D": "hair_shadow"
}
},
{
"target": "head.outer.back",
"op": "pixels",
"at": [
0,
0
],
"rows": [
".HMHHMH.",
"HMHLMHLM",
"MLHDMLHH",
"HHMHHMHM",
"MLHMLHHM",
"HMLHMMHL",
".HHMMHH.",
"..HHHH.."
],
"map": {
"H": "hair",
"M": "hair_mid",
"L": "hair_light",
"D": "hair_shadow"
}
},
{
"target": "head.outer.right",
"op": "pixels",
"at": [
0,
0
],
"rows": [
".HMHHMH.",
"HMHLMHLM",
"MLHDMLHH",
"HHMHHMH.",
"MLHMLH..",
"HMLHM...",
".HHMH...",
"..HH...."
],
"map": {
"H": "hair",
"M": "hair_mid",
"L": "hair_light",
"D": "hair_shadow"
}
},
{
"target": "head.outer.left",
"op": "pixels",
"at": [
0,
0
],
"rows": [
".HMHHMH.",
"MLHMLHMH",
"HHMLDHLM",
".HMHHMHH",
"..HLMHLM",
"...MHLHM",
"...HMHH.",
"....HH.."
],
"map": {
"H": "hair",
"M": "hair_mid",
"L": "hair_light",
"D": "hair_shadow"
}
}
]
},
{
"id": "open-teal-jacket",
"paint": [
{
"target": "body.base.front",
"op": "rect",
"box": [
2,
0,
4,
12
],
"color": "shirt"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
3,
4,
2,
4
],
"color": "shirt_light"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
3,
7,
1,
2
],
"color": "shirt_shadow"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
2,
11,
4,
1
],
"color": "trousers"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
0,
3,
1,
9
],
"color": "jacket_shadow"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
7,
3,
1,
9
],
"color": "jacket_light"
},
{
"target": "body.base.front",
"op": "points",
"points": [
[
1,
3
],
[
2,
4
],
[
2,
5
],
[
2,
6
],
[
1,
8
],
[
1,
9
]
],
"color": "jacket_light"
},
{
"target": "body.base.front",
"op": "points",
"points": [
[
6,
3
],
[
5,
4
],
[
5,
5
],
[
5,
6
],
[
6,
8
],
[
6,
9
]
],
"color": "jacket_light"
},
{
"target": "body.base.front",
"op": "points",
"points": [
[
1,
10
],
[
6,
10
]
],
"color": "metal"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
0,
11,
2,
1
],
"color": "jacket_shadow"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
6,
11,
2,
1
],
"color": "jacket_shadow"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
1,
2,
1,
7
],
"color": "jacket_light"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
3,
2,
1,
10
],
"color": "jacket_shadow"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "jacket_seam"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "jacket_shadow"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
1,
2,
1,
7
],
"color": "jacket_light"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
3,
2,
1,
10
],
"color": "jacket_shadow"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "jacket_seam"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "jacket_shadow"
},
{
"target": "body.base.bottom",
"op": "fill",
"color": "jacket_shadow"
},
{
"target": "body.base.back",
"op": "rect",
"box": [
0,
2,
8,
1
],
"color": "jacket_seam"
},
{
"target": "body.base.back",
"op": "rect",
"box": [
0,
8,
8,
1
],
"color": "jacket_seam"
},
{
"target": "body.base.back",
"op": "rect",
"box": [
0,
11,
8,
1
],
"color": "jacket_shadow"
},
{
"target": "body.base.back",
"op": "rect",
"box": [
1,
3,
1,
5
],
"color": "jacket_light"
},
{
"target": "body.base.back",
"op": "rect",
"box": [
6,
3,
1,
5
],
"color": "jacket_light"
},
{
"target": "body.base.back",
"op": "rect",
"box": [
3,
3,
1,
5
],
"color": "jacket_seam"
},
{
"target": "body.base.back",
"op": "points",
"points": [
[
2,
9
],
[
2,
10
],
[
5,
9
],
[
5,
10
]
],
"color": "jacket_light"
},
{
"target": "body.base.top",
"op": "rect",
"box": [
2,
0,
4,
4
],
"color": "shirt"
}
]
},
{
"id": "cream-collar",
"paint": [
{
"target": "body.outer.front",
"op": "pixels",
"at": [
0,
0
],
"rows": [
"CC....CC",
"CLC..CLC",
".CC..CC.",
"..c..c.."
],
"map": {
"C": "collar",
"L": "collar_light",
"c": "collar_shadow"
}
},
{
"target": "body.outer.back",
"op": "rect",
"box": [
0,
0,
8,
1
],
"color": "collar"
},
{
"target": "body.outer.back",
"op": "points",
"points": [
[
1,
1
],
[
2,
1
],
[
5,
1
],
[
6,
1
]
],
"color": "collar_shadow"
},
{
"target": [
"body.outer.right",
"body.outer.left"
],
"op": "rect",
"box": [
0,
0,
4,
1
],
"color": "collar"
},
{
"target": "body.outer.top",
"op": "fill",
"color": "collar"
},
{
"target": "body.outer.top",
"op": "rect",
"box": [
2,
1,
4,
3
],
"color": "scarf"
}
]
},
{
"id": "ochre-scarf",
"paint": [
{
"target": "body.outer.front",
"op": "pixels",
"at": [
0,
0
],
"rows": [
"..GYYG..",
"..YGGY..",
"...Yg...",
"...GY...",
"...GY...",
"...gY...",
"...YG...",
"....g..."
],
"map": {
"G": "scarf",
"Y": "scarf_light",
"g": "scarf_shadow"
}
},
{
"target": "body.base.back",
"op": "rect",
"box": [
2,
0,
4,
1
],
"color": "scarf_shadow"
}
]
},
{
"id": "belt-and-brass-pin",
"paint": [
{
"target": "body.base.front",
"op": "rect",
"box": [
2,
10,
4,
1
],
"color": "belt"
},
{
"target": "body.base.front",
"op": "points",
"points": [
[
3,
10
],
[
4,
10
]
],
"color": "metal"
},
{
"target": "body.outer.front",
"op": "pixels",
"at": [
6,
3
],
"rows": [
".Y",
"YG",
"g."
],
"map": {
"Y": "scarf_light",
"G": "scarf",
"g": "scarf_shadow"
}
}
]
},
{
"id": "rolled-jacket-sleeves",
"paint": [
{
"target": [
"right_arm.base.front",
"right_arm.base.back",
"right_arm.base.left",
"right_arm.base.right"
],
"op": "rect",
"box": [
0,
8,
4,
4
],
"color": "skin"
},
{
"target": [
"right_arm.base.front",
"right_arm.base.back",
"right_arm.base.left",
"right_arm.base.right"
],
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "skin_shadow"
},
{
"target": "right_arm.base.bottom",
"op": "fill",
"color": "skin_shadow"
},
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
1,
1,
1,
4
],
"color": "jacket_light"
},
{
"target": "right_arm.base.front",
"op": "points",
"points": [
[
2,
4
],
[
1,
5
],
[
2,
6
]
],
"color": "jacket_shadow"
},
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
1,
8,
1,
3
],
"color": "skin_light"
},
{
"target": "right_arm.base.back",
"op": "rect",
"box": [
2,
1,
1,
5
],
"color": "jacket_light"
},
{
"target": "right_arm.base.back",
"op": "points",
"points": [
[
1,
4
],
[
1,
5
],
[
2,
6
]
],
"color": "jacket_shadow"
},
{
"target": "right_arm.base.right",
"op": "rect",
"box": [
0,
0,
1,
8
],
"color": "jacket_shadow"
},
{
"target": "right_arm.base.left",
"op": "rect",
"box": [
3,
0,
1,
8
],
"color": "jacket_shadow"
},
{
"target": "right_arm.base.right",
"op": "rect",
"box": [
0,
8,
1,
4
],
"color": "skin_shadow"
},
{
"target": "right_arm.base.left",
"op": "rect",
"box": [
3,
8,
1,
4
],
"color": "skin_shadow"
},
{
"target": [
"right_arm.outer.front",
"right_arm.outer.back",
"right_arm.outer.left",
"right_arm.outer.right"
],
"op": "rect",
"box": [
0,
6,
4,
2
],
"color": "jacket"
},
{
"target": [
"right_arm.outer.front",
"right_arm.outer.back",
"right_arm.outer.left",
"right_arm.outer.right"
],
"op": "rect",
"box": [
0,
6,
4,
1
],
"color": "jacket_light"
},
{
"target": "right_arm.outer.front",
"op": "rect",
"box": [
0,
7,
1,
1
],
"color": "jacket_shadow"
},
{
"target": "right_arm.base.top",
"op": "rect",
"box": [
0,
0,
4,
1
],
"color": "jacket_shadow"
},
{
"target": "right_arm.base.top",
"op": "points",
"points": [
[
1,
2
],
[
2,
2
]
],
"color": "jacket_light"
},
{
"target": [
"left_arm.base.front",
"left_arm.base.back",
"left_arm.base.left",
"left_arm.base.right"
],
"op": "rect",
"box": [
0,
8,
4,
4
],
"color": "skin"
},
{
"target": [
"left_arm.base.front",
"left_arm.base.back",
"left_arm.base.left",
"left_arm.base.right"
],
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "skin_shadow"
},
{
"target": "left_arm.base.bottom",
"op": "fill",
"color": "skin_shadow"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
1,
1,
1,
4
],
"color": "jacket_light"
},
{
"target": "left_arm.base.front",
"op": "points",
"points": [
[
2,
4
],
[
1,
5
],
[
2,
6
]
],
"color": "jacket_shadow"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
1,
8,
1,
3
],
"color": "skin_light"
},
{
"target": "left_arm.base.back",
"op": "rect",
"box": [
2,
1,
1,
5
],
"color": "jacket_light"
},
{
"target": "left_arm.base.back",
"op": "points",
"points": [
[
1,
4
],
[
1,
5
],
[
2,
6
]
],
"color": "jacket_shadow"
},
{
"target": "left_arm.base.right",
"op": "rect",
"box": [
0,
0,
1,
8
],
"color": "jacket_shadow"
},
{
"target": "left_arm.base.left",
"op": "rect",
"box": [
3,
0,
1,
8
],
"color": "jacket_shadow"
},
{
"target": "left_arm.base.right",
"op": "rect",
"box": [
0,
8,
1,
4
],
"color": "skin_shadow"
},
{
"target": "left_arm.base.left",
"op": "rect",
"box": [
3,
8,
1,
4
],
"color": "skin_shadow"
},
{
"target": [
"left_arm.outer.front",
"left_arm.outer.back",
"left_arm.outer.left",
"left_arm.outer.right"
],
"op": "rect",
"box": [
0,
6,
4,
2
],
"color": "jacket"
},
{
"target": [
"left_arm.outer.front",
"left_arm.outer.back",
"left_arm.outer.left",
"left_arm.outer.right"
],
"op": "rect",
"box": [
0,
6,
4,
1
],
"color": "jacket_light"
},
{
"target": "left_arm.outer.front",
"op": "rect",
"box": [
0,
7,
1,
1
],
"color": "jacket_shadow"
},
{
"target": "left_arm.base.top",
"op": "rect",
"box": [
0,
0,
4,
1
],
"color": "jacket_shadow"
},
{
"target": "left_arm.base.top",
"op": "points",
"points": [
[
1,
2
],
[
2,
2
]
],
"color": "jacket_light"
}
]
},
{
"id": "right-sleeve-leaf-patch",
"paint": [
{
"target": "right_arm.outer.right",
"op": "rect",
"box": [
1,
2,
3,
4
],
"color": "collar_shadow"
},
{
"target": "right_arm.outer.right",
"op": "rect",
"box": [
1,
2,
2,
3
],
"color": "patch"
},
{
"target": "right_arm.outer.right",
"op": "points",
"points": [
[
2,
2
],
[
1,
3
],
[
2,
4
]
],
"color": "patch_dark"
},
{
"target": "right_arm.outer.front",
"op": "rect",
"box": [
0,
2,
1,
4
],
"color": "patch"
},
{
"target": "right_arm.outer.front",
"op": "points",
"points": [
[
0,
3
],
[
0,
4
]
],
"color": "patch_dark"
}
]
},
{
"id": "navy-cargo-trousers",
"paint": [
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
0,
0,
1,
8
],
"color": "trousers_shadow"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
2,
2,
1,
3
],
"color": "trousers_light"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
1,
5,
3,
1
],
"color": "trousers_seam"
},
{
"target": "right_leg.base.front",
"op": "points",
"points": [
[
2,
6
],
[
1,
7
],
[
2,
7
]
],
"color": "trousers_light"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
3,
0,
1,
8
],
"color": "trousers_shadow"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
1,
2,
1,
3
],
"color": "trousers_light"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "trousers_seam"
},
{
"target": "right_leg.base.back",
"op": "points",
"points": [
[
2,
6
],
[
1,
7
]
],
"color": "trousers_light"
},
{
"target": "right_leg.base.right",
"op": "rect",
"box": [
0,
1,
1,
7
],
"color": "trousers_shadow"
},
{
"target": "right_leg.base.left",
"op": "rect",
"box": [
3,
1,
1,
7
],
"color": "trousers_shadow"
},
{
"target": "right_leg.base.right",
"op": "points",
"points": [
[
1,
5
],
[
2,
5
],
[
1,
7
]
],
"color": "trousers_light"
},
{
"target": "right_leg.base.left",
"op": "points",
"points": [
[
1,
5
],
[
2,
5
],
[
1,
7
]
],
"color": "trousers_light"
},
{
"target": "right_leg.outer.right",
"op": "rect",
"box": [
0,
2,
4,
3
],
"color": "trousers_shadow"
},
{
"target": "right_leg.outer.right",
"op": "rect",
"box": [
1,
2,
2,
3
],
"color": "trousers_light"
},
{
"target": "right_leg.outer.right",
"op": "rect",
"box": [
0,
2,
4,
1
],
"color": "trousers"
},
{
"target": "right_leg.outer.right",
"op": "points",
"points": [
[
2,
2
]
],
"color": "metal"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
0,
1,
8
],
"color": "trousers_shadow"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
2,
2,
1,
3
],
"color": "trousers_light"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
1,
5,
3,
1
],
"color": "trousers_seam"
},
{
"target": "left_leg.base.front",
"op": "points",
"points": [
[
2,
6
],
[
1,
7
],
[
2,
7
]
],
"color": "trousers_light"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
3,
0,
1,
8
],
"color": "trousers_shadow"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
1,
2,
1,
3
],
"color": "trousers_light"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "trousers_seam"
},
{
"target": "left_leg.base.back",
"op": "points",
"points": [
[
2,
6
],
[
1,
7
]
],
"color": "trousers_light"
},
{
"target": "left_leg.base.right",
"op": "rect",
"box": [
0,
1,
1,
7
],
"color": "trousers_shadow"
},
{
"target": "left_leg.base.left",
"op": "rect",
"box": [
3,
1,
1,
7
],
"color": "trousers_shadow"
},
{
"target": "left_leg.base.right",
"op": "points",
"points": [
[
1,
5
],
[
2,
5
],
[
1,
7
]
],
"color": "trousers_light"
},
{
"target": "left_leg.base.left",
"op": "points",
"points": [
[
1,
5
],
[
2,
5
],
[
1,
7
]
],
"color": "trousers_light"
},
{
"target": "left_leg.outer.left",
"op": "rect",
"box": [
0,
2,
4,
3
],
"color": "trousers_shadow"
},
{
"target": "left_leg.outer.left",
"op": "rect",
"box": [
1,
2,
2,
3
],
"color": "trousers_light"
},
{
"target": "left_leg.outer.left",
"op": "rect",
"box": [
0,
2,
4,
1
],
"color": "trousers"
},
{
"target": "left_leg.outer.left",
"op": "points",
"points": [
[
2,
2
]
],
"color": "metal"
}
]
},
{
"id": "jacket-hem-and-pockets",
"paint": [
{
"target": "right_leg.outer.front",
"op": "rect",
"box": [
0,
0,
2,
2
],
"color": "jacket"
},
{
"target": "right_leg.outer.front",
"op": "rect",
"box": [
0,
1,
2,
1
],
"color": "jacket_shadow"
},
{
"target": "right_leg.outer.front",
"op": "points",
"points": [
[
0,
0
]
],
"color": "metal"
},
{
"target": "right_leg.outer.right",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "jacket"
},
{
"target": "right_leg.outer.right",
"op": "rect",
"box": [
0,
1,
4,
1
],
"color": "jacket_shadow"
},
{
"target": "right_leg.outer.back",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "jacket"
},
{
"target": "right_leg.outer.back",
"op": "rect",
"box": [
0,
1,
4,
1
],
"color": "jacket_shadow"
},
{
"target": "left_leg.outer.front",
"op": "rect",
"box": [
2,
0,
2,
2
],
"color": "jacket"
},
{
"target": "left_leg.outer.front",
"op": "rect",
"box": [
2,
1,
2,
1
],
"color": "jacket_shadow"
},
{
"target": "left_leg.outer.front",
"op": "points",
"points": [
[
2,
0
]
],
"color": "metal"
},
{
"target": "left_leg.outer.left",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "jacket"
},
{
"target": "left_leg.outer.left",
"op": "rect",
"box": [
0,
1,
4,
1
],
"color": "jacket_shadow"
},
{
"target": "left_leg.outer.back",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "jacket"
},
{
"target": "left_leg.outer.back",
"op": "rect",
"box": [
0,
1,
4,
1
],
"color": "jacket_shadow"
}
]
},
{
"id": "brown-laced-boots",
"paint": [
{
"target": [
"right_leg.base.front",
"right_leg.base.back",
"right_leg.base.left",
"right_leg.base.right"
],
"op": "rect",
"box": [
0,
8,
4,
4
],
"color": "boot"
},
{
"target": [
"right_leg.base.front",
"right_leg.base.back",
"right_leg.base.left",
"right_leg.base.right"
],
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "sole"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
0,
10,
4,
1
],
"color": "boot_shadow"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
1,
8,
2,
2
],
"color": "boot_light"
},
{
"target": "right_leg.base.front",
"op": "points",
"points": [
[
1,
8
],
[
2,
9
]
],
"color": "lace"
},
{
"target": "right_leg.base.front",
"op": "points",
"points": [
[
2,
8
],
[
1,
9
]
],
"color": "boot_shadow"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
1,
8,
2,
2
],
"color": "boot_light"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
2,
8,
1,
3
],
"color": "boot_shadow"
},
{
"target": [
"right_leg.base.right",
"right_leg.base.left"
],
"op": "points",
"points": [
[
1,
8
],
[
1,
9
]
],
"color": "boot_light"
},
{
"target": "right_leg.base.bottom",
"op": "fill",
"color": "sole"
},
{
"target": [
"right_leg.outer.front",
"right_leg.outer.back",
"right_leg.outer.left",
"right_leg.outer.right"
],
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "boot"
},
{
"target": "right_leg.outer.front",
"op": "points",
"points": [
[
1,
8
],
[
2,
8
]
],
"color": "boot_light"
},
{
"target": [
"left_leg.base.front",
"left_leg.base.back",
"left_leg.base.left",
"left_leg.base.right"
],
"op": "rect",
"box": [
0,
8,
4,
4
],
"color": "boot"
},
{
"target": [
"left_leg.base.front",
"left_leg.base.back",
"left_leg.base.left",
"left_leg.base.right"
],
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "sole"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
10,
4,
1
],
"color": "boot_shadow"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
1,
8,
2,
2
],
"color": "boot_light"
},
{
"target": "left_leg.base.front",
"op": "points",
"points": [
[
1,
8
],
[
2,
9
]
],
"color": "lace"
},
{
"target": "left_leg.base.front",
"op": "points",
"points": [
[
2,
8
],
[
1,
9
]
],
"color": "boot_shadow"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
1,
8,
2,
2
],
"color": "boot_light"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
2,
8,
1,
3
],
"color": "boot_shadow"
},
{
"target": [
"left_leg.base.right",
"left_leg.base.left"
],
"op": "points",
"points": [
[
1,
8
],
[
1,
9
]
],
"color": "boot_light"
},
{
"target": "left_leg.base.bottom",
"op": "fill",
"color": "sole"
},
{
"target": [
"left_leg.outer.front",
"left_leg.outer.back",
"left_leg.outer.left",
"left_leg.outer.right"
],
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "boot"
},
{
"target": "left_leg.outer.front",
"op": "points",
"points": [
[
1,
8
],
[
2,
8
]
],
"color": "boot_light"
}
]
}
],
"notes": [
"Reference inspected: brown skin, curly purple bob, amber eyes, teal field jacket, cream collar, ochre scarf, olive top, navy cargo trousers and brown lace-up boots.",
"The cream leaf patch is on the wearer's right upper sleeve; the gold sprig pin is on the wearer's left chest.",
"The unseen back continues the teal jacket with shoulder and waist seams. Purple curls wrap around the back of the head.",
"Curly hair, rolled cuffs, collar, scarf, cargo pockets and jacket tails are represented with the standard outer texture layer. The model retains Minecraft's standard block shape.",
"Classic arms were selected automatically because no arm preference was supplied."
]
}
assets/examples/lunar-courier.json{
"schema_version": 1,
"name": "Lunar Courier",
"model": "classic",
"canvas_model": "classic",
"palette": {
"ivory": "#dbe6e3",
"ivory_light": "#f0f3e8",
"ivory_shadow": "#a9bec2",
"ivory_deep": "#7d969f",
"navy": "#26374c",
"navy_deep": "#172436",
"navy_light": "#3b5265",
"orange": "#e98948",
"orange_light": "#ffb466",
"orange_shadow": "#ad542d",
"visor": "#254e6b",
"visor_dark": "#19354d",
"visor_light": "#498ca4",
"glow": "#96e7e7",
"metal": "#79939f",
"metal_light": "#b3c7ca"
},
"base": {
"head": "ivory",
"body": "ivory",
"right_arm": "ivory",
"left_arm": "ivory",
"right_leg": "ivory",
"left_leg": "ivory"
},
"components": [
{
"id": "surface-shading",
"paint": [
{
"target": "head.base.top",
"op": "fill",
"color": "ivory_light"
},
{
"target": "head.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "head.base.right",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "head.base.left",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "head.base.back",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "body.base.top",
"op": "fill",
"color": "ivory_light"
},
{
"target": "body.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "body.base.right",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "body.base.left",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "body.base.back",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "right_arm.base.top",
"op": "fill",
"color": "ivory_light"
},
{
"target": "right_arm.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "right_arm.base.right",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "right_arm.base.left",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "right_arm.base.back",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "left_arm.base.top",
"op": "fill",
"color": "ivory_light"
},
{
"target": "left_arm.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "left_arm.base.right",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "left_arm.base.left",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "left_arm.base.back",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "right_leg.base.top",
"op": "fill",
"color": "ivory_light"
},
{
"target": "right_leg.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "right_leg.base.right",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "right_leg.base.left",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "right_leg.base.back",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "left_leg.base.top",
"op": "fill",
"color": "ivory_light"
},
{
"target": "left_leg.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "left_leg.base.right",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "left_leg.base.left",
"op": "fill",
"color": "ivory_shadow"
},
{
"target": "left_leg.base.back",
"op": "fill",
"color": "ivory_shadow"
}
]
},
{
"id": "helmet-and-visor",
"paint": [
{
"target": "head.base.front",
"op": "pixels",
"at": [
0,
0
],
"rows": [
"IIIIIIII",
"IssssssI",
"svvvvvvs",
"vbbbbbbv",
"vbccbbbv",
"vbbbbbbv",
"IssssssI",
"IIIsIIII"
],
"map": {
"I": "ivory",
"s": "ivory_deep",
"v": "visor_dark",
"b": "visor",
"c": "visor_light"
}
},
{
"target": "head.base.front",
"op": "points",
"points": [
[
2,
3
],
[
3,
3
]
],
"color": "glow"
},
{
"target": "head.base.front",
"op": "points",
"points": [
[
5,
5
]
],
"color": "visor_light"
},
{
"target": "head.base.top",
"op": "rect",
"box": [
2,
0,
2,
8
],
"color": "orange"
},
{
"target": "head.base.top",
"op": "rect",
"box": [
2,
0,
1,
8
],
"color": "orange_light"
},
{
"target": "head.base.back",
"op": "rect",
"box": [
4,
0,
2,
3
],
"color": "orange"
},
{
"target": "head.base.back",
"op": "rect",
"box": [
1,
3,
6,
3
],
"color": "ivory_deep"
},
{
"target": "head.base.back",
"op": "rect",
"box": [
2,
4,
1,
2
],
"color": "navy"
},
{
"target": "head.base.back",
"op": "rect",
"box": [
4,
4,
1,
2
],
"color": "navy"
},
{
"target": "head.base.right",
"op": "rect",
"box": [
6,
2,
2,
4
],
"color": "visor"
},
{
"target": "head.base.right",
"op": "rect",
"box": [
2,
3,
2,
2
],
"color": "metal"
},
{
"target": "head.base.right",
"op": "points",
"points": [
[
2,
3
]
],
"color": "glow"
},
{
"target": "head.base.right",
"op": "rect",
"box": [
0,
7,
8,
1
],
"color": "ivory_deep"
},
{
"target": "head.base.left",
"op": "rect",
"box": [
0,
2,
2,
4
],
"color": "visor"
},
{
"target": "head.base.left",
"op": "rect",
"box": [
4,
3,
2,
2
],
"color": "metal"
},
{
"target": "head.base.left",
"op": "points",
"points": [
[
5,
3
]
],
"color": "glow"
},
{
"target": "head.base.left",
"op": "rect",
"box": [
0,
7,
8,
1
],
"color": "ivory_deep"
},
{
"target": "head.outer.front",
"op": "rect",
"box": [
0,
0,
8,
1
],
"color": "ivory_light"
},
{
"target": "head.outer.front",
"op": "points",
"points": [
[
0,
1
],
[
7,
1
],
[
0,
6
],
[
7,
6
]
],
"color": "ivory"
},
{
"target": "head.outer.right",
"op": "rect",
"box": [
0,
0,
8,
1
],
"color": "ivory"
},
{
"target": "head.outer.left",
"op": "rect",
"box": [
0,
0,
8,
1
],
"color": "ivory"
}
]
},
{
"id": "suit-chest",
"paint": [
{
"target": "body.base.front",
"op": "pixels",
"at": [
0,
0
],
"rows": [
"ssnnnnss",
"InnssnnI",
"IIsIIssI",
"IIIIIIII",
"IsIIIIII",
"IsIIIsII",
"IsIIIsII",
"IsIIIsII",
"IsIIIsII",
"IIIIIIII",
"nnnnnnnn",
"mmoommnn"
],
"map": {
"I": "ivory",
"s": "ivory_shadow",
"n": "navy",
"m": "metal",
"o": "orange"
}
},
{
"target": "body.base.front",
"op": "rect",
"box": [
0,
3,
8,
1
],
"color": "orange"
},
{
"target": "body.base.front",
"op": "rect",
"box": [
0,
4,
8,
1
],
"color": "orange_shadow"
},
{
"target": "body.outer.front",
"op": "rect",
"box": [
1,
5,
3,
3
],
"color": "ivory_light"
},
{
"target": "body.outer.front",
"op": "rect",
"box": [
5,
5,
2,
2
],
"color": "navy"
},
{
"target": "body.outer.front",
"op": "points",
"points": [
[
5,
5
],
[
6,
5
]
],
"color": "glow"
},
{
"target": "body.outer.front",
"op": "rect",
"box": [
1,
8,
6,
1
],
"color": "ivory_shadow"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
0,
3,
4,
1
],
"color": "orange"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
0,
4,
4,
1
],
"color": "orange_shadow"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
0,
10,
4,
2
],
"color": "navy"
},
{
"target": "body.base.right",
"op": "rect",
"box": [
1,
5,
2,
5
],
"color": "navy_light"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
0,
3,
4,
1
],
"color": "orange"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
0,
4,
4,
1
],
"color": "orange_shadow"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
0,
10,
4,
2
],
"color": "navy"
},
{
"target": "body.base.left",
"op": "rect",
"box": [
1,
5,
2,
5
],
"color": "navy_light"
}
]
},
{
"id": "courier-back-panel",
"paint": [
{
"target": "body.base.back",
"op": "rect",
"box": [
0,
3,
8,
1
],
"color": "orange"
},
{
"target": "body.base.back",
"op": "rect",
"box": [
0,
10,
8,
2
],
"color": "navy"
},
{
"target": "body.outer.back",
"op": "rect",
"box": [
1,
1,
6,
8
],
"color": "navy"
},
{
"target": "body.outer.back",
"op": "rect",
"box": [
2,
2,
4,
6
],
"color": "metal"
},
{
"target": "body.outer.back",
"op": "rect",
"box": [
3,
2,
2,
5
],
"color": "orange"
},
{
"target": "body.outer.back",
"op": "rect",
"box": [
3,
2,
2,
1
],
"color": "orange_light"
},
{
"target": "body.outer.back",
"op": "points",
"points": [
[
2,
7
],
[
5,
7
]
],
"color": "glow"
},
{
"target": "body.outer.back",
"op": "rect",
"box": [
1,
9,
6,
1
],
"color": "ivory_deep"
}
]
},
{
"id": "sleeves-and-gloves",
"paint": [
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "right_arm.base.back",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "right_arm.base.back",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "right_arm.base.back",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "right_arm.base.back",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "right_arm.base.back",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "right_arm.base.right",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "right_arm.base.right",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "right_arm.base.right",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "right_arm.base.right",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "right_arm.base.right",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "right_arm.base.left",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "right_arm.base.left",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "right_arm.base.left",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "right_arm.base.left",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "right_arm.base.left",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "right_arm.outer.front",
"op": "rect",
"box": [
0,
0,
4,
3
],
"color": "ivory_light"
},
{
"target": "right_arm.base.front",
"op": "rect",
"box": [
1,
10,
2,
1
],
"color": "metal"
},
{
"target": "right_arm.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "left_arm.base.back",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "left_arm.base.back",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "left_arm.base.back",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "left_arm.base.back",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "left_arm.base.back",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "left_arm.base.right",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "left_arm.base.right",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "left_arm.base.right",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "left_arm.base.right",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "left_arm.base.right",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "left_arm.base.left",
"op": "rect",
"box": [
0,
4,
4,
2
],
"color": "navy"
},
{
"target": "left_arm.base.left",
"op": "rect",
"box": [
0,
5,
4,
1
],
"color": "navy_light"
},
{
"target": "left_arm.base.left",
"op": "rect",
"box": [
0,
9,
4,
3
],
"color": "navy"
},
{
"target": "left_arm.base.left",
"op": "rect",
"box": [
0,
9,
4,
1
],
"color": "metal"
},
{
"target": "left_arm.base.left",
"op": "rect",
"box": [
1,
7,
2,
1
],
"color": "ivory_deep"
},
{
"target": "left_arm.outer.front",
"op": "rect",
"box": [
0,
0,
4,
3
],
"color": "ivory_light"
},
{
"target": "left_arm.base.front",
"op": "rect",
"box": [
1,
10,
2,
1
],
"color": "metal"
},
{
"target": "left_arm.base.bottom",
"op": "fill",
"color": "navy_deep"
}
]
},
{
"id": "orange-right-shoulder",
"paint": [
{
"target": "right_arm.outer.top",
"op": "rect",
"box": [
0,
0,
4,
4
],
"color": "orange"
},
{
"target": "right_arm.outer.front",
"op": "rect",
"box": [
0,
0,
4,
3
],
"color": "orange"
},
{
"target": "right_arm.outer.front",
"op": "rect",
"box": [
0,
2,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_arm.outer.back",
"op": "rect",
"box": [
0,
0,
4,
3
],
"color": "orange"
},
{
"target": "right_arm.outer.back",
"op": "rect",
"box": [
0,
2,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_arm.outer.right",
"op": "rect",
"box": [
0,
0,
4,
3
],
"color": "orange"
},
{
"target": "right_arm.outer.right",
"op": "rect",
"box": [
0,
2,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_arm.outer.left",
"op": "rect",
"box": [
0,
0,
4,
3
],
"color": "orange"
},
{
"target": "right_arm.outer.left",
"op": "rect",
"box": [
0,
2,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_arm.outer.front",
"op": "rect",
"box": [
0,
0,
4,
1
],
"color": "orange_light"
},
{
"target": "right_arm.outer.front",
"op": "points",
"points": [
[
1,
1
],
[
2,
1
]
],
"color": "ivory_light"
}
]
},
{
"id": "trousers-knee-armor-and-boots",
"paint": [
{
"target": "right_leg.base.front",
"op": "fill",
"color": "navy"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "ivory"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
1,
4,
2,
1
],
"color": "ivory_light"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
1,
9,
2,
1
],
"color": "ivory_light"
},
{
"target": "right_leg.base.back",
"op": "fill",
"color": "navy"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "navy_light"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory_shadow"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "right_leg.base.back",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_leg.base.right",
"op": "fill",
"color": "navy"
},
{
"target": "right_leg.base.right",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "right_leg.base.right",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "navy_light"
},
{
"target": "right_leg.base.right",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory_shadow"
},
{
"target": "right_leg.base.right",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "right_leg.base.right",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_leg.base.left",
"op": "fill",
"color": "navy"
},
{
"target": "right_leg.base.left",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "right_leg.base.left",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "navy_light"
},
{
"target": "right_leg.base.left",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory_shadow"
},
{
"target": "right_leg.base.left",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "right_leg.base.left",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "right_leg.outer.front",
"op": "rect",
"box": [
0,
3,
4,
2
],
"color": "ivory"
},
{
"target": "right_leg.outer.front",
"op": "rect",
"box": [
1,
3,
2,
1
],
"color": "ivory_light"
},
{
"target": "right_leg.base.bottom",
"op": "fill",
"color": "navy_deep"
},
{
"target": "left_leg.base.front",
"op": "fill",
"color": "navy"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "ivory"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
1,
4,
2,
1
],
"color": "ivory_light"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
1,
9,
2,
1
],
"color": "ivory_light"
},
{
"target": "left_leg.base.back",
"op": "fill",
"color": "navy"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "navy_light"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "left_leg.base.back",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "left_leg.base.right",
"op": "fill",
"color": "navy"
},
{
"target": "left_leg.base.right",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.right",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "navy_light"
},
{
"target": "left_leg.base.right",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.right",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "left_leg.base.right",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "left_leg.base.left",
"op": "fill",
"color": "navy"
},
{
"target": "left_leg.base.left",
"op": "rect",
"box": [
0,
0,
4,
2
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.left",
"op": "rect",
"box": [
0,
3,
4,
3
],
"color": "navy_light"
},
{
"target": "left_leg.base.left",
"op": "rect",
"box": [
0,
8,
4,
3
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.left",
"op": "rect",
"box": [
0,
11,
4,
1
],
"color": "navy_deep"
},
{
"target": "left_leg.base.left",
"op": "rect",
"box": [
0,
8,
4,
1
],
"color": "orange_shadow"
},
{
"target": "left_leg.outer.front",
"op": "rect",
"box": [
0,
3,
4,
2
],
"color": "ivory"
},
{
"target": "left_leg.outer.front",
"op": "rect",
"box": [
1,
3,
2,
1
],
"color": "ivory_light"
},
{
"target": "left_leg.base.bottom",
"op": "fill",
"color": "navy_deep"
}
]
},
{
"id": "inner-leg-seams",
"paint": [
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
3,
0,
1,
12
],
"color": "navy_light"
},
{
"target": "right_leg.base.front",
"op": "rect",
"box": [
3,
8,
1,
3
],
"color": "ivory_deep"
},
{
"target": "right_leg.outer.front",
"op": "rect",
"box": [
3,
3,
1,
2
],
"color": "ivory_shadow"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
0,
1,
12
],
"color": "navy_light"
},
{
"target": "left_leg.base.front",
"op": "rect",
"box": [
0,
8,
1,
3
],
"color": "ivory_deep"
},
{
"target": "left_leg.outer.front",
"op": "rect",
"box": [
0,
3,
1,
2
],
"color": "ivory_shadow"
}
]
}
],
"notes": [
"The unseen back has a compact courier equipment panel drawn within the standard outer layer."
],
"request": "A lunar courier in ivory armor with orange trim, a navy undersuit, a blue visor, and one orange shoulder."
}
assets/slim-uv.json{
"head.base.top": [
8,
0,
8,
8
],
"head.base.bottom": [
16,
0,
8,
8
],
"head.base.right": [
0,
8,
8,
8
],
"head.base.front": [
8,
8,
8,
8
],
"head.base.left": [
16,
8,
8,
8
],
"head.base.back": [
24,
8,
8,
8
],
"head.outer.top": [
40,
0,
8,
8
],
"head.outer.bottom": [
48,
0,
8,
8
],
"head.outer.right": [
32,
8,
8,
8
],
"head.outer.front": [
40,
8,
8,
8
],
"head.outer.left": [
48,
8,
8,
8
],
"head.outer.back": [
56,
8,
8,
8
],
"body.base.top": [
20,
16,
8,
4
],
"body.base.bottom": [
28,
16,
8,
4
],
"body.base.right": [
16,
20,
4,
12
],
"body.base.front": [
20,
20,
8,
12
],
"body.base.left": [
28,
20,
4,
12
],
"body.base.back": [
32,
20,
8,
12
],
"body.outer.top": [
20,
32,
8,
4
],
"body.outer.bottom": [
28,
32,
8,
4
],
"body.outer.right": [
16,
36,
4,
12
],
"body.outer.front": [
20,
36,
8,
12
],
"body.outer.left": [
28,
36,
4,
12
],
"body.outer.back": [
32,
36,
8,
12
],
"right_arm.base.top": [
44,
16,
3,
4
],
"right_arm.base.bottom": [
47,
16,
3,
4
],
"right_arm.base.right": [
40,
20,
4,
12
],
"right_arm.base.front": [
44,
20,
3,
12
],
"right_arm.base.left": [
47,
20,
4,
12
],
"right_arm.base.back": [
51,
20,
3,
12
],
"right_arm.outer.top": [
44,
32,
3,
4
],
"right_arm.outer.bottom": [
47,
32,
3,
4
],
"right_arm.outer.right": [
40,
36,
4,
12
],
"right_arm.outer.front": [
44,
36,
3,
12
],
"right_arm.outer.left": [
47,
36,
4,
12
],
"right_arm.outer.back": [
51,
36,
3,
12
],
"left_arm.base.top": [
36,
48,
3,
4
],
"left_arm.base.bottom": [
39,
48,
3,
4
],
"left_arm.base.right": [
32,
52,
4,
12
],
"left_arm.base.front": [
36,
52,
3,
12
],
"left_arm.base.left": [
39,
52,
4,
12
],
"left_arm.base.back": [
43,
52,
3,
12
],
"left_arm.outer.top": [
52,
48,
3,
4
],
"left_arm.outer.bottom": [
55,
48,
3,
4
],
"left_arm.outer.right": [
48,
52,
4,
12
],
"left_arm.outer.front": [
52,
52,
3,
12
],
"left_arm.outer.left": [
55,
52,
4,
12
],
"left_arm.outer.back": [
59,
52,
3,
12
],
"right_leg.base.top": [
4,
16,
4,
4
],
"right_leg.base.bottom": [
8,
16,
4,
4
],
"right_leg.base.right": [
0,
20,
4,
12
],
"right_leg.base.front": [
4,
20,
4,
12
],
"right_leg.base.left": [
8,
20,
4,
12
],
"right_leg.base.back": [
12,
20,
4,
12
],
"right_leg.outer.top": [
4,
32,
4,
4
],
"right_leg.outer.bottom": [
8,
32,
4,
4
],
"right_leg.outer.right": [
0,
36,
4,
12
],
"right_leg.outer.front": [
4,
36,
4,
12
],
"right_leg.outer.left": [
8,
36,
4,
12
],
"right_leg.outer.back": [
12,
36,
4,
12
],
"left_leg.base.top": [
20,
48,
4,
4
],
"left_leg.base.bottom": [
24,
48,
4,
4
],
"left_leg.base.right": [
16,
52,
4,
12
],
"left_leg.base.front": [
20,
52,
4,
12
],
"left_leg.base.left": [
24,
52,
4,
12
],
"left_leg.base.back": [
28,
52,
4,
12
],
"left_leg.outer.top": [
4,
48,
4,
4
],
"left_leg.outer.bottom": [
8,
48,
4,
4
],
"left_leg.outer.right": [
0,
52,
4,
12
],
"left_leg.outer.front": [
4,
52,
4,
12
],
"left_leg.outer.left": [
8,
52,
4,
12
],
"left_leg.outer.back": [
12,
52,
4,
12
]
}
LICENSEMIT License Copyright (c) 2026 BLCNYY This license applies to the software, documentation and reusable templates in this repository. Files under examples/og/ are excluded; see that directory's LICENSE.md for their terms. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
references/design-format.md# Editable design format
The agent interprets the request or visible reference; the Python tools assemble its decisions. They do not contain an image-recognition or text-generation model. Write a design for the actual input. The blank template and examples are coordinate aids, not a fixed character preset.
## Start
`skinmaker.py init design.json --name "Character name"` creates all required base fills. Edit its palette, base colors and named components. Read `assets/classic-uv.json` or `assets/slim-uv.json` only if exact atlas positions are needed; drawing operations use face-local coordinates.
```json
{
"schema_version": 1,
"name": "Copper explorer",
"model": "classic",
"canvas_model": "classic",
"palette": {
"skin": "#a87755", "jacket": "#267b80", "jacket_shadow": "#205c66",
"trousers": "#30354d", "hair": "#352c32", "eye": "#161923"
},
"base": {
"head": "skin", "body": "jacket", "right_arm": "jacket",
"left_arm": "jacket", "right_leg": "trousers", "left_leg": "trousers"
},
"components": [
{"id": "hair", "paint": [
{"target": ["head.base.top", "head.base.back"], "op": "fill", "color": "hair"},
{"target": "head.base.front", "op": "rect", "box": [0, 0, 8, 2], "color": "hair"}
]},
{"id": "eyes", "paint": [
{"target": "head.base.front", "op": "points", "points": [[2, 4], [5, 4]], "color": "eye"}
]},
{"id": "jacket-cuff", "paint": [
{"target": "right_arm.outer.front", "op": "rect", "box": [0, 8, 4, 1], "color": "jacket_shadow"}
]}
],
"notes": ["The unseen back continues the jacket color."]
}
```
This snippet illustrates the format. Add deliberate face, hair, clothing, boot, seam and outer-layer details to finish an actual design.
## Coordinates and operations
Targets are `part.layer.face` or lists of those strings. Parts: `head`, `body`, `right_arm`, `left_arm`, `right_leg`, `left_leg`. Layers: `base`, `outer`. Faces: `front`, `back`, `left`, `right`, `top`, `bottom`. Left and right are always the wearer's side. The wearer's right appears on the viewer's left in a front view. Face `(0,0)` is the upper-left pixel in the atlas tile, not a world coordinate. On top and bottom tiles, row zero is the back edge; column zero is the wearer's right edge. Side tiles run around the box: the right face's last column meets the front; the left face's first column meets the front. Back tiles reverse the front's world x direction.
Sizes: head faces 8×8; body front/back 8×12, sides 4×12, top/bottom 8×4; leg front/back/sides 4×12, top/bottom 4×4. Classic arms match legs. Slim arm front/back 3×12, sides 4×12, top/bottom 3×4. Outer tiles have the same pixel dimensions as their base tile.
Components run in order and must have unique IDs. Operations replace pixels (including alpha); they do not alpha-blend into existing pixels.
- `fill`, `color`: cover the whole target face.
- `rect`, `box: [x,y,width,height]`, `color`: integer pixel rectangle.
- `points`, `points: [[x,y], ...]`, `color`: individual pixels.
- `line`, `points`, `color`, optional `width` (default 1).
- `polygon`, `points`, `color`: filled polygon.
- `pixels`, `at: [x,y]`, `rows: [".AB.", "ABBA"]`, `map: {"A":"hair","B":"#987865"}`: hand-authored pixel art. A dot preserves the previous pixel. Map another symbol to `transparent` to erase outer pixels. All rows must fit the face. `at` defaults to `[0,0]`.
To avoid repeating the same `map` in every `pixels` operation, put a design-level `"symbols": {"H": "hair", "s": "skin"}` next to `palette`. Each `pixels` operation uses these symbols; its own optional `map` overrides individual letters.
The assembler is strict. Unknown or misspelled keys (for example `colour` or `paints`), missing fields, transparent paint on a base face and partial alpha on an outer face stop the build with a message naming the component, operation index and face. Allowed optional metadata: `notes`, `reference_brief`, `request` and `description` on the design; `note`, `notes` and `description` on a component.
Colors are named palette keys or `#RRGGBB`/`#RRGGBBAA`. Base alpha must equal 255. Use alpha 0 or 255 for outer layers so the same skin behaves consistently in Java and Bedrock. No random noise, automatic gradients or hidden styling is added by the assembler.
## Revision behavior
Use specific palette keys such as `jacket`, `jacket_light`, `jacket_shadow`, `scarf`, `hair`, `hair_light`. A jacket edit should update only jacket keys or jacket components, even if another garment currently has the same color. Keep the previous design and outputs in a separate directory. Run `skinmaker.py diff` on the old and new `skin.png` and check the changed pixels stay inside the intended faces or materials.
`revise --model slim` retains `canvas_model` and resamples only arm face widths using nearest-neighbor sampling. All non-arm pixels remain exact. Moving side and back tiles is necessary to repack the arm net. Width reduction can lose a pixel of small motifs: inspect and refine affected arms. Operations remain in `canvas_model` coordinates for future edits.
`import` embeds the source PNG inside JSON, so designs remain portable. Add named components to make targeted changes. Alpha is normalized and unused regions cleared; reported repairs should be disclosed when material. Model metadata from an existing design or known source takes priority over the PNG heuristic. An unmarked 64×64 PNG alone can be ambiguous. Legacy 64×32 PNGs expand to Classic with mirrored left limbs.
Keep creative assumptions in `notes`. For reference work, add a `reference_brief` object with `identity_anchors`, `orientation`, `expression`, `materials` and `unseen_areas` as useful. These are agent-written observations; the helper preserves them without assigning a likeness score. Follow [likeness-and-pixel-art.md](likeness-and-pixel-art.md) for translating and reviewing them. Keep source images local and outside Bedrock packs. Preserve originals when useful for subsequent edits.
references/formats-and-import.md# Minecraft formats and import Checked 2026-09-26. Recheck official guidance when a user's device or Minecraft update makes these instructions uncertain. ## Bedrock packaging Microsoft's [skin-pack guide](https://learn.microsoft.com/en-us/minecraft/creator/documents/packagingaskinpack) specifies root-level `manifest.json`, `skins.json` and PNG textures, plus `texts/en_US.lang` and `texts/languages.json`. Use two distinct UUIDs and a `skin_pack` module. This helper emits manifest format 2, following the newer [Introduction to Skin Packs](https://learn.microsoft.com/en-us/minecraft/creator/documents/skinpack) (updated 2025-07-31) and [skin-pack JSON reference](https://github.com/MicrosoftDocs/minecraft-creator/blob/main/creator/Reference/Content/SkinPacksReference/SkinPackJSONReference.md). The older packaging guide shows format 1; the validator accepts both. `geometry.humanoid.custom` selects Classic; `geometry.humanoid.customSlim` selects Slim. Each skin is `type: free`. The pack serialization/localization key is unique. The language file supplies `pack.name`, `skinpack.<pack-key>` and `skin.<pack-key>.<skin-key>`. `languages.json` lists `en_US`. Localized additional files are optional and must agree with that list. The archive is ZIP with `.mcpack` extension and no enclosing folder. Each new package gets fresh header/module UUIDs, including revisions, so it imports as a separate pack. ## Short import text - **Java, including macOS:** Launcher → Java Edition → Skins → New Skin → browse to the PNG → select the matching Classic/Wide or Slim model → Save & Use. [Official skin guide](https://www.minecraft.net/en-us/article/what-is-minecraft-skin) - **Bedrock on Windows, Android or iOS:** Open the `.mcpack` with Minecraft and select it in Dressing Room → Classic Skins. [Official sample and import instructions](https://github.com/microsoft/minecraft-samples/blob/main/skinpack/README.md). If file association is unavailable, import the PNG through Classic Skins → Owned/Import → Choose New Skin; match the arm thumbnail. That PNG route is documented in [Mojang's release notes](https://feedback.minecraft.net/hc/en-us/articles/360033538652-Minecraft-Beta-1-13-0-15-Xbox-One-Windows-10-Android); menu labels and the file picker vary by release. - **Xbox, PlayStation, Switch:** Official custom-file imports are unavailable. A generated pack does not bypass that platform restriction. Bedrock custom skins do not automatically synchronize between devices. [Official Character Creator FAQ](https://www.minecraft.net/en-us/article/character-creator-faq) The FAQ includes older product names but remains the official statement consulted on the date above. Do not claim a live game import based on it. The helper produces files; it does not access the user's Minecraft account or install Minecraft. ## UV and rendering convention Modern player skins have 64×64 pixels, six cuboids and two layers. See `assets/*-uv.json` for each exact rectangle and `*-mask.png` for alpha checks. Independent orientation checks use asymmetric corner pixels and a separately specified atlas fixture. Geometry and face orientation were cross-checked with the primary [skinview3d model implementation](https://github.com/bs-community/skinview3d/blob/master/src/model.ts), including its reversed bottom-UV vertex assignment. Our renderer is independently written and samples the delivered PNG directly. The default preview is a stationary, orthographic player model. Head outer surfaces extend 0.5 model units per side; other outer surfaces extend 0.25. It renders both sides of transparent outer surfaces with depth sorting. Game lighting, animation, capes, held objects, armor, and shader modifications are outside this preview. Separate three kinds of evidence: automated texture/pack checks; a human/agent viewing the exported previews; actual import and appearance in Minecraft. Record only the checks performed.
references/likeness-and-pixel-art.md# Likeness and pixel-art review Use this guide when translating a reference or when a skin captures the colors but still feels unlike the character. Keep creative choices internal; complete a first result with the normal automatic defaults. ## Read the character before drawing Save a short `reference_brief` in the editable design: recognizable features, orientation, expression, material treatment, and assumptions for unseen areas. Prioritize the features that distinguish this subject. Examples include the direction and depth of a fringe, unequal eye colors, a hood shape, a stripe's position, or a distinctive collar. Match their relative sizes and placement as well as their colors. Record left/right explicitly for asymmetric features. A tilted portrait may make one eye look smaller; decide which differences describe the character and which come from perspective. Keep face proportions coherent in the straight-on skin. Choose quiet, consistent details for unseen trousers, shoes or the back, and mention substantial inventions briefly after delivery. ## Hair and expression Resolve the head before decorating the outfit. An 8×8 front face has little room: reserve pixels for the eyes and expression, then shape the hairline around them. - Follow the reference's part, sweep and lock groupings. Use contiguous bands of color that follow those shapes; make a highlight describe a lock or a broad lit area. - Use selected outer-layer patches for fringe, temples and overlapping locks. Break exposed edges into purposeful steps. The underlying cuboid still limits the silhouette; suggest the character's volume with placement and shading inside those limits. - Place hair highlights and shadows consistently across front, sides, top and back. Avoid a rectangular highlight cap, long straight helmet borders, or random alternating pixels unless the reference calls for them. - Keep distinctive eyes visible and correctly oriented. Use a deliberate arrangement of sclera, iris and pupil at the available resolution. Strong brows, large noses or very dark mouth pixels can change the apparent age or expression. - For a gentle face, keep skin shadows close to the base color. Test a small, low-contrast mouth or a slight asymmetry when that helps express the reference. Judge the rendered face: a pixel that reads as a moustache, scowl or nose should be repositioned or softened. A mouth is optional if the face reads better without it. ## Materials and clothing Start each material with a base tone, a nearby shadow and a nearby highlight. Add tones when a visible feature or surface needs them. Smooth cloth benefits from broad uninterrupted areas. Place shading at seams, hems, folds, under a hood, and where sleeves join the torso. Keep lighting coherent around the body. Maintain the reference's contrast hierarchy: a dark hoodie can remain mostly dark while white bands and colored cords carry the detail. Draw folds with connected shapes or a few pixels near a seam. Scattered light/dark squares can turn plain fabric into apparent camouflage, armor or worn stone. Use that texture only when it belongs to the subject. Give each distinctive construction detail its proper shape. A hood opening should wrap around the neck; drawstrings should hang from their attachment points; a zipper should continue through the garment; sleeve bands should align around every side. Put raised rims, cords, cuffs and hair on the outer layer selectively. Large flat outer panels can add unwanted bulk. ## Review the reference and the rendered skin together For an image request, build with `--reference <image>` and open `reference-review.png`. This locally arranges the original image beside an exact-texture render, enlarged front/angled head views, and a small full-body view. It adds no likeness score and does not modify the reference or the skin. Compare the result at both enlarged and small display sizes: 1. **Identity:** Are the subject's distinguishing features present, on the correct sides, and given enough visual emphasis? 2. **Head:** Does the fringe have the intended direction and overlap? Do the eyes and expression read correctly? Are outer-layer blocks helping the hair shape? 3. **Materials:** Does the garment still read as the reference's fabric or armor? Do highlights describe form? Is unnecessary noise competing with the identifying details? 4. **Construction:** Do collars, hood, seams, bands, pockets and cords connect logically across faces and layers? 5. **Unknown areas:** Do the invented parts support the visible design without introducing a competing theme? Also open `preview.png` for the back and both three-quarter views. Correct concrete mismatches in the editable design and rebuild. If comparing against an earlier preview, use the same view and comparable display size. State the specific improvements and remaining compromises; automated format checks do not establish visual likeness. Record the actual final inspection in `visual-inspection.md`, with the exported PNG's SHA-256 and the features checked. Keep the automated validation report distinct. A reference review marked pending requires the agent to open the images and make the visual judgment.
requirements.txtPillow>=10,<13 numpy>=1.24,<3
scripts/bedrock.py"""Build and validate a self-contained, free Bedrock skin pack."""
import io
import json
import re
import uuid
import zipfile
from pathlib import Path, PurePosixPath
from PIL import Image
from design import validate_image, validate_png
GEOMETRIES = {"classic": "geometry.humanoid.custom", "slim": "geometry.humanoid.customSlim"}
def _text(value):
return " ".join(str(value).replace("=", "-").splitlines()).strip()
def package(png, destination, name, model):
check = validate_png(png, model)
if not check["ok"]:
raise ValueError("Invalid skin: " + "; ".join(check["errors"]))
header_uuid, module_uuid = str(uuid.uuid4()), str(uuid.uuid4())
pack_key = "skin_" + header_uuid.replace("-", "")
name = _text(name) or "My Skin"
# Follow Microsoft's 2025 Introduction to Skin Packs and sample pack.
manifest = {"format_version": 2,
"header": {"name": "pack.name", "uuid": header_uuid, "version": [1, 0, 0]},
"modules": [{"type": "skin_pack", "uuid": module_uuid, "version": [1, 0, 0]}]}
skins = {"serialize_name": pack_key, "localization_name": pack_key,
"skins": [{"localization_name": "main", "geometry": GEOMETRIES[model],
"texture": "skin.png", "type": "free"}]}
files = {"manifest.json": json.dumps(manifest, indent=2),
"skins.json": json.dumps(skins, indent=2),
"texts/languages.json": json.dumps(["en_US"]),
"texts/en_US.lang": f"pack.name={name}\nskinpack.{pack_key}={name}\nskin.{pack_key}.main={name}\n",
"skin.png": Path(png).read_bytes()}
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as archive:
for key, value in files.items():
archive.writestr(key, value.encode("utf-8") if isinstance(value, str) else value)
return validate_pack(destination, expected_model=model, expected_png=png)
def validate_pack(path, expected_model=None, expected_png=None):
errors = []
details = {}
try:
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
if len(names) != len(set(names)):
errors.append("Duplicate ZIP members")
for name in names:
pure = PurePosixPath(name)
if pure.is_absolute() or ".." in pure.parts or "\\" in name:
errors.append(f"Unsafe ZIP member: {name}")
if sum(x.file_size for x in archive.infolist()) > 16_000_000:
raise ValueError("Skin pack exceeds the validator's 16 MB uncompressed limit")
if archive.testzip():
errors.append("ZIP CRC check failed")
required = {"manifest.json", "skins.json", "texts/en_US.lang", "texts/languages.json"}
if required-set(names):
raise ValueError(f"Missing root-level pack files: {sorted(required-set(names))}")
manifest = json.loads(archive.read("manifest.json"))
if manifest.get("format_version") not in (1, 2):
errors.append("Unsupported manifest format_version")
header = manifest["header"]
modules = manifest["modules"]
if len(modules) != 1 or modules[0].get("type") != "skin_pack":
errors.append("Expected one skin_pack module")
ids = []
for entry in [header]+modules:
identifier = str(uuid.UUID(entry["uuid"]))
if identifier == str(uuid.UUID(int=0)):
errors.append("UUID may not be nil")
ids.append(identifier)
version = entry.get("version")
if not isinstance(version, list) or len(version) != 3 or any(type(n) is not int or n < 0 for n in version):
errors.append("Versions must be three nonnegative integers")
if len(ids) != len(set(ids)):
errors.append("Header and module UUIDs must differ")
skins = json.loads(archive.read("skins.json"))
key = skins["serialize_name"]
if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z0-9_]+", key):
errors.append("serialize_name must use letters, digits or underscores")
if skins.get("localization_name") != key:
errors.append("Pack localization_name must match serialize_name")
languages = json.loads(archive.read("texts/languages.json"))
if not isinstance(languages, list) or "en_US" not in languages or len(languages) != len(set(languages)):
raise ValueError("languages.json must contain unique language codes including en_US")
translations = {}
for language in languages:
if not isinstance(language, str) or not re.fullmatch(r"[a-z]{2}_[A-Z]{2}", language):
raise ValueError("Invalid locale code")
entries = {}
for line in archive.read(f"texts/{language}.lang").decode("utf-8-sig").splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
k, v = line.split("=", 1)
if k in entries:
errors.append(f"Duplicate localization key {k}")
entries[k] = v
translations[language] = entries
required_keys = {"pack.name", f"skinpack.{key}"}
if header.get("name") != "pack.name" and not header.get("name"):
errors.append("Manifest header name is empty")
records = skins["skins"]
if not isinstance(records, list) or not records:
raise ValueError("Pack must include at least one skin")
local_keys = []
for entry in records:
local_key = entry["localization_name"]
if not re.fullmatch(r"[A-Za-z0-9_]+", local_key):
errors.append("Invalid skin localization key")
local_keys.append(local_key)
required_keys.add(f"skin.{key}.{local_key}")
if entry.get("type") != "free":
errors.append("Included skins must be free")
model = next((m for m, geom in GEOMETRIES.items() if geom == entry.get("geometry")), None)
if model is None:
errors.append("Unknown player geometry")
continue
if expected_model and model != expected_model:
errors.append("Pack geometry does not match requested model")
filename = entry["texture"]
if not re.fullmatch(r"[A-Za-z0-9_-]+\.png", filename):
errors.append("Skin texture must be a PNG at ZIP root")
continue
content = archive.read(filename)
if expected_png and content != Path(expected_png).read_bytes():
errors.append("Pack texture differs from delivered PNG")
with Image.open(io.BytesIO(content)) as skin:
if skin.format != "PNG":
errors.append("Texture bytes are not PNG")
skin.load()
errors.extend(validate_image(skin, model)["errors"])
if len(local_keys) != len(set(local_keys)):
errors.append("Skin localization keys must be unique")
for lang, entries in translations.items():
for key in required_keys:
if not entries.get(key):
errors.append(f"Missing localization {lang}: {key}")
details = {"uuid": ids[0], "skin_count": len(records), "zip_members": names}
except (ValueError, KeyError, TypeError, IndexError, OSError, zipfile.BadZipFile, UnicodeError) as exc:
errors.append(str(exc))
return {"ok": not errors, "errors": errors, **details}
scripts/design.py"""Editable, named pixel-art components with strict face-local coordinates."""
import base64
import copy
import io
import json
import re
from pathlib import Path
from PIL import Image, ImageDraw
from uv import PARTS, layout, masks, convert_model
def read(path):
return json.loads(Path(path).read_text(encoding="utf-8"))
def write(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def color(value, palette):
value = palette.get(value, value) if isinstance(value, str) else value
if value in ("transparent", None):
return (0, 0, 0, 0)
if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?", value):
b = bytes.fromhex(value[1:])
return tuple(b) if len(b) == 4 else (*b, 255)
raise ValueError(f"Unknown palette color or invalid #RRGGBB[AA]: {value!r}")
def fresh(name="My Skin", model="classic"):
layout(model)
return {"schema_version": 1, "name": name, "model": model, "canvas_model": model,
"palette": {"foundation": "#a57760"},
"base": {part: "foundation" for part in PARTS}, "components": [],
"notes": []}
def _integer(value):
return isinstance(value, int) and not isinstance(value, bool)
# Unknown keys are errors: a misspelled key such as "colour" or "paints" would
# otherwise be ignored and silently drop part of the design.
DESIGN_KEYS = {"schema_version", "name", "model", "canvas_model", "palette", "base", "symbols",
"components", "notes", "reference_brief", "request", "description", "source_png"}
COMPONENT_KEYS = {"id", "paint", "note", "notes", "description"}
OPERATION_KEYS = {
"fill": ({"color"}, set()),
"rect": ({"box", "color"}, set()),
"points": ({"points", "color"}, set()),
"line": ({"points", "color"}, {"width"}),
"polygon": ({"points", "color"}, set()),
"pixels": ({"rows"}, {"at", "map"}),
}
def _keys(where, found, required, optional):
missing = sorted(required - set(found))
unknown = sorted(set(found) - required - optional)
problems = []
if missing:
problems.append(f"missing {', '.join(missing)}")
if unknown:
problems.append(f"unknown key {', '.join(unknown)} (allowed: {', '.join(sorted(required | optional))})")
if problems:
raise ValueError(f"{where}: {'; '.join(problems)}")
def check_structure(data):
"""Reject malformed designs with a message that names the exact location."""
if not isinstance(data, dict):
raise ValueError("Design must be a JSON object")
_keys("design", data, {"schema_version"}, DESIGN_KEYS)
if data["schema_version"] != 1:
raise ValueError("design schema_version must be 1")
for key in ("model", "canvas_model"):
if data.get(key, "classic") not in ("classic", "slim"):
raise ValueError(f"design {key} must be classic or slim")
for key in ("palette", "base", "symbols"):
if not isinstance(data.get(key, {}), dict):
raise ValueError(f"design {key} must be an object")
for symbol in data.get("symbols", {}):
if len(symbol) != 1 or symbol == ".":
raise ValueError(f"symbols key {symbol!r} must be one character other than '.'")
components = data.get("components", [])
if not isinstance(components, list):
raise ValueError("design components must be a list")
identifiers = set()
for index, component in enumerate(components):
where = f"components[{index}]"
if not isinstance(component, dict):
raise ValueError(f"{where} must be an object")
_keys(where, component, {"id", "paint"}, COMPONENT_KEYS - {"id", "paint"})
identifier = component["id"]
if not isinstance(identifier, str) or not identifier:
raise ValueError(f"{where}: id must be a non-empty string")
if identifier in identifiers:
raise ValueError(f"Component IDs must be unique: {identifier}")
identifiers.add(identifier)
if not isinstance(component["paint"], list):
raise ValueError(f"{identifier}: paint must be a list")
for number, operation in enumerate(component["paint"]):
spot = f"{identifier}, paint[{number}]"
if not isinstance(operation, dict):
raise ValueError(f"{spot} must be an object")
kind = operation.get("op")
if kind not in OPERATION_KEYS:
raise ValueError(f"{spot}: op must be one of {', '.join(OPERATION_KEYS)}; got {kind!r}")
required, optional = OPERATION_KEYS[kind]
_keys(f"{spot} ({kind})", operation, required | {"op", "target"}, optional)
targets = operation["target"]
if isinstance(targets, str):
targets = [targets]
if not isinstance(targets, list) or not targets or not all(isinstance(t, str) for t in targets):
raise ValueError(f"{spot}: target must be a face name or a non-empty list of face names")
def _point(point, w, h):
if not isinstance(point, (list, tuple)) or len(point) != 2 or not all(_integer(n) for n in point):
raise ValueError(f"Pixel point must have two integers: {point!r}")
if not (0 <= point[0] < w and 0 <= point[1] < h):
raise ValueError(f"Pixel point {point} is outside {w}x{h} face")
return tuple(point)
def _paint(tile, operation, palette, symbols=None):
w, h = tile.size
kind = operation["op"]
brush = ImageDraw.Draw(tile)
ink = color(operation["color"], palette) if "color" in operation else None
if kind == "fill":
tile.paste(ink, (0, 0, w, h))
elif kind == "rect":
box = operation["box"]
if len(box) != 4 or not all(_integer(n) for n in box):
raise ValueError("rect box must be [x,y,width,height] integers")
x, y, rw, rh = box
if rw <= 0 or rh <= 0:
raise ValueError("rect dimensions must be positive")
_point((x, y), w, h)
_point((x+rw-1, y+rh-1), w, h)
brush.rectangle((x, y, x+rw-1, y+rh-1), fill=ink)
elif kind in ("line", "polygon", "points"):
points = [_point(p, w, h) for p in operation["points"]]
if not points:
raise ValueError("points may not be empty")
if kind == "line":
width = operation.get("width", 1)
if not _integer(width) or width < 1:
raise ValueError("line width must be a positive integer")
brush.line(points, fill=ink, width=width)
elif kind == "polygon":
if len(points) < 3:
raise ValueError("polygon needs at least three vertices")
brush.polygon(points, fill=ink)
else:
brush.point(points, fill=ink)
elif kind == "pixels":
at = operation.get("at", [0, 0])
if not isinstance(at, (list, tuple)) or len(at) != 2 or not all(_integer(n) for n in at):
raise ValueError(f"pixels at must be two integers: {at!r}")
x, y = at
# An operation's own map overrides the design-wide symbols.
symbols = {**(symbols or {}), **operation.get("map", {})}
rows = operation["rows"]
if not isinstance(rows, list) or not rows or not all(isinstance(row, str) and row for row in rows):
raise ValueError("pixels rows must be a list of non-empty strings")
for dy, row in enumerate(rows):
for dx, symbol in enumerate(row):
_point((x+dx, y+dy), w, h)
if symbol != ".":
if symbol not in symbols:
raise ValueError(f"No palette mapping for pixel symbol {symbol}")
tile.putpixel((x+dx, y+dy), color(symbols[symbol], palette))
else:
raise ValueError(f"Unknown paint operation: {kind}")
def assemble(data):
check_structure(data)
model = data.get("model", "classic")
canvas = data.get("canvas_model", model)
faces = layout(canvas)
palette = data.get("palette", {})
if "source_png" in data:
image = Image.open(io.BytesIO(base64.b64decode(data["source_png"], validate=True))).convert("RGBA")
if image.size != (64, 64):
raise ValueError("embedded source must be 64x64")
else:
image = Image.new("RGBA", (64, 64))
for f in faces.values():
if f.layer == "base":
if f.part not in data.get("base", {}):
raise ValueError(f"Missing base color for {f.part}")
ink = color(data["base"][f.part], palette)
if ink[3] != 255:
raise ValueError(f"Base color must be opaque for {f.part}")
image.paste(ink, f.box)
symbols = data.get("symbols", {})
for component in data.get("components", []):
identifier = component["id"]
for operation in component["paint"]:
targets = operation["target"]
targets = [targets] if isinstance(targets, str) else targets
for target in targets:
if target not in faces:
raise ValueError(f"{identifier}: unknown face {target!r}; use part.layer.face such as head.outer.front")
f = faces[target]
tile = image.crop(f.box)
try:
_paint(tile, operation, palette, symbols)
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"{identifier}, {target}: {exc}") from exc
# Report layer rule violations where they were painted, not as an atlas-wide count.
alpha = tile.getchannel("A").getdata()
if f.layer == "base" and any(a != 255 for a in alpha):
raise ValueError(f"{identifier}, {target}: base pixels must stay opaque; paint transparency on the outer layer")
if f.layer == "outer" and any(a not in (0, 255) for a in alpha):
raise ValueError(f"{identifier}, {target}: outer pixels need alpha 0 or 255, not partial transparency")
image.paste(tile, f.box)
return convert_model(image, canvas, model)
def validate_image(image, model):
errors = []
if image.size != (64, 64):
return {"ok": False, "errors": [f"Expected 64x64, got {image.size}"], "model": model}
if image.mode != "RGBA":
errors.append(f"Expected RGBA, got {image.mode}")
image = image.convert("RGBA")
alpha = list(image.getchannel("A").getdata())
mm = masks(model)
base = list(mm["base"].getdata())
outer = list(mm["outer"].getdata())
used = list(mm["used"].getdata())
nonopaque = sum(bool(b and a != 255) for a, b in zip(alpha, base))
unused = sum(bool(not u and a != 0) for a, u in zip(alpha, used))
fractional = sum(bool(o and a not in (0, 255)) for a, o in zip(alpha, outer))
if nonopaque:
errors.append(f"{nonopaque} used base pixels are not fully opaque")
if unused:
errors.append(f"{unused} unused pixels are not transparent")
if fractional:
errors.append(f"{fractional} outer pixels have partial alpha; use 0 or 255 for consistent Java/Bedrock output")
return {"ok": not errors, "model": model, "size": [64, 64],
"base_pixels": sum(bool(b) for b in base),
"outer_visible_pixels": sum(bool(a and b) for a, b in zip(alpha, outer)),
"errors": errors}
def validate_png(path, model):
with Image.open(path) as image:
if image.format != "PNG":
return {"ok": False, "errors": ["File is not a PNG"]}
image.load()
return validate_image(image, model)
def import_skin(path, name=None, model="auto"):
with Image.open(path) as source:
if source.format != "PNG" or source.size not in ((64, 64), (64, 32)):
raise ValueError("Existing skin import accepts 64x64 or legacy 64x32 PNGs")
source.load()
image = source.convert("RGBA")
notes = []
if image.size == (64, 32):
canvas = Image.new("RGBA", (64, 64))
canvas.paste(image, (0, 0))
classic = layout("classic")
for part in ("arm", "leg"):
for face in ("front", "back", "left", "right", "top", "bottom"):
src_face = {"left": "right", "right": "left"}.get(face, face)
src = classic[f"right_{part}.base.{src_face}"]
dst = classic[f"left_{part}.base.{face}"]
canvas.paste(image.crop(src.box).transpose(Image.Transpose.FLIP_LEFT_RIGHT), dst.box)
image = canvas
inferred = "classic"
notes.append("Expanded legacy 64x32 skin; mirrored right limbs to create the left limbs.")
# Legacy editors often filled an unused hat layer with a solid color; Minecraft
# treats a hat area with no transparent pixel as empty, so do the same.
hat = image.crop((32, 0, 64, 16))
if min(hat.getchannel("A").getdata()) >= 128:
image.paste((0, 0, 0, 0), (32, 0, 64, 16))
notes.append("Cleared the fully opaque legacy hat layer, which Minecraft treats as no hat.")
else:
classic_base = masks("classic")["base"]
slim_base = masks("slim")["base"]
pixels = list(image.getdata())
cb, sb = list(classic_base.getdata()), list(slim_base.getdata())
slim_valid = all(p[3] == 255 for p, b in zip(pixels, sb) if b)
exclusive_empty = all(p[3] == 0 for p, c, s in zip(pixels, cb, sb) if c and not s)
inferred = "slim" if slim_valid and exclusive_empty else "classic"
notes.append(f"Model inferred as {inferred}; PNG files do not contain authoritative model metadata.")
chosen = inferred if model == "auto" else model
# An explicit model describes the input UV layout, rather than asking for
# conversion. Use revise --model for a later, separate width conversion.
if model != "auto" and source.size == (64, 64):
inferred = chosen
notes = [f"Used the supplied {chosen} model metadata."]
image = convert_model(image, inferred, chosen)
mm = masks(chosen)
result = Image.new("RGBA", (64, 64))
repaired = 0
for y in range(64):
for x in range(64):
r, g, b, a = image.getpixel((x, y))
if mm["base"].getpixel((x, y)):
repaired += a != 255
result.putpixel((x, y), (r, g, b, 255))
elif mm["outer"].getpixel((x, y)):
repaired += a not in (0, 255)
result.putpixel((x, y), (r, g, b, 255) if a >= 128 else (0, 0, 0, 0))
if repaired:
notes.append(f"Normalized alpha on {repaired} used pixels for compatibility.")
buffer = io.BytesIO()
result.save(buffer, format="PNG")
data = fresh(name or Path(path).stem, chosen)
data["source_png"] = base64.b64encode(buffer.getvalue()).decode("ascii")
data["notes"] = notes
return data
def revise(data, palette=None, model=None, name=None):
result = copy.deepcopy(data)
if palette:
for key, value in palette.items():
if key not in result["palette"]:
raise ValueError(f"Unknown palette key: {key}")
color(value, {})
result["palette"][key] = value
if model:
layout(model)
result["canvas_model"] = result.get("canvas_model", result.get("model", "classic"))
result["model"] = model
if name:
result["name"] = name
return result
scripts/render.py"""Orthographic ray renderer; texture samples come only from the exported PNG."""
import hashlib
import math
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from uv import PARTS, dimensions, layout
def bounds(part, model, layer):
w, h, d = dimensions(part, model)
x = 0 if part in ("head", "body") else (-1 if part.startswith("right") else 1) * (4 + w/2 if "arm" in part else 2)
y = 28 if part == "head" else 18 if part == "body" or "arm" in part else 6
grow = (0.5 if part == "head" else 0.25) if layer == "outer" else 0
return np.array([x-w/2-grow, y-h/2-grow, -d/2-grow]), np.array([x+w/2+grow, y+h/2+grow, d/2+grow])
def surface_uv(points, lo, hi, face):
q = (points - lo) / (hi - lo)
x, y, z = q[..., 0], q[..., 1], q[..., 2]
if face == "front": return x, 1-y
if face == "back": return 1-x, 1-y
if face == "right": return z, 1-y
if face == "left": return 1-z, 1-y
# The bottom atlas tile shares the top tile's back-to-front v direction.
return x, z
def camera(yaw=0, pitch=0):
yaw, pitch = math.radians(yaw), math.radians(pitch)
right = np.array([math.cos(yaw), 0, -math.sin(yaw)])
toward = np.array([math.sin(yaw)*math.cos(pitch), math.sin(pitch), math.cos(yaw)*math.cos(pitch)])
up = np.cross(toward, right)
return right, up, toward
def render_view(texture, model, yaw=0, pitch=0, width=360, height=480, scale=12, lighting=True, layers=("base", "outer")):
"""Sort ray intersections per pixel, including both sides of outer layers."""
tex = np.asarray(texture.convert("RGBA"))
right, up, toward = camera(yaw, pitch)
xx, yy = np.meshgrid((np.arange(width)+0.5-width/2)/scale, (height/2-np.arange(height)-0.5)/scale)
origins = xx[..., None]*right + yy[..., None]*up + np.array([0, 16, 0])
axes = {"front": (2, 1), "back": (2, -1), "right": (0, -1),
"left": (0, 1), "top": (1, 1), "bottom": (1, -1)}
shade = {"front": 1, "back": .87, "right": .83, "left": .91, "top": 1.07, "bottom": .69}
depths, rgba = [], []
for f in layout(model).values():
if f.layer not in layers:
continue
axis, sign = axes[f.face]
if abs(toward[axis]) < 1e-8 or (f.layer == "base" and toward[axis]*sign < 0):
continue
lo, hi = bounds(f.part, model, f.layer)
plane = hi[axis] if sign == 1 else lo[axis]
t = (plane-origins[..., axis])/toward[axis]
point = origins+t[..., None]*toward
valid = np.all((point >= lo-1e-7) & (point <= hi+1e-7), axis=-1)
u, v = surface_uv(point, lo, hi, f.face)
tx = f.x+np.clip(np.floor(u*f.w).astype(int), 0, f.w-1)
ty = f.y+np.clip(np.floor(v*f.h).astype(int), 0, f.h-1)
sample = tex[ty, tx].copy()
sample[..., 3] = np.where(valid, sample[..., 3], 0)
if lighting:
sample[..., :3] = np.minimum(255, sample[..., :3].astype(float)*shade[f.face]).astype(np.uint8)
depths.append(np.where(valid, t, -np.inf).astype(np.float32))
rgba.append(sample)
background = np.empty((height, width, 4), dtype=np.uint8)
background[:] = [238, 241, 244, 255]
# A small ground shadow gives the static body an unambiguous floor.
if pitch >= 0 and abs(pitch) < 80:
foot = np.array([0, 0, 0])-np.array([0, 16, 0])
cy = height/2-float(foot @ up)*scale+3
sy, sx = np.mgrid[:height, :width]
ellipse = ((sx-width/2)/(6.7*scale))**2+((sy-cy)/(0.45*scale))**2 < 1
background[ellipse, :3] = [213, 219, 224]
if not depths:
return Image.fromarray(background)
stack = np.stack(rgba)
order = np.argsort(np.stack(depths), axis=0, kind="stable")
rr, cc = np.indices((height, width))
out = background[..., :3].astype(float)
for rank in order:
pixels = stack[rank, rr, cc]
a = pixels[..., 3:4]/255
out = pixels[..., :3]*a + out*(1-a)
background[..., :3] = out.astype(np.uint8)
return Image.fromarray(background)
def angle_preview(png, model, destination, yaw=0, pitch=0):
"""Render an additional inspection angle directly from the exported texture."""
if not math.isfinite(yaw) or not math.isfinite(pitch) or not -90 <= pitch <= 90:
raise ValueError("Preview angles must be finite; pitch must be between -90 and 90 degrees")
png, destination = Path(png), Path(destination)
output = destination / "custom-view.png"
if output.resolve() == png.resolve():
raise ValueError("Choose a separate output directory so the source skin stays unchanged")
with Image.open(png) as source:
texture = source.convert("RGBA")
if texture.size != (64, 64):
raise ValueError("Preview source must be the exported 64x64 PNG")
view = render_view(texture, model, yaw, pitch)
destination.mkdir(parents=True, exist_ok=True)
view.save(output)
return {"texture_sha256": hashlib.sha256(png.read_bytes()).hexdigest(),
"model": model, "view": str(output), "yaw": yaw, "pitch": pitch,
"in_game_tested": False}
def previews(png, model, destination, title="Minecraft skin"):
destination = Path(destination)
destination.mkdir(parents=True, exist_ok=True)
with Image.open(png) as source:
texture = source.convert("RGBA")
if texture.size != (64, 64):
raise ValueError("Preview source must be the exported 64x64 PNG")
# Both three-quarter views, so asymmetric details on either side get an angled look.
views = [("front", 0, 0, "FRONT"), ("back", 180, 0, "BACK"),
("three-quarter", -33, 16, "WEARER'S RIGHT"), ("three-quarter-left", 33, 16, "WEARER'S LEFT")]
sheet = Image.new("RGB", (20+380*len(views), 624), "#eef1f4")
draw = ImageDraw.Draw(sheet)
font = ImageFont.load_default(size=28)
small = ImageFont.load_default(size=15)
# Clamp visible title length; keep the complete Unicode name in the design.
display_title = title if len(title) <= 50 else title[:47]+"..."
draw.text((30, 22), display_title, font=font, fill="#1c2b38")
draw.text((30, 62), f"{model.title()} arms / 64 x 64 / rendered from the exported skin", font=small, fill="#526675")
paths = []
for i, (name, yaw, pitch, label) in enumerate(views):
view = render_view(texture, model, yaw, pitch)
path = destination/f"{name}.png"
view.save(path)
paths.append(str(path))
sheet.paste(view.convert("RGB"), (10+i*380, 103))
draw.text((190+i*380, 598), label, font=small, anchor="mm", fill="#526675")
sheet.save(destination/"preview.png")
return {"texture_sha256": hashlib.sha256(Path(png).read_bytes()).hexdigest(),
"model": model, "views": paths, "contact_sheet": str(destination/"preview.png"),
"renderer": "orthographic texture ray renderer; base and outer layers", "in_game_tested": False}
scripts/review.py"""Local reference comparison using fresh renders of the delivered skin PNG."""
import hashlib
import itertools
import math
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageOps
from design import validate_png
from render import bounds, camera, render_view
def _head_detail(view, model, yaw, pitch):
lo, hi = bounds("head", model, "outer")
points = np.array(list(itertools.product(*zip(lo, hi)))) - np.array([0,16,0])
right, up, _ = camera(yaw, pitch)
xs = view.width/2+(points@right)*12
ys = view.height/2-(points@up)*12
box = (max(0,math.floor(xs.min())-5),max(0,math.floor(ys.min())-5),
min(view.width,math.ceil(xs.max())+5),min(view.height,math.ceil(ys.max())+5))
return view.crop(box)
def reference_review(png, reference, model, destination, title="Reference review"):
png, reference, destination = Path(png), Path(reference), Path(destination)
result_path = destination/"reference-review.png"
if result_path.resolve() in (png.resolve(),reference.resolve()):
raise ValueError("Reference review output must differ from both source images")
check = validate_png(png,model)
if not check["ok"]:
raise ValueError("Invalid skin for reference review: " + "; ".join(check["errors"]))
with Image.open(reference) as source:
source.load()
original = ImageOps.exif_transpose(source).convert("RGBA")
with Image.open(png) as source:
texture = source.convert("RGBA")
# Render now, so a previous preview cannot be mistaken for the current skin.
front = render_view(texture,model)
angled = render_view(texture,model,-33,16)
small = render_view(texture,model,width=120,height=160,scale=4)
canvas = Image.new("RGB",(1400,880),"#eef1f4")
draw = ImageDraw.Draw(canvas)
heading = ImageFont.load_default(size=27)
label = ImageFont.load_default(size=16)
title = title if len(title)<70 else title[:67]+"..."
draw.text((25,22),title,fill="#1c2b38",font=heading)
draw.text((25,61),"Reference, full skin, enlarged head details and a small view",fill="#526675",font=label)
draw.text((25,108),"REFERENCE",fill="#526675",font=label)
fitted = ImageOps.contain(original,(525,690),Image.Resampling.LANCZOS)
canvas.paste(fitted,(25+(525-fitted.width)//2,145+(690-fitted.height)//2),fitted)
draw.text((740,140),"EXPORTED SKIN",anchor="mm",fill="#526675",font=label)
canvas.paste(front.convert("RGB"),(560,220))
for image, x, text in ((front,960,"FACE"),(angled,1175,"HAIR AND LAYERS")):
yaw,pitch=(0,0) if text=="FACE" else (-33,16)
detail=ImageOps.contain(_head_detail(image,model,yaw,pitch),(200,240),Image.Resampling.NEAREST)
draw.text((x+100,140),text,anchor="mm",fill="#526675",font=label)
canvas.paste(detail.convert("RGB"),(x+(200-detail.width)//2,180+(240-detail.height)//2))
draw.text((1165,504),"SMALL VIEW",anchor="mm",fill="#526675",font=label)
canvas.paste(small.convert("RGB"),(1105,545))
draw.text((25,852),"Compare distinguishing features, expression, hair shape and material shading.",fill="#526675",font=label)
destination.mkdir(parents=True,exist_ok=True)
canvas.save(result_path)
return {"comparison":str(result_path),"model":model,
"texture_sha256":hashlib.sha256(png.read_bytes()).hexdigest(),
"reference_sha256":hashlib.sha256(reference.read_bytes()).hexdigest(),
"visual_likeness_review":"pending", "in_game_tested":False}
scripts/selftest.py#!/usr/bin/env python3
"""Independent UV fixtures, rendering orientation, edit and packaging checks."""
import argparse
import copy
import io
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
import numpy as np
from PIL import Image
import bedrock
import design
import render
import review
import tiles
import uv
# Hand-specified atlas rectangles: top,bottom,right,front,left,back.
# These are deliberately independent of uv.layout and its net calculation.
CLASSIC = {
"head.base": [(8,0,8,8),(16,0,8,8),(0,8,8,8),(8,8,8,8),(16,8,8,8),(24,8,8,8)],
"head.outer": [(40,0,8,8),(48,0,8,8),(32,8,8,8),(40,8,8,8),(48,8,8,8),(56,8,8,8)],
"body.base": [(20,16,8,4),(28,16,8,4),(16,20,4,12),(20,20,8,12),(28,20,4,12),(32,20,8,12)],
"body.outer": [(20,32,8,4),(28,32,8,4),(16,36,4,12),(20,36,8,12),(28,36,4,12),(32,36,8,12)],
"right_arm.base": [(44,16,4,4),(48,16,4,4),(40,20,4,12),(44,20,4,12),(48,20,4,12),(52,20,4,12)],
"right_arm.outer": [(44,32,4,4),(48,32,4,4),(40,36,4,12),(44,36,4,12),(48,36,4,12),(52,36,4,12)],
"left_arm.base": [(36,48,4,4),(40,48,4,4),(32,52,4,12),(36,52,4,12),(40,52,4,12),(44,52,4,12)],
"left_arm.outer": [(52,48,4,4),(56,48,4,4),(48,52,4,12),(52,52,4,12),(56,52,4,12),(60,52,4,12)],
"right_leg.base": [(4,16,4,4),(8,16,4,4),(0,20,4,12),(4,20,4,12),(8,20,4,12),(12,20,4,12)],
"right_leg.outer": [(4,32,4,4),(8,32,4,4),(0,36,4,12),(4,36,4,12),(8,36,4,12),(12,36,4,12)],
"left_leg.base": [(20,48,4,4),(24,48,4,4),(16,52,4,12),(20,52,4,12),(24,52,4,12),(28,52,4,12)],
"left_leg.outer": [(4,48,4,4),(8,48,4,4),(0,52,4,12),(4,52,4,12),(8,52,4,12),(12,52,4,12)],
}
SLIM_ARMS = {
"right_arm.base": [(44,16,3,4),(47,16,3,4),(40,20,4,12),(44,20,3,12),(47,20,4,12),(51,20,3,12)],
"right_arm.outer": [(44,32,3,4),(47,32,3,4),(40,36,4,12),(44,36,3,12),(47,36,4,12),(51,36,3,12)],
"left_arm.base": [(36,48,3,4),(39,48,3,4),(32,52,4,12),(36,52,3,12),(39,52,4,12),(43,52,3,12)],
"left_arm.outer": [(52,48,3,4),(55,48,3,4),(48,52,4,12),(52,52,3,12),(55,52,4,12),(59,52,3,12)],
}
FACE_ORDER = ("top", "bottom", "right", "front", "left", "back")
CORNERS = [(255,41,63,255),(25,224,106,255),(48,107,255,255),(255,219,36,255)]
def fixture(model, only_head=False, outer=False):
atlas = Image.new("RGBA", (64,64))
table = {**CLASSIC, **(SLIM_ARMS if model == "slim" else {})}
for index, (part, rects) in enumerate(table.items()):
if only_head and part != "head.base":
continue
if part.endswith("outer") and not outer:
continue
for face, (x,y,w,h) in zip(FACE_ORDER, rects):
tile = Image.new("RGBA", (w,h), (50+index*13%180,85,125,255))
for point, ink in zip([(0,0),(w-1,0),(0,h-1),(w-1,h-1)], CORNERS):
tile.putpixel(point,ink)
atlas.paste(tile,(x,y))
return atlas
class SkinTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
def tearDown(self):
self.tmp.cleanup()
def test_independent_atlas_rectangles(self):
for model in ("classic","slim"):
table = {**CLASSIC, **(SLIM_ARMS if model == "slim" else {})}
expected = {f"{p}.{f}": tuple(r) for p, rs in table.items() for f,r in zip(FACE_ORDER,rs)}
actual = {k:(f.x,f.y,f.w,f.h) for k,f in uv.layout(model).items()}
self.assertEqual(expected,actual)
def test_masks_are_disjoint_and_complete(self):
for model,count in (("classic",1632),("slim",1568)):
mm=uv.masks(model)
a,b=np.array(mm["base"])>0,np.array(mm["outer"])>0
self.assertEqual(int(a.sum()),count)
self.assertEqual(int(b.sum()),count)
self.assertFalse(np.any(a&b))
self.assertTrue(np.array_equal(a|b,np.array(mm["used"])>0))
def test_six_face_corner_orientation(self):
# Expected screen corners are specified independently for six cardinal views.
# Bottom v=0 is at the bottom of its underside view, unlike the top view.
texture=fixture("classic",only_head=True)
for yaw,pitch,bottom in ((0,0,False),(180,0,False),(-90,0,False),(90,0,False),(0,90,False),(0,-90,True)):
view=render.render_view(texture,"classic",yaw,pitch,100,160,4,lighting=False,layers=("base",))
y0,y1=(66,94) if pitch else (18,46)
expected=CORNERS if not bottom else [CORNERS[2],CORNERS[3],CORNERS[0],CORNERS[1]]
for point,ink in zip([(36,y0),(64,y0),(36,y1),(64,y1)],expected):
self.assertEqual(view.getpixel(point),ink,(yaw,pitch,point))
def test_right_limb_appears_viewer_left(self):
data=design.fresh()
data["base"]["right_arm"]="#e53945"
data["base"]["left_arm"]="#39b65a"
for model in ("classic","slim"):
image=design.assemble(design.revise(data,model=model))
view=render.render_view(image,model,width=100,height=160,scale=4,lighting=False)
self.assertEqual(view.getpixel((26,72))[:3],(229,57,69))
self.assertEqual(view.getpixel((74,72))[:3],(57,182,90))
def test_outer_layer_is_visible_at_its_actual_location(self):
image=fixture("classic",only_head=True)
image.putpixel((43,11),(234,12,199,255))
view=render.render_view(image,"classic",width=100,height=160,scale=4,lighting=False)
self.assertEqual(view.getpixel((48,30))[:3],(234,12,199))
def test_base_alpha_hole_rejected(self):
image=design.assemble(design.fresh())
image.putpixel((8,8),(0,0,0,0))
self.assertFalse(design.validate_image(image,"classic")["ok"])
def test_reference_review_uses_current_png_and_preserves_sources(self):
import hashlib
data=design.fresh()
data["palette"]["foundation"]="#e23852"
png=self.root/"skin.png"
design.assemble(data).save(png)
reference=self.root/"reference.png"
Image.new("RGBA",(160,160),(10,170,70,255)).save(reference)
before=(png.read_bytes(),reference.read_bytes())
# A stale preview must never replace fresh texture rendering.
Image.new("RGB",(360,480),"purple").save(self.root/"front.png")
result=review.reference_review(png,reference,"classic",self.root)
self.assertEqual(result["texture_sha256"],hashlib.sha256(before[0]).hexdigest())
self.assertEqual(result["reference_sha256"],hashlib.sha256(before[1]).hexdigest())
self.assertEqual(result["visual_likeness_review"],"pending")
self.assertEqual(before,(png.read_bytes(),reference.read_bytes()))
with Image.open(result["comparison"]) as output:
self.assertEqual(output.getpixel((740,500)),(226,56,82))
self.assertEqual(output.getpixel((250,400)),(10,170,70))
def test_reference_review_protects_source_at_output_path(self):
png=self.root/"skin.png"
design.assemble(design.fresh()).save(png)
source=self.root/"reference-review.png"
Image.new("RGB",(20,20),"green").save(source)
original=source.read_bytes()
with self.assertRaises(ValueError):
review.reference_review(png,source,"classic",self.root)
self.assertEqual(source.read_bytes(),original)
def test_build_preserves_reference_when_output_names_overlap(self):
import skinmaker
original=self.root/"skin.png"
Image.new("RGB",(100,100),"green").save(original)
before=original.read_bytes()
with self.assertRaises(ValueError):
skinmaker.build(design.fresh(),self.root,reference=original)
self.assertEqual(original.read_bytes(),before)
self.assertFalse((self.root/"design.json").exists())
def test_slim_unused_arm_pixels_rejected(self):
image=design.assemble(design.fresh(model="slim"))
image.putpixel((54,20),(0,0,0,255))
self.assertFalse(design.validate_image(image,"slim")["ok"])
def test_custom_angle_cli_shows_requested_side_from_current_png(self):
import hashlib
import subprocess
import sys
png = self.root / "skin.png"
texture = fixture("classic")
texture.paste((255, 0, 0, 255), (16, 8, 24, 16)) # Wearer's left.
texture.paste((0, 255, 0, 255), (0, 8, 8, 16)) # Wearer's right.
texture.save(png)
before = png.read_bytes()
for yaw, expected in ((90, (232, 0, 0)), (-90, (0, 211, 0))):
output = self.root / str(yaw)
output.mkdir()
Image.new("RGB", (360, 480), "purple").save(output / "custom-view.png")
result = subprocess.run([sys.executable, str(Path(__file__).with_name("skinmaker.py")),
"preview", str(png), "--yaw", str(yaw), "--pitch", "0",
"--out", str(output)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
report = json.loads(result.stdout)
self.assertEqual(report["texture_sha256"], hashlib.sha256(before).hexdigest())
with Image.open(report["view"]) as view:
self.assertEqual(view.getpixel((180, 96))[:3], expected)
self.assertEqual(png.read_bytes(), before)
def test_custom_angle_rejects_invalid_angles_and_source_overwrite(self):
png = self.root / "custom-view.png"
fixture("classic").save(png)
before = png.read_bytes()
with self.assertRaises(ValueError):
render.angle_preview(png, "classic", self.root, 33, 16)
for yaw, pitch in ((float("nan"), 0), (0, 91), (0, float("inf"))):
with self.assertRaises(ValueError):
render.angle_preview(png, "classic", self.root / "invalid", yaw, pitch)
self.assertEqual(png.read_bytes(), before)
self.assertFalse((self.root / "invalid").exists())
def test_fractional_outer_alpha_rejected(self):
image=design.assemble(design.fresh())
image.putpixel((40,8),(60,70,80,125))
self.assertFalse(design.validate_image(image,"classic")["ok"])
def test_non_png_and_wrong_dimensions_rejected(self):
path=self.root/"wrong.png"
Image.new("RGBA",(64,32)).save(path)
self.assertFalse(design.validate_png(path,"classic")["ok"])
Image.new("RGB",(64,64)).save(path,format="BMP")
self.assertFalse(design.validate_png(path,"classic")["ok"])
def test_paint_rejects_out_of_bounds(self):
data=design.fresh()
data["components"]=[{"id":"bad","paint":[{"target":"head.base.front","op":"rect","box":[7,0,2,2],"color":"#ff0000"}]}]
with self.assertRaises(ValueError): design.assemble(data)
def test_misspelled_keys_rejected_instead_of_ignored(self):
for component in ({"id":"a","paints":[]},
{"id":"a","paint":[{"target":"head.base.front","op":"fill","colour":"#ff0000"}]},
{"id":"a","paint":[{"target":"head.base.front","op":"rect","box":[0,0,1,1],"color":"#ff0000","widht":2}]},
{"paint":[]}):
data=design.fresh()
data["components"]=[component]
with self.assertRaises(ValueError): design.assemble(data)
def test_transparent_base_paint_names_the_face(self):
data=design.fresh()
data["components"]=[{"id":"hole","paint":[{"target":"head.base.front","op":"points","points":[[1,1]],"color":"transparent"}]}]
with self.assertRaisesRegex(ValueError,r"hole, head\.base\.front"): design.assemble(data)
def test_design_symbols_are_shared_and_overridable(self):
data=design.fresh()
data["symbols"]={"A":"#ff0000","B":"#00ff00"}
data["components"]=[{"id":"art","paint":[{"target":"head.base.front","op":"pixels","rows":["AB"],"map":{"B":"#0000ff"}}]}]
image=design.assemble(data)
self.assertEqual(image.getpixel((8,8)),(255,0,0,255))
self.assertEqual(image.getpixel((9,8)),(0,0,255,255))
def test_legacy_opaque_hat_cleared_and_real_hat_kept(self):
image=fixture("classic",outer=True).crop((0,0,64,32))
image.paste((0,0,0,255),(32,0,64,16))
path=self.root/"legacy-solid-hat.png"
image.save(path)
out=design.assemble(design.import_skin(path))
self.assertEqual(out.getpixel((40,8))[3],0)
image.putpixel((33,0),(0,0,0,0))
image.save(path)
out=design.assemble(design.import_skin(path))
self.assertEqual(out.getpixel((40,8)),(0,0,0,255))
def test_diff_reports_face_local_changes(self):
before=self.root/"before.png"
after=self.root/"after.png"
image=design.assemble(design.fresh())
image.save(before)
image.putpixel((8+3,8+5),(1,2,3,255)) # head.base.front at face-local (3,5)
image.save(after)
report=tiles.difference(before,after,"classic",self.root/"diff")
self.assertEqual(report["changed_pixels"],1)
self.assertEqual(report["faces"],{"head.base.front":{"pixels":1,"box":[3,5,1,1]}})
self.assertTrue(Path(report["sheets"][0]).exists())
def test_face_sheets_cover_requested_parts(self):
png=self.root/"skin.png"
fixture("slim",outer=True).save(png)
result=tiles.face_sheets(png,"slim",self.root/"faces",("head","left_arm"))
self.assertEqual([Path(p).name for p in result["sheets"]],["faces-head.png","faces-left_arm.png"])
def test_preview_shows_both_three_quarter_sides(self):
data=design.fresh()
data["base"]["right_arm"]="#e53945"
data["base"]["left_arm"]="#39b65a"
png=self.root/"skin.png"
design.assemble(data).save(png)
render.previews(png,"classic",self.root)
for name,near,far in (("three-quarter",(229,57,69),(57,182,90)),("three-quarter-left",(57,182,90),(229,57,69))):
with Image.open(self.root/f"{name}.png") as view:
colors={view.getpixel((x,240))[:3] for x in range(360)}
# The arm nearest the camera is fully visible; exact shading varies by face.
self.assertTrue(any(abs(c[0]-near[0])<40 and abs(c[1]-near[1])<40 for c in colors),name)
def test_semantic_palette_revision_preserves_other_pixels(self):
data=design.fresh()
data["palette"]["jacket"]="#168c8b"
data["base"]["body"]="jacket"
before=np.array(design.assemble(data))
after=np.array(design.assemble(design.revise(data,{"jacket":"#b83543"})))
changed=np.any(before!=after,axis=2)
allowed=np.zeros((64,64),dtype=bool)
for x,y,w,h in CLASSIC["body.base"]: allowed[y:y+h,x:x+w]=True
self.assertTrue(np.array_equal(changed,allowed))
def test_model_conversion_preserves_non_arm_pixels(self):
before=fixture("classic",outer=True)
after=uv.convert_model(before,"classic","slim")
for key,rects in CLASSIC.items():
if "arm" not in key:
for x,y,w,h in rects:
self.assertEqual(before.crop((x,y,x+w,y+h)).tobytes(),after.crop((x,y,x+w,y+h)).tobytes())
self.assertTrue(design.validate_image(after,"slim")["ok"])
def test_build_is_deterministic(self):
data=design.fresh()
self.assertEqual(design.assemble(data).tobytes(),design.assemble(copy.deepcopy(data)).tobytes())
def test_import_roundtrip_both_models(self):
for model in ("classic","slim"):
image=fixture(model,outer=True)
path=self.root/f"{model}.png"
image.save(path)
data=design.import_skin(path)
self.assertEqual(data["model"],model)
self.assertEqual(image.tobytes(),design.assemble(data).tobytes())
def test_legacy_expansion_swaps_side_faces(self):
image=fixture("classic").crop((0,0,64,32))
path=self.root/"legacy.png"
image.save(path)
out=design.assemble(design.import_skin(path))
self.assertEqual(out.getpixel((20,52)),image.getpixel((7,20)))
self.assertEqual(out.getpixel((16,52)),image.getpixel((11,20)))
self.assertTrue(design.validate_image(out,"classic")["ok"])
def test_explicit_model_metadata_overrides_alpha_heuristic(self):
image=fixture("slim",outer=True)
# Some editors save opaque unused pixels, defeating alpha heuristics.
image.putpixel((54,20),(99,77,55,255))
path=self.root/"known-slim.png"
image.save(path)
data=design.import_skin(path,model="slim")
restored=design.assemble(data)
self.assertEqual(restored.getpixel((51,20)),image.getpixel((51,20)))
self.assertEqual(restored.getpixel((54,20))[3],0)
self.assertTrue(design.validate_image(restored,"slim")["ok"])
def _pack(self,model="classic"):
png=self.root/"skin.png"
fixture(model).save(png)
pack=self.root/"skin.mcpack"
self.assertTrue(bedrock.package(png,pack,"Test skin",model)["ok"])
return png,pack
def test_packs_match_png_geometry_and_unique_ids(self):
ids=[]
for model in ("classic","slim"):
png,pack=self._pack(model)
check=bedrock.validate_pack(pack,model,png)
self.assertTrue(check["ok"],check)
ids.append(check["uuid"])
self.assertEqual(len(set(ids)),2)
def _tampered(self,filename,change):
png,pack=self._pack()
with zipfile.ZipFile(pack) as archive: files={n:archive.read(n) for n in archive.namelist()}
value=json.loads(files[filename])
change(value)
files[filename]=json.dumps(value).encode()
with zipfile.ZipFile(pack,"w") as archive:
for n,b in files.items(): archive.writestr(n,b)
return bedrock.validate_pack(pack,"classic",png)
def test_duplicate_uuid_rejected(self):
result=self._tampered("manifest.json",lambda x:x["modules"][0].update(uuid=x["header"]["uuid"]))
self.assertFalse(result["ok"])
def test_paid_skin_rejected(self):
self.assertFalse(self._tampered("skins.json",lambda x:x["skins"][0].update(type="paid"))["ok"])
def test_wrong_geometry_rejected(self):
self.assertFalse(self._tampered("skins.json",lambda x:x["skins"][0].update(geometry="geometry.humanoid.customSlim"))["ok"])
def test_missing_texture_rejected(self):
self.assertFalse(self._tampered("skins.json",lambda x:x["skins"][0].update(texture="missing.png"))["ok"])
def test_localization_mismatch_rejected(self):
self.assertFalse(self._tampered("skins.json",lambda x:x.update(serialize_name="missing"))["ok"])
def test_pack_exact_png_mismatch_rejected(self):
png,pack=self._pack()
other=self.root/"other.png"
design.assemble(design.fresh()).save(other)
self.assertFalse(bedrock.validate_pack(pack,"classic",other)["ok"])
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out",required=True)
args=parser.parse_args()
out=Path(args.out)
out.mkdir(parents=True,exist_ok=True)
suite=unittest.defaultTestLoader.loadTestsFromTestCase(SkinTests)
result=unittest.TextTestRunner(verbosity=2).run(suite)
for model in ("classic","slim"):
image=fixture(model)
png=out/f"{model}-asymmetric-fixture.png"
image.save(png)
render.previews(png,model,out/model,f"{model.title()} UV orientation fixture")
report={"ok":result.wasSuccessful(),"tests":result.testsRun,"failures":len(result.failures),
"errors":len(result.errors),"in_game_test":"not performed",
"fixture":"independently specified atlas rectangles and four unequal corner colors"}
design.write(out/"selftest.json",report)
return 0 if result.wasSuccessful() else 1
if __name__=="__main__":
raise SystemExit(main())
scripts/skinmaker.py#!/usr/bin/env python3
"""Deterministic Minecraft skin assembly, previews, edits and Bedrock packs."""
import argparse
import hashlib
import json
import sys
from pathlib import Path
from PIL import Image
import design
import bedrock
import render
import review
import tiles
import uv
def import_instructions(model, export="both"):
sections = []
if export in ("both", "java"):
sections.append(f"Java: Minecraft Launcher > Java Edition > Skins > New Skin. Browse to skin.png, select {model.title()}, then Save & Use.")
if export in ("both", "bedrock"):
sections.append(f"Bedrock on Windows, Android or iOS: open skin.mcpack with Minecraft, then select the imported pack in Dressing Room > Classic Skins. If your device does not open the pack, use Classic Skins > Owned/Import > Choose New Skin and select skin.png, using the {'wide' if model == 'classic' else 'narrow'} arm thumbnail. Menu labels can vary by game version.")
sections.append("Xbox, PlayStation and Switch do not officially support importing custom skin files. Imported Bedrock skins do not automatically sync across devices. macOS uses Java Edition; there is no native Bedrock client for macOS.")
sections.append("These files have automated format checks and previews from the exported PNG. Actual in-game import has not been tested by this script.")
return "\n\n".join(sections) + "\n"
def build(data, destination, export="both", reference=None):
destination = Path(destination)
if reference:
protected = Path(reference).resolve()
generated = ("skin.png", "design.json", "front.png", "back.png", "three-quarter.png",
"three-quarter-left.png", "preview.png", "reference-review.png", "skin.mcpack", "IMPORT.txt", "validation.json")
if protected in { (destination/name).resolve() for name in generated }:
raise ValueError("Choose a separate output directory so the reference image stays unchanged")
# Detect unusable input before writing any output artifacts.
with Image.open(reference) as reference_source:
reference_source.verify()
image = design.assemble(data)
model = data.get("model", "classic")
check = design.validate_image(image, model)
if not check["ok"]:
raise ValueError("Skin validation failed: " + "; ".join(check["errors"]))
destination.mkdir(parents=True, exist_ok=True)
if export == "java" and (destination/"skin.mcpack").exists():
raise ValueError("Choose a fresh output directory for PNG-only delivery; this directory contains an earlier Bedrock pack")
png = destination/"skin.png"
image.save(png, format="PNG")
design.write(destination/"design.json", data)
preview = render.previews(png, model, destination, data.get("name", "Minecraft skin"))
report = {"texture": check, "texture_sha256": hashlib.sha256(png.read_bytes()).hexdigest(),
"previews": preview, "visual_inspection": "pending", "in_game_test": "not performed"}
if reference:
report["reference_review"] = review.reference_review(png,reference,model,destination,data.get("name","Reference review"))
if export in ("both", "bedrock"):
pack = destination/"skin.mcpack"
report["bedrock"] = bedrock.package(png, pack, data.get("name", "My Skin"), model)
if not report["bedrock"]["ok"]:
raise ValueError("Pack validation failed: " + "; ".join(report["bedrock"]["errors"]))
(destination/"IMPORT.txt").write_text(import_instructions(model, export), encoding="utf-8")
design.write(destination/"validation.json", report)
return {"ok": True, "directory": str(destination.resolve()), "model": model,
"skin": str(png.resolve()), "preview": str((destination/"preview.png").resolve()),
"reference_review": str((destination/"reference-review.png").resolve()) if reference else None,
"bedrock_pack": str((destination/"skin.mcpack").resolve()) if export != "java" else None}
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
init = sub.add_parser("init", help="Create editable blank design JSON")
init.add_argument("output")
init.add_argument("--name", default="My Skin")
init.add_argument("--model", choices=("classic", "slim"), default="classic")
make = sub.add_parser("build", help="Assemble, validate, preview and package a design")
make.add_argument("design")
make.add_argument("--out", required=True)
make.add_argument("--export", choices=("both", "java", "bedrock"), default="both")
make.add_argument("--reference", help="Create a comparison with the supplied reference image")
validate = sub.add_parser("validate", help="Check a PNG or mcpack")
validate.add_argument("input")
validate.add_argument("--model", choices=("classic", "slim"), default="classic")
validate.add_argument("--png", help="For pack checks, require exact bytes from this PNG")
preview = sub.add_parser("preview", help="Render the exported PNG, including outer layers")
preview.add_argument("png")
preview.add_argument("--model", choices=("classic", "slim"), default="classic")
preview.add_argument("--out", required=True)
preview.add_argument("--name", default="Minecraft skin")
preview.add_argument("--yaw", type=float, help="Render a single view at this horizontal angle in degrees")
preview.add_argument("--pitch", type=float, help="Render a single view at this vertical angle, from -90 to 90 degrees")
compare = sub.add_parser("review", help="Compare a reference with fresh renders of the actual PNG")
compare.add_argument("png")
compare.add_argument("--reference", required=True)
compare.add_argument("--model", choices=("classic","slim"), default="classic")
compare.add_argument("--out", required=True)
compare.add_argument("--name", default="Reference review")
pack = sub.add_parser("pack", help="Package a validated PNG as a free skin pack")
pack.add_argument("png")
pack.add_argument("--out", required=True)
pack.add_argument("--name", default="My Skin")
pack.add_argument("--model", choices=("classic", "slim"), default="classic")
edit = sub.add_parser("revise", help="Change palette colors or arm width without redrawing")
edit.add_argument("design")
edit.add_argument("--out", required=True, help="New design JSON path")
edit.add_argument("--set", action="append", default=[], metavar="KEY=#RRGGBB")
edit.add_argument("--model", choices=("classic", "slim"))
edit.add_argument("--name")
imp = sub.add_parser("import", help="Import a 64x64 or legacy 64x32 skin into editable JSON")
imp.add_argument("png")
imp.add_argument("--out", required=True)
imp.add_argument("--name")
imp.add_argument("--model", choices=("auto", "classic", "slim"), default="auto")
grid = sub.add_parser("faces", help="Enlarged, gridded face tiles with face-local coordinates")
grid.add_argument("png")
grid.add_argument("--model", choices=("classic", "slim"), default="classic")
grid.add_argument("--part", action="append", choices=uv.PARTS, help="Repeat for several parts; default all")
grid.add_argument("--out", required=True)
diff = sub.add_parser("diff", help="List changed pixels per face between two skin PNGs")
diff.add_argument("before")
diff.add_argument("after")
diff.add_argument("--model", choices=("classic", "slim"), default="classic")
diff.add_argument("--out", help="Also write face sheets with changed pixels outlined")
template = sub.add_parser("templates", help="Generate both models' UV guides and masks")
template.add_argument("--out", required=True)
args = parser.parse_args()
try:
if args.command == "init":
design.write(args.output, design.fresh(args.name, args.model))
result = {"ok": True, "design": args.output}
elif args.command == "build":
result = build(design.read(args.design), args.out, args.export, args.reference)
elif args.command == "validate":
result = bedrock.validate_pack(args.input, args.model, args.png) if Path(args.input).suffix.lower() == ".mcpack" else design.validate_png(args.input, args.model)
elif args.command == "preview":
result = design.validate_png(args.png, args.model)
if result["ok"]:
if args.yaw is not None or args.pitch is not None:
result.update(render.angle_preview(args.png, args.model, args.out,
args.yaw if args.yaw is not None else 0,
args.pitch if args.pitch is not None else 0))
else:
result.update(render.previews(args.png, args.model, args.out, args.name))
elif args.command == "pack":
result = bedrock.package(args.png, args.out, args.name, args.model)
elif args.command == "review":
result = {"ok":True,**review.reference_review(args.png,args.reference,args.model,args.out,args.name)}
elif args.command == "revise":
if any("=" not in item for item in args.set):
raise ValueError("--set expects KEY=#RRGGBB, for example --set 'jacket=#a43d47'")
palette = dict(item.split("=", 1) for item in args.set)
revised = design.revise(design.read(args.design), palette, args.model, args.name)
design.write(args.out, revised)
result = {"ok": True, "design": args.out}
elif args.command == "import":
data = design.import_skin(args.png, args.name, args.model)
design.write(args.out, data)
result = {"ok": True, "design": args.out, "model": data["model"], "notes": data["notes"]}
elif args.command == "faces":
result = tiles.face_sheets(args.png, args.model, args.out, args.part or uv.PARTS)
elif args.command == "diff":
result = tiles.difference(args.before, args.after, args.model, args.out)
elif args.command == "templates":
uv.templates(args.out)
result = {"ok": True, "templates": args.out}
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result.get("ok", True) else 1
except (ValueError, KeyError, TypeError, OSError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
scripts/tiles.py"""Enlarged, gridded face tiles and per-face pixel differences for exact edits."""
import hashlib
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from uv import PARTS, layout
# Unfolded around the body, so neighbouring tiles share an edge: right|front|left|back.
SHEET_FACES = ("right", "front", "left", "back", "top", "bottom")
CELL = 22
def _load(png):
with Image.open(png) as source:
texture = source.convert("RGBA")
if texture.size != (64, 64):
raise ValueError(f"{png}: expected a 64x64 skin PNG")
return texture
def _checker(w, h):
tile = Image.new("RGB", (w*CELL, h*CELL), "#ffffff")
draw = ImageDraw.Draw(tile)
half = CELL//2
for y in range(0, h*CELL, half):
for x in range(0, w*CELL, half):
if (x//half + y//half) % 2:
draw.rectangle((x, y, x+half-1, y+half-1), fill="#d9dde2")
return tile
def _face_image(texture, face, highlight=None):
"""One face at CELL pixels per texel, transparent texels shown on a checkerboard."""
crop = texture.crop(face.box).resize((face.w*CELL, face.h*CELL), Image.Resampling.NEAREST)
tile = _checker(face.w, face.h)
tile.paste(crop, (0, 0), crop)
draw = ImageDraw.Draw(tile)
for x in range(face.w+1):
draw.line((x*CELL, 0, x*CELL, face.h*CELL), fill="#7d8791")
for y in range(face.h+1):
draw.line((0, y*CELL, face.w*CELL, y*CELL), fill="#7d8791")
for x, y in highlight or ():
draw.rectangle((x*CELL, y*CELL, (x+1)*CELL, (y+1)*CELL), outline="#ff2bd6", width=3)
return tile
def part_sheet(texture, model, part, highlights=None):
faces = layout(model)
font = ImageFont.load_default(size=13)
title = ImageFont.load_default(size=20)
cols = [faces[f"{part}.base.{name}"] for name in SHEET_FACES]
col_w = [max(f.w*CELL+44, int(font.getlength(f"{part}.outer.{f.face} {f.w}x{f.h}"))+16) for f in cols]
row_h = max(f.h for f in cols)*CELL+52
sheet = Image.new("RGB", (sum(col_w)+30, 70+2*row_h), "#eef1f4")
draw = ImageDraw.Draw(sheet)
draw.text((20, 14), f"{part} / {model} / face-local x,y: (0,0) is each tile's upper-left",
font=title, fill="#1c2b38")
for row, layer in enumerate(("base", "outer")):
x0 = 20
y0 = 60+row*row_h
for name, width in zip(SHEET_FACES, col_w):
face = faces[f"{part}.{layer}.{name}"]
ox, oy = x0+30, y0+34
draw.text((x0, y0), f"{part}.{layer}.{name} {face.w}x{face.h}", font=font, fill="#1c2b38")
for x in range(face.w):
draw.text((ox+x*CELL+CELL//2, oy-4), str(x), font=font, anchor="mb", fill="#526675")
for y in range(face.h):
draw.text((ox-5, oy+y*CELL+CELL//2), str(y), font=font, anchor="rm", fill="#526675")
spots = (highlights or {}).get(face.key)
sheet.paste(_face_image(texture, face, spots), (ox, oy))
x0 += width
return sheet
def face_sheets(png, model, destination, parts=PARTS):
"""Write faces-<part>.png sheets for the requested parts of the exported texture."""
texture = _load(png)
destination = Path(destination)
destination.mkdir(parents=True, exist_ok=True)
written = []
for part in parts:
if part not in PARTS:
raise ValueError(f"Unknown part {part!r}; use one of {', '.join(PARTS)}")
path = destination/f"faces-{part}.png"
if path.resolve() == Path(png).resolve():
raise ValueError("Choose a separate output directory so the source skin stays unchanged")
part_sheet(texture, model, part).save(path)
written.append(str(path))
return {"ok": True, "model": model, "sheets": written,
"texture_sha256": hashlib.sha256(Path(png).read_bytes()).hexdigest()}
def difference(before_png, after_png, model, destination=None):
"""List changed texels per face; optionally draw them outlined on face sheets."""
before, after = _load(before_png), _load(after_png)
a, b = np.asarray(before), np.asarray(after)
# Fully transparent texels are equal whatever their hidden RGB values.
changed = np.any(a != b, axis=2) & ~((a[..., 3] == 0) & (b[..., 3] == 0))
faces = {}
highlights = {}
for face in layout(model).values():
region = changed[face.y:face.y+face.h, face.x:face.x+face.w]
if region.any():
ys, xs = np.nonzero(region)
spots = [(int(x), int(y)) for x, y in zip(xs, ys)]
highlights[face.key] = spots
faces[face.key] = {"pixels": len(spots),
"box": [int(xs.min()), int(ys.min()), int(xs.max()-xs.min()+1), int(ys.max()-ys.min()+1)]}
report = {"ok": True, "model": model, "changed_pixels": int(changed.sum()), "faces": faces}
if destination and faces:
destination = Path(destination)
destination.mkdir(parents=True, exist_ok=True)
parts = [p for p in PARTS if any(k.startswith(p+".") for k in faces)]
report["sheets"] = []
for part in parts:
path = destination/f"diff-{part}.png"
part_sheet(after, model, part, highlights).save(path)
report["sheets"].append(str(path))
return report
scripts/uv.py"""Minecraft 64x64 UV nets. Left/right always mean the wearer's side."""
from dataclasses import dataclass
from pathlib import Path
import json
from PIL import Image, ImageDraw
PARTS = ("head", "body", "right_arm", "left_arm", "right_leg", "left_leg")
FACES = ("top", "bottom", "right", "front", "left", "back")
ORIGINS = {
"head": ((0, 0), (32, 0)), "body": ((16, 16), (16, 32)),
"right_arm": ((40, 16), (40, 32)), "left_arm": ((32, 48), (48, 48)),
"right_leg": ((0, 16), (0, 32)), "left_leg": ((16, 48), (0, 48)),
}
@dataclass(frozen=True)
class Face:
part: str
layer: str
face: str
x: int
y: int
w: int
h: int
@property
def key(self):
return f"{self.part}.{self.layer}.{self.face}"
@property
def box(self):
return (self.x, self.y, self.x + self.w, self.y + self.h)
def dimensions(part, model):
if model not in ("classic", "slim"):
raise ValueError("model must be classic or slim")
if part == "head":
return (8, 8, 8)
return (8 if part == "body" else 3 if "arm" in part and model == "slim" else 4, 12, 4)
def layout(model="classic"):
result = {}
for part in PARTS:
w, h, d = dimensions(part, model)
for layer, (u, v) in zip(("base", "outer"), ORIGINS[part]):
boxes = ((u+d, v, w, d), (u+d+w, v, w, d),
(u, v+d, d, h), (u+d, v+d, w, h),
(u+d+w, v+d, d, h), (u+2*d+w, v+d, w, h))
for face, rect in zip(FACES, boxes):
item = Face(part, layer, face, *rect)
result[item.key] = item
return result
def masks(model):
result = {name: Image.new("L", (64, 64)) for name in ("base", "outer", "used")}
for f in layout(model).values():
result[f.layer].paste(255, f.box)
result["used"].paste(255, f.box)
return result
def convert_model(image, old, new):
"""Repack each face independently; never resize the complete texture atlas."""
if old == new:
return image.copy()
out = Image.new("RGBA", (64, 64))
before = layout(old)
for key, after in layout(new).items():
tile = image.crop(before[key].box)
if tile.size != (after.w, after.h):
tile = tile.resize((after.w, after.h), Image.Resampling.NEAREST)
out.paste(tile, after.box)
return out
def templates(destination):
destination = Path(destination)
destination.mkdir(parents=True, exist_ok=True)
colors = {"top": "#d6b95a", "bottom": "#947446", "right": "#5c91b4",
"front": "#60ac96", "left": "#aa7aba", "back": "#d27d6d"}
for model in ("classic", "slim"):
for name, mask in masks(model).items():
mask.save(destination / f"{model}-{name}-mask.png")
atlas = Image.new("RGBA", (64, 64))
data = {}
for f in layout(model).values():
tile = Image.new("RGBA", (f.w, f.h), colors[f.face])
# Two unequal corner marks make every face's orientation visible.
tile.putpixel((0, 0), (255, 255, 255, 255))
tile.putpixel((f.w - 1, 0), (25, 25, 25, 255))
atlas.paste(tile, f.box)
data[f.key] = [f.x, f.y, f.w, f.h]
atlas.save(destination / f"{model}-uv-template.png")
(destination / f"{model}-uv.json").write_text(json.dumps(data, indent=2) + "\n")
SKILL.md--- name: minecraft-skin-maker description: Create or revise importable Minecraft player skins from images, existing skin PNGs, text descriptions, or both. Delivers the skin PNG, previews rendered from the exported texture and a Bedrock skin pack, with technical choices handled automatically. --- # Minecraft Skin Maker Turn the user's idea into a finished skin. Assume no Minecraft skin-making knowledge. Produce the first result without a setup questionnaire, options menu or approval checkpoint. Edition, arm width and layers are internal decisions. Honor volunteered preferences; otherwise use Classic arms, a 64×64 PNG and a companion `.mcpack`. Preserve a reliably known existing model. Ask only when the input is genuinely unusable, such as an inaccessible image with no description. ## Agent requirements Use an agent with file read/write access, Python execution and image inspection for references and exported previews. Read supporting files relative to this skill's directory and use the host's available image-viewing and shell tools. The optional `agents/openai.yaml` file supplies Codex interface metadata; the workflow and scripts run independently of it. If visual inspection is unavailable, state that limitation and leave the visual review incomplete. ## Create 1. For an image, inspect the actual attachment using the available image-viewing tool. If it is an existing skin atlas, start with the importer below to preserve its design and model. For text, interpret the description in its language. Read [likeness-and-pixel-art.md](references/likeness-and-pixel-art.md) when translating a reference or improving visual quality. Save a short `reference_brief` in the design: distinguishing features, their left/right orientation, expression, materials and assumptions for unseen areas. Keep these decisions internal and explain only useful, significant assumptions after delivery. 2. Read [design-format.md](references/design-format.md). Write editable design JSON with named palette entries and independent components. Build a design specific to this request; arbitrary face-local pixel patterns support humans, animals, robots, armor and clothing. `assets/blank-design.json` and the UV maps/masks are reusable coordinate aids. `assets/examples/` contains two complete designs to illustrate component organization; design each new character from its own input. 3. Resolve the head's hairline, eyes and expression before adding outfit details. Use contiguous shading that describes locks, folds or surface form. Keep broad areas of smooth cloth quiet. Use outer layers selectively for overlapping hair, collars, cords and cuffs within the standard player model. Align seams and bands across faces. The wearer's right is the viewer's left in a front view. Keep every used base pixel opaque and unused pixels transparent. Assemble and export with the helper below. 4. For a local reference image, pass it to `build --reference` and open `reference-review.png`; compare the original, enlarged head details and small full-body view. If the usable reference is visible only in the conversation, compare it directly with the rendered views. Also open `preview.png` for front, back and both three-quarter views. Follow the visual questions in the likeness guide, correct concrete mismatches, then rebuild and recheck. These previews sample `skin.png` directly. Inspect another angle when needed. To place or fix exact pixels, run `faces` and read the gridded tile of the face you are editing. Record the actual final inspection and texture hash in `visual-inspection.md`; passing format checks alone does not establish likeness. 5. Deliver the skin and preview first, then the Bedrock pack and short import instructions in the user's language. Keep technical explanations brief. State the required Classic or Slim selection as an import instruction after delivery. Keep `design.json` for revisions and provide it when useful. Record actual visual inspection separately from the automated report. Optional image generation can help explore a concept or supply a reference. Final placement, PNG export and validation stay deterministic. A generated character illustration is not an exported-skin preview. The agent supplies interpretation and pixel-design decisions through its normal model access; the helpers require no separate image-generation API account. ## Run the helpers Use Python 3.9+ with Pillow and NumPy; the Python 3.9 built into macOS works. Prefer an already available interpreter that imports both. On first use, if needed, create a local environment in the skill directory and install `requirements.txt`; handle this automatically within normal tool permissions. The following examples use a POSIX shell; adapt the syntax to the host. On Windows, a virtual environment's interpreter is `.venv\Scripts\python.exe`. `SKILL_DIR` means the absolute directory containing this file: ```sh python3 -m venv "$SKILL_DIR/.venv" "$SKILL_DIR/.venv/bin/python" -m pip install -r "$SKILL_DIR/requirements.txt" ``` Set `PY` to that interpreter (or the working bundled Python); keep all generated user artifacts in the current task's output directory. Exact commands: ```sh "$PY" "$SKILL_DIR/scripts/skinmaker.py" init design.json --name "My character" "$PY" "$SKILL_DIR/scripts/skinmaker.py" build design.json --out outputs/my-character "$PY" "$SKILL_DIR/scripts/skinmaker.py" build design.json --reference reference.png --out outputs/my-character "$PY" "$SKILL_DIR/scripts/skinmaker.py" validate outputs/my-character/skin.png --model classic "$PY" "$SKILL_DIR/scripts/skinmaker.py" validate outputs/my-character/skin.mcpack --model classic --png outputs/my-character/skin.png ``` `build` defaults to both exports and writes PNG, mcpack, four individual views, a preview sheet, editable JSON, import notes and validation evidence. `--reference` also creates a local comparison sheet and records both source hashes; it leaves likeness review pending until the agent inspects it. Use `--export java` for an explicit PNG-only request; `--export bedrock` retains the main PNG and includes the pack. It validates size, masks, base opacity, alpha, references, geometry, UUIDs, localization and ZIP contents. A zero exit status means automated format checks passed. ## Revise or import Read the previous design. Edit only the requested palette keys/components; write a new version. Preserve unrelated features and check the resulting pixel differences with `diff`. Examples: ```sh "$PY" "$SKILL_DIR/scripts/skinmaker.py" revise design.json --set 'jacket=#a43d47' --set 'jacket_shadow=#6f2933' --out design-red.json "$PY" "$SKILL_DIR/scripts/skinmaker.py" revise design.json --model slim --out design-slim.json "$PY" "$SKILL_DIR/scripts/skinmaker.py" build design-slim.json --out outputs/my-character-slim "$PY" "$SKILL_DIR/scripts/skinmaker.py" import existing-skin.png --out imported-design.json "$PY" "$SKILL_DIR/scripts/skinmaker.py" preview skin.png --model slim --out outputs/preview "$PY" "$SKILL_DIR/scripts/skinmaker.py" preview skin.png --model classic --yaw 33 --pitch 16 --out outputs/opposite-view "$PY" "$SKILL_DIR/scripts/skinmaker.py" review skin.png --reference reference.png --model classic --out outputs/review "$PY" "$SKILL_DIR/scripts/skinmaker.py" pack skin.png --model slim --name "My character" --out skin.mcpack "$PY" "$SKILL_DIR/scripts/skinmaker.py" faces skin.png --model classic --part head --out outputs/faces "$PY" "$SKILL_DIR/scripts/skinmaker.py" diff old/skin.png new/skin.png --model classic --out outputs/diff ``` `faces` writes one enlarged sheet per part with every base and outer face, a pixel grid and the face-local x,y numbers used in design operations; transparent pixels show as a checkerboard. After any revision, run `diff` on the previous and new `skin.png`: it lists changed pixels per face with face-local bounding boxes, and `--out` outlines them on face sheets. Changes outside the requested feature mean the edit leaked; fix them before delivery. The importer accepts modern 64×64 and legacy 64×32 PNGs, preserves pixel data in a portable embedded source, and reports compatibility repairs. Use `import --model slim` or `--model classic` when reliable source metadata identifies the model. The model converter repacks/resamples arm faces while preserving all other pixels. Review narrow motifs after width changes. For an extra inspection angle, supplying `--yaw` or `--pitch` writes one `custom-view.png`; the omitted angle defaults to zero. Yaw `33` shows the opposite side from the standard three-quarter view. Yaw `90` shows the wearer's left side and `-90` the right; positive pitch looks down from above. Keep these choices internal. ## Compatibility and evidence Read [formats-and-import.md](references/formats-and-import.md) for packaging and device-specific delivery. Official guidance was checked on 2026-09-26; verify current official sources when platform/version uncertainty matters. Supported output is a standard 64×64 player skin. Convert impossible shapes into recognizable texture/outer-layer details and mention material compromises. Custom 3D geometry, functional capes, high-resolution output and automatic Minecraft account updates are outside this helper. Windows/mobile Bedrock imports and Java imports have different steps. Consoles do not officially import arbitrary custom skin files. Match any known device; otherwise give concise Java and Windows/mobile Bedrock instructions plus the console limitation. A pack export alone is not proof of device import. For maintenance, run `"$PY" "$SKILL_DIR/scripts/selftest.py" --out work/skin-checks`. Regenerate masks/templates with `skinmaker.py templates --out <directory>`. Distinguish automated checks, actual preview inspection and in-game testing. Claim in-game testing only after performing it. The helper leaves visual inspection pending and in-game testing unperformed until independently recorded.