bible-strong-avatar-lab/src/proceduralBrowserRuntime.ts
2026-08-13 18:31:27 +02:00

352 lines
14 KiB
TypeScript

export const proceduralBrowserRuntime = `
const SVG_NS = 'http://www.w3.org/2000/svg';
const avatarInstanceId = () => typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
const clamp01 = value => Math.max(0, Math.min(1, value));
const easeProgress = (progress, transition) => transition === 'smooth'
? progress * progress * (3 - 2 * progress)
: transition === 'snappy'
? 1 - (1 - progress) ** 3
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
const nearestAngle = (target, current) => {
let resolved = target;
while (resolved - current > 180) resolved -= 360;
while (resolved - current < -180) resolved += 360;
return resolved;
};
const resolvedTargetExpression = (target, current) => ({
...target,
headX: nearestAngle(target.headX, current.headX),
headY: nearestAngle(target.headY, current.headY),
headZ: nearestAngle(target.headZ, current.headZ),
leftAngle: nearestAngle(target.leftAngle, current.leftAngle),
rightAngle: nearestAngle(target.rightAngle, current.rightAngle),
});
const colorChannels = color => {
const value = color.replace('#', '');
const hex = value.length === 3 ? value.split('').map(channel => channel + channel).join('') : value;
const numeric = Number.parseInt(hex, 16);
return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255];
};
const interpolateColor = (from, to, progress) => {
const left = colorChannels(from);
const right = colorChannels(to);
const value = left.map((channel, index) => Math.round(channel + (right[index] - channel) * progress));
return '#' + value.map(channel => channel.toString(16).padStart(2, '0')).join('');
};
const resolveColors = expression => ({
body: expression.bodyColor || DATA.avatar.colors.body,
eyes: expression.eyeColor || DATA.avatar.colors.eyes,
});
const svgElement = name => document.createElementNS(SVG_NS, name);
function mountAvatar(target, options = {}) {
const host = typeof target === 'string' ? document.querySelector(target) : target;
if (!host) throw new Error('Avatar target was not found.');
const animationNames = Object.keys(DATA.animations);
if (!animationNames.length) throw new Error('The avatar export contains no animations.');
const instanceId = avatarInstanceId();
const clipId = 'avatar-procedural-clip-' + instanceId;
const svg = svgElement('svg');
svg.setAttribute('viewBox', '-150 -150 300 300');
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', DATA.avatar.name);
svg.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
svg.style.display = 'block';
svg.style.overflow = 'visible';
const defs = svgElement('defs');
const clipPath = svgElement('clipPath');
const clipHead = svgElement('path');
clipPath.id = clipId;
clipPath.append(clipHead);
defs.append(clipPath);
svg.append(defs);
const motionLayer = svgElement('g');
const backLayer = svgElement('g');
const head = svgElement('path');
const eyesLayer = svgElement('g');
const leftEye = svgElement('path');
const rightEye = svgElement('path');
const frontLayer = svgElement('g');
eyesLayer.setAttribute('clip-path', 'url(#' + clipId + ')');
eyesLayer.append(leftEye, rightEye);
motionLayer.append(backLayer, head, eyesLayer, frontLayer);
svg.append(motionLayer);
host.replaceChildren(svg);
const ensurePaths = (group, paths, fill) => {
while (group.children.length < paths.length) group.append(svgElement('path'));
while (group.children.length > paths.length) group.lastElementChild.remove();
paths.forEach((path, index) => {
group.children[index].setAttribute('d', path);
group.children[index].setAttribute('fill', fill);
});
};
let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0];
const initialStep = DATA.animations[currentAnimation].steps[0];
const initialExpression = DATA.expressions[initialStep.expressionId];
let currentPose = AvatarProceduralEngine.poseFromExpression(initialExpression);
let currentColors = resolveColors(initialExpression);
let blinkAmount = 1;
let transitionState = null;
let blinkState = null;
let frameRequest = null;
let stepTimer = null;
let blinkTimer = null;
let blinkDueAt = null;
let stepIndex = 0;
let direction = 1;
let playing = false;
let paused = false;
let pausedRemainingMs = 0;
let pausedTransition = null;
let pausedBlink = null;
let pausedBlinkDelay = 0;
let stepDueAt = null;
let ambientStartedAt = performance.now();
let ambientSignature = initialExpression.eyeMotion + ':' + initialExpression.bodyMotion;
let lastAmbientFrame = 0;
const applyMotion = expression => {
const signature = expression.eyeMotion + ':' + expression.bodyMotion;
if (signature !== ambientSignature) {
ambientSignature = signature;
ambientStartedAt = performance.now();
}
};
const render = (time = performance.now()) => {
const elapsed = time - ambientStartedAt;
const expression = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)
? AvatarProceduralEngine.applyAmbientMotion(currentPose.expression, elapsed)
: currentPose.expression;
const renderedPose = AvatarProceduralEngine.poseFromExpression(expression);
const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, {
includeWire: false,
bodyNodes: DATA.avatar.bodyNodes,
});
const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, elapsed);
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
ensurePaths(backLayer, geometry.backPaths, currentColors.body);
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
head.setAttribute('d', geometry.headPath);
head.setAttribute('fill', currentColors.body);
clipHead.setAttribute('d', geometry.headPath);
leftEye.setAttribute('d', geometry.leftPath);
rightEye.setAttribute('d', geometry.rightPath);
leftEye.setAttribute('fill', currentColors.eyes);
rightEye.setAttribute('fill', currentColors.eyes);
leftEye.style.display = geometry.leftVisible ? '' : 'none';
rightEye.style.display = geometry.rightVisible ? '' : 'none';
};
const tick = time => {
frameRequest = null;
if (transitionState) {
const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs);
const eased = easeProgress(linear, transitionState.transition);
const expression = { ...transitionState.fromPose.expression };
AvatarProceduralEngine.expressionFields.forEach(field => {
expression[field] = transitionState.fromPose.expression[field] +
(transitionState.toPose.expression[field] - transitionState.fromPose.expression[field]) * eased;
});
expression.eyeMotion = transitionState.toPose.expression.eyeMotion;
expression.bodyMotion = transitionState.toPose.expression.bodyMotion;
currentPose = AvatarProceduralEngine.poseFromExpression(expression);
currentColors = {
body: interpolateColor(transitionState.fromColors.body, transitionState.toColors.body, clamp01(eased)),
eyes: interpolateColor(transitionState.fromColors.eyes, transitionState.toColors.eyes, clamp01(eased)),
};
if (linear >= 1) {
currentPose = transitionState.toPose;
currentColors = transitionState.toColors;
transitionState = null;
}
}
if (blinkState) {
const progress = clamp01((time - blinkState.startedAt) / blinkState.durationMs);
if (progress <= 0.42) {
const closeProgress = progress / 0.42;
blinkAmount = 1 - closeProgress * closeProgress;
} else {
const openProgress = (progress - 0.42) / 0.58;
blinkAmount = 1 - (1 - openProgress) ** 2;
}
if (progress >= 1) {
blinkAmount = 1;
blinkState = null;
}
}
const ambientActive = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression);
if (transitionState || blinkState || !ambientActive || time - lastAmbientFrame >= 1000 / 30) {
render(time);
if (ambientActive) lastAmbientFrame = time;
}
if (transitionState || blinkState || ambientActive) frameRequest = requestAnimationFrame(tick);
};
const requestTick = () => {
if (frameRequest === null) frameRequest = requestAnimationFrame(tick);
};
const animateTo = (expressionId, durationMs, transition) => {
const target = DATA.expressions[expressionId];
if (!target) return;
applyMotion(target);
const resolved = resolvedTargetExpression(target, currentPose.expression);
const targetPose = AvatarProceduralEngine.poseFromExpression(resolved);
const targetColors = resolveColors(target);
if (durationMs <= 0) {
transitionState = null;
currentPose = targetPose;
currentColors = targetColors;
render();
if (AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)) requestTick();
return;
}
transitionState = {
fromPose: currentPose,
toPose: targetPose,
fromColors: currentColors,
toColors: targetColors,
startedAt: performance.now(),
durationMs,
transition,
expressionId,
};
requestTick();
};
const clearSchedule = () => {
if (stepTimer !== null) clearTimeout(stepTimer);
if (blinkTimer !== null) clearTimeout(blinkTimer);
stepTimer = null;
blinkTimer = null;
blinkDueAt = null;
stepDueAt = null;
};
const scheduleBlink = (animation, delay) => {
if (!animation.blink.enabled) return;
blinkDueAt = performance.now() + delay;
blinkTimer = setTimeout(() => {
blinkDueAt = null;
blinkState = { startedAt: performance.now(), durationMs: animation.blink.durationMs };
requestTick();
const range = animation.blink.maxIntervalMs - animation.blink.minIntervalMs;
scheduleBlink(animation, animation.blink.durationMs + animation.blink.minIntervalMs + Math.random() * range);
}, delay);
};
const advance = animation => {
const last = animation.steps.length - 1;
const playbackMode = options.loop === true ? 'loop' : options.loop === false ? 'once' : animation.playbackMode;
if (playbackMode === 'once' && stepIndex >= last) {
playing = false;
options.onAnimationEnd?.(currentAnimation);
return;
}
if (playbackMode === 'pingPong' && last > 0) {
if (stepIndex >= last) direction = -1;
else if (stepIndex <= 0) direction = 1;
stepIndex += direction;
} else stepIndex = (stepIndex + 1) % (last + 1);
runStep(animation);
};
const runStep = animation => {
if (!playing || !animation.steps.length) return;
const step = animation.steps[stepIndex];
animateTo(step.expressionId, step.transitionMs, step.transition);
const duration = step.transitionMs + step.holdMs;
stepDueAt = performance.now() + duration;
stepTimer = setTimeout(() => advance(animation), duration);
};
const api = {
element: svg,
get animation() { return currentAnimation; },
get playing() { return playing; },
play(animationName) {
animationName = animationName || currentAnimation;
if (!DATA.animations[animationName]) throw new Error('Unknown animation: ' + animationName);
clearSchedule();
if (animationName === currentAnimation && paused) {
paused = false;
playing = true;
if (pausedTransition) animateTo(pausedTransition.expressionId, pausedTransition.durationMs, pausedTransition.transition);
if (pausedBlink) {
blinkState = {
startedAt: performance.now() - pausedBlink.progress * pausedBlink.durationMs,
durationMs: pausedBlink.durationMs,
};
requestTick();
}
stepDueAt = performance.now() + pausedRemainingMs;
stepTimer = setTimeout(() => advance(DATA.animations[currentAnimation]), pausedRemainingMs);
scheduleBlink(
DATA.animations[currentAnimation],
pausedBlinkDelay || DATA.animations[currentAnimation].blink.minIntervalMs
);
pausedTransition = null;
pausedBlink = null;
pausedBlinkDelay = 0;
return api;
}
currentAnimation = animationName;
stepIndex = 0;
direction = 1;
paused = false;
playing = true;
runStep(DATA.animations[currentAnimation]);
scheduleBlink(DATA.animations[currentAnimation], DATA.animations[currentAnimation].blink.initialDelayMs);
return api;
},
pause() {
const now = performance.now();
if (playing && stepDueAt !== null) pausedRemainingMs = Math.max(stepDueAt - now, 0);
pausedBlinkDelay = blinkDueAt === null ? 0 : Math.max(blinkDueAt - now, 0);
if (transitionState) {
const elapsed = now - transitionState.startedAt;
pausedTransition = {
expressionId: transitionState.expressionId,
durationMs: Math.max(transitionState.durationMs - elapsed, 0),
transition: transitionState.transition,
};
}
if (blinkState) {
pausedBlink = {
progress: clamp01((now - blinkState.startedAt) / blinkState.durationMs),
durationMs: blinkState.durationMs,
};
}
clearSchedule();
transitionState = null;
blinkState = null;
paused = true;
playing = false;
render();
return api;
},
stop() {
clearSchedule();
transitionState = null;
blinkState = null;
blinkAmount = 1;
pausedBlink = null;
pausedBlinkDelay = 0;
paused = false;
playing = false;
stepIndex = 0;
direction = 1;
const first = DATA.animations[currentAnimation].steps[0];
if (first) animateTo(first.expressionId, 0, first.transition);
return api;
},
destroy() {
clearSchedule();
if (frameRequest !== null) cancelAnimationFrame(frameRequest);
svg.remove();
},
};
applyMotion(initialExpression);
render();
if (AvatarProceduralEngine.hasAmbientMotion(initialExpression)) requestTick();
if (options.autoplay !== false) api.play(currentAnimation);
return api;
}
`