keyframes moveable, individual motors selectable
parent
cb99feea2f
commit
93a047bbb4
295
index.html
295
index.html
|
|
@ -1,280 +1,37 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>ESP32 Animation Creator</title>
|
<title>ESP32 Animation Creator</title>
|
||||||
<script src="https://unpkg.com/nexusui"></script>
|
<script src="https://unpkg.com/nexusui"></script>
|
||||||
<style>
|
<link rel="stylesheet" href="style.css">
|
||||||
body {
|
|
||||||
font-family: sans-serif;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dial-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 30px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dial {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h2>ESP32 Animation Creator</h2>
|
<h2>ESP32 Animation Creator</h2>
|
||||||
<button id="connect">Connect</button>
|
<button id="connect">Connect</button>
|
||||||
<button id="sendFrame">Send Frame</button>
|
<button id="sendFrame">Send Frame</button>
|
||||||
|
|
||||||
<div class="dial-container">
|
<div class="dial-container">
|
||||||
<div class="dial">
|
<div class="dial" data-index="0"><label>Motor 1</label><div id="dial0"></div><span id="value0">512</span></div>
|
||||||
<label>Motor 1</label>
|
<div class="dial" data-index="1"><label>Motor 2</label><div id="dial1"></div><span id="value1">512</span></div>
|
||||||
<div id="dial0"></div>
|
<div class="dial" data-index="2"><label>Motor 3</label><div id="dial2"></div><span id="value2">512</span></div>
|
||||||
<span id="value0">512</span>
|
<div class="dial" data-index="3"><label>Motor 4</label><div id="dial3"></div><span id="value3">512</span></div>
|
||||||
</div>
|
<div class="dial" data-index="4"><label>Motor 5</label><div id="dial4"></div><span id="value4">512</span></div>
|
||||||
<div class="dial">
|
</div>
|
||||||
<label>Motor 2</label>
|
|
||||||
<div id="dial1"></div>
|
|
||||||
<span id="value1">512</span>
|
|
||||||
</div>
|
|
||||||
<div class="dial">
|
|
||||||
<label>Motor 3</label>
|
|
||||||
<div id="dial2"></div>
|
|
||||||
<span id="value2">512</span>
|
|
||||||
</div>
|
|
||||||
<div class="dial">
|
|
||||||
<label>Motor 4</label>
|
|
||||||
<div id="dial3"></div>
|
|
||||||
<span id="value3">512</span>
|
|
||||||
</div>
|
|
||||||
<div class="dial">
|
|
||||||
<label>Motor 5</label>
|
|
||||||
<div id="dial4"></div>
|
|
||||||
<span id="value4">512</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<canvas id="timelineCanvas" width="800" height="30" style="margin-bottom: 5px;"></canvas>
|
|
||||||
|
|
||||||
|
|
||||||
<div>
|
<canvas id="timelineCanvas" width="800" height="30"></canvas>
|
||||||
<label>Frame: <span id="frameDisplay">0</span></label><br>
|
|
||||||
<input type="range" id="frameSlider" min="0" max="399" value="0" style="width: 80%">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea id="log" rows="10" cols="60" readonly></textarea>
|
<div>
|
||||||
<br>
|
<label>Frame: <span id="frameDisplay">0</span></label><br>
|
||||||
<input type="text" id="input" placeholder="Type message here">
|
<input type="range" id="frameSlider" min="0" max="399" value="0" style="width: 80%">
|
||||||
<button id="send">Send</button>
|
</div>
|
||||||
<button id="saveAnimation">Save Animation</button>
|
|
||||||
|
|
||||||
<script>
|
<textarea id="log" rows="10" cols="60" readonly></textarea><br>
|
||||||
let isInterpolating = false;
|
<input type="text" id="input" placeholder="Type message here">
|
||||||
|
<button id="send">Send</button>
|
||||||
|
<button id="saveAnimation">Save Animation</button>
|
||||||
|
|
||||||
let currentFrame = 0;
|
<script src="script.js"></script>
|
||||||
let dialKeyframes = Array.from({ length: 5 }, () => ({}));
|
|
||||||
|
|
||||||
|
|
||||||
const frameSlider = document.getElementById('frameSlider');
|
|
||||||
const frameDisplay = document.getElementById('frameDisplay');
|
|
||||||
|
|
||||||
frameSlider.oninput = () => {
|
|
||||||
currentFrame = parseInt(frameSlider.value);
|
|
||||||
frameDisplay.textContent = currentFrame;
|
|
||||||
|
|
||||||
isInterpolating = true;
|
|
||||||
|
|
||||||
for (let ch = 0; ch < 5; ch++) {
|
|
||||||
const keyframes = dialKeyframes[ch];
|
|
||||||
let prevFrame = null, nextFrame = null;
|
|
||||||
|
|
||||||
for (let f = currentFrame; f >= 0; f--) {
|
|
||||||
if (keyframes[f] !== undefined) {
|
|
||||||
prevFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let f = currentFrame; f <= 399; f++) {
|
|
||||||
if (keyframes[f] !== undefined) {
|
|
||||||
nextFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let value;
|
|
||||||
if (prevFrame !== null && nextFrame !== null && prevFrame !== nextFrame) {
|
|
||||||
const prevVal = keyframes[prevFrame];
|
|
||||||
const nextVal = keyframes[nextFrame];
|
|
||||||
const t = (currentFrame - prevFrame) / (nextFrame - prevFrame);
|
|
||||||
value = Math.round(prevVal + (nextVal - prevVal) * t);
|
|
||||||
} else if (prevFrame !== null) {
|
|
||||||
value = keyframes[prevFrame];
|
|
||||||
} else if (nextFrame !== null) {
|
|
||||||
value = keyframes[nextFrame];
|
|
||||||
} else {
|
|
||||||
value = 512;
|
|
||||||
}
|
|
||||||
|
|
||||||
dials[ch].value = value;
|
|
||||||
document.getElementById(`value${ch}`).textContent = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawTimelineMarkers();
|
|
||||||
isInterpolating = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let port, reader, writer;
|
|
||||||
const dials = [];
|
|
||||||
|
|
||||||
// Initialize rotary dials
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
dials[i] = new Nexus.Dial(`#dial${i}`, {
|
|
||||||
size: [80, 80],
|
|
||||||
min: 0,
|
|
||||||
max: 1023,
|
|
||||||
value: 512
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update display when dial changes
|
|
||||||
dials[i].on('change', (v) => {
|
|
||||||
if (isInterpolating) return;
|
|
||||||
|
|
||||||
const val = Math.round(v);
|
|
||||||
document.getElementById(`value${i}`).textContent = val;
|
|
||||||
|
|
||||||
dialKeyframes[i][currentFrame] = val;
|
|
||||||
drawTimelineMarkers();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to ESP32
|
|
||||||
document.getElementById('connect').onclick = async () => {
|
|
||||||
port = await navigator.serial.requestPort();
|
|
||||||
await port.open({ baudRate: 115200 });
|
|
||||||
|
|
||||||
writer = port.writable.getWriter();
|
|
||||||
reader = port.readable.getReader();
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
document.getElementById('log').value += decoder.decode(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send typed message
|
|
||||||
document.getElementById('send').onclick = async () => {
|
|
||||||
const text = document.getElementById('input').value + '\n';
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
await writer.write(encoder.encode(text));
|
|
||||||
document.getElementById('input').value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send current dial positions as a frame
|
|
||||||
document.getElementById('sendFrame').onclick = async () => {
|
|
||||||
const positions = dials.map(d => Math.round(d.value));
|
|
||||||
const message = `FRAME ${positions.join(',')}\n`;
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
await writer.write(encoder.encode(message));
|
|
||||||
};
|
|
||||||
|
|
||||||
const canvas = document.getElementById('timelineCanvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const totalFrames = 400;
|
|
||||||
|
|
||||||
function drawTimelineMarkers() {
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
const width = canvas.width;
|
|
||||||
const height = canvas.height;
|
|
||||||
|
|
||||||
// Draw keyframe ticks for all motors
|
|
||||||
for (let ch = 0; ch < 5; ch++) {
|
|
||||||
for (let frame in dialKeyframes[ch]) {
|
|
||||||
const x = (frame / 400) * width;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, height);
|
|
||||||
ctx.strokeStyle = ['red', 'green', 'blue', 'orange', 'purple'][ch];
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw current frame marker
|
|
||||||
const currentX = (currentFrame / 400) * width;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(currentX, 0);
|
|
||||||
ctx.lineTo(currentX, height);
|
|
||||||
ctx.strokeStyle = 'black';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
document.getElementById('saveAnimation').onclick = async () => {
|
|
||||||
if (!writer) {
|
|
||||||
alert("Serial not connected.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalFrames = 400;
|
|
||||||
const buffer = new ArrayBuffer(totalFrames * 5 * 2); // 5 motors × 2 bytes
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
|
|
||||||
for (let frame = 0; frame < totalFrames; frame++) {
|
|
||||||
for (let ch = 0; ch < 5; ch++) {
|
|
||||||
const keyframes = dialKeyframes[ch];
|
|
||||||
let prevFrame = null, nextFrame = null;
|
|
||||||
|
|
||||||
for (let f = frame; f >= 0; f--) {
|
|
||||||
if (keyframes[f] !== undefined) {
|
|
||||||
prevFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let f = frame; f <= totalFrames - 1; f++) {
|
|
||||||
if (keyframes[f] !== undefined) {
|
|
||||||
nextFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let value;
|
|
||||||
if (prevFrame !== null && nextFrame !== null && prevFrame !== nextFrame) {
|
|
||||||
const prevVal = keyframes[prevFrame];
|
|
||||||
const nextVal = keyframes[nextFrame];
|
|
||||||
const t = (frame - prevFrame) / (nextFrame - prevFrame);
|
|
||||||
value = Math.round(prevVal + (nextVal - prevVal) * t);
|
|
||||||
} else if (prevFrame !== null) {
|
|
||||||
value = keyframes[prevFrame];
|
|
||||||
} else if (nextFrame !== null) {
|
|
||||||
value = keyframes[nextFrame];
|
|
||||||
} else {
|
|
||||||
value = 512;
|
|
||||||
}
|
|
||||||
|
|
||||||
view.setUint16((frame * 5 + ch) * 2, value, true); // little-endian
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(buffer);
|
|
||||||
await writer.write(buffer);
|
|
||||||
alert("Animation sent over serial.");
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
|
|
|
||||||
462
script.js
462
script.js
|
|
@ -1,176 +1,308 @@
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
let isInterpolating = false;
|
let isInterpolating = false;
|
||||||
let currentFrame = 0;
|
let currentFrame = 0;
|
||||||
let dialKeyframes = Array.from({ length: 5 }, () => ({}));
|
let dialKeyframes = Array.from({ length: 5 }, () => ({}));
|
||||||
let port, reader, writer;
|
let port, reader, writer;
|
||||||
const dials = [];
|
let selectedDial = null;
|
||||||
const frameSlider = document.getElementById('frameSlider');
|
let draggingKeyframe = null; // { dialIndex, originalFrame }
|
||||||
const frameDisplay = document.getElementById('frameDisplay');
|
let isDragging = false;
|
||||||
const canvas = document.getElementById('timelineCanvas');
|
const dialColors = ['red', 'green', 'blue', 'orange', 'purple'];
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const totalFrames = 400;
|
|
||||||
|
|
||||||
frameSlider.oninput = () => {
|
const dials = [];
|
||||||
currentFrame = parseInt(frameSlider.value);
|
const frameSlider = document.getElementById('frameSlider');
|
||||||
frameDisplay.textContent = currentFrame;
|
const frameDisplay = document.getElementById('frameDisplay');
|
||||||
isInterpolating = true;
|
const canvas = document.getElementById('timelineCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const totalFrames = 400;
|
||||||
|
|
||||||
for (let ch = 0; ch < 5; ch++) {
|
frameSlider.oninput = () => {
|
||||||
const keyframes = dialKeyframes[ch];
|
currentFrame = parseInt(frameSlider.value);
|
||||||
let prevFrame = null, nextFrame = null;
|
frameDisplay.textContent = currentFrame;
|
||||||
|
isInterpolating = true;
|
||||||
|
|
||||||
for (let f = currentFrame; f >= 0; f--) {
|
for (let ch = 0; ch < 5; ch++) {
|
||||||
if (keyframes[f] !== undefined) {
|
const keyframes = dialKeyframes[ch];
|
||||||
prevFrame = f;
|
let prevFrame = null, nextFrame = null;
|
||||||
break;
|
|
||||||
|
for (let f = currentFrame; f >= 0; f--) {
|
||||||
|
if (keyframes[f] !== undefined) {
|
||||||
|
prevFrame = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let f = currentFrame; f <= 399; f++) {
|
||||||
|
if (keyframes[f] !== undefined) {
|
||||||
|
nextFrame = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let value;
|
||||||
|
if (prevFrame !== null && nextFrame !== null && prevFrame !== nextFrame) {
|
||||||
|
const prevVal = keyframes[prevFrame];
|
||||||
|
const nextVal = keyframes[nextFrame];
|
||||||
|
const t = (currentFrame - prevFrame) / (nextFrame - prevFrame);
|
||||||
|
value = Math.round(prevVal + (nextVal - prevVal) * t);
|
||||||
|
} else if (prevFrame !== null) {
|
||||||
|
value = keyframes[prevFrame];
|
||||||
|
} else if (nextFrame !== null) {
|
||||||
|
value = keyframes[nextFrame];
|
||||||
|
} else {
|
||||||
|
value = 512;
|
||||||
|
}
|
||||||
|
|
||||||
|
dials[ch].value = value;
|
||||||
|
document.getElementById(`value${ch}`).textContent = value;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (let f = currentFrame; f <= 399; f++) {
|
|
||||||
if (keyframes[f] !== undefined) {
|
|
||||||
nextFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let value;
|
drawTimelineMarkers();
|
||||||
if (prevFrame !== null && nextFrame !== null && prevFrame !== nextFrame) {
|
isInterpolating = false;
|
||||||
const prevVal = keyframes[prevFrame];
|
};
|
||||||
const nextVal = keyframes[nextFrame];
|
|
||||||
const t = (currentFrame - prevFrame) / (nextFrame - prevFrame);
|
|
||||||
value = Math.round(prevVal + (nextVal - prevVal) * t);
|
|
||||||
} else if (prevFrame !== null) {
|
|
||||||
value = keyframes[prevFrame];
|
|
||||||
} else if (nextFrame !== null) {
|
|
||||||
value = keyframes[nextFrame];
|
|
||||||
} else {
|
|
||||||
value = 512;
|
|
||||||
}
|
|
||||||
|
|
||||||
dials[ch].value = value;
|
for (let i = 0; i < 5; i++) {
|
||||||
document.getElementById(`value${ch}`).textContent = value;
|
dials[i] = new Nexus.Dial(`#dial${i}`, {
|
||||||
|
size: [80, 80],
|
||||||
|
min: 0,
|
||||||
|
max: 1023,
|
||||||
|
value: 512
|
||||||
|
});
|
||||||
|
|
||||||
|
dials[i].colorize("accent", dialColors[i]);
|
||||||
|
|
||||||
|
dials[i].on('change', (v) => {
|
||||||
|
if (isInterpolating) return;
|
||||||
|
const val = Math.round(v);
|
||||||
|
document.getElementById(`value${i}`).textContent = val;
|
||||||
|
dialKeyframes[i][currentFrame] = val;
|
||||||
|
drawTimelineMarkers();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
document.querySelectorAll('.dial').forEach(el => {
|
||||||
|
el.onclick = () => {
|
||||||
|
selectedDial = parseInt(el.dataset.index);
|
||||||
|
|
||||||
|
document.querySelectorAll('.dial').forEach(d => d.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
|
||||||
|
drawTimelineMarkers();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
document.getElementById('connect').onclick = async () => {
|
||||||
|
port = await navigator.serial.requestPort();
|
||||||
|
await port.open({ baudRate: 115200 });
|
||||||
|
writer = port.writable.getWriter();
|
||||||
|
reader = port.readable.getReader();
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
document.getElementById('log').value += decoder.decode(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('send').onclick = async () => {
|
||||||
|
const text = document.getElementById('input').value + '\n';
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
await writer.write(encoder.encode(text));
|
||||||
|
document.getElementById('input').value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('sendFrame').onclick = async () => {
|
||||||
|
const positions = dials.map(d => Math.round(d.value));
|
||||||
|
const message = `FRAME ${positions.join(',')}\n`;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
await writer.write(encoder.encode(message));
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawTimelineMarkers() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const width = canvas.width;
|
||||||
|
const height = canvas.height;
|
||||||
|
|
||||||
|
// Draw tick marks every 50 frames
|
||||||
|
ctx.strokeStyle = '#aaa';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let f = 0; f <= totalFrames; f += 25) {
|
||||||
|
const x = (f / totalFrames) * width;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, height * 0.75); // small tick at bottom
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let f = 0; f <= totalFrames; f += 50) {
|
||||||
|
const x = (f / totalFrames) * width;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, height * 0.65); // small tick at bottom
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw label on tick marks
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
|
for (let f = 0; f <= totalFrames; f += 50) {
|
||||||
|
const x = (f / totalFrames) * width;
|
||||||
|
const seconds = f / 50;
|
||||||
|
ctx.fillText(seconds.toString() + "s", x, height - 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (selectedDial !== null) {
|
||||||
|
for (let frame in dialKeyframes[selectedDial]) {
|
||||||
|
const x = (frame / 400) * width;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.strokeStyle = dialColors[selectedDial];
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let ch = 0; ch < 5; ch++) {
|
||||||
|
for (let frame in dialKeyframes[ch]) {
|
||||||
|
const x = (frame / 400) * width;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.strokeStyle = dialColors[ch];
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentX = (currentFrame / 400) * width;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(currentX, 0);
|
||||||
|
ctx.lineTo(currentX, height);
|
||||||
|
ctx.strokeStyle = 'black';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const ignoredTags = ['BUTTON', 'INPUT', 'TEXTAREA', 'CANVAS'];
|
||||||
|
const clickedInsideDial = e.target.closest('.dial');
|
||||||
|
const clickedControl = ignoredTags.includes(e.target.tagName);
|
||||||
|
|
||||||
|
if (!clickedInsideDial && !clickedControl && selectedDial !== null) {
|
||||||
|
selectedDial = null;
|
||||||
|
document.querySelectorAll('.dial').forEach(el => el.classList.remove('selected'));
|
||||||
|
drawTimelineMarkers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
|
const x = e.offsetX;
|
||||||
|
const frame = Math.round((x / canvas.width) * totalFrames);
|
||||||
|
|
||||||
|
const dialIndices = selectedDial !== null ? [selectedDial] : [0, 1, 2, 3, 4];
|
||||||
|
|
||||||
|
for (let ch of dialIndices) {
|
||||||
|
for (let f in dialKeyframes[ch]) {
|
||||||
|
const fx = (f / totalFrames) * canvas.width;
|
||||||
|
if (Math.abs(fx - x) < 5) {
|
||||||
|
draggingKeyframe = { dialIndex: ch, originalFrame: parseInt(f) };
|
||||||
|
isDragging = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (!isDragging || !draggingKeyframe) return;
|
||||||
|
|
||||||
|
const x = e.offsetX;
|
||||||
|
const newFrame = Math.max(0, Math.min(totalFrames - 1, Math.round((x / canvas.width) * totalFrames)));
|
||||||
|
|
||||||
|
const { dialIndex, originalFrame } = draggingKeyframe;
|
||||||
|
const value = dialKeyframes[dialIndex][originalFrame];
|
||||||
|
|
||||||
|
if (newFrame !== originalFrame) {
|
||||||
|
delete dialKeyframes[dialIndex][originalFrame];
|
||||||
|
dialKeyframes[dialIndex][newFrame] = value;
|
||||||
|
draggingKeyframe.originalFrame = newFrame;
|
||||||
|
drawTimelineMarkers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
canvas.addEventListener('mouseup', () => {
|
||||||
|
isDragging = false;
|
||||||
|
draggingKeyframe = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('dblclick', (e) => {
|
||||||
|
const x = e.offsetX;
|
||||||
|
const frame = Math.round((x / canvas.width) * totalFrames);
|
||||||
|
const dialIndices = selectedDial !== null ? [selectedDial] : [0, 1, 2, 3, 4];
|
||||||
|
|
||||||
|
for (let ch of dialIndices) {
|
||||||
|
if (dialKeyframes[ch][frame] !== undefined) {
|
||||||
|
delete dialKeyframes[ch][frame];
|
||||||
|
drawTimelineMarkers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
document.getElementById('saveAnimation').onclick = async () => {
|
||||||
|
if (!writer) {
|
||||||
|
alert("Serial not connected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(totalFrames * 5 * 2);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
for (let frame = 0; frame < totalFrames; frame++) {
|
||||||
|
for (let ch = 0; ch < 5; ch++) {
|
||||||
|
const keyframes = dialKeyframes[ch];
|
||||||
|
let prevFrame = null, nextFrame = null;
|
||||||
|
|
||||||
|
for (let f = frame; f >= 0; f--) {
|
||||||
|
if (keyframes[f] !== undefined) {
|
||||||
|
prevFrame = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let f = frame; f <= totalFrames - 1; f++) {
|
||||||
|
if (keyframes[f] !== undefined) {
|
||||||
|
nextFrame = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let value;
|
||||||
|
if (prevFrame !== null && nextFrame !== null && prevFrame !== nextFrame) {
|
||||||
|
const prevVal = keyframes[prevFrame];
|
||||||
|
const nextVal = keyframes[nextFrame];
|
||||||
|
const t = (frame - prevFrame) / (nextFrame - prevFrame);
|
||||||
|
value = Math.round(prevVal + (nextVal - prevVal) * t);
|
||||||
|
} else if (prevFrame !== null) {
|
||||||
|
value = keyframes[prevFrame];
|
||||||
|
} else if (nextFrame !== null) {
|
||||||
|
value = keyframes[nextFrame];
|
||||||
|
} else {
|
||||||
|
value = 512;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.setUint16((frame * 5 + ch) * 2, value, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await writer.write(buffer);
|
||||||
|
alert("Animation sent over serial.");
|
||||||
|
};
|
||||||
|
|
||||||
drawTimelineMarkers();
|
drawTimelineMarkers();
|
||||||
isInterpolating = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
dials[i] = new Nexus.Dial(`#dial${i}`, {
|
|
||||||
size: [80, 80],
|
|
||||||
min: 0,
|
|
||||||
max: 1023,
|
|
||||||
value: 512
|
|
||||||
});
|
|
||||||
|
|
||||||
dials[i].on('change', (v) => {
|
|
||||||
if (isInterpolating) return;
|
|
||||||
const val = Math.round(v);
|
|
||||||
document.getElementById(`value${i}`).textContent = val;
|
|
||||||
dialKeyframes[i][currentFrame] = val;
|
|
||||||
drawTimelineMarkers();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('connect').onclick = async () => {
|
|
||||||
port = await navigator.serial.requestPort();
|
|
||||||
await port.open({ baudRate: 115200 });
|
|
||||||
writer = port.writable.getWriter();
|
|
||||||
reader = port.readable.getReader();
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
document.getElementById('log').value += decoder.decode(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('send').onclick = async () => {
|
|
||||||
const text = document.getElementById('input').value + '\n';
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
await writer.write(encoder.encode(text));
|
|
||||||
document.getElementById('input').value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('sendFrame').onclick = async () => {
|
|
||||||
const positions = dials.map(d => Math.round(d.value));
|
|
||||||
const message = `FRAME ${positions.join(',')}\n`;
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
await writer.write(encoder.encode(message));
|
|
||||||
};
|
|
||||||
|
|
||||||
function drawTimelineMarkers() {
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
const width = canvas.width;
|
|
||||||
const height = canvas.height;
|
|
||||||
|
|
||||||
for (let ch = 0; ch < 5; ch++) {
|
|
||||||
for (let frame in dialKeyframes[ch]) {
|
|
||||||
const x = (frame / 400) * width;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, 0);
|
|
||||||
ctx.lineTo(x, height);
|
|
||||||
ctx.strokeStyle = ['red', 'green', 'blue', 'orange', 'purple'][ch];
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentX = (currentFrame / 400) * width;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(currentX, 0);
|
|
||||||
ctx.lineTo(currentX, height);
|
|
||||||
ctx.strokeStyle = 'black';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('saveAnimation').onclick = async () => {
|
|
||||||
if (!writer) {
|
|
||||||
alert("Serial not connected.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = new ArrayBuffer(totalFrames * 5 * 2);
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
|
|
||||||
for (let frame = 0; frame < totalFrames; frame++) {
|
|
||||||
for (let ch = 0; ch < 5; ch++) {
|
|
||||||
const keyframes = dialKeyframes[ch];
|
|
||||||
let prevFrame = null, nextFrame = null;
|
|
||||||
|
|
||||||
for (let f = frame; f >= 0; f--) {
|
|
||||||
if (keyframes[f] !== undefined) {
|
|
||||||
prevFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let f = frame; f <= totalFrames - 1; f++) {
|
|
||||||
if (keyframes[f] !== undefined) {
|
|
||||||
nextFrame = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let value;
|
|
||||||
if (prevFrame !== null && nextFrame !== null && prevFrame !== nextFrame) {
|
|
||||||
const prevVal = keyframes[prevFrame];
|
|
||||||
const nextVal = keyframes[nextFrame];
|
|
||||||
const t = (frame - prevFrame) / (nextFrame - prevFrame);
|
|
||||||
value = Math.round(prevVal + (nextVal - prevVal) * t);
|
|
||||||
} else if (prevFrame !== null) {
|
|
||||||
value = keyframes[prevFrame];
|
|
||||||
} else if (nextFrame !== null) {
|
|
||||||
value = keyframes[nextFrame];
|
|
||||||
} else {
|
|
||||||
value = 512;
|
|
||||||
}
|
|
||||||
|
|
||||||
view.setUint16((frame * 5 + ch) * 2, value, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await writer.write(buffer);
|
|
||||||
alert("Animation sent over serial.");
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
12
style.css
12
style.css
|
|
@ -16,6 +16,18 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dial.selected {
|
||||||
|
background-color: #ddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
background-color: #f0f0f0; /* light grey */
|
||||||
|
border: 1px solid #ccc; /* optional subtle border */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue