@energy8platform/golem 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +18 -0
  2. package/dist/editor.css +3 -4
  3. package/dist/editor.js +177 -75
  4. package/dist/lib/cli.js +302 -126
  5. package/dist/lib/cli.js.map +4 -4
  6. package/dist/lib/e8/agent.d.ts +35 -0
  7. package/dist/lib/e8/host.d.ts +22 -0
  8. package/dist/lib/e8/node.d.ts +66 -0
  9. package/dist/lib/e8/runtime.d.ts +10 -0
  10. package/dist/lib/e8/schema.d.ts +3 -0
  11. package/dist/lib/e8/types.d.ts +80 -0
  12. package/dist/lib/e8-agent.js +6781 -0
  13. package/dist/lib/e8-agent.js.map +7 -0
  14. package/dist/lib/e8-client.js +116 -0
  15. package/dist/lib/e8-host.js +6929 -0
  16. package/dist/lib/e8-host.js.map +7 -0
  17. package/dist/lib/e8-runtime.js +2024 -0
  18. package/dist/lib/e8-runtime.js.map +7 -0
  19. package/dist/lib/e8-schema.js +7 -0
  20. package/dist/lib/e8-schema.js.map +7 -0
  21. package/dist/lib/editor/api.d.ts +1 -0
  22. package/dist/lib/editor/embed.d.ts +9 -0
  23. package/dist/lib/editor/server.d.ts +23 -1
  24. package/dist/lib/editor/store.d.ts +107 -0
  25. package/dist/lib/editor/timeline.d.ts +15 -0
  26. package/dist/lib/editor-entry.d.ts +1 -1
  27. package/dist/lib/editor-entry.js +303 -126
  28. package/dist/lib/editor-entry.js.map +4 -4
  29. package/dist/lib/harness.js +69 -14
  30. package/dist/lib/interactive-editor.js.map +1 -1
  31. package/dist/lib/rig-anim.d.ts +2 -0
  32. package/dist/lib/rig-control-qa.d.ts +27 -0
  33. package/dist/lib/rig-format.d.ts +989 -0
  34. package/dist/lib/rig-import-layers.d.ts +2 -0
  35. package/dist/lib/rig-version.d.ts +12 -0
  36. package/dist/lib/runtime.js +69 -14
  37. package/dist/lib/runtime.js.map +3 -3
  38. package/dist/lib/spine-import.d.ts +4 -0
  39. package/dist/lib/tool-schema.d.ts +3 -0
  40. package/dist/lib/tools.d.ts +2 -0
  41. package/dist/lib/tools.js +257 -73
  42. package/dist/lib/tools.js.map +4 -4
  43. package/editor.html +2 -2
  44. package/package.json +38 -3
  45. package/skills/e8-golem/SKILL.md +71 -0
  46. package/skills/golem-symbol-animation/SKILL.md +87 -0
  47. package/skills/golem-symbol-animation/agents/openai.yaml +4 -0
  48. package/skills/golem-symbol-animation/references/facial-controls.md +48 -0
  49. package/skills/golem-symbol-animation/references/game-engine-integration.md +133 -0
  50. package/skills/golem-symbol-animation/references/golem-authoring.md +138 -0
  51. package/skills/golem-symbol-animation/references/interactive-editor.md +17 -0
  52. package/skills/golem-symbol-animation/references/motion-craft.md +113 -0
  53. package/skills/golem-symbol-animation/references/packed-delivery.md +88 -0
  54. package/skills/golem-symbol-animation/references/quantitative-qa.md +106 -0
  55. package/skills/golem-symbol-animation/references/spine-rive-study.md +87 -0
  56. package/skills/golem-symbol-animation/scripts/atlas_parts.py +87 -0
  57. package/skills/golem-symbol-animation/scripts/check_package.py +71 -0
  58. package/skills/golem-symbol-animation/scripts/image_gates.py +143 -0
  59. package/skills/golem-symbol-cutting/SKILL.md +70 -0
  60. package/skills/golem-symbol-cutting/agents/openai.yaml +4 -0
  61. package/skills/golem-symbol-cutting/assets/h1/h1.atlas.png +0 -0
  62. package/skills/golem-symbol-cutting/assets/h1/h1.png +0 -0
  63. package/skills/golem-symbol-cutting/references/cutting-workflow.md +92 -0
  64. package/skills/golem-symbol-cutting/references/facial-layers.md +44 -0
  65. package/skills/golem-symbol-cutting/references/generation-workflow.md +109 -0
  66. package/skills/golem-symbol-cutting/references/golem-handoff.md +62 -0
  67. package/skills/golem-symbol-cutting/references/h1-example.md +46 -0
  68. package/skills/golem-symbol-cutting/references/hybrid-workflow.md +119 -0
  69. package/skills/golem-symbol-cutting/references/registration-and-motion.md +89 -0
  70. package/skills/golem-symbol-cutting/references/spine-rive-construction.md +73 -0
  71. package/skills/golem-symbol-cutting/scripts/atlas_parts.py +87 -0
  72. package/skills/golem-symbol-cutting/scripts/hybrid_parts.py +279 -0
