Files
2026-07-04 10:34:46 +09:00

884 lines
29 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Isabel Reaction Sequencer — reactions.html</title>
<style>
:root { color-scheme: dark; }
body { margin:0; font-family: system-ui, "Segoe UI", sans-serif; background:#0e1014; color:#e6e8ee; }
header { padding:10px 16px; border-bottom:1px solid #232733; display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
h1 { font-size:15px; margin:0; font-weight:600; color:#9ff0e0; }
.wrap { display:flex; gap:16px; padding:16px; flex-wrap:wrap; }
.stage { background:
linear-gradient(45deg,#1a1d24 25%,transparent 25%,transparent 75%,#1a1d24 75%),
linear-gradient(45deg,#1a1d24 25%,#15171d 25%,#15171d 75%,#1a1d24 75%);
background-size:24px 24px; background-position:0 0,12px 12px; border:1px solid #232733; border-radius:8px; }
canvas { display:block; }
.panel { min-width:250px; max-width:360px; font-size:13px; line-height:1.6; }
.row { display:flex; align-items:center; gap:8px; margin:6px 0; flex-wrap:wrap; }
button { background:#1d2a3a; color:#cfe6ff; border:1px solid #2c4a63; border-radius:6px; padding:6px 12px; cursor:pointer; font-size:13px; }
button:hover { background:#26405a; }
button.on { background:#2e9e8b; color:#04231d; border-color:#2e9e8b; }
button.trig { background:#2a2536; border-color:#4a3d63; color:#e2d6ff; }
button.trig:hover { background:#39304d; }
label.file { display:inline-block; background:#232733; border:1px solid #37414f; border-radius:6px; padding:6px 10px; cursor:pointer; }
input[type=file]{ display:none; } input[type=range]{ width:130px; }
.muted { color:#8a93a6; font-size:12px; } code { background:#191c23; padding:1px 5px; border-radius:4px; color:#9ff0e0; }
.tag { font-size:11px; color:#9ff0e0; background:#12241f; border:1px solid #1f4238; border-radius:10px; padding:1px 8px; }
</style>
</head>
<body>
<header>
<h1>이사벨 Reaction Sequencer — <span id="stateName">dance_idle</span> <span class="muted">(520×900 / file://)</span></h1>
<div class="row">
<button id="playBtn" class="on">⏸ 일시정지</button>
<span class="muted">속도</span><input type="range" id="speed" min="0" max="2" step="0.05" value="1"><span id="speedV" class="muted">1.0×</span>
<button id="capBtn" class="on">💬 말풍선</button>
</div>
</header>
<div class="wrap">
<div class="stage"><canvas id="cv" width="520" height="900"></canvas></div>
<div class="panel">
<div class="row" id="trigRow"></div>
<p class="muted" id="status">상황 트리거를 누르면 반응 클립을 재생하고 종료 후 <code>dance_idle</code> 로 복귀합니다.</p>
<div class="row">
<label class="file">🖼 이미지 로드(다중)<input type="file" id="imgFiles" accept="image/png" multiple></label>
</div>
<p class="muted">• 리그 idle 은 풀캔버스 파츠를 원점에 그리고 관절 피벗 기준 회전.<br>
• baked 반응은 headless 바디 + 표정 머리를 <b>목(neck) 부착점</b>에 합성 (Python <code>reactions_layout_render.py</code> 와 동일 어파인).<br>
• 상대경로 로드가 막히면 <b>이미지 로드(다중)</b> 로 PNG 를 직접 지정(파일명 매칭).</p>
<p class="muted" id="loadInfo"></p>
</div>
</div>
<script>
/* ============================================================
Embedded JSON (inlined so it runs from file:// with zero fetch)
============================================================ */
const RIG = {
"name": "Isabel",
"canvas": { "width": 520, "height": 900 },
"imageBase": "../03_Assets/Parts/Images/",
"mode": "fullcanvas",
"note": "Full-canvas parts (520x900, part at master position). Draw at origin; rotate about pivot (joint). pivot = auto-derived overlap centroid from Isabel's parts (_tools/rig_pivots_render.py).",
"bones": [
{ "name": "pelvis", "parent": null, "pivot": [258.0, 425.0], "z": 6, "image": "isabel_part_pelvis.png" },
{ "name": "chest", "parent": "pelvis", "pivot": [259.3, 192.0], "z": 8, "image": "isabel_part_chest.png" },
{ "name": "neck", "parent": "chest", "pivot": [260.0, 165.0], "z": 9, "image": "isabel_part_neck.png" },
{ "name": "head", "parent": "neck", "pivot": [274.3, 167.6], "z": 10, "image": "isabel_part_head.png" },
{ "name": "upperarm_r", "parent": "chest", "pivot": [187.1, 196.0], "z": 5, "image": "isabel_part_upperarm_r.png" },
{ "name": "forearm_r", "parent": "upperarm_r", "pivot": [150.9, 324.0], "z": 5, "image": "isabel_part_forearm_r.png" },
{ "name": "hand_r", "parent": "forearm_r", "pivot": [124.8, 425.0], "z": 5, "image": "isabel_part_hand_r.png" },
{ "name": "upperarm_l", "parent": "chest", "pivot": [332.7, 196.0], "z": 12, "image": "isabel_part_upperarm_l.png" },
{ "name": "forearm_l", "parent": "upperarm_l", "pivot": [368.5, 324.0], "z": 12, "image": "isabel_part_forearm_l.png" },
{ "name": "hand_l", "parent": "forearm_l", "pivot": [393.7, 426.0], "z": 13, "image": "isabel_part_hand_l.png" },
{ "name": "thigh_r", "parent": "pelvis", "pivot": [231.6, 545.0], "z": 4, "image": "isabel_part_thigh_r.png" },
{ "name": "shin_r", "parent": "thigh_r", "pivot": [233.9, 645.0], "z": 3, "image": "isabel_part_shin_r.png" },
{ "name": "foot_r", "parent": "shin_r", "pivot": [241.4, 795.0], "z": 2, "image": "isabel_part_foot_r.png" },
{ "name": "thigh_l", "parent": "pelvis", "pivot": [285.1, 545.0], "z": 4, "image": "isabel_part_thigh_l.png" },
{ "name": "shin_l", "parent": "thigh_l", "pivot": [283.1, 645.0], "z": 3, "image": "isabel_part_shin_l.png" },
{ "name": "foot_l", "parent": "shin_l", "pivot": [275.8, 795.0], "z": 2, "image": "isabel_part_foot_l.png" }
]
};
const DANCE_IDLE = {
"name": "dance_idle",
"duration": 2.0,
"loop": true,
"fpsHint": 60,
"defaultEase": "sine",
"note": "이사벨 전용 튜닝(노출 최대: 클럽 의상=미드리프·맨다리·맨팔). occlusion-aware — CHEST 트랙 없음(골반에 리지드→미드리프 봉인). 스웨이는 pelvis가 상체 통째로. 맨살 관절(무릎·팔꿈치)은 진폭 최소. 힙 스웨이 강조의 잔잔·관능 그루브. (파츠 도착 후 렌더로 미세조정.)",
"tracks": {
"pelvis": {
"tx": [ {"t":0,"v":0}, {"t":0.5,"v":9}, {"t":1.0,"v":0}, {"t":1.5,"v":-9}, {"t":2.0,"v":0} ],
"ty": [ {"t":0,"v":0}, {"t":0.5,"v":6}, {"t":1.0,"v":0}, {"t":1.5,"v":6}, {"t":2.0,"v":0} ],
"rot": [ {"t":0,"v":0}, {"t":0.5,"v":3}, {"t":1.0,"v":0}, {"t":1.5,"v":-3}, {"t":2.0,"v":0} ]
},
"neck": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":2}, {"t":1.0,"v":0}, {"t":1.5,"v":-2}, {"t":2.0,"v":0} ] },
"head": {
"rot": [ {"t":0,"v":0}, {"t":0.5,"v":4}, {"t":1.0,"v":0}, {"t":1.5,"v":-4}, {"t":2.0,"v":0} ],
"ty": [ {"t":0,"v":0}, {"t":0.5,"v":-2}, {"t":1.0,"v":0}, {"t":1.5,"v":-2}, {"t":2.0,"v":0} ]
},
"upperarm_r": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":6}, {"t":1.0,"v":0}, {"t":1.5,"v":-3}, {"t":2.0,"v":0} ] },
"forearm_r": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":8}, {"t":1.0,"v":0}, {"t":1.5,"v":-4}, {"t":2.0,"v":0} ] },
"hand_r": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":4}, {"t":1.0,"v":0}, {"t":1.5,"v":-2}, {"t":2.0,"v":0} ] },
"upperarm_l": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":-3}, {"t":1.0,"v":0}, {"t":1.5,"v":6}, {"t":2.0,"v":0} ] },
"forearm_l": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":-4}, {"t":1.0,"v":0}, {"t":1.5,"v":8}, {"t":2.0,"v":0} ] },
"hand_l": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":-2}, {"t":1.0,"v":0}, {"t":1.5,"v":4}, {"t":2.0,"v":0} ] },
"thigh_r": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":2}, {"t":1.0,"v":0}, {"t":1.5,"v":-1}, {"t":2.0,"v":0} ] },
"shin_r": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":4}, {"t":1.0,"v":0}, {"t":1.5,"v":0}, {"t":2.0,"v":0} ] },
"thigh_l": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":-1}, {"t":1.0,"v":0}, {"t":1.5,"v":2}, {"t":2.0,"v":0} ] },
"shin_l": { "rot": [ {"t":0,"v":0}, {"t":0.5,"v":0}, {"t":1.0,"v":0}, {"t":1.5,"v":4}, {"t":2.0,"v":0} ] }
}
};
const REACTIONS = {
"name": "Isabel reactions map",
"note": "상황키(app event) → 반응 클립 이름(clips/<name>.json). idle은 배경 기본 루프.",
"idleDefault": "dance_idle",
"map": {
"idle": "dance_idle",
"error": "gesture_no",
"success": "gesture_heart"
},
"plannedExpansion": {
"greet": "gesture_wave",
"explain": "gesture_present",
"thinking": "gesture_think"
}
};
const LAYOUT = {
"stage": {
"w": 520,
"h": 900
},
"neck": [
260,
250
],
"overlap": 6,
"headTargetW": 150,
"bodies": {
"isabel_body_bikini_armscross": {
"scale": 0.3528,
"ox": -26.8,
"oy": 227.1
},
"isabel_body_bikini_cheer": {
"scale": 0.1603,
"ox": 45.4,
"oy": 244.7
},
"isabel_body_bikini_clap": {
"scale": 0.2788,
"ox": 10.3,
"oy": 229.6
},
"isabel_body_bikini_control": {
"scale": 0.2396,
"ox": 76.6,
"oy": 237.1
},
"isabel_body_bikini_dj": {
"scale": 0.2972,
"ox": -44.4,
"oy": 228.0
},
"isabel_body_bikini_handwave": {
"scale": 0.2182,
"ox": 62.0,
"oy": 233.0
},
"isabel_body_bikini_heart": {
"scale": 0.2805,
"ox": 42.8,
"oy": 228.1
},
"isabel_body_bikini_idle_full": {
"scale": 0.4822,
"ox": 31.4,
"oy": 199.4
},
"isabel_body_bikini_idle_upper": {
"scale": 0.2748,
"ox": 45.0,
"oy": 228.3
},
"isabel_body_bikini_joy": {
"scale": 0.3071,
"ox": 222.7,
"oy": 226.4
},
"isabel_body_bikini_listen": {
"scale": 0.2937,
"ox": 73.2,
"oy": 230.6
},
"isabel_body_bikini_peace": {
"scale": 0.3212,
"ox": 82.0,
"oy": 226.9
},
"isabel_body_bikini_piano": {
"scale": 0.2871,
"ox": 37.2,
"oy": 234.2
},
"isabel_body_bikini_point": {
"scale": 0.2381,
"ox": 64.8,
"oy": 230.5
},
"isabel_body_bikini_present": {
"scale": 0.1923,
"ox": 75.8,
"oy": 235.8
},
"isabel_body_bikini_shrug": {
"scale": 0.1773,
"ox": 115.2,
"oy": 235.8
},
"isabel_body_bikini_thumbsup": {
"scale": 0.3083,
"ox": 22.4,
"oy": 224.7
},
"isabel_body_bikini_wave": {
"scale": 0.2434,
"ox": 193.2,
"oy": 230.3
},
"isabel_body_ceo_armscross": {
"scale": 0.3986,
"ox": -37.2,
"oy": 209.7
},
"isabel_body_ceo_cheer": {
"scale": 0.2486,
"ox": 184.7,
"oy": 238.3
},
"isabel_body_ceo_clap": {
"scale": 0.41,
"ox": -44.6,
"oy": 219.7
},
"isabel_body_ceo_control": {
"scale": 0.2995,
"ox": 143.4,
"oy": 224.2
},
"isabel_body_ceo_dj": {
"scale": 0.3377,
"ox": -27.6,
"oy": 228.0
},
"isabel_body_ceo_handwave": {
"scale": 0.3087,
"ox": 24.0,
"oy": 221.0
},
"isabel_body_ceo_heart": {
"scale": 0.3428,
"ox": 0.2,
"oy": 225.7
},
"isabel_body_ceo_idle_full": {
"scale": 0.5033,
"ox": 29.2,
"oy": 201.2
},
"isabel_body_ceo_idle_upper": {
"scale": 0.3528,
"ox": -10.9,
"oy": 228.8
},
"isabel_body_ceo_joy": {
"scale": 0.2632,
"ox": 120.4,
"oy": 231.3
},
"isabel_body_ceo_listen": {
"scale": 0.4078,
"ox": -5.1,
"oy": 232.5
},
"isabel_body_ceo_peace": {
"scale": 0.4449,
"ox": 34.9,
"oy": 183.7
},
"isabel_body_ceo_piano": {
"scale": 0.3746,
"ox": -26.0,
"oy": 227.9
},
"isabel_body_ceo_point": {
"scale": 0.3358,
"ox": 1.6,
"oy": 221.1
},
"isabel_body_ceo_present": {
"scale": 0.2386,
"ox": 64.6,
"oy": 224.7
},
"isabel_body_ceo_shrug": {
"scale": 0.1808,
"ox": 128.7,
"oy": 228.3
},
"isabel_body_ceo_thumbsup": {
"scale": 0.3125,
"ox": 23.4,
"oy": 228.4
},
"isabel_body_ceo_wave": {
"scale": 0.3168,
"ox": 176.2,
"oy": 224.0
},
"isabel_body_club_armscross": {
"scale": 0.4364,
"ox": -56.4,
"oy": 225.1
},
"isabel_body_club_cheer": {
"scale": 0.211,
"ox": 215.7,
"oy": 241.8
},
"isabel_body_club_clap": {
"scale": 0.4283,
"ox": -67.2,
"oy": 210.6
},
"isabel_body_club_control": {
"scale": 0.3117,
"ox": 42.6,
"oy": 226.3
},
"isabel_body_club_dj": {
"scale": 0.3662,
"ox": -42.0,
"oy": 217.0
},
"isabel_body_club_handwave": {
"scale": 0.2908,
"ox": 32.9,
"oy": 225.0
},
"isabel_body_club_heart": {
"scale": 0.3363,
"ox": 0.2,
"oy": 221.4
},
"isabel_body_club_idle_full": {
"scale": 0.5665,
"ox": -26.9,
"oy": 197.9
},
"isabel_body_club_idle_upper": {
"scale": 0.3348,
"ox": 18.6,
"oy": 207.8
},
"isabel_body_club_joy": {
"scale": 0.3042,
"ox": 225.6,
"oy": 228.4
},
"isabel_body_club_listen": {
"scale": 0.4049,
"ox": 18.5,
"oy": 210.7
},
"isabel_body_club_peace": {
"scale": 0.3611,
"ox": 71.3,
"oy": 220.8
},
"isabel_body_club_piano": {
"scale": 0.3686,
"ox": -23.4,
"oy": 218.3
},
"isabel_body_club_point": {
"scale": 0.3239,
"ox": 28.2,
"oy": 224.7
},
"isabel_body_club_present": {
"scale": 0.2632,
"ox": 62.9,
"oy": 220.0
},
"isabel_body_club_shrug": {
"scale": 0.2019,
"ox": 103.5,
"oy": 232.4
},
"isabel_body_club_thumbsup": {
"scale": 0.3146,
"ox": 19.9,
"oy": 225.5
},
"isabel_body_club_wave": {
"scale": 0.3474,
"ox": 152.3,
"oy": 230.5
}
},
"heads": {
"isabel_head_wave": {
"w": 952,
"neckNorm": [
0.4956,
0.9689
]
},
"isabel_head_wave_blink": {
"w": 946,
"neckNorm": [
0.4932,
0.9825
]
},
"isabel_head_wave_confused": {
"w": 940,
"neckNorm": [
0.4916,
0.9992
]
},
"isabel_head_wave_cool": {
"w": 936,
"neckNorm": [
0.494,
0.9992
]
},
"isabel_head_wave_laugh": {
"w": 939,
"neckNorm": [
0.4928,
0.9992
]
},
"isabel_head_wave_love": {
"w": 939,
"neckNorm": [
0.4928,
0.9992
]
},
"isabel_head_wave_negative": {
"w": 940,
"neckNorm": [
0.4924,
0.9992
]
},
"isabel_head_wave_neutral": {
"w": 956,
"neckNorm": [
0.4964,
0.9825
]
},
"isabel_head_wave_playful": {
"w": 939,
"neckNorm": [
0.4928,
0.9992
]
},
"isabel_head_wave_positive": {
"w": 940,
"neckNorm": [
0.4924,
0.9992
]
},
"isabel_head_wave_pout": {
"w": 937,
"neckNorm": [
0.4928,
0.9992
]
},
"isabel_head_wave_proud": {
"w": 949,
"neckNorm": [
0.4984,
0.9992
]
},
"isabel_head_wave_sad": {
"w": 937,
"neckNorm": [
0.4936,
0.9992
]
},
"isabel_head_wave_shy": {
"w": 942,
"neckNorm": [
0.4988,
0.9992
]
},
"isabel_head_wave_sleepy": {
"w": 936,
"neckNorm": [
0.494,
0.9992
]
},
"isabel_head_wave_smile": {
"w": 941,
"neckNorm": [
0.492,
0.9992
]
},
"isabel_head_wave_surprised": {
"w": 939,
"neckNorm": [
0.492,
0.9992
]
},
"isabel_head_wave_talk": {
"w": 944,
"neckNorm": [
0.4924,
0.9992
]
},
"isabel_head_wave_talk_wide": {
"w": 941,
"neckNorm": [
0.492,
0.9992
]
},
"isabel_head_wave_thinking": {
"w": 941,
"neckNorm": [
0.496,
0.9992
]
},
"isabel_head_wave_wink": {
"w": 938,
"neckNorm": [
0.4924,
0.9992
]
}
}
};
const CLIPS = {
"gesture_heart": {
"name": "gesture_heart",
"desc": "손 하트를 그리며 밝게 '잘됐어요'",
"duration": 2.2,
"return": "idle",
"layers": {
"body": [
{ "t": 0.0, "mode": "rig", "clip": "idle" },
{ "t": 0.15, "mode": "baked", "image": "isabel_body_club_heart", "fade": 0.2 }
],
"face": [
{ "t": 0.0, "expr": "smile" },
{ "t": 0.25, "expr": "love" }
],
"mouth": [
{ "t": 0.5, "say": "잘됐어요", "dur": 1.1, "pattern": "talk" }
],
"transform": {
"pelvis": { "ty": [ {"t":0.4,"v":0}, {"t":0.7,"v":8}, {"t":1.0,"v":0}, {"t":1.3,"v":8}, {"t":1.6,"v":0} ] },
"head": { "rot": [ {"t":0.4,"v":0}, {"t":0.7,"v":4}, {"t":1.0,"v":-4}, {"t":1.3,"v":4}, {"t":1.6,"v":0} ] }
},
"caption": [ { "t": 0.5, "text": "잘됐어요", "dur": 1.5 } ],
"sfx": [ { "t": 0.45, "id": "success" } ]
}
},
"gesture_no": {
"name": "gesture_no",
"desc": "서있다 → 팔짱 끼고 인상 쓰며 고개 저으며 '안돼요'",
"duration": 2.4,
"return": "idle",
"layers": {
"body": [
{ "t": 0.0, "mode": "rig", "clip": "idle" },
{ "t": 0.15, "mode": "baked", "image": "isabel_body_club_armscross", "fade": 0.2 }
],
"face": [
{ "t": 0.0, "expr": "neutral" },
{ "t": 0.3, "expr": "negative" }
],
"mouth": [
{ "t": 0.55, "say": "안돼요", "dur": 1.2, "pattern": "talk" }
],
"transform": {
"chest": { "ty": [ {"t":0.0,"v":0}, {"t":0.2,"v":-4}, {"t":0.5,"v":0} ] },
"head": { "rot": [ {"t":0.5,"v":0}, {"t":0.8,"v":9}, {"t":1.1,"v":-9}, {"t":1.4,"v":9}, {"t":1.7,"v":-9}, {"t":2.0,"v":0} ] }
},
"caption": [ { "t": 0.55, "text": "안돼요", "dur": 1.6 } ],
"sfx": [ { "t": 0.5, "id": "nope" } ]
}
}
};
/* ============================================================
Asset paths
============================================================ */
const PARTS_DIR = RIG.imageBase || "../03_Assets/Parts/Images/";
const HEAD_DIR = "../03_Assets/Library/Heads/";
const BAKED_DIRS = ["../03_Assets/Library/BakedPoses/Club/","../03_Assets/Library/BakedPoses/Bikini/","../03_Assets/Library/BakedPoses/Ceo/"];
/* images keyed by basename (no extension) */
const images = {}; // key -> HTMLImageElement (loaded)
let loadTotal = 0, loadOk = 0;
function tryLoad(key, dirs){
loadTotal++;
let i = 0;
const attempt = () => {
if (i >= dirs.length) { refreshInfo(); return; } // give up (leave undefined)
const im = new Image();
im.onload = () => { images[key] = im; loadOk++; refreshInfo(); };
im.onerror = () => { i++; attempt(); };
im.src = dirs[i] + key + ".png";
};
attempt();
}
function loadAllAssets(){
// rig parts
RIG.bones.forEach(b => { if (b.image) tryLoad(b.image.replace(/\.png$/i,""), [PARTS_DIR]); });
// heads (all expressions + talk frames)
Object.keys(LAYOUT.heads).forEach(k => tryLoad(k, [HEAD_DIR]));
// baked bodies referenced by clips (+ common ones); resolve across Cozy/Day/Night
const bakedNeeded = new Set();
Object.values(CLIPS).forEach(c => (c.layers.body||[]).forEach(e => { if (e.mode==="baked" && e.image) bakedNeeded.add(e.image); }));
bakedNeeded.forEach(k => tryLoad(k, BAKED_DIRS));
}
function refreshInfo(){
document.getElementById('loadInfo').textContent = `이미지 로드: ${loadOk} / ${loadTotal}` + (loadOk===0?' (없음 → 이미지 로드(다중) 사용)':'');
}
/* ============================================================
Matrix + sampling helpers (same convention as index.html)
mul(A,B) = A·B (B applied first, then A); tp = A·point
============================================================ */
const mul=(m,n)=>({a:m.a*n.a+m.c*n.b,b:m.b*n.a+m.d*n.b,c:m.a*n.c+m.c*n.d,d:m.b*n.c+m.d*n.d,e:m.a*n.e+m.c*n.f+m.e,f:m.b*n.e+m.d*n.f+m.f});
const T=(x,y)=>({a:1,b:0,c:0,d:1,e:x,f:y});
const R=deg=>{const r=deg*Math.PI/180,c=Math.cos(r),s=Math.sin(r);return{a:c,b:s,c:-s,d:c,e:0,f:0};};
const Sc=k=>({a:k,b:0,c:0,d:k,e:0,f:0});
function ease(ty,x){return ty==='linear'?x:-(Math.cos(Math.PI*x)-1)/2;}
function sample(keys,t,defEase){ if(!keys||!keys.length)return 0; if(t<=keys[0].t)return keys[0].v; const n=keys.length; if(t>=keys[n-1].t)return keys[n-1].v;
for(let i=0;i<n-1;i++){ if(t>=keys[i].t&&t<=keys[i+1].t){ const k0=keys[i],k1=keys[i+1],sp=(k1.t-k0.t)||1e-6; return k0.v+(k1.v-k0.v)*ease(k0.e||defEase||'sine',(t-k0.t)/sp);} } return keys[n-1].v; }
function sT(track,sub,t){ return (track && track[sub]) ? sample(track[sub],t) : 0; }
// active timeline entry: last with t<=now (or first)
function activeEntry(arr,now){ if(!arr||!arr.length)return null; let e=arr[0]; for(let i=0;i<arr.length;i++){ if(arr[i].t<=now) e=arr[i]; else break; } return e; }
function activeIndex(arr,now){ let idx=0; for(let i=0;i<arr.length;i++){ if(arr[i].t<=now) idx=i; else break; } return idx; }
/* ============================================================
Rig FK render (copied engine from index.html) with optional
per-bone extra deltas and whole-stage translate.
============================================================ */
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
cv.width=RIG.canvas.width; cv.height=RIG.canvas.height;
const bones=RIG.bones.map(b=>Object.assign({},b));
const boneMap={}; bones.forEach(b=>boneMap[b.name]=b);
function rigWorlds(time, extra){
extra = extra||{};
bones.forEach(b=>{
const tr=DANCE_IDLE.tracks[b.name]||{};
const ex=extra[b.name]||{};
const rot=sample(tr.rot,time,DANCE_IDLE.defaultEase)+(ex.rot||0);
const tx =sample(tr.tx ,time,DANCE_IDLE.defaultEase)+(ex.tx ||0);
const ty =sample(tr.ty ,time,DANCE_IDLE.defaultEase)+(ex.ty ||0);
const jx=b.pivot[0],jy=b.pivot[1];
const local=mul(T(tx,ty),mul(T(jx,jy),mul(R(rot),T(-jx,-jy))));
b._world=b.parent?mul(boneMap[b.parent]._world,local):local;
});
}
function drawRig(time, extra, stageTx, stageTy, alpha){
rigWorlds(time, extra);
const base=T(stageTx||0, stageTy||0);
ctx.globalAlpha = alpha==null?1:alpha;
bones.slice().sort((a,b)=>(a.z||0)-(b.z||0)).forEach(b=>{
const im=images[b.image.replace(/\.png$/i,"")]; if(!im)return;
const w=mul(base,b._world);
ctx.setTransform(w.a,w.b,w.c,w.d,w.e,w.f); ctx.drawImage(im,0,0);
});
ctx.setTransform(1,0,0,1,0,0); ctx.globalAlpha=1;
}
/* ============================================================
Baked composition — mirrors reactions_layout_render.py compose()
body: M = T(stage) · T(ox,oy) · Sc(scale)
head: M = T(stage) · T(NECK_X, NECK_Y+OVERLAP+headTy) · R(rot) · Sc(hs) · T(-ax,-ay)
============================================================ */
const NECK_X = LAYOUT.neck[0], NECK_Y = LAYOUT.neck[1], OVERLAP = LAYOUT.overlap, HEAD_TARGET_W = LAYOUT.headTargetW;
function drawBakedBody(imgKey, stageTx, stageTy, alpha){
const bl=LAYOUT.bodies[imgKey]; const im=images[imgKey];
if(!bl||!im)return;
const M=mul(T(stageTx||0,stageTy||0), mul(T(bl.ox,bl.oy), Sc(bl.scale)));
ctx.globalAlpha=alpha==null?1:alpha;
ctx.setTransform(M.a,M.b,M.c,M.d,M.e,M.f); ctx.drawImage(im,0,0);
ctx.setTransform(1,0,0,1,0,0); ctx.globalAlpha=1;
}
function headKeyFallback(key){
return images[key] ? key : (images['isabel_head_wave_neutral'] ? 'isabel_head_wave_neutral' : (images['isabel_head_wave'] ? 'isabel_head_wave' : null));
}
function drawHead(headKey, rot, headTy, stageTx, stageTy, alpha){
const key=headKeyFallback(headKey); if(!key)return;
const hl=LAYOUT.heads[headKey]||LAYOUT.heads[key]; const im=images[key]; if(!hl||!im)return;
const hs=HEAD_TARGET_W/hl.w;
const ax=hl.neckNorm[0]*im.naturalWidth, ay=hl.neckNorm[1]*im.naturalHeight;
const M=mul(T(stageTx||0,stageTy||0),
mul(T(NECK_X, NECK_Y+OVERLAP+(headTy||0)),
mul(R(rot||0), mul(Sc(hs), T(-ax,-ay)))));
ctx.globalAlpha=alpha==null?1:alpha;
ctx.setTransform(M.a,M.b,M.c,M.d,M.e,M.f); ctx.drawImage(im,0,0);
ctx.setTransform(1,0,0,1,0,0); ctx.globalAlpha=1;
}
/* current expression + mouth lip-sync frame */
function currentHeadKey(clip, now){
const face=clip.layers.face; const fe=activeEntry(face,now);
const expr=fe?fe.expr:'neutral';
const exprKey='isabel_head_wave_'+expr;
const mouth=clip.layers.mouth||[];
for(const m of mouth){
if(now>=m.t && now < m.t+(m.dur||0)){
// ~7Hz swap between talk / expr / talk_wide to approximate lip-sync
const seq=['isabel_head_wave_talk', exprKey, 'isabel_head_wave_talk_wide', exprKey];
return seq[Math.floor((now-m.t)*7)%seq.length];
}
}
return exprKey;
}
/* baked body layer draw (body + head) at given alpha */
function drawBakedLayer(clip, entry, now, stageTx, stageTy, headRot, headTy, alpha){
drawBakedBody(entry.image, stageTx, stageTy, alpha);
drawHead(currentHeadKey(clip, now), headRot, headTy, stageTx, stageTy, alpha);
}
/* rig body layer draw (rig already includes head; add head rot/ty delta) */
function drawRigLayer(clip, now, stageTx, stageTy, headRot, headTy, alpha){
drawRig(now, { head:{ rot:headRot, ty:headTy } }, stageTx, stageTy, alpha);
}
function drawBodyEntry(clip, entry, now, stageTx, stageTy, headRot, headTy, alpha){
if(entry.mode==='baked') drawBakedLayer(clip, entry, now, stageTx, stageTy, headRot, headTy, alpha);
else drawRigLayer(clip, now, stageTx, stageTy, headRot, headTy, alpha);
}
/* speech bubble caption near the head */
let showCaption=true;
function drawCaption(clip, now, stageTx, stageTy){
if(!showCaption)return;
const cap=(clip.layers.caption||[]).find(c=>now>=c.t && now<c.t+(c.dur||0));
if(!cap)return;
const cx=NECK_X+(stageTx||0), topY=NECK_Y-190+(stageTy||0);
ctx.save(); ctx.setTransform(1,0,0,1,0,0);
ctx.font='600 20px system-ui, "Segoe UI", sans-serif';
const tw=ctx.measureText(cap.text).width;
const padX=14, padY=10, bw=tw+padX*2, bh=34, bx=cx-bw/2, by=topY-bh;
ctx.fillStyle='rgba(20,24,30,0.92)'; ctx.strokeStyle='#2e9e8b'; ctx.lineWidth=2;
roundRect(bx,by,bw,bh,10); ctx.fill(); ctx.stroke();
// tail
ctx.beginPath(); ctx.moveTo(cx-8,by+bh); ctx.lineTo(cx+8,by+bh); ctx.lineTo(cx,by+bh+12); ctx.closePath();
ctx.fillStyle='rgba(20,24,30,0.92)'; ctx.fill();
ctx.fillStyle='#e6f7f2'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(cap.text, cx, by+bh/2);
ctx.restore();
}
function roundRect(x,y,w,h,r){ ctx.beginPath(); ctx.moveTo(x+r,y); ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r); ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath(); }
/* ============================================================
Reaction render (full frame)
============================================================ */
function renderReaction(clip, now){
ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,cv.width,cv.height);
const tr=clip.layers.transform||{};
const stageTx=sT(tr.pelvis,'tx',now)+sT(tr.chest,'tx',now);
const stageTy=sT(tr.pelvis,'ty',now)+sT(tr.chest,'ty',now);
const headRot=sT(tr.head,'rot',now);
const headTy =sT(tr.head,'ty',now);
const entries=clip.layers.body||[];
const ai=activeIndex(entries,now);
const cur=entries[ai];
const fade=cur?cur.fade||0:0;
if(cur && fade>0 && ai>0 && now < cur.t+fade){
const p=Math.max(0,Math.min(1,(now-cur.t)/fade));
drawBodyEntry(clip, entries[ai-1], now, stageTx, stageTy, headRot, headTy, 1-p); // outgoing
drawBodyEntry(clip, cur, now, stageTx, stageTy, headRot, headTy, p); // incoming
} else if(cur){
drawBodyEntry(clip, cur, now, stageTx, stageTy, headRot, headTy, 1);
}
drawCaption(clip, now, stageTx, stageTy);
}
function renderIdle(time){
ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,cv.width,cv.height);
drawRig(time, null, 0, 0, 1);
}
/* ============================================================
Playback loop / state machine
============================================================ */
let stateMode='idle'; // 'idle' | 'reaction'
let curClip=null, reactTime=0, idleTime=0;
let playing=true, speed=1, last=performance.now();
function playClip(name){
const clip=CLIPS[name];
if(!clip){ stateMode='idle'; setStateName('dance_idle'); return; }
curClip=clip; reactTime=0; stateMode='reaction'; setStateName(clip.name);
}
function goIdle(){ stateMode='idle'; curClip=null; setStateName('dance_idle'); }
function setStateName(n){ document.getElementById('stateName').textContent=n; }
function frame(now){
const dt=(now-last)/1000; last=now;
if(playing){
if(stateMode==='reaction' && curClip){
reactTime+=dt*speed;
if(reactTime>=curClip.duration){ goIdle(); }
} else {
idleTime+=dt*speed; if(idleTime>=DANCE_IDLE.duration) idleTime%=DANCE_IDLE.duration;
}
}
if(stateMode==='reaction' && curClip) renderReaction(curClip, reactTime);
else renderIdle(idleTime);
requestAnimationFrame(frame);
}
/* ============================================================
UI
============================================================ */
const trigRow=document.getElementById('trigRow');
Object.keys(REACTIONS.map).forEach(key=>{
const clipName=REACTIONS.map[key];
const btn=document.createElement('button');
btn.className='trig';
btn.innerHTML=`▶ ${key} <span class="tag">${clipName}</span>`;
btn.onclick=()=>{
if(key==='idle' || clipName===REACTIONS.idleDefault){ idleTime=0; goIdle(); }
else playClip(clipName);
};
trigRow.appendChild(btn);
});
const playBtn=document.getElementById('playBtn'), capBtn=document.getElementById('capBtn');
playBtn.onclick=()=>{ playing=!playing; playBtn.textContent=playing?'⏸ 일시정지':'▶ 재생'; playBtn.classList.toggle('on',playing); };
capBtn.onclick=()=>{ showCaption=!showCaption; capBtn.classList.toggle('on',showCaption); };
document.getElementById('speed').oninput=e=>{ speed=parseFloat(e.target.value); document.getElementById('speedV').textContent=speed.toFixed(2)+'×'; };
document.getElementById('imgFiles').onchange=e=>{
[...e.target.files].forEach(f=>{
const key=f.name.replace(/\.png$/i,"");
const im=new Image(); im.onload=()=>{ if(!images[key])loadOk++; images[key]=im; refreshInfo(); }; im.src=URL.createObjectURL(f);
});
};
loadAllAssets(); refreshInfo(); requestAnimationFrame(frame);
</script>
</body>
</html>