Smooth LeeSori dance pose transitions

This commit is contained in:
eKeerar
2026-07-04 11:17:45 +09:00
parent 95fd05b8a9
commit 1264326c37
6 changed files with 566 additions and 28 deletions
@@ -1,6 +1,6 @@
{
"name": "LeeSoriDance",
"status": "leesori_solo3_pose_sequence_no_overlap_2026_07_04",
"status": "leesori_solo3_motion_tween_2026_07_04",
"renderMode": "poseSequence",
"canvas": {
"width": 1024,
@@ -8,7 +8,7 @@
},
"imageBase": "./Images/",
"poseBase": "./Gestures/dance/solo3/",
"note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime shows exactly one normalized frame at a time to avoid residual image overlap; original parts are retained under partsRig for future rigging.",
"note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime crossfades normalized frames with per-frame transform offsets so the motion reads as a continuous upper-body dance while still avoiding residual image overlap.",
"gestures": [
{
"id": "leesori.dance.solo3",
@@ -21,7 +21,7 @@
"idle",
"success"
],
"frameMs": 260,
"frameMs": 340,
"loop": true,
"tags": [
"upper-body",
@@ -33,29 +33,222 @@
"frames": [
{
"image": "frame_01_ready.png",
"label": "ready bounce"
"label": "ready bounce",
"motion": {
"target": {
"x": 0,
"y": 0,
"rotate": -0.6,
"scale": 1
},
"enter": {
"x": -5,
"y": 5,
"rotate": -0.8,
"scale": 0.992
},
"exit": {
"x": 4,
"y": -3,
"rotate": 0.8,
"scale": 1.006
}
}
},
{
"image": "frame_03_wrist_wave.png",
"label": "wrist wave prep",
"motion": {
"target": {
"x": -4,
"y": -2,
"rotate": 0.4,
"scale": 1.012
},
"enter": {
"x": -10,
"y": 4,
"rotate": -0.7,
"scale": 0.99
},
"exit": {
"x": 5,
"y": -3,
"rotate": 0.7,
"scale": 1.004
}
}
},
{
"image": "frame_02_hand_forward.png",
"label": "hand forward"
"label": "hand forward",
"motion": {
"target": {
"x": -8,
"y": -5,
"rotate": 0.8,
"scale": 1.026
},
"enter": {
"x": -12,
"y": 5,
"rotate": -0.8,
"scale": 0.988
},
"exit": {
"x": 8,
"y": -4,
"rotate": 0.9,
"scale": 0.996
}
}
},
{
"image": "frame_03_wrist_wave.png",
"label": "wrist wave"
"label": "wrist wave return",
"motion": {
"target": {
"x": 3,
"y": -2,
"rotate": -0.3,
"scale": 1.014
},
"enter": {
"x": 8,
"y": 4,
"rotate": 0.9,
"scale": 0.992
},
"exit": {
"x": -5,
"y": -4,
"rotate": -0.8,
"scale": 1.002
}
}
},
{
"image": "frame_04_arms_up.png",
"label": "arms up"
"label": "arms up lift",
"motion": {
"target": {
"x": 0,
"y": -12,
"rotate": 0.2,
"scale": 1.022
},
"enter": {
"x": -5,
"y": 7,
"rotate": -0.7,
"scale": 0.99
},
"exit": {
"x": 5,
"y": -5,
"rotate": 0.6,
"scale": 1.004
}
}
},
{
"image": "frame_05_hair_touch.png",
"label": "hair touch"
"label": "hair touch",
"motion": {
"target": {
"x": -5,
"y": -4,
"rotate": 1,
"scale": 1.018
},
"enter": {
"x": 6,
"y": 5,
"rotate": -0.6,
"scale": 0.992
},
"exit": {
"x": -6,
"y": -3,
"rotate": -0.8,
"scale": 1.002
}
}
},
{
"image": "frame_04_arms_up.png",
"label": "arms up release",
"motion": {
"target": {
"x": 4,
"y": -8,
"rotate": -0.4,
"scale": 1.018
},
"enter": {
"x": -7,
"y": 4,
"rotate": 0.8,
"scale": 0.992
},
"exit": {
"x": 6,
"y": -3,
"rotate": 0.7,
"scale": 1.004
}
}
},
{
"image": "frame_03_wrist_wave.png",
"label": "return wave"
"label": "settle wave",
"motion": {
"target": {
"x": -2,
"y": -1,
"rotate": 0.2,
"scale": 1.01
},
"enter": {
"x": 7,
"y": 5,
"rotate": -0.7,
"scale": 0.992
},
"exit": {
"x": -4,
"y": -3,
"rotate": 0.7,
"scale": 1.002
}
}
},
{
"image": "frame_01_ready.png",
"label": "ready settle",
"motion": {
"target": {
"x": 1,
"y": 1,
"rotate": 0.3,
"scale": 1.004
},
"enter": {
"x": 6,
"y": 4,
"rotate": 0.6,
"scale": 0.994
},
"exit": {
"x": -5,
"y": -3,
"rotate": -0.6,
"scale": 1.002
}
}
}
]
],
"transitionMs": 190
}
],
"partsRig": {
@@ -82,19 +82,85 @@ function selectGesture(rig, state) {
|| null;
}
function motionTransform(frame, phase) {
const motion = frame?.motion || {};
const target = motion.target || { x: 0, y: 0, rotate: 0, scale: 1 };
const offset = phase === "target"
? { x: 0, y: 0, rotate: 0, scale: 1 }
: (motion[phase] || { x: 0, y: 0, rotate: 0, scale: 1 });
const x = (target.x || 0) + (offset.x || 0);
const y = (target.y || 0) + (offset.y || 0);
const rotate = (target.rotate || 0) + (offset.rotate || 0);
const scale = (target.scale || 1) * (offset.scale || 1);
return `translate(${x}px, ${y}px) rotate(${rotate}deg) scale(${scale})`;
}
function frameForElement(element) {
const frames = loadedRig?.activeGesture?.frames || [];
return frames[Number(element.dataset.frameIndex)] || {};
}
function hideInactiveFrame(frame) {
frame.classList.remove("is-active", "is-entering", "is-exiting");
frame.style.opacity = "0";
frame.style.zIndex = "1";
}
function setActiveGestureFrame(index) {
const frames = [...puppet.querySelectorAll(".puppet-frame")];
if (!frames.length) return;
const activeIndex = ((index % frames.length) + frames.length) % frames.length;
const transitionMs = Math.max(80, Number(loadedRig?.activeGesture?.transitionMs || 170));
const current = frames.find(frame => frame.classList.contains("is-active"));
const next = frames[activeIndex];
if (!next || current === next) return;
for (const frame of frames) {
frame.classList.toggle("is-active", Number(frame.dataset.frameIndex) === activeIndex);
if (frame !== current && frame !== next) {
hideInactiveFrame(frame);
}
}
if (current) {
current.classList.remove("is-active", "is-entering");
current.classList.add("is-exiting");
current.style.transitionDuration = `${transitionMs}ms`;
current.style.zIndex = "2";
current.style.opacity = "0";
current.style.transform = motionTransform(frameForElement(current), "exit");
window.setTimeout(() => {
if (current.classList.contains("is-exiting")) hideInactiveFrame(current);
}, transitionMs + 40);
}
next.classList.remove("is-exiting");
next.classList.add("is-entering");
next.style.transitionDuration = `${transitionMs}ms`;
next.style.zIndex = "3";
next.style.opacity = "0";
next.style.transform = motionTransform(frameForElement(next), "enter");
void next.offsetWidth;
next.classList.add("is-active");
window.requestAnimationFrame(() => {
next.style.opacity = "1";
next.style.transform = motionTransform(frameForElement(next), "target");
});
}
function startPoseSequence(gesture) {
stopGestureTimer();
setActiveGestureFrame(0);
const frameMs = Math.max(80, Number(gesture.frameMs || 260));
loadedRig.activeGesture = gesture;
const first = puppet.querySelector('.puppet-frame[data-frame-index="0"]');
if (first) {
first.style.transitionDuration = "0ms";
first.style.opacity = "1";
first.style.zIndex = "3";
first.style.transform = motionTransform(frameForElement(first), "target");
first.classList.add("is-active");
}
const frameMs = Math.max(120, Number(gesture.frameMs || 340));
if (gesture.loop !== false && (gesture.frames?.length || 0) > 1) {
gestureTimer = window.setInterval(() => {
gestureFrame += 1;
@@ -123,6 +189,7 @@ function renderPoseSequence(rig, active, gesture) {
img.src = `${poseBase}${frame.image}?v=${assetVersion}`;
img.alt = "";
img.decoding = "async";
img.style.transform = motionTransform(frame, "enter");
fragment.appendChild(img);
}
puppet.replaceChildren(fragment);
@@ -256,3 +323,4 @@ window.addEventListener("DOMContentLoaded", async () => {
setState("idle");
post("hostReady");
});
@@ -76,13 +76,20 @@ body {
.puppet-frame {
opacity: 0;
z-index: 1;
transform-origin: 50% 38%;
transform-origin: 50% 40%;
transition-property: opacity, transform;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(.22,.84,.32,1);
}
.puppet-frame.is-active {
opacity: 1;
}
.puppet-frame.is-exiting {
opacity: 0;
}
#puppet[data-mode="pose-sequence"] .puppet-part {
display: none;
}
@@ -456,3 +463,4 @@ body {
@@ -1,6 +1,6 @@
{
"name": "LeeSoriDance",
"status": "leesori_solo3_pose_sequence_no_overlap_2026_07_04",
"status": "leesori_solo3_motion_tween_2026_07_04",
"renderMode": "poseSequence",
"canvas": {
"width": 1024,
@@ -8,7 +8,7 @@
},
"imageBase": "./Images/",
"poseBase": "./Gestures/dance/solo3/",
"note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime shows exactly one normalized frame at a time to avoid residual image overlap; original parts are retained under partsRig for future rigging.",
"note": "Solo Dance 3 inspired reusable pose-sequence gesture. Runtime crossfades normalized frames with per-frame transform offsets so the motion reads as a continuous upper-body dance while still avoiding residual image overlap.",
"gestures": [
{
"id": "leesori.dance.solo3",
@@ -21,7 +21,7 @@
"idle",
"success"
],
"frameMs": 260,
"frameMs": 340,
"loop": true,
"tags": [
"upper-body",
@@ -33,29 +33,222 @@
"frames": [
{
"image": "frame_01_ready.png",
"label": "ready bounce"
"label": "ready bounce",
"motion": {
"target": {
"x": 0,
"y": 0,
"rotate": -0.6,
"scale": 1
},
"enter": {
"x": -5,
"y": 5,
"rotate": -0.8,
"scale": 0.992
},
"exit": {
"x": 4,
"y": -3,
"rotate": 0.8,
"scale": 1.006
}
}
},
{
"image": "frame_03_wrist_wave.png",
"label": "wrist wave prep",
"motion": {
"target": {
"x": -4,
"y": -2,
"rotate": 0.4,
"scale": 1.012
},
"enter": {
"x": -10,
"y": 4,
"rotate": -0.7,
"scale": 0.99
},
"exit": {
"x": 5,
"y": -3,
"rotate": 0.7,
"scale": 1.004
}
}
},
{
"image": "frame_02_hand_forward.png",
"label": "hand forward"
"label": "hand forward",
"motion": {
"target": {
"x": -8,
"y": -5,
"rotate": 0.8,
"scale": 1.026
},
"enter": {
"x": -12,
"y": 5,
"rotate": -0.8,
"scale": 0.988
},
"exit": {
"x": 8,
"y": -4,
"rotate": 0.9,
"scale": 0.996
}
}
},
{
"image": "frame_03_wrist_wave.png",
"label": "wrist wave"
"label": "wrist wave return",
"motion": {
"target": {
"x": 3,
"y": -2,
"rotate": -0.3,
"scale": 1.014
},
"enter": {
"x": 8,
"y": 4,
"rotate": 0.9,
"scale": 0.992
},
"exit": {
"x": -5,
"y": -4,
"rotate": -0.8,
"scale": 1.002
}
}
},
{
"image": "frame_04_arms_up.png",
"label": "arms up"
"label": "arms up lift",
"motion": {
"target": {
"x": 0,
"y": -12,
"rotate": 0.2,
"scale": 1.022
},
"enter": {
"x": -5,
"y": 7,
"rotate": -0.7,
"scale": 0.99
},
"exit": {
"x": 5,
"y": -5,
"rotate": 0.6,
"scale": 1.004
}
}
},
{
"image": "frame_05_hair_touch.png",
"label": "hair touch"
"label": "hair touch",
"motion": {
"target": {
"x": -5,
"y": -4,
"rotate": 1,
"scale": 1.018
},
"enter": {
"x": 6,
"y": 5,
"rotate": -0.6,
"scale": 0.992
},
"exit": {
"x": -6,
"y": -3,
"rotate": -0.8,
"scale": 1.002
}
}
},
{
"image": "frame_04_arms_up.png",
"label": "arms up release",
"motion": {
"target": {
"x": 4,
"y": -8,
"rotate": -0.4,
"scale": 1.018
},
"enter": {
"x": -7,
"y": 4,
"rotate": 0.8,
"scale": 0.992
},
"exit": {
"x": 6,
"y": -3,
"rotate": 0.7,
"scale": 1.004
}
}
},
{
"image": "frame_03_wrist_wave.png",
"label": "return wave"
"label": "settle wave",
"motion": {
"target": {
"x": -2,
"y": -1,
"rotate": 0.2,
"scale": 1.01
},
"enter": {
"x": 7,
"y": 5,
"rotate": -0.7,
"scale": 0.992
},
"exit": {
"x": -4,
"y": -3,
"rotate": 0.7,
"scale": 1.002
}
}
},
{
"image": "frame_01_ready.png",
"label": "ready settle",
"motion": {
"target": {
"x": 1,
"y": 1,
"rotate": 0.3,
"scale": 1.004
},
"enter": {
"x": 6,
"y": 4,
"rotate": 0.6,
"scale": 0.994
},
"exit": {
"x": -5,
"y": -3,
"rotate": -0.6,
"scale": 1.002
}
}
}
]
],
"transitionMs": 190
}
],
"partsRig": {
@@ -82,19 +82,85 @@ function selectGesture(rig, state) {
|| null;
}
function motionTransform(frame, phase) {
const motion = frame?.motion || {};
const target = motion.target || { x: 0, y: 0, rotate: 0, scale: 1 };
const offset = phase === "target"
? { x: 0, y: 0, rotate: 0, scale: 1 }
: (motion[phase] || { x: 0, y: 0, rotate: 0, scale: 1 });
const x = (target.x || 0) + (offset.x || 0);
const y = (target.y || 0) + (offset.y || 0);
const rotate = (target.rotate || 0) + (offset.rotate || 0);
const scale = (target.scale || 1) * (offset.scale || 1);
return `translate(${x}px, ${y}px) rotate(${rotate}deg) scale(${scale})`;
}
function frameForElement(element) {
const frames = loadedRig?.activeGesture?.frames || [];
return frames[Number(element.dataset.frameIndex)] || {};
}
function hideInactiveFrame(frame) {
frame.classList.remove("is-active", "is-entering", "is-exiting");
frame.style.opacity = "0";
frame.style.zIndex = "1";
}
function setActiveGestureFrame(index) {
const frames = [...puppet.querySelectorAll(".puppet-frame")];
if (!frames.length) return;
const activeIndex = ((index % frames.length) + frames.length) % frames.length;
const transitionMs = Math.max(80, Number(loadedRig?.activeGesture?.transitionMs || 170));
const current = frames.find(frame => frame.classList.contains("is-active"));
const next = frames[activeIndex];
if (!next || current === next) return;
for (const frame of frames) {
frame.classList.toggle("is-active", Number(frame.dataset.frameIndex) === activeIndex);
if (frame !== current && frame !== next) {
hideInactiveFrame(frame);
}
}
if (current) {
current.classList.remove("is-active", "is-entering");
current.classList.add("is-exiting");
current.style.transitionDuration = `${transitionMs}ms`;
current.style.zIndex = "2";
current.style.opacity = "0";
current.style.transform = motionTransform(frameForElement(current), "exit");
window.setTimeout(() => {
if (current.classList.contains("is-exiting")) hideInactiveFrame(current);
}, transitionMs + 40);
}
next.classList.remove("is-exiting");
next.classList.add("is-entering");
next.style.transitionDuration = `${transitionMs}ms`;
next.style.zIndex = "3";
next.style.opacity = "0";
next.style.transform = motionTransform(frameForElement(next), "enter");
void next.offsetWidth;
next.classList.add("is-active");
window.requestAnimationFrame(() => {
next.style.opacity = "1";
next.style.transform = motionTransform(frameForElement(next), "target");
});
}
function startPoseSequence(gesture) {
stopGestureTimer();
setActiveGestureFrame(0);
const frameMs = Math.max(80, Number(gesture.frameMs || 260));
loadedRig.activeGesture = gesture;
const first = puppet.querySelector('.puppet-frame[data-frame-index="0"]');
if (first) {
first.style.transitionDuration = "0ms";
first.style.opacity = "1";
first.style.zIndex = "3";
first.style.transform = motionTransform(frameForElement(first), "target");
first.classList.add("is-active");
}
const frameMs = Math.max(120, Number(gesture.frameMs || 340));
if (gesture.loop !== false && (gesture.frames?.length || 0) > 1) {
gestureTimer = window.setInterval(() => {
gestureFrame += 1;
@@ -123,6 +189,7 @@ function renderPoseSequence(rig, active, gesture) {
img.src = `${poseBase}${frame.image}?v=${assetVersion}`;
img.alt = "";
img.decoding = "async";
img.style.transform = motionTransform(frame, "enter");
fragment.appendChild(img);
}
puppet.replaceChildren(fragment);
@@ -256,3 +323,4 @@ window.addEventListener("DOMContentLoaded", async () => {
setState("idle");
post("hostReady");
});
@@ -76,13 +76,20 @@ body {
.puppet-frame {
opacity: 0;
z-index: 1;
transform-origin: 50% 38%;
transform-origin: 50% 40%;
transition-property: opacity, transform;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(.22,.84,.32,1);
}
.puppet-frame.is-active {
opacity: 1;
}
.puppet-frame.is-exiting {
opacity: 0;
}
#puppet[data-mode="pose-sequence"] .puppet-part {
display: none;
}
@@ -456,3 +463,4 @@ body {