#!/usr/bin/env python3
"""Cut Bob (desert story, pose 'happy', asset a_4cf5) into parts and build Lottie walks.

Run: /cache/tmp/venv-anim/bin/python build.py
Out: parts/*.png, parts.json, parts-sheet.png, bob-walk-raw.json, bob-walk-front.json, bob.html
"""
import base64, io, json, math, os
import numpy as np, cv2
from PIL import Image, ImageDraw

HERE = os.path.dirname(os.path.abspath(__file__))
SRC = '/cache/tmp/bob/trip/a_4cf5.png'
CLIP = '/cache/tmp/lottie-go/examples/state-editor/presets/chibi-male/walk-anim.json'
OVERLAP = 16   # px dilation into neighbour at a joint
JOINT_R = 34   # overlap only this close to a joint

img = np.array(Image.open(SRC).convert('RGBA'))
H, W = img.shape[:2]
alpha = img[:, :, 3] > 8

# name: parent, pivot (source px), polygon (source px), priority (higher wins ownership)
# L = image-left = Bob's right side = chibi "near" (trailing edge, -x)
PARTS = {
    'pelvis':    dict(parent=None, pivot=(200, 330), pri=3,
                      poly=[(128, 296), (272, 296), (282, 340), (266, 356), (232, 370), (212, 394), (188, 394), (168, 370), (136, 356), (122, 340)]),
    'torso':     dict(parent='pelvis', pivot=(200, 318), pri=2,
                      poly=[(95, 95), (305, 95), (305, 318), (95, 318)]),
    'head':      dict(parent='torso', pivot=(193, 122), pri=9,
                      poly=[(120, 0), (262, 0), (262, 100), (232, 106), (216, 122), (200, 130), (182, 128), (166, 116), (160, 142), (126, 142)]),
    'uparm_L':   dict(parent='torso', pivot=(118, 172), pri=6,
                      poly=[(116, 138), (142, 160), (138, 200), (102, 240), (84, 262), (40, 262), (36, 232), (68, 192), (94, 156)]),
    'forearm_L': dict(parent='uparm_L', pivot=(64, 248), pri=7,
                      poly=[(38, 236), (78, 240), (104, 268), (134, 286), (178, 290), (190, 314), (184, 338), (134, 338), (112, 316), (78, 292), (50, 270)]),
    'uparm_R':   dict(parent='torso', pivot=(268, 170), pri=6,
                      poly=[(248, 138), (290, 146), (318, 196), (322, 244), (312, 262), (282, 262), (272, 230), (256, 200), (246, 168)]),
    'forearm_R': dict(parent='uparm_R', pivot=(300, 250), pri=7,
                      poly=[(322, 238), (316, 276), (292, 302), (278, 320), (268, 338), (230, 338), (228, 300), (254, 288), (274, 264), (284, 242)]),
    'thigh_L':   dict(parent='pelvis', pivot=(170, 352), pri=1,
                      poly=[(100, 330), (201, 330), (200, 400), (182, 470), (168, 512), (110, 512), (100, 470), (104, 400)]),
    'shin_L':    dict(parent='thigh_L', pivot=(140, 500), pri=0,
                      poly=[(95, 492), (188, 492), (172, 560), (145, 600), (132, 700), (60, 700), (76, 600), (88, 540)]),
    'thigh_R':   dict(parent='pelvis', pivot=(232, 352), pri=1,
                      poly=[(200, 330), (292, 330), (298, 400), (288, 450), (282, 512), (214, 512), (206, 450), (200, 400)]),
    'shin_R':    dict(parent='thigh_R', pivot=(250, 500), pri=0,
                      poly=[(208, 492), (288, 492), (284, 560), (280, 600), (325, 650), (325, 700), (226, 700), (232, 600), (222, 540)]),
}
# back -> front
ZORDER = ['shin_R', 'thigh_R', 'shin_L', 'thigh_L', 'pelvis', 'torso',
          'uparm_R', 'forearm_R', 'uparm_L', 'forearm_L', 'head']
