summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-17 07:52:48 +0100
committerskal <pascal.massimino@gmail.com>2026-02-17 07:52:48 +0100
commitcd53ff0be8971b592d8d01836a6572c4123e5495 (patch)
treeaa0f9e48a906e352b6c3ab198580ff48978cc2da
parentcdd14146df16de0493acfd6dfbf24c154edbfce3 (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.html179
-rw-r--r--tools/timeline_editor/test_format.html127
-rw-r--r--tools/timeline_editor/timeline-format.js214
-rw-r--r--workspaces/test/timeline_v2_test.seq8
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