summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/timeline-format.js
blob: a2d00037134a5b659367995345aa2ff4af8e7f91 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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(', ')}`;
  }
}