# body outline without the arms: arm pixels inside it are "hidden torso" to inpaint
BODY_HULL = [(130, 118), (262, 118), (266, 200), (262, 262), (262, 300), (272, 345), (132, 345), (140, 300), (136, 262), (132, 200)]


def poly_mask(poly):
    m = np.zeros((H, W), np.uint8)
    cv2.fillPoly(m, [np.array(poly, np.int32)], 1)
    return m.astype(bool)


def disk(c, r):
    yy, xx = np.ogrid[:H, :W]
    return (xx - c[0]) ** 2 + (yy - c[1]) ** 2 <= r * r


# exclusive ownership by priority
own = np.full((H, W), -1, int)
names = list(PARTS)
best = np.full((H, W), -99)
for i, n in enumerate(names):
    m = poly_mask(PARTS[n]['poly']) & alpha
    win = m & (PARTS[n]['pri'] > best)
    own[win] = i
    best[win] = PARTS[n]['pri']
orphan = alpha & (own < 0)
print('orphan px:', int(orphan.sum()))
# give orphans to nearest owned pixel
if orphan.any():
    owned = own >= 0
    d, (iy, ix) = __import__('scipy.ndimage', fromlist=['x']).distance_transform_edt(~owned, return_indices=True)
    own[orphan] = own[iy[orphan], ix[orphan]]

children = {n: [c for c in PARTS if PARTS[c]['parent'] == n] for n in PARTS}
hull = poly_mask(BODY_HULL) & alpha
arm_px = np.isin(own, [names.index(n) for n in ('uparm_L', 'forearm_L', 'uparm_R', 'forearm_R')])
kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * OVERLAP + 1, 2 * OVERLAP + 1))

os.makedirs(os.path.join(HERE, 'parts'), exist_ok=True)
meta = {}
rgb = img[:, :, :3].copy()
for i, n in enumerate(names):
    p = PARTS[n]
    base = own == i
    if n.startswith('thigh'):
        # the thigh top sits BEHIND the pelvis: take those pixels too, the pelvis covers them at rest
        base = base | (poly_mask(p['poly']) & alpha & (own == names.index('pelvis')))
    joints = [p['pivot']] + [PARTS[c]['pivot'] for c in children[n]]
    near = np.zeros((H, W), bool)
    for j in joints:
        near |= disk(j, JOINT_R)
    grow = cv2.dilate(base.astype(np.uint8), kern).astype(bool) & alpha & near
    mask = base | grow
    out = img.copy()
    fill = np.zeros((H, W), bool)
    if n in ('torso', 'pelvis'):
        # the body under the arms: inpaint what the arm covered
        region = poly_mask(p['poly'])
        fill = hull & arm_px & region
        if fill.any():
            src = rgb.copy()
            ip = cv2.inpaint(cv2.cvtColor(src, cv2.COLOR_RGB2BGR), fill.astype(np.uint8) * 255, 9, cv2.INPAINT_TELEA)
            out[:, :, :3][fill] = cv2.cvtColor(ip, cv2.COLOR_BGR2RGB)[fill]
            # belt + waist are horizontal bands: reflect-clone each row from the inner side,
            # keeps texture where the blur would leave a grey slab. TELEA stays as fallback.
            ok = alpha & ~fill & ~arm_px
            for y in np.unique(np.where(fill)[0]):
                xs_ = np.where(fill[y])[0]
                runs = np.split(xs_, np.where(np.diff(xs_) != 1)[0] + 1)
                for r in runs:
                    a, b = r[0], r[-1]
                    inward = 1 if (a + b) / 2 < 200 else -1
                    edge = b + 1 if inward == 1 else a - 1
                    for x in r:
                        sx = edge + inward * abs(x - edge)
                        if 0 <= sx < W and ok[y, sx]:
                            out[y, x, :3] = img[y, sx, :3]
            out[:, :, 3][fill] = 255
        mask = mask | fill
    if n.startswith('thigh'):
        # extend the top of the thigh upward by edge-clone so a swing never opens a hole at the hip
        ys, xs = np.where(base)
        top = ys.min()
        for x in np.unique(xs):
            col = np.where(base[:, x])[0]
            t = col.min()
            if t < top + 12:
                for y in range(max(0, t - 18), t):
                    out[y, x] = img[t, x]
                    mask[y, x] = True
    # drop crumbs: slivers of outline cut off from the part read as dirt once it moves
    ncc, lab, st, _ = cv2.connectedComponentsWithStats((mask & (out[:, :, 3] > 8)).astype(np.uint8), connectivity=8)
    big = int(np.argmax(st[1:, cv2.CC_STAT_AREA])) + 1 if ncc > 1 else 0
    for c_ in range(1, ncc):
        if c_ != big and st[c_, cv2.CC_STAT_AREA] < 400:
            mask[lab == c_] = False
    out[:, :, 3] = np.where(mask, out[:, :, 3], 0)
    ys, xs = np.where(out[:, :, 3] > 0)
    x0, y0, x1, y1 = xs.min(), ys.min(), xs.max() + 1, ys.max() + 1
    crop = Image.fromarray(out[y0:y1, x0:x1])
    crop.save(os.path.join(HERE, 'parts', f'{n}.png'))
    meta[n] = dict(parent=p['parent'], bbox=[int(x0), int(y0), int(x1 - x0), int(y1 - y0)],
                   pivot=list(p['pivot']), pivotLocal=[p['pivot'][0] - int(x0), p['pivot'][1] - int(y0)],
                   filledPx=int(fill.sum()))
