diff options
Diffstat (limited to 'tools/timeline_editor/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 179 |
1 files changed, 112 insertions, 67 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index c5e0264..856d314 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -537,11 +537,36 @@ </div> <div class="stats" id="stats"></div> + + <div id="nodeEditorModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;"> + <div style="background: var(--bg-secondary); padding: 20px; border-radius: 8px; min-width: 300px;"> + <h3 style="margin-top: 0;">Add Node Declaration</h3> + <div style="margin-bottom: 10px;"> + <label>Node Name</label> + <input type="text" id="nodeNameInput" placeholder="temp1" style="width: 100%; padding: 8px; margin-top: 5px;"> + </div> + <div style="margin-bottom: 20px;"> + <label>Node Type</label> + <select id="nodeTypeSelect" style="width: 100%; padding: 8px; margin-top: 5px;"> + <option value="u8x4_norm">u8x4_norm (RGBA8 standard)</option> + <option value="f32x4">f32x4 (RGBA float32)</option> + <option value="f16x8">f16x8 (8-channel float16)</option> + <option value="depth24">depth24 (Depth buffer)</option> + <option value="compute_f32">compute_f32 (Storage buffer)</option> + </select> + </div> + <div style="display: flex; gap: 10px;"> + <button onclick="addNodeToSequence()" style="flex: 1;">Add</button> + <button onclick="closeNodeEditor()" style="flex: 1; background: var(--bg-tertiary);">Cancel</button> + </div> + </div> + </div> </div> <script type="module"> import { ViewportController } from './timeline-viewport.js'; import { PlaybackController } from './timeline-playback.js'; + import { TimelineFormat } from './timeline-format.js'; // Constants const POST_PROCESS_EFFECTS = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect', @@ -576,6 +601,9 @@ ...computeBPMValues(DEFAULT_BPM) }; + // Timeline format handler + const timelineFormat = new TimelineFormat(); + // DOM const dom = { timeline: document.getElementById('timeline'), @@ -613,55 +641,9 @@ waveformTooltip: document.getElementById('waveformTooltip') }; - // Parser + // Parser (delegated to TimelineFormat) function parseSeqFile(content) { - const sequences = []; - let currentSequence = null, bpm = 120, currentPriority = 0; - - const parseTime = (timeStr) => { - if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0; // Local bpm during parsing - if (timeStr.endsWith('b')) return parseFloat(timeStr.slice(0, -1)); - return parseFloat(timeStr); - }; - - const stripComment = (line) => { - const idx = line.indexOf('#'); - return idx >= 0 ? line.slice(0, idx).trim() : line; - }; - - for (let line of content.split('\n')) { - line = line.trim(); - if (!line || line.startsWith('#')) { - if (line.startsWith('# BPM ')) { - const m = line.match(/# BPM (\d+)/); - if (m) bpm = parseInt(m[1]); - } - continue; - } - line = stripComment(line); - if (!line) continue; - - const seqMatch = line.match(/^SEQUENCE\s+(\S+)\s+(\d+)(?:\s+"([^"]+)")?(?:\s+(\S+))?$/); - if (seqMatch) { - currentSequence = { type: 'sequence', startTime: parseTime(seqMatch[1]), priority: parseInt(seqMatch[2]), effects: [], name: seqMatch[3] || '', _collapsed: true }; - sequences.push(currentSequence); - currentPriority = -1; - continue; - } - - const effectMatch = line.match(/^EFFECT\s+([+=-])\s+(\w+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/); - if (effectMatch && currentSequence) { - const modifier = effectMatch[1]; - if (modifier === '+') currentPriority++; - else if (modifier === '-') currentPriority--; - currentSequence.effects.push({ - type: 'effect', className: effectMatch[2], - startTime: parseTime(effectMatch[3]), endTime: parseTime(effectMatch[4]), - priority: currentPriority, priorityModifier: modifier, args: effectMatch[5] || '' - }); - } - } - return { sequences, bpm }; + return timelineFormat.parse(content, 120); } // Helpers @@ -702,19 +684,7 @@ } function serializeSeqFile(sequences) { - let output = `# Demo Timeline\n# Generated by Timeline Editor\n# BPM ${state.bpm}\n\n`; - for (const seq of sequences) { - output += `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}${seq.name ? ` "${seq.name}"` : ''}\n`; - for (const effect of seq.effects) { - const modifier = effect.priorityModifier || '+'; - const cleanArgs = effect.args?.replace(/\s*#\s*Priority:\s*\d+/i, '').trim(); - output += ` EFFECT ${modifier} ${effect.className} ${effect.startTime.toFixed(2)} ${effect.endTime.toFixed(2)}`; - if (cleanArgs) output += ` ${cleanArgs}`; - output += '\n'; - } - output += '\n'; - } - return output; + return timelineFormat.serialize(sequences, state.bpm); } // Controllers - initialized after DOM setup @@ -880,7 +850,8 @@ effectDiv.innerHTML = `<div class="effect-handle left"></div><small>${effect.className}</small><div class="effect-handle right"></div>`; const conflictWarning = conflicts.has(effectIndex) ? `\nā ļø CONFLICT: Multiple post-process effects share priority ${effect.priority}` : ''; - effectDiv.title = `${effect.className}\n${beatRange(effect.startTime, effect.endTime)}\nPriority: ${effect.priority}${conflictWarning}\n${effect.args || '(no args)'}`; + const bufferChain = timelineFormat.renderBufferChain(effect); + effectDiv.title = `${effect.className}\n${beatRange(effect.startTime, effect.endTime)}\nPriority: ${effect.priority}${conflictWarning}\nBuffer: ${bufferChain}\n${effect.params || '(no params)'}`; if (state.selectedItem?.type === 'effect' && state.selectedItem.seqIndex === seqIndex && state.selectedItem.effectIndex === effectIndex) effectDiv.classList.add('selected'); effectDiv.querySelector('.effect-handle.left').addEventListener('mousedown', e => { @@ -982,10 +953,16 @@ dom.propertiesContent.innerHTML = ` <div class="property-group"><label>Name</label><input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name" inputmode="text"></div> <div class="property-group"><label>Start Time (beats)</label><input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0"></div> + <div class="property-group"> + <label>Nodes</label> + <div id="nodesList" style="margin-bottom: 10px;">${timelineFormat.renderNodesList(seq.nodes)}</div> + <button id="addNodeBtn" style="width: 100%;">+ Add Node</button> + </div> <div class="property-group"><button id="propDeleteBtn" style="width: 100%; background: var(--accent-red);">šļø Delete Sequence</button></div> `; document.getElementById('propName').addEventListener('input', applyProperties); document.getElementById('propStartTime').addEventListener('input', applyProperties); + document.getElementById('addNodeBtn').addEventListener('click', showNodeEditor); document.getElementById('propDeleteBtn').addEventListener('click', () => dom.deleteBtn.click()); } else if (state.selectedItem.type === 'effect') { const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex]; @@ -994,9 +971,17 @@ const samePriority = effect.priorityModifier === '='; dom.propertiesContent.innerHTML = ` <div class="property-group"><label>Effect Class</label><input type="text" id="propClassName" value="${effect.className}"></div> + <div class="property-group"><label>Inputs (space-separated)</label><input type="text" id="propInputs" value="${(effect.inputs || ['source']).join(' ')}" placeholder="source"></div> + <div class="property-group"><label>Outputs (space-separated)</label><input type="text" id="propOutputs" value="${(effect.outputs || ['sink']).join(' ')}" placeholder="sink"></div> <div class="property-group"><label>Start Time (beats, relative to sequence)</label><input type="number" id="propStartTime" value="${effect.startTime}" step="0.1"></div> <div class="property-group"><label>End Time (beats, relative to sequence)</label><input type="number" id="propEndTime" value="${effect.endTime}" step="0.1"></div> - <div class="property-group"><label>Constructor Arguments</label><input type="text" id="propArgs" value="${effect.args || ''}"></div> + <div class="property-group"><label>Parameters</label><input type="text" id="propParams" value="${effect.params || ''}" placeholder="Optional effect parameters"></div> + <div class="property-group"> + <label>Buffer Chain</label> + <div id="bufferChain" style="font-family: monospace; padding: 8px; background: rgba(255,255,255,0.05); border-radius: 3px; color: var(--accent-cyan);"> + ${timelineFormat.renderBufferChain(effect)} + </div> + </div> <div class="property-group"><label>Stack Position (determines priority)</label> <div style="display: flex; gap: 5px; margin-bottom: 10px;"> <button id="moveUpBtn" ${!canMoveUp ? 'disabled' : ''} style="flex: 1;">ā Up</button> @@ -1007,9 +992,11 @@ <div class="property-group"><button id="propDeleteBtn" style="width: 100%; background: var(--accent-red);">šļø Delete Effect</button></div> `; document.getElementById('propClassName').addEventListener('input', applyProperties); + document.getElementById('propInputs').addEventListener('input', applyProperties); + document.getElementById('propOutputs').addEventListener('input', applyProperties); document.getElementById('propStartTime').addEventListener('input', applyProperties); document.getElementById('propEndTime').addEventListener('input', applyProperties); - document.getElementById('propArgs').addEventListener('input', applyProperties); + document.getElementById('propParams').addEventListener('input', applyProperties); document.getElementById('moveUpBtn').addEventListener('click', moveEffectUp); document.getElementById('moveDownBtn').addEventListener('click', moveEffectDown); document.getElementById('togglePriorityBtn').addEventListener('click', toggleSamePriority); @@ -1026,9 +1013,11 @@ } else if (state.selectedItem.type === 'effect') { const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex]; effect.className = document.getElementById('propClassName').value; + effect.inputs = document.getElementById('propInputs').value.trim().split(/\s+/).filter(s => s); + effect.outputs = document.getElementById('propOutputs').value.trim().split(/\s+/).filter(s => s); effect.startTime = parseFloat(document.getElementById('propStartTime').value); effect.endTime = parseFloat(document.getElementById('propEndTime').value); - effect.args = document.getElementById('propArgs').value; + effect.params = document.getElementById('propParams').value; } renderTimeline(); } @@ -1058,6 +1047,52 @@ updateProperties(); } + // Node management functions (global for onclick handlers) + window.showNodeEditor = function() { + document.getElementById('nodeEditorModal').style.display = 'flex'; + document.getElementById('nodeNameInput').value = ''; + document.getElementById('nodeTypeSelect').value = 'u8x4_norm'; + document.getElementById('nodeNameInput').focus(); + }; + + window.closeNodeEditor = function() { + document.getElementById('nodeEditorModal').style.display = 'none'; + }; + + window.addNodeToSequence = function() { + if (!state.selectedItem || state.selectedItem.type !== 'sequence') return; + + const name = document.getElementById('nodeNameInput').value.trim(); + const type = document.getElementById('nodeTypeSelect').value; + + if (!name) { + showMessage('Node name required', 'error'); + return; + } + + const seq = state.sequences[state.selectedItem.index]; + if (!seq.nodes) seq.nodes = []; + + // Check duplicate + if (seq.nodes.some(n => n.name === name)) { + showMessage(`Node "${name}" already exists`, 'error'); + return; + } + + seq.nodes.push({ name, type }); + window.closeNodeEditor(); + updateProperties(); + showMessage(`Node "${name}" added`, 'success'); + }; + + window.deleteNode = function(index) { + if (!state.selectedItem || state.selectedItem.type !== 'sequence') return; + const seq = state.sequences[state.selectedItem.index]; + seq.nodes.splice(index, 1); + updateProperties(); + showMessage('Node deleted', 'success'); + }; + function updateStats() { const effectCount = state.sequences.reduce((sum, seq) => sum + seq.effects.length, 0); const maxTimeBeats = Math.max(0, ...state.sequences.flatMap(seq => @@ -1115,6 +1150,16 @@ }); dom.saveBtn.addEventListener('click', () => { + // Validate all sequences before saving + const allErrors = state.sequences.flatMap((seq, i) => + timelineFormat.validateSequence(seq).map(err => `Sequence ${i}: ${err}`) + ); + + if (allErrors.length > 0) { + showMessage(`Validation errors:\n${allErrors.join('\n')}`, 'error'); + return; // Block save + } + const content = serializeSeqFile(state.sequences), blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = state.currentFile || 'timeline.seq'; a.click(); URL.revokeObjectURL(url); @@ -1124,14 +1169,14 @@ // Audio/playback event handlers - managed by PlaybackController dom.addSequenceBtn.addEventListener('click', () => { - state.sequences.push({ type: 'sequence', startTime: 0, priority: 0, effects: [], _collapsed: true }); + state.sequences.push({ type: 'sequence', startTime: 0, priority: 0, nodes: [], effects: [], _collapsed: true }); renderTimeline(); showMessage('New sequence added', 'success'); }); dom.addEffectBtn.addEventListener('click', () => { if (!state.selectedItem || state.selectedItem.type !== 'sequence') return; const seq = state.sequences[state.selectedItem.index]; - seq.effects.push({ type: 'effect', className: 'Effect', startTime: 0, endTime: 10, priority: 0, priorityModifier: '+', args: '' }); + seq.effects.push({ type: 'effect', className: 'Effect', inputs: ['source'], outputs: ['sink'], startTime: 0, endTime: 10, priority: 0, priorityModifier: '+', params: '' }); seq._collapsed = false; renderTimeline(); showMessage('New effect added', 'success'); }); |
