732 lines
24 KiB
JavaScript
732 lines
24 KiB
JavaScript
window.onload = () => {
|
|
let isInterpolating = false;
|
|
let currentFrame = 0;
|
|
const dialKeyframes = Array.from({ length: 5 }, () => ({}));
|
|
let currentAnimation = null;
|
|
|
|
let port, reader, writer;
|
|
let selectedDial = null;
|
|
let draggingKeyframe = null; // { dialIndex, originalFrame }
|
|
let isDragging = false;
|
|
const dialColors = ['red', 'green', 'blue', 'orange', 'purple'];
|
|
|
|
const dials = [];
|
|
const frameSlider = document.getElementById('frameSlider');
|
|
const frameDisplay = document.getElementById('frameDisplay');
|
|
const canvas = document.getElementById('timelineCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const totalFrames = 500;
|
|
|
|
|
|
|
|
|
|
|
|
// Animation File List
|
|
const fileListElement = document.getElementById('fileList');
|
|
const loadButton = document.getElementById('loadFile');
|
|
const deleteButton = document.getElementById('deleteFile');
|
|
|
|
let selectedFile = null;
|
|
|
|
function clearFileList() {
|
|
fileListElement.innerHTML = '';
|
|
selectedFile = null;
|
|
loadButton.disabled = true;
|
|
deleteButton.disabled = true;
|
|
}
|
|
|
|
|
|
function addFileToList(filename) {
|
|
const li = document.createElement('li');
|
|
li.textContent = filename;
|
|
|
|
li.addEventListener('click', () => {
|
|
// Deselect previous
|
|
const previouslySelected = fileListElement.querySelector('.selected');
|
|
if (previouslySelected) previouslySelected.classList.remove('selected');
|
|
|
|
// Select new
|
|
li.classList.add('selected');
|
|
selectedFile = filename;
|
|
loadButton.disabled = false;
|
|
deleteButton.disabled = false;
|
|
});
|
|
|
|
fileListElement.appendChild(li);
|
|
}
|
|
|
|
loadButton.addEventListener('click', () => {
|
|
if (!selectedFile || loadButton.disabled) return;
|
|
|
|
const filename = "/" + selectedFile;
|
|
console.log(`Loading file: ${filename}`);
|
|
|
|
// 🧷 Lock buttons
|
|
loadButton.disabled = true;
|
|
deleteButton.disabled = true;
|
|
|
|
// 🧹 Clear previous state
|
|
fileAssembly[filename] = null;
|
|
fileStats[filename] = { chunks: 0, bytes: 0 };
|
|
|
|
const encoder = new TextEncoder();
|
|
const payload = Array.from(encoder.encode(filename));
|
|
const CMD_LOAD_FILE = 0x03;
|
|
|
|
sendCommand(port, CMD_LOAD_FILE, payload)
|
|
.then(response => {
|
|
const { file, status, chunks, bytesSent } = response;
|
|
const stats = fileStats[file];
|
|
|
|
if (status === "complete" && stats) {
|
|
const chunkMatch = stats.chunks === chunks;
|
|
const byteMatch = stats.bytes === bytesSent;
|
|
|
|
console.log(`Chunks match: ${chunkMatch} (${stats.chunks} vs ${chunks})`);
|
|
console.log(`Bytes match: ${byteMatch} (${stats.bytes} vs ${bytesSent})`);
|
|
|
|
if (chunkMatch && byteMatch) {
|
|
console.log(`✅ File ${file} loaded successfully`);
|
|
console.log("Reassembled file bytes:", fileAssembly[file]);
|
|
// TODO: trigger animation preview or playback
|
|
currentAnimation = parseAnimFile(fileAssembly[file]);
|
|
console.log(currentAnimation);
|
|
} else {
|
|
console.warn(`⚠️ Mismatch detected for ${file}`);
|
|
}
|
|
} else {
|
|
console.warn("Unexpected final response:", response);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Failed to load file:", err);
|
|
})
|
|
.finally(() => {
|
|
// 🔓 Unlock buttons
|
|
loadButton.disabled = false;
|
|
deleteButton.disabled = false;
|
|
});
|
|
});
|
|
|
|
|
|
function parseAnimFile(buffer) {
|
|
console.log("decoding anim file");
|
|
const view = new DataView(buffer.buffer);
|
|
console.log(view);
|
|
let offset = 0;
|
|
|
|
// 1. Parse header
|
|
const magic = String.fromCharCode(...buffer.slice(offset, offset + 4));
|
|
offset += 4;
|
|
|
|
const frameCount = view.getUint16(offset, true); offset += 2;
|
|
const version = view.getUint8(offset++);
|
|
const frameRate = view.getUint8(offset++);
|
|
|
|
offset += 8; // skip reserved
|
|
|
|
if (magic !== "ANIM" || version !== 1) {
|
|
throw new Error("Invalid animation file");
|
|
}
|
|
|
|
console.log("Version: " + version);
|
|
console.log("Framerate: " + frameRate);
|
|
console.log("Frame Count: " + frameCount);
|
|
|
|
// 2. Parse frame data
|
|
const frames = [];
|
|
for (let frame = 0; frame < frameCount; frame++) {
|
|
const positions = [];
|
|
for (let ch = 0; ch < 5; ch++) {
|
|
positions.push(view.getUint16(offset, true));
|
|
offset += 2;
|
|
}
|
|
frames.push(positions);
|
|
}
|
|
|
|
// Move offset to end of reserved 10 seconds of animation, to keyframe data
|
|
offset = 5016;
|
|
|
|
const keyFrameCount = view.getUint16(offset, true); offset += 2;
|
|
let keyframes = [];
|
|
for (var i = 0; i < keyFrameCount; i++) {
|
|
let motorID = view.getUint8(offset); offset += 1;
|
|
let frame = view.getUint16(offset, true); offset += 2;
|
|
let position = view.getUint16(offset, true); offset += 2;
|
|
keyframes.push({ motorID, frame, position });
|
|
}
|
|
|
|
console.log(keyFrameCount);
|
|
console.log(keyframes);
|
|
|
|
// Populate from parsed animation frames
|
|
keyframes.forEach(({ motorID, frame, position }) => {
|
|
if (!dialKeyframes[motorID]) {
|
|
dialKeyframes[motorID] = []; // Initialize if missing
|
|
}
|
|
dialKeyframes[motorID][frame] = position;
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
header: { magic, version, frameCount, frameRate },
|
|
frames, keyFrameCount, keyframes
|
|
};
|
|
}
|
|
|
|
|
|
|
|
deleteButton.addEventListener('click', () => {
|
|
if (selectedFile) {
|
|
console.log(`Deleting file: ${selectedFile}`);
|
|
// Add logic to send delete command to ESP32
|
|
|
|
// Remove from UI
|
|
const selectedLi = fileListElement.querySelector('.selected');
|
|
if (selectedLi) selectedLi.remove();
|
|
selectedFile = null;
|
|
loadButton.disabled = true;
|
|
deleteButton.disabled = true;
|
|
}
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Communications
|
|
let pendingResponse = null;
|
|
let byteBuffer = [];
|
|
|
|
async function readLoop(port) {
|
|
const reader = port.readable.getReader();
|
|
|
|
try {
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
for (let byte of value) {
|
|
byteBuffer.push(byte);
|
|
tryParseBuffer();
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Read loop error:", err);
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
|
|
function sendCommand(port, commandCode, payload = []) {
|
|
return new Promise(async (resolve, reject) => {
|
|
if (pendingResponse) {
|
|
reject("Another command is still pending");
|
|
return;
|
|
}
|
|
|
|
pendingResponse = { commandCode, resolve, reject, timeout: null };
|
|
|
|
const header = [0xAA, 0x55];
|
|
const length = payload.length;
|
|
const lengthHigh = (length >> 8) & 0xFF;
|
|
const lengthLow = length & 0xFF;
|
|
const message = [...header, commandCode, lengthHigh, lengthLow, ...payload];
|
|
|
|
const writer = port.writable.getWriter();
|
|
await writer.write(new Uint8Array(message));
|
|
writer.releaseLock();
|
|
|
|
// Set timeout
|
|
pendingResponse.timeout = setTimeout(() => {
|
|
pendingResponse.reject("Timeout waiting for response");
|
|
pendingResponse = null;
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
function dispatchResponse({ command, payload }) {
|
|
// Notify any listeners waiting for "ok"
|
|
if (payload && typeof payload === "object" && payload.status === "ok") {
|
|
console.log(command, payload);
|
|
console.log("Chunk acknowledged ✅");
|
|
return;
|
|
}
|
|
|
|
// Your existing logic...
|
|
if (command === 0x05) {
|
|
handleChunkResponse(payload);
|
|
} else if (pendingResponse && pendingResponse.commandCode === command) {
|
|
clearTimeout(pendingResponse.timeout);
|
|
pendingResponse.resolve(payload);
|
|
pendingResponse = null;
|
|
} else {
|
|
console.warn("Unexpected or unsolicited response:", command, payload);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function tryParseBuffer() {
|
|
const HEADER1 = 0xAA;
|
|
const HEADER2 = 0x55;
|
|
|
|
while (byteBuffer.length >= 6) { // Minimum size with 2-byte length
|
|
// Look for header
|
|
if (byteBuffer[0] !== HEADER1 || byteBuffer[1] !== HEADER2) {
|
|
byteBuffer.shift(); // discard until we find header
|
|
continue;
|
|
}
|
|
|
|
const command = byteBuffer[2];
|
|
const lengthHigh = byteBuffer[3];
|
|
const lengthLow = byteBuffer[4];
|
|
const length = (lengthHigh << 8) | lengthLow;
|
|
|
|
const totalLength = 5 + length + 1; // header + payload + checksum
|
|
|
|
if (byteBuffer.length < totalLength) {
|
|
// Wait for more data
|
|
return;
|
|
}
|
|
|
|
const payloadBytes = byteBuffer.slice(5, 5 + length);
|
|
const checksum = byteBuffer[5 + length];
|
|
|
|
// Verify checksum
|
|
let computedChecksum = command ^ lengthHigh ^ lengthLow;
|
|
for (let b of payloadBytes) {
|
|
computedChecksum ^= b;
|
|
}
|
|
|
|
if (checksum !== computedChecksum) {
|
|
console.warn("Checksum mismatch");
|
|
byteBuffer.shift(); // discard first byte and retry
|
|
continue;
|
|
}
|
|
|
|
// Parse payload
|
|
const payloadText = new TextDecoder().decode(new Uint8Array(payloadBytes));
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(payloadText);
|
|
} catch {
|
|
payload = payloadText;
|
|
}
|
|
|
|
const parsed = { command, payload };
|
|
dispatchResponse(parsed);
|
|
|
|
// Remove parsed packet from buffer
|
|
byteBuffer.splice(0, totalLength);
|
|
}
|
|
}
|
|
|
|
|
|
const fileAssembly = {};
|
|
const fileStats = {};
|
|
|
|
function handleChunkResponse({ file, offset, totalSize, chunk }) {
|
|
if (!fileAssembly[file]) {
|
|
fileAssembly[file] = new Uint8Array(totalSize);
|
|
fileStats[file] = { chunks: 0, bytes: 0 };
|
|
}
|
|
|
|
const chunkBytes = Uint8Array.from(chunk);
|
|
fileAssembly[file].set(chunkBytes, offset);
|
|
|
|
fileStats[file].chunks += 1;
|
|
fileStats[file].bytes += chunkBytes.length;
|
|
|
|
console.log(`Chunk received: ${file} offset=${offset} size=${chunkBytes.length}`);
|
|
}
|
|
|
|
|
|
function buildAnimationPayload({ header, frames, keyframes }) {
|
|
const frameCount = frames.length;
|
|
const channelCount = frames[0].length;
|
|
const frameDataSize = frameCount * channelCount * 2;
|
|
const keyframeCount = keyframes.length;
|
|
const keyframeBlockSize = 2 + keyframeCount * 5;
|
|
const totalSize = 16 + frameDataSize + keyframeBlockSize;
|
|
|
|
const buffer = new ArrayBuffer(totalSize);
|
|
const view = new DataView(buffer);
|
|
let offset = 0;
|
|
|
|
// 🔹 Header (16 bytes)
|
|
for (let i = 0; i < 4; i++) view.setUint8(offset++, header.magic.charCodeAt(i));
|
|
view.setUint16(offset, header.frameCount, true); offset += 2;
|
|
view.setUint8(offset++, header.version);
|
|
view.setUint8(offset++, header.frameRate);
|
|
for (let i = 0; i < 8; i++) view.setUint8(offset++, 0); // reserved
|
|
|
|
// 🔹 Frame Data (5000 bytes)
|
|
for (let i = 0; i < frameCount; i++) {
|
|
for (let j = 0; j < channelCount; j++) {
|
|
view.setUint16(offset, frames[i][j], true);
|
|
offset += 2;
|
|
}
|
|
}
|
|
|
|
// 🔹 Keyframes Block
|
|
view.setUint16(offset, keyframeCount, true); offset += 2;
|
|
keyframes.forEach(({ motorID, frame, position }) => {
|
|
view.setUint8(offset++, motorID);
|
|
view.setUint16(offset, frame, true); offset += 2;
|
|
view.setUint16(offset, position, true); offset += 2;
|
|
});
|
|
|
|
return buffer;
|
|
}
|
|
|
|
async function sendAnimationToESP32(port, commandCode, filename, currentAnimation, chunkSize = 256) {
|
|
const { header, frames, keyframes } = currentAnimation;
|
|
const payloadBuffer = buildAnimationPayload({ header, frames, keyframes });
|
|
const totalSize = payloadBuffer.byteLength;
|
|
const payloadArray = new Uint8Array(payloadBuffer);
|
|
|
|
for (let offset = 0; offset < totalSize; offset += chunkSize) {
|
|
const end = Math.min(offset + chunkSize, totalSize);
|
|
const chunkData = payloadArray.slice(offset, end);
|
|
|
|
const HEADER1 = 0xAA;
|
|
const HEADER2 = 0x55;
|
|
const offsetHigh = (offset >> 8) & 0xFF;
|
|
const offsetLow = offset & 0xFF;
|
|
const totalHigh = (totalSize >> 8) & 0xFF;
|
|
const totalLow = totalSize & 0xFF;
|
|
|
|
const length = 4 + chunkData.length; // offset(2) + total(2) + chunk
|
|
const lengthHigh = (length >> 8) & 0xFF;
|
|
const lengthLow = length & 0xFF;
|
|
|
|
const packet = new Uint8Array(5 + length + 1);
|
|
packet[0] = HEADER1;
|
|
packet[1] = HEADER2;
|
|
packet[2] = commandCode;
|
|
packet[3] = lengthHigh;
|
|
packet[4] = lengthLow;
|
|
packet[5] = offsetHigh;
|
|
packet[6] = offsetLow;
|
|
packet[7] = totalHigh;
|
|
packet[8] = totalLow;
|
|
packet.set(chunkData, 9);
|
|
|
|
let checksum = commandCode ^ lengthHigh ^ lengthLow ^ offsetHigh ^ offsetLow ^ totalHigh ^ totalLow;
|
|
for (let b of chunkData) checksum ^= b;
|
|
packet[5 + length] = checksum;
|
|
|
|
const writer = port.writable.getWriter();
|
|
await writer.write(packet);
|
|
writer.releaseLock();
|
|
|
|
console.log(`Sent chunk: ${filename} offset=${offset} size=${chunkData.length}`);
|
|
console.log(packet);
|
|
|
|
}
|
|
|
|
console.log("Total animation size:", totalSize);
|
|
}
|
|
|
|
|
|
function waitForOkResponse(timeoutMs = 1000) {
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
const index = okResponseQueue.indexOf(resolve);
|
|
if (index !== -1) okResponseQueue.splice(index, 1);
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
|
|
okResponseQueue.push(() => {
|
|
clearTimeout(timeout);
|
|
resolve(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function flattenKeyframes(dialKeyframes) {
|
|
const flat = [];
|
|
|
|
dialKeyframes.forEach((frameMap, motorId) => {
|
|
Object.entries(frameMap).forEach(([frameStr, position]) => {
|
|
flat.push({
|
|
motorId,
|
|
frame: parseInt(frameStr),
|
|
position
|
|
});
|
|
});
|
|
});
|
|
|
|
return flat;
|
|
}
|
|
|
|
|
|
|
|
|
|
// Timeline
|
|
|
|
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;
|
|
};
|
|
|
|
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].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').addEventListener('click', async () => {
|
|
try {
|
|
port = await navigator.serial.requestPort();
|
|
await port.open({ baudRate: 115200 });
|
|
|
|
readLoop(port); // Start listening
|
|
|
|
const id = await sendCommand(port, 0x01);
|
|
console.log("Device ID:", id);
|
|
|
|
const files = await sendCommand(port, 0x02);
|
|
clearFileList();
|
|
console.log("File list:", files);
|
|
files.forEach(addFileToList);
|
|
} catch (err) {
|
|
console.error("Connection or communication failed:", err);
|
|
}
|
|
});
|
|
|
|
|
|
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));
|
|
console.log(dialKeyframes);
|
|
};
|
|
|
|
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 () => {
|
|
await sendAnimationToESP32(port, 0x05, "anim1.bin", currentAnimation);
|
|
|
|
};
|
|
|
|
drawTimelineMarkers();
|
|
};
|