@@ -0,0 +1,119 @@
1
+ # Approved artwork with generated hidden surfaces
2
+
3
+ Use this route when identity, silhouette, expression and visible ink must match approved art.
4
+ A complete generated atlas is a different brief. Parts may mix approved visible pixels,
5
+ generated hidden continuations, and explicitly redrawn props; record this per part.
6
+
7
+ ## Partition by ownership, not rectangles
8
+
9
+ Measure the original canvas. Mark semantic paint regions using seeds and polygons along
10
+ ink. Order layers back to front before cutting: the shared line travels with the foreground
11
+ part. Give a thick contour enough `inkPx` to retain its whole stroke; inspect both sides of
12
+ that contour in motion. A polygon may split one unbroken paint region (`raw`, optionally
13
+ `within` a prior owner). Use `minus` to correct the claim locally.
14
+
15
+ Run `scripts/hybrid_parts.py partition plan.json masks/`. Example plan (illustrative coordinates):
16
+
17
+ ```json
18
+ {
19
+ "source": "approved.png",
20
+ "ink": 40,
21
+ "layers": [
22
+ {"id": "coat", "seeds": [[110, 160]], "inkPx": 3},
23
+ {"id": "sleeve", "seeds": [[175, 150]], "inkPx": 4}
24
+ ]
25
+ }
26
+ ```
27
+
28
+ The script produces one ownership mask per part and a report of unassigned pixels, areas
29
+ and island counts. Every nonzero-alpha source pixel must have one owner. It assigns nearby
30
+ unclaimed ink deterministically; this is not semantic recognition. Inspect the result and
31
+ correct seeds/polygons when another feature gets assigned to the wrong part.
32
+
33
+ Do not automatically give all small islands to a neighbour: eyelashes, fang tips and sparks
34
+ can be meaningful. Transfer a confirmed stray through explicit masks, retaining the source.
35
+ If the mesh tracer would omit an intentional island, use `rig_mesh_from_image`
36
+ `minIslandRatio: 0` or a separate registered attachment; do not alter the approved drawing
37
+ just to satisfy a tracer threshold.
38
+
39
+ ## Repair hidden anatomy and ragged props
40
+
41
+ Define each part's complete silhouette separately from its visible ownership. The repair
42
+ region is the complete shape hidden by foreground layers, with real overlap through the
43
+ planned joint range. It is not an arbitrary dilation into neighbouring anatomy.
44
+
45
+ `hybrid_parts.py guide approved.png own.png hidden.png guide.png` preserves the owned
46
+ pixels and marks the repair in magenta. The guide is an authoring aid; use the approved art
47
+ as an additional style reference. Prompt templates and known failure modes are in
48
+ [generation-workflow.md](generation-workflow.md). Explicitly say that the removed occluder
49
+ must not be reconstructed. Judge scale and colour after generation, not from the prompt.
50
+
51
+ Keep stable, non-collinear landmarks away from the changed region (two distinct points
52
+ suffice for a similarity fit; three or more expose inconsistency). For example:
53
+
54
+ ```json
55
+ {"source": [[70,90],[160,90],[100,180]], "generated": [[74,92],[168,92],[105,186]]}
56
+ ```
57
+
58
+ Run `hybrid_parts.py extract approved.png generated.png hidden.png own.png landmarks.json fill.png`.
59
+ Optional `--background-mask`, `--tone-mask`, `--ink-mask`, `--feather` and `--max-fit-error`
60
+ control the ordered cleanup. Masks use source-canvas coordinates after fitting:
61
+
62
+ 1. Fit scale/rotation/translation; fail if the landmark residual exceeds the declared limit.
63
+ 2. Clear only reviewed background pixels. White skin highlights and eye whites remain art.
64
+ 3. Measure tone correction in unchanged opaque host material, never neighbouring skin/fabric.
65
+ 4. Blend the repair boundary toward the nearest owned colour, preserving its alpha.
66
+ 5. Replace a pale outline fringe only in a reviewed ink mask using the host's ink.
67
+
68
+ The final repair is clipped to the explicit region and fails on remaining guide magenta.
69
+ A fit report records scale, angle, residual and colour shift. This does not establish that the
70
+ painted hidden anatomy is correct: inspect the full output and exposed motion frames.
71
+
72
+ For a ragged prop, generate the entire object and register it with the same landmarks.
73
+ Declare it as `replacement` in assembly, with its approved contour change recorded. A rigid
74
+ camera housing and flexible strap usually need separate controls/weights. A closed eyelid
75
+ can be an authored stroke or source-derived variant if the art supports that; do not regenerate
76
+ a whole head solely to blink. Every variant must retain shared facial landmarks.
77
+
78
+ ## Assemble without revealing backing
79
+
80
+ Run `hybrid_parts.py assemble assembly.json work/`. Example part:
81
+
82
+ ```json
83
+ {
84
+ "source": "approved.png",
85
+ "parts": [
86
+ {"id":"coat", "own":"masks/coat.png", "shape":"shapes/coat.png",
87
+ "fill":"fills/coat.png", "joint":[120,180], "parent":"root", "underlap":2},
88
+ {"id":"sleeve", "own":"masks/sleeve.png", "shape":"shapes/sleeve.png",
89
+ "joint":[170,120], "parent":"coat", "underlap":2}
90
+ ]
91
+ }
92
+ ```
93
+
94
+ `parts` are back to front. Canvas dimensions come from the source, not fixed character
95
+ constants. The script writes trimmed RGBA layers, `layers/layers.json`, debug fill masks,
96
+ an assembled PNG and provenance. The import document uses the source canvas coordinates;
97
+ resize/offset once in the rig hierarchy if the game canvas differs.
98
+ Debug masks include generated fills, deterministic underlap and all backing pixels, so the
99
+ rest-pose visibility check measures every hidden repair rather than only generator output.
100
+
101
+ Underlap uses the part's own colour, stays inside its complete silhouette, and lies beneath
102
+ fully opaque foreground pixels. Generated fills sit deeper than the antialiased edge. Do not
103
+ copy the front layer's ink into the backing: it becomes a ghost contour when the front moves.
104
+ A static underpainting is optional and must remain well inside the silhouette, composed only
105
+ from declared stable surfaces. It must not contain a rest copy of a moving leg, tail or scarf.
106
+ Prefer local repair where a static underpainting would conceal a legitimate changing gap.
107
+ For a justified static backing, the assembly plan accepts
108
+ `"backing": {"id":"underpaint","parts":["coat"],"inset":12,"joint":[120,180],"bone":"root"}`.
109
+ The inset is in source pixels and must be positive; choose it from the motion clearance,
110
+ then inspect inward swings for exposed ghost surfaces.
111
+
112
+ Render the normal and magenta-debug assembly at rest and at joint extrema. Generated fill
113
+ pixels should be invisible in approved rest art, except explicitly reviewed replacement
114
+ regions. Test the actual rig, including semitransparent edges and texture resampling; an
115
+ ownership partition alone does not prove a clean composite.
116
+
117
+ Pass placements and provenance directly into animation. See
118
+ [quantitative-qa.md](../../golem-symbol-animation/references/quantitative-qa.md) for current
119
+ capture verification, reference holes, exclusion limits and negative tests.
@@ -0,0 +1,89 @@
1
+ # Register features before accepting motion
2
+
3
+ Use when generating facial parts, adding expression variants/FX, or fixing size,
4
+ placement and overlap. The acceptance target is the composed character in motion.
5
+
6
+ ## Establish a measured reference
7
+
8
+ 1. Inspect the supplied setup art and existing action references before generating.
9
+ For a spritesheet, identify its cell dimensions and the relevant expression
10
+ frames; sheet packing alone does not establish timing or action semantics.
11
+ 2. Record the reference-to-document transform using stable landmarks: eye centers,
12
+ nose base, mouth corners/upper lip, chin, neck contact and clothing anchors as
13
+ relevant. Distinguish reference pixels, generated pixels, document pixels and
14
+ rendered cell pixels. Atlas dimensions are not a scale reference.
15
+ 3. For each variant record its intended width relative to the face, upper-lip or
16
+ other fixed anchor, center/baseline offset, and allowed contour changes. Use the
17
+ matching reference frame when available. Without a matching frame, use accepted
18
+ neighboring states and the brief, and label the choice as authored. Do not claim
19
+ to have measured an expression that the reference does not contain.
20
+
21
+ A crop's opaque bbox is useful for detecting padding, but teeth height, cavity
22
+ shape and painted cheek content affect perceived size. Equal bbox dimensions or
23
+ centers do not guarantee equal mouth size or vertical registration. A common scale
24
+ factor works only if the variants already share verified registration; independent
25
+ generations must each be fitted to the same landmark system. Prefer uniform scaling
26
+ for intact parts; inspect any nonuniform fitting for unintended distortion.
27
+
28
+ For a layer containing paired features, measure and register each feature separately.
29
+ Shrinking the pair's bounding box also shrinks the distance between the features;
30
+ small replacement pupils or symbols should normally keep the accepted eye spacing.
31
+ Use independent atlas regions/attachments or preserve their document-space offsets
32
+ on a shared canvas. Match each feature's anchor, not only the pair's centre.
33
+
34
+ ## Construct local changes and actual occluders
35
+
36
+ - A mouth-only variant contains the intended line/lips/cavity/teeth/tongue, not
37
+ unrelated cheeks, nose, beard or a new face silhouette. Specify these exclusions
38
+ in generation prompts and inspect the result. Remove the original mouth from the
39
+ base and restore its backing. Reject or regenerate oversized semantic patches
40
+ instead of merely shrinking their unwanted face pixels.
41
+ - Keep the accepted head silhouette and stable landmarks. Full-head replacements
42
+ are appropriate for a required view or global expression change; for a local
43
+ mouth/blink, prefer local attachments or deformation. A shared generation canvas
44
+ is not proof that independently generated heads match.
45
+ - Define draw order for the actual overlap, for example back hood/torso → neck →
46
+ front collar → head/beard → mouth/FX. This is an anatomy-dependent example, not a
47
+ universal hierarchy. Generate a front collar when required, not a torso crop
48
+ masquerading as one. Check that it covers the neck without cutting the chin or
49
+ beard through the planned rotations and translations.
50
+ - Register eye glow to the final fitted eye locations, and horn highlights to the
51
+ final horn. Parent them to the corresponding control. Recompute their local
52
+ placement after changing the base or expression registration. Inspect every
53
+ state where the effect is visible; one aligned setup does not prove the swaps.
54
+
55
+ ## Account for articulation and the composed transform
56
+
57
+ Fit features after accounting for attachment size/center, bone hierarchy and keyed
58
+ scale/translation/deformation. A PNG that fits the head at setup can still spill
59
+ outside it during an action. Unless the brief calls for expansion, keep mouth
60
+ corners within the intended cheek contour and align the stable upper-lip/nose
61
+ relationship; increasing openness need not increase outer width or move the whole
62
+ mouth upward.
63
+
64
+ When the intended performance opens the jaw, prepare continuous lower-face/chin
65
+ art and a mouth cavity that cover the motion. In animation, use a suitable mesh or
66
+ jaw control with a smooth influence into the lower face. Keep nose/upper-face
67
+ landmarks anchored unless their movement is deliberate. Coordinate the mouth with
68
+ the moving surface and check the whole opening/closing path, not only its peak.
69
+ Mesh topology and vertex counts do not themselves establish correct articulation.
70
+
71
+ ## Review a complete correction
72
+
73
+ For each affected action, render a comparison using the same camera, background
74
+ and scale, with setup, every expression, both sides of each switch and extremes.
75
+ Include a face/joint closeup and playback at actual cell size and speed. Check:
76
+
77
+ - silhouette/proportions and stable landmarks against the reference;
78
+ - all variants, including the largest mouth, and simultaneous FX;
79
+ - mouth backing, neck/collar coverage and absence of duplicate outlines;
80
+ - interpolation, mesh folds, jaw closing, loop seams and return to idle;
81
+ - whether idle and the intended accent are perceptible in normal playback.
82
+
83
+ After a user reports a defect, turn it into an observable criterion, for example
84
+ “upper lip stays below the nose and mouth corners remain inside both cheeks during
85
+ all three states.” Preserve accepted parts and correct the failed relationship.
86
+ If the same defect survives a correction, revisit the reference transform,
87
+ landmarks, runtime transforms or semantic part boundary before trying another
88
+ arbitrary percentage. Do not report “matches exactly” from a bbox measurement or
89
+ “fixed” from a build/rig checker. Describe the visual evidence and remaining limits.
@@ -0,0 +1,73 @@
1
+ # Construction lessons from Spine and Rive
2
+
3
+ Use this reference for a new character, large joint motion, turns, reuse of a public rig,
4
+ or a decision between cuts and meshes. These are construction choices, not a fixed recipe.
5
+ The examples below were inspected on 2026-10-05; distinguish source-file measurements,
6
+ runtime observation and an interpretation of a finished image.
7
+
8
+ ## Obtain evidence before cutting
9
+
10
+ A Spine reference is useful when its skeleton JSON, text atlas and all page images are
11
+ available together. Keep the exporter version, selected skin, animation names and source
12
+ URL. A `.spine` project contains authoring information; a rendered GIF only shows the
13
+ result. For Rive, preserve the `.riv`, artboard and named timeline/state machine. Loading
14
+ a `.riv` in a runtime reveals playback and exposed inputs; it does not prove recovery of
15
+ the editor's paths, bone weights or original layered artwork. Do not describe observation
16
+ of a finished animation as measurement of its topology.
17
+
18
+ Public availability is not permission to redistribute artwork. Record its actual license
19
+ or reuse terms with the source. Use examples to learn mechanisms; keep downloaded source
20
+ art outside a production delivery unless its use is authorized.
21
+
22
+ ## Choose the smallest construction that carries the action
23
+
24
+ | Example / source | Construction lesson | Application to cutting |
25
+ |---|---|---|
26
+ | [Spine Raptor](https://esotericsoftware.com/spine-examples-raptor) | A continuous weighted surface spans several bones; independent controls preserve contacts | A thigh need not become one PNG per bone. Keep the joint's painted continuation and blend only where it should bend |
27
+ | [Spine Stretchyman](https://esotericsoftware.com/spine-examples-stretchyman) | IK controls endpoints while paths shape flexible limbs | Prepare a continuous limb strip with stable ends and inspect its most compressed curve before committing to a mesh |
28
+ | [Spine Spineboy](https://esotericsoftware.com/spine-examples-spineboy) | Rigid parts, deformation, attachment swaps and clipping coexist | Keep rigid hands/props rigid, prepare local swaps, and draw the complete masked object rather than cutting the currently visible fragment |
29
+ | [Spine Mix and Match](https://esotericsoftware.com/spine-examples-mix-and-match) | Appearance variants share controls and placeholders | Register variants to joints and semantic landmarks; do not assemble arbitrary first attachments from different skins |
30
+ | [Rive bones](https://rive.app/docs/editor/manipulating-shapes/bones) | Parenting moves a whole element; binding deforms raster vertices or vector points/handles | Choose rigid parenting for shoes/hands, weighted deformation for sleeves; vector curves are not a raster atlas to crop |
31
+ | [Rive meshes](https://rive.app/docs/editor/manipulating-shapes/meshes) | A raster image can retain its mesh while its image changes | Reuse only when dimensions, landmarks and intended deformation correspond; shared topology does not guarantee matching artwork |
32
+ | [Rive Solos](https://rive.app/docs/editor/manipulating-shapes/solos) | Mutually exclusive drawings occupy one logical control | Place eyelid, mouth or grip alternatives in one swap slot when they replace each other; isolate simultaneously visible teeth/tongue separately |
33
+
34
+ For every required extreme, record: which edge becomes visible, which feature is rigid,
35
+ which surface bends, what remains in contact, what changes drawing and what crosses an
36
+ occluder. This defines the cuts more reliably than naming anatomical parts first.
37
+
38
+ ## Coverage, mesh topology and variants
39
+
40
+ Separate three margins: painted joint backing covers articulation; transparent padding
41
+ allows texture sampling; packing extrusion protects atlas edges. They solve different
42
+ problems. A mask changes visibility and cannot provide missing skin, cloth or outline.
43
+
44
+ Keep triangles local to the material they deform. Do not bridge an open mouth, the gap
45
+ between legs, a disconnected spark or a silhouette notch just to obtain a smaller mesh.
46
+ Put enough vertices around a bend to retain volume, then use few enough to remain editable.
47
+ Pin rigid tips and landmarks; blend adjacent bones around the joint rather than smearing
48
+ weights over the entire image. Auto tracing/weights are starting points; inspect the bind
49
+ pose and both bending directions. Do not transfer numerical weights between unrelated rigs.
50
+
51
+ A torso, forearm or head changing front/back orientation may need alternate views or
52
+ registered segment swaps. Mark the switching pose and matching landmarks during cutting.
53
+ Design the front/back layer relationship at contact: a prop may need a rear body and a
54
+ foreground grip so fingers can cross it without changing the bone hierarchy.
55
+
56
+ For a face, define the clean backing, eyelid coverage, pupil containment, mouth cavity,
57
+ and expression ownership. A local expression should not change the head silhouette by
58
+ accident. Test combined gaze × blink × emotion × mouth opening, not only isolated drawings.
59
+ Rive's public `look.riv` and `birb.riv` demonstrate independent named facial/motion timelines;
60
+ their runtime playback alone does not establish whether a particular feature uses a swap
61
+ or deformation internally.
62
+
63
+ ## Atlas evidence and handoff
64
+
65
+ Spine attachment keys are skin placeholders. An attachment's `path`, or its `name` override,
66
+ can select a different texture region. Linked meshes share geometry while using another
67
+ image. Sequence numbering has a declared start (Spine defaults to 1); never infer frame
68
+ chronology from packing coordinates. Retain original size, trim offset, packed rotation,
69
+ page and source-to-document placement independently.
70
+
71
+ Choose a coherent skin explicitly before validating the setup. Record inaccessible source
72
+ layers and absent views as missing evidence. Include the hardest bend, the occlusion crossing
73
+ and the swap boundary in the cutting QA handoff; pass that same brief to animation.
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect a PNG and crop parts using an agent-authored manifest; requires Pillow."""
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from PIL import Image, ImageDraw
9
+
10
+
11
+ def checker(size):
12
+ bg = Image.new('RGBA', size, '#b7bcc4')
13
+ draw = ImageDraw.Draw(bg)
14
+ for y in range(0, size[1], 20):
15
+ for x in range(0, size[0], 20):
16
+ if (x // 20 + y // 20) % 2:
17
+ draw.rectangle((x, y, x + 19, y + 19), fill='#e5e7eb')
18
+ return bg
19
+
20
+
21
+ def run():
22
+ parser = argparse.ArgumentParser(description=__doc__, epilog='Manifest: [{"id":"head","rect":[x,y,width,height]}, ...]. Coordinates are ORIGINAL PNG pixels, manually selected from raster inspection. Output PNG crops preserve all RGBA values, without trimming, rotation or alpha cleanup.')
23
+ parser.add_argument('atlas', type=Path)
24
+ parser.add_argument('out_dir', type=Path)
25
+ parser.add_argument('--manifest', type=Path, help='Agent-authored JSON crop list; omit for inspection only')
26
+ parser.add_argument('--inspection-width', type=int, default=1600)
27
+ parser.add_argument('--overwrite', action='store_true', help='Replace only this invocation\'s named outputs')
28
+ args = parser.parse_args()
29
+ if args.inspection_width < 1:
30
+ parser.error('--inspection-width must be positive')
31
+ with Image.open(args.atlas) as source:
32
+ if source.format != 'PNG':
33
+ parser.error('input must be a PNG')
34
+ image = source.convert('RGBA')
35
+ specs = json.loads(args.manifest.read_text()) if args.manifest else []
36
+ if not isinstance(specs, list):
37
+ parser.error('manifest must be a list of {id, rect} objects')
38
+ ids = set()
39
+ for item in specs:
40
+ if not isinstance(item, dict) or set(item) != {'id', 'rect'}:
41
+ parser.error('each part must contain exactly id and rect')
42
+ name, rect = item['id'], item['rect']
43
+ if not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9_]*', name) or name in ids:
44
+ parser.error('part IDs must be unique snake_case names')
45
+ ids.add(name)
46
+ if not isinstance(rect, list) or len(rect) != 4 or any(type(v) is not int for v in rect):
47
+ parser.error(f'{name}: rect must contain four integers')
48
+ x, y, w, h = rect
49
+ if x < 0 or y < 0 or w < 1 or h < 1 or x + w > image.width or y + h > image.height:
50
+ parser.error(f'{name}: rectangle is outside {image.width}x{image.height}')
51
+ outputs = [args.out_dir / 'inspection.jpg', args.out_dir / 'source-record.json']
52
+ if specs:
53
+ outputs += [args.out_dir / 'parts.jpg'] + [args.out_dir / 'layers' / f'{s["id"]}.png' for s in specs]
54
+ protected = {args.atlas.resolve()}
55
+ if args.manifest:
56
+ protected.add(args.manifest.resolve())
57
+ for output in outputs:
58
+ if output.resolve() in protected:
59
+ parser.error(f'output would overwrite an input: {output}')
60
+ if output.exists() and not args.overwrite:
61
+ parser.error(f'output exists: {output}; choose another directory or --overwrite')
62
+ args.out_dir.mkdir(parents=True, exist_ok=True)
63
+ inspection = checker(image.size)
64
+ inspection.alpha_composite(image)
65
+ inspection.thumbnail((args.inspection_width, image.height))
66
+ inspection.convert('RGB').save(outputs[0], quality=92)
67
+ record = {'source': str(args.atlas.resolve()), 'sha256': hashlib.sha256(args.atlas.read_bytes()).hexdigest(), 'size': list(image.size), 'inspectionSize': list(inspection.size), 'sourcePixelsPerInspectionPixel': [image.width / inspection.width, image.height / inspection.height], 'parts': specs, 'operations': 'RGBA crop only; no trim, alpha cleanup, rotation'}
68
+ if specs:
69
+ (args.out_dir / 'layers').mkdir(exist_ok=True)
70
+ sheet = checker((1000, ((len(specs) + 3) // 4) * 240))
71
+ draw = ImageDraw.Draw(sheet)
72
+ for i, item in enumerate(specs):
73
+ x, y, w, h = item['rect']
74
+ part = image.crop((x, y, x + w, y + h))
75
+ part.save(args.out_dir / 'layers' / f'{item["id"]}.png')
76
+ part.thumbnail((230, 190))
77
+ sx, sy = (i % 4) * 250, (i // 4) * 240
78
+ sheet.alpha_composite(part, (sx + (250 - part.width) // 2, sy + 42))
79
+ draw.text((sx + 7, sy + 5), item['id'], fill='black')
80
+ draw.text((sx + 7, sy + 20), f'{x},{y} {w}x{h}', fill='black')
81
+ sheet.convert('RGB').save(args.out_dir / 'parts.jpg', quality=92)
82
+ outputs[1].write_text(json.dumps(record, indent=2) + '\n')
83
+ print(json.dumps({'out': str(args.out_dir.resolve()), 'size': list(image.size), 'parts': len(specs)}))
84
+
85
+
86
+ if __name__ == '__main__':
87
+ run()
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env python3
2
+ """Approved art + generated hidden surfaces. Dependencies: Pillow, numpy.
3
+
4
+ Run --help. All masks and landmarks use ORIGINAL source-canvas pixels. No character-specific sizes.
5
+ Partition plans: {source, layers:[{id,seeds:[[x,y]],polys:[[[x,y],...]],raw:[...],minus:[...],inkPx:4}]}
6
+ Layers are back-to-front. Shared ink belongs to the foreground claimant; islands are preserved.
7
+ Assembly plans: {source, parts:[{id,own,shape,fill?,replacement?,joint:[x,y],bone?,parent?,underlap:2}]}.
8
+ Paths are relative to the plan. own/shape are grayscale masks on the source canvas; fill/replacement RGBA.
9
+ """
10
+ import argparse
11
+ from collections import deque
12
+ import hashlib
13
+ import json
14
+ from pathlib import Path
15
+ import re
16
+ import numpy as np
17
+ from PIL import Image, ImageDraw, ImageFilter
18
+
19
+
20
+ def rgba(path):
21
+ return np.array(Image.open(path).convert('RGBA'))
22
+
23
+
24
+ def mask(path, shape):
25
+ a = np.array(Image.open(path).convert('L')) > 127
26
+ if a.shape != shape:
27
+ raise ValueError(f'mask {path}: dimensions differ from source')
28
+ return a
29
+
30
+
31
+ def polygon(shape, polys):
32
+ im = Image.new('L', (shape[1], shape[0]))
33
+ draw = ImageDraw.Draw(im)
34
+ for p in polys:
35
+ draw.polygon([tuple(x) for x in p], fill=255)
36
+ return np.array(im) > 0
37
+
38
+
39
+ def dilate(m, radius):
40
+ if radius < 0 or int(radius) != radius:
41
+ raise ValueError('dilation radius must be a nonnegative integer')
42
+ return np.array(Image.fromarray(m.astype('uint8') * 255).filter(ImageFilter.MaxFilter(radius * 2 + 1))) > 0 if radius else m.copy()
43
+
44
+
45
+ def labels(m):
46
+ """Four-connected components, preserving small islands; returns labels and areas."""
47
+ h, w = m.shape
48
+ out = np.zeros((h, w), np.int32)
49
+ sizes = [0]
50
+ for y, x in zip(*np.nonzero(m)):
51
+ if out[y, x]:
52
+ continue
53
+ i = len(sizes)
54
+ q = deque([(y, x)])
55
+ out[y, x] = i
56
+ size = 0
57
+ while q:
58
+ cy, cx = q.popleft()
59
+ size += 1
60
+ for ny, nx in ((cy-1,cx),(cy+1,cx),(cy,cx-1),(cy,cx+1)):
61
+ if 0 <= ny < h and 0 <= nx < w and m[ny, nx] and not out[ny, nx]:
62
+ out[ny, nx] = i
63
+ q.append((ny, nx))
64
+ sizes.append(size)
65
+ return out, np.array(sizes)
66
+
67
+
68
+ def nearest(own):
69
+ """Deterministic nearest (Manhattan distance) source indices and distance; empty source is an error."""
70
+ if not own.any():
71
+ raise ValueError('nearest-colour source mask is empty')
72
+ h, w = own.shape
73
+ iy, ix = np.indices(own.shape)
74
+ dist = np.full(own.shape, -1, np.int32)
75
+ dist[own] = 0
76
+ edge = own & dilate(~own, 1)
77
+ q = deque(zip(*np.nonzero(edge)))
78
+ while q:
79
+ y, x = q.popleft()
80
+ for ny, nx in ((y-1,x),(y+1,x),(y,x-1),(y,x+1)):
81
+ if 0 <= ny < h and 0 <= nx < w and dist[ny, nx] < 0:
82
+ dist[ny,nx] = dist[y,x] + 1
83
+ iy[ny,nx], ix[ny,nx] = iy[y,x], ix[y,x]
84
+ q.append((ny,nx))
85
+ return iy, ix, dist
86
+
87
+
88
+ def partition(ref, layers, ink=40):
89
+ visible = ref[...,3] > 0
90
+ inkm = visible & (ref[...,:3].max(axis=2) < ink)
91
+ lab, size = labels(visible & ~inkm)
92
+ owner = np.full(visible.shape, -1, np.int32)
93
+ claims = []
94
+ ids = [p['id'] for p in layers]
95
+ if len(set(ids)) != len(ids) or any(not re.fullmatch('[a-z][a-z0-9_]*', i) for i in ids):
96
+ raise ValueError('part ids must be unique snake_case')
97
+ for i, p in enumerate(layers):
98
+ c = np.zeros_like(visible)
99
+ for x,y in p.get('seeds', []):
100
+ if not (0 <= y < lab.shape[0] and 0 <= x < lab.shape[1]) or not lab[y,x]:
101
+ raise ValueError(f'{ids[i]}: seed {x},{y} is outside paint or on ink')
102
+ c |= lab == lab[y,x]
103
+ if p.get('polys'):
104
+ hit = np.bincount(lab[polygon(visible.shape,p['polys'])], minlength=len(size))
105
+ take = (size > 0) & (hit * 2 >= size)
106
+ take[0] = False
107
+ c |= take[lab]
108
+ c |= polygon(visible.shape,p.get('raw',[])) & visible
109
+ c |= polygon(visible.shape,p.get('rawInk',[])) & inkm
110
+ c &= ~polygon(visible.shape,p.get('minus',[]))
111
+ if p.get('within'):
112
+ j = ids.index(p['within'])
113
+ if j >= i: raise ValueError('within must name an earlier layer')
114
+ c &= owner == j
115
+ owner[c & ~inkm] = i
116
+ claims.append(c)
117
+ for i,c in enumerate(claims):
118
+ owner[inkm & dilate(c, layers[i].get('inkPx',4))] = i
119
+ if (owner >= 0).any():
120
+ iy,ix,_ = nearest(owner >= 0)
121
+ rest = visible & (owner < 0)
122
+ owner[rest] = owner[iy[rest],ix[rest]]
123
+ return {p['id']: owner == i for i,p in enumerate(layers)}, visible & (owner < 0)
124
+
125
+
126
+ def fit_similarity(generated, source):
127
+ """Least-squares similarity from explicit corresponding landmarks; reports fit error, never guesses scale."""
128
+ g, s = np.array(generated,float), np.array(source,float)
129
+ if g.shape != s.shape or g.ndim != 2 or g.shape[1] != 2 or len(g) < 2 or not np.isfinite(g).all() or not np.isfinite(s).all():
130
+ raise ValueError('need at least two finite matching landmark pairs')
131
+ a,b = g-g.mean(0),s-s.mean(0)
132
+ if (a*a).sum() < 1e-9: raise ValueError('generated landmarks coincide')
133
+ u,sv,vt = np.linalg.svd(a.T @ b)
134
+ fix = np.diag([1, np.linalg.det(u @ vt)])
135
+ rot = u @ fix @ vt
136
+ scale = float((sv * np.diag(fix)).sum() / (a*a).sum())
137
+ if scale <= 0: raise ValueError('invalid fitted scale')
138
+ offset = s.mean(0)-g.mean(0) @ (scale*rot)
139
+ predicted = g @ (scale*rot)+offset
140
+ residual = float(np.max(np.linalg.norm(predicted-s,axis=1)))
141
+ # Pillow maps each output/source point back into the generated image.
142
+ inv = np.linalg.inv(scale*rot)
143
+ shift = -offset @ inv
144
+ coeff = (inv[0,0],inv[1,0],shift[0],inv[0,1],inv[1,1],shift[1])
145
+ return coeff, {'scale':scale,'rotation_degrees':float(np.degrees(np.arctan2(rot[0,1],rot[0,0]))),'max_residual':residual}
146
+
147
+
148
+ def extract(ref, gen, region, own, pairs, background=None, tone=None, ink=None, feather=4, max_error=2):
149
+ coeff, stats = fit_similarity(pairs['generated'],pairs['source'])
150
+ if stats['max_residual'] > max_error: raise ValueError(f'landmark residual exceeds {max_error}: {stats}')
151
+ im = Image.fromarray(gen).convert('RGBa').transform((ref.shape[1],ref.shape[0]),Image.Transform.AFFINE,coeff,Image.Resampling.BICUBIC).convert('RGBA')
152
+ out = np.array(im)
153
+ if background is not None: out[background,3] = 0 # explicit background mask; never globally delete white paint
154
+ if tone is not None:
155
+ band = tone & own & (out[...,3]>240) & (ref[...,3]>240)
156
+ if not band.any(): raise ValueError('tone mask contains no comparable opaque approved pixels')
157
+ offset = np.median(ref[band,:3].astype(float)-out[band,:3],axis=0)
158
+ out[...,:3] = np.clip(out[...,:3].astype(float)+offset,0,255).astype('uint8')
159
+ stats['tone_offset'] = offset.tolist()
160
+ iy,ix,dist = nearest(own)
161
+ if feather:
162
+ w = np.clip(dist/max(feather,1),0,1)[...,None]
163
+ paint = region & (out[...,3]>0)
164
+ out[paint,:3] = (out[paint,:3]*w[paint]+ref[iy[paint],ix[paint],:3]*(1-w[paint])).astype('uint8')
165
+ if ink is not None:
166
+ known = own & (ref[...,:3].max(axis=2)<60)
167
+ ky,kx,_ = nearest(known)
168
+ fix = ink & region & (out[...,3]>0)
169
+ out[fix,:3] = ref[ky[fix],kx[fix],:3]
170
+ out[~region] = 0
171
+ pink = region & (out[...,3]>0) & (out[...,0]>220) & (out[...,1]<70) & (out[...,2]>220)
172
+ stats['magenta_pixels'] = int(pink.sum())
173
+ if pink.any(): raise ValueError(f'generation still contains guide magenta: {stats}')
174
+ return out, stats
175
+
176
+
177
+ def underlap(ref, own, front, shape, radius):
178
+ """Only extend the part's own colour, inside its complete silhouette and under fully opaque foreground."""
179
+ iy,ix,dist = nearest(own)
180
+ use = (dist>0) & (dist<=radius) & front & shape
181
+ out = np.zeros_like(ref)
182
+ out[use,:3] = ref[iy[use],ix[use],:3]
183
+ out[use,3] = 255
184
+ return out
185
+
186
+
187
+ def assemble(plan, base, outdir):
188
+ ref = rgba(base/plan['source']); shape = ref.shape[:2]; parts = plan['parts']
189
+ ids = [p['id'] for p in parts]
190
+ if not ids or len(set(ids)) != len(ids) or any(not re.fullmatch('[a-z][a-z0-9_]*', i) for i in ids):
191
+ raise ValueError('assembly part ids must be unique snake_case')
192
+ owns = [mask(base/p['own'],shape) for p in parts]
193
+ coverage = np.sum(owns,axis=0)
194
+ if (coverage>1).any(): raise ValueError('approved pixel ownership overlaps')
195
+ if ((ref[...,3]>0)&(coverage==0)).any(): raise ValueError('approved pixels are unassigned')
196
+ layers = outdir/'layers'; debug = outdir/'debug-masks'
197
+ layers.mkdir(parents=True,exist_ok=True);debug.mkdir(exist_ok=True)
198
+ entries=[]; completed={}; composite=Image.new('RGBA',(shape[1],shape[0]))
199
+ for i,p in enumerate(parts):
200
+ own=owns[i]; front=np.any(owns[i+1:],axis=0) if i+1<len(parts) else np.zeros(shape,bool)
201
+ front &= ref[...,3] == 255
202
+ complete=mask(base/p['shape'],shape)
203
+ if (own & ~complete).any(): raise ValueError(f"{p['id']}: complete shape excludes approved pixels")
204
+ img=underlap(ref,own,front,complete,p.get('underlap',2))
205
+ fillmask=img[...,3]>0
206
+ if p.get('fill'):
207
+ fill=rgba(base/p['fill'])
208
+ if fill.shape!=ref.shape: raise ValueError('fill canvas mismatch')
209
+ paint=(fill[...,3]>0)&~own&complete&front&~dilate(~front,1)
210
+ img[paint]=fill[paint];fillmask|=paint
211
+ img[own]=ref[own]
212
+ if p.get('replacement'):
213
+ replacement=rgba(base/p['replacement'])
214
+ if replacement.shape!=ref.shape: raise ValueError('replacement canvas mismatch')
215
+ # A fully generated part is an explicitly declared departure from source-pixel identity.
216
+ img=replacement.copy();fillmask=img[...,3]>0
217
+ yy,xx=np.nonzero(img[...,3])
218
+ if not len(xx): raise ValueError(f"empty part {p['id']}")
219
+ x,y,x1,y1=int(xx.min()),int(yy.min()),int(xx.max()+1),int(yy.max()+1)
220
+ Image.fromarray(img[y:y1,x:x1]).save(layers/f"{p['id']}.png")
221
+ Image.fromarray((fillmask[y:y1,x:x1]*255).astype('uint8')).save(debug/f"{p['id']}.png")
222
+ completed[p['id']]=img.copy()
223
+ composite.alpha_composite(Image.fromarray(img))
224
+ e={'id':p['id'],'file':p['id']+'.png','bbox':[x,y,x1-x,y1-y],'joint':p['joint'],'z':i,'inpainted':bool(p.get('fill') or p.get('replacement'))}
225
+ for k in ['bone','parent','slot','swap']:
226
+ if k in p:e[k]=p[k]
227
+ entries.append(e)
228
+ if plan.get('backing'):
229
+ b=plan['backing']; back=Image.new('RGBA',(shape[1],shape[0]))
230
+ if not b.get('parts') or b.get('inset',0)<=0: raise ValueError('backing requires stable part ids and a positive inset')
231
+ for lid in b['parts']:
232
+ if lid not in completed: raise ValueError('unknown backing part '+lid)
233
+ back.alpha_composite(Image.fromarray(completed[lid]))
234
+ pixels=np.array(back);inside=~dilate(ref[...,3]<255,int(b['inset']))
235
+ pixels[~inside]=0; yy,xx=np.nonzero(pixels[...,3])
236
+ if not len(xx): raise ValueError('backing inset leaves no painted pixels')
237
+ x,y,x1,y1=int(xx.min()),int(yy.min()),int(xx.max()+1),int(yy.max()+1)
238
+ lid=b.get('id','underpaint')
239
+ if lid in ids or not re.fullmatch('[a-z][a-z0-9_]*',lid): raise ValueError('backing id must be unique snake_case')
240
+ Image.fromarray(pixels[y:y1,x:x1]).save(layers/(lid+'.png'))
241
+ Image.fromarray(((pixels[y:y1,x:x1,3]>0)*255).astype('uint8')).save(debug/(lid+'.png'))
242
+ entries.insert(0,{'id':lid,'file':lid+'.png','bbox':[x,y,x1-x,y1-y],'joint':b['joint'],'bone':b['bone'],'z':-1})
243
+ back=Image.fromarray(pixels);back.alpha_composite(composite);composite=back
244
+ composite.save(outdir/'assembly.png')
245
+ (layers/'layers.json').write_text(json.dumps(entries,indent=2))
246
+ (outdir/'provenance.json').write_text(json.dumps({'source_sha256':hashlib.sha256((base/plan['source']).read_bytes()).hexdigest(),'plan':plan},indent=2))
247
+
248
+
249
+ def main():
250
+ ap=argparse.ArgumentParser(description=__doc__,formatter_class=argparse.RawDescriptionHelpFormatter)
251
+ sub=ap.add_subparsers(dest='cmd',required=True)
252
+ for cmd in ['partition','assemble']:
253
+ p=sub.add_parser(cmd);p.add_argument('plan',type=Path);p.add_argument('output',type=Path)
254
+ p=sub.add_parser('guide');p.add_argument('source',type=Path);p.add_argument('own',type=Path);p.add_argument('region',type=Path);p.add_argument('output',type=Path)
255
+ p=sub.add_parser('extract');p.add_argument('source',type=Path);p.add_argument('generated',type=Path);p.add_argument('region',type=Path);p.add_argument('own',type=Path);p.add_argument('landmarks',type=Path);p.add_argument('output',type=Path)
256
+ for name in ['background','tone','ink']:p.add_argument('--'+name+'-mask',type=Path)
257
+ p.add_argument('--feather',type=int,default=4);p.add_argument('--max-fit-error',type=float,default=2)
258
+ a=ap.parse_args()
259
+ if a.cmd in ['partition','assemble']:
260
+ plan=json.loads(a.plan.read_text());base=a.plan.parent;a.output.mkdir(parents=True,exist_ok=True)
261
+ if a.cmd=='assemble': assemble(plan,base,a.output);return
262
+ ref=rgba(base/plan['source']);masks,missing=partition(ref,plan['layers'],plan.get('ink',40))
263
+ for k,m in masks.items():
264
+ if not m.any():raise ValueError(f'empty part {k}')
265
+ Image.fromarray((m*255).astype('uint8')).save(a.output/(k+'.png'))
266
+ report={'unassigned':int(missing.sum()),'parts':{k:int(m.sum()) for k,m in masks.items()},'islands':{k:len(labels(m)[1])-1 for k,m in masks.items()}}
267
+ (a.output/'partition.json').write_text(json.dumps(report,indent=2));print(json.dumps(report))
268
+ if missing.any():raise ValueError('unassigned source pixels')
269
+ else:
270
+ ref=rgba(a.source);shape=ref.shape[:2];own=mask(a.own,shape);region=mask(a.region,shape)
271
+ if a.cmd=='guide':
272
+ out=np.zeros_like(ref);out[own]=ref[own];out[region]=(255,0,255,255);Image.fromarray(out).save(a.output)
273
+ else:
274
+ get=lambda name:mask(getattr(a,name+'_mask'),shape) if getattr(a,name+'_mask') else None
275
+ out,stats=extract(ref,rgba(a.generated),region,own,json.loads(a.landmarks.read_text()),get('background'),get('tone'),get('ink'),a.feather,a.max_fit_error)
276
+ Image.fromarray(out).save(a.output);a.output.with_suffix('.fit.json').write_text(json.dumps(stats,indent=2));print(json.dumps(stats))
277
+
278
+
279
+ if __name__=='__main__':main()