json.dump(dict(source=dict(story='trip-to-my-desert', character='bob', pose='happy', asset='a_4cf5', size=[W, H]),
               zorder=ZORDER, parts=meta), open(os.path.join(HERE, 'parts.json'), 'w'), indent=1)

# ---------------------------------------------------------------- parts sheet
pad = 16
cells = [(n, Image.open(os.path.join(HERE, 'parts', f'{n}.png'))) for n in names]
sw = sum(c.width for _, c in cells[:6]) + pad * 7
sheet = Image.new('RGBA', (max(sw, 900), 380 + 420), (238, 238, 232, 255))
x, y, rowh = pad, pad + 14, 0
d = ImageDraw.Draw(sheet)
for n, c in cells:
    if x + c.width + pad > sheet.width:
        x, y, rowh = pad, y + rowh + pad + 14, 0
    sheet.alpha_composite(c, (x, y))
    px, py = meta[n]['pivotLocal']
    d.ellipse([x + px - 5, y + py - 5, x + px + 5, y + py + 5], fill=(230, 30, 60, 255), outline=(255, 255, 255, 255))
    d.rectangle([x, y, x + c.width - 1, y + c.height - 1], outline=(120, 120, 120, 255))
    d.text((x, y - 13), n, fill=(20, 20, 20, 255))
    x += c.width + pad
    rowh = max(rowh, c.height)
sheet = sheet.crop((0, 0, sheet.width, y + rowh + pad))
sheet.save(os.path.join(HERE, 'parts-sheet.png'))

# ---------------------------------------------------------------- motion source
clip = json.load(open(CLIP))
FR, OP = clip['fr'], clip['op']
cl = {l['nm']: l for l in clip['layers']}


def keys(prop):
    if prop['a'] == 0:
        v = prop['k']
        return [(0, v if isinstance(v, list) else [v])]
    return [(k['t'], k['s']) for k in prop['k']]


def sample(prop, t):
    """Value at frame t. Clip easing is (0.5,0)->(0.5,1): ease in-out; approximate with smoothstep."""
    ks = keys(prop)
    if len(ks) == 1:
        return ks[0][1]
    for (ta, va), (tb, vb) in zip(ks, ks[1:]):
        if ta <= t <= tb:
            u = (t - ta) / (tb - ta)
            u = u * u * (3 - 2 * u)
            return [a + (b - a) * u for a, b in zip(va, vb)]
    return ks[-1][1]


