-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathadd_click_sounds.mjs
More file actions
177 lines (154 loc) · 6.59 KB
/
add_click_sounds.mjs
File metadata and controls
177 lines (154 loc) · 6.59 KB
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
#!/usr/bin/env node
/**
* Post-processing script: Add click and typing sounds to recorded videos
*
* Reads .clicks.json files generated by flow scripts and uses ffmpeg to
* overlay click and typing sounds at the recorded timestamps.
*
* Usage:
* node add_click_sounds.mjs <video.webm> # single video
* node add_click_sounds.mjs --all # all videos under ./videos
* node add_click_sounds.mjs --dir ./videos/myflow # all videos in directory
*
* Sound files are read from the skill folder (.agents/skills/video-recording/).
* When this script is copied into scripts/video_recording/, the SKILL_DIR path
* resolves back to the skill folder automatically — no MP3 copies needed.
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { readFile, readdir, access, rename, unlink } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const execAsync = promisify(exec);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Sound files live in the skill folder regardless of where this script is copied.
const SKILL_DIR = path.join(__dirname, '..', '..', '.agents', 'skills', 'video-recording');
const CLICK_SOUND = path.join(SKILL_DIR, 'computer-mouse-click-352734.mp3');
const TYPING_SOUND = path.join(SKILL_DIR, 'keyboard-typing-asmr-356118.mp3');
const VIDEO_DIR = path.join(__dirname, '..', '..', 'videos');
async function fileExists(p) {
try { await access(p); return true; } catch { return false; }
}
function parseTypingPairs(typingEvents) {
const pairs = [];
let start = null;
for (const e of typingEvents || []) {
if (e.type === 'start') { start = e; }
else if (e.type === 'end' && start) {
pairs.push({ start: start.time, duration: e.time - start.time, label: start.label });
start = null;
}
}
return pairs;
}
async function addClickSoundsToVideo(videoPath, clickDataPath, outputPath) {
const audioData = JSON.parse(await readFile(clickDataPath, 'utf-8'));
const clicks = (audioData.clicks || []).map(c => typeof c === 'object' ? c.time : c);
const typingPairs = parseTypingPairs(audioData.typingEvents);
const hasTyping = await fileExists(TYPING_SOUND);
if (clicks.length === 0 && typingPairs.length === 0) {
console.log(` ⚠ No audio events recorded for ${path.basename(videoPath)}`);
return false;
}
console.log(` Processing: ${path.basename(videoPath)}`);
if (clicks.length > 0)
console.log(` Clicks: ${clicks.length} at ${clicks.map(t => t.toFixed(2) + 's').join(', ')}`);
if (typingPairs.length > 0)
console.log(` Typing: ${typingPairs.length} events${hasTyping ? '' : ' (typing sound not found, skipping)'}`);
const inputs = [];
const filters = [];
const mixInputs = [];
let idx = 1; // input 0 is the video
for (let i = 0; i < clicks.length; i++) {
inputs.push(`-i "${CLICK_SOUND}"`);
const delayMs = Math.round(clicks[i] * 1000);
filters.push(`[${idx}:a]adelay=${delayMs}|${delayMs}[click${i}]`);
mixInputs.push(`[click${i}]`);
idx++;
}
if (hasTyping) {
for (let i = 0; i < typingPairs.length; i++) {
const { start, duration } = typingPairs[i];
if (duration > 0.1) {
inputs.push(`-i "${TYPING_SOUND}"`);
const delayMs = Math.round(start * 1000);
filters.push(`[${idx}:a]aloop=loop=-1:size=2e9,atrim=duration=${duration.toFixed(2)},adelay=${delayMs}|${delayMs}[type${i}]`);
mixInputs.push(`[type${i}]`);
idx++;
}
}
}
if (mixInputs.length === 0) {
console.log(` ⚠ No audio events to process`);
return false;
}
filters.push(`${mixInputs.join('')}amix=inputs=${mixInputs.length}:duration=longest:normalize=0[audio]`);
const filterComplex = filters.join(';');
const cmd = `ffmpeg -y -i "${videoPath}" ${inputs.join(' ')} -filter_complex "${filterComplex}" -map 0:v -map "[audio]" -c:v copy -c:a libopus -b:a 96k "${outputPath}" 2>&1`;
try {
await execAsync(cmd, { maxBuffer: 50 * 1024 * 1024 });
console.log(` ✅ Created: ${path.basename(outputPath)}`);
return true;
} catch (err) {
console.error(` ❌ Error: ${err.message.split('\n').slice(-3).join(' ')}`);
return false;
}
}
async function findVideosWithClickData(dir) {
const results = [];
async function scan(d) {
for (const entry of await readdir(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) { await scan(full); }
else if (entry.name.endsWith('.clicks.json')) {
const video = full.replace('.clicks.json', '.webm');
if (await fileExists(video)) results.push({ video, clickData: full });
}
}
}
await scan(dir);
return results;
}
async function processSingleVideo(videoPath) {
const clickDataPath = videoPath.replace(/\.webm$/, '.clicks.json');
if (!await fileExists(clickDataPath)) {
console.log(` ⚠ No click data found: ${clickDataPath}`);
return false;
}
return addClickSoundsToVideo(videoPath, clickDataPath, videoPath.replace(/\.webm$/, '-with-audio.webm'));
}
async function processDirectory(dir) {
console.log(`\n🔊 Scanning: ${dir}`);
const videos = await findVideosWithClickData(dir);
if (!videos.length) { console.log(` No videos with click data found.`); return; }
console.log(` Found ${videos.length} video(s)\n`);
let ok = 0;
for (const { video, clickData } of videos) {
if (await addClickSoundsToVideo(video, clickData, video.replace(/\.webm$/, '-with-audio.webm'))) ok++;
console.log('');
}
console.log(`\n✅ Processed ${ok}/${videos.length} videos`);
}
async function main() {
const args = process.argv.slice(2);
if (!args.length) {
console.log(`
🔊 Add Click Sounds to Video Recordings
Usage:
node add_click_sounds.mjs <video.webm>
node add_click_sounds.mjs --all
node add_click_sounds.mjs --dir <directory>
`);
return;
}
if (!await fileExists(CLICK_SOUND)) {
console.error(`❌ Click sound not found: ${CLICK_SOUND}`);
console.error(` Make sure the video-recording skill is installed at .agents/skills/video-recording/`);
process.exit(1);
}
if (args[0] === '--all') await processDirectory(VIDEO_DIR);
else if (args[0] === '--dir' && args[1]) await processDirectory(args[1]);
else if (args[0].endsWith('.webm')) { console.log('\n🔊 Adding click sounds to video'); await processSingleVideo(args[0]); }
else { console.error(`❌ Unknown argument: ${args[0]}`); process.exit(1); }
}
main().catch(console.error);