aivee 0.1.0-alpha.0 → 0.1.0-dev.d34a6ba

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.
Files changed (73) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +120 -118
  3. package/docs/architecture.md +401 -401
  4. package/docs/aspect-ratio-presets.md +72 -72
  5. package/docs/releasing.md +19 -19
  6. package/docs/usage.md +1193 -1193
  7. package/package.json +52 -52
  8. package/requirements-otio.txt +1 -1
  9. package/src/capabilities.mjs +307 -307
  10. package/src/cli.mjs +646 -646
  11. package/src/doctor.mjs +133 -133
  12. package/src/edits/analysis-to-edl.mjs +43 -43
  13. package/src/edits/detect-silence.mjs +51 -51
  14. package/src/edits/find-mistake-candidates.mjs +42 -42
  15. package/src/edits/plan-shot-placements.mjs +105 -105
  16. package/src/edits/plan-silence-cuts.mjs +49 -49
  17. package/src/edits/retime-transcript.mjs +105 -105
  18. package/src/files.mjs +80 -80
  19. package/src/index.mjs +44 -44
  20. package/src/interchange/otio.mjs +204 -204
  21. package/src/interchange/otio_bridge.py +521 -521
  22. package/src/media/analysis-provider.mjs +75 -75
  23. package/src/media/compatibility.mjs +45 -45
  24. package/src/media/ffmpeg-capabilities.mjs +129 -129
  25. package/src/media/frame-rate.mjs +58 -58
  26. package/src/media/frame-time.mjs +22 -22
  27. package/src/media/preview-assets.mjs +350 -350
  28. package/src/media/probe.mjs +149 -149
  29. package/src/media/render-cuts.mjs +90 -90
  30. package/src/media/scene-detection.mjs +107 -107
  31. package/src/media/scene-review.mjs +205 -205
  32. package/src/project/assets.mjs +1194 -1194
  33. package/src/project/create.mjs +100 -100
  34. package/src/project/presets.mjs +44 -44
  35. package/src/render/checkpoints.mjs +192 -192
  36. package/src/render/formats.mjs +163 -163
  37. package/src/render/hyperframes.mjs +590 -590
  38. package/src/render/process.mjs +111 -111
  39. package/src/render/progress.mjs +46 -46
  40. package/src/render/timeline-control.mjs +53 -53
  41. package/src/timeline/assemble.mjs +119 -119
  42. package/src/timeline/audio-effects.mjs +75 -75
  43. package/src/timeline/audio-mastering.mjs +236 -236
  44. package/src/timeline/audio-mix.mjs +105 -105
  45. package/src/timeline/audio-track-effects.mjs +139 -139
  46. package/src/timeline/audio-volume.mjs +63 -63
  47. package/src/timeline/background.mjs +8 -8
  48. package/src/timeline/blend-mode.mjs +34 -34
  49. package/src/timeline/caption-layouts.mjs +62 -61
  50. package/src/timeline/captions.mjs +80 -80
  51. package/src/timeline/edit.mjs +965 -965
  52. package/src/timeline/fingerprint.mjs +6 -6
  53. package/src/timeline/from-edl.mjs +31 -31
  54. package/src/timeline/history-store.mjs +281 -281
  55. package/src/timeline/history.mjs +69 -69
  56. package/src/timeline/inspect.mjs +117 -117
  57. package/src/timeline/keyframes.mjs +158 -158
  58. package/src/timeline/layers.mjs +18 -18
  59. package/src/timeline/mask.mjs +53 -53
  60. package/src/timeline/project.mjs +52 -52
  61. package/src/timeline/render.mjs +759 -759
  62. package/src/timeline/sidechain-render.mjs +209 -209
  63. package/src/timeline/source-fit.mjs +15 -15
  64. package/src/timeline/speech-bubble.mjs +26 -26
  65. package/src/timeline/speed-ramp.mjs +188 -188
  66. package/src/timeline/track-state.mjs +22 -22
  67. package/src/timeline/transition.mjs +34 -34
  68. package/src/timeline/validate.mjs +322 -322
  69. package/src/transcript/corrections.mjs +21 -21
  70. package/src/transcript/normalize.mjs +17 -17
  71. package/src/transcript/provider.mjs +42 -42
  72. package/src/transcript/subtitles.mjs +57 -57
  73. package/src/transcript/validate.mjs +39 -39
package/src/doctor.mjs CHANGED
@@ -1,133 +1,133 @@
1
- #!/usr/bin/env node
2
- import { spawnSync } from 'node:child_process';
3
- import { createRequire } from 'node:module';
4
- import { listFfmpegEncoders, listFfmpegFilters } from './media/ffmpeg-capabilities.mjs';
5
- import { doctorHyperframes } from './render/hyperframes.mjs';
6
-
7
- const nodeVersion = process.versions.node.split('.').map(Number);
8
- const require = createRequire(import.meta.url);
9
- const results = [
10
- {
11
- name: 'Node.js',
12
- ok: nodeVersion[0] >= 22,
13
- detail: `found ${process.versions.node}; requires 22 or newer`,
14
- },
15
- checkCommand('FFmpeg', 'ffmpeg'),
16
- checkCommand('ffprobe', 'ffprobe'),
17
- checkPackage('HyperFrames', 'hyperframes'),
18
- checkPackage('GSAP', 'gsap'),
19
- ];
20
-
21
- for (const result of results) {
22
- console.log(`${result.ok ? '✓' : '✗'} ${result.name}: ${result.detail}`);
23
- if (!result.ok && result.name !== 'Node.js') {
24
- if (result.name === 'FFmpeg' || result.name === 'ffprobe') {
25
- console.log(' Install FFmpeg with ffprobe and make both commands available on PATH.');
26
- console.log(' Guide: https://ffmpeg.org/download.html');
27
- } else if (result.name === 'HyperFrames' || result.name === 'GSAP') {
28
- console.log(' Run npm install in the engine repository.');
29
- }
30
- } else if (!result.ok && result.name === 'Node.js') {
31
- console.log(' Install Node.js 22 or newer: https://nodejs.org/en/download');
32
- }
33
- }
34
-
35
- const ready = results.every((result) => result.ok);
36
- const otioPython = process.env.AIVE_OTIO_PYTHON || process.env.PYTHON || 'python';
37
- const openTimelineIO = checkPythonModule('OpenTimelineIO', otioPython);
38
- console.log(`${openTimelineIO.ok ? '✓' : '⚠'} Optional ${openTimelineIO.name}: ${openTimelineIO.detail}`);
39
- if (!openTimelineIO.ok) {
40
- console.log(' Install with `python -m pip install -r requirements-otio.txt` or set `AIVE_OTIO_PYTHON` to a Python command with OpenTimelineIO installed.');
41
- }
42
- if (results.find((result) => result.name === 'HyperFrames')?.ok) {
43
- console.log('\nChecking HyperFrames renderer requirements:');
44
- try {
45
- doctorHyperframes(process.cwd());
46
- } catch (error) {
47
- console.error(error.message);
48
- process.exitCode = 1;
49
- }
50
- }
51
- console.log(ready
52
- ? 'Core Engine requirements are available; optional and feature-specific tools are reported above.'
53
- : 'Some required Engine tools are missing.');
54
- try {
55
- const filters = listFfmpegFilters();
56
- for (const [filter, requirement] of [
57
- ['rubberband', 'pitch-preserving audio speed ramps'],
58
- ['equalizer', 'audio equalization and mastering bands'],
59
- ['highpass', 'audio high-pass filtering'],
60
- ['lowpass', 'audio low-pass filtering'],
61
- ['acompressor', 'audio compression effects'],
62
- ['afftdn', 'per-clip spectral denoise'],
63
- ['stereotools', 'per-clip stereo balance'],
64
- ['sidechaincompress', 'signal-aware sidechain compression'],
65
- ['loudnorm', 'audio loudness mastering'],
66
- ['alimiter', 'audio peak limiting'],
67
- ['aresample', 'mastered output channel mapping'],
68
- ['reverse', 'video reverse playback'],
69
- ['areverse', 'audio reverse playback'],
70
- ['colorspace', 'Rec.709 and Rec.2020 SDR output profiles'],
71
- ['zscale', 'HDR10 output'],
72
- ]) {
73
- const available = filters.has(filter);
74
- console.log(`${available ? '✓' : '⚠'} FFmpeg ${filter} filter: ${available ? 'available' : `not available; required for ${requirement}`}`);
75
- }
76
- } catch (error) {
77
- console.log(`⚠ FFmpeg filters: could not verify (${error.message})`);
78
- }
79
- if (results.find((result) => result.name === 'FFmpeg')?.ok) {
80
- try {
81
- const encoders = listFfmpegEncoders();
82
- for (const [name, encoder] of [['H.264', 'libx264'], ['H.265', 'libx265'], ['VP9', 'libvpx-vp9'], ['ProRes', 'prores_ks']]) {
83
- const available = encoders.has(encoder);
84
- console.log(`${available ? '✓' : '⚠'} FFmpeg ${name} encoder: ${available ? 'available' : `not available (${encoder})`}`);
85
- }
86
- } catch (error) {
87
- console.log(`⚠ FFmpeg encoders: could not verify (${error.message})`);
88
- }
89
- }
90
- if (!ready) process.exitCode = 1;
91
-
92
- function checkCommand(label, command, args = ['-version']) {
93
- const result = spawnSync(command, args, {
94
- encoding: 'utf8',
95
- windowsHide: true,
96
- });
97
- if (result.error || result.status !== 0) {
98
- return { name: label, ok: false, detail: 'not found or failed to run' };
99
- }
100
- const firstLine = result.stdout.trim().split(/\r?\n/, 1)[0];
101
- return { name: label, ok: true, detail: firstLine };
102
- }
103
-
104
- function checkPackage(label, packageName) {
105
- try {
106
- require.resolve(`${packageName}/package.json`);
107
- return { name: label, ok: true, detail: 'installed' };
108
- } catch {
109
- return { name: label, ok: false, detail: 'not installed' };
110
- }
111
- }
112
-
113
- function checkPythonModule(label, command) {
114
- const result = spawnSync(command, ['-c', "import opentimelineio as otio; assert 'otio_json' in otio.adapters.available_adapter_names(), 'otio_json adapter unavailable'; print(getattr(otio, '__version__', 'installed'))"], {
115
- encoding: 'utf8',
116
- windowsHide: true,
117
- maxBuffer: 1024 * 1024,
118
- });
119
- if (result.error) return { name: label, ok: false, detail: `Python command unavailable (${command})` };
120
- if (result.status !== 0) {
121
- const details = `${result.stderr}\n${result.stdout}`;
122
- return {
123
- name: label,
124
- ok: false,
125
- detail: /No module named ['"]?opentimelineio/.test(details)
126
- ? 'Python package not installed'
127
- : /otio_json adapter unavailable/.test(details)
128
- ? 'JSON adapter not available'
129
- : 'Python package failed to import',
130
- };
131
- }
132
- return { name: label, ok: true, detail: `Python package ${result.stdout.trim().split(/\r?\n/, 1)[0]}` };
133
- }
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { listFfmpegEncoders, listFfmpegFilters } from './media/ffmpeg-capabilities.mjs';
5
+ import { doctorHyperframes } from './render/hyperframes.mjs';
6
+
7
+ const nodeVersion = process.versions.node.split('.').map(Number);
8
+ const require = createRequire(import.meta.url);
9
+ const results = [
10
+ {
11
+ name: 'Node.js',
12
+ ok: nodeVersion[0] >= 22,
13
+ detail: `found ${process.versions.node}; requires 22 or newer`,
14
+ },
15
+ checkCommand('FFmpeg', 'ffmpeg'),
16
+ checkCommand('ffprobe', 'ffprobe'),
17
+ checkPackage('HyperFrames', 'hyperframes'),
18
+ checkPackage('GSAP', 'gsap'),
19
+ ];
20
+
21
+ for (const result of results) {
22
+ console.log(`${result.ok ? '✓' : '✗'} ${result.name}: ${result.detail}`);
23
+ if (!result.ok && result.name !== 'Node.js') {
24
+ if (result.name === 'FFmpeg' || result.name === 'ffprobe') {
25
+ console.log(' Install FFmpeg with ffprobe and make both commands available on PATH.');
26
+ console.log(' Guide: https://ffmpeg.org/download.html');
27
+ } else if (result.name === 'HyperFrames' || result.name === 'GSAP') {
28
+ console.log(' Run npm install in the engine repository.');
29
+ }
30
+ } else if (!result.ok && result.name === 'Node.js') {
31
+ console.log(' Install Node.js 22 or newer: https://nodejs.org/en/download');
32
+ }
33
+ }
34
+
35
+ const ready = results.every((result) => result.ok);
36
+ const otioPython = process.env.AIVE_OTIO_PYTHON || process.env.PYTHON || 'python';
37
+ const openTimelineIO = checkPythonModule('OpenTimelineIO', otioPython);
38
+ console.log(`${openTimelineIO.ok ? '✓' : '⚠'} Optional ${openTimelineIO.name}: ${openTimelineIO.detail}`);
39
+ if (!openTimelineIO.ok) {
40
+ console.log(' Install with `python -m pip install -r requirements-otio.txt` or set `AIVE_OTIO_PYTHON` to a Python command with OpenTimelineIO installed.');
41
+ }
42
+ if (results.find((result) => result.name === 'HyperFrames')?.ok) {
43
+ console.log('\nChecking HyperFrames renderer requirements:');
44
+ try {
45
+ doctorHyperframes(process.cwd());
46
+ } catch (error) {
47
+ console.error(error.message);
48
+ process.exitCode = 1;
49
+ }
50
+ }
51
+ console.log(ready
52
+ ? 'Core Engine requirements are available; optional and feature-specific tools are reported above.'
53
+ : 'Some required Engine tools are missing.');
54
+ try {
55
+ const filters = listFfmpegFilters();
56
+ for (const [filter, requirement] of [
57
+ ['rubberband', 'pitch-preserving audio speed ramps'],
58
+ ['equalizer', 'audio equalization and mastering bands'],
59
+ ['highpass', 'audio high-pass filtering'],
60
+ ['lowpass', 'audio low-pass filtering'],
61
+ ['acompressor', 'audio compression effects'],
62
+ ['afftdn', 'per-clip spectral denoise'],
63
+ ['stereotools', 'per-clip stereo balance'],
64
+ ['sidechaincompress', 'signal-aware sidechain compression'],
65
+ ['loudnorm', 'audio loudness mastering'],
66
+ ['alimiter', 'audio peak limiting'],
67
+ ['aresample', 'mastered output channel mapping'],
68
+ ['reverse', 'video reverse playback'],
69
+ ['areverse', 'audio reverse playback'],
70
+ ['colorspace', 'Rec.709 and Rec.2020 SDR output profiles'],
71
+ ['zscale', 'HDR10 output'],
72
+ ]) {
73
+ const available = filters.has(filter);
74
+ console.log(`${available ? '✓' : '⚠'} FFmpeg ${filter} filter: ${available ? 'available' : `not available; required for ${requirement}`}`);
75
+ }
76
+ } catch (error) {
77
+ console.log(`⚠ FFmpeg filters: could not verify (${error.message})`);
78
+ }
79
+ if (results.find((result) => result.name === 'FFmpeg')?.ok) {
80
+ try {
81
+ const encoders = listFfmpegEncoders();
82
+ for (const [name, encoder] of [['H.264', 'libx264'], ['H.265', 'libx265'], ['VP9', 'libvpx-vp9'], ['ProRes', 'prores_ks']]) {
83
+ const available = encoders.has(encoder);
84
+ console.log(`${available ? '✓' : '⚠'} FFmpeg ${name} encoder: ${available ? 'available' : `not available (${encoder})`}`);
85
+ }
86
+ } catch (error) {
87
+ console.log(`⚠ FFmpeg encoders: could not verify (${error.message})`);
88
+ }
89
+ }
90
+ if (!ready) process.exitCode = 1;
91
+
92
+ function checkCommand(label, command, args = ['-version']) {
93
+ const result = spawnSync(command, args, {
94
+ encoding: 'utf8',
95
+ windowsHide: true,
96
+ });
97
+ if (result.error || result.status !== 0) {
98
+ return { name: label, ok: false, detail: 'not found or failed to run' };
99
+ }
100
+ const firstLine = result.stdout.trim().split(/\r?\n/, 1)[0];
101
+ return { name: label, ok: true, detail: firstLine };
102
+ }
103
+
104
+ function checkPackage(label, packageName) {
105
+ try {
106
+ require.resolve(`${packageName}/package.json`);
107
+ return { name: label, ok: true, detail: 'installed' };
108
+ } catch {
109
+ return { name: label, ok: false, detail: 'not installed' };
110
+ }
111
+ }
112
+
113
+ function checkPythonModule(label, command) {
114
+ const result = spawnSync(command, ['-c', "import opentimelineio as otio; assert 'otio_json' in otio.adapters.available_adapter_names(), 'otio_json adapter unavailable'; print(getattr(otio, '__version__', 'installed'))"], {
115
+ encoding: 'utf8',
116
+ windowsHide: true,
117
+ maxBuffer: 1024 * 1024,
118
+ });
119
+ if (result.error) return { name: label, ok: false, detail: `Python command unavailable (${command})` };
120
+ if (result.status !== 0) {
121
+ const details = `${result.stderr}\n${result.stdout}`;
122
+ return {
123
+ name: label,
124
+ ok: false,
125
+ detail: /No module named ['"]?opentimelineio/.test(details)
126
+ ? 'Python package not installed'
127
+ : /otio_json adapter unavailable/.test(details)
128
+ ? 'JSON adapter not available'
129
+ : 'Python package failed to import',
130
+ };
131
+ }
132
+ return { name: label, ok: true, detail: `Python package ${result.stdout.trim().split(/\r?\n/, 1)[0]}` };
133
+ }
@@ -1,43 +1,43 @@
1
- import { validateEdl } from './retime-transcript.mjs';
2
- import { normalizeMediaFindings } from '../media/analysis-provider.mjs';
3
-
4
- /** Convert explicit provider cut suggestions into an EDL that requires review. */
5
- export function mediaAnalysisToEdl(analysis, { minConfidence = 0 } = {}) {
6
- if (!analysis || typeof analysis !== 'object' || analysis.schemaVersion !== 1
7
- || !Number.isFinite(analysis.durationSeconds) || analysis.durationSeconds <= 0
8
- || !Array.isArray(analysis.findings)) {
9
- throw new TypeError('Media analysis must be a normalized schemaVersion 1 result.');
10
- }
11
- if (!Number.isFinite(minConfidence) || minConfidence < 0 || minConfidence > 1) {
12
- throw new TypeError('minConfidence must be between 0 and 1.');
13
- }
14
- const normalizedFindings = normalizeMediaFindings({ findings: analysis.findings }, analysis.durationSeconds).findings;
15
-
16
- const cuts = normalizedFindings
17
- .filter((finding) => finding.suggestion && (finding.confidence ?? 0) >= minConfidence)
18
- .map((finding) => ({
19
- sourceStart: finding.suggestion.sourceStart,
20
- sourceEnd: finding.suggestion.sourceEnd,
21
- reason: `${finding.id}: ${finding.suggestion.reason}`,
22
- }))
23
- .sort((left, right) => left.sourceStart - right.sourceStart || left.sourceEnd - right.sourceEnd)
24
- .reduce((result, cut) => {
25
- const previous = result.at(-1);
26
- if (previous && cut.sourceStart < previous.sourceEnd) {
27
- previous.sourceEnd = Math.max(previous.sourceEnd, cut.sourceEnd);
28
- previous.reason = `${previous.reason}; ${cut.reason}`;
29
- } else {
30
- result.push(cut);
31
- }
32
- return result;
33
- }, []);
34
-
35
- const edl = {
36
- schemaVersion: 1,
37
- sourceDurationSeconds: analysis.durationSeconds,
38
- reviewStatus: 'needs-review',
39
- cuts,
40
- };
41
- validateEdl(edl, analysis.durationSeconds);
42
- return edl;
43
- }
1
+ import { validateEdl } from './retime-transcript.mjs';
2
+ import { normalizeMediaFindings } from '../media/analysis-provider.mjs';
3
+
4
+ /** Convert explicit provider cut suggestions into an EDL that requires review. */
5
+ export function mediaAnalysisToEdl(analysis, { minConfidence = 0 } = {}) {
6
+ if (!analysis || typeof analysis !== 'object' || analysis.schemaVersion !== 1
7
+ || !Number.isFinite(analysis.durationSeconds) || analysis.durationSeconds <= 0
8
+ || !Array.isArray(analysis.findings)) {
9
+ throw new TypeError('Media analysis must be a normalized schemaVersion 1 result.');
10
+ }
11
+ if (!Number.isFinite(minConfidence) || minConfidence < 0 || minConfidence > 1) {
12
+ throw new TypeError('minConfidence must be between 0 and 1.');
13
+ }
14
+ const normalizedFindings = normalizeMediaFindings({ findings: analysis.findings }, analysis.durationSeconds).findings;
15
+
16
+ const cuts = normalizedFindings
17
+ .filter((finding) => finding.suggestion && (finding.confidence ?? 0) >= minConfidence)
18
+ .map((finding) => ({
19
+ sourceStart: finding.suggestion.sourceStart,
20
+ sourceEnd: finding.suggestion.sourceEnd,
21
+ reason: `${finding.id}: ${finding.suggestion.reason}`,
22
+ }))
23
+ .sort((left, right) => left.sourceStart - right.sourceStart || left.sourceEnd - right.sourceEnd)
24
+ .reduce((result, cut) => {
25
+ const previous = result.at(-1);
26
+ if (previous && cut.sourceStart < previous.sourceEnd) {
27
+ previous.sourceEnd = Math.max(previous.sourceEnd, cut.sourceEnd);
28
+ previous.reason = `${previous.reason}; ${cut.reason}`;
29
+ } else {
30
+ result.push(cut);
31
+ }
32
+ return result;
33
+ }, []);
34
+
35
+ const edl = {
36
+ schemaVersion: 1,
37
+ sourceDurationSeconds: analysis.durationSeconds,
38
+ reviewStatus: 'needs-review',
39
+ cuts,
40
+ };
41
+ validateEdl(edl, analysis.durationSeconds);
42
+ return edl;
43
+ }
@@ -1,51 +1,51 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { probeMedia } from '../media/probe.mjs';
3
- import { validateEdl } from './retime-transcript.mjs';
4
-
5
- export function detectAcousticSilence(inputPath, options = {}) {
6
- const media = probeMedia(inputPath, { requireVideo: false });
7
- if (!media.hasAudio) throw new TypeError('Acoustic silence detection requires an audio stream.');
8
- const noiseDb = options.noiseDb ?? -35;
9
- const minSilenceSeconds = options.minSilenceSeconds ?? 0.5;
10
- const paddingSeconds = options.paddingSeconds ?? 0.12;
11
- if (!Number.isFinite(noiseDb) || noiseDb < -90 || noiseDb > 0) throw new TypeError('noiseDb must be between -90 and 0.');
12
- if (!Number.isFinite(minSilenceSeconds) || minSilenceSeconds <= 0) throw new TypeError('minSilenceSeconds must be positive.');
13
- if (!Number.isFinite(paddingSeconds) || paddingSeconds < 0) throw new TypeError('paddingSeconds must be non-negative.');
14
-
15
- const result = spawnSync('ffmpeg', [
16
- '-nostdin', '-hide_banner', '-i', media.path,
17
- '-af', `silencedetect=noise=${noiseDb}dB:d=${minSilenceSeconds}`,
18
- '-f', 'null', '-',
19
- ], { encoding: 'utf8', windowsHide: true });
20
- if (result.error || result.status !== 0) throw new Error(`FFmpeg silence detection failed: ${result.error?.message ?? result.stderr.trim()}`);
21
-
22
- const intervals = parseSilenceIntervals(result.stderr, media.durationSeconds);
23
- const cuts = intervals.flatMap(({ start, end }) => {
24
- const sourceStart = round(start + paddingSeconds);
25
- const sourceEnd = round(end - paddingSeconds);
26
- return sourceEnd > sourceStart ? [{ sourceStart, sourceEnd, reason: `acoustic silence (${round(end - start)}s)` }] : [];
27
- });
28
- const edl = { schemaVersion: 1, sourceDurationSeconds: media.durationSeconds, cuts };
29
- validateEdl(edl, media.durationSeconds);
30
- return edl;
31
- }
32
-
33
- export function parseSilenceIntervals(log, durationSeconds) {
34
- const intervals = [];
35
- const events = /silence_(start|end):\s*(-?(?:\d+(?:\.\d*)?|\.\d+))/g;
36
- let silenceStart;
37
- for (const match of log.matchAll(events)) {
38
- const time = Number(match[2]);
39
- if (match[1] === 'start') silenceStart = Math.max(0, time);
40
- else if (silenceStart !== undefined && time > silenceStart) {
41
- intervals.push({ start: silenceStart, end: Math.min(time, durationSeconds) });
42
- silenceStart = undefined;
43
- }
44
- }
45
- if (silenceStart !== undefined && durationSeconds > silenceStart) intervals.push({ start: silenceStart, end: durationSeconds });
46
- return intervals;
47
- }
48
-
49
- function round(value) {
50
- return Number(value.toFixed(3));
51
- }
1
+ import { spawnSync } from 'node:child_process';
2
+ import { probeMedia } from '../media/probe.mjs';
3
+ import { validateEdl } from './retime-transcript.mjs';
4
+
5
+ export function detectAcousticSilence(inputPath, options = {}) {
6
+ const media = probeMedia(inputPath, { requireVideo: false });
7
+ if (!media.hasAudio) throw new TypeError('Acoustic silence detection requires an audio stream.');
8
+ const noiseDb = options.noiseDb ?? -35;
9
+ const minSilenceSeconds = options.minSilenceSeconds ?? 0.5;
10
+ const paddingSeconds = options.paddingSeconds ?? 0.12;
11
+ if (!Number.isFinite(noiseDb) || noiseDb < -90 || noiseDb > 0) throw new TypeError('noiseDb must be between -90 and 0.');
12
+ if (!Number.isFinite(minSilenceSeconds) || minSilenceSeconds <= 0) throw new TypeError('minSilenceSeconds must be positive.');
13
+ if (!Number.isFinite(paddingSeconds) || paddingSeconds < 0) throw new TypeError('paddingSeconds must be non-negative.');
14
+
15
+ const result = spawnSync('ffmpeg', [
16
+ '-nostdin', '-hide_banner', '-i', media.path,
17
+ '-af', `silencedetect=noise=${noiseDb}dB:d=${minSilenceSeconds}`,
18
+ '-f', 'null', '-',
19
+ ], { encoding: 'utf8', windowsHide: true });
20
+ if (result.error || result.status !== 0) throw new Error(`FFmpeg silence detection failed: ${result.error?.message ?? result.stderr.trim()}`);
21
+
22
+ const intervals = parseSilenceIntervals(result.stderr, media.durationSeconds);
23
+ const cuts = intervals.flatMap(({ start, end }) => {
24
+ const sourceStart = round(start + paddingSeconds);
25
+ const sourceEnd = round(end - paddingSeconds);
26
+ return sourceEnd > sourceStart ? [{ sourceStart, sourceEnd, reason: `acoustic silence (${round(end - start)}s)` }] : [];
27
+ });
28
+ const edl = { schemaVersion: 1, sourceDurationSeconds: media.durationSeconds, cuts };
29
+ validateEdl(edl, media.durationSeconds);
30
+ return edl;
31
+ }
32
+
33
+ export function parseSilenceIntervals(log, durationSeconds) {
34
+ const intervals = [];
35
+ const events = /silence_(start|end):\s*(-?(?:\d+(?:\.\d*)?|\.\d+))/g;
36
+ let silenceStart;
37
+ for (const match of log.matchAll(events)) {
38
+ const time = Number(match[2]);
39
+ if (match[1] === 'start') silenceStart = Math.max(0, time);
40
+ else if (silenceStart !== undefined && time > silenceStart) {
41
+ intervals.push({ start: silenceStart, end: Math.min(time, durationSeconds) });
42
+ silenceStart = undefined;
43
+ }
44
+ }
45
+ if (silenceStart !== undefined && durationSeconds > silenceStart) intervals.push({ start: silenceStart, end: durationSeconds });
46
+ return intervals;
47
+ }
48
+
49
+ function round(value) {
50
+ return Number(value.toFixed(3));
51
+ }
@@ -1,42 +1,42 @@
1
- import { validateTranscript } from '../transcript/validate.mjs';
2
-
3
- export function findMistakeCandidates(transcript, { maxRepeatGap = 0.5 } = {}) {
4
- validateTranscript(transcript);
5
- if (transcript.granularity === 'cue') {
6
- throw new TypeError('Mistake candidates require a word-level transcript.');
7
- }
8
- if (!Number.isFinite(maxRepeatGap) || maxRepeatGap < 0) {
9
- throw new TypeError('maxRepeatGap must be a non-negative number.');
10
- }
11
-
12
- const candidates = [];
13
- for (let index = 1; index < transcript.words.length; index++) {
14
- const first = transcript.words[index - 1];
15
- const second = transcript.words[index];
16
- const firstToken = normalize(first.text);
17
- const secondToken = normalize(second.text);
18
- const gap = second.start - first.end;
19
- if (!firstToken || firstToken !== secondToken || gap < 0 || gap > maxRepeatGap) continue;
20
-
21
- candidates.push({
22
- id: `repeat-${String(candidates.length + 1).padStart(3, '0')}`,
23
- type: 'repeated-word',
24
- confidence: 'review',
25
- cut: {
26
- sourceStart: first.start,
27
- sourceEnd: second.start,
28
- reason: `possible repeated word: ${first.text}`,
29
- },
30
- removes: first.text,
31
- keeps: second.text,
32
- context: transcript.words.slice(Math.max(0, index - 4), Math.min(transcript.words.length, index + 4))
33
- .map((word) => word.text)
34
- .join(' '),
35
- });
36
- }
37
- return candidates;
38
- }
39
-
40
- function normalize(text) {
41
- return text.toLowerCase().normalize('NFKC').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
42
- }
1
+ import { validateTranscript } from '../transcript/validate.mjs';
2
+
3
+ export function findMistakeCandidates(transcript, { maxRepeatGap = 0.5 } = {}) {
4
+ validateTranscript(transcript);
5
+ if (transcript.granularity === 'cue') {
6
+ throw new TypeError('Mistake candidates require a word-level transcript.');
7
+ }
8
+ if (!Number.isFinite(maxRepeatGap) || maxRepeatGap < 0) {
9
+ throw new TypeError('maxRepeatGap must be a non-negative number.');
10
+ }
11
+
12
+ const candidates = [];
13
+ for (let index = 1; index < transcript.words.length; index++) {
14
+ const first = transcript.words[index - 1];
15
+ const second = transcript.words[index];
16
+ const firstToken = normalize(first.text);
17
+ const secondToken = normalize(second.text);
18
+ const gap = second.start - first.end;
19
+ if (!firstToken || firstToken !== secondToken || gap < 0 || gap > maxRepeatGap) continue;
20
+
21
+ candidates.push({
22
+ id: `repeat-${String(candidates.length + 1).padStart(3, '0')}`,
23
+ type: 'repeated-word',
24
+ confidence: 'review',
25
+ cut: {
26
+ sourceStart: first.start,
27
+ sourceEnd: second.start,
28
+ reason: `possible repeated word: ${first.text}`,
29
+ },
30
+ removes: first.text,
31
+ keeps: second.text,
32
+ context: transcript.words.slice(Math.max(0, index - 4), Math.min(transcript.words.length, index + 4))
33
+ .map((word) => word.text)
34
+ .join(' '),
35
+ });
36
+ }
37
+ return candidates;
38
+ }
39
+
40
+ function normalize(text) {
41
+ return text.toLowerCase().normalize('NFKC').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
42
+ }