rot = lambda nm, t: sample(cl[nm]['ks']['r'], t)[0]
body_y = lambda t: sample(cl['body']['ks']['p'], t)[1]
MAP = {'uparm_L': 'upper-arm-near', 'forearm_L': 'forearm-near', 'uparm_R': 'upper-arm-far',
       'forearm_R': 'forearm-far', 'thigh_L': 'thigh-near', 'shin_L': 'shin-near',
       'thigh_R': 'thigh-far', 'shin_R': 'shin-far', 'head': 'head'}
FOREARM_REST = -16      # chibi forearm carries a constant bend; Bob's bend is in the art
ARM_GAIN = 0.35         # hands are on the hips: full swing tears them off
# proportion: chibi hip->ground 57px, Bob hip->ground (352->693) 341px
LEG_RATIO = (693 - 352) / (236 - 179)
FORESHORTEN = 0.6      # 1 = true cos foreshortening

# ---------------------------------------------------------------- lottie
COMP_W, COMP_H, GROUND = 560, 780, 750
ORIGIN = ((COMP_W - W) / 2, GROUND - 693)   # where source (0,0) lands in the comp


def data_uri(n):
    b = open(os.path.join(HERE, 'parts', f'{n}.png'), 'rb').read()
    return 'data:image/png;base64,' + base64.b64encode(b).decode()


def static(v):
    return {'a': 0, 'k': v}


def anim(frames, vals, hold=False):
    ks = []
    for t, v in zip(frames, vals):
        k = {'t': t, 's': v if isinstance(v, list) else [v]}
        if t != frames[-1]:
            k['i'] = {'x': [0.5] if not isinstance(v, list) or len(v) == 1 else [0.5] * len(v), 'y': [1] if not isinstance(v, list) or len(v) == 1 else [1] * len(v)}
            k['o'] = {'x': k['i']['x'], 'y': [0] * len(k['i']['x'])}
            if hold:
                k['h'] = 1
        ks.append(k)
    return {'a': 1, 'k': ks}


def lin(frames, vals):
    ks = []
    for t, v in zip(frames, vals):
        v = v if isinstance(v, list) else [v]
        k = {'t': t, 's': v}
        if t != frames[-1]:
            k['i'] = {'x': [1] * len(v), 'y': [1] * len(v)}
            k['o'] = {'x': [0] * len(v), 'y': [0] * len(v)}
        ks.append(k)
    return {'a': 1, 'k': ks}


IND = {n: i + 1 for i, n in enumerate(names)}


def rest_pos(n):
    p = PARTS[n]
    if p['parent'] is None:
        return [p['pivot'][0] + ORIGIN[0], p['pivot'][1] + ORIGIN[1]]
    bx, by = meta[p['parent']]['bbox'][:2]
    return [p['pivot'][0] - bx, p['pivot'][1] - by]


def bone_layer(n, ind):
    """Dot at the part's pivot + a line to each child's joint, drawn in the part's own space."""
    ax, ay = meta[n]['pivotLocal']
    its = []
    for c in children[n]:
        cx, cy = rest_pos(c)
        its.append({'ty': 'gr', 'it': [
            {'ty': 'sh', 'ks': static({'i': [[0, 0], [0, 0]], 'o': [[0, 0], [0, 0]], 'v': [[ax, ay], [cx, cy]], 'c': False})},
            {'ty': 'st', 'c': static([0.1, 0.8, 1, 1]), 'o': static(100), 'w': static(4), 'lc': 2, 'lj': 2},
            {'ty': 'tr', 'p': static([0, 0]), 'a': static([0, 0]), 's': static([100, 100]), 'r': static(0), 'o': static(100)}]})
    its.append({'ty': 'gr', 'it': [
        {'ty': 'el', 'p': static([ax, ay]), 's': static([14, 14])},
        {'ty': 'fl', 'c': static([0.95, 0.1, 0.3, 1]), 'o': static(100)},
        {'ty': 'st', 'c': static([1, 1, 1, 1]), 'o': static(100), 'w': static(2), 'lc': 2, 'lj': 2},
        {'ty': 'tr', 'p': static([0, 0]), 'a': static([0, 0]), 's': static([100, 100]), 'r': static(0), 'o': static(100)}]})
    return {'ddd': 0, 'ind': ind, 'ty': 4, 'nm': f'bone:{n}', 'cl': 'bone', 'parent': IND[n], 'sr': 1,
            'ks': {'o': static(100), 'r': static(0), 'p': static([0, 0]), 'a': static([0, 0]), 's': static([100, 100])},
            'ao': 0, 'shapes': its, 'ip': 0, 'op': OP, 'st': 0, 'bm': 0}


