@hustle-together/api-dev-tools 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Audio Sync Controller for Workflow Demo
3
+ *
4
+ * This module syncs audio narration with GSAP animations.
5
+ * It uses word-level timestamps to trigger highlights at the right moment.
6
+ *
7
+ * Usage:
8
+ * const sync = new AudioSyncController('audio/narration.mp3', 'audio/narration-timing.json');
9
+ * await sync.init();
10
+ * sync.play();
11
+ */
12
+
13
+ class AudioSyncController {
14
+ constructor(audioUrl, timingUrl) {
15
+ this.audioUrl = audioUrl;
16
+ this.timingUrl = timingUrl;
17
+ this.audio = null;
18
+ this.timing = null;
19
+ this.currentSection = null;
20
+ this.highlightedElements = new Set();
21
+ this.isPlaying = false;
22
+ this.onSectionChange = null;
23
+ this.onHighlight = null;
24
+ this.onWordSpoken = null;
25
+ }
26
+
27
+ /**
28
+ * Initialize the controller by loading audio and timing data
29
+ */
30
+ async init() {
31
+ // Load timing data
32
+ const response = await fetch(this.timingUrl);
33
+ this.timing = await response.json();
34
+
35
+ // Create audio element
36
+ this.audio = new Audio(this.audioUrl);
37
+ this.audio.preload = 'auto';
38
+
39
+ // Set up event listeners
40
+ this.audio.addEventListener('timeupdate', () => this.onTimeUpdate());
41
+ this.audio.addEventListener('ended', () => this.onEnded());
42
+ this.audio.addEventListener('play', () => { this.isPlaying = true; });
43
+ this.audio.addEventListener('pause', () => { this.isPlaying = false; });
44
+
45
+ // Wait for audio to be ready
46
+ return new Promise((resolve, reject) => {
47
+ this.audio.addEventListener('canplaythrough', () => resolve(), { once: true });
48
+ this.audio.addEventListener('error', (e) => reject(e), { once: true });
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Get the current playback time
54
+ */
55
+ get currentTime() {
56
+ return this.audio ? this.audio.currentTime : 0;
57
+ }
58
+
59
+ /**
60
+ * Get the total duration
61
+ */
62
+ get duration() {
63
+ return this.audio ? this.audio.duration : 0;
64
+ }
65
+
66
+ /**
67
+ * Play the audio
68
+ */
69
+ play() {
70
+ if (this.audio) {
71
+ this.audio.play();
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Pause the audio
77
+ */
78
+ pause() {
79
+ if (this.audio) {
80
+ this.audio.pause();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Toggle play/pause
86
+ */
87
+ toggle() {
88
+ if (this.isPlaying) {
89
+ this.pause();
90
+ } else {
91
+ this.play();
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Seek to a specific time
97
+ */
98
+ seek(time) {
99
+ if (this.audio) {
100
+ this.audio.currentTime = time;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Seek to a specific section
106
+ */
107
+ seekToSection(sectionId) {
108
+ const section = this.timing.sections.find(s => s.id === sectionId);
109
+ if (section) {
110
+ this.seek(section.timestamp);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Handle time updates from the audio element
116
+ */
117
+ onTimeUpdate() {
118
+ const currentTime = this.audio.currentTime;
119
+
120
+ // Check for section changes
121
+ const newSection = this.getCurrentSection(currentTime);
122
+ if (newSection !== this.currentSection) {
123
+ this.currentSection = newSection;
124
+ this.clearHighlights();
125
+ if (this.onSectionChange) {
126
+ this.onSectionChange(newSection);
127
+ }
128
+ // Scroll to section
129
+ this.scrollToSection(newSection);
130
+ }
131
+
132
+ // Check for highlights that should be active
133
+ this.updateHighlights(currentTime);
134
+
135
+ // Check for words being spoken
136
+ if (this.onWordSpoken) {
137
+ const currentWord = this.getCurrentWord(currentTime);
138
+ if (currentWord) {
139
+ this.onWordSpoken(currentWord);
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Get the current section based on time
146
+ */
147
+ getCurrentSection(time) {
148
+ let current = null;
149
+ for (const section of this.timing.sections) {
150
+ if (section.timestamp <= time) {
151
+ current = section.id;
152
+ } else {
153
+ break;
154
+ }
155
+ }
156
+ return current;
157
+ }
158
+
159
+ /**
160
+ * Get the current word being spoken
161
+ */
162
+ getCurrentWord(time) {
163
+ for (const word of this.timing.words) {
164
+ if (time >= word.start && time <= word.end) {
165
+ return word;
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Update highlights based on current time
173
+ */
174
+ updateHighlights(time) {
175
+ // Get highlights that should be active (within 3 seconds of their timestamp)
176
+ const HIGHLIGHT_DURATION = 3; // seconds
177
+
178
+ for (const highlight of this.timing.highlights) {
179
+ const isActive = time >= highlight.timestamp &&
180
+ time < highlight.timestamp + HIGHLIGHT_DURATION;
181
+
182
+ const wasHighlighted = this.highlightedElements.has(highlight.selector);
183
+
184
+ if (isActive && !wasHighlighted) {
185
+ // Add highlight
186
+ this.applyHighlight(highlight.selector);
187
+ this.highlightedElements.add(highlight.selector);
188
+ if (this.onHighlight) {
189
+ this.onHighlight(highlight.selector, true);
190
+ }
191
+ } else if (!isActive && wasHighlighted) {
192
+ // Remove highlight
193
+ this.removeHighlight(highlight.selector);
194
+ this.highlightedElements.delete(highlight.selector);
195
+ if (this.onHighlight) {
196
+ this.onHighlight(highlight.selector, false);
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Apply highlight animation to an element
204
+ */
205
+ applyHighlight(selector) {
206
+ const elements = document.querySelectorAll(selector);
207
+ elements.forEach(el => {
208
+ // Add highlight class
209
+ el.classList.add('audio-highlighted');
210
+
211
+ // Use GSAP for smooth animation if available
212
+ if (typeof gsap !== 'undefined') {
213
+ gsap.to(el, {
214
+ boxShadow: '0 0 30px var(--accent-red-glow), 0 0 60px var(--accent-red-glow)',
215
+ borderColor: 'var(--accent-red)',
216
+ scale: 1.02,
217
+ duration: 0.3,
218
+ ease: 'power2.out'
219
+ });
220
+ }
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Remove highlight from an element
226
+ */
227
+ removeHighlight(selector) {
228
+ const elements = document.querySelectorAll(selector);
229
+ elements.forEach(el => {
230
+ el.classList.remove('audio-highlighted');
231
+
232
+ if (typeof gsap !== 'undefined') {
233
+ gsap.to(el, {
234
+ boxShadow: 'none',
235
+ borderColor: 'var(--grey)',
236
+ scale: 1,
237
+ duration: 0.3,
238
+ ease: 'power2.out'
239
+ });
240
+ }
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Clear all highlights
246
+ */
247
+ clearHighlights() {
248
+ for (const selector of this.highlightedElements) {
249
+ this.removeHighlight(selector);
250
+ }
251
+ this.highlightedElements.clear();
252
+ }
253
+
254
+ /**
255
+ * Scroll to a section
256
+ */
257
+ scrollToSection(sectionId) {
258
+ const element = document.getElementById(sectionId);
259
+ if (element) {
260
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Handle audio ended
266
+ */
267
+ onEnded() {
268
+ this.isPlaying = false;
269
+ this.clearHighlights();
270
+ if (this.onSectionChange) {
271
+ this.onSectionChange(null);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Get all section timestamps for building a progress bar
277
+ */
278
+ getSectionMarkers() {
279
+ return this.timing.sections.map(s => ({
280
+ id: s.id,
281
+ timestamp: s.timestamp,
282
+ percentage: (s.timestamp / this.duration) * 100
283
+ }));
284
+ }
285
+ }
286
+
287
+ // Export for use in browser
288
+ if (typeof window !== 'undefined') {
289
+ window.AudioSyncController = AudioSyncController;
290
+ }
291
+
292
+ // Export for Node.js (if needed for testing)
293
+ if (typeof module !== 'undefined') {
294
+ module.exports = AudioSyncController;
295
+ }
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate narration audio with word-level timestamps using ElevenLabs API
4
+ *
5
+ * Usage: ELEVENLABS_API_KEY=your_key node generate-narration.js
6
+ *
7
+ * Output:
8
+ * - narration.mp3 - The audio file
9
+ * - narration-timing.json - Word timestamps with highlight triggers
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ // ElevenLabs API configuration
16
+ const API_BASE = 'https://api.elevenlabs.io/v1';
17
+ const VOICE_ID = 'pNInz6obpgDQGcFmaJgB'; // Adam - deep, professional voice
18
+ const MODEL_ID = 'eleven_turbo_v2_5'; // Fast, high-quality model
19
+
20
+ // The narration script with section markers and highlight triggers
21
+ // Format: [SECTION:id] marks a new section, [HIGHLIGHT:element-selector] marks what to highlight
22
+ const NARRATION_SCRIPT = `
23
+ [SECTION:intro]
24
+ Welcome to Hustle API Dev Tools.
25
+
26
+ [HIGHLIGHT:#hustleBrand]
27
+ This package enforces a structured workflow for AI-assisted API development.
28
+
29
+ [HIGHLIGHT:[data-phase="research"]]
30
+ First, you research. No assumptions. No training data. Real documentation.
31
+
32
+ [HIGHLIGHT:[data-phase="interview"]]
33
+ Then you interview. The AI asks YOU questions with structured options based on what it learned.
34
+
35
+ [HIGHLIGHT:[data-phase="test"]]
36
+ Next, you write tests first. Red, green, refactor. No implementation without a failing test.
37
+
38
+ [HIGHLIGHT:[data-phase="code"]]
39
+ Only then do you write code. Minimal. Just enough to pass the tests.
40
+
41
+ [HIGHLIGHT:[data-phase="docs"]]
42
+ Finally, documentation. Every endpoint documented with real examples and schemas.
43
+
44
+ The philosophy is simple: Hustle together. Share resources. Build stronger.
45
+
46
+ [SECTION:problems]
47
+ [HIGHLIGHT:#problems h2]
48
+ Let's talk about the problem. What goes wrong when AI builds APIs without structure?
49
+
50
+ [HIGHLIGHT:.gap-item:nth-child(1)]
51
+ Gap one: AI doesn't use your exact words. You say Vercel AI Gateway but it searches for Vercel AI SDK. Wrong library. Wrong documentation. Wrong code.
52
+
53
+ [HIGHLIGHT:.gap-item:nth-child(2)]
54
+ Gap two: AI claims files are updated without proof. It says I've updated the file but there's no git diff. No verification. You're trusting on faith.
55
+
56
+ [HIGHLIGHT:.gap-item:nth-child(3)]
57
+ Gap three: Skipped tests are accepted. The AI runs tests, some fail, and it moves on. We can fix those later. Those later fixes never come.
58
+
59
+ [HIGHLIGHT:.gap-item:nth-child(4)]
60
+ Gap four: Tasks marked complete without verification. The AI says Done but the feature doesn't work. No one actually checked.
61
+
62
+ [HIGHLIGHT:.gap-item:nth-child(5)]
63
+ Gap five: Environment variable mismatch. Tests pass locally but fail in production. The AI used different values than what's actually deployed.
64
+
65
+ These gaps compound. One wrong assumption leads to another. By the time you notice, you've built on a broken foundation.
66
+
67
+ [SECTION:solution]
68
+ [HIGHLIGHT:#solution h2]
69
+ The solution is enforcement. Python hooks that intercept every tool call.
70
+
71
+ [HIGHLIGHT:.hook-box:nth-child(1)]
72
+ PreToolUse hooks run before Claude can write or edit any file. They check: Did you research first? Did you interview the user? Did you write a failing test?
73
+
74
+ [HIGHLIGHT:.hook-box:nth-child(2)]
75
+ PostToolUse hooks run after research and interviews. They track what was learned. They log every query. They build a paper trail.
76
+
77
+ [HIGHLIGHT:.hook-box:nth-child(3)]
78
+ The Stop hook runs when Claude tries to mark a task complete. It checks: Are all phases done? Did tests pass? Is documentation updated? If not, blocked.
79
+
80
+ This isn't about limiting AI. It's about holding it to the same standards we hold ourselves.
81
+
82
+ [SECTION:workflow]
83
+ [HIGHLIGHT:#workflow h2]
84
+ The workflow has ten phases. Let's walk through each one.
85
+
86
+ [HIGHLIGHT:.workflow-phase:nth-child(1)]
87
+ Phase one: Scope. Define what you're building. What's the endpoint? What does it do?
88
+
89
+ [HIGHLIGHT:.workflow-phase:nth-child(2)]
90
+ Phase two: Initial research. Use Context7 or web search. Find the real documentation. No guessing.
91
+
92
+ [HIGHLIGHT:.workflow-phase:nth-child(3)]
93
+ Phase three: Interview. Ask the user questions with multiple choice options. What provider? What format? What error handling?
94
+
95
+ [HIGHLIGHT:.workflow-phase:nth-child(4)]
96
+ Phase four: Deep research. Based on interview answers, research specific APIs and SDKs.
97
+
98
+ [HIGHLIGHT:.workflow-phase:nth-child(5)]
99
+ Phase five: Schema design. Define request and response schemas with Zod. Types before code.
100
+
101
+ [HIGHLIGHT:.workflow-phase:nth-child(6)]
102
+ Phase six: Environment setup. Check API keys. Verify environment variables. Test connectivity.
103
+
104
+ [HIGHLIGHT:.workflow-phase:nth-child(7)]
105
+ Phase seven: Red. Write a failing test. Define what success looks like before writing any implementation.
106
+
107
+ [HIGHLIGHT:.workflow-phase:nth-child(8)]
108
+ Phase eight: Green. Write minimal code to pass the test. No extra features. No premature optimization.
109
+
110
+ [HIGHLIGHT:.workflow-phase:nth-child(9)]
111
+ Phase nine: Refactor. Clean up the code. Extract utilities. Improve readability. Tests stay green.
112
+
113
+ [HIGHLIGHT:.workflow-phase:nth-child(10)]
114
+ Phase ten: Documentation. Update OpenAPI spec. Add to test manifest. Include real examples.
115
+
116
+ Only when all ten phases are complete can the workflow finish.
117
+
118
+ [SECTION:installation]
119
+ [HIGHLIGHT:#installation h2]
120
+ Installation takes one command.
121
+
122
+ [HIGHLIGHT:.install-command]
123
+ Run npx @hustle-together/api-dev-tools. That's it.
124
+
125
+ The CLI copies slash commands to your .claude/commands folder. Red, green, refactor, cycle, and the API development commands.
126
+
127
+ It copies Python hooks to .claude/hooks. These are the enforcers. The gatekeepers.
128
+
129
+ It merges settings into your settings.json. Hooks are registered. Permissions are configured.
130
+
131
+ And it offers to add the Context7 MCP server for live documentation lookup.
132
+
133
+ Your project is now enforced. Every API you build follows the workflow.
134
+
135
+ [SECTION:credits]
136
+ [HIGHLIGHT:#credits h2]
137
+ This project builds on the work of others.
138
+
139
+ The TDD workflow commands are based on @wbern/claude-instructions by William Bernmalm. The red, green, refactor pattern that makes AI development rigorous.
140
+
141
+ The interview methodology is inspired by Anthropic's Interviewer approach. Structured discovery before implementation.
142
+
143
+ And Context7 provides live documentation lookup. Current docs, not stale training data.
144
+
145
+ Thank you to the Claude Code community. Together, we're making AI development better.
146
+
147
+ [SECTION:outro]
148
+ [HIGHLIGHT:#intro]
149
+ Hustle API Dev Tools. Research first. Interview second. Test before code. Document everything.
150
+
151
+ Build together. Share resources. Grow stronger.
152
+
153
+ Install it now with npx @hustle-together/api-dev-tools.
154
+ `.trim();
155
+
156
+ /**
157
+ * Extract plain text from the narration script (remove markers)
158
+ */
159
+ function extractPlainText(script) {
160
+ return script
161
+ .replace(/\[SECTION:[^\]]+\]/g, '')
162
+ .replace(/\[HIGHLIGHT:[^\]]+\]/g, '')
163
+ .replace(/\n{3,}/g, '\n\n')
164
+ .trim();
165
+ }
166
+
167
+ /**
168
+ * Parse the script to extract section and highlight markers with their positions
169
+ */
170
+ function parseMarkers(script) {
171
+ const markers = [];
172
+ const lines = script.split('\n');
173
+ let charPosition = 0;
174
+ let currentSection = 'intro';
175
+
176
+ for (const line of lines) {
177
+ // Check for section marker
178
+ const sectionMatch = line.match(/\[SECTION:([^\]]+)\]/);
179
+ if (sectionMatch) {
180
+ currentSection = sectionMatch[1];
181
+ markers.push({
182
+ type: 'section',
183
+ id: currentSection,
184
+ charPosition
185
+ });
186
+ }
187
+
188
+ // Check for highlight marker
189
+ const highlightMatch = line.match(/\[HIGHLIGHT:([^\]]+)\]/);
190
+ if (highlightMatch) {
191
+ markers.push({
192
+ type: 'highlight',
193
+ selector: highlightMatch[1],
194
+ section: currentSection,
195
+ charPosition
196
+ });
197
+ }
198
+
199
+ // Update char position (for plain text, not including markers)
200
+ const plainLine = line
201
+ .replace(/\[SECTION:[^\]]+\]/g, '')
202
+ .replace(/\[HIGHLIGHT:[^\]]+\]/g, '');
203
+ if (plainLine.trim()) {
204
+ charPosition += plainLine.length + 1; // +1 for newline
205
+ }
206
+ }
207
+
208
+ return markers;
209
+ }
210
+
211
+ /**
212
+ * Convert character-level timestamps to word-level timestamps
213
+ */
214
+ function characterToWordTimestamps(alignment) {
215
+ const words = [];
216
+ let currentWord = '';
217
+ let wordStart = null;
218
+ let wordEnd = null;
219
+
220
+ for (let i = 0; i < alignment.characters.length; i++) {
221
+ const char = alignment.characters[i];
222
+ const startTime = alignment.character_start_times_seconds[i];
223
+ const endTime = alignment.character_end_times_seconds[i];
224
+
225
+ if (char === ' ' || char === '\n') {
226
+ if (currentWord) {
227
+ words.push({
228
+ word: currentWord,
229
+ start: wordStart,
230
+ end: wordEnd,
231
+ charIndex: i - currentWord.length
232
+ });
233
+ currentWord = '';
234
+ wordStart = null;
235
+ wordEnd = null;
236
+ }
237
+ } else {
238
+ if (wordStart === null) {
239
+ wordStart = startTime;
240
+ }
241
+ wordEnd = endTime;
242
+ currentWord += char;
243
+ }
244
+ }
245
+
246
+ // Don't forget the last word
247
+ if (currentWord) {
248
+ words.push({
249
+ word: currentWord,
250
+ start: wordStart,
251
+ end: wordEnd,
252
+ charIndex: alignment.characters.length - currentWord.length
253
+ });
254
+ }
255
+
256
+ return words;
257
+ }
258
+
259
+ /**
260
+ * Match markers to timestamps based on text position
261
+ */
262
+ function matchMarkersToTimestamps(markers, wordTimestamps, plainText) {
263
+ const timedMarkers = [];
264
+
265
+ for (const marker of markers) {
266
+ // Find the word closest to this marker's position
267
+ let closestWord = wordTimestamps[0];
268
+ let minDiff = Infinity;
269
+
270
+ for (const word of wordTimestamps) {
271
+ const diff = Math.abs(word.charIndex - marker.charPosition);
272
+ if (diff < minDiff) {
273
+ minDiff = diff;
274
+ closestWord = word;
275
+ }
276
+ }
277
+
278
+ timedMarkers.push({
279
+ ...marker,
280
+ timestamp: closestWord ? closestWord.start : 0
281
+ });
282
+ }
283
+
284
+ return timedMarkers;
285
+ }
286
+
287
+ /**
288
+ * Call ElevenLabs API to generate speech with timestamps
289
+ */
290
+ async function generateSpeech(text, apiKey) {
291
+ const url = `${API_BASE}/text-to-speech/${VOICE_ID}/with-timestamps`;
292
+
293
+ console.log('Calling ElevenLabs API...');
294
+ console.log(`Text length: ${text.length} characters`);
295
+
296
+ const response = await fetch(url, {
297
+ method: 'POST',
298
+ headers: {
299
+ 'xi-api-key': apiKey,
300
+ 'Content-Type': 'application/json'
301
+ },
302
+ body: JSON.stringify({
303
+ text,
304
+ model_id: MODEL_ID,
305
+ voice_settings: {
306
+ stability: 0.5,
307
+ similarity_boost: 0.75,
308
+ style: 0.3,
309
+ use_speaker_boost: true
310
+ }
311
+ })
312
+ });
313
+
314
+ if (!response.ok) {
315
+ const error = await response.text();
316
+ throw new Error(`ElevenLabs API error: ${response.status} - ${error}`);
317
+ }
318
+
319
+ return response.json();
320
+ }
321
+
322
+ /**
323
+ * Main function
324
+ */
325
+ async function main() {
326
+ const apiKey = process.env.ELEVENLABS_API_KEY;
327
+
328
+ if (!apiKey) {
329
+ console.error('Error: ELEVENLABS_API_KEY environment variable is required');
330
+ console.error('Usage: ELEVENLABS_API_KEY=your_key node generate-narration.js');
331
+ process.exit(1);
332
+ }
333
+
334
+ const outputDir = __dirname;
335
+ const audioPath = path.join(outputDir, 'narration.mp3');
336
+ const timingPath = path.join(outputDir, 'narration-timing.json');
337
+
338
+ // Extract plain text for TTS
339
+ const plainText = extractPlainText(NARRATION_SCRIPT);
340
+ console.log('Plain text extracted:', plainText.substring(0, 200) + '...');
341
+
342
+ // Parse markers from script
343
+ const markers = parseMarkers(NARRATION_SCRIPT);
344
+ console.log(`Found ${markers.length} markers`);
345
+
346
+ try {
347
+ // Generate speech with timestamps
348
+ const result = await generateSpeech(plainText, apiKey);
349
+
350
+ console.log('Audio generated successfully!');
351
+
352
+ // Decode and save audio
353
+ const audioBuffer = Buffer.from(result.audio_base64, 'base64');
354
+ fs.writeFileSync(audioPath, audioBuffer);
355
+ console.log(`Audio saved to: ${audioPath}`);
356
+ console.log(`Audio size: ${(audioBuffer.length / 1024 / 1024).toFixed(2)} MB`);
357
+
358
+ // Convert character timestamps to word timestamps
359
+ const wordTimestamps = characterToWordTimestamps(result.alignment);
360
+ console.log(`Extracted ${wordTimestamps.length} word timestamps`);
361
+
362
+ // Match markers to timestamps
363
+ const timedMarkers = matchMarkersToTimestamps(markers, wordTimestamps, plainText);
364
+
365
+ // Calculate duration
366
+ const lastWord = wordTimestamps[wordTimestamps.length - 1];
367
+ const duration = lastWord ? lastWord.end : 0;
368
+
369
+ // Build timing data
370
+ const timingData = {
371
+ generated: new Date().toISOString(),
372
+ duration,
373
+ wordCount: wordTimestamps.length,
374
+ sections: [],
375
+ highlights: [],
376
+ words: wordTimestamps.map(w => ({
377
+ word: w.word,
378
+ start: w.start,
379
+ end: w.end
380
+ }))
381
+ };
382
+
383
+ // Separate sections and highlights
384
+ for (const marker of timedMarkers) {
385
+ if (marker.type === 'section') {
386
+ timingData.sections.push({
387
+ id: marker.id,
388
+ timestamp: marker.timestamp
389
+ });
390
+ } else if (marker.type === 'highlight') {
391
+ timingData.highlights.push({
392
+ selector: marker.selector,
393
+ section: marker.section,
394
+ timestamp: marker.timestamp
395
+ });
396
+ }
397
+ }
398
+
399
+ // Save timing data
400
+ fs.writeFileSync(timingPath, JSON.stringify(timingData, null, 2));
401
+ console.log(`Timing data saved to: ${timingPath}`);
402
+
403
+ console.log('\n=== Summary ===');
404
+ console.log(`Duration: ${duration.toFixed(1)} seconds`);
405
+ console.log(`Sections: ${timingData.sections.length}`);
406
+ console.log(`Highlights: ${timingData.highlights.length}`);
407
+ console.log(`Words: ${timingData.words.length}`);
408
+
409
+ console.log('\n=== Section Timestamps ===');
410
+ for (const section of timingData.sections) {
411
+ const mins = Math.floor(section.timestamp / 60);
412
+ const secs = (section.timestamp % 60).toFixed(1);
413
+ console.log(` ${section.id}: ${mins}:${secs.padStart(4, '0')}`);
414
+ }
415
+
416
+ } catch (error) {
417
+ console.error('Error:', error.message);
418
+ process.exit(1);
419
+ }
420
+ }
421
+
422
+ main();