summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/timeline-format.js
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 /tools/timeline_editor/timeline-format.js
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>
Diffstat (limited to 'tools/timeline_editor/timeline-format.js')
-rw-r--r--tools/timeline_editor/timeline-format.js214
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(', ')}`;
+ }
+}