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 /tools/timeline_editor/timeline-format.js | |
| 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>
Diffstat (limited to 'tools/timeline_editor/timeline-format.js')
| -rw-r--r-- | tools/timeline_editor/timeline-format.js | 214 |
1 files changed, 214 insertions, 0 deletions
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(', ')}`; + } +} |
