diff options
Diffstat (limited to 'tools/timeline_editor/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 675 |
1 files changed, 675 insertions, 0 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html new file mode 100644 index 0000000..0d10e4a --- /dev/null +++ b/tools/timeline_editor/index.html @@ -0,0 +1,675 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Timeline Editor - demo.seq</title> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: #1e1e1e; + color: #d4d4d4; + padding: 20px; + } + + .container { + max-width: 1400px; + margin: 0 auto; + } + + header { + background: #252526; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + } + + h1 { + margin-bottom: 10px; + color: #4ec9b0; + } + + .controls { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 20px; + } + + button { + background: #0e639c; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + } + + button:hover { + background: #1177bb; + } + + button:disabled { + background: #3c3c3c; + cursor: not-allowed; + } + + input[type="file"] { + display: none; + } + + .file-label { + background: #0e639c; + color: white; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + display: inline-block; + } + + .file-label:hover { + background: #1177bb; + } + + .timeline-container { + background: #252526; + border-radius: 8px; + padding: 20px; + overflow-x: auto; + } + + .timeline { + position: relative; + min-height: 400px; + border-left: 2px solid #3c3c3c; + } + + .time-markers { + position: relative; + height: 30px; + margin-bottom: 10px; + border-bottom: 1px solid #3c3c3c; + } + + .time-marker { + position: absolute; + top: 0; + font-size: 12px; + color: #858585; + } + + .time-marker::before { + content: ''; + position: absolute; + left: 0; + top: 20px; + width: 1px; + height: 10px; + background: #3c3c3c; + } + + .sequence { + position: absolute; + background: #264f78; + border: 2px solid #0e639c; + border-radius: 4px; + padding: 8px; + cursor: move; + min-height: 40px; + transition: box-shadow 0.2s; + } + + .sequence:hover { + box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); + } + + .sequence.selected { + border-color: #4ec9b0; + box-shadow: 0 0 10px rgba(78, 201, 176, 0.5); + } + + .effect { + position: absolute; + background: #3a3d41; + border: 1px solid #858585; + border-radius: 3px; + padding: 6px; + cursor: move; + font-size: 12px; + transition: box-shadow 0.2s; + } + + .effect:hover { + box-shadow: 0 0 8px rgba(133, 133, 133, 0.5); + } + + .effect.selected { + border-color: #ce9178; + box-shadow: 0 0 8px rgba(206, 145, 120, 0.5); + } + + .properties-panel { + background: #252526; + padding: 20px; + border-radius: 8px; + margin-top: 20px; + } + + .properties-panel h2 { + margin-bottom: 15px; + color: #4ec9b0; + } + + .property-group { + margin-bottom: 15px; + } + + .property-group label { + display: block; + margin-bottom: 5px; + color: #858585; + font-size: 14px; + } + + .property-group input, + .property-group select { + width: 100%; + padding: 8px; + background: #3c3c3c; + border: 1px solid #858585; + border-radius: 4px; + color: #d4d4d4; + font-size: 14px; + } + + .zoom-controls { + margin-bottom: 10px; + } + + .stats { + background: #1e1e1e; + padding: 10px; + border-radius: 4px; + margin-top: 10px; + font-size: 12px; + color: #858585; + } + + .error { + background: #5a1d1d; + color: #f48771; + padding: 10px; + border-radius: 4px; + margin-bottom: 10px; + } + + .success { + background: #1e5231; + color: #89d185; + padding: 10px; + border-radius: 4px; + margin-bottom: 10px; + } + </style> +</head> +<body> + <div class="container"> + <header> + <h1>📊 Timeline Editor</h1> + <p>Interactive editor for demo.seq files</p> + </header> + + <div class="controls"> + <label class="file-label"> + 📂 Load demo.seq + <input type="file" id="fileInput" accept=".seq"> + </label> + <button id="saveBtn" disabled>💾 Save demo.seq</button> + <button id="addSequenceBtn" disabled>➕ Add Sequence</button> + <button id="deleteBtn" disabled>🗑️ Delete Selected</button> + </div> + + <div class="zoom-controls"> + <label>Zoom: <input type="range" id="zoomSlider" min="10" max="200" value="100" step="10"></label> + <span id="zoomLevel">100%</span> + <label style="margin-left: 20px">Pixels per second: <span id="pixelsPerSec">100</span></label> + </div> + + <div id="messageArea"></div> + + <div class="timeline-container"> + <div class="time-markers" id="timeMarkers"></div> + <div class="timeline" id="timeline"></div> + </div> + + <div class="properties-panel" id="propertiesPanel" style="display: none;"> + <h2>Properties</h2> + <div id="propertiesContent"></div> + </div> + + <div class="stats" id="stats"></div> + </div> + + <script> + // Global state + let sequences = []; + let currentFile = null; + let selectedItem = null; + let pixelsPerSecond = 100; + let isDragging = false; + let dragOffset = { x: 0, y: 0 }; + + // DOM elements + const timeline = document.getElementById('timeline'); + const fileInput = document.getElementById('fileInput'); + const saveBtn = document.getElementById('saveBtn'); + const addSequenceBtn = document.getElementById('addSequenceBtn'); + const deleteBtn = document.getElementById('deleteBtn'); + const propertiesPanel = document.getElementById('propertiesPanel'); + const propertiesContent = document.getElementById('propertiesContent'); + const messageArea = document.getElementById('messageArea'); + const zoomSlider = document.getElementById('zoomSlider'); + const zoomLevel = document.getElementById('zoomLevel'); + const pixelsPerSecLabel = document.getElementById('pixelsPerSec'); + const stats = document.getElementById('stats'); + + // Parser: demo.seq → JavaScript objects + function parseSeqFile(content) { + const sequences = []; + const lines = content.split('\n'); + let currentSequence = null; + + for (let line of lines) { + line = line.trim(); + + // Skip empty lines and comments + if (!line || line.startsWith('#')) continue; + + // Parse SEQUENCE line + const seqMatch = line.match(/^SEQUENCE\s+([\d.]+)\s+(\d+)$/); + if (seqMatch) { + currentSequence = { + type: 'sequence', + startTime: parseFloat(seqMatch[1]), + priority: parseInt(seqMatch[2]), + effects: [] + }; + sequences.push(currentSequence); + continue; + } + + // Parse EFFECT line + const effectMatch = line.match(/^EFFECT\s+(\w+)\s+([\d.]+)\s+([\d.]+)\s+(-?\d+)(?:\s+(.*))?$/); + if (effectMatch && currentSequence) { + const effect = { + type: 'effect', + className: effectMatch[1], + startTime: parseFloat(effectMatch[2]), + endTime: parseFloat(effectMatch[3]), + priority: parseInt(effectMatch[4]), + args: effectMatch[5] || '' + }; + currentSequence.effects.push(effect); + } + } + + return sequences; + } + + // Serializer: JavaScript objects → demo.seq + function serializeSeqFile(sequences) { + let output = '# Demo Timeline\n'; + output += '# Generated by Timeline Editor\n\n'; + + for (const seq of sequences) { + output += `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}\n`; + + for (const effect of seq.effects) { + output += ` EFFECT ${effect.className} ${effect.startTime.toFixed(2)} ${effect.endTime.toFixed(2)} ${effect.priority}`; + if (effect.args) { + output += ` ${effect.args}`; + } + output += '\n'; + } + output += '\n'; + } + + return output; + } + + // Render timeline + function renderTimeline() { + timeline.innerHTML = ''; + const timeMarkers = document.getElementById('timeMarkers'); + timeMarkers.innerHTML = ''; + + // Calculate max time + let maxTime = 30; // Default 30 seconds + for (const seq of sequences) { + const seqEnd = seq.startTime + 10; // Default sequence duration + maxTime = Math.max(maxTime, seqEnd); + + for (const effect of seq.effects) { + maxTime = Math.max(maxTime, seq.startTime + effect.endTime); + } + } + + // Render time markers + const timelineWidth = maxTime * pixelsPerSecond; + timeline.style.width = `${timelineWidth}px`; + + for (let t = 0; t <= maxTime; t += 1) { + const marker = document.createElement('div'); + marker.className = 'time-marker'; + marker.style.left = `${t * pixelsPerSecond}px`; + marker.textContent = `${t}s`; + timeMarkers.appendChild(marker); + } + + // Render sequences + sequences.forEach((seq, seqIndex) => { + const seqDiv = document.createElement('div'); + seqDiv.className = 'sequence'; + seqDiv.dataset.index = seqIndex; + + // Calculate sequence duration based on effects + let seqDuration = 10; // Default + if (seq.effects.length > 0) { + seqDuration = Math.max(...seq.effects.map(e => e.endTime)); + } + + seqDiv.style.left = `${seq.startTime * pixelsPerSecond}px`; + seqDiv.style.top = `${seqIndex * 80}px`; + seqDiv.style.width = `${seqDuration * pixelsPerSecond}px`; + seqDiv.style.height = '70px'; + + seqDiv.innerHTML = ` + <strong>Sequence ${seqIndex + 1}</strong><br> + <small>Start: ${seq.startTime.toFixed(2)}s | Priority: ${seq.priority}</small> + `; + + if (selectedItem && selectedItem.type === 'sequence' && selectedItem.index === seqIndex) { + seqDiv.classList.add('selected'); + } + + seqDiv.addEventListener('mousedown', (e) => startDrag(e, 'sequence', seqIndex)); + seqDiv.addEventListener('click', (e) => { + e.stopPropagation(); + selectItem('sequence', seqIndex); + }); + + timeline.appendChild(seqDiv); + + // Render effects within sequence + seq.effects.forEach((effect, effectIndex) => { + const effectDiv = document.createElement('div'); + effectDiv.className = 'effect'; + effectDiv.dataset.seqIndex = seqIndex; + effectDiv.dataset.effectIndex = effectIndex; + + const effectStart = (seq.startTime + effect.startTime) * pixelsPerSecond; + const effectWidth = (effect.endTime - effect.startTime) * pixelsPerSecond; + + effectDiv.style.left = `${effectStart}px`; + effectDiv.style.top = `${seqIndex * 80 + 25}px`; + effectDiv.style.width = `${effectWidth}px`; + effectDiv.style.height = '35px'; + + effectDiv.innerHTML = ` + ${effect.className}<br> + <small>${effect.startTime.toFixed(1)}-${effect.endTime.toFixed(1)}s</small> + `; + + if (selectedItem && selectedItem.type === 'effect' && + selectedItem.seqIndex === seqIndex && selectedItem.effectIndex === effectIndex) { + effectDiv.classList.add('selected'); + } + + effectDiv.addEventListener('mousedown', (e) => { + e.stopPropagation(); + startDrag(e, 'effect', seqIndex, effectIndex); + }); + effectDiv.addEventListener('click', (e) => { + e.stopPropagation(); + selectItem('effect', seqIndex, effectIndex); + }); + + timeline.appendChild(effectDiv); + }); + }); + + updateStats(); + } + + // Drag handling + function startDrag(e, type, seqIndex, effectIndex = null) { + e.preventDefault(); + isDragging = true; + + const rect = e.target.getBoundingClientRect(); + dragOffset.x = e.clientX - rect.left; + dragOffset.y = e.clientY - rect.top; + + selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; + renderTimeline(); + updateProperties(); + + document.addEventListener('mousemove', onDrag); + document.addEventListener('mouseup', stopDrag); + } + + function onDrag(e) { + if (!isDragging || !selectedItem) return; + + const timelineRect = timeline.getBoundingClientRect(); + const newX = e.clientX - timelineRect.left - dragOffset.x; + const newTime = Math.max(0, newX / pixelsPerSecond); + + if (selectedItem.type === 'sequence') { + sequences[selectedItem.index].startTime = Math.round(newTime * 100) / 100; + } else if (selectedItem.type === 'effect') { + const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex]; + const duration = effect.endTime - effect.startTime; + effect.startTime = Math.round(newTime * 100) / 100; + effect.endTime = effect.startTime + duration; + } + + renderTimeline(); + updateProperties(); + } + + function stopDrag() { + isDragging = false; + document.removeEventListener('mousemove', onDrag); + document.removeEventListener('mouseup', stopDrag); + } + + // Selection + function selectItem(type, seqIndex, effectIndex = null) { + selectedItem = { type, index: seqIndex, seqIndex, effectIndex }; + renderTimeline(); + updateProperties(); + deleteBtn.disabled = false; + } + + // Properties panel + function updateProperties() { + if (!selectedItem) { + propertiesPanel.style.display = 'none'; + return; + } + + propertiesPanel.style.display = 'block'; + + if (selectedItem.type === 'sequence') { + const seq = sequences[selectedItem.index]; + propertiesContent.innerHTML = ` + <div class="property-group"> + <label>Start Time (seconds)</label> + <input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0"> + </div> + <div class="property-group"> + <label>Priority</label> + <input type="number" id="propPriority" value="${seq.priority}" step="1"> + </div> + <button onclick="applyProperties()">Apply</button> + `; + } else if (selectedItem.type === 'effect') { + const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex]; + propertiesContent.innerHTML = ` + <div class="property-group"> + <label>Effect Class</label> + <input type="text" id="propClassName" value="${effect.className}"> + </div> + <div class="property-group"> + <label>Start Time (relative to sequence)</label> + <input type="number" id="propStartTime" value="${effect.startTime}" step="0.1" min="0"> + </div> + <div class="property-group"> + <label>End Time (relative to sequence)</label> + <input type="number" id="propEndTime" value="${effect.endTime}" step="0.1" min="0"> + </div> + <div class="property-group"> + <label>Priority</label> + <input type="number" id="propPriority" value="${effect.priority}" step="1"> + </div> + <div class="property-group"> + <label>Constructor Arguments</label> + <input type="text" id="propArgs" value="${effect.args}"> + </div> + <button onclick="applyProperties()">Apply</button> + `; + } + } + + function applyProperties() { + if (!selectedItem) return; + + if (selectedItem.type === 'sequence') { + const seq = sequences[selectedItem.index]; + seq.startTime = parseFloat(document.getElementById('propStartTime').value); + seq.priority = parseInt(document.getElementById('propPriority').value); + } else if (selectedItem.type === 'effect') { + const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex]; + effect.className = document.getElementById('propClassName').value; + effect.startTime = parseFloat(document.getElementById('propStartTime').value); + effect.endTime = parseFloat(document.getElementById('propEndTime').value); + effect.priority = parseInt(document.getElementById('propPriority').value); + effect.args = document.getElementById('propArgs').value; + } + + renderTimeline(); + showMessage('Properties updated', 'success'); + } + + // File operations + fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + currentFile = file.name; + const reader = new FileReader(); + + reader.onload = (e) => { + try { + sequences = parseSeqFile(e.target.result); + renderTimeline(); + saveBtn.disabled = false; + addSequenceBtn.disabled = false; + showMessage(`Loaded ${currentFile} - ${sequences.length} sequences`, 'success'); + } catch (err) { + showMessage(`Error parsing file: ${err.message}`, 'error'); + } + }; + + reader.readAsText(file); + }); + + saveBtn.addEventListener('click', () => { + const content = serializeSeqFile(sequences); + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = currentFile || 'demo.seq'; + a.click(); + URL.revokeObjectURL(url); + showMessage('File saved', 'success'); + }); + + addSequenceBtn.addEventListener('click', () => { + sequences.push({ + type: 'sequence', + startTime: 0, + priority: 0, + effects: [] + }); + renderTimeline(); + showMessage('New sequence added', 'success'); + }); + + deleteBtn.addEventListener('click', () => { + if (!selectedItem) return; + + if (selectedItem.type === 'sequence') { + sequences.splice(selectedItem.index, 1); + } else if (selectedItem.type === 'effect') { + sequences[selectedItem.seqIndex].effects.splice(selectedItem.effectIndex, 1); + } + + selectedItem = null; + deleteBtn.disabled = true; + renderTimeline(); + updateProperties(); + showMessage('Item deleted', 'success'); + }); + + // Zoom + zoomSlider.addEventListener('input', (e) => { + const zoom = parseInt(e.target.value); + pixelsPerSecond = zoom; + zoomLevel.textContent = `${zoom}%`; + pixelsPerSecLabel.textContent = zoom; + renderTimeline(); + }); + + // Click outside to deselect + timeline.addEventListener('click', () => { + selectedItem = null; + deleteBtn.disabled = true; + renderTimeline(); + updateProperties(); + }); + + // Utilities + function showMessage(text, type) { + messageArea.innerHTML = `<div class="${type}">${text}</div>`; + setTimeout(() => messageArea.innerHTML = '', 3000); + } + + function updateStats() { + const effectCount = sequences.reduce((sum, seq) => sum + seq.effects.length, 0); + const maxTime = sequences.reduce((max, seq) => { + const seqMax = seq.effects.reduce((m, e) => Math.max(m, seq.startTime + e.endTime), seq.startTime); + return Math.max(max, seqMax); + }, 0); + + stats.innerHTML = ` + 📊 Sequences: ${sequences.length} | + 🎬 Effects: ${effectCount} | + ⏱️ Duration: ${maxTime.toFixed(2)}s + `; + } + + // Initial render + renderTimeline(); + </script> +</body> +</html> |