def build(mode):
    tracks = {}   # name -> dict(r=, p=, s=)
    if mode == 'raw':
        # straight retarget: same keys, same easing, angles as-is; arm swing damped
        T = [k[0] for k in keys(cl['thigh-near']['ks']['r'])]
        for n, src in MAP.items():
            vals = [rot(src, t) for t in T]
            if n.startswith('forearm'):
                vals = [ARM_GAIN * (v - FOREARM_REST) for v in vals]
            elif n.startswith('uparm'):
                vals = [ARM_GAIN * v for v in vals]
            tracks[n] = {'r': anim(T, vals)}
        # pelvis height: forward kinematics on Bob's own rest legs, lowest sole pinned to the ground
        TT = list(range(0, OP + 1, 2))
        SOLE = {'L': (100, 690), 'R': (265, 690)}
        def R(v, deg):
            c, s_ = math.cos(math.radians(deg)), math.sin(math.radians(deg))
            return (c * v[0] - s_ * v[1], s_ * v[0] + c * v[1])
        drops = []
        for t in TT:
            ys = []
            for side, near in (('L', 'near'), ('R', 'far')):
                h, k, f = PARTS[f'thigh_{side}']['pivot'], PARTS[f'shin_{side}']['pivot'], SOLE[side]
                a, b = rot(f'thigh-{near}', t), rot(f'shin-{near}', t)
                k2 = R((k[0] - h[0], k[1] - h[1]), a)
                f2 = R((f[0] - k[0], f[1] - k[1]), a + b)
                ys.append(h[1] + k2[1] + f2[1])
            drops.append(690 - max(ys))
        rp = rest_pos('pelvis')
        tracks['pelvis'] = {'p': lin(TT, [[rp[0], rp[1] + d] for d in drops])}
        tracks['torso'] = {'r': static(4)}      # the chibi's forward lean. front view: reads as a sideways tilt
    else:
        # front-view adaptation: sagittal swing -> foreshortening (scale y), pelvis kept on the ground
        T = list(range(0, OP + 1, 2))
        segs = {}
        for side, near in (('L', 'near'), ('R', 'far')):
            th = meta[f'thigh_{side}']
            sh = meta[f'shin_{side}']
            segs[side] = dict(thigh=PARTS[f'shin_{side}']['pivot'][1] - PARTS[f'thigh_{side}']['pivot'][1],
                              shin=693 - PARTS[f'shin_{side}']['pivot'][1], near=near)
        rows = {k: [] for k in ('thigh_L', 'shin_L', 'thigh_R', 'shin_R')}
        pel = []
        for t in T:
            drop = []
            for side, sg in segs.items():
                a_t = -rot(f"thigh-{sg['near']}", t)                    # forward swing, deg
                a_s = -(rot(f"thigh-{sg['near']}", t) + rot(f"shin-{sg['near']}", t))
                # full cos squashes the boot to half height on the trailing leg; soften it
                ct, cs = (1 - FORESHORTEN * (1 - math.cos(math.radians(a)))
                          for a in (a_t, a_s))
                rows[f'thigh_{side}'].append([100, 100 * ct])
                rows[f'shin_{side}'].append([100, 100 * cs / ct])      # undo inherited thigh scale
                drop.append(sg['thigh'] * (1 - ct) + sg['shin'] * (1 - cs))
            pel.append(min(drop))                                          # the longer leg is planted
        for k, v in rows.items():
            tracks[k] = {'s': lin(T, v)}
        rp = rest_pos('pelvis')
        tracks['pelvis'] = {'p': lin(T, [[rp[0], rp[1] + d] for d in pel])}
        # a little hip sway toward the planted leg + counter tilt in the shoulders
        tracks['pelvis']['r'] = lin(T, [1.5 * math.sin(2 * math.pi * t / OP) for t in T])
        tracks['torso'] = {'r': lin(T, [-2.0 * math.sin(2 * math.pi * t / OP) for t in T])}
        for n in ('uparm_L', 'uparm_R'):
            tracks[n] = {'r': anim([0, 12, 24, 36, 48], [ARM_GAIN * rot(MAP[n], t) * 0.5 for t in (0, 12, 24, 36, 48)])}
        tracks['head'] = {'r': lin(T, [1.5 * math.sin(2 * math.pi * t / OP) for t in T])}

    layers = []
    for i, n in enumerate(reversed(ZORDER)):  # lottie: first = top
        m = meta[n]
        tr = tracks.get(n, {})
        ks = {'o': static(100), 'r': tr.get('r', static(0)), 'p': tr.get('p', static(rest_pos(n))),
              'a': static(m['pivotLocal']), 's': tr.get('s', static([100, 100]))}
        L = {'ddd': 0, 'ind': IND[n], 'ty': 2, 'nm': n, 'refId': n, 'sr': 1, 'ks': ks, 'ao': 0,
             'ip': 0, 'op': OP, 'st': 0, 'bm': 0}
        if PARTS[n]['parent']:
            L['parent'] = IND[PARTS[n]['parent']]
        layers.append(L)
    bones = [bone_layer(n, 100 + i) for i, n in enumerate(names)]
    shadow = {'ddd': 0, 'ind': 200, 'ty': 4, 'nm': 'shadow', 'sr': 1,
              'ks': {'o': static(22), 'r': static(0), 'p': static([COMP_W / 2, GROUND - 4]), 'a': static([0, 0]), 's': static([100, 100])},
              'ao': 0, 'shapes': [{'ty': 'gr', 'it': [{'ty': 'el', 'p': static([0, 0]), 's': static([250, 26])},
                                                      {'ty': 'fl', 'c': static([0, 0, 0, 1]), 'o': static(100)},
                                                      {'ty': 'tr', 'p': static([0, 0]), 'a': static([0, 0]), 's': static([100, 100]), 'r': static(0), 'o': static(100)}]}],
              'ip': 0, 'op': OP, 'st': 0, 'bm': 0}
    return {'v': '5.7.4', 'fr': FR, 'ip': 0, 'op': OP, 'w': COMP_W, 'h': COMP_H, 'nm': f'bob-walk-{mode}', 'ddd': 0,
            'assets': [{'id': n, 'w': meta[n]['bbox'][2], 'h': meta[n]['bbox'][3], 'u': '', 'p': data_uri(n), 'e': 1} for n in names],
            'layers': bones + layers + [shadow]}


anims = {m: build(m) for m in ('raw', 'front')}
for m, a in anims.items():
    json.dump(a, open(os.path.join(HERE, f'bob-walk-{m}.json'), 'w'))

# ---------------------------------------------------------------- html
orig = 'data:image/png;base64,' + base64.b64encode(open(SRC, 'rb').read()).decode()
sheet_uri = 'data:image/png;base64,' + base64.b64encode(open(os.path.join(HERE, 'parts-sheet.png'), 'rb').read()).decode()
tpl = open(os.path.join(HERE, 'bob.tpl.html')).read()
html = (tpl.replace('/*RAW*/null', json.dumps(anims['raw']))
           .replace('/*FRONT*/null', json.dumps(anims['front']))
           .replace('/*PARTS*/null', json.dumps(meta))
           .replace('__ORIG__', orig).replace('__SHEET__', sheet_uri))
open(os.path.join(HERE, 'bob.html'), 'w').write(html)
print('ok', {n: meta[n]['bbox'] for n in names})
