// 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 '