diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-17 07:52:48 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-17 07:52:48 +0100 |
| commit | cd53ff0be8971b592d8d01836a6572c4123e5495 (patch) | |
| tree | aa0f9e48a906e352b6c3ab198580ff48978cc2da | |
| parent | cdd14146df16de0493acfd6dfbf24c154edbfce3 (diff) | |
feat: add Sequence V2 format support to timeline editor
Implements full support for the sequence v2 DAG format with explicit
node routing and arrow syntax.
New features:
- timeline-format.js module for parsing/serializing v2 format
- NODE declarations with typed buffers (u8x4_norm, f32x4, etc.)
- Arrow syntax for effect routing: input1 input2 -> output1 output2
- Buffer chain visualization in properties panel and tooltips
- Node editor modal for adding/deleting node declarations
- Validation for undeclared node references (when NODEs explicit)
- Backward compatible with auto-inferred nodes
Files added:
- tools/timeline_editor/timeline-format.js (214 lines)
- tools/timeline_editor/test_format.html (automated tests)
- workspaces/test/timeline_v2_test.seq (test file with NODE declarations)
Files modified:
- tools/timeline_editor/index.html (~40 changes for v2 support)
All success criteria met. Round-trip tested with existing timelines.
handoff(Claude): Timeline editor now fully supports v2 format with
explicit node routing, NODE declarations, and buffer chain visualization.
Parser handles both explicit NODE declarations and auto-inferred nodes.
Validation only runs when explicit NODEs exist. Ready for production use.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
| -rw-r--r-- | tools/timeline_editor/index.html | 179 | ||||
| -rw-r--r-- | tools/timeline_editor/test_format.html | 127 | ||||
| -rw-r--r-- | tools/timeline_editor/timeline-format.js | 214 | ||||
| -rw-r--r-- | workspaces/test/timeline_v2_test.seq | 8 |
4 files changed, 461 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'); }); diff --git a/tools/timeline_editor/test_format.html b/tools/timeline_editor/test_format.html new file mode 100644 index 0000000..12b788f --- /dev/null +++ b/tools/timeline_editor/test_format.html @@ -0,0 +1,127 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>Timeline Format Test</title> + <style> + body { font-family: monospace; padding: 20px; background: #1e1e1e; color: #d4d4d4; } + .test { margin: 20px 0; padding: 10px; border: 1px solid #444; } + .pass { border-color: #4ec9b0; } + .fail { border-color: #f48771; } + pre { background: #2d2d30; padding: 10px; overflow-x: auto; } + </style> +</head> +<body> + <h1>Timeline Format V2 Test</h1> + <div id="results"></div> + + <script type="module"> + import { TimelineFormat } from './timeline-format.js'; + + const timelineFormat = new TimelineFormat(); + const results = document.getElementById('results'); + + function addTest(name, passed, details) { + const div = document.createElement('div'); + div.className = `test ${passed ? 'pass' : 'fail'}`; + div.innerHTML = `<h3>${passed ? 'ā' : 'ā'} ${name}</h3>${details}`; + results.appendChild(div); + } + + // Test 1: Parse existing timeline (no NODE declarations) + const testTimeline1 = `# WORKSPACE: test +# BPM 120 + +SEQUENCE 0.0 0 "MainLoop" +EFFECT + Hybrid3D source -> temp1 0.00 4.00 +EFFECT + GaussianBlur temp1 -> sink 0.00 4.00`; + + const parsed1 = timelineFormat.parse(testTimeline1, 120); + const test1Pass = parsed1.sequences.length === 1 && + parsed1.sequences[0].effects.length === 2 && + parsed1.sequences[0].effects[0].inputs[0] === 'source' && + parsed1.sequences[0].effects[0].outputs[0] === 'temp1'; + addTest('Parse timeline without NODE declarations', test1Pass, + `<pre>Sequences: ${parsed1.sequences.length} +Effects: ${parsed1.sequences[0].effects.length} +First effect inputs: ${parsed1.sequences[0].effects[0].inputs.join(', ')} +First effect outputs: ${parsed1.sequences[0].effects[0].outputs.join(', ')}</pre>`); + + // Test 2: Parse timeline with NODE declarations + const testTimeline2 = `# BPM 120 +SEQUENCE 0.0 0 "v2_test" + NODE temp1 u8x4_norm + NODE temp2 f32x4 + EFFECT + Hybrid3D source -> temp1 0.0 4.0 + EFFECT + GaussianBlur temp1 -> temp2 0.0 4.0`; + + const parsed2 = timelineFormat.parse(testTimeline2, 120); + const test2Pass = parsed2.sequences[0].nodes.length === 2 && + parsed2.sequences[0].nodes[0].name === 'temp1' && + parsed2.sequences[0].nodes[0].type === 'u8x4_norm'; + addTest('Parse timeline with NODE declarations', test2Pass, + `<pre>Nodes: ${parsed2.sequences[0].nodes.length} +Node 1: ${parsed2.sequences[0].nodes[0].name} (${parsed2.sequences[0].nodes[0].type}) +Node 2: ${parsed2.sequences[0].nodes[1].name} (${parsed2.sequences[0].nodes[1].type})</pre>`); + + // Test 3: Serialize without nodes + const serialized1 = timelineFormat.serialize(parsed1.sequences, 120); + const test3Pass = serialized1.includes('EFFECT + Hybrid3D source -> temp1') && + !serialized1.includes('NODE'); + addTest('Serialize timeline without NODE declarations', test3Pass, + `<pre>${serialized1}</pre>`); + + // Test 4: Serialize with nodes + const serialized2 = timelineFormat.serialize(parsed2.sequences, 120); + const test4Pass = serialized2.includes('NODE temp1 u8x4_norm') && + serialized2.includes('NODE temp2 f32x4'); + addTest('Serialize timeline with NODE declarations', test4Pass, + `<pre>${serialized2}</pre>`); + + // Test 5: Validation (no nodes - should pass) + const errors1 = timelineFormat.validateSequence(parsed1.sequences[0]); + const test5Pass = errors1.length === 0; + addTest('Validation without NODE declarations', test5Pass, + `<pre>Errors: ${errors1.length}</pre>`); + + // Test 6: Validation (with nodes - should pass) + const errors2 = timelineFormat.validateSequence(parsed2.sequences[0]); + const test6Pass = errors2.length === 0; + addTest('Validation with NODE declarations', test6Pass, + `<pre>Errors: ${errors2.length}</pre>`); + + // Test 7: Validation failure (undeclared node) + const invalidSeq = { + nodes: [{ name: 'temp1', type: 'u8x4_norm' }], + effects: [{ + className: 'Test', + inputs: ['source'], + outputs: ['temp2'], // temp2 not declared! + startTime: 0, + endTime: 4 + }] + }; + const errors3 = timelineFormat.validateSequence(invalidSeq); + const test7Pass = errors3.length > 0 && errors3[0].includes('temp2'); + addTest('Validation detects undeclared nodes', test7Pass, + `<pre>Errors: ${errors3.join('\n')}</pre>`); + + // Test 8: Round-trip (parse ā serialize ā parse) + const reparsed = timelineFormat.parse(serialized2, 120); + const test8Pass = JSON.stringify(parsed2.sequences[0].nodes) === + JSON.stringify(reparsed.sequences[0].nodes); + addTest('Round-trip consistency', test8Pass, + `<pre>Original nodes: ${JSON.stringify(parsed2.sequences[0].nodes, null, 2)} +Reparsed nodes: ${JSON.stringify(reparsed.sequences[0].nodes, null, 2)}</pre>`); + + // Summary + const allTests = document.querySelectorAll('.test'); + const passed = document.querySelectorAll('.test.pass').length; + const total = allTests.length; + const summary = document.createElement('div'); + summary.style.cssText = 'margin-top: 30px; padding: 20px; background: #2d2d30; font-size: 18px;'; + summary.innerHTML = `<strong>Results: ${passed}/${total} tests passed</strong>`; + results.appendChild(summary); + </script> +</body> +</html> diff --git a/tools/timeline_editor/timeline-format.js b/tools/timeline_editor/timeline-format.js new file mode 100644 index 0000000..a2d0003 --- /dev/null +++ b/tools/timeline_editor/timeline-format.js @@ -0,0 +1,214 @@ +// timeline-format.js - Timeline V2 format I/O +// Handles parsing, serialization, validation for sequence v2 DAG format + +export class TimelineFormat { + constructor() { + this.NODE_TYPES = ['u8x4_norm', 'f32x4', 'f16x8', 'depth24', 'compute_f32']; + this.NODE_COLORS = { + u8x4_norm: '#4a90e2', + f32x4: '#e24a90', + f16x8: '#e2904a', + depth24: '#4ae290', + compute_f32: '#904ae2' + }; + } + + parse(content, defaultBPM = 120) { + const sequences = []; + let currentSequence = null; + let currentPriority = 0; + let bpm = defaultBPM; + + const parseTime = (timeStr) => { + if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0; + 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(); + + // BPM header + if (line.startsWith('# BPM ')) { + const m = line.match(/# BPM (\d+)/); + if (m) bpm = parseInt(m[1]); + continue; + } + + // Skip empty/comment lines + if (!line || line.startsWith('#')) continue; + line = stripComment(line); + if (!line) continue; + + // SEQUENCE line + const seqMatch = line.match(/^SEQUENCE\s+(\S+)\s+(\d+)(?:\s+"([^"]+)")?$/); + if (seqMatch) { + currentSequence = { + type: 'sequence', + startTime: parseTime(seqMatch[1]), + priority: parseInt(seqMatch[2]), + name: seqMatch[3] || '', + nodes: [], + effects: [], + _collapsed: true + }; + sequences.push(currentSequence); + currentPriority = -1; + continue; + } + + if (!currentSequence) continue; + + // NODE declaration + const nodeMatch = line.match(/^NODE\s+(\S+)\s+(\S+)$/); + if (nodeMatch) { + currentSequence.nodes.push({ + name: nodeMatch[1], + type: nodeMatch[2].toLowerCase() + }); + continue; + } + + // EFFECT line with arrow syntax (v2 only) + const effectMatch = line.match(/^EFFECT\s+([+=-])\s+(\w+)\s+(.+)$/); + if (effectMatch) { + const modifier = effectMatch[1]; + const className = effectMatch[2]; + const remainder = effectMatch[3].split(/\s+/); + + // Parse: input1 input2 -> output1 output2 start end [params] + const arrowIdx = remainder.indexOf('->'); + if (arrowIdx === -1) { + console.warn(`Effect missing arrow syntax: ${line}`); + continue; + } + + const inputs = remainder.slice(0, arrowIdx); + const afterArrow = remainder.slice(arrowIdx + 1); + + // Find first numeric token (start time) + let firstTimeIdx = afterArrow.findIndex(s => !isNaN(parseFloat(s))); + if (firstTimeIdx === -1) { + console.warn(`Effect missing times: ${line}`); + continue; + } + + const outputs = afterArrow.slice(0, firstTimeIdx); + const startTime = parseTime(afterArrow[firstTimeIdx]); + const endTime = parseTime(afterArrow[firstTimeIdx + 1]); + const params = afterArrow.slice(firstTimeIdx + 2).join(' '); + + // Update priority + if (modifier === '+') currentPriority++; + else if (modifier === '-') currentPriority--; + + currentSequence.effects.push({ + type: 'effect', + className, + inputs, + outputs, + startTime, + endTime, + params, + priority: currentPriority, + priorityModifier: modifier + }); + } + } + + return { sequences, bpm }; + } + + serialize(sequences, bpm) { + let output = `# Demo Timeline\n# Generated by Timeline Editor\n# BPM ${bpm}\n\n`; + + for (const seq of sequences) { + output += `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}`; + if (seq.name) output += ` "${seq.name}"`; + output += '\n'; + + // Write NODE declarations + if (seq.nodes && seq.nodes.length > 0) { + for (const node of seq.nodes) { + output += ` NODE ${node.name} ${node.type}\n`; + } + } + + // Write EFFECT lines + for (const effect of seq.effects) { + const modifier = effect.priorityModifier || '+'; + const inputs = (effect.inputs || ['source']).join(' '); + const outputs = (effect.outputs || ['sink']).join(' '); + const params = effect.params || ''; + + output += ` EFFECT ${modifier} ${effect.className} ${inputs} -> ${outputs} `; + output += `${effect.startTime.toFixed(2)} ${effect.endTime.toFixed(2)}`; + if (params) output += ` ${params}`; + output += '\n'; + } + + output += '\n'; + } + + return output; + } + + validateSequence(sequence) { + // Only validate if explicit NODE declarations exist + if (!sequence.nodes || sequence.nodes.length === 0) { + return []; // No validation needed - nodes are auto-inferred + } + + const declaredNodes = new Set(['source', 'sink']); + sequence.nodes.forEach(n => declaredNodes.add(n.name)); + + const errors = []; + sequence.effects.forEach((effect, idx) => { + effect.inputs?.forEach(inp => { + if (!declaredNodes.has(inp)) { + errors.push(`Effect ${idx} (${effect.className}): undeclared input "${inp}"`); + } + }); + effect.outputs?.forEach(out => { + if (out !== 'sink' && !declaredNodes.has(out)) { + errors.push(`Effect ${idx} (${effect.className}): undeclared output "${out}"`); + } + }); + }); + + return errors; + } + + getNodeColor(type) { + return this.NODE_COLORS[type] || '#999'; + } + + renderNodesList(nodes) { + if (!nodes || nodes.length === 0) { + return '<div style="color: var(--text-secondary); font-style: italic;">No nodes declared (auto-inferred)</div>'; + } + + return nodes.map((node, idx) => { + const color = this.getNodeColor(node.type); + return ` + <div style="display: flex; align-items: center; gap: 8px; padding: 4px; background: rgba(255,255,255,0.05); border-radius: 3px; margin-bottom: 4px;"> + <span style="flex: 1; color: ${color};"> + <strong>${node.name}</strong>: ${node.type} + </span> + <button onclick="deleteNode(${idx})" style="padding: 2px 8px; background: var(--accent-red);">Ć</button> + </div> + `; + }).join(''); + } + + renderBufferChain(effect) { + const inputs = effect.inputs || ['source']; + const outputs = effect.outputs || ['sink']; + return `${inputs.join(', ')} ā [${effect.className}] ā ${outputs.join(', ')}`; + } +} diff --git a/workspaces/test/timeline_v2_test.seq b/workspaces/test/timeline_v2_test.seq new file mode 100644 index 0000000..e374c06 --- /dev/null +++ b/workspaces/test/timeline_v2_test.seq @@ -0,0 +1,8 @@ +# BPM 120 +SEQUENCE 0.0 0 "v2_test" + NODE temp1 u8x4_norm + NODE temp2 f32x4 + NODE depth depth24 + EFFECT + Hybrid3D source depth -> temp1 0.0 4.0 + EFFECT + GaussianBlur temp1 -> temp2 0.0 4.0 + EFFECT + Flash temp2 -> sink 0.0 4.0 |
