1 # Dead Space Removal & Social Media Chunking
2
3 Complete implementation guide for removing silence/dead space from videos and chunking longer videos into social media-ready clips.
4
5 ## Table of Contents
6
7 1. [Dead Space Removal](#dead-space-removal)
8 2. [Social Media Chunking](#social-media-chunking)
9
10 3. [Combined Pipeline](#combined-pipeline)
11
12 ---
13
14 ## Dead Space Removal
15
16 Remove silence, dead air, and filler from a video to produce a tighter cut.
17
18 ### Step 1: Detect Silent Intervals
19
20 Use FFmpeg's `silencedetect` filter to find all silent sections. Parse the output from stderr.
21
22 ```typescript
23
24 import { execFile } from 'child_process';
25
26 import { promisify } from 'util';
27
28 const execFileAsync = promisify(execFile);
29
30 interface SilentInterval {
31
32 start: number;
33
34 end: number;
35
36 duration: number;
37
38 }
39
40 interface SpeakingInterval {
41
42 start: number;
43
44 end: number;
45
46 duration: number;
47
48 }
49
50 async function detectSilence(
51
52 videoPath: string,
53
54 noiseThresholdDb = -35,
55
56 minSilenceDuration = 0.5
57
58 ): Promise<SilentInterval[]> {
59
60 const { stderr } = await execFileAsync('ffmpeg', [
61
62 '-i', videoPath,
63
64 '-af', `silencedetect=noise=${noiseThresholdDb}dB:d=${minSilenceDuration}`,
65
66 '-f', 'null', '-'
67
68 ], { maxBuffer: 10 * 1024 * 1024 });
69
70 const silentIntervals: SilentInterval[] = [];
71
72 const lines = stderr.split('\n');
73
74 let currentStart: number | null = null;
75
76 for (const line of lines) {
77
78 const startMatch = line.match(/silence_start:\s*([0-9.]+)/);
79
80 const endMatch = line.match(/silence_end:\s*([0-9.]+)\s*\\\s*silence_duration:\s*([0-9.]+)/);
81
82 if (startMatch) {
83
84 currentStart = parseFloat(startMatch[1]);
85
86 }
87
88 if (endMatch && currentStart !== null) {
89
90 const end = parseFloat(endMatch[1]);
91
92 const duration = parseFloat(endMatch[2]);
93
94 silentIntervals.push({ start: currentStart, end, duration });
95
96 currentStart = null;
97
98 }
99
100 }
101
102 return silentIntervals;
103
104 }
105
106 ```
107
108 ### Presets
109
110 ```typescript
111
112 const SILENCE_PRESETS = {
113
114 light: { noiseThresholdDb: -40, minSilenceDuration: 1.0 },
115
116 medium: { noiseThresholdDb: -35, minSilenceDuration: 0.5 },
117
118 aggressive: { noiseThresholdDb: -30, minSilenceDuration: 0.3 },
119
120 } as const;
121
122 type SilencePreset = keyof typeof SILENCE_PRESETS;
123
124 ```
125
126 ### Step 2: Calculate Non-Silent (Speaking) Intervals
127
128 Invert the silent intervals to find the parts worth keeping. Add a small padding around each speaking segment so words don't get clipped.
129
130 ```typescript
131
132 async function getSpeakingIntervals(
133
134 videoPath: string,
135
136 silentIntervals: SilentInterval[],
137
138 paddingSeconds = 0.1
139
140 ): Promise<SpeakingInterval[]> {
141
142 const { stdout } = await execFileAsync('ffprobe', [
143
144 '-v', 'quiet', '-print_format', 'json', '-show_format', videoPath
145
146 ]);
147
148 const totalDuration = parseFloat(JSON.parse(stdout).format.duration);
149
150 if (silentIntervals.length === 0) {
151
152 return [{ start: 0, end: totalDuration, duration: totalDuration }];
153
154 }
155
156 const speaking: SpeakingInterval[] = [];
157
158 let cursor = 0;
159
160 for (const silence of silentIntervals) {
161
162 const segStart = Math.max(0, cursor);
163
164 const segEnd = Math.max(segStart, silence.start + paddingSeconds);
165
166 if (segEnd - segStart >= 0.1) {
167
168 speaking.push({ start: segStart, end: segEnd, duration: segEnd - segStart });
169
170 }
171
172 cursor = Math.max(cursor, silence.end - paddingSeconds);
173
174 }
175
176 if (cursor < totalDuration) {
177
178 speaking.push({
179
180 start: cursor,
181
182 end: totalDuration,
183
184 duration: totalDuration - cursor,
185
186 });
187
188 }
189
190 return speaking;
191
192 }
193
194 ```
195
196 ### Step 3: Extract and Reassemble
197
198 Extract each speaking segment and concatenate them into the final output.
199
200 ```typescript
201
202 import * as fs from 'fs';
203
204 import * as path from 'path';
205
206 async function removeDeadSpace(
207
208 videoPath: string,
209
210 outputPath: string,
211
212 preset: SilencePreset = 'medium',
213
214 useTransitions = false,
215
216 transitionDuration = 0.3
217
218 ): Promise<{ outputPath: string; originalDuration: number; newDuration: number; removedSeconds: number }> {
219
220 const config = SILENCE_PRESETS[preset];
221
222 console.log(`Detecting silence (preset: ${preset}, threshold: ${config.noiseThresholdDb}dB, min duration: ${config.minSilenceDuration}s)...`);
223
224 const silentIntervals = await detectSilence(videoPath, config.noiseThresholdDb, config.minSilenceDuration);
225
226 console.log(`Found ${silentIntervals.length} silent intervals`);
227
228 const speakingIntervals = await getSpeakingIntervals(videoPath, silentIntervals);
229
230 console.log(`Found ${speakingIntervals.length} speaking segments`);
231
232 if (speakingIntervals.length === 0) {
233
234 throw new Error('No speaking segments found — the entire video appears silent');
235
236 }
237
238 if (speakingIntervals.length === 1 && silentIntervals.length === 0) {
239
240 console.log('No silence detected — video is already tight');
241
242 await fs.promises.copyFile(videoPath, outputPath);
243
244 const dur = speakingIntervals[0].duration;
245
246 return { outputPath, originalDuration: dur, newDuration: dur, removedSeconds: 0 };
247
248 }
249
250 const tmpDir = path.join(path.dirname(outputPath), `tmp_deadspace_${Date.now()}`);
251
252 await fs.promises.mkdir(tmpDir, { recursive: true });
253
254 const clipPaths: string[] = [];
255
256 for (let i = 0; i < speakingIntervals.length; i++) {
257
258 const seg = speakingIntervals[i];
259
260 const clipPath = path.join(tmpDir, `seg_${i.toString().padStart(4, '0')}.mp4`);
261
262 await execFileAsync('ffmpeg', [
263
264 '-y',
265
266 '-i', videoPath,
267
268 '-ss', seg.start.toFixed(3),
269
270 '-to', seg.end.toFixed(3),
271
272 '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
273
274 '-c:a', 'aac', '-b:a', '128k',
275
276 clipPath
277
278 ]);
279
280 clipPaths.push(clipPath);
281
282 }
283
284 const concatList = path.join(tmpDir, 'concat.txt');
285
286 const lines = clipPaths.map(p => `file '${p}'`).join('\n');
287
288 await fs.promises.writeFile(concatList, lines);
289
290 await execFileAsync('ffmpeg', [
291
292 '-y',
293
294 '-f', 'concat', '-safe', '0',
295
296 '-i', concatList,
297
298 '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
299
300 '-c:a', 'aac', '-b:a', '128k',
301
302 outputPath
303
304 ]);
305
306 for (const cp of clipPaths) await fs.promises.unlink(cp).catch(() => {});
307
308 await fs.promises.rm(tmpDir, { recursive: true, force: true });
309
310 const { stdout: probeOrig } = await execFileAsync('ffprobe', [
311
312 '-v', 'quiet', '-print_format', 'json', '-show_format', videoPath
313
314 ]);
315
316 const { stdout: probeNew } = await execFileAsync('ffprobe', [
317
318 '-v', 'quiet', '-print_format', 'json', '-show_format', outputPath
319
320 ]);
321
322 const originalDuration = parseFloat(JSON.parse(probeOrig).format.duration);
323
324 const newDuration = parseFloat(JSON.parse(probeNew).format.duration);
325
326 return {
327
328 outputPath,
329
330 originalDuration,
331
332 newDuration,
333
334 removedSeconds: originalDuration - newDuration,
335
336 };
337
338 }
339
340 ```
341
342 ### Quick FFmpeg-only approach (no Node.js)
343
344 For a simple one-off dead space removal, you can use FFmpeg directly with a two-pass approach:
345
346 ```bash
347
348 # Step 1: Detect silence and note timestamps
349
350 ffmpeg -i input.mp4 -af "silencedetect=noise=-35dB:d=0.5" -f null - 2>&1 | grep "silence_"
351
352 # Step 2: Manually construct a trim filter from the timestamps, or use the select filter:
353
354 # This keeps only non-silent frames (audio-based)
355
356 ffmpeg -i input.mp4 \\
357
358 -af "silenceremove=stop_periods=-1:stop_duration=0.5:stop_threshold=-35dB" \\
359
360 -c:v libx264 -crf 23 -preset medium \\
361
362 output_no_silence.mp4
363
364 ```
365
366 **Note:** The `silenceremove` filter only removes silence from the audio track — the video frames during silence are NOT removed. For proper video dead space removal (where both audio and video are cut), you must use the segmented extraction approach above.
367
368 ---
369
370 ## Social Media Chunking
371
372 Break a longer video into self-contained clips of 10-60 seconds for TikTok, Reels, and Shorts.
373
374 ### Step 1: Find Natural Break Points
375
376 Combine scene detection and silence detection to find the best places to split.
377
378 ```typescript
379
380 interface BreakPoint {
381
382 time: number;
383
384 type: 'scene' | 'silence' | 'both';
385
386 strength: number;
387
388 }
389
390 async function findBreakPoints(
391
392 videoPath: string,
393
394 sceneThreshold = 0.3,
395
396 silenceThresholdDb = -35,
397
398 minSilenceDuration = 0.3
399
400 ): Promise<BreakPoint[]> {
401
402 const sceneChanges = await detectSceneChangeTimes(videoPath, sceneThreshold);
403
404 const silentIntervals = await detectSilence(videoPath, silenceThresholdDb, minSilenceDuration);
405
406 const breakPoints: BreakPoint[] = [];
407
408 for (const sceneTime of sceneChanges) {
409
410 const nearbySilence = silentIntervals.find(
411
412 s => Math.abs(s.start - sceneTime) < 1.0 || Math.abs(s.end - sceneTime) < 1.0
413
414 );
415
416 if (nearbySilence) {
417
418 breakPoints.push({
419
420 time: nearbySilence.end,
421
422 type: 'both',
423
424 strength: 1.0,
425
426 });
427
428 } else {
429
430 breakPoints.push({ time: sceneTime, type: 'scene', strength: 0.6 });
431
432 }
433
434 }
435
436 for (const silence of silentIntervals) {
437
438 const alreadyCovered = breakPoints.some(bp => Math.abs(bp.time - silence.end) < 1.0);
439
440 if (!alreadyCovered) {
441
442 breakPoints.push({
443
444 time: silence.end,
445
446 type: 'silence',
447
448 strength: 0.4 + Math.min(silence.duration / 3, 0.4),
449
450 });
451
452 }
453
454 }
455
456 breakPoints.sort((a, b) => a.time - b.time);
457
458 return breakPoints;
459
460 }
461
462 async function detectSceneChangeTimes(videoPath: string, threshold = 0.3): Promise<number[]> {
463
464 const { stderr } = await execFileAsync('ffmpeg', [
465
466 '-i', videoPath,
467
468 '-filter:v', `select='gt(scene,${threshold})',showinfo`,
469
470 '-f', 'null', '-'
471
472 ], { maxBuffer: 10 * 1024 * 1024 });
473
474 const times: number[] = [];
475
476 for (const line of stderr.split('\n')) {
477
478 const match = line.match(/pts_time:([0-9.]+)/);
479
480 if (match) {
481
482 const pts = parseFloat(match[1]);
483
484 if (pts > 0) times.push(pts);
485
486 }
487
488 }
489
490 return times;
491
492 }
493
494 ```
495
496 ### Step 2: Build Clips from Break Points
497
498 Group segments between break points into clips targeting the desired length range.
499
500 ```typescript
501
502 interface ClipRange {
503
504 index: number;
505
506 start: number;
507
508 end: number;
509
510 duration: number;
511
512 breakType: string;
513
514 }
515
516 interface ChunkingConfig {
517
518 minClipDuration: number;
519
520 maxClipDuration: number;
521
522 idealClipDuration: number;
523
524 }
525
526 const PLATFORM_CONFIGS: Record<string, ChunkingConfig> = {
527
528 tiktok: { minClipDuration: 10, maxClipDuration: 45, idealClipDuration: 25 },
529
530 reels: { minClipDuration: 10, maxClipDuration: 30, idealClipDuration: 20 },
531
532 instagram_reels: { minClipDuration: 10, maxClipDuration: 30, idealClipDuration: 20 },
533
534 instagram_stories: { minClipDuration: 1, maxClipDuration: 60, idealClipDuration: 15 },
535
536 instagram_feed: { minClipDuration: 3, maxClipDuration: 60, idealClipDuration: 30 },
537
538 shorts: { minClipDuration: 15, maxClipDuration: 60, idealClipDuration: 40 },
539
540 youtube_shorts: { minClipDuration: 15, maxClipDuration: 60, idealClipDuration: 40 },
541
542 youtube: { minClipDuration: 30, maxClipDuration: 300, idealClipDuration: 120 },
543
544 twitter: { minClipDuration: 10, maxClipDuration: 45, idealClipDuration: 25 },
545
546 x: { minClipDuration: 10, maxClipDuration: 45, idealClipDuration: 25 },
547
548 facebook_reels: { minClipDuration: 10, maxClipDuration: 30, idealClipDuration: 20 },
549
550 facebook_feed: { minClipDuration: 15, maxClipDuration: 60, idealClipDuration: 30 },
551
552 linkedin: { minClipDuration: 10, maxClipDuration: 60, idealClipDuration: 30 },
553
554 pinterest: { minClipDuration: 6, maxClipDuration: 60, idealClipDuration: 20 },
555
556 snapchat: { minClipDuration: 5, maxClipDuration: 60, idealClipDuration: 20 },
557
558 general: { minClipDuration: 10, maxClipDuration: 60, idealClipDuration: 30 },
559
560 };
561
562 const PLATFORM_REFRAME: Record<string, { width: number; height: number; ratio: string }> = {
563
564 tiktok: { width: 1080, height: 1920, ratio: '9:16' },
565
566 reels: { width: 1080, height: 1920, ratio: '9:16' },
567
568 instagram_reels: { width: 1080, height: 1920, ratio: '9:16' },
569
570 instagram_stories: { width: 1080, height: 1920, ratio: '9:16' },
571
572 instagram_feed: { width: 1080, height: 1080, ratio: '1:1' },
573
574 shorts: { width: 1080, height: 1920, ratio: '9:16' },
575
576 youtube_shorts: { width: 1080, height: 1920, ratio: '9:16' },
577
578 youtube: { width: 1920, height: 1080, ratio: '16:9' },
579
580 twitter: { width: 1280, height: 720, ratio: '16:9' },
581
582 x: { width: 1280, height: 720, ratio: '16:9' },
583
584 facebook_reels: { width: 1080, height: 1920, ratio: '9:16' },
585
586 facebook_feed: { width: 1920, height: 1080, ratio: '16:9' },
587
588 linkedin: { width: 1920, height: 1080, ratio: '16:9' },
589
590 pinterest: { width: 1080, height: 1920, ratio: '9:16' },
591
592 snapchat: { width: 1080, height: 1920, ratio: '9:16' },
593
594 };
595
596 async function buildClips(
597
598 videoPath: string,
599
600 breakPoints: BreakPoint[],
601
602 platform: string = 'tiktok'
603
604 ): Promise<ClipRange[]> {
605
606 const config = PLATFORM_CONFIGS[platform] ?? PLATFORM_CONFIGS.general;
607
608 const { stdout } = await execFileAsync('ffprobe', [
609
610 '-v', 'quiet', '-print_format', 'json', '-show_format', videoPath
611
612 ]);
613
614 const totalDuration = parseFloat(JSON.parse(stdout).format.duration);
615
616 const allPoints = [0, ...breakPoints.map(bp => bp.time), totalDuration];
617
618 const uniquePoints = [...new Set(allPoints)].sort((a, b) => a - b);
619
620 const clips: ClipRange[] = [];
621
622 let clipStart = 0;
623
624 let clipIndex = 0;
625
626 for (let i = 1; i < uniquePoints.length; i++) {
627
628 const candidateEnd = uniquePoints[i];
629
630 const candidateDuration = candidateEnd - clipStart;
631
632 if (candidateDuration >= config.idealClipDuration) {
633
634 clips.push({
635
636 index: clipIndex++,
637
638 start: clipStart,
639
640 end: candidateEnd,
641
642 duration: candidateDuration,
643
644 breakType: breakPoints.find(bp => Math.abs(bp.time - candidateEnd) < 0.5)?.type ?? 'end',
645
646 });
647
648 clipStart = candidateEnd;
649
650 } else if (candidateDuration > config.maxClipDuration) {
651
652 const bestBreak = findBestBreakInRange(
653
654 breakPoints,
655
656 clipStart + config.minClipDuration,
657
658 clipStart + config.maxClipDuration
659
660 );
661
662 const splitPoint = bestBreak?.time ?? (clipStart + config.idealClipDuration);
663
664 clips.push({
665
666 index: clipIndex++,
667
668 start: clipStart,
669
670 end: splitPoint,
671
672 duration: splitPoint - clipStart,
673
674 breakType: bestBreak?.type ?? 'forced',
675
676 });
677
678 clipStart = splitPoint;
679
680 i--;
681
682 }
683
684 }
685
686 if (clipStart < totalDuration) {
687
688 const remainingDuration = totalDuration - clipStart;
689
690 if (remainingDuration >= config.minClipDuration) {
691
692 clips.push({
693
694 index: clipIndex++,
695
696 start: clipStart,
697
698 end: totalDuration,
699
700 duration: remainingDuration,
701
702 breakType: 'end',
703
704 });
705
706 } else if (clips.length > 0) {
707
708 const lastClip = clips[clips.length - 1];
709
710 lastClip.end = totalDuration;
711
712 lastClip.duration = totalDuration - lastClip.start;
713
714 }
715
716 }
717
718 return clips;
719
720 }
721
722 function findBestBreakInRange(
723
724 breakPoints: BreakPoint[],
725
726 rangeStart: number,
727
728 rangeEnd: number
729
730 ): BreakPoint | null {
731
732 const candidates = breakPoints.filter(bp => bp.time >= rangeStart && bp.time <= rangeEnd);
733
734 if (candidates.length === 0) return null;
735
736 return candidates.sort((a, b) => b.strength - a.strength)[0];
737
738 }
739
740 ```
741
742 ### Step 3: Export Clips
743
744 ```typescript
745
746 async function exportChunkedClips(
747
748 videoPath: string,
749
750 clips: ClipRange[],
751
752 outputDir: string
753
754 ): Promise<string[]> {
755
756 await fs.promises.mkdir(outputDir, { recursive: true });
757
758 const exportedPaths: string[] = [];
759
760 for (const clip of clips) {
761
762 const outputPath = path.join(
763
764 outputDir,
765
766 `clip_${(clip.index + 1).toString().padStart(2, '0')}_${formatTime(clip.start).replace(':', 'm')}s-${formatTime(clip.end).replace(':', 'm')}s.mp4`
767
768 );
769
770 await execFileAsync('ffmpeg', [
771
772 '-y',
773
774 '-i', videoPath,
775
776 '-ss', clip.start.toFixed(3),
777
778 '-to', clip.end.toFixed(3),
779
780 '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
781
782 '-c:a', 'aac', '-b:a', '128k',
783
784 outputPath
785
786 ]);
787
788 exportedPaths.push(outputPath);
789
790 console.log(` Clip ${clip.index + 1}: ${formatTime(clip.start)} - ${formatTime(clip.end)} (${clip.duration.toFixed(1)}s) → ${path.basename(outputPath)}`);
791
792 }
793
794 return exportedPaths;
795
796 }
797
798 function formatTime(seconds: number): string {
799
800 const m = Math.floor(seconds / 60);
801
802 const s = Math.floor(seconds % 60);
803
804 return `${m}:${s.toString().padStart(2, '0')}`;
805
806 }
807
808 ```
809
810 ### Complete Chunking Pipeline
811
812 ```typescript
813
814 async function chunkVideoForSocialMedia(
815
816 videoPath: string,
817
818 outputDir: string,
819
820 platform: string = 'tiktok'
821
822 ): Promise<string[]> {
823
824 console.log(`Chunking ${videoPath} for ${platform}...`);
825
826 const config = PLATFORM_CONFIGS[platform] ?? PLATFORM_CONFIGS.general;
827
828 console.log(`Target clip length: ${config.minClipDuration}-${config.maxClipDuration}s (ideal: ${config.idealClipDuration}s)\n`);
829
830 console.log('Finding natural break points (scenes + silence)...');
831
832 const breakPoints = await findBreakPoints(videoPath);
833
834 console.log(`Found ${breakPoints.length} break points\n`);
835
836 console.log('Building clips...');
837
838 const clips = await buildClips(videoPath, breakPoints, platform);
839
840 console.log(`Generated ${clips.length} clips:\n`);
841
842 for (const clip of clips) {
843
844 console.log(` Clip ${clip.index + 1}: ${formatTime(clip.start)}-${formatTime(clip.end)} (${clip.duration.toFixed(1)}s, break: ${clip.breakType})`);
845
846 }
847
848 console.log('\nExporting clips...');
849
850 const paths = await exportChunkedClips(videoPath, clips, outputDir);
851
852 console.log(`\nDone! ${paths.length} clips exported to ${outputDir}/`);
853
854 return paths;
855
856 }
857
858 ```
859
860 ---
861
862 ## Combined Pipeline
863
864 For the full workflow of taking a raw long-form video and producing polished social clips.
865
866 **IMPORTANT:** When a user specifies a platform, reframing is NOT optional — the output must be in the platform's native format. TikTok/Reels = 9:16 (1080x1920), YouTube = 16:9 (1920x1080), Instagram Feed = 1:1 (1080x1080). Never deliver landscape clips when the user asked for TikTok content.
867
868 ```typescript
869
870 async function fullSocialMediaPipeline(
871
872 videoPath: string,
873
874 outputDir: string,
875
876 options: {
877
878 platform?: string;
879
880 removeDeadSpace?: boolean;
881
882 deadSpacePreset?: SilencePreset;
883
884 scoreWithAI?: boolean;
885
886 } = {}
887
888 ) {
889
890 const {
891
892 platform = 'tiktok',
893
894 removeDeadSpace: shouldRemoveDeadSpace = true,
895
896 deadSpacePreset = 'medium',
897
898 scoreWithAI = false,
899
900 } = options;
901
902 let workingPath = videoPath;
903
904 if (shouldRemoveDeadSpace) {
905
906 console.log('=== Step 1: Removing dead space ===\n');
907
908 const tightPath = path.join(outputDir, 'tightened.mp4');
909
910 const result = await removeDeadSpace(workingPath, tightPath, deadSpacePreset);
911
912 console.log(`Removed ${result.removedSeconds.toFixed(1)}s of dead space (${result.originalDuration.toFixed(1)}s → ${result.newDuration.toFixed(1)}s)\n`);
913
914 workingPath = tightPath;
915
916 }
917
918 console.log('=== Step 2: Chunking into clips ===\n');
919
920 const clipPaths = await chunkVideoForSocialMedia(workingPath, path.join(outputDir, 'clips'), platform);
921
922 if (scoreWithAI) {
923
924 console.log('\n=== Step 3: AI Virality Scoring (clip-level) ===\n');
925
926 // Score each exported clip using the clip-level prompt (Mode 2)
927
928 // This uses CLIP_SCORING_PROMPT which evaluates narrative arc,
929
930 // hook-to-payoff, and standalone quality — not just visual moments.
931
932 // See virality-scoring.md for the full scoring pipeline.
933
934 //
935
936 // Key differences from segment scoring:
937
938 // - Extracts 5-8 frames per clip (not 3) including first and last frame
939
940 // - Uses CLIP_SCORING_PROMPT which adds narrativeCompleteness,
941
942 // hookStrength, and standaloneQuality factors
943
944 // - Returns postingRecommendation: 'post as-is', 'post with minor edits', or 'consider skipping'
945
946 //
947
948 // Import and use:
949
950 // import { extractFrames, analyzeSegment, getScoringPrompt } from virality pipeline
951
952 // Score each clip file, rank by overallScore, present ranked results to user
953
954 console.log('Scoring clips with AI (clip-level prompt — evaluates narrative arc, hook, and standalone quality)...');
955
956 }
957
958 // Step 4: Reframe ALL clips for the target platform
959
960 // This is NOT optional when a platform is specified — users expect platform-ready output.
961
962 // See operations.md "Social Media Reframing" section for FFmpeg commands.
963
964 // Preferred strategy: blurred fill for landscape→portrait (looks polished).
965
966 // Alternative: center crop if the subject is center-framed throughout.
967
968 console.log('\n=== Step 4: Reframing clips for platform ===\n');
969
970 const reframeSpec = PLATFORM_REFRAME[platform];
971
972 if (reframeSpec) {
973
974 const reframedDir = path.join(outputDir, 'reframed');
975
976 await fs.promises.mkdir(reframedDir, { recursive: true });
977
978 const { width, height, ratio } = reframeSpec;
979
980 console.log(`Reframing all clips to ${ratio} (${width}×${height}) for ${platform}...`);
981
982 // Use blurred fill strategy (Strategy 3 from operations.md) for each clip
983
984 // See operations.md for the exact FFmpeg filter_complex commands
985
986 for (const clipPath of clipPaths) {
987
988 const outName = path.basename(clipPath, '.mp4') + `_${platform}.mp4`;
989
990 const outPath = path.join(reframedDir, outName);
991
992 await execFileAsync('ffmpeg', [
993
994 '-y', '-i', clipPath,
995
996 '-filter_complex',
997
998 `[0:v]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},gblur=sigma=50[bg];` +
999
1000 `[0:v]scale=${width}:${height}:force_original_aspect_ratio=decrease[fg];` +
1001
1002 `[bg][fg]overlay=(W-w)/2:(H-h)/2[outv]`,
1003
1004 '-map', '[outv]', '-map', '0:a?',
1005
1006 '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
1007
1008 '-c:a', 'aac', '-b:a', '128k',
1009
1010 outPath
1011
1012 ]);
1013
1014 console.log(` ${path.basename(clipPath)} → ${outName}`);
1015
1016 }
1017
1018 } else {
1019
1020 console.log(`No reframing needed for platform: ${platform}`);
1021
1022 }
1023
1024 return clipPaths;
1025
1026 }
1027
1028 ```
1029
1030 ### Example Usage
1031
1032 ```typescript
1033
1034 await fullSocialMediaPipeline('raw_video.mp4', 'output/social', {
1035
1036 platform: 'tiktok',
1037
1038 removeDeadSpace: true,
1039
1040 deadSpacePreset: 'medium',
1041
1042 scoreWithAI: true,
1043
1044 });
1045
1046 ```
1047
1048 This produces:
1049
1050 1. `output/social/tightened.mp4` — the video with dead space removed
1051 2. `output/social/clips/clip_01_...mp4` through `clip_N_...mp4` — individual clips chunked to platform duration targets
1052
1053 3. `output/social/reframed/clip_01_tiktok.mp4` — each clip reframed to 9:16 vertical (1080x1920) for TikTok
1054 4. Each clip scored with clip-level AI analysis (narrative arc, hook strength, standalone quality)
1055
1056 5. Clips ranked with posting recommendations ('post as-is', 'post with minor edits', 'consider skipping')
1057 6. The reframed clips are the final deliverables — always present these to the user, not the intermediate landscape clips
1058
1059 ### Why clip-level scoring matters
1060
1061 The original segment-level scoring (Mode 1) evaluates tiny 2-10 second fragments on visual dynamism and emotional moments. This is useful for finding highlights, but it doesn't tell you whether a 30-second clip will actually perform well on social media.
1062
1063 Clip-level scoring (Mode 2) evaluates the full clip as a piece of content that will be posted. It checks:
1064
1065 - Does the clip **open strong** (hook evaluation on the first frame)?
1066 - Does it **tell a complete story** in 15-45 seconds?
1067
1068 - Would it **make sense without context** from the full video?
1069 - Does the ending **satisfy or intrigue** the viewer?
1070
1071 A clip can have great individual moments but still fail as a social post if it starts slow, ends mid-sentence, or requires context the viewer doesn't have. Clip-level scoring catches these issues.
1072
1073 ## Key Implementation Notes
1074
1075 - **`silenceremove` vs segmented approach:** FFmpeg's `silenceremove` filter only strips silence from the audio — it does NOT cut the corresponding video frames. For proper dead space removal where both audio and video are cut, always use the segmented extract-and-concatenate approach.
1076 - **Padding around speaking segments:** Always add 0.05-0.15s padding around each speaking segment. Without padding, words get clipped at the start/end.
1077
1078 - **Break point priority:** When building clips, prefer to split at points that are both a scene change AND a silence gap (strength 1.0). Pure scene changes (0.6) are better than pure silence (0.4), because visual continuity matters more than audio gaps.
1079 - **Short remainders:** If the last clip would be under `minClipDuration`, merge it into the previous clip rather than exporting a too-short orphan.
1080
1081 - **Re-encoding:** Each extract-and-concat pass re-encodes the video. For the combined pipeline (dead space removal + chunking), consider running dead space removal first to produce one clean file, then chunking that — this minimizes total re-encodes to 2 passes instead of more.